From 00c1cf5d56a703fb8c3625b4f841b2536bc1270c Mon Sep 17 00:00:00 2001 From: Pablo Botin Date: Tue, 28 Jul 2026 11:06:38 -0600 Subject: [PATCH] fix: show balance slacks owned by buses and areas (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack categories were read through `PA.Metrics.calc_system_slack_up` and its down counterpart, which resolve to `PowerAnalytics.read_system_result`. That function hardcodes `make_key(entry, PSY.System)` and asserts the result has a single data column. PowerSimulations attaches balance slacks to four different component types depending on the network formulation: `System` for copper-plate and PTDF, `Area` for the area-balance models, and `ACBus` for the PowerModels formulations (with `"P"`/`"Q"` meta in the AC case). Only the first was ever found, so node- and area-level slacks silently never appeared — and would have tripped the single-column assertion had they been read at all. Discover the slack keys present in the results by entry type instead, regardless of which component owns them, and sum across the per-bus or per-area columns into one series per direction. The `System` case has a single column, so its values are unchanged. Only the active-power `"P"` slack is included for AC formulations; the fuel stack plots active power, so reactive slack does not belong on it. This works around the limitation rather than fixing it: `read_system_result` is still `System`-only for every other PowerAnalytics consumer. --- src/call_plots.jl | 72 ++++++++++++++-------- test/test_fuel_stack_behavior.jl | 101 +++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 25 deletions(-) diff --git a/src/call_plots.jl b/src/call_plots.jl index 2ffd87c..61bb89b 100644 --- a/src/call_plots.jl +++ b/src/call_plots.jl @@ -1006,7 +1006,7 @@ plot = plot_fuel(res) # Accepted Key Words - `generator_mapping_file` = "file_path" : file path to yaml defining generator category by fuel and primemover -- `slacks::Bool = true` : display slack variables +- `slacks::Bool = true` : display the system balance slack variables as "Unserved Energy" and "Over Generation". Nodal and area formulations attach one slack per bus/area; those are summed into a single series per direction. Reactive slacks (the `"Q"` meta of full AC formulations) are excluded, since this is an active-power plot. - `load::Bool = true` : display load line - `curtailment::Bool = true`: To plot the curtailment in the stack plot - `storage::Bool = true`: include storage components (as " In"/" Out" traces) @@ -1041,8 +1041,8 @@ _report_plot_fuel(backend::PlottingBackend, result; kwargs...) = # contributing component must vanish instead of producing all-zero columns. # TODO upstream: PowerAnalytics has no built-in metrics for these entry types -# (it should export `calc_system_slack_down` and forecast metrics for the -# storage/source time-series parameters); build them locally until then. See +# (it should export forecast metrics for the storage/source time-series +# parameters); build them locally until then. See # https://github.com/PabloBotin/PowerAnalytics.jl/issues/4. const _CALC_POWER_OUTPUT = PA.make_component_metric_from_entry("PowerOutput", PA.PSI.PowerOutput) @@ -1054,8 +1054,6 @@ const _CALC_ACTIVE_POWER_OUT_FORECAST = PA.make_component_metric_from_entry( "ActivePowerOutForecast", PA.PSI.ActivePowerOutTimeSeriesParameter, ) -const _CALC_SYSTEM_SLACK_DOWN = - PA.make_system_metric_from_entry("SystemSlackDown", PA.PSI.SystemBalanceSlackDown) # Fallback chain for generators: dispatch power if the component was modeled # with a variable, otherwise its forecast parameter (e.g. `FixedOutput` @@ -1081,12 +1079,10 @@ const _SOURCE_IN_METRICS = ((PA.Metrics.calc_active_power_in, -1.0), (_CALC_ACTIVE_POWER_IN_FORECAST, 1.0)) const _SOURCE_OUT_METRICS = ((PA.Metrics.calc_active_power_out, 1.0), (_CALC_ACTIVE_POWER_OUT_FORECAST, 1.0)) -# System balance slacks and their fixed display names, taken from -# `PA.BALANCE_SLACKVARS` so the naming has a single source of truth. -const _SLACK_METRICS = ( - (PA.BALANCE_SLACKVARS[PA.PSI.SystemBalanceSlackUp], PA.Metrics.calc_system_slack_up), - (PA.BALANCE_SLACKVARS[PA.PSI.SystemBalanceSlackDown], _CALC_SYSTEM_SLACK_DOWN), -) +# System balance slack entry types, in the display order they stack. The names +# come from `PA.BALANCE_SLACKVARS` so the naming has a single source of truth. +const _SLACK_ENTRY_TYPES = + (PA.PSI.SystemBalanceSlackUp, PA.PSI.SystemBalanceSlackDown) # Catch-all category for components matched by no rule in the generator # mapping; matches the `Other` key in the default mapping and the color @@ -1219,21 +1215,47 @@ function _accumulate_curtailment!( return acc end +""" +Every variable key holding a slack of entry type `T`, whatever component type +owns it. PowerSimulations attaches the balance slacks to `PSY.System` under +`CopperPlatePowerModel`/`PTDFPowerModel`, to `PSY.Area` under the area models, +and to `PSY.ACBus` — one column per bus — under the power-flow models, so +looking only for the `PSY.System` variant (as PowerAnalytics' +`calc_system_slack_up` does) makes nodal and area slacks disappear. Full AC +models split the bus slacks into `"P"` and `"Q"` metas; only `"P"` is returned, +because the fuel stack is an active-power plot. +""" +function _slack_keys(result::IS.Results, ::Type{T}) where {T <: PA.PSI.VariableType} + return filter(PA.PSI.list_variable_keys(result)) do key + PA.PSI.get_entry_type(key) === T && key.meta != "Q" + end +end + function _accumulate_slacks!(acc::_FuelAccumulator, result::IS.Results) - for (name, metric) in _SLACK_METRICS - df = try - PA.compute(metric, result) - catch e - # Results without slack variables simply skip the category. - _is_missing_result_error(e) || rethrow() - continue - end - _add_fuel_values!( - acc, - name, - Vector{Dates.DateTime}(PA.get_time_vec(df)), - Vector{Float64}(PA.get_data_vec(df)), + for entry in _SLACK_ENTRY_TYPES + slack_keys = _slack_keys(result, entry) + # Results without slack variables simply skip the category. + isempty(slack_keys) && continue + name = PA.BALANCE_SLACKVARS[entry] + dfs = PA.PSI.read_results_with_keys( + result, + slack_keys; + table_format = IS.TableFormat.WIDE, ) + for df in values(dfs) + # One column per bus/area (exactly one for the `PSY.System` case); + # the whole direction stacks as a single series. + vals = zeros(Float64, DataFrames.nrow(df)) + for col in DataFrames.names(df, DataFrames.Not(PA.DATETIME_COL)) + vals .+= df[!, col] + end + _add_fuel_values!( + acc, + name, + Vector{Dates.DateTime}(df[!, PA.DATETIME_COL]), + vals, + ) + end end return acc end @@ -1623,7 +1645,7 @@ renderer. # Accepted Key Words - `generator_mapping_file` = "file_path" : file path to yaml defining generator category by fuel and primemover -- `slacks::Bool = true` : display slack variables +- `slacks::Bool = true` : display the system balance slack variables as "Unserved Energy" and "Over Generation". Nodal and area formulations attach one slack per bus/area; those are summed into a single series per direction. Reactive slacks (the `"Q"` meta of full AC formulations) are excluded, since this is an active-power plot. - `load::Bool = true` : display load line - `curtailment::Bool = true`: To plot the curtailment in the stack plot - `storage::Bool = true`: include storage components (as " In"/" Out" traces) diff --git a/test/test_fuel_stack_behavior.jl b/test/test_fuel_stack_behavior.jl index 09da634..4f03409 100644 --- a/test/test_fuel_stack_behavior.jl +++ b/test/test_fuel_stack_behavior.jl @@ -320,3 +320,104 @@ end @info("removing test files") rm(save_root; recursive = true) end + +# --- Balance slacks owned by something other than `PSY.System` (issue #94) --- + +# PowerSimulations attaches the balance slacks to the component type implied by +# the network formulation, so a nodal formulation stores one slack column per +# `ACBus`. The load is scaled up so the slacks are actually nonzero and the +# aggregation assertions have teeth. +function run_nodal_slack_model() + sys = deepcopy(PSB.build_system(PSB.PSITestSystems, "c_sys5_uc")) + for load in get_components(PowerLoad, sys) + set_max_active_power!(load, 3 * get_max_active_power(load)) + end + template = ProblemTemplate(NetworkModel(DCPPowerModel; use_slacks = true)) + set_device_model!(template, ThermalStandard, ThermalBasicUnitCommitment) + set_device_model!(template, PowerLoad, StaticPowerLoad) + set_device_model!(template, Line, StaticBranch) + model = DecisionModel( + template, + sys; + optimizer = optimizer_with_attributes(HiGHS.Optimizer), + horizon = Hour(6), + ) + build!(model; output_dir = mktempdir()) + solve!(model) + return OptimizationProblemResults(model) +end + +@testset "bus-level balance slacks appear in the fuel stack" begin + res = run_nodal_slack_model() + + # The regression of #94: the slacks are keyed on `ACBus`, not `System`, so + # looking only for the `System` variant made them vanish from the plot. + @test Set( + PSI.encode_key_as_string(k) for k in PSI.list_variable_keys(res) if + PSI.get_entry_type(k) in keys(PA.BALANCE_SLACKVARS) + ) == Set(["SystemBalanceSlackUp__ACBus", "SystemBalanceSlackDown__ACBus"]) + + p = plot_fuel_plotly(res; set_display = false, stack = true, auto_units = false) + trace_names = [t.name for t in p.data] + @test "Unserved Energy" in trace_names + @test "Over Generation" in trace_names + + # Each direction is the row-wise sum over the per-bus columns. + for (name, entry) in ( + ("Unserved Energy", PSI.SystemBalanceSlackUp), + ("Over Generation", PSI.SystemBalanceSlackDown), + ) + entry_keys = + [k for k in PSI.list_variable_keys(res) if PSI.get_entry_type(k) == entry] + df = only( + values( + PSI.read_results_with_keys( + res, + entry_keys; + table_format = IS.TableFormat.WIDE, + ), + ), + ) + # More than DateTime plus one column, i.e. genuinely nodal. + @test ncol(df) > 2 + expected = vec(sum(Matrix(no_datetime(df)); dims = 2)) + @test collect(only([t for t in p.data if t.name == name]).y) ≈ expected + end + # The scaled-up load leaves energy unserved, so the sum above is not + # trivially zero. + @test sum(only([t for t in p.data if t.name == "Unserved Energy"]).y) > 0 + + p_cm = plot_fuel(res; set_display = false, stack = true) + @test p_cm.series_count == length(p.data) +end + +@testset "system-level balance slacks are unchanged" begin + (_, results_ed) = run_test_sim(TEST_RESULT_DIR, TEST_SIM_NAME) + + # The ED template is CopperPlate with `use_slacks = true`, so the slacks are + # keyed on `PSY.System` — the only case PowerAnalytics' own system metrics + # handle. Those values are the pre-fix reference and must be reproduced. + calc_slack_down = + PA.make_system_metric_from_entry("SystemSlackDown", PSI.SystemBalanceSlackDown) + p = plot_fuel_plotly(results_ed; set_display = false, stack = true, auto_units = false) + for (name, metric) in ( + ("Unserved Energy", PA.Metrics.calc_system_slack_up), + ("Over Generation", calc_slack_down), + ) + expected = PA.get_data_vec(PA.compute(metric, results_ed)) + @test collect(only([t for t in p.data if t.name == name]).y) ≈ expected + end +end + +@testset "results without balance slacks skip the slack categories" begin + (results_uc, _) = run_test_sim(TEST_RESULT_DIR, TEST_SIM_NAME) + + # The UC template runs with `use_slacks = false`: no slack variable is + # stored, so the categories must be absent rather than raising. + @test !any( + PSI.get_entry_type(k) in keys(PA.BALANCE_SLACKVARS) for + k in PSI.list_variable_keys(results_uc) + ) + p = plot_fuel_plotly(results_uc; set_display = false, stack = true) + @test isdisjoint([t.name for t in p.data], ["Unserved Energy", "Over Generation"]) +end