Source code for hydromodpy._api

"""Top-level functional API for HydroModPy.

Mirrors the CLI verbs so ``hmp run config.toml`` and ``hmp.run("config.toml")``
execute the same workflow. Kept as a private module so the package facade
stays minimal.

The run-management CLI verbs (``hmp catalog trash/restore/tag/note/rename/
rerun/diff/reindex/gc/watch``) are intentionally not mirrored as top-level
``hmp.*`` symbols. Their Python surface is the :class:`Catalog` handle returned
by :func:`open`: ``cat.trash(ref)``, ``cat.restore(ref)``, ``cat.add_tag(...)``,
``cat.rename_simulation(...)``, ``cat.diff(...)``, plus
:func:`hydromodpy.results.catalog.reindex.rebuild_index`. The
``hmp.cli._workers`` package is a CLI-private implementation detail, not a
public API.
"""

from __future__ import annotations

import importlib
import platform
import shutil
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any

from hydromodpy.core.version import __version__

if TYPE_CHECKING:
    import geopandas as gpd
    import pandas as pd
    import xarray as xr

    from hydromodpy.core.state.global_index import GlobalIndex
    from hydromodpy.project.spinup import SpinupResult
    from hydromodpy.results.catalog import Catalog
    from hydromodpy.results.run import Run

    Readable = xr.DataArray | pd.Series | pd.DataFrame | gpd.GeoDataFrame


[docs] def open(workspace: Any, *, create: bool = False, read_only: bool = True) -> Catalog: """Open a HydroModPy project catalog. The single door to a workspace catalog: returns a :class:`hydromodpy.results.catalog.Catalog` backed by ``<project>/.hmp/index.duckdb``. It exposes object access (``latest``, ``best``, ``find``, ``cat[ref]``), tabular access (``frame``, ``sql``, ``list_simulations``), schema discovery (``describe``, ``tables``, ``columns``, ``variables``, ``metrics``, ``stations``), and per-id reads (``read``). The default open is **read-only**: inspecting a catalog never migrates it, never rewrites a view, and never touches its mtime, so an archived project can be browsed without leaving a trace and a reader never contends with a running solve. Pass ``read_only=False`` (or ``create=True``) for the writable handle used to initialise or annotate a catalog. Parameters ---------- workspace Project directory holding ``.hmp/index.duckdb`` (or a direct path to the ``.duckdb`` file). create ``False`` (default) raises :class:`FileNotFoundError` when no catalog exists yet (no phantom catalog is created). ``True`` opens a writable handle and initialises an empty catalog. read_only ``True`` (default) opens the catalog read-only. Ignored when ``create=True`` (initialisation requires a writable handle). Returns ------- hydromodpy.results.catalog.Catalog Catalog handle for the project. Raises ------ FileNotFoundError If no index database is found and ``create`` is ``False``. hydromodpy.core.exceptions.CatalogError If the DuckDB catalog file is locked, corrupted, or unreadable. Examples -------- >>> import hydromodpy as hmp >>> cat = hmp.open("~/ws/projects/naizin") # doctest: +SKIP >>> cat.latest() # doctest: +SKIP See Also -------- hydromodpy.index Machine-wide federation across registered projects. """ from hydromodpy.core.state.paths import catalog_path_for, resolve_project_root from hydromodpy.results.catalog import Catalog ws = Path(workspace).expanduser().resolve() if ws.suffix == ".duckdb": catalog_file = ws else: catalog_file = catalog_path_for(resolve_project_root(ws)) if not create and not catalog_file.is_file(): raise FileNotFoundError( f"No catalog at {catalog_file.parent}. Run a workflow there first, " f"or pass create=True to initialise an empty catalog." ) if create: return Catalog(ws) return Catalog(ws, read_only=read_only)
[docs] def index(db_path: Any = None, *, read_only: bool = True) -> GlobalIndex: """Open the machine-wide global index that federates registered projects. One row is one project root, since a project root is what owns an index database. Registering a workspace root expands it into the project roots it holds. Read-only by default, mirroring :func:`open`: browsing the federation index never migrates it or touches its mtime, and a reader never contends with a concurrent solve. Pass ``read_only=False`` for the writable handle used to ``register`` / ``unregister`` / ``prune``. Parameters ---------- db_path Optional path to the index DuckDB file. ``None`` uses the default machine-state location. read_only Open the index in read-only mode (default ``True``). Writes (``register``, ``unregister``, ``prune``) will raise. Pure reads (``search``, ``find``, ``list_projects``) keep working while another process holds the write-lock. Returns ------- GlobalIndex Index object exposing ``register``, ``unregister``, ``list_projects``, ``find``, ``search`` and ``prune``. Raises ------ RuntimeError If a mutating method is called on a read-only handle. duckdb.IOException If the index database cannot be opened due to non-lock I/O errors. Examples -------- >>> import hydromodpy as hmp >>> idx = hmp.index(read_only=True) # doctest: +SKIP >>> idx.list_projects() # doctest: +SKIP See Also -------- hydromodpy.core.state.global_index.GlobalIndex Underlying federation implementation. """ from pathlib import Path as _Path from hydromodpy.core.state.global_index import GlobalIndex resolved = _Path(db_path).expanduser().resolve() if db_path is not None else None return GlobalIndex(resolved, read_only=read_only)
def _open_run(project_root: Any, sim_id: Any) -> Any: """Return the :class:`Run` for ``sim_id`` bound to an open catalog. Both :func:`run` branches expose the ``simulation`` workflow as a ``Run`` (or ``None`` for a dry-run / no persisted result) so :func:`read` / :func:`export` accept the result directly. The catalog is intentionally **not** context-managed here: the Run reads through it, so it must stay open after :func:`run` returns (matching ``cat = hmp.open(...); cat[ref]``). Returns ``None`` on any resolution failure rather than raising. """ if not sim_id: return None try: return open(project_root)[sim_id] except Exception: return None def _write_lock_for_run(config_source: Any, *, no_lock: bool) -> None: """Write ``hydromodpy.lock`` after a Python-driven simulation. Mirrors the CLI post-run lock write so ``hmp.run`` records the same reproducibility provenance the terminal does. Best-effort and silent on failure. ``config_source`` is a TOML path or a resolved config object. """ if no_lock: return try: from hydromodpy.project.lockfile import write_project_lockfile if isinstance(config_source, (str, Path)): from hydromodpy.config import HydroModPyConfig cfg = HydroModPyConfig.from_toml(config_source) else: cfg = config_source write_project_lockfile(cfg) except Exception: pass @contextmanager def _materialized_config(config: Any): """Write a resolved config to a temp TOML in its project_root and yield it. Lets the config-object branch of :func:`run` reuse the same path-based workflow adapters as the TOML branch. The temp file lives inside ``project_root`` so relative paths resolve identically; it is removed afterwards. """ import uuid root = Path(config.workspace.project_root).expanduser().resolve() materialized = root / f".hmp_effective_{uuid.uuid4().hex[:8]}.toml" config.to_toml(materialized) try: yield materialized finally: materialized.unlink(missing_ok=True)
[docs] def run(config: Any, **kwargs: Any) -> Run | dict | None: """Run a HydroModPy workflow from Python. Path and config-object inputs converge on the same dispatch. Simulation workflows return a :class:`~hydromodpy.results.run.Run` (or ``None`` when nothing was persisted, e.g. ``dry_run``). Overview, calibration, comparison and testbed workflows return their adapter ``dict`` summary. Parameters ---------- config TOML path or validated configuration object. kwargs Runtime options forwarded to the selected workflow. The ``headless`` keyword is honored on both branches (path and config object) and controls the underlying ``Project`` interactive side effects. Set ``no_lock=True`` to skip the post-run ``hydromodpy.lock`` write (a successful simulation writes the reproducibility lock by default, matching ``hmp run``). Returns ------- Run or None or dict ``Run`` instance (or ``None``) for the ``simulation`` workflow. ``dict`` summary for ``overview``, ``calibration``, ``comparison`` and ``testbed`` workflows. Raises ------ FileNotFoundError If the TOML path does not exist. hydromodpy.core.exceptions.ConfigError If the TOML payload fails Pydantic validation. hydromodpy.core.exceptions.PipelineError If a workflow step raises during execution. hydromodpy.core.exceptions.SolverError If the configured solver fails to converge or crashes. Examples -------- >>> import hydromodpy as hmp >>> run = hmp.run("run_transient_nwt.toml", name="baseline") # doctest: +SKIP See Also -------- hydromodpy.project.Project.simulate Object-oriented form for repeated runs from one project. """ headless = bool(kwargs.pop("headless", False)) no_lock = bool(kwargs.pop("no_lock", False)) if isinstance(config, (str, Path)): from hydromodpy.project.dispatch.workflow import dispatch_workflow from hydromodpy.workflow.dispatch import resolve_workflow config_path = Path(config).expanduser().resolve() workflow = resolve_workflow( config_path, cli_workflow=None, require_toml_field=True, ) summary = dispatch_workflow(workflow, config_path, **kwargs) # The simulation adapter returns a summary dict; expose it as the Run # so hmp.read / hmp.export accept the result on either branch. if workflow == "simulation": sim_id = summary.get("sim_id") if isinstance(summary, dict) else None run_obj = _open_run(config_path.parent, sim_id) if run_obj is not None: _write_lock_for_run(config_path, no_lock=no_lock) return run_obj return summary # In-memory config object: dispatch on the declared workflow mode so a # pure-Python config reaches every workflow, not only a plain simulation. mode = getattr(getattr(config, "workflow", None), "mode", "simulation") if mode == "simulation": from hydromodpy.project import Project with Project(config, headless=headless) as project: result = project.simulate(**kwargs) if result is None: return None _write_lock_for_run(config, no_lock=no_lock) # Re-bind to a freshly opened catalog so hmp.read works after the # project context (and its catalog connection) has closed. return _open_run(config.workspace.project_root, result.sim_id) or result from hydromodpy.project.dispatch.workflow import dispatch_workflow with _materialized_config(config) as materialized_path: return dispatch_workflow(mode, materialized_path, **kwargs)
def _phase_summaries(declarations: Any) -> list[dict[str, Any]]: """Describe declared calibration phases, in declaration order.""" return [ { "name": decl.name, "description": decl.description, "method": decl.method, "parameters": list(decl.parameters), "depends_on": decl.depends_on, "freeze_on_success": decl.freeze_on_success, } for decl in (declarations or []) ]
[docs] def calibrate( config: Any, *, phase: str | None = None, list_phases: bool = False, **kwargs: Any, ) -> Any: """Run a calibration workflow from a TOML file or config object. Paths route directly to :func:`run_calibration_cli`; in-memory config objects open a lazy :class:`Project` so :func:`run_calibration_programmatic` has the project context it requires. A TOML declaring ``[[calibration.phases]]`` **routes** to :func:`~hydromodpy.calibration.runners.staged_runner.run_staged_calibration`: each phase calibrates its own parameters and freezes them for the next. The same declaration carried by an in-memory config object is **refused**: a phase forks its configuration from the source file, and there is none. Write the config out and pass the path. Parameters ---------- config Calibration TOML path or validated configuration object. phase Run only the named phase. It still needs the parameters its dependency froze, so a phase whose dependency has not run is refused rather than calibrated against un-frozen values. list_phases Return the declared phases without running anything. kwargs Options forwarded to the underlying calibration runner. The ``headless`` keyword controls the project initialization for the in-memory config branch and is ignored for the TOML branch (which builds no project). Returns ------- Any Calibration report or workflow-specific result. A ``list`` of phase descriptions when ``list_phases`` is set. Raises ------ FileNotFoundError If the calibration TOML path does not exist. hydromodpy.core.exceptions.ConfigError If the configuration cannot be read and the answer depends on reading it (``phase`` or ``list_phases``). hydromodpy.core.exceptions.ConfigMissingError If neither ``config_path`` nor ``parameters`` is supplied. hydromodpy.core.exceptions.CalibrationError If a staged calibration cannot be run as declared, if ``phase`` names a phase the configuration does not declare, or if the optimizer or objective evaluation fails. Examples -------- >>> import hydromodpy as hmp >>> report = hmp.calibrate("calibration.toml") # doctest: +SKIP See Also -------- hydromodpy.calibration.runners.cli_runner.run_calibration_cli TOML entry point used by the path branch. hydromodpy.calibration.runners.staged_runner.run_staged_calibration Staged entry point used when the TOML declares phases. hydromodpy.calibration.runners.programmatic_runner.run_calibration_programmatic Python entry point used by the config-object branch. hydromodpy.calibration.CalibrationReport Structured calibration result. """ from hydromodpy.core.exceptions import CalibrationError, ConfigError from hydromodpy.project.dispatch.workflow import in_memory_staged_refusal, no_such_phase if isinstance(config, (str, Path)): from hydromodpy.calibration.runners.cli_runner import ( load_toml_calibration, run_calibration_cli, ) from hydromodpy.calibration.runners.staged_runner import run_staged_calibration kwargs.pop("headless", None) target = Path(config).expanduser().resolve() # Probe the file to decide staged or not. When that decision is all # that hangs on the read, a failure is left to the runner, which # reports it with its own context. When the answer itself is what the # file says, no runner is reached, so the read failure surfaces here. try: cfg, _raw = load_toml_calibration(target) except Exception as exc: if list_phases or phase is not None: raise ConfigError(f"{target} cannot be read: {exc}") from exc return run_calibration_cli(target, **kwargs) if list_phases: return _phase_summaries(cfg.phases) if cfg.phases: return run_staged_calibration(target, phase=phase, **kwargs) if phase is not None: raise CalibrationError(no_such_phase(target.name, phase)) return run_calibration_cli(target, **kwargs) declared = getattr(getattr(config, "calibration", None), "phases", None) if list_phases: return _phase_summaries(declared) if declared: raise CalibrationError(in_memory_staged_refusal([decl.name for decl in declared])) if phase is not None: raise CalibrationError(no_such_phase("this configuration", phase)) from hydromodpy.project import Project headless = bool(kwargs.pop("headless", True)) with Project(config, headless=headless) as project: return project.calibrate(**kwargs)
def spinup(config: Any, **kwargs: Any) -> SpinupResult: """Run a cyclic spin-up from a TOML file or config object. Repeats the representative window (``[spinup] window_*``, else ``[simulation.time]``), restarting each cycle from the previous cycle's state, until the aquifer heads and the lake stage converge. One :class:`Project` is reused so the mesh is identical across cycles. Parameters ---------- config TOML path or validated configuration object. kwargs Forwarded to :func:`~hydromodpy.project.spinup.run_spinup` (``spinup`` settings override, ``name_prefix``). ``headless`` controls the project. Returns ------- hydromodpy.project.spinup.SpinupResult The loop outcome. Feed ``result.restart_from`` to a production run's ``[flow] restart_from`` (enable ``[mesh_catchment] cache`` for a gmsh grid so that run reproduces this mesh). See Also -------- hydromodpy.project.spinup.run_spinup The underlying driver, callable on an existing Project. """ import copy import dataclasses from hydromodpy.project import Project from hydromodpy.project.facade import _resolve_config from hydromodpy.project.spinup import run_spinup headless = bool(kwargs.pop("headless", True)) then_run = bool(kwargs.pop("then_run", False)) source = Path(config).expanduser().resolve() if isinstance(config, (str, Path)) else config # Snapshot a clean production config before the spin-up mutates the model's # (cycle window, restart_from, IC). prod_cfg = None if then_run: if isinstance(source, Path): from hydromodpy.config import HydroModPyConfig prod_cfg = HydroModPyConfig.from_toml(source) else: prod_cfg = copy.deepcopy(_resolve_config(source)) with Project(source, headless=headless, no_display=True) as project: result = run_spinup(project, **kwargs) if not then_run or not result.restart_from: return result # Production run: a fresh project over the full [simulation.time] window, # seeded from the converged state. Its mesh must reproduce the spin-up mesh, # so a gmsh grid needs [mesh_catchment] cache = true. warning = result.antecedent_warning() if warning is not None: from hydromodpy.core.logging import get_logger get_logger(__name__).warning("chaining a production run: %s", warning) prod_cfg.flow.restart_from = result.restart_from production = run(prod_cfg, headless=headless) return dataclasses.replace(result, production_sim_id=getattr(production, "sim_id", None))
[docs] def compare_pair(sim_a: Any, sim_b: Any, *, workspace: Any = None) -> Any: """Compare two simulations by id or result object. Parameters ---------- sim_a, sim_b Simulation ids or objects accepted by the comparison runtime. workspace Optional workspace used to resolve simulation ids. Returns ------- pandas.DataFrame Side-by-side comparison table. Raises ------ hydromodpy.results.errors.RunNotFoundError If either simulation id cannot be resolved in the workspace. Examples -------- >>> import hydromodpy as hmp >>> table = hmp.compare_pair( ... "ab12cd34", "ef56gh78", workspace="~/hmp_workspace" ... ) # doctest: +SKIP See Also -------- hydromodpy.analysis.comparison Comparison package used by this helper. """ from hydromodpy.analysis.comparison.pairwise import compare_pair as _compare_pair return _compare_pair(sim_a, sim_b, workspace=workspace)
[docs] def report(session_id_or_prefix: Any = None, *, workspace: Any = None) -> Any: """Render the HTML report for a calibration session. ``session_id_or_prefix`` accepts a full UUID or a unique hex prefix of either a calibration session or one of its runs (an iteration or the promoted best run, as printed by ``hmp catalog ls``); the run reference is mapped to its parent session. ``None`` falls back to the most recently started session. ``workspace`` defaults to the nearest ancestor of the current working directory holding a project index database. When ``workspace`` is a workspace root, the lookup federates across every ``projects/<name>`` catalog, matching how ``hmp catalog ls`` lists runs. Parameters ---------- session_id_or_prefix Full UUID or unique hex prefix of a session or one of its runs, or ``None`` for the latest session. workspace Optional workspace root or project directory. Returns ------- Any Report rendering result. Raises ------ hydromodpy.results.errors.RunNotFoundError If no calibration session matches ``session_id_or_prefix``. hydromodpy.core.exceptions.DisplayError If the report template or one of its figures fails to render. Examples -------- >>> import hydromodpy as hmp >>> hmp.report() # latest session in the current workspace # doctest: +SKIP >>> hmp.report("ab12cd34", workspace="~/hmp_workspace") # doctest: +SKIP """ from hydromodpy.calibration.report import resolve_session_in_workspace from hydromodpy.core.state.paths import catalog_path_for from hydromodpy.results.catalog import Catalog from hydromodpy.workflow.steps.calibration import step_render_calibration_report if workspace is None: workspace_root = Path.cwd() for parent in [workspace_root] + list(workspace_root.parents): if (catalog_path_for(parent)).exists(): workspace_root = parent break else: workspace_root = Path(workspace).expanduser().resolve() catalog_root, full_id = resolve_session_in_workspace(workspace_root, session_id_or_prefix) with Catalog(catalog_root) as catalog: return step_render_calibration_report( catalog=catalog, session_id=full_id, workspace_root=catalog_root, )
[docs] def read( sim: Any, var: str, *, time: int | slice | None = None, layer: int | None = None, sel: dict | None = None, bbox: tuple[float, float, float, float] | None = None, ) -> Readable: """Read a variable from a simulation Run with storage-kind auto-dispatch. Single entry point for reads on a :class:`~hydromodpy.results.run.Run`. The return type follows one rule: - Zarr field -> ``xr.DataArray`` (lazy); when ``time`` is an ``int``, the eager ``np.ndarray`` of that single timestep instead. - timeseries -> ``pd.Series``. - geographic feature -> ``gpd.GeoDataFrame``. Every field a run reports through ``has_field`` reads back here. Fields rebuilt on the fly (water-table elevation/depth, seepage mask, drain outflow) are loaded eagerly rather than lazily, and ``time`` is ignored for a field with no time dimension. To read by reference (id / unique prefix / name) instead of a ``Run``, use ``cat.read(ref, var)`` on a :class:`hydromodpy.catalog.Catalog`. Parameters ---------- sim A :class:`~hydromodpy.results.run.Run` (e.g. ``cat.latest()`` or ``cat[ref]``). var Variable name, resolved against the field registry, then the DuckDB ``timeseries`` table, then the geographic features. time Timestep index (``int``) or ``slice`` for Zarr fields. ``None`` loads every persisted timestep lazily. layer Optional layer index for three-dimensional fields. sel Optional selectors forwarded to the reader: ``{"station": ...}`` for timeseries, ``{"period": ...}`` for a time window. bbox Optional ``(xmin, ymin, xmax, ymax)`` in the simulation CRS; restricts Zarr fields to faces whose centroid lies in the box. Returns ------- xarray.DataArray or numpy.ndarray or pandas.Series or geopandas.GeoDataFrame See the rule above. Raises ------ TypeError If ``sim`` is not a :class:`Run` instance. hydromodpy.results.errors.FieldNotFoundError If ``var`` could not be resolved by any backend. Examples -------- >>> import hydromodpy as hmp >>> cat = hmp.open("~/ws/projects/naizin") # doctest: +SKIP >>> run = cat.latest() # doctest: +SKIP >>> da = hmp.read(run, "head") # lazy DataArray # doctest: +SKIP >>> arr = hmp.read(run, "head", time=-1, layer=0) # ndarray # doctest: +SKIP >>> ts = hmp.read(run, "discharge", sel={"station": "outlet"}) # doctest: +SKIP >>> gdf = hmp.read(run, "watershed_polygon") # doctest: +SKIP """ from hydromodpy.results.derive.reading import read_variable return read_variable(sim, var, time=time, layer=layer, sel=sel, bbox=bbox)
def figure( sim: Any, name: str, *, save: Any = None, dpi: int = 150, **opts: Any, ) -> Any: """Render one registered figure for a simulation Run. The Python counterpart of ``[display].figures``: the same registry, the same names, the same options, so a figure produced by ``hmp run`` can be reproduced (or re-styled) from a script without importing anything from the display internals. List the names with :func:`hydromodpy.display.list_figures`. Parameters ---------- sim A :class:`~hydromodpy.results.run.Run` (e.g. ``cat.latest()``). name Registered figure name, for example ``"piezometric_map"``. save Optional output path. A directory (or an extension-less path) gets ``<name>.png`` appended. dpi Raster resolution used when saving. **opts Figure-specific options, identical to the ``[display.overrides]`` entries (``timestep``, ``overlays``, ``cmap``, ``units``, ...). Returns ------- matplotlib.figure.Figure The rendered figure. Raises ------ KeyError If ``name`` is not registered. ValueError If the run does not carry what the figure needs. Examples -------- >>> import hydromodpy as hmp >>> cat = hmp.open("~/ws/projects/aber") # doctest: +SKIP >>> run = cat.latest() # doctest: +SKIP >>> hmp.figure(run, "cross_section", orientation="sn") # doctest: +SKIP """ from pathlib import Path as _Path from hydromodpy.display import get as _get_figure renderer = _get_figure(name) reason = renderer.unavailable_reason(sim) if reason is not None: raise ValueError(f"figure '{name}' does not apply to this run: {reason}") save_path = None if save is not None: target = _Path(save) save_path = target / f"{name}.png" if target.suffix == "" else target return renderer.plot(sim, dpi=dpi, save_path=save_path, **opts)
[docs] def export( sim: Any, var: str | list[str], dest: Any, *, fmt: str | None = None, time: int | str | None = None, layer: int | None = None, resolution: float | None = None, crs: str | None = None, nodata: float = -9999.0, ) -> Path: """Export a variable from a simulation to a standalone file. Functional mirror of :func:`read`: same selector (``sim`` / ``var`` / ``time`` / ``layer``) plus an output format and destination. ``sim`` must be a :class:`~hydromodpy.results.run.Run`, as returned by ``hmp.open(workspace)[ref]`` or ``catalog.latest()``. ``fmt`` is optional when ``dest`` carries a known extension (``.nc`` -> netcdf, ``.tif`` -> geotiff, ``.csv`` -> csv, ``.shp`` -> shapefile, ``.vtu`` -> vtu, ``.hmp`` -> portable package). Examples -------- >>> import hydromodpy as hmp >>> run = hmp.open("~/hmp_workspace")["transient_nwt"] # doctest: +SKIP >>> hmp.export( ... run, "head", "head.tif", time="last", resolution=50 ... ) # doctest: +SKIP >>> hmp.export( ... run, ["head", "watertable_depth"], "fields.nc", time="all" ... ) # doctest: +SKIP """ from hydromodpy.results.run import Run if not isinstance(sim, Run): raise TypeError( f"hmp.export expects a Run object as first argument, got {type(sim).__name__}. " f"Obtain one with hmp.open(workspace)[ref] or catalog.latest()." ) return sim.export( var, dest, fmt=fmt, time=time, layer=layer, resolution=resolution, crs=crs, nodata=nodata, )
[docs] def doctor() -> dict: """Lightweight environment diagnostic. Returns a dict describing Python, hydromodpy, and solver versions. Quick by design (no actual solver invocation) and safe to call at import probing time. Returns ------- dict Diagnostic payload with Python, HydroModPy, optional package, and solver executable information. Examples -------- >>> import hydromodpy as hmp >>> hmp.doctor()["hydromodpy"] # doctest: +SKIP """ info: dict = { "python": platform.python_version(), "hydromodpy": __version__, "solvers": {}, "optional": {}, } for pkg in ( "numpy", "pandas", "scipy", "duckdb", "zarr", "pyproj", "rasterio", "shapely", "xarray", "flopy", "pydantic", "pint", "matplotlib", "gmsh", "whitebox_workflows", "geopandas", "pyvista", ): try: mod = importlib.import_module(pkg) info["optional"][pkg] = getattr(mod, "__version__", "?") except Exception: info["optional"][pkg] = None for exe in ("mf2005", "mfnwt", "mf6", "mp6", "mp7", "mt3dusgs"): info["solvers"][exe] = shutil.which(exe) return info