Source code for hydromodpy.calibration.report

"""Calibration session data structures and loaders.

Two concerns live here, both purely data-side:

1. :class:`CalibrationReport` - structured return type of
   ``run_calibration_cli`` and ``Project.calibrate``. Exposes session metadata plus
   lazy accessors for the iteration history and the best :class:`Run`.
2. :class:`SessionReportData` + :func:`load_session_report_data` - read
   one calibration session out of the workspace catalog and return a
   plain dataclass ready to be handed to the display layer for HTML
   rendering. The rendering itself lives in
   :mod:`hydromodpy.reporting.calibration_report`; this module never
   imports ``hydromodpy.display`` or ``hydromodpy.reporting``.
"""

from __future__ import annotations

import json
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from hydromodpy.core.logging import get_logger

if TYPE_CHECKING:
    import pandas as pd

logger = get_logger(__name__)


# ---------------------------------------------------------------------------
# CalibrationReport dataclass
# ---------------------------------------------------------------------------


[docs] @dataclass(frozen=True) class CalibrationReport: """Structured summary of one calibration session. Returned by ``Project.calibrate`` and by the ``run_calibration_cli`` helper (both keep a ``to_dict`` shim so existing callers keep working). Attributes ---------- session_id UUID (hex) of the calibration session in the catalog. method Optimizer method name (e.g. ``"optuna"``). n_iterations Number of iterations actually run (could be < ``max_iter`` if the optimizer converged). best_objective Best (minimum) objective value achieved. best_sim_id UUID of the promoted best run when ``save_runs != "none"``, otherwise ``None``. best_parameters Physical values of the best candidate, keyed by calibrated parameter name. ``None`` when no candidate was evaluated. A staged calibration reads it to freeze what a phase calibrated. duration_s Wall-clock duration of the calibration loop in seconds. save_runs The ``save_runs`` mode used (``"none"``, ``"best_n"`` or ``"all"``). promoted Count of iterations promoted to full simulations after the loop. workspace Workspace root the session was written to. extra Free-form metadata (callers may attach anything extra here). """ session_id: str method: str n_iterations: int best_objective: float | None best_sim_id: str | None duration_s: float save_runs: str promoted: int best_parameters: dict[str, float] | None = None parameter_uncertainty: tuple[Any, ...] = () """Width beside each calibrated value, when the file asked for one. Empty unless ``[calibration.uncertainty]`` declared a method that produces it. The values themselves are in ``best_parameters`` and are never touched by it.""" workspace: Path | None = None extra: dict[str, Any] = field(default_factory=dict) store_factory: Callable[[Path], Any] | None = field( default=None, repr=False, compare=False, ) # ------------------------------------------------------------------ # Lazy accessors # ------------------------------------------------------------------ @property def iterations(self): """Return the iteration history as a :class:`pandas.DataFrame`. Loads lazily through the configured calibration store. """ import pandas as pd if self.workspace is None: return pd.DataFrame() from hydromodpy.calibration.persistence import CalibrationPersistence with self._open_store() as catalog: rows = CalibrationPersistence(catalog).load_iterations(self.session_id) return pd.DataFrame(rows) @property def best(self): """Return the best promoted :class:`Run` or ``None``.""" if self.best_sim_id is None or self.workspace is None: return None with self._open_store() as catalog: return catalog[self.best_sim_id] def _open_store(self): """Open the report store using the injected factory or default catalog.""" if self.workspace is None: raise ValueError("CalibrationReport has no workspace") if self.store_factory is not None: return self.store_factory(self.workspace) from hydromodpy.calibration.runners.state import default_store_factory return default_store_factory(self.workspace, None)
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-friendly summary matching the legacy CLI output.""" payload: dict[str, Any] = { "session_id": self.session_id, "method": self.method, "n_iterations": int(self.n_iterations), "best_objective": self.best_objective, "best_sim_id": self.best_sim_id, "duration_s": round(float(self.duration_s), 3), "save_runs": self.save_runs, "promoted": int(self.promoted), } if self.best_parameters is not None: payload["best_parameters"] = dict(self.best_parameters) if self.parameter_uncertainty: payload["parameter_uncertainty"] = [ item.to_dict() for item in self.parameter_uncertainty ] if self.workspace is not None: payload["workspace"] = str(self.workspace) if self.extra: payload["extra"] = dict(self.extra) return payload
# --------------------------------------------------------------------------- # SessionReportData - data handed to the display layer # --------------------------------------------------------------------------- @dataclass(frozen=True) class SessionReportData: """Plain data extracted from one calibration session. Carries everything :func:`hydromodpy.reporting.calibration_report.render_session` needs to produce the HTML report; no catalog handle, no live database connection. Constructed by :func:`load_session_report_data`. Attributes ---------- session_id Canonical hex session identifier. session_name Readable name of the session directory on disk (``<date>-<method>-<id8>``), so the report sits under the same name. session Row from ``calibration_sessions`` as a dict. iterations Rows from ``calibration_iterations`` as a list of dicts. workspace_root Workspace root the session was written to (used to compute the report output directory). best_sim_id Canonical hex sim_id of the promoted best run, or ``None`` when no run was promoted. sim_timeseries Simulated discharge for ``best_sim_id`` (columns ``datetime``, ``value``), or ``None`` when no best run is available. obs_timeseries Observed discharge for ``best_sim_id`` (columns ``datetime``, ``value``), or ``None`` when no observations are available. """ session_id: str session_name: str session: dict[str, Any] iterations: list[dict[str, Any]] workspace_root: Path best_sim_id: str | None sim_timeseries: pd.DataFrame | None obs_timeseries: pd.DataFrame | None variable: str = "discharge" # --------------------------------------------------------------------------- # Loader # --------------------------------------------------------------------------- def _session_candidates(conn: Any) -> list[tuple[str, Any]]: """Return ``[(session_hex, started_at)]`` ordered most-recent first.""" rows = conn.execute( "SELECT session_id, started_at FROM calibration_sessions " "ORDER BY started_at DESC NULLS LAST", ).fetchall() return [(_hex(r[0]), r[1]) for r in rows] def _sessions_for_sim_prefix(conn: Any, normalized: str) -> set[str]: """Return session hexes whose calibration run matches ``normalized``. Matches both iteration runs (``calibration_iterations.sim_id``) and the promoted best run (``calibration_sessions.best_sim_id``), so a reference copied from ``hmp catalog ls`` resolves to its parent session. """ out: set[str] = set() for session_id, sim_id in conn.execute( "SELECT session_id, sim_id FROM calibration_iterations WHERE sim_id IS NOT NULL" ).fetchall(): if _hex(sim_id).startswith(normalized): out.add(_hex(session_id)) for session_id, best in conn.execute( "SELECT session_id, best_sim_id FROM calibration_sessions WHERE best_sim_id IS NOT NULL" ).fetchall(): if _hex(best).startswith(normalized): out.add(_hex(session_id)) return out def _match_session_ids(conn: Any, raw: str) -> list[str]: """Return session hexes matching ``raw`` as a session id or a run id. ``raw`` is a full UUID or a unique hex prefix. A reference that matches a calibration run (an iteration sim_id or the promoted best run) resolves to that run's parent session. Never raises; returns ``[]`` on no match. """ import uuid normalized = raw.replace("-", "").lower() candidates = {h for h, _ in _session_candidates(conn)} if not candidates: return [] hits: set[str] = set() if len(normalized) == 32: try: full = uuid.UUID(normalized).hex except ValueError: full = None if full is not None and full in candidates: hits.add(full) else: hits.update(h for h in candidates if h.startswith(normalized)) hits.update(h for h in _sessions_for_sim_prefix(conn, normalized) if h in candidates) return sorted(hits) def resolve_calibration_session_id( catalog: Any, raw: str | None, ) -> str: """Return the canonical hex session id for ``raw`` in one catalog. ``raw`` accepts a full UUID (hex or dashed, 32 hex chars) or a unique prefix of >= 1 hex char, matching either a calibration session id or a calibration run id (mapped to its parent session). When ``raw`` is ``None``, return the most recently started session. """ from hydromodpy.core.exceptions import ConfigError, ConfigMissingError candidates = _session_candidates(catalog.connection) if not candidates: raise ConfigMissingError("No calibration session found in the workspace catalog.") if raw is None: return candidates[0][0] matches = _match_session_ids(catalog.connection, raw) if not matches: raise ConfigMissingError(f"No calibration session or run matches {raw!r}.") if len(matches) > 1: raise ConfigError( f"Reference {raw!r} is ambiguous ({len(matches)} sessions). Use more hex characters." ) return matches[0] def _iter_catalog_roots(workspace_root: Path) -> list[Path]: """Return the workspace catalog roots (single canonical federation helper).""" from hydromodpy.results.catalog import iter_project_catalog_roots return iter_project_catalog_roots(workspace_root) def resolve_session_in_workspace( workspace_root: Path, raw: str | None, ) -> tuple[Path, str]: """Resolve a calibration session across a whole workspace. Searches the workspace-level catalog and every ``projects/<name>`` catalog under ``workspace_root``. ``raw`` may be a session id/prefix or a calibration run id/prefix (mapped to its parent session). ``None`` selects the most recently started session anywhere in the workspace. Returns ``(catalog_root, session_hex)``: the project root whose catalog owns the session, ready to open for rendering. """ from hydromodpy.core.exceptions import ConfigError, ConfigMissingError from hydromodpy.results.catalog import Catalog workspace_root = Path(workspace_root).expanduser().resolve() roots = _iter_catalog_roots(workspace_root) if not roots: raise ConfigMissingError( f"No catalog found under {workspace_root}. Pass -w <workspace-or-project>." ) if len(roots) == 1: with Catalog(roots[0], read_only=True) as catalog: return roots[0], resolve_calibration_session_id(catalog, raw) matches: list[tuple[Path, str, Any]] = [] for root in roots: try: with Catalog(root, read_only=True) as catalog: conn = catalog.connection if raw is None: cands = _session_candidates(conn) if cands: matches.append((root, cands[0][0], cands[0][1])) else: started = dict(_session_candidates(conn)) matches.extend( (root, sid, started.get(sid)) for sid in _match_session_ids(conn, raw) ) except Exception as exc: # a locked or stale catalog must not hide the rest logger.warning("report: skipping catalog %s: %s", root, exc) if not matches: if raw is None: raise ConfigMissingError( f"No calibration session found in any project under {workspace_root}." ) raise ConfigMissingError( f"No calibration session or run matches {raw!r} in workspace {workspace_root}. " "Run 'hmp catalog ls' to list runs." ) if raw is None: matches.sort( key=lambda m: (m[2] is not None, m[2].timestamp() if m[2] is not None else 0.0), reverse=True, ) return matches[0][0], matches[0][1] distinct = {(root, sid) for root, sid, _ in matches} if len(distinct) > 1: listing = ", ".join( f"{root.name}/{sid[:8]}" for root, sid in sorted(distinct, key=lambda x: (x[0].name, x[1])) ) raise ConfigError( f"Reference {raw!r} is ambiguous across the workspace: {listing}. " "Use more hex characters or pass -w <project_dir>." ) root, sid = next(iter(distinct)) return root, sid def load_session_report_data( *, catalog: Any, session_id: str, workspace_root: Path, ) -> SessionReportData: """Load all data needed to render an HTML report for one session. Reads the session row, the iteration history, and (when a best run is available) the simulated and observed discharge timeseries. The return value is a plain dataclass, so the caller is free to render it through any backend. """ session_row = _load_session(catalog, session_id) iterations = _load_iterations(catalog, session_id) variable = _calibration_variable(session_row) best_sim_id = _pick_report_sim_id(session_row, iterations) sim_df = obs_df = None if best_sim_id is not None: sid = _dashed(best_sim_id) if variable == "lake_level": sim_df, obs_df = _load_best_lake_level(catalog, sid) else: sim_df, obs_df = _load_best_discharge(catalog, sid) return SessionReportData( session_id=session_id, session_name=_session_name(session_row, session_id), session=session_row, iterations=iterations, workspace_root=Path(workspace_root), best_sim_id=best_sim_id, sim_timeseries=sim_df, obs_timeseries=obs_df, variable=variable, ) def _session_name(session_row: dict, session_id: str) -> str: """Return the on-disk directory name of the session. Rebuilt from the same ``(method, started_at)`` pair the journal used, so a report never invents a second vocabulary for one session. """ from datetime import datetime from hydromodpy.results.session_journal import session_dir_name started_at = session_row["started_at"] if not isinstance(started_at, datetime): started_at = datetime.fromisoformat(str(started_at)) return session_dir_name(session_id, str(session_row["method"]), started_at) # --------------------------------------------------------------------------- # Catalog reads # --------------------------------------------------------------------------- def _load_session(catalog: Any, session_id: str) -> dict: import uuid sid = uuid.UUID(session_id) if len(session_id) == 32 else session_id row = catalog.connection.execute( """ SELECT s.session_id, s.project, s.method, s.objective_name, s.n_iterations, s.config, s.started_at, s.ended_at, st.code AS status, s.best_sim_id, s.best_objective, s.duration_s FROM calibration_sessions s LEFT JOIN statuses st ON st.id = s.status_id WHERE s.session_id = ? """, [sid], ).fetchone() if row is None: raise ValueError(f"Unknown calibration session {session_id!r}") keys = ( "session_id", "project", "method", "objective_name", "n_iterations", "config", "started_at", "ended_at", "status", "best_sim_id", "best_objective", "duration_s", ) return dict(zip(keys, row, strict=False)) def _load_iterations(catalog: Any, session_id: str) -> list[dict]: from hydromodpy.calibration.persistence import CalibrationPersistence return CalibrationPersistence(catalog).load_iterations(session_id) def _calibration_variable(session_row: dict) -> str: """Return the calibrated variable from the persisted session config.""" config = session_row.get("config") if isinstance(config, str): try: config = json.loads(config) except (TypeError, ValueError): config = {} if isinstance(config, dict): return str(config.get("variable", "discharge")) return "discharge" def _dashed(value: Any) -> str: """Normalize a sim id to the dashed UUID form stored in the catalog.""" import uuid hexed = _hex(value) try: return str(uuid.UUID(hexed)) except ValueError: return str(value) def _load_best_lake_level( catalog: Any, sim_id: str ) -> tuple[pd.DataFrame | None, pd.DataFrame | None]: """Return (simulated stage, observed lake level) for ``sim_id``. The simulated LAK stage comes from the catalog ``timeseries`` (one ``lake:<id>`` station per lake); the observed series comes from the ``observations`` table populated at promotion. Either side may be ``None`` when the catalog has no matching rows. """ try: stations = catalog.backend.query( "SELECT DISTINCT station_id FROM timeseries " "WHERE sim_id = ? AND variable = 'stage' AND station_id LIKE 'lake:%' " "ORDER BY station_id", [sim_id], ) if stations.empty: return None, None station = str(stations.iloc[0, 0]) sim_df = catalog.backend.query( "SELECT time AS datetime, value FROM timeseries " "WHERE sim_id = ? AND station_id = ? AND variable = 'stage' ORDER BY timestep", [sim_id, station], ) obs_df = catalog.backend.query( "SELECT datetime, value FROM observations " "WHERE station_id = ? AND variable_type = 'lake_level' ORDER BY datetime", [station], ) except Exception as exc: logger.warning("best_lake_level: catalog query failed: %s", exc) return None, None return sim_df, obs_df def _load_best_discharge( catalog: Any, sim_id: str ) -> tuple[pd.DataFrame | None, pd.DataFrame | None]: """Return (simulated, observed) discharge frames for ``sim_id``. Either side may be ``None`` (or empty) if the catalog does not contain matching rows; the display layer is expected to skip the obs-vs-sim figure in that case. """ try: sim_df = catalog.backend.query( "SELECT time AS datetime, value FROM timeseries " "WHERE sim_id = ? AND variable = 'discharge' AND station_id = '_catchment' " "ORDER BY timestep", [sim_id], ) obs_df = catalog.backend.query( "SELECT time AS datetime, value FROM timeseries " "WHERE sim_id = ? AND variable = 'discharge_obs' " "ORDER BY timestep", [sim_id], ) except Exception as exc: logger.warning("best_obs_vs_sim: catalog query failed: %s", exc) return None, None return sim_df, obs_df def _pick_report_sim_id(session_row: dict, iterations: list[dict]) -> str | None: """Return the sim_id used by the obs-vs-sim figure. Prefers the session-level ``best_sim_id`` (promoted run); falls back to the lowest-cost iteration whose ``sim_id`` is set when the session field is empty (e.g. failed promotion of the top iteration). """ sid = session_row.get("best_sim_id") if sid is not None: return _hex(sid) for row in sorted( (r for r in iterations if r.get("sim_id") and r.get("objective_value") is not None), key=lambda r: r["objective_value"], ): return _hex(row["sim_id"]) return None def _hex(value: Any) -> str: if value is None: return "" if hasattr(value, "hex"): return value.hex return str(value).replace("-", "") __all__ = ( "CalibrationReport", "SessionReportData", "load_session_report_data", "resolve_calibration_session_id", "resolve_session_in_workspace", )