From 7f0ec3827e388a606ade6d7f2342ae1b2cb3bf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Vegard=20Ven=C3=A5s?= Date: Sun, 7 Jun 2026 11:49:37 +0200 Subject: [PATCH 1/3] Added functionality for adding additional plots --- NEWS.md | 4 + src/setup_GUI.jl | 27 ++++++ src/utils_GUI/GUI_utils.jl | 132 +++++++++++++++++++++++----- src/utils_GUI/results_axis_utils.jl | 8 +- test/Project.toml | 2 + test/runtests.jl | 2 + test/test_results_IO.jl | 69 ++++++++++++++- 7 files changed, 221 insertions(+), 23 deletions(-) diff --git a/NEWS.md b/NEWS.md index c48fa0b..49b346e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,9 @@ # Release notes +## Unversioned (2026-06-07) + +* Added functionality for adding additional plots. + ## Version 0.7.2 (2026-05-20) ### Adjustments diff --git a/src/setup_GUI.jl b/src/setup_GUI.jl index b73b943..a536418 100644 --- a/src/setup_GUI.jl +++ b/src/setup_GUI.jl @@ -55,6 +55,10 @@ to the old EnergyModelsX `case` dictionary. geographical boundary data for plotting to be used instead of the default coastlines. - **`alpha::Number=1.0`** is the alpha value for non-invested connections and nodes in the topology design. +- **`additional_plots::Vector{<:Dict}=Dict[]`** is a vector of dictionaries containing the information + for additional plots to be added to the GUI. Each dictionary should contain the following + keys: `data::Union{String, DataFrame}` (where providing string signalizes a path to a CSV file), + and optionally `normalize::Bool` (whether to normalize the data, defaults to `false`). !!! warning "Reading model results from CSV-files" Reading model results from a directory (*i.e.*, `model::String` implying that the results @@ -90,7 +94,29 @@ function GUI( simplify_all_levels::Bool = false, map_boundary_file::String = "", alpha::Number = 1.0, + additional_plots::Vector{<:Dict} = Dict[], ) + errors = String[] + for additional_plot ∈ additional_plots + if !haskey(additional_plot, "data") || + !isa(additional_plot["data"], Union{String,DataFrame}) + push!( + errors, + "An entry in `additional_plots` is missing the required key " * + "`data`. The `data` field should contain either a string " * + "(path to a CSV file) or a DataFrame.", + ) + end + if haskey(additional_plot, "normalize") && !isa(additional_plot["normalize"], Bool) + push!( + errors, + "The `normalize` key in `additional_plots` should be of type Bool.", + ) + end + end + if !isempty(errors) + throw(ArgumentError(join(errors, " "))) + end # Generate the system topology: @info raw"Setting up the topology design structure" root_design::EnergySystemDesign = EnergySystemDesign( @@ -150,6 +176,7 @@ function GUI( :results_rp => GLMakie.HyperRectangle(Vec2f(0, 0), Vec2f(1, 1)), :results_sp => GLMakie.HyperRectangle(Vec2f(0, 0), Vec2f(1, 1)), ), + :additional_plots => additional_plots, ) # global variables for legends diff --git a/src/utils_GUI/GUI_utils.jl b/src/utils_GUI/GUI_utils.jl index 54e4344..dd1dd59 100644 --- a/src/utils_GUI/GUI_utils.jl +++ b/src/utils_GUI/GUI_utils.jl @@ -190,7 +190,7 @@ end """ results_available(model::Model) - results_available(model::String) + results_available(model::Dict) Check if the model has a feasible solution. """ @@ -210,6 +210,7 @@ function initialize_available_data!(gui) design = get_root_design(gui) system = get_system(design) model = get_model(gui) + T = get_time_struct(gui) plotables = [nothing; vcat(get_elements_vec(system))...] # `nothing` here represents no selection gui.vars[:available_data] = Dict{Any,Vector{PlotContainer}}( element => Vector{PlotContainer}() for element ∈ plotables @@ -217,7 +218,6 @@ function initialize_available_data!(gui) # Find appearances of node/area/link/transmission in the model if results_available(model) - T = get_time_struct(gui) mode_to_transmission = get_mode_to_transmission_map(system) for sym ∈ get_JuMP_names(gui) var = model[sym] @@ -406,6 +406,82 @@ function initialize_available_data!(gui) append!(get_available_data(gui)[element], available_data) end end + + # Add additional plots provided by the user + element = nothing # Additional plots are not associated with a specific element + for (i, additional_plot) ∈ enumerate(get_var(gui, :additional_plots)) + df = get_data(additional_plot["data"]) + if isempty(df) + @warn "Additional plot data is empty. Skipping this plot." + continue + end + if "t" ∈ names(df) + if !allunique(df[!, :t]) + throw( + ArgumentError( + "Additional plot data must have unique values in column :t.", + ), + ) + end + df[!, :t] = convert_array(df[!, :t], get_all_periods(T)) + else + @warn "Additional plot data does not contain a 't' column. Creating a OperationalProfile." + if length(df[!, 1]) > length(collect(T)) + throw( + ArgumentError( + "Additional plot data has more rows than the number of time periods in the model.", + ), + ) + end + df[!, :t] = collect(T)[1:length(df[!, 1])] + end + for col ∈ names(df) + if !(eltype(df[!, col]) <: Number) + continue + end + if isa(additional_plot["data"], String) + descriptive_name = basename(additional_plot["data"]) * " - " * string(col) + else + descriptive_name = "Additional plot $i - " * string(col) + end + val = df[!, col] + + # Normalize the data if requested by the user + if haskey(additional_plot, "normalize") && additional_plot["normalize"] + if !all(val .== 0) + val = val ./ maximum(abs.(val)) + descriptive_name *= " (normalized)" + end + end + container = GlobalDataContainer( + string(col), + [element], + DataFrame(t = df[!, :t], val = val), + descriptive_name, + ) + push!(get_available_data(gui)[element], container) + end + end +end + +""" + get_data(data::String) + get_data(data::DataFrame) + +Get the data from a string path to a CSV file or directly from a DataFrame. +""" +function get_data(data::String) + return read_csv(data) +end +function get_data(data::DataFrame) + # Make a copy of the DataFrame to avoid modifying the original one when renaming columns + df = copy(data) + + # Make sure the time column name is :t if present as string + if "t" ∈ names(df) + rename!(df, "t" => :t) + end + return df end """ @@ -738,12 +814,18 @@ function get_descriptive_names(model::Model, descriptive_names::Dict{Symbol,Any} end """ - select_data!(gui::GUI, name::String; selection::Vector = Any[]) + select_data!(gui::GUI, name::String; selection::Vector = Any[], fun::Function = findfirst) Select the data with name `name` from the `available_data` menu. If `selection` is provided, -it is used to further specify which data to select. -""" -function select_data!(gui::GUI, name::String; selection::Vector = Any[]) +it is used to further specify which data to select. The `fun` argument is used to specify the +function for finding the data in the menu (default is `findfirst`). +""" +function select_data!( + gui::GUI, + name::String; + selection::Vector = Any[], + fun::Function = findfirst, +) # Fetch the available data menu object menu = get_menu(gui, :available_data) @@ -751,9 +833,9 @@ function select_data!(gui::GUI, name::String; selection::Vector = Any[]) # Find menu number for data with name `name` if isempty(selection) - i_selected = findfirst(x -> get_name(x[2]) == name, items) + i_selected = fun(x -> get_name(x[2]) == name, items) else - i_selected = findfirst( + i_selected = fun( x -> get_name(x[2]) == name && issubset(selection, get_selection(x[2])), items, ) @@ -780,6 +862,12 @@ function get_total_sum_time(data::DataFrame, periods::Vector{<:TS.TimeStructure} return [sum(data[data.:t .== [t], :val]) for t ∈ periods] end +function get_all_periods(𝒯::TimeStructure) + all_periods = Union{TS.TimePeriod,TS.TimeStructure}[] + get_all_periods!(all_periods, 𝒯) + return get_repr_dict(unique(all_periods)) +end + """ get_all_periods!(vec::Vector, ts::TwoLevel) get_all_periods!(vec::Vector, ts::RepresentativePeriods) @@ -845,9 +933,7 @@ function transfer_model(model::String, system::AbstractSystem) 𝒯 = get_time_struct(system) results = Vector{Pair{Symbol,DataFrame}}(undef, length(files)) - all_periods = Union{TS.TimePeriod,TS.TimeStructure}[] - get_all_periods!(all_periods, 𝒯) - periods_dict = get_repr_dict(unique(all_periods)) + periods_dict = get_all_periods(𝒯) products_dict = get_repr_dict(get_products(system)) plotables_dict = get_repr_dict(get_plotables(system)) @@ -855,16 +941,7 @@ function transfer_model(model::String, system::AbstractSystem) file = files[i] varname = Symbol(basename(file)[1:(end-4)]) - df = CSV.read(file, DataFrame) - - # Rename columns :sp, :op, or :osc to :t if present. Note that the type of the - # time structure is available through the type of the column. - for col ∈ (:sp, :rp, :osc) - if string(col) ∈ names(df) - rename!(df, col => :t) - end - end - + df = read_csv(file) col_names = names(df) df[!, :t] = convert_array(df[!, :t], periods_dict) if "res" ∈ col_names @@ -885,6 +962,19 @@ function transfer_model(model::String, system::AbstractSystem) return data end +function read_csv(file::String) + df = CSV.read(file, DataFrame) + + # Rename columns :sp, :rp, or :osc to :t if present. Note that the type of the + # time structure is available through the type of the column. + for col ∈ (:t, :sp, :rp, :osc, :op) + if string(col) ∈ names(df) + rename!(df, col => :t) + end + end + return df +end + """ sub_plots_empty(component::EnergySystemDesign) diff --git a/src/utils_GUI/results_axis_utils.jl b/src/utils_GUI/results_axis_utils.jl index 2e9b791..969cd15 100644 --- a/src/utils_GUI/results_axis_utils.jl +++ b/src/utils_GUI/results_axis_utils.jl @@ -190,7 +190,13 @@ function get_data( field_data = get_field_data(selection) if isa(selection, JuMPContainer) sym = Symbol(get_name(selection)) - i_T, type = get_time_axis(model[sym]) + _, type = get_time_axis(model[sym]) + elseif isa(selection, GlobalDataContainer) + if isa(field_data, DataFrame) + _, type = get_time_axis(field_data) + else + type = nested_eltype(field_data) + end else type = nested_eltype(field_data) end diff --git a/test/Project.toml b/test/Project.toml index 220179a..d12a06d 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,5 +1,7 @@ [deps] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" EnergyModelsBase = "5d7e687e-f956-46f3-9045-6f5a5fd49f50" EnergyModelsCO2 = "84b3f4d7-d799-4a5d-b06c-25c90dcfcad7" EnergyModelsGeography = "3f775d88-a4da-46c4-a2cc-aa9f16db6708" diff --git a/test/runtests.jl b/test/runtests.jl index 002435c..cfcc371 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,6 +2,8 @@ using EnergyModelsGUI using Test using YAML using Logging +using CSV +using DataFrames const TEST_ATOL = 1e-6 const EMGUI = EnergyModelsGUI diff --git a/test/test_results_IO.jl b/test/test_results_IO.jl index f5b59d9..1c777a2 100644 --- a/test/test_results_IO.jl +++ b/test/test_results_IO.jl @@ -1,4 +1,4 @@ -tmpdir = mktempdir(@__DIR__; prefix = "exported_files_") +tmpdir = mktempdir(testdir; prefix = "exported_files_") function get_case(file) if file == "EMB_network.jl" @@ -44,3 +44,70 @@ end EMGUI.close(gui) end end + +@testset "Test reading additional plot data from files" verbose = true begin + # Read the case and model data from previous test + file = "EMB_network.jl" + directory = joinpath(tmpdir, splitext(file)[1]) + case, model = get_case(file) + + # Create additional plot data and save it to a CSV file + test_additional_plots_dir = joinpath(tmpdir, "test_additional_plots") + if !ispath(test_additional_plots_dir) + mkdir(test_additional_plots_dir) + end + cap_use_path = joinpath(directory, "cap_use.csv") + additional_data = CSV.read(cap_use_path, DataFrame) + additional_data.val = Float64.(additional_data.val) + additional_data = unstack( + additional_data, + Not([:element, :val]), + :element, + :val; + fill = NaN, + ) + cap_use_unstacked_path = joinpath(test_additional_plots_dir, "cap_use_unstacked.csv") + CSV.write(cap_use_unstacked_path, additional_data) + + # Generate the GUI with the additional plot + gui = GUI(case; + model = directory, + additional_plots = [ + Dict("data" => additional_data, "normalize" => true), + Dict("data" => cap_use_unstacked_path, "normalize" => true), + ], + ) + + pin_plot_button = get_button(gui, :pin_plot) + available_data_menu = get_menu(gui, :available_data) + + for node_name ∈ ["NG source", "electricity demand"] + clear_selection!(gui, :topo) + notify(get_button(gui, :clear_all).clicks) + node = get_component(get_root_design(gui), node_name) + pick_component!(gui, node, :topo) + update!(gui) + select_data!(gui, "cap_use") + data_points = get_ax(gui, :results).scene.plots[1][1][] + ref_values = [data_point[2] for data_point ∈ data_points] + + # normalize + ref_values ./= maximum(ref_values) + + # Select the additional data + clear_selection!(gui, :topo) + select_data!(gui, "n_$(node_name)") + notify(pin_plot_button.clicks) + select_data!(gui, "n_$(node_name)"; fun = findlast) + data_points_dataframe = get_ax(gui, :results).scene.plots[1][1][] + data_points_csv = get_ax(gui, :results).scene.plots[2][1][] + + for data_points ∈ [data_points_dataframe, data_points_csv] + values = [data_point[2] for data_point ∈ data_points] + values ./= maximum(values) + @test all(isapprox.(values, ref_values, atol = 1e-6)) + end + end + + EMGUI.close(gui) +end From 5435f9342a4316dd11d97751605dad1201a9f383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Vegard=20Ven=C3=A5s?= Date: Sun, 7 Jun 2026 15:44:25 +0200 Subject: [PATCH 2/3] Add suggestions from copilot review --- NEWS.md | 2 +- src/utils_GUI/GUI_utils.jl | 57 ++++++++++++++++++++++++++++++-------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/NEWS.md b/NEWS.md index 49b346e..a7d98f1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,6 @@ # Release notes -## Unversioned (2026-06-07) +## Unversioned updates (2026-06-07) * Added functionality for adding additional plots. diff --git a/src/utils_GUI/GUI_utils.jl b/src/utils_GUI/GUI_utils.jl index dd1dd59..08f5231 100644 --- a/src/utils_GUI/GUI_utils.jl +++ b/src/utils_GUI/GUI_utils.jl @@ -423,7 +423,21 @@ function initialize_available_data!(gui) ), ) end - df[!, :t] = convert_array(df[!, :t], get_all_periods(T)) + + t_vals = df[!, :t] + if all(x -> isa(x, AbstractString), t_vals) + periods_dict = get_all_periods(T) + missing_t = setdiff(unique(t_vals), collect(keys(periods_dict))) + if !isempty(missing_t) + throw( + ArgumentError( + "Additional plot data contains unknown time values in column :t: " + * join(string.(missing_t), ", ") * ".", + ), + ) + end + df[!, :t] = convert_array(t_vals, periods_dict) + end else @warn "Additional plot data does not contain a 't' column. Creating a OperationalProfile." if length(df[!, 1]) > length(collect(T)) @@ -477,10 +491,7 @@ function get_data(data::DataFrame) # Make a copy of the DataFrame to avoid modifying the original one when renaming columns df = copy(data) - # Make sure the time column name is :t if present as string - if "t" ∈ names(df) - rename!(df, "t" => :t) - end + rename_time_column!(df) return df end @@ -862,6 +873,11 @@ function get_total_sum_time(data::DataFrame, periods::Vector{<:TS.TimeStructure} return [sum(data[data.:t .== [t], :val]) for t ∈ periods] end +""" + get_all_periods(𝒯::TimeStructure) + +Get all TimeStructures in `𝒯` as a dictionary with their string representation as keys. +""" function get_all_periods(𝒯::TimeStructure) all_periods = Union{TS.TimePeriod,TS.TimeStructure}[] get_all_periods!(all_periods, 𝒯) @@ -962,15 +978,34 @@ function transfer_model(model::String, system::AbstractSystem) return data end +""" + read_csv(file::String) + +Read a CSV file and return it as a DataFrame. The time column is renamed to :t if it is +named "t", "sp", "rp", "osc", or "op". +""" function read_csv(file::String) df = CSV.read(file, DataFrame) + rename_time_column!(df) + return df +end - # Rename columns :sp, :rp, or :osc to :t if present. Note that the type of the - # time structure is available through the type of the column. - for col ∈ (:t, :sp, :rp, :osc, :op) - if string(col) ∈ names(df) - rename!(df, col => :t) - end +""" + rename_time_column!(df::DataFrame) + +Rename the time column in `df` to :t if it is named "t", "sp", "rp", "osc", or "op". +If more than one of these columns are present, an error is thrown. +""" +function rename_time_column!(df::DataFrame) + time_cols = filter(c -> c ∈ names(df), ["t", "sp", "rp", "osc", "op"]) + if length(time_cols) > 1 + throw( + ArgumentError( + "Additional plot data must contain at most one time column (t/sp/rp/osc/op).", + ), + ) + elseif length(time_cols) == 1 + rename!(df, time_cols[1] => :t) end return df end From 7a110dbc09198dfeff99ccd2a2594283f5a2e7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Vegard=20Ven=C3=A5s?= Date: Tue, 23 Jun 2026 12:30:39 +0200 Subject: [PATCH 3/3] Add suggestion from review --- docs/make.jl | 1 + docs/src/how-to/add_additional_plots.md | 49 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 docs/src/how-to/add_additional_plots.md diff --git a/docs/make.jl b/docs/make.jl index 864c3fd..b1d79d0 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -52,6 +52,7 @@ makedocs(; "Customize descriptive_names"=>"how-to/customize-descriptive_names.md", "Improve performance"=>"how-to/improve-performance.md", "Use custom backgroun map"=>"how-to/use-custom-background-map.md", + "Add additional plots"=>"how-to/add_additional_plots.md", ], "Library" => Any[ "Public"=>"library/public.md", diff --git a/docs/src/how-to/add_additional_plots.md b/docs/src/how-to/add_additional_plots.md new file mode 100644 index 0000000..0c852f9 --- /dev/null +++ b/docs/src/how-to/add_additional_plots.md @@ -0,0 +1,49 @@ +# [Add Additional Plots](@id how_to-add_additional_plots) + +Use the `additional_plots` keyword in the [`GUI(::Case; kwargs...)`](@ref) constructor to add external time series (or other indexed series) in the results axis, alongside model data. + +The `additional_plots` keyword argument accepts a vector of dictionaries. Each dictionary defines one additional plot source: + +- `"data"` (**required**): + - a `DataFrame`, or + - a path to a CSV file. +- `"normalize"` (*optional*, default `false`): + If `true`, the series is scaled before plotting, which is useful for shape comparisons with model results. + +The time index of the additional data must match the time index of the model results. The time index column must be named one of the following `t`, `sp`, `rp`, `osc`, `op`. If this column is missing, an `OperationalProfile` will be created for the additional data (where the time index is inferred based on the row order to match the time structure of the model case), and a warning will be issued. + +## Example + +Assuming you have a CSV file with additional time series data located at `path/to/your/data.csv`, for example with content + +```csv +sp,val1,val2 +sp1,0.5,0.6 +sp2,0.6,0.7 +sp3,0.7,0.8 +sp4,0.8,0.9 +``` + +you can load it into the GUI as follows: + +```julia +using EnergyModelsGUI + +gui = GUI(case; additional_plots=[Dict("data"=>"path/to/your/data.csv", "normalize"=>true)]) +``` + +Adding a data frame directly is also possible, for example (assuming the time index is `sp1-t1`, `sp1-t2`, ..., `sp1-t10`): + +```julia +using EnergyModelsGUI, DataFrames + +t = "sp1-t" .* string.(1:10) +additional_data = DataFrame(t=t, series1=rand(10), series2=rand(10)) +gui = GUI(case; additional_plots=[ + Dict("data"=>"path/to/your/data.csv", "normalize"=>true), + Dict("data"=>additional_data), + ] +) +``` + +After loading the GUI the additional series are available in the **Available data** menu (make sure not to have any element selected), and can be selected for plotting alongside model results.