Source code for tseda.visualization.time_plots

"""
Time-domain plots for :class:`~tseda.core.TimeSeries`.

Functions
---------
plot_series            — line plot with optional rolling mean overlay
plot_seasonal_subseries— one sub-panel per season position
plot_lag               — scatter plot matrix of ts vs lagged ts
plot_calendar_heatmap  — value by day-of-week × calendar week  *(innovative)*
plot_annual_boxplots   — distribution per calendar month
plot_density_ridge     — year-over-year KDE ridgeline  *(innovative)*
"""
from __future__ import annotations

from typing import Optional, Sequence, Tuple

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

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

__all__ = [
    "plot_series",
    "plot_seasonal_subseries",
    "plot_lag",
    "plot_calendar_heatmap",
    "plot_annual_boxplots",
    "plot_density_ridge",
]


[docs] def plot_series( ts: TimeSeries, *, rolling_window: Optional[int] = None, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """Line plot of *ts* with an optional rolling-mean overlay. Parameters ---------- ts : TimeSeries rolling_window : int, optional If given, overlay a rolling mean of this width. ax, title, figsize : optional Standard plot arguments. Returns ------- matplotlib.figure.Figure """ fig, ax = _make_fig_ax(ax, figsize, (12, 4)) ax.plot(ts.index, ts.values, color=PALETTE["accent"], linewidth=1.0, label=ts.name) if rolling_window is not None and rolling_window > 1: rolled = ( pd.Series(ts.values, index=ts.index) .rolling(rolling_window, center=True) .mean() ) ax.plot(ts.index, rolled.values, color=PALETTE["anomaly"], linewidth=1.5, linestyle="--", label=f"MA({rolling_window})") ax.legend(fontsize=9) ax.set_xlabel("Time") ax.set_ylabel(ts.unit or "Value") _set_title(ax, title, ts.name) fig.tight_layout() return fig
[docs] def plot_seasonal_subseries( ts: TimeSeries, period: int, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """One sub-panel per season position, each showing all cycles. Parameters ---------- ts : TimeSeries period : int Seasonal period (number of sub-panels). ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ period = max(2, int(period)) ncols = min(period, 12) nrows = (period + ncols - 1) // ncols fig, axes = plt.subplots( nrows, ncols, figsize=figsize or (max(12, 2 * period), 3 * nrows), sharey=True, ) axes_flat = np.array(axes).flatten() x = ts.values n = len(x) for s in range(period): axi = axes_flat[s] positions = np.arange(s, n, period) vals = x[positions] axi.plot(range(len(vals)), vals, color=PALETTE["accent"], marker="o", markersize=3, linewidth=0.8) mean_val = np.nanmean(vals) axi.axhline(mean_val, color=PALETTE["anomaly"], linewidth=1.0, linestyle="--", alpha=0.7) axi.set_title(f"Season {s + 1}", fontsize=9) axi.tick_params(labelsize=8) for i in range(period, len(axes_flat)): axes_flat[i].set_visible(False) fig.suptitle(title or f"Seasonal subseries (period={period})") fig.tight_layout() return fig
[docs] def plot_lag( ts: TimeSeries, lags: Sequence[int] = (1, 2, 7), *, figsize: Optional[Tuple[float, float]] = None, title: Optional[str] = None, ) -> Figure: """Scatter-plot matrix of *ts* versus lagged copies. Parameters ---------- ts : TimeSeries lags : sequence of int Lag values to plot. figsize, title : optional Returns ------- matplotlib.figure.Figure """ lags = [int(k) for k in lags if int(k) > 0] n_lags = len(lags) fig, axes = plt.subplots( 1, n_lags, figsize=figsize or (4 * n_lags, 4), squeeze=False ) x = ts.values n = len(x) for j, k in enumerate(lags): ax = axes[0, j] if k >= n: ax.set_visible(False) continue ax.scatter(x[:-k], x[k:], s=8, alpha=0.5, color=PALETTE["accent"]) rng = np.nanmax(x) - np.nanmin(x) lo = np.nanmin(x) - 0.05 * rng hi = np.nanmax(x) + 0.05 * rng ax.plot([lo, hi], [lo, hi], color=PALETTE["neutral"], linewidth=0.8, linestyle="--") ax.set_xlabel(f"t") ax.set_ylabel(f"t+{k}") ax.set_title(f"Lag {k}") fig.suptitle(title or f"Lag plots — {ts.name}") fig.tight_layout() return fig
[docs] def plot_calendar_heatmap( ts: TimeSeries, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """Heatmap of values by day-of-week (columns) × calendar week (rows). Works best with daily or sub-daily data. For non-daily series a weekly-aggregated heatmap (ISO week × year) is produced instead. Parameters ---------- ts : TimeSeries ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ import matplotlib.colors as mcolors idx = ts.index vals = ts.values # Determine if daily resolution freq = ts.freq is_daily = freq in ("D", "B") or (freq is None and len(idx) > 1 and np.median(np.diff(idx.astype(np.int64)) / 1e9) <= 86400) if is_daily: # Pivot: rows=weeks (ISO week), cols=day-of-week s = pd.Series(vals, index=idx) df = pd.DataFrame({ "val": s.values, "week": idx.isocalendar().week.values, "year": idx.year, "dow": idx.dayofweek, }) df["week_key"] = df["year"] * 100 + df["week"] pivot = df.pivot_table(index="week_key", columns="dow", values="val", aggfunc="mean") xlabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] xlabels = [xlabels[c] for c in pivot.columns] ylabel = "ISO week" else: # Monthly aggregation: rows=year, cols=month s = pd.Series(vals, index=idx) s = s.resample("MS").mean() df = pd.DataFrame({"val": s.values, "year": s.index.year, "month": s.index.month}) pivot = df.pivot_table(index="year", columns="month", values="val", aggfunc="mean") xlabels = ["Jan","Feb","Mar","Apr","May","Jun", "Jul","Aug","Sep","Oct","Nov","Dec"] xlabels = [xlabels[c - 1] for c in pivot.columns] ylabel = "Year" fig, ax = _make_fig_ax(ax, figsize, (12, max(4, len(pivot) * 0.25 + 2))) data_arr = pivot.values.astype(float) im = ax.imshow(data_arr, aspect="auto", cmap="RdYlGn", vmin=np.nanpercentile(data_arr, 5), vmax=np.nanpercentile(data_arr, 95)) ax.set_xticks(range(len(xlabels))) ax.set_xticklabels(xlabels, fontsize=8) ax.set_xlabel("Day of week" if is_daily else "Month") ax.set_ylabel(ylabel) fig.colorbar(im, ax=ax, fraction=0.025, pad=0.02, label=ts.unit or "Value") _set_title(ax, title, f"Calendar heatmap — {ts.name}") fig.tight_layout() return fig
[docs] def plot_annual_boxplots( ts: TimeSeries, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """Box-and-whisker plot of values grouped by calendar month. Parameters ---------- ts : TimeSeries ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ 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 = [s[months == m].dropna().values for m in range(1, 13)] groups_nonempty = [(month_names[i], g) for i, g in enumerate(groups) if len(g) > 0] if not groups_nonempty: ax.text(0.5, 0.5, "No data", transform=ax.transAxes, ha="center") else: labels, data = zip(*groups_nonempty) bp = ax.boxplot(data, patch_artist=True, medianprops={"color": PALETTE["anomaly"]}) ax.set_xticklabels(labels) for patch in bp["boxes"]: patch.set_facecolor(PALETTE["accent"]) patch.set_alpha(0.6) ax.set_xlabel("Month") ax.set_ylabel(ts.unit or "Value") _set_title(ax, title, f"Monthly distribution — {ts.name}") fig.tight_layout() return fig
[docs] def plot_density_ridge( ts: TimeSeries, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """Year-over-year density ridgeline (KDE per year). Each year is a separate KDE curve, offset vertically. Parameters ---------- ts : TimeSeries ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ from scipy.stats import gaussian_kde s = pd.Series(ts.values, index=ts.index) years = sorted(s.index.year.unique()) if len(years) < 2: # Fallback: single KDE fig, ax = _make_fig_ax(ax, figsize, (10, 4)) x_clean = s.dropna().values if len(x_clean) >= 2: x_grid = np.linspace(x_clean.min(), x_clean.max(), 200) kde = gaussian_kde(x_clean) ax.fill_between(x_grid, kde(x_grid), alpha=0.5, color=PALETTE["accent"]) ax.plot(x_grid, kde(x_grid), color=PALETTE["dark"], linewidth=1) _set_title(ax, title, f"Density — {ts.name}") fig.tight_layout() return fig fig, ax = _make_fig_ax(ax, figsize, (10, max(4, len(years) * 1.2))) cmap = plt.cm.viridis all_vals = s.dropna().values x_min, x_max = float(all_vals.min()), float(all_vals.max()) x_grid = np.linspace(x_min, x_max, 300) scale = (x_max - x_min) * 0.15 for i, yr in enumerate(years): yr_vals = s[s.index.year == yr].dropna().values if len(yr_vals) < 3: continue kde = gaussian_kde(yr_vals) density = kde(x_grid) density = density / density.max() * scale color = cmap(i / max(len(years) - 1, 1)) ax.fill_between(x_grid, i, i + density, alpha=0.6, color=color) ax.plot(x_grid, i + density, color=color, linewidth=0.8) ax.set_yticks(range(len(years))) ax.set_yticklabels([str(y) for y in years], fontsize=9) ax.set_xlabel(ts.unit or "Value") ax.set_ylabel("Year") _set_title(ax, title, f"Density ridgeline — {ts.name}") fig.tight_layout() return fig