From 1b9e0a5a1cdc4cf15907aaef1195987dfc3603f3 Mon Sep 17 00:00:00 2001 From: Pablo Botin Date: Tue, 28 Jul 2026 08:05:10 -0600 Subject: [PATCH] feat: add duration curve and histogram plot types (#99) Neither of the plot types requested in #99 existed. Both are data transforms rather than new drawing primitives, so they are computed in the backend-agnostic core and routed through the existing `_plot_dataframe!` path. No functions were added to the extension contract and no drawing code is duplicated, which keeps the two backends consistent by construction. `plot_duration_curve` sorts each column descending and plots it against percent of time, or against elapsed hours with `x_axis = :hours`. `plot_histogram` bins every column over one common edge range so overlaid series stay comparable, defaulting the bin count to Sturges' rule and accepting an explicit `bins`. Supporting this required the recipes to accept a non-temporal x axis. Both now dispatch on whether the axis is a `TimeType`: the temporal path is unchanged, while a numeric axis uses its values directly, takes its label from `x_label`, ticks automatically, and draws bars per row rather than integrating over time. --- ext/plot_recipes.jl | 58 ++++-- ext/plotly_recipes.jl | 45 +++- src/PowerGraphics.jl | 4 + src/call_plots.jl | 413 ++++++++++++++++++++++++++++++++++++- test/test_plot_creation.jl | 96 +++++++++ 5 files changed, 586 insertions(+), 30 deletions(-) diff --git a/ext/plot_recipes.jl b/ext/plot_recipes.jl index 7b76f24..f7be723 100644 --- a/ext/plot_recipes.jl +++ b/ext/plot_recipes.jl @@ -33,11 +33,12 @@ function PowerGraphics._dataframe_plots_internal( linestyle = get(kwargs, :linestyle, :solid) linewidth = get(kwargs, :linewidth, 1) - time_interval = PowerGraphics.IS.convert_compound_period( - length(time_range) * (time_range[2] - time_range[1]), - ) - interval = - Dates.Millisecond(Dates.Hour(1)) / Dates.Millisecond(time_range[2] - time_range[1]) + # `time_range` is a `DateTime` axis for every results-driven plot, but the + # transform plots (duration curve, histogram) pass a numeric axis instead. + # These three helpers dispatch on that so the drawing code below stays shared. + temporal = PowerGraphics._is_temporal(time_range) + x_label = PowerGraphics._x_axis_label(time_range, get(kwargs, :x_label, nothing)) + interval = PowerGraphics._x_interval(time_range) if isnothing(plot) plot = PowerGraphics._empty_plot(backend) @@ -64,7 +65,7 @@ function PowerGraphics._dataframe_plots_internal( # CairoMakie.band doesn't allow for DateTime axes. Every plot now gets # float axes instead so plots can be layered on the same Axis. - time_range_float = Dates.datetime2unix.(time_range) + x_float = PowerGraphics._x_values(time_range) data = Matrix(ndf) power_scale = get(kwargs, :power_scale, 1.0) @@ -73,7 +74,7 @@ function PowerGraphics._dataframe_plots_internal( end labels = [label_fn(label) for label in column_names] - plot.axis.xlabel = "$time_interval" + plot.axis.xlabel = x_label plot.axis.ylabel = get(kwargs, :y_label, "") if title != " " # Only set title if not default plot.axis.title = title @@ -85,7 +86,23 @@ function PowerGraphics._dataframe_plots_internal( # manually with PolyElement below. bar_legend_entries = nothing - if bar + if bar && !temporal + # A numeric x axis carries its own bar positions (histogram bin centers), + # so there is nothing to integrate over time — each row is already a bar. + # Series are overlaid with transparency so distributions stay comparable. + bar_width = length(x_float) > 1 ? x_float[2] - x_float[1] : 1.0 + for ix in 1:length(labels) + CairoMakie.barplot!( + plot.axis, + x_float, + data[:, ix]; + color = (seriescolor[ix], 0.6), + label = string(labels[ix]), + width = bar_width, + ) + end + plot.axis.xgridvisible = false + elseif bar plot_data = sum(data; dims = 1) ./ interval if stack @@ -148,7 +165,7 @@ function PowerGraphics._dataframe_plots_internal( if stair CairoMakie.stairs!( plot.axis, - time_range_float, + x_float, outer; color = color, label = string(labels[ix]), @@ -158,7 +175,7 @@ function PowerGraphics._dataframe_plots_internal( ) CairoMakie.band!( plot.axis, - time_range_float, + x_float, lo, up; color = (color, 0.3), @@ -171,7 +188,7 @@ function PowerGraphics._dataframe_plots_internal( # the stack. CairoMakie.band!( plot.axis, - time_range_float, + x_float, lo, up; color = (color, 0.7), @@ -191,7 +208,7 @@ function PowerGraphics._dataframe_plots_internal( if stair CairoMakie.stairs!( plot.axis, - time_range_float, + x_float, outer; color = color, label = string(labels[ix]), @@ -202,7 +219,7 @@ function PowerGraphics._dataframe_plots_internal( else CairoMakie.lines!( plot.axis, - time_range_float, + x_float, outer; color = color, label = string(labels[ix]), @@ -217,7 +234,7 @@ function PowerGraphics._dataframe_plots_internal( if stair CairoMakie.stairs!( plot.axis, - time_range_float, + x_float, data[:, ix]; color = color, label = string(labels[ix]), @@ -228,7 +245,7 @@ function PowerGraphics._dataframe_plots_internal( else CairoMakie.lines!( plot.axis, - time_range_float, + x_float, data[:, ix]; color = color, label = string(labels[ix]), @@ -239,9 +256,14 @@ function PowerGraphics._dataframe_plots_internal( end end - tick_positions = [time_range_float[1], last(time_range_float)] - tick_labels = string.([time_range[1], last(time_range)]) - plot.axis.xticks = (tick_positions, tick_labels) + # A DateTime axis is drawn as unix seconds, which auto-ticks into + # meaningless numbers — label the endpoints instead. A numeric axis + # already ticks sensibly on its own. + if temporal + tick_positions = [x_float[1], last(x_float)] + tick_labels = string.([time_range[1], last(time_range)]) + plot.axis.xticks = (tick_positions, tick_labels) + end end CairoMakie.reset_limits!(plot.axis) diff --git a/ext/plotly_recipes.jl b/ext/plotly_recipes.jl index 304f0a3..e37132a 100644 --- a/ext/plotly_recipes.jl +++ b/ext/plotly_recipes.jl @@ -39,11 +39,13 @@ function PowerGraphics._dataframe_plots_internal( )[(plot_length + 1):end], ) - time_interval = PowerGraphics.IS.convert_compound_period( - length(time_range) * (time_range[2] - time_range[1]), - ) - interval = - Dates.Millisecond(Dates.Hour(1)) / Dates.Millisecond(time_range[2] - time_range[1]) + # `time_range` is a `DateTime` axis for every results-driven plot, but the + # transform plots (duration curve, histogram) pass a numeric axis instead. + # These helpers dispatch on that so the trace code below stays shared; + # Plotly consumes both element types as `x` directly. + temporal = PowerGraphics._is_temporal(time_range) + x_label = PowerGraphics._x_axis_label(time_range, get(kwargs, :x_label, nothing)) + interval = PowerGraphics._x_interval(time_range) if isempty(variable) @warn "Plot dataframe empty: skipping plot creation" @@ -60,7 +62,23 @@ function PowerGraphics._dataframe_plots_internal( line_shape = get(kwargs, :stair, false) ? "hv" : "linear" line_dash = get(kwargs, :line_dash, "solid") - if bar + if bar && !temporal + # A numeric x axis carries its own bar positions (histogram bin centers), + # so there is nothing to integrate over time — each row is already a bar. + for ix = 1:length(names) + plot( + PlotlyLight.Config(; + type = "bar", + x = time_range, + y = plot_data[:, ix], + marker = PlotlyLight.Config(; color = seriescolor[ix]), + name = names[ix], + opacity = 0.6, + showlegend = true, + ), + ) + end + elseif bar plot_data = sum(plot_data; dims = 1) ./ interval if nofill plot_data = [plot_data; plot_data] @@ -154,10 +172,19 @@ function PowerGraphics._dataframe_plots_internal( plot.layout.yaxis.showticklabels = true plot.layout.yaxis.rangemode = "tozero" plot.layout.yaxis.title.text = y_label - plot.layout.xaxis.showticklabels = !bar - plot.layout.xaxis.title.text = string(time_interval) + # Time-axis bar plots collapse to one bar per series, so their tick labels are + # redundant with the legend; a numeric axis needs its ticks. + plot.layout.xaxis.showticklabels = !(bar && temporal) + plot.layout.xaxis.title.text = x_label plot.layout.title.text = title - plot.layout.barmode = stack ? "relative" : "group" + if stack + plot.layout.barmode = "relative" + elseif bar && !temporal + # Overlay rather than dodge so histogram series remain aligned on shared bins. + plot.layout.barmode = "overlay" + else + plot.layout.barmode = "group" + end legend_position = get(kwargs, :legend_position, :right) legend_font_size = get(kwargs, :legend_font_size, nothing) diff --git a/src/PowerGraphics.jl b/src/PowerGraphics.jl index 075bb83..5c9cc46 100644 --- a/src/PowerGraphics.jl +++ b/src/PowerGraphics.jl @@ -7,11 +7,15 @@ export plot_dataframe, plot_dataframe_plotly export plot_powerdata, plot_powerdata_plotly export plot_results, plot_results_plotly export plot_fuel, plot_fuel_plotly +export plot_duration_curve, plot_duration_curve_plotly +export plot_histogram, plot_histogram_plotly export plot_demand!, plot_demand_plotly! export plot_dataframe!, plot_dataframe_plotly! export plot_powerdata!, plot_powerdata_plotly! export plot_results!, plot_results_plotly! export plot_fuel!, plot_fuel_plotly! +export plot_duration_curve!, plot_duration_curve_plotly! +export plot_histogram!, plot_histogram_plotly! export report export save_plot export label_component, label_variable, label_acronym, label_first_word diff --git a/src/call_plots.jl b/src/call_plots.jl index ace2451..0ab6f79 100644 --- a/src/call_plots.jl +++ b/src/call_plots.jl @@ -15,6 +15,39 @@ end _display_plot(::CairoMakieBackend, p) = display(p.figure) _display_plot(::PlotlyLightBackend, p) = display(p) +################################### X AXIS ################################# + +# Plot x axes are normally `DateTime`, but the transform plots (duration curve, +# histogram) hand the recipes a plain numeric axis instead. Dispatching on the +# axis element type lets both backends share one code path while leaving the +# temporal behavior untouched. +_is_temporal(::AbstractVector{<:Dates.TimeType}) = true +_is_temporal(::AbstractVector) = false + +_time_vector(time_range::DataFrames.DataFrame) = time_range[:, 1] +_time_vector(time_range) = collect(time_range) + +# A temporal axis labels itself with the span it covers; a numeric axis has no +# such span and relies on the `x_label` kwarg. +function _x_axis_label(time_range::AbstractVector{<:Dates.TimeType}, x_label) + span = IS.convert_compound_period(length(time_range) * (time_range[2] - time_range[1])) + return something(x_label, "$span") +end +_x_axis_label(::AbstractVector, x_label) = something(x_label, "") + +# Bar plots over time report energy, so per-timestep values are divided by the +# number of samples per hour. Off a time axis there is nothing to normalize by. +function _x_interval(time_range::AbstractVector{<:Dates.TimeType}) + return Dates.Millisecond(Dates.Hour(1)) / + Dates.Millisecond(time_range[2] - time_range[1]) +end +_x_interval(::AbstractVector) = 1.0 + +# CairoMakie needs float axes throughout (`band` rejects `DateTime`), so a +# temporal axis is converted to unix seconds. +_x_values(time_range::AbstractVector{<:Dates.TimeType}) = Dates.datetime2unix.(time_range) +_x_values(time_range::AbstractVector) = float.(time_range) + # Translation table for the user-facing `aggregate::String` kwarg of # `plot_demand` to the typed `aggregation::Type` kwarg expected by # `PowerAnalytics.get_load_data(::PSY.System; aggregation = …)`. The @@ -377,9 +410,13 @@ function _plot_dataframe!( backend; kwargs..., ) - tr = - typeof(time_range) == DataFrames.DataFrame ? time_range[:, 1] : collect(time_range) - return _dataframe_plots_internal(p, variable, tr, backend; kwargs...) + return _dataframe_plots_internal( + p, + variable, + _time_vector(time_range), + backend; + kwargs..., + ) end """ @@ -456,6 +493,376 @@ function plot_dataframe_plotly!( return _plot_dataframe!(p, variable, time_range, PlotlyLightBackend(); kwargs...) end +################################# Duration Curve ############################## + +# Elapsed hours since the first sample. A non-temporal axis has no clock to read +# from, so the sample index stands in for it. +function _elapsed_hours(time_range::AbstractVector{<:Dates.TimeType}) + t0 = first(time_range) + return [Dates.value(Dates.Millisecond(t - t0)) / 3.6e6 for t in time_range] +end +_elapsed_hours(time_range::AbstractVector) = collect(0.0:(length(time_range) - 1)) + +""" +X values and default x label for a duration curve, given the `x_axis` mode and +the time axis the data was sampled on. +""" +function _duration_curve_x(x_axis::Symbol, time_range::AbstractVector) + if x_axis === :percent + # `range` rejects `length = 1` between distinct endpoints, so a degenerate + # axis (a single sample, or none) gets its percentages directly. + n = length(time_range) + percent = n > 1 ? collect(range(0.0, 100.0; length = n)) : zeros(n) + return (percent, "Percent of time") + elseif x_axis === :hours + return (_elapsed_hours(time_range), "Hours") + else + throw( + ArgumentError( + "Unknown `x_axis` value $(repr(x_axis)). Valid options: :percent, :hours.", + ), + ) + end +end + +""" + plot_duration_curve(df) + plot_duration_curve(df, time_range) + +Plots a duration curve from a [`DataFrames.DataFrame`](@extref): each column is sorted +descending on its own and drawn against the fraction of time (or the number of hours) +its value is met or exceeded. + +# Arguments + +- `df::DataFrames.DataFrame`: `DataFrame` where each row represents a time period and each column represents a trace. +If only the `DataFrame` is provided, it must have a column of `DateTime` values. +- `time_range::Union{DataFrames.DataFrame, Array, StepRange}`: The time periods of the data + +# Example + +```julia +var_name = :ActivePowerVariable__ThermalStandard +df = PowerSimulations.read_realized_variable(results, var_name) +plot = plot_duration_curve(df; x_axis = :hours) +``` + +# Accepted Key Words +- `x_axis::Symbol = :percent`: `:percent` for 0–100% of the time span, or `:hours` for elapsed hours +- `x_label::String`: override the x-axis label (defaults to `"Percent of time"` or `"Hours"`) +- `y_label::String`: label for the y axis +- `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 +- `format::String = "png"`: file extension for saved plots. CairoMakie supports `"png"`, `"pdf"`, `"svg"`. PlotlyLight only supports `"html"` (other values are written as `.html` with a warning). +- `seriescolor::Array`: Set different colors for the plots +- `palette` : color palette from [`load_palette`](@ref) +- `title::String = "Title"`: Set a title for the plots +- `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)`. +- `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` +- `legend_font_size::Number`: override the legend label font size +""" +function plot_duration_curve(df::DataFrames.DataFrame; kwargs...) + return plot_duration_curve!(_empty_plot(), PA.no_datetime(df), df.DateTime; kwargs...) +end + +function plot_duration_curve( + df::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}; + kwargs..., +) + return plot_duration_curve!(_empty_plot(), df, time_range; kwargs...) +end + +@doc (@doc plot_duration_curve) function plot_duration_curve_plotly( + df::DataFrames.DataFrame; + kwargs..., +) + return plot_duration_curve_plotly!( + _empty_plot_plotly(), + PA.no_datetime(df), + df.DateTime; + kwargs..., + ) +end + +function plot_duration_curve_plotly( + df::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}; + kwargs..., +) + return plot_duration_curve_plotly!(_empty_plot_plotly(), df, time_range; kwargs...) +end + +function _plot_duration_curve!( + p, + variable::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}, + backend; + kwargs..., +) + ndf = PA.no_datetime(variable) + # Each column is ranked independently: a duration curve answers "how often is + # *this* series above a level", not "what did the system look like at time t". + sorted = DataFrames.DataFrame([ + name => sort(ndf[!, name]; rev = true) for name in DataFrames.names(ndf) + ]) + x, default_x_label = + _duration_curve_x(get(kwargs, :x_axis, :percent), _time_vector(time_range)) + x_label = get(kwargs, :x_label, default_x_label) + kwargs = popkwargs(popkwargs(kwargs, :x_axis), :x_label) + return _plot_dataframe!(p, sorted, x, backend; x_label = x_label, kwargs...) +end + +""" + plot_duration_curve!(plot, df) + plot_duration_curve!(plot, df, time_range) + plot_duration_curve_plotly!(plot, df) + plot_duration_curve_plotly!(plot, df, time_range) + +Plots a duration curve from a [`DataFrames.DataFrame`](@extref) onto an existing plot +handle. The `_plotly` variants render with the PlotlyLight backend instead of CairoMakie. + +# Arguments + +- `plot`: existing plot handle returned by a previous PowerGraphics plot call (e.g. [`plot_duration_curve`](@ref)) +- `df::DataFrames.DataFrame`: `DataFrame` where each row represents a time period and each column represents a trace. +If only the `DataFrame` is provided, it must have a column of `DateTime` values. +- `time_range::Union{DataFrames.DataFrame, Array, StepRange}`: The time periods of the data + +# Accepted Key Words +- `x_axis::Symbol = :percent`: `:percent` for 0–100% of the time span, or `:hours` for elapsed hours +- `x_label::String`: override the x-axis label (defaults to `"Percent of time"` or `"Hours"`) +- `y_label::String`: label for the y axis +- `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 +- `format::String = "png"`: file extension for saved plots. CairoMakie supports `"png"`, `"pdf"`, `"svg"`. PlotlyLight only supports `"html"` (other values are written as `.html` with a warning). +- `seriescolor::Array`: Set different colors for the plots +- `palette` : color palette from [`load_palette`](@ref) +- `title::String = "Title"`: Set a title for the plots +- `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)`. +- `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` +- `legend_font_size::Number`: override the legend label font size +""" +function plot_duration_curve!(p, df::DataFrames.DataFrame; kwargs...) + return _plot_duration_curve!( + p, + PA.no_datetime(df), + df.DateTime, + CairoMakieBackend(); + kwargs..., + ) +end + +function plot_duration_curve!( + p, + variable::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}; + kwargs..., +) + return _plot_duration_curve!(p, variable, time_range, CairoMakieBackend(); kwargs...) +end + +@doc (@doc plot_duration_curve!) function plot_duration_curve_plotly!( + p, + df::DataFrames.DataFrame; + kwargs..., +) + return _plot_duration_curve!( + p, + PA.no_datetime(df), + df.DateTime, + PlotlyLightBackend(); + kwargs..., + ) +end + +function plot_duration_curve_plotly!( + p, + variable::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}; + kwargs..., +) + return _plot_duration_curve!(p, variable, time_range, PlotlyLightBackend(); kwargs...) +end + +#################################### Histogram ################################ + +# Sturges' rule, the default bin count. +_sturges(n::Integer) = ceil(Int, log2(max(n, 1))) + 1 + +""" +Bin every column of `data` over one common edge range so the overlaid series stay +comparable. Returns `(centers, counts)`, where `counts` is `bins × series`. +""" +function _histogram_bins(data::AbstractMatrix, bins::Int) + bins > 0 || throw(ArgumentError("`bins` must be positive, got $bins.")) + lo, hi = float(minimum(data)), float(maximum(data)) + # A degenerate range (every sample identical) would give zero-width bins. + if lo == hi + lo -= 0.5 + hi += 0.5 + end + width = (hi - lo) / bins + centers = [lo + (ix - 0.5) * width for ix in 1:bins] + counts = zeros(Int, bins, size(data, 2)) + for col in 1:size(data, 2), value in view(data, :, col) + # The top bin is closed on the right so the maximum sample is not dropped. + counts[min(floor(Int, (value - lo) / width) + 1, bins), col] += 1 + end + return centers, counts +end + +""" + plot_histogram(df) + plot_histogram(df, time_range) + +Plots the value distribution of each column of a [`DataFrames.DataFrame`](@extref) as an +overlaid histogram. All columns share one set of bin edges so their distributions can be +compared directly. + +# Arguments + +- `df::DataFrames.DataFrame`: `DataFrame` where each row represents a time period and each column represents a trace. +If only the `DataFrame` is provided, it must have a column of `DateTime` values. +- `time_range::Union{DataFrames.DataFrame, Array, StepRange}`: The time periods of the data. Ignored except to +identify the `DateTime` column, since a histogram has no time axis. + +# Example + +```julia +var_name = :ActivePowerVariable__ThermalStandard +df = PowerSimulations.read_realized_variable(results, var_name) +plot = plot_histogram(df; bins = 20) +``` + +# Accepted Key Words +- `bins::Int`: number of bins; defaults to Sturges' rule, `ceil(Int, log2(n)) + 1` +- `x_label::String`: label for the x axis; defaults to the column label when there is only one series +- `y_label::String = "Count"`: label for the y axis +- `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 +- `format::String = "png"`: file extension for saved plots. CairoMakie supports `"png"`, `"pdf"`, `"svg"`. PlotlyLight only supports `"html"` (other values are written as `.html` with a warning). +- `seriescolor::Array`: Set different colors for the plots +- `palette` : color palette from [`load_palette`](@ref) +- `title::String = "Title"`: Set a title for the plots +- `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)`. +- `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` +- `legend_font_size::Number`: override the legend label font size +""" +function plot_histogram(df::DataFrames.DataFrame; kwargs...) + return plot_histogram!(_empty_plot(), PA.no_datetime(df); kwargs...) +end + +function plot_histogram( + df::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}; + kwargs..., +) + return plot_histogram!(_empty_plot(), df, time_range; kwargs...) +end + +@doc (@doc plot_histogram) function plot_histogram_plotly( + df::DataFrames.DataFrame; + kwargs..., +) + return plot_histogram_plotly!(_empty_plot_plotly(), PA.no_datetime(df); kwargs...) +end + +function plot_histogram_plotly( + df::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}; + kwargs..., +) + return plot_histogram_plotly!(_empty_plot_plotly(), df, time_range; kwargs...) +end + +function _plot_histogram!(p, variable::DataFrames.DataFrame, backend; kwargs...) + ndf = PA.no_datetime(variable) + column_names = DataFrames.names(ndf) + data = Matrix(ndf) + centers, counts = + _histogram_bins(data, get(kwargs, :bins, _sturges(DataFrames.nrow(ndf)))) + label_fn = get(kwargs, :label_fn, label_short) + default_x_label = length(column_names) == 1 ? label_fn(only(column_names)) : "Value" + x_label = get(kwargs, :x_label, default_x_label) + y_label = get(kwargs, :y_label, "Count") + kwargs = Dict{Symbol, Any}( + (k, v) for (k, v) in kwargs if k ∉ [:bins, :x_label, :y_label, :bar] + ) + return _plot_dataframe!( + p, + DataFrames.DataFrame(counts, column_names), + centers, + backend; + bar = true, + x_label = x_label, + y_label = y_label, + kwargs..., + ) +end + +""" + plot_histogram!(plot, df) + plot_histogram!(plot, df, time_range) + plot_histogram_plotly!(plot, df) + plot_histogram_plotly!(plot, df, time_range) + +Plots the value distribution of each column of a [`DataFrames.DataFrame`](@extref) as an +overlaid histogram, onto an existing plot handle. The `_plotly` variants render with the +PlotlyLight backend instead of CairoMakie. + +# Arguments + +- `plot`: existing plot handle returned by a previous PowerGraphics plot call (e.g. [`plot_histogram`](@ref)) +- `df::DataFrames.DataFrame`: `DataFrame` where each row represents a time period and each column represents a trace. +If only the `DataFrame` is provided, it must have a column of `DateTime` values. +- `time_range::Union{DataFrames.DataFrame, Array, StepRange}`: The time periods of the data. Ignored except to +identify the `DateTime` column, since a histogram has no time axis. + +# Accepted Key Words +- `bins::Int`: number of bins; defaults to Sturges' rule, `ceil(Int, log2(n)) + 1` +- `x_label::String`: label for the x axis; defaults to the column label when there is only one series +- `y_label::String = "Count"`: label for the y axis +- `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 +- `format::String = "png"`: file extension for saved plots. CairoMakie supports `"png"`, `"pdf"`, `"svg"`. PlotlyLight only supports `"html"` (other values are written as `.html` with a warning). +- `seriescolor::Array`: Set different colors for the plots +- `palette` : color palette from [`load_palette`](@ref) +- `title::String = "Title"`: Set a title for the plots +- `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)`. +- `legend_position::Symbol = :right`: legend placement, `:right` or `:bottom` +- `legend_font_size::Number`: override the legend label font size +""" +function plot_histogram!(p, df::DataFrames.DataFrame; kwargs...) + return _plot_histogram!(p, PA.no_datetime(df), CairoMakieBackend(); kwargs...) +end + +function plot_histogram!( + p, + variable::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}; + kwargs..., +) + return _plot_histogram!(p, variable, CairoMakieBackend(); kwargs...) +end + +@doc (@doc plot_histogram!) function plot_histogram_plotly!( + p, + df::DataFrames.DataFrame; + kwargs..., +) + return _plot_histogram!(p, PA.no_datetime(df), PlotlyLightBackend(); kwargs...) +end + +function plot_histogram_plotly!( + p, + variable::DataFrames.DataFrame, + time_range::Union{DataFrames.DataFrame, Array, StepRange}; + kwargs..., +) + return _plot_histogram!(p, variable, PlotlyLightBackend(); kwargs...) +end + ################################# Plotting PowerData ########################## """ diff --git a/test/test_plot_creation.jl b/test/test_plot_creation.jl index 69cf878..852ea8f 100644 --- a/test/test_plot_creation.jl +++ b/test/test_plot_creation.jl @@ -8,12 +8,30 @@ function test_plots(file_path::String; backend_pkg::String = "cairomakie") plot_demand_fn = plot_demand plot_powerdata_fn = PG.plot_powerdata plot_fuel_fn = plot_fuel + plot_duration_curve_fn = plot_duration_curve + plot_histogram_fn = plot_histogram + n_series = p -> p.series_count + x_label_of = p -> p.axis.xlabel[] + y_label_of = p -> p.axis.ylabel[] + # Every CairoMakie series is stored as a `Point2` vector on the scene. + series_xy = function (p, ix) + points = p.axis.scene.plots[ix][1][] + return (first.(points), last.(points)) + end + series_y = (p, ix) -> last.(p.axis.scene.plots[ix][1][]) elseif backend_pkg == "plotlylight" plot_dataframe_fn = plot_dataframe_plotly plot_dataframe_fn! = plot_dataframe_plotly! plot_demand_fn = plot_demand_plotly plot_powerdata_fn = PG.plot_powerdata_plotly plot_fuel_fn = plot_fuel_plotly + plot_duration_curve_fn = plot_duration_curve_plotly + plot_histogram_fn = plot_histogram_plotly + n_series = p -> length(p.data) + x_label_of = p -> p.layout.xaxis.title.text + y_label_of = p -> p.layout.yaxis.title.text + series_xy = (p, ix) -> (collect(p.data[ix].x), collect(p.data[ix].y)) + series_y = (p, ix) -> collect(p.data[ix].y) else throw(error("$backend_pkg backend_pkg not supported")) end @@ -347,6 +365,84 @@ function test_plots(file_path::String; backend_pkg::String = "cairomakie") cleanup && rm(out_path; recursive = true) end + @testset "test $backend_pkg duration curve and histogram" begin + df = gen_uc.data[:ActivePowerVariable__ThermalStandard] + n_columns = ncol(no_datetime(df)) + n_rows = nrow(df) + + # Regression guard: the duration curve/histogram work generalized the + # recipes' x axis, which every other plot also goes through. + p = plot_dataframe_fn(df, gen_uc.time; set_display = set_display) + @test n_series(p) == n_columns + @test x_label_of(p) == string( + IS.convert_compound_period( + length(gen_uc.time) * (gen_uc.time[2] - gen_uc.time[1]), + ), + ) + + # A bar plot over time still integrates to a single bar per series. If it + # ever fell into the numeric-axis bar branch it would silently draw one + # bar per timestep instead, which still renders and still saves a file. + p = plot_dataframe_fn(df, gen_uc.time; set_display = set_display, bar = true) + @test n_series(p) == n_columns + for ix in 1:n_columns + @test length(series_y(p, ix)) == 1 + end + + p = plot_duration_curve_fn(df, gen_uc.time; set_display = set_display) + @test n_series(p) == n_columns + @test x_label_of(p) == "Percent of time" + for ix in 1:n_columns + x, y = series_xy(p, ix) + @test length(y) == n_rows + @test issorted(y; rev = true) + @test first(x) ≈ 0.0 + @test last(x) ≈ 100.0 + end + + elapsed_hours = + Dates.value(Millisecond(last(gen_uc.time) - first(gen_uc.time))) / 3.6e6 + p = plot_duration_curve_fn( + df, + gen_uc.time; + set_display = set_display, + x_axis = :hours, + ) + @test x_label_of(p) == "Hours" + for ix in 1:n_columns + x, y = series_xy(p, ix) + @test issorted(y; rev = true) + @test first(x) ≈ 0.0 + @test last(x) ≈ elapsed_hours + end + + @test_throws ArgumentError plot_duration_curve_fn( + df, + gen_uc.time; + set_display = set_display, + x_axis = :not_a_mode, + ) + + default_bins = ceil(Int, log2(n_rows)) + 1 + p = plot_histogram_fn(df, gen_uc.time; set_display = set_display) + @test n_series(p) == n_columns + @test y_label_of(p) == "Count" + for ix in 1:n_columns + _, counts = series_xy(p, ix) + @test length(counts) == default_bins + # Nothing may fall outside the shared bin range. + @test sum(counts) == n_rows + end + + p = plot_histogram_fn(df, gen_uc.time; set_display = set_display, bins = 12) + @test n_series(p) == n_columns + for ix in 1:n_columns + _, counts = series_xy(p, ix) + @test length(counts) == 12 + @test sum(counts) == n_rows + end + end + @testset "test alternate mapping yamls" begin # Alternate color palette makes curtailment hot pink out_path = joinpath(file_path, backend_pkg * "_alternate_palette")