Add a Figure#
Figures live in hydromodpy.display and are auto-discovered through
display/figures/__init__.py (pkgutil.iter_modules). Today 33
figures ship: hydrograph, piezometric_map, water_budget,
calibration_convergence, hydrographic_network_comparison,
simulated_active_network, side_by_side_map, etc.
Contract#
Every figure is a BaseFigure subclass decorated with
@register. It exposes a FigureSpec dataclass and a render
method:
# hydromodpy/display/figure.py
@dataclass(frozen=True)
class FigureSpec:
name: str
title: str
kind: str # "spatial" | "timeseries" | "balance" | "calibration" | ...
required_fields: tuple[str, ...]
required_tables: tuple[str, ...]
default_figsize: tuple[float, float]
class Figure(Protocol):
spec: ClassVar[FigureSpec]
def render(self, sim: "Run", ax: "Axes", **opts) -> "Axes": ...
def plot(self, sim: "Run", *, save_path: Path | None, **opts) -> "Figure": ...
class BaseFigure(ABC):
"""Implements ``plot()`` boilerplate (subplots + render + save)."""
Files to create#
A new figure called my_figure:
hydromodpy/display/figures/my_figure.py
Skeleton:
from matplotlib.axes import Axes
from hydromodpy.display.catalog import register
from hydromodpy.display.figure import BaseFigure, FigureSpec
from hydromodpy.results.run import Run
@register
class MyFigure(BaseFigure):
spec = FigureSpec(
name="my_figure",
title="My figure",
kind="spatial",
required_fields=("head",),
required_tables=(),
default_figsize=(7.0, 5.0),
)
def render(self, sim: Run, ax: Axes, **opts) -> Axes:
timestep = opts.get("timestep", -1)
head = sim.field("head", timestep=timestep)
im = ax.imshow(head, origin="lower")
ax.set_title(self.spec.title)
ax.figure.colorbar(im, ax=ax, label="head [m]")
return ax
The base BaseFigure.plot method handles the full lifecycle:
resolve
required_fields/required_tablesagainstRun;create the matplotlib subplots with
default_figsize;call
render;optionally save to
save_path.
Pick the right kind#
The kind field hints the layout used by composed reports:
spatial– map-style plots (imshow,pcolormesh,tripcolor).timeseries– line plots over the simulation period.balance– water budget bar charts.calibration– convergence, posterior, landscape views.compare– side-by-side grids.
Add a new kind only when the existing layouts cannot host the
view.
Required fields and tables#
Listing requirements explicitly lets the registry pre-flight the plot before reading the disk:
required_fields– Zarr dataset names thatRun.field(...)must be able to load.required_tables– Parquet-backed DuckDB tables (timeseries,budgets,mass_balance).
A figure that depends on an optional product should declare it and
fall back gracefully in render.
Auto-discovery#
display/figures/__init__.py walks every .py module in the
folder with pkgutil.iter_modules and imports it. Any
@register-decorated class becomes part of the global catalog. No
extra wiring is needed.
Render from CLI and TOML#
Once registered, the figure is reachable from:
hmp display <sim_ref> --figure my_figure
hmp display run.toml --session <session_id> --figure my_figure # calibration
And from TOML:
[display]
figures = ["piezometric_map", "water_budget", "my_figure"]
Tests to add#
Unit under
tests/unit/display/figures/against a syntheticRunfixture: assert the figure produces a non-empty axes and honoursrequired_fields/required_tables.Image regression (optional): commit a small reference PNG and compare with
matplotlib.testing.compare_images.
Pitfalls flagged by the layer matrix#
displaymay importcore,schema,results, anddisplay. It must not importdata,simulation,solver,calibration,analysis, orworkflow.The
analysis -> displaytolerance is reserved for comparison exports. Do not push display logic intoanalysis.Reach the data through
Run(run.field,run.timeseries,run.budget), never through raw Zarr / DuckDB calls inside the figure module.
See also#
display for the figure registry and the existing inventory.
Add an Exporter if your output is a file format rather than a matplotlib figure.
Figure Catalog for the user-facing figure catalog.