From 5b9bc1840ca6315a83516d9ab7eae283bf0f6b48 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 30 Sep 2026 11:12:23 +0200 Subject: [PATCH 1/9] Collect PeriodPartition-indexed data in available data Co-Authored-By: Claude Fable 5 --- Project.toml | 2 +- src/utils_GUI/GUI_utils.jl | 41 ++++++++++++++++++++++- src/utils_GUI/results_axis_utils.jl | 11 +++++- src/utils_gen/utils.jl | 52 +++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/Project.toml b/Project.toml index 1e37695..3842449 100644 --- a/Project.toml +++ b/Project.toml @@ -54,7 +54,7 @@ Pkg = "1" PrettyTables = "3" Printf = "1" SparseVariables = "0.7" -TimeStruct = "0.9" +TimeStruct = "0.9.12" XLSX = "0.12" YAML = "0.4" julia = "1.10, 1.11, 1.12" diff --git a/src/utils_GUI/GUI_utils.jl b/src/utils_GUI/GUI_utils.jl index c68741a..da65b54 100644 --- a/src/utils_GUI/GUI_utils.jl +++ b/src/utils_GUI/GUI_utils.jl @@ -232,7 +232,14 @@ function initialize_available_data!(gui) for combination ∈ get_combinations(var, i_T) selection = collect(combination) - field_data = extract_data_selection(var, selection, i_T, periods) + if type <: TS.PeriodPartition && isa(var, SparseVars) + # Partitions may differ between elements; extract them from the + # variable itself for the given selection + periods_comb = get_var_periods(var, selection, i_T) + else + periods_comb = periods + end + field_data = extract_data_selection(var, selection, i_T, periods_comb) element = getfirst(x -> !isa(x, Resource), selection) if !isa(element, AbstractElement) && !isnothing(element) # it must be a transmission element = mode_to_transmission[element] @@ -677,6 +684,38 @@ function get_investment_times(gui::GUI, max_inst::Float64) end end +""" + get_var_periods(var::SparseVars, selection::Vector, i_T::Int64) + +Get the sorted time indices available in `var` at axis `i_T` for the combination +`selection` of the remaining indices. This is required for variables indexed over +`TS.PeriodPartition`s as the partitions may differ between elements. +""" +function get_var_periods(var::SparseVars, selection::Vector, i_T::Int64) + sel = Tuple(selection) + pds = [ + key[i_T] for + key ∈ keys(var.data) if (key[1:(i_T-1)]..., key[(i_T+1):end]...) == sel + ] + return sort(pds; by = partition_sort_key) +end + +""" + is_partition_data(container::PlotContainer) + +Return `true` if `container` holds data indexed over `TS.PeriodPartition`s, *i.e.*, a JuMP +variable with a `TS.PeriodPartition` axis or a field of type `PartitionProfile`. +""" +is_partition_data(::PlotContainer) = false +function is_partition_data(container::JuMPContainer) + field_data = get_field_data(container) + return eltype(first(axes(field_data))) <: TS.PeriodPartition +end +function is_partition_data(container::CaseDataContainer) + field_data = get_field_data(container) + return isa(field_data, TimeProfile) && nested_eltype(field_data) <: PartitionProfile +end + """ get_combinations(var::SparseVars, i_T::Int) get_combinations(var::JuMP.Containers.DenseAxisArray, i_T::Int) diff --git a/src/utils_GUI/results_axis_utils.jl b/src/utils_GUI/results_axis_utils.jl index d237400..3605015 100644 --- a/src/utils_GUI/results_axis_utils.jl +++ b/src/utils_GUI/results_axis_utils.jl @@ -252,6 +252,11 @@ end function get_periods(T::TS.TimeStructure, ::Type{<:TS.ScenarioPeriod}) return collect(TS.opscenarios(T)) end +function get_periods(::TS.TimeStructure, ::Type{<:TS.PeriodPartition}) + # Partitions may differ between elements and are instead extracted from the variable + # itself (see `get_var_periods`) + return TS.PeriodPartition[] +end function get_periods(T::TS.TimeStructure, ::Type{<:Any}) return collect(T) end @@ -278,7 +283,11 @@ function get_time_axis( ) types::Vector{Type} = collect(get_jump_axis_types(data)) i_T::Union{Int64,Nothing} = findfirst( - x -> x <: TS.TimePeriod || x <: TS.TimeStructure{T} where {T}, types, + x -> + x <: TS.TimePeriod || + x<:(TS.TimeStructure{T} where {T}) || + x <: TS.PeriodPartition, + types, ) if isnothing(i_T) return i_T, nothing diff --git a/src/utils_gen/utils.jl b/src/utils_gen/utils.jl index 0f26de2..34a1f2e 100644 --- a/src/utils_gen/utils.jl +++ b/src/utils_gen/utils.jl @@ -49,6 +49,58 @@ function get_op(tp::TS.TimePeriod) end end +""" + period_partitions(element, T::TS.TimeStructure) + +Return the vector of `TS.PeriodPartition`s partitioning the operational periods of `T` for +`element`, or `nothing` if `element` does not use partitioned data. + +By default, the partitions are constructed from the field `period_duration` of `element` +(the convention introduced by, *e.g.*, `PeriodDemandSink` in `EnergyModelsFlex`). The +function can be specialized for elements that store the partition duration differently. +""" +function period_partitions(element, T::TS.TimeStructure) + if hasproperty(element, :period_duration) + return collect(partition_duration(T, element.period_duration)) + end + return nothing +end +period_partitions(::Nothing, ::TS.TimeStructure) = nothing + +""" + partition_sort_key(pd::TS.PeriodPartition) + +Return a sort key for the `TS.PeriodPartition` `pd` given by the tuple of its strategic +period, representative period, scenario, and partition indices (indices not present in +`pd` are set to zero). +""" +function partition_sort_key(pd::TS.PeriodPartition) + return ( + hasproperty(pd, :sp) ? pd.sp : 0, + hasproperty(pd, :rp) ? pd.rp : 0, + hasproperty(pd, :scen) ? pd.scen : 0, + pd.part, + ) +end + +""" + filter_partitions( + pds::Vector{<:TS.PeriodPartition}, sp::Int64, rp::Int64, sc::Int64, + ) + +Filter the `TS.PeriodPartition`s `pds` to the strategic period `sp`, representative period +`rp`, and scenario `sc` (indices not present in the partitions are ignored). +""" +function filter_partitions( + pds::Vector{<:TS.PeriodPartition}, sp::Int64, rp::Int64, sc::Int64, +) + return [ + pd for pd ∈ pds if (!hasproperty(pd, :sp) || pd.sp == sp) && + (!hasproperty(pd, :rp) || pd.rp == rp) && + (!hasproperty(pd, :scen) || pd.scen == sc) + ] +end + """ get_supertypes(x::Any) From 3291676815bad7f4aef178dc45f0e6502c6b62fa Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 30 Sep 2026 11:19:35 +0200 Subject: [PATCH 2/9] Add partition time axis to results plotting Co-Authored-By: Claude Fable 5 --- src/setup_GUI.jl | 6 ++++-- src/utils_GUI/results_axis_utils.jl | 33 ++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/setup_GUI.jl b/src/setup_GUI.jl index a536418..221d0a2 100644 --- a/src/setup_GUI.jl +++ b/src/setup_GUI.jl @@ -166,12 +166,14 @@ function GUI( :alpha => Observable(Float32(alpha)), :autolimits => Dict( :results_op => true, + :results_pt => true, :results_sc => true, :results_rp => true, :results_sp => true, ), # Automatically adjust limits of the axis :finallimits => Dict( :results_op => GLMakie.HyperRectangle(Vec2f(0, 0), Vec2f(1, 1)), + :results_pt => GLMakie.HyperRectangle(Vec2f(0, 0), Vec2f(1, 1)), :results_sc => GLMakie.HyperRectangle(Vec2f(0, 0), Vec2f(1, 1)), :results_rp => GLMakie.HyperRectangle(Vec2f(0, 0), Vec2f(1, 1)), :results_sp => GLMakie.HyperRectangle(Vec2f(0, 0), Vec2f(1, 1)), @@ -590,8 +592,8 @@ function create_makie_objects(vars::Dict, design::EnergySystemDesign) time_menu = Makie.Menu( gridlayout_results_taskbar1[1, 2]; options = zip( - ["Strategic", "Representative", "Scenario", "Operational"], - [:results_sp, :results_rp, :results_sc, :results_op], + ["Strategic", "Representative", "Scenario", "Operational", "Partition"], + [:results_sp, :results_rp, :results_sc, :results_op, :results_pt], ), halign = :left, width = 110 * vars[:fontsize] / 12, diff --git a/src/utils_GUI/results_axis_utils.jl b/src/utils_GUI/results_axis_utils.jl index 3605015..a7f53cd 100644 --- a/src/utils_GUI/results_axis_utils.jl +++ b/src/utils_GUI/results_axis_utils.jl @@ -200,11 +200,32 @@ function get_data( else type = nested_eltype(field_data) end - periods, time_axis = get_periods(T, type, sp, rp, sc) + if type <: TS.PeriodPartition || type <: PartitionProfile + periods = filter_partitions(get_partition_periods(selection, T), sp, rp, sc) + time_axis = :results_pt + else + periods, time_axis = get_periods(T, type, sp, rp, sc) + end y_values = get_values(field_data, periods) return periods, y_values, time_axis end +""" + get_partition_periods(selection::JuMPContainer, ::TS.TimeStructure) + get_partition_periods(selection::PlotContainer, T::TS.TimeStructure) + +Get all `TS.PeriodPartition`s for the data in `selection`, either from the axis of the +extracted JuMP data or, for case data, constructed from the element itself (see +[`period_partitions`](@ref)). +""" +function get_partition_periods(selection::JuMPContainer, ::TS.TimeStructure) + return collect(first(axes(get_field_data(selection)))) +end +function get_partition_periods(selection::PlotContainer, T::TS.TimeStructure) + element = getfirst(x -> !isa(x, Resource) && !isnothing(x), get_selection(selection)) + return period_partitions(element, T) +end + """ get_periods(T::TS.TimeStructure, type::Type, sp::Int64, rp::Int64, sc::Int64) @@ -414,8 +435,12 @@ function update_plot!(gui::GUI, element) else label *= " for strategic period $sp" end - elseif time_axis == :results_op - xlabel *= " (OperationalPeriods)" + elseif time_axis == :results_op || time_axis == :results_pt + if time_axis == :results_op + xlabel *= " (OperationalPeriods)" + else + xlabel *= " (PeriodPartitions)" + end if eltype(T.operational) <: TS.RepresentativePeriods if eltype(T.operational[sp].rep_periods) <: TS.OperationalScenarios @@ -463,6 +488,8 @@ function update_plot!(gui::GUI, element) if !isempty(scenarios_labels) custom_ticks = (1:no_pts, scenarios_labels[1:no_pts]) end + elseif time_axis == :results_pt + time_menu.i_selected[] = 5 end end if time_axis == :results_op From 217334f085ebbc22e582c0f33e22aacf2c452805 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 30 Sep 2026 11:26:31 +0200 Subject: [PATCH 3/9] Show partition time axis only for elements with partition data Co-Authored-By: Claude Fable 5 --- src/datastructures.jl | 8 +++++++ src/setup_GUI.jl | 5 +---- src/utils_GUI/GUI_utils.jl | 34 +++++++++++++++++++++++++++++ src/utils_GUI/results_axis_utils.jl | 3 ++- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/datastructures.jl b/src/datastructures.jl index 466a422..3dd6332 100644 --- a/src/datastructures.jl +++ b/src/datastructures.jl @@ -349,6 +349,14 @@ const JuMPContainer = PlotContainer{:JuMP} const CaseDataContainer = PlotContainer{:CaseData} const GlobalDataContainer = PlotContainer{:GlobalData} +# Define the time axes of the results axis and their labels in the time menu. The +# partition axis (PARTITION_AXIS) is appended to the base options only for elements +# with data indexed over `TS.PeriodPartition`s +const TIME_AXES_LABELS = ["Strategic", "Representative", "Scenario", "Operational"] +const TIME_AXES = [:results_sp, :results_rp, :results_sc, :results_op] +const PARTITION_AXIS_LABEL = "Partition" +const PARTITION_AXIS = :results_pt + # Define standard colours in EMGUI const BLACK = RGBA{Float32}(0.0, 0.0, 0.0, 1.0) const WHITE = RGBA{Float32}(1.0, 1.0, 1.0, 1.0) diff --git a/src/setup_GUI.jl b/src/setup_GUI.jl index 221d0a2..b432ecd 100644 --- a/src/setup_GUI.jl +++ b/src/setup_GUI.jl @@ -591,10 +591,7 @@ function create_makie_objects(vars::Dict, design::EnergySystemDesign) ) time_menu = Makie.Menu( gridlayout_results_taskbar1[1, 2]; - options = zip( - ["Strategic", "Representative", "Scenario", "Operational", "Partition"], - [:results_sp, :results_rp, :results_sc, :results_op, :results_pt], - ), + options = zip(TIME_AXES_LABELS, TIME_AXES), halign = :left, width = 110 * vars[:fontsize] / 12, fontsize = vars[:fontsize], diff --git a/src/utils_GUI/GUI_utils.jl b/src/utils_GUI/GUI_utils.jl index da65b54..7f74072 100644 --- a/src/utils_GUI/GUI_utils.jl +++ b/src/utils_GUI/GUI_utils.jl @@ -747,6 +747,40 @@ function update_available_data_menu!(gui::GUI, element) container = available_data[element] container_strings = create_label.(container) get_menu(gui, :available_data).options = zip(container_strings, container) + update_time_menu!(gui, element) +end + +""" + has_partition_data(gui::GUI, element) + +Return `true` if `element` has available data indexed over `TS.PeriodPartition`s. +""" +function has_partition_data(gui::GUI, element) + return any(is_partition_data, get_available_data(gui)[element]) +end + +""" + update_time_menu!(gui::GUI, element) + +Update the options of the time menu based on `element`: The option for plotting data over +`TS.PeriodPartition`s is only made available if `element` has data indexed over such +partitions. +""" +function update_time_menu!(gui::GUI, element) + time_menu = get_menu(gui, :time) + labels = copy(TIME_AXES_LABELS) + time_axes = copy(TIME_AXES) + if has_partition_data(gui, element) + push!(labels, PARTITION_AXIS_LABEL) + push!(time_axes, PARTITION_AXIS) + end + if length(collect(time_menu.options[])) != length(time_axes) + # Reset the selection if the partition axis is removed while being selected + if time_menu.selection[] ∉ time_axes + time_menu.i_selected = 1 + end + time_menu.options = zip(labels, time_axes) + end end """ diff --git a/src/utils_GUI/results_axis_utils.jl b/src/utils_GUI/results_axis_utils.jl index a7f53cd..0a3fe51 100644 --- a/src/utils_GUI/results_axis_utils.jl +++ b/src/utils_GUI/results_axis_utils.jl @@ -489,7 +489,8 @@ function update_plot!(gui::GUI, element) custom_ticks = (1:no_pts, scenarios_labels[1:no_pts]) end elseif time_axis == :results_pt - time_menu.i_selected[] = 5 + # The partition axis is appended after the base time axes options + time_menu.i_selected[] = length(TIME_AXES) + 1 end end if time_axis == :results_op From faf878a64855a4bce6b4f34cd6f92530faf0e3fd Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 30 Sep 2026 11:28:40 +0200 Subject: [PATCH 4/9] Add descriptive names for period demand sinks Co-Authored-By: Claude Fable 5 --- src/descriptive_names.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/descriptive_names.yml b/src/descriptive_names.yml index 7cbc5de..76efb1b 100644 --- a/src/descriptive_names.yml +++ b/src/descriptive_names.yml @@ -149,6 +149,16 @@ structures: cap_price: "Price of capacity usage" cap_price_periods: "The number of sub periods in an investment period" + ## sink/datastructures.jl + AbstractPeriodDemandSink: + period_duration: "Duration of each demand period" + period_demand: "Demand within each demand period" + + StratPeriodDemandSink: + strat_demand: "Demand within each strategic period" + period_min: "Minimum fraction of the strategic demand satisfied in each demand period" + period_max: "Maximum fraction of the strategic demand satisfied in each demand period" + variables: # EnergyModelsBase @@ -268,6 +278,10 @@ variables: solar_cap_use: "Absolute capacity utilization" # EnergyModelsFlex + demand_sink_surplus: "Surplus in demand within a demand period" + demand_sink_deficit: "Deficit in demand within a demand period" + demand_sink_strat_surplus: "Surplus in demand within a strategic period" + demand_sink_strat_deficit: "Deficit in demand within a strategic period" input_frac_strat: "Input resource fraction" load_shift_from: "Load shift from" load_shift_to: "Load shift to" From 0dfa14517d0b954a5e7d5fce3060ed45ed505988 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 30 Sep 2026 16:09:59 +0200 Subject: [PATCH 5/9] Fix axis limits for plots of constant data Co-Authored-By: Claude Fable 5 --- src/utils_GUI/results_axis_utils.jl | 32 +++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/utils_GUI/results_axis_utils.jl b/src/utils_GUI/results_axis_utils.jl index 0a3fe51..44b41c7 100644 --- a/src/utils_GUI/results_axis_utils.jl +++ b/src/utils_GUI/results_axis_utils.jl @@ -644,8 +644,15 @@ function update_limits!(ax::Axis) yorigin = ax.finallimits[].origin[2] ywidth = ax.finallimits[].widths[2] - # try to avoid legend box overlapping the plots - ylims!(ax, yorigin, yorigin + ywidth * 1.1) + if iszero(ywidth) + # Pad limits without a span (occuring for plots of constant data) to avoid + # errors when setting the limits + Δy = pad_amount(yorigin) + ylims!(ax, yorigin - Δy, yorigin + Δy) + else + # try to avoid legend box overlapping the plots + ylims!(ax, yorigin, yorigin + ywidth * 1.1) + end end """ @@ -658,9 +665,30 @@ function update_limits!(ax::Axis, limits::GLMakie.HyperRectangle) xmax = limits.origin[1] + limits.widths[1] ymin = limits.origin[2] ymax = limits.origin[2] + limits.widths[2] + + # Pad limits without a span (occuring for, e.g., stored limits of plots of constant + # data where the widths vanish in Float32 precision) to avoid errors when setting + # the limits + if xmax ≤ xmin + Δx = pad_amount(xmin) + xmin -= Δx + xmax += Δx + end + if ymax ≤ ymin + Δy = pad_amount(ymin) + ymin -= Δy + ymax += Δy + end limits!(ax, xmin, xmax, ymin, ymax) end +""" + pad_amount(val::Real) + +Return the amount with which degenerate axis limits around `val` are padded. +""" +pad_amount(val::Real) = max(abs(val) / 10, oftype(float(val), 0.1)) + """ update_barplot_dodge!(gui::GUI) From 2a9956df8b67e007807cd7d0c9e95969578fa82a Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 30 Sep 2026 16:09:59 +0200 Subject: [PATCH 6/9] Test PeriodPartition support with PeriodDemandSink Co-Authored-By: Claude Fable 5 --- test/Project.toml | 1 + test/case7.jl | 7 ++- test/test_interactivity.jl | 103 ++++++++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/test/Project.toml b/test/Project.toml index d12a06d..06f3def 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -4,6 +4,7 @@ CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" EnergyModelsBase = "5d7e687e-f956-46f3-9045-6f5a5fd49f50" EnergyModelsCO2 = "84b3f4d7-d799-4a5d-b06c-25c90dcfcad7" +EnergyModelsFlex = "a81b9388-333d-4b63-81f2-910b060b544c" EnergyModelsGeography = "3f775d88-a4da-46c4-a2cc-aa9f16db6708" EnergyModelsHeat = "ad1b8b27-e232-4da9-b498-bea9c19a30d7" EnergyModelsHydrogen = "44855f8b-b147-4985-ac18-48817d03c548" diff --git a/test/case7.jl b/test/case7.jl index f92eab7..5665919 100644 --- a/test/case7.jl +++ b/test/case7.jl @@ -1,4 +1,5 @@ using EnergyModelsBase +using EnergyModelsFlex using EnergyModelsGeography using EnergyModelsInvestments using EnergyModelsRenewableProducers @@ -186,9 +187,11 @@ function get_sub_system_data_case7(a_id, products, T) ), Dict(Heat => 1), # input `Resource`s with conversion value `Real` ) - hot_water_1 = RefSink( + hot_water_1 = PeriodDemandSink( "Hot water 1", # Node id - FixedProfile(0.2), # cap: the demand + FixedProfile(0.5), # cap: maximum demand satisfaction per operational period + 12, # period_duration: 2 demand periods per representative period + PartitionProfile([3, 1.8]), # period_demand: the demand within each demand period Dict( # penality: penalties for surplus or deficits :surplus => FixedProfile(0), # Penalty for surplus :deficit => FixedProfile(1e5) # Penalty for deficit diff --git a/test/test_interactivity.jl b/test/test_interactivity.jl index 3466a25..d3b7802 100644 --- a/test/test_interactivity.jl +++ b/test/test_interactivity.jl @@ -26,7 +26,7 @@ alpha_slider = get_slider(gui, :alpha) # Test specific GUI functionalities @testset "Test interactivity" verbose = true begin - op_cost = [3371970.00359, 5382390.00598, 2010420.00219] + op_cost = [3262470.0, 5272890.0, 2010420.0] inv_cost = [0.0, 0.0, 29536224.881975] @testset "Compare with Integrate results" begin T = get_time_struct(gui) @@ -290,11 +290,11 @@ alpha_slider = get_slider(gui, :alpha) period_menu.i_selected = 1 data_point = get_ax(gui, :results).scene.plots[1][1][][17][2] - @test data_point ≈ 2.8f0 atol = 1e-5 + @test data_point ≈ 2.6f0 atol = 1e-5 period_menu.i_selected = 2 data_point = get_ax(gui, :results).scene.plots[1][1][][17][2] - @test data_point ≈ 4.0f0 atol = 1e-5 + @test data_point ≈ 3.8f0 atol = 1e-5 period_menu.i_selected = 3 data_point = get_ax(gui, :results).scene.plots[1][1][][17][2] @@ -379,7 +379,7 @@ alpha_slider = get_slider(gui, :alpha) notify(pin_plot_button.clicks) # test redundant clicks data_point = get_ax(gui, :results).scene.plots[1][1][][5][2] @test data_point ≈ 0.25f0 atol = 1e-5 - data_point = get_ax(gui, :results).scene.plots[4][1][][5][2] + data_point = get_ax(gui, :results).scene.plots[5][1][][5][2] @test data_point ≈ 0.6f0 atol = 1e-5 end @@ -464,8 +464,8 @@ alpha_slider = get_slider(gui, :alpha) update!(gui) get_menu(gui, :period).i_selected = 3 select_data!(gui, "stor_level_Δ_rp") - @test get_ax(gui, :results).scene.plots[3][1][][1][2] ≈ -7.2 atol = 1e-5 - @test get_ax(gui, :results).scene.plots[3][1][][2][2] ≈ 7.2 atol = 1e-5 + @test get_ax(gui, :results).scene.plots[3][1][][1][2] ≈ -7.0265152 atol = 1e-5 + @test get_ax(gui, :results).scene.plots[3][1][][2][2] ≈ 7.0265152 atol = 1e-5 end @testset "Test icon not found" begin @@ -623,5 +623,96 @@ alpha_slider = get_slider(gui, :alpha) @test ax_results.scene.plots[3][1][][2][2] ≈ 4.0e6 atol = 1e-5 EMGUI.close(gui3) end + + ## Test the PeriodDemandSink of case7 (data indexed over PeriodPartitions) + @testset "PeriodPartition support" verbose = true begin + clear_selection!(gui, :topo) + hot_water_design = get_component(area1, "Hot water 1") + hot_water = get_element(hot_water_design) + el_1_design = get_component(area1, "El 1") + el_1 = get_element(el_1_design) + + @testset "Available data" begin + available_data = get_var(gui, :available_data) + partition_containers = + filter(EMGUI.is_partition_data, available_data[hot_water]) + + # Both the JuMP variables indexed over PeriodPartitions and the + # PartitionProfile fields of the sink should be available + @test any(x -> EMGUI.get_name(x) == "demand_sink_surplus", partition_containers) + @test any(x -> EMGUI.get_name(x) == "demand_sink_deficit", partition_containers) + @test any(x -> EMGUI.get_name(x) == "period_demand", partition_containers) + + # The El 1 sink does not have any data indexed over PeriodPartitions + @test !any(EMGUI.is_partition_data, available_data[el_1]) + @test EMGUI.has_partition_data(gui, hot_water) + @test !EMGUI.has_partition_data(gui, el_1) + end + + @testset "Time menu options" begin + # The partition option should only be available when the sink is selected + pick_component!(gui, hot_water_design, :topo) + update!(gui) + @test length(collect(time_menu.options[])) == 5 + + pick_component!(gui, nothing, :topo) # deselect + @test length(collect(time_menu.options[])) == 4 + + pick_component!(gui, el_1_design, :topo) + update!(gui) + @test length(collect(time_menu.options[])) == 4 + pick_component!(gui, nothing, :topo) # deselect + end + + @testset "Plot JuMP partition data" begin + pick_component!(gui, hot_water_design, :topo) + update!(gui) + select_data!(gui, "demand_sink_deficit") + period_menu.i_selected = 1 + representative_period_menu.i_selected = 1 + + @test time_menu.selection[] == :results_pt + data = get_visible_data(gui, :results_pt)[end] + pds = data[:t] + @test all(pd -> isa(pd, TimeStruct.PeriodPartition), pds) + @test string.(pds) == ["sp1-rp1-part1", "sp1-rp1-part2"] + @test data[:y] ≈ + [value(m[:demand_sink_deficit][hot_water, pd]) for pd ∈ pds] atol = + TEST_ATOL + # The demand can always be satisfied within the demand periods + @test data[:y] ≈ [0.0, 0.0] atol = TEST_ATOL + end + + @testset "Filter by strategic and representative periods" begin + period_menu.i_selected = 2 + data = get_visible_data(gui, :results_pt)[end] + @test string.(data[:t]) == ["sp2-rp1-part1", "sp2-rp1-part2"] + + representative_period_menu.i_selected = 2 + data = get_visible_data(gui, :results_pt)[end] + @test string.(data[:t]) == ["sp2-rp2-part1", "sp2-rp2-part2"] + + period_menu.i_selected = 1 + representative_period_menu.i_selected = 1 + end + + @testset "Plot case data (PartitionProfile)" begin + select_data!(gui, "period_demand") + data = get_visible_data(gui, :results_pt)[end] + @test string.(data[:t]) == ["sp1-rp1-part1", "sp1-rp1-part2"] + @test data[:y] ≈ [3.0, 1.8] atol = TEST_ATOL + end + + @testset "Time menu reset for unsupported element" begin + # With the partition axis selected, selecting an element without partition + # data should remove the partition option and reset the time menu selection + @test time_menu.selection[] == :results_pt + pick_component!(gui, nothing, :topo) + pick_component!(gui, el_1_design, :topo) + update!(gui) + @test time_menu.selection[] == :results_sp + @test length(collect(time_menu.options[])) == 4 + end + end end EMGUI.close(gui) From 3ec2c0849038b10ad48974a4d21bd9b5ee1f3c05 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Mon, 5 Oct 2026 14:53:50 +0200 Subject: [PATCH 7/9] Allow for proper saving of period partitions --- src/utils_gen/utils.jl | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/utils_gen/utils.jl b/src/utils_gen/utils.jl index 34a1f2e..d45fcc7 100644 --- a/src/utils_gen/utils.jl +++ b/src/utils_gen/utils.jl @@ -53,19 +53,26 @@ end period_partitions(element, T::TS.TimeStructure) Return the vector of `TS.PeriodPartition`s partitioning the operational periods of `T` for -`element`, or `nothing` if `element` does not use partitioned data. +`element`. By default, the partitions are constructed from the field `period_duration` of `element` -(the convention introduced by, *e.g.*, `PeriodDemandSink` in `EnergyModelsFlex`). The -function can be specialized for elements that store the partition duration differently. +(the convention introduced by, *e.g.*, `PeriodDemandSink` in `EnergyModelsFlex`). Elements +that store the partition duration differently require a specialized method of this +function. If the partitions cannot be determined, a warning highlighting `element` is +issued and an empty vector is returned. """ function period_partitions(element, T::TS.TimeStructure) if hasproperty(element, :period_duration) return collect(partition_duration(T, element.period_duration)) end - return nothing + @warn "The `PeriodPartition`s of element `$element` (of type `$(typeof(element))`) " * + "cannot be determined as it has no field `period_duration`. Provide a method " * + "`EnergyModelsGUI.period_partitions(element::$(nameof(typeof(element))), T)` " * + "to visualize its data indexed over `PeriodPartition`s." _id = + (:period_partitions, element) maxlog = 1 + return TS.PeriodPartition[] end -period_partitions(::Nothing, ::TS.TimeStructure) = nothing +period_partitions(::Nothing, ::TS.TimeStructure) = TS.PeriodPartition[] """ partition_sort_key(pd::TS.PeriodPartition) @@ -269,6 +276,7 @@ end _type_to_header(::Type{<:TS.AbstractOperationalScenario}) _type_to_header(::Type{<:TS.TimePeriod}) _type_to_header(::Type{<:TS.TimeStructure}) + _type_to_header(::Type{<:TS.PeriodPartition}) _type_to_header(::Type{<:Resource}) _type_to_header(::Type{<:AbstractElement}) @@ -279,6 +287,7 @@ _type_to_header(::Type{<:TS.AbstractRepresentativePeriod}) = :rp _type_to_header(::Type{<:TS.AbstractOperationalScenario}) = :osc _type_to_header(::Type{<:TS.TimePeriod}) = :t _type_to_header(::Type{<:TS.TimeStructure}) = :t +_type_to_header(::Type{<:TS.PeriodPartition}) = :pd _type_to_header(::Type{<:Resource}) = :res _type_to_header(::Type{<:AbstractElement}) = :element _type_to_header(::Type{<:Int}) = :segment From df31c61653bd2a225679c31a7920bcd07fc558a6 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 7 Oct 2026 12:55:23 +0200 Subject: [PATCH 8/9] Read PeriodPartition-indexed results from CSV files Co-Authored-By: Claude Opus 5.5 --- src/utils_GUI/GUI_utils.jl | 69 +++++++++++++++++++++++++++-- src/utils_GUI/results_axis_utils.jl | 9 ++-- test/test_results_IO.jl | 56 +++++++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/utils_GUI/GUI_utils.jl b/src/utils_GUI/GUI_utils.jl index 7f74072..9e1b95c 100644 --- a/src/utils_GUI/GUI_utils.jl +++ b/src/utils_GUI/GUI_utils.jl @@ -588,9 +588,11 @@ get_JuMP_dict(model::JuMP.Model) = object_dictionary(model) get_values(vals::SparseVariables.IndexedVarArray, ts::Vector) get_values(vals::JuMP.Containers.DenseAxisArray, ts::Vector) get_values(vals::DataFrame, ts::Vector) + get_values(vals::DataFrame, ts::Vector{<:TS.PeriodPartition}) Get the values of the variables in `vals`. If a vector of time periods `ts` is provided, it -returns the values for the times in `ts`. +returns the values for the times in `ts`. For `TS.PeriodPartition`s read from CSV-files, the +values are returned in the order of `ts`. """ get_values(vals::SparseVars) = isempty(vals) ? [] : collect(Iterators.flatten(value.(vals))) get_values(vals::SparseVariables.IndexedVarArray) = collect(value.(values(vals.data))) @@ -601,6 +603,8 @@ get_values(vals::SparseVariables.IndexedVarArray, ts::Vector) = isempty(vals) ? [] : value.(vals[ts]) get_values(vals::JuMP.Containers.DenseAxisArray, ts::Vector) = Array(value.(vals[ts])) get_values(vals::DataFrame, ts::Vector) = vals[in.(vals.t, Ref(ts)), :val] +get_values(vals::DataFrame, ts::Vector{<:TS.PeriodPartition}) = + [vals[findfirst(==(t), vals.t), :val] for t ∈ ts] get_values(vals::TimeProfile, ts::Vector) = vals[ts] """ @@ -700,6 +704,16 @@ function get_var_periods(var::SparseVars, selection::Vector, i_T::Int64) return sort(pds; by = partition_sort_key) end +""" + get_time_periods(field_data::DataFrame) + get_time_periods(field_data) + +Get the time periods of the extracted data `field_data` of a JuMP variable, *i.e.*, the +column `:t` if the model results are read from CSV-files and the first axis otherwise. +""" +get_time_periods(field_data::DataFrame) = field_data[!, :t] +get_time_periods(field_data) = first(axes(field_data)) + """ is_partition_data(container::PlotContainer) @@ -708,8 +722,7 @@ variable with a `TS.PeriodPartition` axis or a field of type `PartitionProfile`. """ is_partition_data(::PlotContainer) = false function is_partition_data(container::JuMPContainer) - field_data = get_field_data(container) - return eltype(first(axes(field_data))) <: TS.PeriodPartition + return eltype(get_time_periods(get_field_data(container))) <: TS.PeriodPartition end function is_partition_data(container::CaseDataContainer) field_data = get_field_data(container) @@ -1043,13 +1056,18 @@ function transfer_model(model::String, system::AbstractSystem) df = read_csv(file) col_names = names(df) - df[!, :t] = convert_array(df[!, :t], periods_dict) if "res" ∈ col_names df[!, :res] = convert_array(df[!, :res], products_dict) end if "element" ∈ col_names df[!, :element] = convert_array(df[!, :element], plotables_dict) end + if "pd" ∈ col_names + # Partitions may differ between elements and are reconstructed per element + df = convert_partitions(df, 𝒯, varname) + else + df[!, :t] = convert_array(df[!, :t], periods_dict) + end results[i] = varname => df end @@ -1062,6 +1080,49 @@ function transfer_model(model::String, system::AbstractSystem) return data end +""" + convert_partitions(df::DataFrame, 𝒯::TimeStructure, varname::Symbol) + +Convert the string representations of the `TS.PeriodPartition`s in column `:pd` of `df` to +the partitions themselves and store them in column `:t`. + +As the string representation of a partition does not include its operational periods and +the partitions may differ between elements, the partitions are reconstructed for each +element in column `:element` through [`period_partitions`](@ref). Rows whose partition +cannot be reconstructed are removed with a warning highlighting the element. +""" +function convert_partitions(df::DataFrame, 𝒯::TimeStructure, varname::Symbol) + if !("element" ∈ names(df)) + @warn "The variable `$varname` is indexed over `PeriodPartition`s without an " * + "element index. Its partitions cannot be reconstructed and it is skipped." + return DataFrame() + end + partitions_dicts = Dict{Any,Dict}() + unmatched_elements = Set() + partitions = Vector{Union{Nothing,TS.PeriodPartition}}(undef, nrow(df)) + for (i, row) ∈ enumerate(eachrow(df)) + element = row[:element] + partitions_dict = get!(partitions_dicts, element) do + get_repr_dict(period_partitions(element, 𝒯)) + end + partitions[i] = get(partitions_dict, string(row[:pd]), nothing) + # Elements without any partitions are already highlighted by `period_partitions` + if isnothing(partitions[i]) && !isempty(partitions_dict) + push!(unmatched_elements, element) + end + end + for element ∈ unmatched_elements + @warn "The `PeriodPartition`s of element `$element` in the variable `$varname` do " * + "not match the partitions provided by `period_partitions`. The unmatched " * + "values are skipped." + end + + keep = .!isnothing.(partitions) + df = df[keep, Not(:pd)] + df[!, :t] = TS.PeriodPartition[pd for pd ∈ partitions[keep]] + return select!(df, Not(:val), :val) # Keep the values in the last column +end + """ read_csv(file::String) diff --git a/src/utils_GUI/results_axis_utils.jl b/src/utils_GUI/results_axis_utils.jl index 44b41c7..e9f15a5 100644 --- a/src/utils_GUI/results_axis_utils.jl +++ b/src/utils_GUI/results_axis_utils.jl @@ -214,12 +214,13 @@ end get_partition_periods(selection::JuMPContainer, ::TS.TimeStructure) get_partition_periods(selection::PlotContainer, T::TS.TimeStructure) -Get all `TS.PeriodPartition`s for the data in `selection`, either from the axis of the -extracted JuMP data or, for case data, constructed from the element itself (see -[`period_partitions`](@ref)). +Get all `TS.PeriodPartition`s for the data in `selection`, either from the time periods of +the extracted JuMP data (sorted through [`partition_sort_key`](@ref)) or, for case data, +constructed from the element itself (see [`period_partitions`](@ref)). """ function get_partition_periods(selection::JuMPContainer, ::TS.TimeStructure) - return collect(first(axes(get_field_data(selection)))) + pds = collect(get_time_periods(get_field_data(selection))) + return sort(pds; by = partition_sort_key) end function get_partition_periods(selection::PlotContainer, T::TS.TimeStructure) element = getfirst(x -> !isa(x, Resource) && !isnothing(x), get_selection(selection)) diff --git a/test/test_results_IO.jl b/test/test_results_IO.jl index 1c777a2..dbf03cf 100644 --- a/test/test_results_IO.jl +++ b/test/test_results_IO.jl @@ -111,3 +111,59 @@ end EMGUI.close(gui) end + +@testset "Test reading PeriodPartition results from files" verbose = true begin + directory = joinpath(tmpdir, "case7") + if !ispath(directory) + mkdir(directory) + end + + # Save the results of case7 (including variables indexed over `PeriodPartition`s) + case, model, m, gui_jump = run_case() + EMGUI.save_results(m; directory) + df_csv = CSV.read(joinpath(directory, "demand_sink_deficit.csv"), DataFrame) + @test "pd" ∈ names(df_csv) + + # Generate the GUI from saved files + gui = GUI(case; model = directory) + m_df = EMGUI.get_model(gui) + T = EMGUI.get_time_struct(gui) + @test eltype(m_df[:demand_sink_deficit][!, :t]) <: TimeStruct.PeriodPartition + + # Test that all variables have the expected values + for var ∈ EMGUI.get_JuMP_names(gui) + if !isempty(m[var]) + vals = vec(EMGUI.get_values(m[var])) + @test length(vals) == length(EMGUI.get_values(m_df[var])) + @test all(isapprox.(vals, EMGUI.get_values(m_df[var]), atol = TEST_ATOL)) + end + end + + # Test that the partition data is identical to the data of the GUI using the JuMP model + area1 = get_component(get_components(get_root_design(gui)), "area1") + hot_water = get_element(get_component(area1, "Hot water 1")) + el_1 = get_element(get_component(area1, "El 1")) + @test EMGUI.has_partition_data(gui, hot_water) + @test !EMGUI.has_partition_data(gui, el_1) + available_data_jump = EMGUI.get_available_data(gui_jump)[hot_water] + available_data_csv = EMGUI.get_available_data(gui)[hot_water] + for var ∈ ["demand_sink_deficit", "demand_sink_surplus"] + is_var = x -> EMGUI.get_name(x) == var + container_jump = EMGUI.getfirst(is_var, available_data_jump) + container_csv = EMGUI.getfirst(is_var, available_data_csv) + @test EMGUI.is_partition_data(container_csv) + for sp ∈ 1:3, rp ∈ 1:2 + pds_jump, vals_jump, ax_jump = EMGUI.get_data(m, container_jump, T, sp, rp, 1) + pds_csv, vals_csv, ax_csv = EMGUI.get_data(m_df, container_csv, T, sp, rp, 1) + @test ax_csv == ax_jump == :results_pt + @test string.(pds_csv) == string.(pds_jump) + @test vals_csv ≈ vals_jump atol = TEST_ATOL + end + end + + # Test that elements without partitions are highlighted + @test_logs (:warn, r"El 1") EMGUI.period_partitions(el_1, T) + + EMGUI.close(gui) + EMGUI.close(gui_jump) +end From e4a85b64a0ac164d6c49836dba29ea99b465593e Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 7 Oct 2026 12:55:37 +0200 Subject: [PATCH 9/9] Updated NEWS.md, Project.toml, and documentation --- NEWS.md | 14 +++++++++++- docs/src/manual/quick-start.md | 39 ++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index db06858..1670560 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,9 +2,21 @@ ## Unversioned -### Bugfix +### Bugfixes * Fixed a bug in the functionality `get_all_periods` when using `RepresentativePeriods` or `OperationalScenarios`. +* Fix crash when reapplying the stored axis limits of plots of constant data (*e.g.*, a flat demand profile), as the stored limits can degenerate in Float32 precision. +* Fix crash in `save_results` for models with variables indexed over `PeriodPartition`s. + +### Enhancements + +* Added support for visualizing data indexed over `PeriodPartition`s (introduced in *[`TimeStruct` v0.9.12](https://github.com/sintefore/TimeStruct.jl/releases/tag/v0.9.12)*), resolving [Issue #55](https://github.com/EnergyModelsX/EnergyModelsGUI.jl/issues/55): + * Both JuMP variables indexed over `PeriodPartition`s and `PartitionProfile` fields of elements (*e.g.*, `PeriodDemandSink` from `EnergyModelsFlex`) are available for plotting through the new *Partition* time axis. + * The *Partition* option in the time menu is only available for elements with data indexed over `PeriodPartition`s. + * The partitions of an element are by default deduced from its `period_duration` field; the function `period_partitions` can be specialized by packages using a different convention. + If the partitions of an element cannot be determined, a warning naming the element and the required method is issued and its data indexed over `PeriodPartition`s is not available for plotting. + * Variables indexed over `PeriodPartition`s are also supported when reading model results from CSV-files. + As the partition labels in the files are not unique across elements, the partitions are rebuilt per element through `period_partitions`. ## Version 0.7.2 (2026-08-04) diff --git a/docs/src/manual/quick-start.md b/docs/src/manual/quick-start.md index eb189ed..c5ea0c5 100644 --- a/docs/src/manual/quick-start.md +++ b/docs/src/manual/quick-start.md @@ -32,6 +32,14 @@ The results from a `JuMP` model can be visualized through the keyword argument ` GUI(case; model=m) ``` +A complete overview of the keyword arguments available for the `GUI` functions is available *[in its docstring](@ref GUI(case::Case; kwargs...))*. + +!!! tip "Example" + The GUI and its functionality is described through *[an example](@ref man-exampl)*. + You can also load different examples from the example folder, if desired. + +### [Visualization of saved results](@id man-quick-saved) + It is furthermore possible to visualize results from a saved model run. This however requires you to first save the results from a model run through the function [`save_results`](@ref). You can then visualize the results from a saved model run, again with the keyword argument `model`. @@ -54,8 +62,31 @@ GUI(case; model = dir_save) It **cannot** be a saved `Case` as the pointers to specific instances of, *e.g.*, `Link`s are not recreated when loading a `Case`. 2. You **must** use the function `save_results` for saving your results as we require the meta data when reading the CSV files for translating the data into the correct format. -A complete overview of the keyword arguments available for the `GUI` functions is available *[in its docstring](@ref GUI(case::Case; kwargs...))*. +### [Technologies with period partitions](@id man-quick-partitions) -!!! tip "Example" - The GUI and its functionality is described through *[an example](@ref man-exampl)*. - You can also load different examples from the folder, if desired +[`TimeStruct`](https://github.com/sintefore/TimeStruct.jl) (version 0.9.12 and later) allows for partitioning the operational periods of a time structure into `PeriodPartition`s. +Technologies using them, *e.g.*, the `PeriodDemandSink` of [`EnergyModelsFlex`](https://github.com/EnergyModelsX/EnergyModelsFlex.jl), have `PartitionProfile` fields and `JuMP` variables indexed over these partitions. +`EnergyModelsGUI` visualizes this data through the time axis option *Partition*, which is only available for elements with partitioned data. + +Partitions differ between elements and are not part of the time structure of the case. +`EnergyModelsGUI` therefore has to construct the partitions of each element itself through the function [`period_partitions`](@ref EnergyModelsGUI.period_partitions). +By default, it uses the field `period_duration` of the element, following the convention of `EnergyModelsFlex`. +If your technology stores the duration of its partitions differently, you must provide a method for your type returning the vector of `PeriodPartition`s for the time structure `𝒯` of the case. +Consider the case in which these are stored within the field `duation`, you must create a new method: + +```julia +using EnergyModelsGUI, TimeStruct + +function EnergyModelsGUI.period_partitions(n::MyPeriodNode, 𝒯::TimeStructure) + return collect(partition_duration(𝒯, n.duration)) +end +``` + +The method can be defined in your script or in the package introducing the technology, *e.g.*, through a package extension on `EnergyModelsGUI`. +It is our aim to include a default method within `EnergyModelsBase` which can be extended in a later stage. + +!!! warning "Requirements for period partitions" + 1. Without a method of `period_partitions` for an element lacking the field `period_duration`, a warning naming the element and the required method is issued and the partitioned data of this element is not available for plotting. + All other data of the element and of the case remains available. + 2. The method is also required when loading results from file. + The partition labels written by `save_results` are not unique across elements, so the partitions are rebuilt for each element through `period_partitions` when reading the CSV files.