From 24ccf802d38538942a53d8163efb2621f2d94660 Mon Sep 17 00:00:00 2001 From: Pablo Botin Date: Mon, 27 Jul 2026 16:16:39 -0600 Subject: [PATCH] feat: add `size` keyword for figure dimensions (#77) Figure size was hardcoded at the single `CairoMakie.Figure` call site, and PlotlyLight set no size at all, so output dimensions could not be controlled from any plot function. Add a `size::Tuple{Int, Int}` keyword, honored on both the fresh-figure and in-place paths in both backends. CairoMakie resizes the figure, which `CairoMakie.save` then inherits; PlotlyLight sets `layout.width`/`height`. PlotlyLight is sized only when `size` is passed: its output is responsive to the containing element, and defaulting it to a fixed size would change how every existing HTML plot renders. The shared default lives in `DEFAULT_FIGURE_SIZE`, replacing a comment that misstated Makie's own default as 800x600 rather than 600x450. --- ext/plot_recipes.jl | 11 +++++---- ext/plotly_recipes.jl | 9 ++++++++ src/call_plots.jl | 10 +++++++++ src/definitions.jl | 3 +++ test/test_plot_creation.jl | 46 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 6 deletions(-) diff --git a/ext/plot_recipes.jl b/ext/plot_recipes.jl index 7b76f24..38e4da4 100644 --- a/ext/plot_recipes.jl +++ b/ext/plot_recipes.jl @@ -9,9 +9,7 @@ mutable struct CairoMakiePlot end function PowerGraphics._empty_plot(backend::PowerGraphics.CairoMakieBackend) - # 16:9 by default — the Makie 800x600 (4:3) default deforms time-series - # stack plots too much. - fig = CairoMakie.Figure(; size = (1280, 720)) + fig = CairoMakie.Figure(; size = PowerGraphics.DEFAULT_FIGURE_SIZE) ax = CairoMakie.Axis(fig[1, 1]) return CairoMakiePlot(fig, ax, 0, false) end @@ -32,6 +30,8 @@ function PowerGraphics._dataframe_plots_internal( label_fn = get(kwargs, :label_fn, PowerGraphics.label_short) linestyle = get(kwargs, :linestyle, :solid) linewidth = get(kwargs, :linewidth, 1) + # Not named `size`: that would shadow `Base.size`. + fig_size = get(kwargs, :size, nothing) time_interval = PowerGraphics.IS.convert_compound_period( length(time_range) * (time_range[2] - time_range[1]), @@ -39,9 +39,8 @@ function PowerGraphics._dataframe_plots_internal( interval = Dates.Millisecond(Dates.Hour(1)) / Dates.Millisecond(time_range[2] - time_range[1]) - if isnothing(plot) - plot = PowerGraphics._empty_plot(backend) - end + isnothing(plot) && (plot = PowerGraphics._empty_plot(backend)) + isnothing(fig_size) || CairoMakie.resize!(plot.figure, fig_size...) ndf = PowerGraphics.PA.no_datetime(variable) column_names = DataFrames.names(ndf) diff --git a/ext/plotly_recipes.jl b/ext/plotly_recipes.jl index 304f0a3..6b54c28 100644 --- a/ext/plotly_recipes.jl +++ b/ext/plotly_recipes.jl @@ -159,6 +159,15 @@ function PowerGraphics._dataframe_plots_internal( plot.layout.title.text = title plot.layout.barmode = stack ? "relative" : "group" + # Sized only on request, unlike CairoMakie: Plotly output is responsive to + # its container, and defaulting to a fixed size would change how every + # existing HTML plot renders. + fig_size = get(kwargs, :size, nothing) + if !isnothing(fig_size) + plot.layout.width = fig_size[1] + plot.layout.height = fig_size[2] + end + legend_position = get(kwargs, :legend_position, :right) legend_font_size = get(kwargs, :legend_font_size, nothing) diff --git a/src/call_plots.jl b/src/call_plots.jl index ace2451..30d709b 100644 --- a/src/call_plots.jl +++ b/src/call_plots.jl @@ -171,6 +171,7 @@ plot = plot_demand(res) - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed - `filter_func::Function = `[`PowerSystems.get_available`](@extref PowerSystems InfrastructureSystems.get_available-Tuple{RenewableDispatch}): filter components included in plot """ # ^ temporary workaround for https://github.com/Sienna-Platform/PowerSystems.jl/issues/1598 function plot_demand(result::Union{IS.Results, PSY.System}; kwargs...) @@ -286,6 +287,7 @@ instead of CairoMakie. - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed - `filter_func::Function = `[`PowerSystems.get_available`](@extref PowerSystems InfrastructureSystems.get_available-Tuple{RenewableDispatch}): filter components included in plot - `palette` : color palette from [`load_palette`](@ref) """ @@ -339,6 +341,7 @@ plot = plot_dataframe(df, time_range) - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed """ function plot_dataframe(df::DataFrames.DataFrame; kwargs...) return plot_dataframe!(_empty_plot(), PA.no_datetime(df), df.DateTime; kwargs...) @@ -413,6 +416,7 @@ If only the `DataFrame` is provided, it must have a column of `DateTime` values. - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed """ function plot_dataframe!(p, df::DataFrames.DataFrame; kwargs...) return _plot_dataframe!( @@ -483,6 +487,7 @@ Makes a plot from a `PowerAnalytics.PowerData` object, such as the result of - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed """ function plot_powerdata(powerdata::PA.PowerData; kwargs...) return plot_powerdata!(_empty_plot(), powerdata; kwargs...) @@ -549,6 +554,7 @@ variant renders with the PlotlyLight backend instead of CairoMakie. - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed """ function plot_powerdata!(p, powerdata::PA.PowerData; kwargs...) return _plot_powerdata!(p, powerdata, CairoMakieBackend(); kwargs...) @@ -586,6 +592,7 @@ Makes a plot from a results dictionary object - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed """ function plot_results(results::Dict{String, DataFrames.DataFrame}; kwargs...) return plot_powerdata!(_empty_plot(), PA.PowerData(results); kwargs...) @@ -623,6 +630,7 @@ Makes a plot from a results dictionary - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed """ function plot_results!(p, results::Dict{String, DataFrames.DataFrame}; kwargs...) return plot_powerdata!(p, PA.PowerData(results); kwargs...) @@ -674,6 +682,7 @@ plot = plot_fuel(res) - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed - `filter_func::Function = `[`PowerSystems.get_available`](@extref PowerSystems InfrastructureSystems.get_available-Tuple{RenewableDispatch}): filter components included in plot """ function plot_fuel(result::IS.Results; kwargs...) @@ -825,6 +834,7 @@ PlotlyLight backend instead of CairoMakie. - `label_fn::Function = label_short`: function applied to legend labels (typically the raw `Variable__Component` strings produced by PowerAnalytics). Built-in options: `label_short`, `label_component`, `label_variable`, `label_acronym`, `label_first_word`, `label_truncate(n)`. Note that when `combine_categories = true` (the default for `plot_powerdata`, `plot_results`, and `plot_fuel`), columns are aggregated to category names *before* `label_fn` runs — those names don't contain `__`, so the default `label_short` is a no-op. Pass `combine_categories = false` to see the effect of `label_fn` on the raw labels. - `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` - `legend_font_size::Number`: override the legend label font size +- `size::Tuple{Int, Int} = (1280, 720)`: figure size in pixels; PlotlyLight plots stay responsive to their container unless `size` is passed - `filter_func::Function = `[`PowerSystems.get_available`](@extref PowerSystems InfrastructureSystems.get_available-Tuple{RenewableDispatch}): filter components included in plot - `palette` : Color palette as from [`load_palette`](@ref). """ diff --git a/src/definitions.jl b/src/definitions.jl index 5913fc6..a47d1df 100644 --- a/src/definitions.jl +++ b/src/definitions.jl @@ -1,4 +1,7 @@ +# 16:9 by default — Makie's (600, 450) 4:3 default deforms time-series stack plots. +const DEFAULT_FIGURE_SIZE = (1280, 720) + # Color Definitions const DEFAULT_PALETTE_FILE = joinpath( dirname(dirname(pathof(PowerGraphics))), diff --git a/test/test_plot_creation.jl b/test/test_plot_creation.jl index 69cf878..d419d51 100644 --- a/test/test_plot_creation.jl +++ b/test/test_plot_creation.jl @@ -111,6 +111,39 @@ function test_plots(file_path::String; backend_pkg::String = "cairomakie") cleanup && rm(out_path; recursive = true) end + @testset "test $backend_pkg figure size" begin + df = gen_uc.data[:ActivePowerVariable__ThermalStandard] + + p = plot_dataframe_fn(df, gen_uc.time; set_display = set_display) + if backend_pkg == "cairomakie" + @test size(p.figure.scene) == PG.DEFAULT_FIGURE_SIZE + else + # `haskey`, not property access: EasyConfig `getproperty` inserts + # the key it is asked for, which would then be serialized. + @test !haskey(p.layout, :width) + @test !haskey(p.layout, :height) + end + + p = plot_dataframe_fn( + df, + gen_uc.time; + set_display = set_display, + size = (800, 400), + ) + if backend_pkg == "cairomakie" + @test size(p.figure.scene) == (800, 400) + else + @test (p.layout.width, p.layout.height) == (800, 400) + end + + plot_dataframe_fn!(p, df, gen_uc.time; set_display = set_display, size = (640, 480)) + if backend_pkg == "cairomakie" + @test size(p.figure.scene) == (640, 480) + else + @test (p.layout.width, p.layout.height) == (640, 480) + end + end + @testset "test $backend_pkg powerdata plot production" begin out_path = joinpath(file_path, backend_pkg * "_powerdata_plots") !isdir(out_path) && mkdir(out_path) @@ -267,6 +300,19 @@ function test_plots(file_path::String; backend_pkg::String = "cairomakie") plot_length = backend_pkg == "cairomakie" ? p.series_count : length(p.data) @test plot_length == 3 + # `size` has to survive the kwarg splat into the PowerAnalytics getters. + p = plot_demand_fn( + sys_with_ts; + set_display = set_display, + aggregate = "System", + size = (900, 500), + ) + if backend_pkg == "cairomakie" + @test size(p.figure.scene) == (900, 500) + else + @test (p.layout.width, p.layout.height) == (900, 500) + end + list = readdir(out_path) # PlotlyLight only supports HTML export, CairoMakie supports PNG file_ext = backend_pkg == "plotlylight" ? ".html" : ".png"