"""Multi-panel calibration dashboard — pure Plotly HTML, no server required.
Single public function:
- :func:`plot_calibration_dashboard` — combine multiple sub-figures into a
single offline HTML file.
"""
from __future__ import annotations
import pathlib
from typing import TYPE_CHECKING, Union
if TYPE_CHECKING:
import pandas as pd
from ..calibration.result import CalibrationResult
try:
import plotly.graph_objects as go
except ImportError: # pragma: no cover
pass
try:
import plotly.graph_objects as go # type: ignore[import]
import plotly.io as pio # type: ignore[import]
def plot_calibration_dashboard(
result: "CalibrationResult",
timeseries_df: "pd.DataFrame | None" = None,
datetime_col: str = "datetime",
predicted_col: str = "predicted",
observed_col: Union[str, None] = None,
rainfall_col: Union[str, None] = None,
flow_label: str = "Flow",
title: str = "Calibration Dashboard",
output_path: Union[str, None] = None,
use_cdn: bool = True,
) -> "go.Figure":
"""Build a multi-panel calibration dashboard and write it to an HTML file.
Because :class:`plotly.graph_objects.Frame` objects (used by
:func:`plot_pareto_evolution`) and ``go.Parcoords`` traces cannot be
embedded in a shared ``make_subplots`` grid, each sub-figure is
serialised individually with ``plotly.io.to_html(full_html=False)``
and the sections are joined in an HTML template with a single Plotly
CDN ``<script>`` tag.
**Panels** (shown when data is available):
1. Time series — if *timeseries_df* is supplied.
2. Objective convergence — always shown.
3. Pareto front evolution (animated) — requires ≥ 1 history record.
4. Parameter distributions — always shown.
5. Parallel coordinates — always shown.
:param result: Calibration result.
:type result: CalibrationResult
:param timeseries_df: Optional model-output DataFrame.
:type timeseries_df: pandas.DataFrame or None
:param datetime_col: Datetime column name in *timeseries_df*.
:type datetime_col: str
:param predicted_col: Predicted-flow column name in *timeseries_df*.
:type predicted_col: str
:param observed_col: Observed-flow column name, or ``None`` to omit.
:type observed_col: str or None
:param rainfall_col: Rainfall column name, or ``None`` to omit zeros.
:type rainfall_col: str or None
:param flow_label: Y-axis label for flow panels.
:type flow_label: str
:param title: Dashboard heading.
:type title: str
:param output_path: File path for the HTML output. Pass ``None`` to
skip writing.
:type output_path: str or None
:param use_cdn: Include Plotly via CDN ``<script>`` tag when ``True``;
inline the full Plotly bundle when ``False`` (larger file, fully
offline).
:type use_cdn: bool
:returns: The objective-convergence figure (for interactive notebook
use; the full dashboard is only available in the HTML file).
:rtype: plotly.graph_objects.Figure
"""
import numpy as np
from .calibration import (
plot_objective_convergence,
plot_parallel_coordinates,
plot_parameter_distributions,
plot_pareto_evolution,
)
from .timeseries import plot_timeseries
panels: list[tuple[str, go.Figure]] = []
# Panel 1 — time series
if timeseries_df is not None:
dt = timeseries_df[datetime_col]
pred = timeseries_df[predicted_col].to_numpy(dtype=float)
obs = (
timeseries_df[observed_col].to_numpy(dtype=float)
if observed_col and observed_col in timeseries_df.columns
else None
)
rain = (
timeseries_df[rainfall_col].to_numpy(dtype=float)
if rainfall_col and rainfall_col in timeseries_df.columns
else np.zeros(len(timeseries_df))
)
ts_fig = plot_timeseries(
datetime=dt,
rainfall_mm=rain,
observed_flow=obs,
predicted_flow=pred,
title="Time Series",
flow_label=flow_label,
)
panels.append(("Time Series", ts_fig))
# Panel 2 — objective convergence
conv_fig = plot_objective_convergence(result, title="Objective Convergence")
panels.append(("Objective Convergence", conv_fig))
# Panel 3 — animated Pareto evolution
if result.history:
pareto_fig = plot_pareto_evolution(result, title="Pareto Front Evolution")
panels.append(("Pareto Front Evolution", pareto_fig))
# Panel 4 — parameter distributions
dist_fig = plot_parameter_distributions(result, title="Parameter Distributions")
panels.append(("Parameter Distributions", dist_fig))
# Panel 5 — parallel coordinates
if result.pareto_X.shape[0] >= 2:
pc_fig = plot_parallel_coordinates(result, title="Parallel Coordinates")
panels.append(("Parallel Coordinates", pc_fig))
# Build HTML
if output_path is not None:
if use_cdn:
plotlyjs_tag = (
'<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>'
)
include_plotlyjs = False
else:
include_plotlyjs = True
plotlyjs_tag = ""
panel_htmls = []
for panel_title, fig in panels:
fig_html = pio.to_html(fig, full_html=False, include_plotlyjs=include_plotlyjs)
if include_plotlyjs:
include_plotlyjs = False # only embed once
panel_htmls.append(
f'<section style="margin-bottom:2em;">'
f'<h2 style="font-family:sans-serif;color:#333;">{panel_title}</h2>'
f"{fig_html}"
f"</section>"
)
body = "\n".join(panel_htmls)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
{plotlyjs_tag}
<style>
body {{ font-family: sans-serif; margin: 1.5em; background: #fafafa; }}
h1 {{ color: #222; border-bottom: 2px solid #aaa; padding-bottom: 0.3em; }}
section {{ background: white; padding: 1em; border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,.1); }}
</style>
</head>
<body>
<h1>{title}</h1>
{body}
</body>
</html>"""
pathlib.Path(output_path).write_text(html, encoding="utf-8")
return conv_fig
except ImportError:
[docs]
def plot_calibration_dashboard(*args, **kwargs): # type: ignore[misc]
raise ImportError(
"plotly is required for plot_calibration_dashboard. "
"Install with: pip install plotly"
)