Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Release notes

## Unversioned (2026-06-07)

* Added functionality for adding additional plots.

## Version 0.7.2 (2026-05-20)

### Adjustments
Expand Down
27 changes: 27 additions & 0 deletions src/setup_GUI.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
132 changes: 111 additions & 21 deletions src/utils_GUI/GUI_utils.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -210,14 +210,14 @@ 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
)

# 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]
Expand Down Expand Up @@ -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
Comment thread
Zetison marked this conversation as resolved.
@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
Comment thread
Zetison marked this conversation as resolved.
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
Comment thread
Zetison marked this conversation as resolved.
end

"""
Expand Down Expand Up @@ -738,22 +814,28 @@ 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)

items = collect(menu.options[])

# 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,
)
Expand All @@ -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)
Expand Down Expand Up @@ -845,26 +933,15 @@ 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))

Threads.@threads for i ∈ eachindex(files)
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
Expand All @@ -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
Comment thread
Zetison marked this conversation as resolved.
end

"""
sub_plots_empty(component::EnergySystemDesign)

Expand Down
8 changes: 7 additions & 1 deletion src/utils_GUI/results_axis_utils.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test/Project.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 2 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ using EnergyModelsGUI
using Test
using YAML
using Logging
using CSV
using DataFrames

const TEST_ATOL = 1e-6
const EMGUI = EnergyModelsGUI
Expand Down
69 changes: 68 additions & 1 deletion test/test_results_IO.jl
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Loading