Source code for hydromodpy.calibration.optim.engine

"""CalibrationEngine - orchestrates an ask/tell loop.

The engine is solver-agnostic and pipeline-agnostic: it takes a callable
``evaluator(values: dict[str, float]) -> EvaluationResult`` and runs the
ask/tell loop until the optimizer converges or ``max_iter`` is reached.

``save_runs`` modes (implemented via ``promote_best_n``):

- ``"none"``  (default): each iteration is **only** a DuckDB row. No Zarr.
- ``"best_n"``: after the loop, promote the top ``save_best_n`` iterations
                into full simulations (caller-supplied promoter callable).
- ``"all"``:   each iteration is already a full simulation.
"""

from __future__ import annotations

import math
import time
import uuid
from collections.abc import Callable, Mapping
from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
from dataclasses import dataclass, field
from typing import Any, Protocol

from hydromodpy.calibration.optim.cache import ParamsHashCache, params_hash
from hydromodpy.calibration.optim.optimizer import (
    EvaluationResult,
    Optimizer,
    ParamSuggestion,
)
from hydromodpy.calibration.optim.parameters import ParameterSpace

EvaluatorFn = Callable[[ParamSuggestion], EvaluationResult]


class ProgressReporter(Protocol):
    def update(self, trial_id: int, result: EvaluationResult) -> None: ...
    def close(self) -> None: ...


class _NoopProgress:
    def update(self, trial_id: int, result: EvaluationResult) -> None: ...
    def close(self) -> None: ...


[docs] @dataclass class CalibrationSession: """Runtime result returned after a calibration loop. The session keeps the optimizer instance, the calibrated parameter space, every evaluation result, and timing metadata. Use ``best`` for the current minimum-cost evaluation and ``duration_s`` for elapsed wall-clock time. """ session_id: str optimizer: Optimizer space: ParameterSpace history: list[EvaluationResult] = field(default_factory=list) started_at: float = 0.0 finished_at: float | None = None @property def best(self) -> EvaluationResult | None: return self.optimizer.best() @property def duration_s(self) -> float: end = self.finished_at or time.time() return end - self.started_at
[docs] @dataclass class CalibrationEngine: """Drive an ask/tell loop until convergence or budget is exhausted. Minimal moving parts: ``space`` + ``optimizer`` + ``evaluator``. The engine only decides *when* to stop and which results to forward to the optimizer. Everything else (simulation, catalog, promotion) happens in the ``evaluator`` closure. The evaluator receives a ``ParamSuggestion`` and returns an ``EvaluationResult``. Optional caching uses a parameter hash so repeated candidates can reuse previous objective values. """ space: ParameterSpace optimizer: Optimizer evaluator: EvaluatorFn max_iter: int = 100 batch_size: int = 1 parallel: int = 1 cache: ParamsHashCache | None = None cache_context: Mapping[str, Any] | None = None progress: ProgressReporter | None = None session_id: str | None = None on_iteration: Callable[[ParamSuggestion, EvaluationResult], None] | None = None """Called once per evaluated suggestion, cache hits included. Takes the suggestion alongside its result: a cache hit never reaches the evaluator, so the caller has no other way to know which parameters the iteration carried, and the row it persists would be dropped. """ def run(self) -> CalibrationSession: sid = self.session_id or uuid.uuid4().hex reporter = self.progress or _NoopProgress() session = CalibrationSession( session_id=sid, optimizer=self.optimizer, space=self.space, started_at=time.time(), ) try: n_done = 0 while n_done < self.max_iter: take = min(self.batch_size, self.max_iter - n_done) suggestions = self.optimizer.ask(n=take) if not suggestions: break results = self._evaluate_batch(suggestions) for sugg, result in zip(suggestions, results, strict=True): session.history.append(result) reporter.update(sugg.trial_id, result) if self.on_iteration is not None: self.on_iteration(sugg, result) self.optimizer.tell(results) n_done += len(results) if self.optimizer.converged(): break finally: session.finished_at = time.time() reporter.close() close_optimizer = getattr(self.optimizer, "close", None) if callable(close_optimizer): close_optimizer() return session def _evaluate_batch( self, suggestions: list[ParamSuggestion], ) -> list[EvaluationResult]: """Run every suggestion of one batch and return their results in order. ``parallel <= 1`` keeps the legacy sequential loop. ``parallel > 1`` dispatches trials through a :class:`ThreadPoolExecutor`. Threads are used over processes because evaluators close over a live ``Project`` whose DuckDB connection and Zarr handles are not pickle-safe. """ if self.parallel <= 1 or len(suggestions) <= 1: return [self._evaluate_with_cache(sugg) for sugg in suggestions] workers = min(self.parallel, len(suggestions)) # ContextVars (e.g. the api-isolation scope the caller opened) do NOT cross # the thread boundary, so give each worker its own copy of THIS thread's # context. copy_context() runs here, in the caller thread, so every copy # inherits the current bindings; a fresh copy per task avoids entering one # Context object from several threads at once. tasks = [(copy_context(), sugg) for sugg in suggestions] def _run_in_context(item: tuple) -> EvaluationResult: ctx, sugg = item return ctx.run(self._evaluate_with_cache, sugg) with ThreadPoolExecutor(max_workers=workers) as pool: return list(pool.map(_run_in_context, tasks)) def _evaluate_with_cache(self, sugg: ParamSuggestion) -> EvaluationResult: if self.cache is None: return self._with_parameter_metadata(self.evaluator(sugg), sugg) key = params_hash(sugg.values, context=self.cache_context) hit = self.cache.get(key) if hit is not None: return EvaluationResult( trial_id=sugg.trial_id, sim_id=hit.sim_id, objective_value=hit.objective_value, status="completed", from_cache=True, components=hit.components, metadata={ "params_hash": key, "cached_status": hit.status, "parameters": self.space.describe_values(sugg.values), }, ) result = self.evaluator(sugg) if result.status == "completed" and math.isfinite(result.objective_value): self.cache.put( key, result.sim_id, objective_value=result.objective_value, components=result.components, ) # Enrich metadata with hash for persistence. meta = dict(result.metadata or {}) meta.setdefault("params_hash", key) meta.setdefault("parameters", self.space.describe_values(sugg.values)) return EvaluationResult( trial_id=result.trial_id, sim_id=result.sim_id, objective_value=result.objective_value, status=result.status, duration_s=result.duration_s, components=result.components, from_cache=result.from_cache, metadata=meta, ) def _with_parameter_metadata( self, result: EvaluationResult, sugg: ParamSuggestion, ) -> EvaluationResult: meta = dict(result.metadata or {}) meta.setdefault("parameters", self.space.describe_values(sugg.values)) return EvaluationResult( trial_id=result.trial_id, sim_id=result.sim_id, objective_value=result.objective_value, status=result.status, duration_s=result.duration_s, components=result.components, from_cache=result.from_cache, metadata=meta, )
__all__ = [ "CalibrationEngine", "CalibrationSession", "EvaluatorFn", ]