"""Calibrable parameters: annotations, space, transforms, discovery.
A Pydantic field becomes calibrable by attaching a ``Calibrable`` instance via
``Field.json_schema_extra['calibrable']`` - or by being referenced in a TOML
``[calibration.parameters]`` block. Discovery walks a config tree and emits
``CalibParameter`` entries.
"""
from __future__ import annotations
import math
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import Any
from pydantic import BaseModel
from hydromodpy.core.config_kit.calibrable import Calibrable
# ---------------------------------------------------------------------------
# Annotation
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Transforms
# ---------------------------------------------------------------------------
def _forward(transform: str, x: float) -> float:
if transform == "identity":
return x
if transform == "log":
if x <= 0.0:
raise ValueError(f"log transform requires positive values, got {x}")
return math.log10(x)
if transform == "logit":
if not (0.0 < x < 1.0):
raise ValueError(f"logit transform requires 0<x<1, got {x}")
return math.log(x / (1.0 - x))
raise ValueError(f"Unknown transform: {transform!r}")
def _inverse(transform: str, y: float) -> float:
if transform == "identity":
return y
if transform == "log":
return 10.0**y
if transform == "logit":
return 1.0 / (1.0 + math.exp(-y))
raise ValueError(f"Unknown transform: {transform!r}")
# ---------------------------------------------------------------------------
# CalibParameter + ParameterSpace
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True, slots=True)
class CalibParameter:
"""Resolved calibration dimension in physical and transformed space.
The object combines user TOML declarations with optional ``Calibrable``
field metadata. Bounds are stored in physical units, while helper
properties expose transformed bounds for optimizers that sample in log or
logit space.
"""
name: str
lower: float
upper: float
transform: str = "identity"
prior: str = "uniform"
path: str | None = None # dotted path into HydroModPyConfig (optional)
target: str | None = None # readable alias for ``path`` (wins when set)
mode: str = "replace" # "replace" writes the sample; "scale" multiplies the base
units: str | None = None
@property
def lower_transformed(self) -> float:
return _forward(self.transform, self.lower)
@property
def upper_transformed(self) -> float:
return _forward(self.transform, self.upper)
def to_physical(self, y: float) -> float:
return _inverse(self.transform, y)
def to_transformed(self, x: float) -> float:
return _forward(self.transform, x)
@property
def effective_path(self) -> str | None:
"""Return ``target`` when set, else ``path``."""
return self.target if self.target is not None else self.path
_MESH_SECTIONS: tuple[str, ...] = ("mesh_catchment", "mesh_catchment_batch", "mesh_input")
def _assert_path_is_not_the_mesh(name: str, path: str | None) -> None:
"""Refuse a parameter that would search over the mesh itself.
Two reasons, and either one is enough. The stream-network criterion is
normalised by cell size, so refining the mesh moves the yardstick and a
search that optimises it is circular. And the mesh step reads the mesh
sections resolved once from the raw file rather than the per-trial
configuration, so the value would never reach the mesh: every trial would
score the same model under a different number.
A mesh question is answered by a sweep, not by a search: one run per mesh,
compared, and read for convergence.
"""
if not path:
return
head = str(path).split(".", 1)[0]
if head not in _MESH_SECTIONS:
return
raise ValueError(
f"[calibration.parameters.{name}] points at {path!r}, and the mesh is not a "
"parameter a search may move: the stream-network criterion is normalised by "
"cell size, so refining the mesh moves the yardstick the search is scored "
"against. Run one simulation per mesh and compare them instead "
'(workflow mode = "comparison", `hmp compare`), which reads them for '
"convergence rather than for a best score."
)
_STAGE_BOUNDARY_PATHS: tuple[str, ...] = (
"flow.flow_regime",
"simulation.time.start_datetime",
"simulation.time.end_datetime",
"simulation.time.step_value",
"simulation.time.step_unit",
"simulation.time.substeps_per_period",
)
"""Paths that say WHICH MODEL a stage runs, rather than a property of one."""
def _assert_path_is_not_a_stage_boundary(name: str, path: str | None) -> None:
"""Refuse a parameter that would search over the stage instead of the model.
A flow regime and a simulation window are not properties of the aquifer:
they say which model a stage runs. A phase declares them in its own
``overrides``, which is where a two-stage method makes one stage steady and
the other transient. Searching over one asks the optimizer to choose between
two different models on a cost that only ever compares trials of one, and a
regime is categorical besides: every candidate is written through
``float(value)``, so the search would put 0.5 into a field whose two legal
values are two words.
"""
if not path:
return
if str(path) not in _STAGE_BOUNDARY_PATHS:
return
raise ValueError(
f"[calibration.parameters.{name}] points at {path!r}, which is a stage boundary "
"and not a dimension a search may move: it says which model the stage runs, not "
"a property of that model. Declare it in a phase's [calibration.phases.overrides] "
"instead, which is how a two-stage method makes one stage steady and the next "
"transient."
)
def _assert_bounds_are_physical(name: str, low: float, high: float, unit: object) -> None:
"""Face a declared bound with the ceiling a literal value already faces.
The registry refuses a specific yield of 0.8 written in ``[flow.param]`` and
never saw the bounds a calibration searches between, so the same value passed
unnoticed there. Checking the bounds rather than each sample refuses the whole
impossible region once, before the first solve. An id the registry does not
know is left alone, exactly as it is everywhere else.
"""
from hydromodpy.spatial.field.core.physical_bounds import (
PhysicalBoundsError,
validate_physical_value,
)
unit_text = str(unit) if unit is not None else None
for edge, value in (("lower", low), ("upper", high)):
try:
validate_physical_value(param_id=name, value=value, unit=unit_text)
except PhysicalBoundsError as exc:
raise ValueError(
f"[calibration.parameters.{name}] {edge} bound {value!r}: {exc}"
) from None
[docs]
class ParameterSpace:
"""Ordered collection of calibrated parameters.
The space preserves declaration order, exposes transformed bounds for
optimizers, and can describe one candidate as serializable metadata for
persistence and reporting.
"""
def __init__(self, parameters: Iterable[CalibParameter]):
self._params = tuple(parameters)
self._by_name = {p.name: p for p in self._params}
if len(self._by_name) != len(self._params):
raise ValueError("Duplicate parameter names in space")
@property
def dim(self) -> int:
return len(self._params)
@property
def parameters(self) -> tuple[CalibParameter, ...]:
return self._params
@property
def names(self) -> tuple[str, ...]:
return tuple(p.name for p in self._params)
def __getitem__(self, name: str) -> CalibParameter:
return self._by_name[name]
def __iter__(self):
return iter(self._params)
def __len__(self) -> int:
return len(self._params)
@property
def transformed_bounds(self) -> dict[str, tuple[float, float]]:
return {p.name: (p.lower_transformed, p.upper_transformed) for p in self._params}
def physical_bounds(self) -> dict[str, tuple[float, float]]:
return {p.name: (p.lower, p.upper) for p in self._params}
[docs]
def describe_values(self, values: Mapping[str, float]) -> dict[str, dict[str, Any]]:
"""Return serializable parameter metadata for one candidate."""
out: dict[str, dict[str, Any]] = {}
for param in self._params:
if param.name not in values:
continue
physical = float(values[param.name])
out[param.name] = {
"value": physical,
"transformed_value": param.to_transformed(physical),
"bounds": [param.lower, param.upper],
"transformed_bounds": [
param.lower_transformed,
param.upper_transformed,
],
"transform": param.transform,
"prior": param.prior,
"mode": param.mode,
"target": param.effective_path,
"units": param.units,
}
return out
[docs]
def default_prior_mean_std(self) -> tuple[list[float], list[float]] | None:
"""Return Normal-prior vectors in transformed space when declared."""
means: list[float] = []
stds: list[float] = []
has_normal = False
for param in self._params:
low = param.lower_transformed
high = param.upper_transformed
span = high - low
if param.prior == "normal":
has_normal = True
means.append(0.5 * (low + high))
stds.append(max(span / 6.0, 1e-12))
else:
means.append(0.5 * (low + high))
stds.append(max(span * 1e6, 1e12))
if not has_normal:
return None
return means, stds
[docs]
@classmethod
def from_toml_mapping(
cls,
declarations: Mapping[str, Mapping[str, Any]],
*,
annotations: Mapping[str, Calibrable] | None = None,
) -> ParameterSpace:
"""Build a space from ``[calibration.parameters]`` TOML section.
``annotations`` may provide defaults harvested from Pydantic
``Calibrable`` hints keyed by parameter name. TOML overrides win.
"""
annotations = annotations or {}
params: list[CalibParameter] = []
for name, decl in declarations.items():
ann = annotations.get(name)
bounds = decl.get("bounds")
if bounds is None and ann is not None:
bounds = ann.bounds
if bounds is None:
raise ValueError(f"Parameter {name!r} has no bounds (TOML or annotation)")
low, high = float(bounds[0]), float(bounds[1])
transform = decl.get("transform", ann.transform if ann else "identity")
prior = decl.get("prior", ann.prior if ann else "uniform")
if not low < high:
raise ValueError(f"Parameter {name!r}: lower bound must be < upper bound")
if transform == "log" and low <= 0.0:
raise ValueError(f"Parameter {name!r}: log transform requires lower > 0")
if transform == "logit" and not (0.0 < low < high < 1.0):
raise ValueError(
f"Parameter {name!r}: logit transform requires 0 < lower < upper < 1"
)
_assert_bounds_are_physical(name, low, high, decl.get("units"))
if prior not in {"uniform", "log_uniform", "normal"}:
raise ValueError(f"Parameter {name!r}: unknown prior {prior!r}")
if prior == "log_uniform" and low <= 0.0:
raise ValueError(f"Parameter {name!r}: log_uniform prior requires lower > 0")
units = decl.get("units", ann.units if ann else None)
path = decl.get("path")
target = decl.get("target")
_assert_path_is_not_the_mesh(name, path)
_assert_path_is_not_the_mesh(name, target)
_assert_path_is_not_a_stage_boundary(name, path)
_assert_path_is_not_a_stage_boundary(name, target)
mode = str(decl.get("mode", "replace")).strip().lower()
if mode not in {"replace", "scale"}:
raise ValueError(
f"Parameter {name!r}: mode must be 'replace' or 'scale', got {mode!r}"
)
params.append(
CalibParameter(
name=name,
lower=low,
upper=high,
transform=transform,
prior=prior,
path=path,
target=target,
mode=mode,
units=units,
)
)
return cls(params)
# ---------------------------------------------------------------------------
# Auto-discovery
# ---------------------------------------------------------------------------
def _iter_annotations(model_cls: type[BaseModel]) -> Iterable[tuple[str, Calibrable]]:
for field_name, f in model_cls.model_fields.items():
extra = f.json_schema_extra or {}
if not isinstance(extra, dict):
continue
hint = extra.get("calibrable")
if hint is None:
continue
if isinstance(hint, Calibrable):
yield field_name, hint
elif isinstance(hint, Mapping):
yield (
field_name,
Calibrable(
bounds=tuple(hint["bounds"]) if hint.get("bounds") else None,
transform=hint.get("transform", "identity"),
prior=hint.get("prior", "uniform"),
units=hint.get("units"),
description=hint.get("description", ""),
),
)
[docs]
def discover_calibrable(
config: BaseModel | type[BaseModel], *, _prefix: str = ""
) -> dict[str, Calibrable]:
"""Walk a Pydantic config tree and collect Calibrable annotations.
Keys are dotted paths, e.g. ``"flow.properties.k_aquifer"``. Values are
``Calibrable`` metadata harvested from ``Field.json_schema_extra``.
"""
cls = config if isinstance(config, type) else type(config)
if not (isinstance(cls, type) and issubclass(cls, BaseModel)):
return {}
found: dict[str, Calibrable] = {}
for field_name, hint in _iter_annotations(cls):
key = f"{_prefix}{field_name}"
found[key] = hint
for field_name, f in cls.model_fields.items():
sub_path = f"{_prefix}{field_name}."
sub_cls = _resolve_submodel(f.annotation)
if sub_cls is None:
continue
sub = getattr(config, field_name, None) if not isinstance(config, type) else None
found.update(discover_calibrable(sub or sub_cls, _prefix=sub_path))
return found
def _resolve_submodel(annotation: Any) -> type[BaseModel] | None:
import types
from typing import get_args, get_origin
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return annotation
origin = get_origin(annotation)
if origin is None:
return None
if origin in (types.UnionType, getattr(__import__("typing"), "Union", None)):
for arg in get_args(annotation):
if isinstance(arg, type) and issubclass(arg, BaseModel):
return arg
return None
# ---------------------------------------------------------------------------
# Apply resolved params to a config
# ---------------------------------------------------------------------------
[docs]
def apply_parameter_to_config(
cfg: Any,
param: CalibParameter,
value: float,
) -> None:
"""Write ``value`` into ``cfg`` for ``param``, honouring ``param.mode``.
``mode="replace"`` writes the candidate value as-is at ``param.effective_path``.
``mode="scale"`` multiplies the existing value at that path by the candidate
(the base value must be numeric).
"""
path = param.effective_path
if path is None:
raise ValueError(f"Parameter {param.name!r} has no target or path")
try:
_apply_resolved(cfg, param, path, value)
except ValueError as exc:
# Name the TOML key that declared the path: the walk only knows the path.
raise ValueError(f"[calibration.parameters.{param.name}] {exc}") from None
def _assert_candidate_is_physical(param: CalibParameter, written: float) -> None:
"""Face the value about to be written with the registry's ceiling.
Checking the declared bounds at load time refuses the impossible region
once, and that covers ``mode="replace"``. It does not cover ``mode="scale"``,
where the sample is a multiplier: legal bounds on the multiplier say nothing
about where the product lands, so a scale of 8 on a specific yield of 0.1
writes 0.8, past the physical ceiling, and only the solver would notice.
"""
from hydromodpy.spatial.field.core.physical_bounds import (
PhysicalBoundsError,
validate_physical_value,
)
unit_text = str(param.units) if param.units is not None else None
try:
validate_physical_value(param_id=param.name, value=written, unit=unit_text)
except PhysicalBoundsError as exc:
raise ValueError(f"candidate {written!r}: {exc}") from None
def _apply_resolved(cfg: Any, param: CalibParameter, path: str, value: float) -> None:
if param.mode == "replace":
_assert_candidate_is_physical(param, float(value))
set_by_path(cfg, path, float(value))
return
if param.mode == "scale":
base = _get_by_path(cfg, path)
if base is None:
raise ValueError(
f"Parameter {param.name!r}: scale mode requires a numeric base "
f"value at {path!r}; resolved to None."
)
try:
base_f = float(base)
except (TypeError, ValueError) as exc:
raise ValueError(
f"Parameter {param.name!r}: scale mode requires a numeric base "
f"value at {path!r}; got {base!r}."
) from exc
written = base_f * float(value)
_assert_candidate_is_physical(param, written)
set_by_path(cfg, path, written)
return
raise ValueError(
f"Parameter {param.name!r}: unsupported mode {param.mode!r} (expected 'replace' or 'scale')"
)
def _unknown_segment(path: str, part: str, depth: int, target: Any) -> ValueError:
"""Say which segment of which path is wrong, and what was there instead.
The reader wrote the path in a TOML and has never seen the Python class the
walk reached, so the class name is the last thing in the message rather than
its subject.
"""
reached = ".".join(path.split(".")[:depth]) or "the configuration root"
if isinstance(target, Mapping):
available = sorted(str(key) for key in target)
else:
available = sorted(k for k in vars(type(target)).get("model_fields", ()) or ())
if not available:
available = sorted(k for k in vars(target) if not k.startswith("_"))
known = ", ".join(available[:12]) if available else "nothing"
return ValueError(
f"{path!r}: no {part!r} under {reached}, which holds {known} ({type(target).__name__})."
)
def set_by_path(cfg: Any, path: str, value: Any) -> None:
"""Set ``value`` at ``path`` on ``cfg``. Accepts Pydantic and Mapping."""
parts = path.split(".")
target: Any = cfg
for depth, part in enumerate(parts[:-1]):
if isinstance(target, Mapping):
if part not in target:
raise _unknown_segment(path, part, depth, target)
target = target[part]
else:
if not hasattr(target, part):
raise _unknown_segment(path, part, depth, target)
target = getattr(target, part)
leaf = parts[-1]
if isinstance(target, Mapping):
target[leaf] = value
else:
if not hasattr(target, leaf):
raise _unknown_segment(path, leaf, len(parts) - 1, target)
setattr(target, leaf, value)
def _get_by_path(cfg: Any, path: str) -> Any:
"""Return the value at dotted ``path`` on ``cfg`` or raise if missing."""
parts = path.split(".")
target: Any = cfg
for part in parts:
if isinstance(target, Mapping):
if part not in target:
raise ValueError(f"Path segment {part!r} not found on {type(target).__name__}")
target = target[part]
else:
if not hasattr(target, part):
raise ValueError(f"Path segment {part!r} not found on {type(target).__name__}")
target = getattr(target, part)
return target
__all__ = [
"Calibrable",
"CalibParameter",
"ParameterSpace",
"discover_calibrable",
"apply_parameter_to_config",
"set_by_path",
]