diff --git a/src/TimeStruct.jl b/src/TimeStruct.jl index 4436783..2e8e18b 100644 --- a/src/TimeStruct.jl +++ b/src/TimeStruct.jl @@ -32,6 +32,8 @@ include("partitions/tree_periods.jl") include("partitions/rep_periods.jl") include("partitions/opscenarios.jl") +include("hashing.jl") + export TimeStructure export SimpleTimes export CalendarTimes diff --git a/src/hashing.jl b/src/hashing.jl new file mode 100644 index 0000000..3c33686 --- /dev/null +++ b/src/hashing.jl @@ -0,0 +1,35 @@ +# Hashing of periods +# +# Periods are often used as keys of dictionaries, e.g. in JuMP containers indexed by +# nodes and time periods. Periods of bits types (e.g. `OperationalPeriod`) keep +# Julia's default hash, which hashes their bytes and is fast. For the others (e.g. +# `StrategicPeriod`, which carries along its time structure in the field +# `operational`), the default hash is the generic hash of the object, which is about +# ten times slower. For those, the methods below hash the type's name and the fields +# that identify a period instead: its numbers, dates and nested periods. Fields +# holding a time structure, a time profile, or of type `Any` (e.g. the parent of a +# strategic node) are left out. +# +# Equality is unchanged (the default `===`). Values that are `===` have `===` +# fields and hence the same hash, so `isequal(a, b)` still implies +# `hash(a) == hash(b)`; leaving fields out can only make two periods hash alike. + +function _hashed_field(::Type{F}) where {F} + F === Any && return false + F <: TimeStructurePeriod && return true # (hashed by the methods below) + return !(F <: Union{TimeStructure,TimeStructInnerIter,TimeStructOuterIter,TimeProfile}) +end + +@generated function _hash_period(x, h::UInt) + isbitstype(x) && return :(invoke(hash, Tuple{Any,UInt}, x, h)) + ex = :(hash($(QuoteNode(nameof(x))), h)) + for (f, F) in zip(fieldnames(x), fieldtypes(x)) + _hashed_field(F) || continue + ex = :(hash(getfield(x, $(QuoteNode(f))), $ex)) + end + return ex +end + +Base.hash(t::TimePeriod, h::UInt) = _hash_period(t, h) +Base.hash(t::TimeStructurePeriod, h::UInt) = _hash_period(t, h) +Base.hash(t::PeriodPartition, h::UInt) = _hash_period(t, h) diff --git a/test/runtests.jl b/test/runtests.jl index 21e1f2d..bf14240 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -793,7 +793,6 @@ end end @testitem "Duration invariants" begin - #= day = SimpleTimes(24,1) @test sum(duration(sp) for sp in strat_periods(day)) == TimeStruct._total_duration(day) @@ -805,7 +804,6 @@ end @test sum(duration(sp) for sp in strat_periods(periods)) == TimeStruct._total_duration(periods) @test sum(duration(rp) for sp in strat_periods(periods) for rp in repr_periods(sp)) == TimeStruct._total_duration(periods) =# - end @testitem "Profiles" begin @@ -2722,5 +2720,61 @@ end @test hasproperty(df4, :start_time) end +@testitem "Hashing of periods" begin + using Dates + + day = SimpleTimes(24, 1) + cal = CalendarTimes(DateTime(2024, 1, 1), 12, Hour(1)) + osc = OperationalScenarios(3, day) + rp_osc = RepresentativePeriods(2, 8760, [0.5, 0.5], [osc, osc]) + two_level = TwoLevel(3, 8760, rp_osc) + tree = regular_tree(2, [3, 2], RepresentativePeriods(2, 8760, [0.5, 0.5], [day, day])) + sps = strat_periods(two_level) + + # The periods of each kind, collected twice (as new objects) to compare with + function periods() + return [ + collect(day), + collect(cal), + collect(osc), + collect(two_level), + collect(tree), + collect(opscenarios(osc)), + collect(sps), + [rp for sp in sps for rp in repr_periods(sp)], + [sc for sp in sps for rp in repr_periods(sp) for sc in opscenarios(rp)], + collect(strat_nodes(tree)), + collect(strategic_scenarios(tree)), + [pd for sp in sps for pd in partition_duration(sp, 4)], + [pd for n in strat_nodes(tree) for pd in partition_duration(n, 4)], + ] + end + for (xs, ys) in zip(periods(), periods()) + # Equal periods have equal hashes + @test all(isequal(x, y) && hash(x) == hash(y) for (x, y) in zip(xs, ys)) + # Distinct periods have distinct hashes (no collisions within a structure) + @test length(unique(hash.(xs))) == length(xs) + # Dictionaries keyed by periods (and tuples with periods) + d = Dict(("n", x) => k for (k, x) in enumerate(xs)) + @test all(d[("n", y)] == k for (k, y) in enumerate(ys)) + end + + # The time structure a period holds is not hashed: strategic periods that only + # differ in their operational periods hash alike (and are not equal) + sp = first(sps) + sp_other = TimeStruct.StrategicPeriod(sp.sp, sp.duration, sp.mult_sp, SimpleTimes(5, 1)) + @test hash(sp_other) == hash(sp) + @test !isequal(sp_other, sp) + + # Periods defined outside TimeStruct are hashed by their fields as well + struct _NamedPeriod <: TimeStruct.TimePeriod + name::String + op::Int + end + named = [_NamedPeriod(n, k) for n in ("a", "b") for k in 1:3] + @test length(unique(hash.(named))) == length(named) + @test hash(_NamedPeriod("a", 1)) == hash(_NamedPeriod("a", 1)) +end + @run_package_tests Aqua.test_all(TimeStruct; ambiguities = false, unbound_args = false)