From bf331d4ee13fead85875188433b9abd648565a62 Mon Sep 17 00:00:00 2001 From: Pablo Botin Date: Mon, 27 Jul 2026 20:31:38 -0600 Subject: [PATCH] fix: make `combine_categories = false` plot per-component series (#111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `combine_categories = false` branch of `_plot_powerdata!` assigned the `PowerData`'s raw `Dict{Symbol, DataFrame}` and handed it to `_plot_dataframe!`, which takes a `DataFrame` — so a documented keyword raised a `MethodError` on `plot_powerdata`, `plot_results`, and their `!` and `_plotly` variants. Concatenate the per-category frames into the single wide frame the backends expect, dropping the duplicate `DateTime` columns. Component names recur across categories — a battery appears under both the storage charge and discharge categories, and renewable units under both dispatch and curtailment — so qualify each column as `Category__Component` using `PowerAnalytics.COMPONENT_NAME_DELIMITER`. That mirrors `metric_selector_to_string`, which is how PowerAnalytics names columns when `compute_all` combines frames, and it is what makes the documented `label_fn` behavior work: `label_component` and `label_variable` split on the same delimiter. Categories are visited in sorted order, since `Dict` iteration order is not stable and would otherwise shuffle series between calls. The docstrings claimed this keyword defaults to `false` when the code defaults to `true`; correct the docs rather than the long-standing behavior. --- src/call_plots.jl | 32 +++++++++++++++++++++++++++----- test/test_plot_creation.jl | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/call_plots.jl b/src/call_plots.jl index ace2451..17863c1 100644 --- a/src/call_plots.jl +++ b/src/call_plots.jl @@ -469,7 +469,7 @@ Makes a plot from a `PowerAnalytics.PowerData` object, such as the result of - `powerdata::PowerAnalytics.PowerData`: The `PowerData` object to be plotted # Accepted Key Words -- `combine_categories::Bool = false` : plot category values or each value in a category +- `combine_categories::Bool = true` : plot one series per aggregated category (`true`) or one series per component, named `Category__Component` (`false`) - `curtailment::Bool`: plot the curtailment with the variable - `set_display::Bool = true`: set to false to prevent the plots from displaying - `save::String = "file_path"`: set a file path to save the plots @@ -495,6 +495,28 @@ end return plot_powerdata_plotly!(_empty_plot_plotly(), powerdata; kwargs...) end +""" +Concatenate the per-category `DataFrame`s of a `PowerData` into the single wide +`DataFrame` that the plotting backends take, qualifying every column as +`Category__Component`. The qualification follows PowerAnalytics' +`metric_selector_to_string` convention and is what keeps the names unique: the +same component name recurs across categories (e.g. a battery under both +`ActivePowerInVariable__EnergyReservoirStorage` and `ActivePowerOutVariable__…`). +Categories are visited in sorted order because `Dict` iteration order is not +stable and would otherwise shuffle series and legend entries between calls. +""" +function _flatten_categories(data::Dict{Symbol, DataFrames.DataFrame}) + dfs = DataFrames.DataFrame[] + for key in sort(collect(keys(data))) + df = PA.no_datetime(data[key]) + isempty(df) && continue + DataFrames.rename!(c -> string(key) * PA.COMPONENT_NAME_DELIMITER * c, df) + push!(dfs, df) + end + isempty(dfs) && return DataFrames.DataFrame() + return DataFrames.hcat(dfs...) +end + function _plot_powerdata!(p, powerdata::PA.PowerData, backend; kwargs...) title = get(kwargs, :title, "") set_display = get(kwargs, :set_display, true) @@ -505,7 +527,7 @@ function _plot_powerdata!(p, powerdata::PA.PowerData, backend; kwargs...) names = get(kwargs, :names, nothing) data = PA.combine_categories(powerdata.data; names = names, aggregate = aggregate) else - data = powerdata.data + data = _flatten_categories(powerdata.data) end kwargs = Dict{Symbol, Any}((k, v) for (k, v) in kwargs if k ∉ [:title, :save, :set_display]) @@ -535,7 +557,7 @@ variant renders with the PlotlyLight backend instead of CairoMakie. - `powerdata::PowerAnalytics.PowerData`: The `PowerData` object to be plotted # Accepted Key Words -- `combine_categories::Bool = false` : plot category values or each value in a category +- `combine_categories::Bool = true` : plot one series per aggregated category (`true`) or one series per component, named `Category__Component` (`false`) - `curtailment::Bool`: plot the curtailment with the variable - `set_display::Bool = true`: set to false to prevent the plots from displaying - `save::String = "file_path"`: set a file path to save the plots @@ -572,7 +594,7 @@ Makes a plot from a results dictionary object - `results::Dict{String, DataFrame`: The results to be plotted # Accepted Key Words -- `combine_categories::Bool = false` : plot category values or each value in a category +- `combine_categories::Bool = true` : plot one series per aggregated category (`true`) or one series per component, named `Category__Component` (`false`) - `curtailment::Bool`: plot the curtailment with the variable - `set_display::Bool = true`: set to false to prevent the plots from displaying - `save::String = "file_path"`: set a file path to save the plots @@ -609,7 +631,7 @@ Makes a plot from a results dictionary - `results::Dict{String, DataFrame}`: The results to be plotted # Accepted Key Words -- `combine_categories::Bool = false` : plot category values or each value in a category +- `combine_categories::Bool = true` : plot one series per aggregated category (`true`) or one series per component, named `Category__Component` (`false`) - `curtailment::Bool`: plot the curtailment with the variable - `set_display::Bool = true`: set to false to prevent the plots from displaying - `save::String = "file_path"`: set a file path to save the plots diff --git a/test/test_plot_creation.jl b/test/test_plot_creation.jl index 69cf878..dbd4b8d 100644 --- a/test/test_plot_creation.jl +++ b/test/test_plot_creation.jl @@ -148,6 +148,32 @@ function test_plots(file_path::String; backend_pkg::String = "cairomakie") stack = true, ) + # `combine_categories = false` (issue #111) plots one series per component + # column, qualified as `Category__Component`. + flat_names = names(PG._flatten_categories(gen_uc.data)) + expected_series = sum(size(no_datetime(df), 2) for df in values(gen_uc.data)) + @test length(flat_names) == expected_series + @test "ActivePowerVariable__ThermalStandard__Solitude" in flat_names + # `test_batt` appears in both storage categories: it must stay two series + @test filter(n -> endswith(n, "__test_batt"), flat_names) == [ + "ActivePowerInVariable__EnergyReservoirStorage__test_batt", + "ActivePowerOutVariable__EnergyReservoirStorage__test_batt", + ] + p = plot_powerdata_fn( + gen_uc; + set_display = set_display, + title = "pg_data_uncombined", + save = out_path, + combine_categories = false, + ) + plot_length = backend_pkg == "cairomakie" ? p.series_count : length(p.data) + @test plot_length == expected_series + + # combining still collapses each category to a single series + p = plot_powerdata_fn(gen_uc; set_display = set_display) + plot_length = backend_pkg == "cairomakie" ? p.series_count : length(p.data) + @test plot_length == length(gen_uc.data) + list = readdir(out_path) # PlotlyLight only supports HTML export, CairoMakie supports PNG file_ext = backend_pkg == "plotlylight" ? ".html" : ".png" @@ -156,6 +182,7 @@ function test_plots(file_path::String; backend_pkg::String = "cairomakie") "pg_data_stack$file_ext", "pg_data_bar$file_ext", "pg_data_bar_stack$file_ext", + "pg_data_uncombined$file_ext", ] # expected results not created @test isempty(setdiff(expected_files, list))