"""Plotting utilities for temporal load profiles and quantile bands.
Provides two components for visualising charging load profiles in reporting
notebooks:
- :class:`ProfileDensifier` — fills missing time-steps in a sparse profile
DataFrame so that ``matplotlib``/``seaborn`` plots render without gaps.
- :func:`plot_quantile_bands` — overlays shaded confidence bands on a
``seaborn.FacetGrid`` for a set of quantile columns symmetric around the
median.
"""
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from laurel.models.group_times import AdaptiveTimeGrouper
MEDIAN = 0.5
[docs]
class ProfileDensifier:
"""Class for densifying sparse temporal profiles using time groupers.
Provides methods to create reusable time groupers and densify sparse
DataFrames by filling missing time points with specified values.
"""
[docs]
def __init__(self, time_col: str, freq: str, dur: str = "1d"):
"""Initialize ProfileDensifier with time configuration.
Args:
time_col: Name of the column containing time/datetime values.
freq: Frequency string for time series generation (e.g., "15min", "1h").
dur: Duration string defining the total time span (default: "1d").
"""
self.time_col = time_col
self.freq = freq
self.dur = dur
self.grouper = None
[docs]
def create_grouper(self) -> AdaptiveTimeGrouper:
"""Create and return configured AdaptiveTimeGrouper for external use.
Returns:
Configured AdaptiveTimeGrouper instance that can be used independently.
"""
if self.grouper is None:
self.grouper = AdaptiveTimeGrouper(
time_col=self.time_col,
tz_col="tz",
start_time=pd.Timestamp(0),
end_time=pd.Timestamp(0)
+ pd.Timedelta(self.dur)
- pd.Timedelta(self.freq),
possible_tzs=["local_time"],
freq=self.freq,
)
return self.grouper
[docs]
def densify(
self,
sparse: pd.DataFrame,
grp_cols: list[object],
value_cols: list[object],
fill_value=0.0,
) -> pd.DataFrame:
"""Densify sparse profiles by filling missing time points.
Args:
sparse: Sparse DataFrame containing observed data with missing time points.
grp_cols: List of column names to group by for densification.
value_cols: List of column names containing values to be preserved.
fill_value: Value to use for filling missing time points (default: 0.0).
Returns:
Complete DataFrame with densified profiles indexed by grp_cols + [time_col].
"""
grouper = self.create_grouper()
# Build all possible time combinations
all_classes = grouper.get_possible_obs_counts().reset_index()
all_classes = all_classes.drop(columns=["tz", grouper.count_col])
all_classes[self.time_col] = all_classes[self.time_col].dt.tz_localize(None)
# Apply time combinations to all groups
components = []
for col in grp_cols:
if col in sparse.columns:
vals = sparse[col]
elif col in sparse.index.names:
vals = sparse.index.get_level_values(col)
else:
raise RuntimeError(
f"Column {col} not found in the sparse dataframe columns or index names."
)
components.append(vals.unique())
components.append(all_classes[self.time_col])
idx_cols = grp_cols + [self.time_col]
sub_idx = pd.MultiIndex.from_product(components, names=idx_cols)
sub_frame = sub_idx.to_frame(index=False)
# Merge and fill missing values
mrg = sparse.reset_index().loc[:, idx_cols + value_cols]
profs_frame = sub_frame.merge(mrg, how="left", on=idx_cols)
profs_frame = profs_frame.set_index(idx_cols)
profs_frame = profs_frame.fillna(fill_value)
return profs_frame
[docs]
def get_band_bounds(qtls: list[float] | pd.Index) -> list[tuple[float, float]]:
"""Pair quantiles symmetrically around the median for band plotting.
Given a list of quantile levels that includes 0.5 and is of odd length,
returns pairs ``(low, high)`` ordered from the outermost band inward, ready
for :func:`plot_quantile_bands`.
Args:
qtls: Sorted list (or ``pd.Index``) of quantile levels. Must contain
the median (0.5) and have an odd number of entries.
Returns:
List of ``(lower_quantile, upper_quantile)`` tuples, outermost first.
Raises:
RuntimeError: If the median is absent or the length is even.
"""
if isinstance(qtls, pd.Index):
qtls = qtls.tolist()
if MEDIAN not in qtls:
raise RuntimeError(
"The plot requires the median (the 0.5 quantile) to be recorded."
)
if not len(qtls) % 2 == 1: # If list length is not odd
raise RuntimeError(
"The number of quantiles must be odd so that there are pairs of quantiles around the median."
)
n_bands = np.floor_divide(len(qtls), 2)
band_bounds = []
for i in range(n_bands):
band_bounds.append((qtls[i], qtls[-(i + 1)]))
return band_bounds
[docs]
def plot_quantile_bands(
g: sns.FacetGrid, x: str, y_quants: list, palette: str = "mako_r", **kwargs
) -> sns.FacetGrid:
"""Overlay shaded quantile bands on a seaborn FacetGrid.
For each pair of symmetric quantile columns (e.g. 5th/95th, 25th/75th),
draws a ``fill_between`` band using colours drawn from ``palette``.
Bands are drawn from outermost to innermost so darker inner bands are
rendered on top.
Args:
g: Seaborn ``FacetGrid`` to draw onto.
x: Column name for the x-axis (e.g. ``"local_time"``).
y_quants: Sorted list of quantile column names corresponding to the
quantile levels (must be compatible with :func:`get_band_bounds`).
palette: Seaborn palette name for colouring bands. Defaults to
``"mako_r"``.
**kwargs: Additional keyword arguments forwarded to ``plt.fill_between``
(e.g. ``alpha``, ``linewidth``).
Returns:
The modified ``FacetGrid``.
"""
pal = sns.color_palette(palette)
def _plot_band(x, y_lower, y_upper, **kwargs):
plt.fill_between(x, y_lower, y_upper, **kwargs)
for i, (low, high) in enumerate(get_band_bounds(y_quants)):
g.map(_plot_band, x, low, high, color=pal[i], **kwargs)
return g