From 63c215ec1ce00f2a2866b9fd6f95b9b2f5307335 Mon Sep 17 00:00:00 2001 From: Lars Hellemo Date: Thu, 15 Oct 2026 19:51:00 +0000 Subject: [PATCH 1/2] Faster hashing of periods that hold a time structure Periods are often dictionary keys (e.g. in JuMP containers indexed by periods). Non-bits periods such as StrategicPeriod hashed through the generic object hash; they now hash their identifying fields (numbers, dates, nested periods), leaving out time structures, profiles and fields of type Any. Bits periods keep the default hash. Equality is unchanged and the hashes are consistent with it. Co-Authored-By: Claude Opus 5.5 --- src/TimeStruct.jl | 2 ++ src/hashing.jl | 35 +++++++++++++++++++++++++++++ test/runtests.jl | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/hashing.jl 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..7328386 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2722,5 +2722,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) From 0303cfe82bca72e655f516d71edde7b6374df2ab Mon Sep 17 00:00:00 2001 From: Lars Hellemo Date: Thu, 15 Oct 2026 21:22:09 +0000 Subject: [PATCH 2/2] Format test/runtests.jl (JuliaFormatter 2.14) Co-Authored-By: Claude Opus 5.5 --- test/runtests.jl | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/runtests.jl b/test/runtests.jl index 7328386..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