"""Plot helpers for :mod:`pycsamt.pipeline` results.
The pipeline engine stores step timing, status, station counts, and generated
plot paths in :class:`pycsamt.pipeline.PipelineResult`. This module provides
small Matplotlib helpers for turning that metadata into review figures that
work in notebooks, reports, and the documentation gallery.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from ._pipeline import PipelineResult
__all__ = [
"plot_pipeline_dashboard",
"plot_pipeline_status",
"plot_pipeline_timing",
"plot_site_count_flow",
]
_OK = "#2f9e44"
_ERR = "#c92a2a"
_WARN = "#f08c00"
_BLUE = "#2f6f8f"
_INK = "#243746"
_GRID = "#d7dee5"
_MUTED = "#74808a"
def _require_matplotlib():
import matplotlib.pyplot as plt
return plt
def _step_results(result: PipelineResult) -> list[Any]:
return list(getattr(result, "step_results", []) or [])
def _step_labels(result: PipelineResult) -> list[str]:
labels = []
for sr in _step_results(result):
idx = getattr(sr, "step_idx", len(labels) + 1)
name = str(getattr(sr, "step_name", f"step_{idx}"))
labels.append(f"{idx}. {name}")
return labels
def _short_labels(result: PipelineResult) -> list[str]:
labels = []
for sr in _step_results(result):
idx = getattr(sr, "step_idx", len(labels) + 1)
name = str(getattr(sr, "step_name", f"step_{idx}"))
labels.append(f"{idx}\n{name}")
return labels
def _elapsed(result: PipelineResult) -> np.ndarray:
vals = []
for sr in _step_results(result):
try:
vals.append(max(float(getattr(sr, "elapsed_sec", 0.0)), 0.0))
except Exception:
vals.append(0.0)
return np.asarray(vals, dtype=float)
def _site_counts(result: PipelineResult) -> tuple[np.ndarray, np.ndarray]:
n_in, n_out = [], []
for sr in _step_results(result):
n_in.append(int(getattr(sr, "n_sites_in", 0) or 0))
n_out.append(int(getattr(sr, "n_sites_out", 0) or 0))
return np.asarray(n_in, dtype=float), np.asarray(n_out, dtype=float)
def _plot_counts(result: PipelineResult) -> np.ndarray:
vals = []
for sr in _step_results(result):
plots = getattr(sr, "plots", []) or []
vals.append(len(plots))
return np.asarray(vals, dtype=float)
def _status_colors(result: PipelineResult) -> list[str]:
return [_OK if getattr(sr, "ok", False) else _ERR for sr in _step_results(result)]
def _style_axis(ax: Any, *, xgrid: bool = False, ygrid: bool = True) -> None:
ax.set_axisbelow(True)
if ygrid:
ax.grid(axis="y", color=_GRID, lw=0.7, alpha=0.75)
if xgrid:
ax.grid(axis="x", color=_GRID, lw=0.7, alpha=0.55)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_color("#b8c1ca")
ax.tick_params(colors=_INK, labelsize=8)
ax.xaxis.label.set_color(_INK)
ax.yaxis.label.set_color(_INK)
ax.title.set_color(_INK)
def _empty_axis(ax: Any, title: str) -> Any:
ax.text(
0.5,
0.5,
"no pipeline steps",
ha="center",
va="center",
transform=ax.transAxes,
color=_MUTED,
)
ax.set_xticks([])
ax.set_yticks([])
ax.set_title(title)
for spine in ax.spines.values():
spine.set_color("#d0d7de")
return ax
def _annotate_bars(ax: Any, bars: Any, values: np.ndarray, fmt: str) -> None:
finite = values[np.isfinite(values)]
ymax = float(np.nanmax(finite)) if finite.size else 1.0
pad = max(ymax * 0.025, 0.02)
for bar, val in zip(bars, values):
if not np.isfinite(val):
continue
ax.text(
bar.get_x() + bar.get_width() / 2.0,
bar.get_height() + pad,
fmt.format(val),
ha="center",
va="bottom",
fontsize=8,
color=_INK,
)
[docs]
def plot_pipeline_status(
result: PipelineResult,
*,
ax: Any | None = None,
ok_color: str = _OK,
error_color: str = _ERR,
annotate_errors: bool = True,
) -> Any:
"""Plot per-step success status for a pipeline run.
Failed steps are coloured red and, when available, the exception message
is written below the corresponding bar. Empty results draw a neutral
placeholder instead of failing.
"""
plt = _require_matplotlib()
if ax is None:
_, ax = plt.subplots(figsize=(9.5, 4.0))
labels = _step_labels(result)
if not labels:
return _empty_axis(ax, f"Pipeline status: {result.pipeline_name}")
x = np.arange(len(labels))
colors = [
ok_color if getattr(sr, "ok", False) else error_color
for sr in _step_results(result)
]
bars = ax.bar(x, np.ones(len(labels)), color=colors, edgecolor="#1f2933")
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=8)
ax.set_yticks([])
ax.set_ylim(-0.32 if annotate_errors else 0.0, 1.25)
ax.set_ylabel("status")
ax.set_title(f"Pipeline status: {result.pipeline_name}")
for i, (bar, sr) in enumerate(zip(bars, _step_results(result))):
ok = bool(getattr(sr, "ok", False))
ax.text(
bar.get_x() + bar.get_width() / 2.0,
0.52,
"OK" if ok else "ERR",
ha="center",
va="center",
color="white",
fontsize=8,
fontweight="bold",
)
err = getattr(sr, "error", None)
if annotate_errors and err is not None:
msg = str(err).strip().replace("\n", " ")
if len(msg) > 34:
msg = msg[:31] + "..."
ax.text(
i,
-0.08,
msg or type(err).__name__,
ha="center",
va="top",
rotation=35,
fontsize=7,
color=error_color,
)
_style_axis(ax, ygrid=False)
return ax
[docs]
def plot_pipeline_timing(
result: PipelineResult,
*,
ax: Any | None = None,
color: str = _BLUE,
slow_color: str = _WARN,
slow_quantile: float = 0.80,
annotate: bool = True,
) -> Any:
"""Plot elapsed time per pipeline step.
Steps above *slow_quantile* are highlighted, helping users quickly spot
expensive processing or plotting stages.
"""
plt = _require_matplotlib()
if ax is None:
_, ax = plt.subplots(figsize=(9.5, 4.0))
labels = _step_labels(result)
if not labels:
return _empty_axis(ax, f"Pipeline timing: {result.pipeline_name}")
elapsed = _elapsed(result)
finite = elapsed[np.isfinite(elapsed)]
q = float(np.nanquantile(finite, slow_quantile)) if finite.size else np.inf
colors = [slow_color if v >= q and v > 0 else color for v in elapsed]
x = np.arange(len(labels))
bars = ax.bar(x, elapsed, color=colors, edgecolor="#1f2933")
if annotate:
_annotate_bars(ax, bars, elapsed, "{:.2f}s")
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=8)
ax.set_ylabel("Elapsed time (s)")
ax.set_title(f"Pipeline timing: {result.pipeline_name}")
ax.set_ylim(0.0, max(float(np.nanmax(elapsed)) * 1.18, 1.0))
_style_axis(ax)
return ax
[docs]
def plot_site_count_flow(
result: PipelineResult,
*,
ax: Any | None = None,
color_in: str = "#687582",
color_out: str = "#7c4d79",
drop_color: str = _ERR,
annotate: bool = True,
) -> Any:
"""Plot station counts entering and leaving each pipeline step.
A faint red band marks steps where the output site count is lower than
the input site count, which is useful for QC workflows that reject stations.
"""
plt = _require_matplotlib()
if ax is None:
_, ax = plt.subplots(figsize=(9.5, 4.0))
labels = _step_labels(result)
if not labels:
return _empty_axis(ax, f"Pipeline station flow: {result.pipeline_name}")
x = np.arange(len(labels))
n_in, n_out = _site_counts(result)
for xi, a, b in zip(x, n_in, n_out):
if np.isfinite(a) and np.isfinite(b) and b < a:
ax.axvspan(xi - 0.45, xi + 0.45, color=drop_color, alpha=0.08)
ax.plot(x, n_in, marker="o", color=color_in, lw=1.8, label="input")
ax.plot(x, n_out, marker="s", color=color_out, lw=1.8, label="output")
if annotate:
for xi, a, b in zip(x, n_in, n_out):
ax.text(xi, a, f"{int(a)}", ha="center", va="bottom", fontsize=7)
ax.text(xi, b, f"{int(b)}", ha="center", va="top", fontsize=7)
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=8)
ax.set_ylabel("Stations")
ax.set_title(f"Pipeline station flow: {result.pipeline_name}")
ax.legend(frameon=False, ncol=2, fontsize=8)
ymax = max(float(np.nanmax([n_in, n_out])), 1.0)
ax.set_ylim(-0.05 * ymax, 1.15 * ymax)
_style_axis(ax)
return ax
def _plot_output_counts(result: PipelineResult, *, ax: Any) -> Any:
labels = _step_labels(result)
if not labels:
return _empty_axis(ax, "Generated figures")
plots = _plot_counts(result)
x = np.arange(len(labels))
bars = ax.bar(x, plots, color="#4c6ef5", edgecolor="#1f2933")
_annotate_bars(ax, bars, plots, "{:.0f}")
ax.set_xticks(x)
ax.set_xticklabels(_short_labels(result), fontsize=7)
ax.set_ylabel("Plots")
ax.set_title("Generated figures by step")
ax.set_ylim(0.0, max(float(np.nanmax(plots)) * 1.25, 1.0))
_style_axis(ax)
return ax
def _plot_summary_cards(result: PipelineResult, *, ax: Any) -> Any:
ax.axis("off")
steps = _step_results(result)
n_steps = len(steps)
n_ok = sum(1 for sr in steps if getattr(sr, "ok", False))
n_err = n_steps - n_ok
elapsed = float(getattr(result, "elapsed_sec", 0.0) or 0.0)
plots = len(getattr(result, "plots", []) or [])
paths = len(getattr(result, "processed_paths", []) or [])
n_in, n_out = _site_counts(result)
in0 = int(n_in[0]) if n_in.size else 0
outn = int(n_out[-1]) if n_out.size else 0
cards = [
("Steps", f"{n_ok}/{n_steps} ok", _OK if n_err == 0 else _WARN),
("Errors", str(n_err), _OK if n_err == 0 else _ERR),
("Sites", f"{in0} -> {outn}", _BLUE),
("Time", f"{elapsed:.2f}s", _BLUE),
("Figures", str(plots), "#4c6ef5"),
("EDI files", str(paths), "#7c4d79"),
]
for i, (title, value, color) in enumerate(cards):
x0 = (i % 3) / 3.0 + 0.02
y0 = 0.58 if i < 3 else 0.12
width = 0.29
height = 0.30
rect = plt_rectangle(ax, x0, y0, width, height, color)
ax.add_patch(rect)
ax.text(
x0 + 0.025,
y0 + height - 0.07,
title,
transform=ax.transAxes,
fontsize=8,
color=_MUTED,
va="top",
)
ax.text(
x0 + 0.025,
y0 + 0.08,
value,
transform=ax.transAxes,
fontsize=14,
color=_INK,
fontweight="bold",
va="bottom",
)
ax.set_title(f"Pipeline summary: {result.pipeline_name}", loc="left")
return ax
def plt_rectangle(ax: Any, x: float, y: float, w: float, h: float, color: str) -> Any:
from matplotlib.patches import FancyBboxPatch
return FancyBboxPatch(
(x, y),
w,
h,
transform=ax.transAxes,
boxstyle="round,pad=0.008,rounding_size=0.018",
facecolor="#f8fafc",
edgecolor=color,
linewidth=1.4,
)
[docs]
def plot_pipeline_dashboard(
result: PipelineResult,
*,
figsize: tuple[float, float] = (12.0, 8.0),
) -> Any:
"""Create a compact dashboard for a completed pipeline run.
The dashboard combines run-level cards, per-step status, timing,
station-count flow, and generated-figure counts. It is the most useful
single figure to place in a processing report or notebook.
Returns
-------
matplotlib.figure.Figure
Figure containing the dashboard.
"""
plt = _require_matplotlib()
fig = plt.figure(figsize=figsize, constrained_layout=True)
gs = fig.add_gridspec(3, 2, height_ratios=(0.85, 1.25, 1.2))
ax_cards = fig.add_subplot(gs[0, :])
ax_status = fig.add_subplot(gs[1, 0])
ax_timing = fig.add_subplot(gs[1, 1])
ax_sites = fig.add_subplot(gs[2, 0])
ax_plots = fig.add_subplot(gs[2, 1])
_plot_summary_cards(result, ax=ax_cards)
plot_pipeline_status(result, ax=ax_status, annotate_errors=False)
plot_pipeline_timing(result, ax=ax_timing, annotate=False)
plot_site_count_flow(result, ax=ax_sites, annotate=False)
_plot_output_counts(result, ax=ax_plots)
return fig