Source code for hydromodpy.project.runner

"""Run-phase orchestration for :class:`hydromodpy.project.Project`.

Holds the internal run-phase entry points backing the facade: ``run`` (the
engine method that ``Project.simulate`` delegates to) and ``sweep``. The
facade verb is :meth:`Project.simulate`.

TOML-driven workflows that do not benefit from setup-once state (overview,
mesh, comparison) are reached through :func:`hydromodpy.run` on the
``[workflow] mode``, not through ``Project``.
"""

from __future__ import annotations

from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

from hydromodpy.core.exceptions import ConfigError, ResumeError
from hydromodpy.core.logging import get_logger

if TYPE_CHECKING:
    from hydromodpy.project.facade import Project
    from hydromodpy.results.run import Run

logger = get_logger(__name__)

DEFAULT_RUN_NAME_TEMPLATE = "run_{counter:04d}"


@contextmanager
def _pin_parent_sim_id(ctx: Any, parent_sim_id: str | None) -> Iterator[None]:
    """Temporarily set ``ctx.parent_sim_id`` for the duration of the block.

    Restores the previous value on exit, including when an exception
    propagates. ``None`` is a no-op so callers can pass through unchanged.
    """
    if parent_sim_id is None:
        yield
        return
    previous = getattr(ctx, "parent_sim_id", None)
    ctx.parent_sim_id = str(parent_sim_id)
    try:
        yield
    finally:
        ctx.parent_sim_id = previous


def _resolve_step_index(step: str | int, steps: tuple) -> int:
    """Resolve a step name (or integer/digit string) to a tuple index.

    Accepts either the ``step.name`` attribute (snake_case, e.g.
    ``"setup_process"``), the class name (``"SetupProcessStep"``), the
    class-name prefix (``"setupprocess"``), or a numeric index.
    """
    if isinstance(step, int):
        return step
    text = str(step)
    if text.isdigit():
        return int(text)
    lower = text.lower()
    target = lower.removesuffix("step").rstrip("_")
    flat = target.replace("_", "")
    for idx, obj in enumerate(steps):
        if getattr(obj, "name", None) == lower:
            return idx
        candidate = type(obj).__name__.lower().removesuffix("step").rstrip("_")
        if candidate == flat:
            return idx
    known = ", ".join(type(s).__name__ for s in steps)
    raise ConfigError(f"Unknown pipeline step: {step!r}. Known steps: {known}")


def _resolve_resume_step_index(
    workspace: Path,
    run_id: str,
    *,
    steps_blueprint: tuple[str, ...] | None = None,
) -> int:
    """Locate the next step index to execute for a previously interrupted run.

    The workflow journal in the project index is the single source of truth:
    when no row exists for ``run_id`` the run is treated as fresh and starts
    from step 0.
    """
    from hydromodpy.results.catalog import Catalog
    from hydromodpy.workflow.tracking.journal import WorkflowJournal
    from hydromodpy.workflow.tracking.resume import ResumePlanner

    try:
        catalog = Catalog(workspace)
    except Exception as exc:
        raise ResumeError(
            f"Could not open the catalog at {workspace} to resume '{run_id}': {exc}"
        ) from exc

    try:
        journal = WorkflowJournal(catalog)
        planner = ResumePlanner(journal, workspace)
        plan = planner.compute(
            run_id=run_id,
            current_config_sha256=None,
            steps_blueprint=steps_blueprint or (),
        )
    finally:
        try:
            catalog.close()
        except Exception:
            pass

    # Make a degraded resume visible: a snapshot-rebuilt config or a changed
    # pipeline can force a full restart, which otherwise looks like a normal run.
    if plan.full_restart:
        logger.warning(
            "resume %r cannot pick up where it left off (%s); restarting from step 0",
            run_id,
            plan.reason or "pipeline or config changed",
        )
    elif plan.restart_index == 0:
        logger.warning(
            "resume %r found no resumable journal (%s); starting fresh from step 0",
            run_id,
            plan.reason or "no journal entries",
        )
    else:
        logger.info(
            "resume %r picks up from step %d (last completed: %s)",
            run_id,
            plan.restart_index,
            plan.last_completed.step_name if plan.last_completed is not None else "?",
        )
    return plan.restart_index


def _print_dry_run_plan(
    *,
    run_id: str,
    steps: tuple,
    resume_from: int | None,
) -> None:
    """Emit the resolved Pipeline plan without executing any step."""
    print(f"[dry-run] run_id    : {run_id}")
    if resume_from is not None:
        print(f"[dry-run] resume_from: {resume_from}")
    print("[dry-run] steps     :")
    for idx, step in enumerate(steps):
        print(f"  {idx:02d}  {type(step).__name__}")


def _rebind_run_history_catalog(project: Project) -> None:
    """Bind previously returned Run handles to the current project catalog."""
    store = project._store
    if store is None:
        return
    project._ctx.store = store
    for run in project._run_history:
        run._catalog = store


[docs] class ProjectRunner: """Run-phase methods bound to a :class:`Project` instance. Composed by :class:`Project` (``project._runner``). Holds no state of its own besides the back-reference to ``project`` and a delegate for the prepared-run primitives; every call reads or mutates the project's workflow context directly. """ def __init__(self, project: Project) -> None: self._project = project # -- High-level workflow entry points ---------------------------------
[docs] def run( self, *, name: str | None = None, resume: str | None = None, from_step: str | int | None = None, until_step: str | int | None = None, dry_run: bool = False, frozen: bool = False, no_display: bool = False, parallel: bool = True, **overrides, ) -> Run | None: """Run the simulation through the canonical workflow Pipeline. ``parallel`` (default True) lets the Pipeline dispatch independent Kahn cohorts through a thread pool. Pass ``parallel=False`` to force the legacy sequential path (useful for debugging). """ from hydromodpy.workflow.internals.state import PipelineState from hydromodpy.workflow.orchestrator import standard_steps from hydromodpy.workflow.runner import Pipeline from hydromodpy.workflow.steps.planning import step_build_plan project = self._project skip_display = bool(project._no_display) or bool(no_display) thickness = overrides.pop("thickness", None) first_clim = overrides.pop("first_clim", None) properties = overrides.pop("properties", None) if name is None: project._run_counter += 1 name = DEFAULT_RUN_NAME_TEMPLATE.format(counter=project._run_counter) all_steps = standard_steps() steps = all_steps if until_step is not None: until_idx = _resolve_step_index(until_step, all_steps) steps = tuple(all_steps[: until_idx + 1]) workspace_path = self._resolve_workspace_path() # A model-phase-ready run reuses the model built in this process; its # resume_from > 0 is an in-process skip, not a journal resume of a prior # same-name run (which would otherwise abort on an edited config). model_phase_ready = False if from_step is not None: resume_from: int | None = _resolve_step_index(from_step, all_steps) run_id = resume or name elif resume is not None: resume_from = _resolve_resume_step_index( workspace_path, resume, steps_blueprint=tuple(getattr(step, "name", "") for step in all_steps), ) run_id = resume elif self._is_model_phase_ready(): resume_from = _resolve_step_index("setup_process", all_steps) run_id = name model_phase_ready = True else: resume_from = None run_id = name if dry_run: _print_dry_run_plan( run_id=run_id, steps=steps, resume_from=resume_from, ) return None step_build_plan( project._ctx, name=name, overrides=overrides or {}, thickness=thickness, first_clim=first_clim, solver=project._solver, ) if properties is not None: project._ctx.setup.flow_runtime_overrides = { "source": "project_run", "properties": dict(properties), } else: project._ctx.setup.flow_runtime_overrides = None project._ctx.setup.run_id = name if project._store is not None: project._store.close() project._store = None project._ctx.store = None initial = PipelineState( run_id=run_id, data={ "ctx": project._ctx, "cfg": project._cfg, "config_path": project._config_path, "raw_toml": getattr(project._ctx, "raw_toml", {}) or {}, "skip_display": skip_display, # The geographic preprocessing tree is shared by every run of # this Project. Dropping it at the end of the first one leaves # the next ones without a DEM to ingest; Project.close() owns it. "keep_preprocessing": True, "spatial_support_registry": project._spatial_support_registry, "requested_spatial_support_ids": project._requested_support_ids, "requested_domain_supports": project._requested_domain_supports, }, ) pipeline = Pipeline(steps, workspace=workspace_path) restore_frozen_root: Path | None = None if frozen: from hydromodpy.data.data_freeze import frozen_project_root, set_frozen_mode restore_frozen_root = frozen_project_root() set_frozen_mode(True, project_root=workspace_path) try: final = pipeline.run( initial, resume_from=resume_from, parallel=parallel, model_phase_ready=model_phase_ready, ) except Exception: from hydromodpy.project import phases as project_phases project_phases.open_catalog(project) failed_sim_id = getattr(project._ctx, "sim_id", None) if failed_sim_id is not None and project._store is not None: try: project._store.finalize(failed_sim_id, status="failed") except Exception: logger.debug( "Could not mark failed simulation %s after pipeline error", failed_sim_id, exc_info=True, ) raise finally: from hydromodpy.project import phases as project_phases if project._store is None: project_phases.open_catalog(project) _rebind_run_history_catalog(project) if frozen: from hydromodpy.data.data_freeze import set_frozen_mode set_frozen_mode( restore_frozen_root is not None, project_root=restore_frozen_root, ) final_ctx = final.get("ctx") if final is not None else None sim_id = getattr(final_ctx, "sim_id", None) if final_ctx is not None else None if sim_id is None or project._store is None: return None run_view = project._store[sim_id] project._run_history.append(run_view) _rebind_run_history_catalog(project) return run_view
[docs] def sweep( self, parameters: dict[str, list[float] | dict], *, strategy: str = "enumerate", name_template: str = "{param}_{value:.4g}", parallel: int = 1, ): """Run N simulations from a parameter table. ``parallel > 1`` enables the thread-pool backend in :func:`hydromodpy.workflow.parallel.run_sweep`. Threads are chosen over processes because the live ``Project`` (DuckDB catalog, Zarr store, in-memory ``WorkflowContext``) is not pickle-safe. """ from hydromodpy.results.run.group import RunSet from hydromodpy.workflow.parallel import run_sweep self._project._ensure_model_built() sim_ids = run_sweep( cast(Any, self._project), parameters=parameters, strategy=strategy, name_template=name_template, parallel=parallel, ) return RunSet(sim_ids, self._project._store)
# -- Helpers ---------------------------------------------------------- def _is_model_phase_ready(self) -> bool: """Return True when Project's eager init has produced the runtime objects.""" setup = self._project._ctx.setup return ( setup.workspace is not None and setup.geographic is not None and setup.domain is not None ) def _resolve_workspace_path(self) -> Path: """Return the project runtime root used for checkpoints and ledger.""" project = self._project workspace = project._ctx.setup.workspace if workspace is not None: return Path(workspace.project_root) if project._config_path is not None: return project._config_path.parent return Path.cwd()