Source code for bsrn.visualization.calendar

"""
Calendar-style irradiance plots.
"""

import numpy as np
import pandas as pd
from plotnine import (
    aes,
    element_blank,
    element_line,
    element_text,
    facet_grid,
    geom_line,
    geom_ribbon,
    geom_vline,
    ggplot,
    labs,
    scale_x_continuous,
    scale_y_continuous,
    theme,
    theme_minimal,
    scale_color_manual,
    scale_fill_manual,
)


[docs] def plot_calendar(df, output_file, meas_col=None, clear_col=None, other_cols=None, labels=None, title=None): """ Plot a one-page calendar-style comparison for multiple irradiance series (up to 7). Parameters ---------- df : pd.DataFrame Processed DataFrame with UTC DatetimeIndex and 'zenith' column. output_file : str or None Path to save the output PDF. If falsy, the figure is not written to disk. meas_col : str, optional Column name for measured data (plotted as solid line). clear_col : str, optional Column name for clear-sky data (plotted with line and ribbon). other_cols : list of str, optional Additional column names to plot as solid lines. labels : list of str, optional Labels for the legend corresponding to provided columns in order: [meas_col, clear_col] + other_cols. If None, column names are used. title : str, optional Plot title. If None (default), no title is drawn. """ if "zenith" not in df.columns: raise ValueError("df must contain a 'zenith' column.") columns = [] if meas_col: columns.append(meas_col) if clear_col: columns.append(clear_col) if other_cols: columns.extend(other_cols) if not columns: raise ValueError("At least one column must be specified to plot.") if len(columns) > 7: import warnings warnings.warn("WONG_PALETTE has only 7 colors; first 7 will have distinct colors.") if labels is None: labels = columns df = df.sort_index() clean_idx = df.index.tz_localize(None) if df.index.tz is not None else df.index # Automatically slice to the most frequent month to handle boundary effects from averaging all_months = clean_idx.to_period("M") if all_months.empty: raise ValueError("DataFrame index is empty.") target_month = pd.Series(all_months).mode()[0] if len(all_months.unique()) > 1: mask = all_months == target_month df = df[mask].copy() clean_idx = clean_idx[mask] import warnings warnings.warn(f"Automatically sliced input DataFrame to target month: {target_month}") unique_months = clean_idx.to_period("M").unique() if len(unique_months) != 1: raise ValueError(f"Found multiple months based on interval coverage: {list(unique_months)}") # Keep only daytime samples (sun above horizon + twilight). df_day = df.loc[df["zenith"] < 93.0].copy() if df_day.empty: raise ValueError("No daytime data found in input DataFrame.") # Calendar coordinates. times = df_day.index dates = times.normalize() first_date = dates.min() first_monday = first_date - pd.Timedelta(days=int(first_date.weekday())) week = ((dates - first_monday).days // 7).astype(int) day_series = pd.Series(dates, index=times) t_index = day_series.groupby(day_series).cumcount().to_numpy() base = pd.DataFrame( { "t_index": t_index, "date": dates, "weekday": dates.weekday, "week": np.clip(week, 0, 4), }, index=times, ) for col in columns: base[col] = df_day[col].to_numpy(dtype=float) # Melt for plotnine long_df = base.melt( id_vars=["t_index", "date", "weekday", "week"], value_vars=columns, var_name="kind", value_name="value", ) # Automatic color mapping from WONG_PALETTE (Ordered) from bsrn.constants import WONG_PALETTE label_map = dict(zip(columns, labels)) # Pre-define colors to ensure consistency colors_pool = WONG_PALETTE full_color_map = {} # Map colors based on provided columns to maintain specific color for meas/clear # This ensures meas_col is always Wong[0] if present, clear_col is Wong[1], etc. current_idx = 0 if meas_col: full_color_map[label_map[meas_col]] = colors_pool[0] current_idx = 1 if clear_col: full_color_map[label_map[clear_col]] = colors_pool[1] # Skip Wong[1] for other_cols if clear_col is already assigned if current_idx < 2: current_idx = 2 if other_cols: for oc in other_cols: full_color_map[label_map[oc]] = colors_pool[current_idx % 7] current_idx += 1 long_df["kind_label"] = pd.Categorical( [label_map[c] for c in long_df["kind"]], categories=labels, ordered=True ) weekday_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] long_df["weekday"] = pd.Categorical( [weekday_labels[i] for i in long_df["weekday"]], categories=weekday_labels, ordered=True, ) bounds = ( base.reset_index() .groupby("date", as_index=False) .agg( t_start=("t_index", "min"), t_end=("t_index", "max"), week=("week", "first"), weekday=("weekday", "first"), ) ) bounds["weekday"] = pd.Categorical( [weekday_labels[i] for i in bounds["weekday"]], categories=weekday_labels, ordered=True, ) width_inch = 160 / 25.4 height_inch = width_inch * (5 / 7) max_t = int(base["t_index"].max()) p = ( ggplot(long_df, aes(x="t_index", y="value", color="kind_label", group="kind_label")) + geom_line(size=0.3) + geom_vline( data=bounds, mapping=aes(xintercept="t_start"), color="#999999", size=0.2, alpha=0.4, linetype="dashed", ) + geom_vline( data=bounds, mapping=aes(xintercept="t_end"), color="#999999", size=0.2, alpha=0.4, linetype="dashed", ) + facet_grid("week ~ weekday", scales="fixed") # Ensure 0 is always included in Y-axis + scale_x_continuous(limits=(0, max_t), expand=(0, 0)) + scale_y_continuous(expand=(0.05, 0.1)) + scale_color_manual(values=full_color_map) + labs( **( {"title": title, "x": "", "y": "Irradiance [W/m²]", "color": ""} if title is not None else {"x": "", "y": "Irradiance [W/m²]", "color": ""} ) ) + theme_minimal() + theme( text=element_text(family="Times New Roman", size=9), axis_title=element_text(size=9), axis_text=element_text(size=9), axis_text_x=element_blank(), plot_title=element_text(size=9), strip_text_x=element_text(size=9), strip_text_y=element_blank(), figure_size=(width_inch, height_inch), panel_grid_major=element_line(size=0.15, alpha=0.7), legend_position="bottom", legend_text=element_text(size=9), panel_spacing=0, panel_border=element_blank(), ) ) # Ribbon for clear-sky ONLY if provided if clear_col: clear_label = label_map[clear_col] clear_data = long_df[long_df["kind_label"] == clear_label].copy() p = p + geom_ribbon( data=clear_data, mapping=aes(ymin=0, ymax="value", fill="kind_label"), color=None, alpha=0.15, show_legend=False, ) + scale_fill_manual(values=full_color_map) if output_file: import warnings with warnings.catch_warnings(): warnings.filterwarnings("ignore", module="plotnine") p.save(output_file, dpi=300, width=width_inch, height=height_inch, verbose=False) return p