Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 updates (2026-06-07)

* Added functionality for adding additional plots.

## Version 0.7.2 (2026-05-20)

### Adjustments
Expand Down
1 change: 1 addition & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions docs/src/how-to/add_additional_plots.md
Original file line number Diff line number Diff line change
@@ -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.
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
167 changes: 146 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,93 @@ 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

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
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)

rename_time_column!(df)
return df
Comment thread
Zetison marked this conversation as resolved.
end

"""
Expand Down Expand Up @@ -738,22 +825,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 +873,17 @@ 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, 𝒯)
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 +949,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 +978,38 @@ 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_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
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
Loading
Loading