Source code for tseda.visualization.seasonality_plots

"""
Seasonality visualisation plots.

Functions
---------
plot_periodogram    — FFT power spectrum with marked dominant peaks
plot_polar_seasonal — values on a clock-face polar chart  *(innovative)*
plot_season_heatmap — period-phase × time heatmap  *(innovative)*
plot_monthly_boxplots — box per calendar month
"""
from __future__ import annotations

from typing import Optional, Tuple

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from matplotlib.axes import Axes

from tseda.core.timeseries import TimeSeries
from tseda.seasonality.detector import SeasonalityReport
from tseda.visualization.base import PALETTE, _make_fig_ax, _set_title

__all__ = [
    "plot_periodogram",
    "plot_polar_seasonal",
    "plot_season_heatmap",
    "plot_monthly_boxplots",
]


[docs] def plot_periodogram( report: SeasonalityReport, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """FFT power spectrum with dominant periods marked. Parameters ---------- report : SeasonalityReport ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ fig, ax = _make_fig_ax(ax, figsize, (12, 4)) periods = np.array(report.periodogram_periods, dtype=float) strengths = np.array(report.strength_scores.get("periodogram", []), dtype=float) if len(periods) == 0 or len(strengths) == 0 or len(periods) != len(strengths): ax.text(0.5, 0.5, "No periodogram data available", transform=ax.transAxes, ha="center") _set_title(ax, title, "Periodogram") fig.tight_layout() return fig ax.plot(periods, strengths, color=PALETTE["accent"], linewidth=1.2) ax.fill_between(periods, strengths, alpha=0.2, color=PALETTE["accent"]) top_n = min(3, len(report.candidate_periods)) for p in report.candidate_periods[:top_n]: p_float = float(p) closest = int(np.argmin(np.abs(periods - p_float))) ax.axvline(p_float, color=PALETTE["anomaly"], linewidth=1.5, linestyle="--", alpha=0.8) if closest < len(strengths): ax.annotate( f"T={p}", xy=(p_float, strengths[closest]), xytext=(p_float + 0.5, strengths[closest] * 1.1), fontsize=8, color=PALETTE["anomaly"], ) ax.set_xlabel("Period (observations)") ax.set_ylabel("Power") _set_title(ax, title, "Periodogram") fig.tight_layout() return fig
[docs] def plot_polar_seasonal( ts: TimeSeries, period: int, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """Values projected onto a clock-face polar chart. Each observation is plotted at angle = (position mod period) / period × 2π and radius proportional to the normalised value. Parameters ---------- ts : TimeSeries period : int ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ period = max(2, int(period)) x = ts.values[~np.isnan(ts.values)] n = len(x) if ax is None: fig = plt.figure(figsize=figsize or (7, 7)) ax = fig.add_subplot(111, polar=True) else: fig = ax.get_figure() phases = (np.arange(n) % period) / period * 2 * np.pi # Normalise to [0.2, 1.0] for radius (avoid the origin) x_min, x_max = float(np.nanmin(x)), float(np.nanmax(x)) if x_max > x_min: radii = 0.2 + 0.8 * (x - x_min) / (x_max - x_min) else: radii = np.full(n, 0.6) sc = ax.scatter(phases, radii, c=radii, cmap="viridis", s=8, alpha=0.6, zorder=3) # Overlay per-phase mean phase_means = np.zeros(period) for s in range(period): idx_s = np.arange(s, n, period) vals_s = x[idx_s] if len(vals_s) > 0: phase_means[s] = np.nanmean(vals_s) mean_norm = 0.2 + 0.8 * (phase_means - x_min) / max(x_max - x_min, 1e-15) phase_angles = np.linspace(0, 2 * np.pi, period, endpoint=False) mean_angles = np.append(phase_angles, phase_angles[0]) mean_radii = np.append(mean_norm, mean_norm[0]) ax.plot(mean_angles, mean_radii, color=PALETTE["anomaly"], linewidth=2.0, linestyle="-", label="Phase mean") ax.set_xticks(np.linspace(0, 2 * np.pi, period, endpoint=False)) ax.set_xticklabels([str(i + 1) for i in range(period)], fontsize=8) ax.set_ylim(0, 1.05) ax.set_title(title or f"Polar seasonal (period={period}) — {ts.name}", pad=20) fig.tight_layout() return fig
[docs] def plot_season_heatmap( ts: TimeSeries, period: int, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """Period-phase × cycle heatmap. Rows = phase within period (0 … period−1). Columns = cycle index. Cell colour = observed value. Parameters ---------- ts : TimeSeries period : int ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ import pandas as pd period = max(2, int(period)) x = ts.values n = len(x) n_cycles = n // period if n_cycles < 1: fig, ax = _make_fig_ax(ax, figsize, (8, 4)) ax.text(0.5, 0.5, "Too few cycles to build heatmap", transform=ax.transAxes, ha="center") _set_title(ax, title, f"Season heatmap (period={period})") fig.tight_layout() return fig grid = x[:n_cycles * period].reshape(n_cycles, period).T fig, ax = _make_fig_ax(ax, figsize, (max(6, n_cycles * 0.5 + 2), max(4, period * 0.4 + 2))) im = ax.imshow(grid, aspect="auto", cmap="RdYlGn", vmin=np.nanpercentile(grid, 5), vmax=np.nanpercentile(grid, 95)) ax.set_xlabel("Cycle") ax.set_ylabel("Phase") ax.set_yticks(range(period)) ax.set_yticklabels([str(i + 1) for i in range(period)], fontsize=8) fig.colorbar(im, ax=ax, fraction=0.025, pad=0.02, label=ts.unit or "Value") _set_title(ax, title, f"Season heatmap (period={period}) — {ts.name}") fig.tight_layout() return fig
[docs] def plot_monthly_boxplots( ts: TimeSeries, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """Box-and-whisker plot per calendar month position. Parameters ---------- ts : TimeSeries ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ import pandas as pd fig, ax = _make_fig_ax(ax, figsize, (12, 4)) s = pd.Series(ts.values, index=ts.index) months = s.index.month month_names = ["Jan","Feb","Mar","Apr","May","Jun", "Jul","Aug","Sep","Oct","Nov","Dec"] groups = [] labels = [] for m in range(1, 13): g = s[months == m].dropna().values if len(g) > 0: groups.append(g) labels.append(month_names[m - 1]) if not groups: ax.text(0.5, 0.5, "No data", transform=ax.transAxes, ha="center") else: bp = ax.boxplot(groups, patch_artist=True, medianprops={"color": PALETTE["anomaly"]}) ax.set_xticklabels(labels) for patch in bp["boxes"]: patch.set_facecolor(PALETTE["seasonal"]) patch.set_alpha(0.5) ax.set_xlabel("Month") ax.set_ylabel(ts.unit or "Value") _set_title(ax, title, f"Monthly boxplots — {ts.name}") fig.tight_layout() return fig