Add a Solver#
A solver in HydroModPy is one (process_type, solver_name) pair
bound to a concrete backend. Today three backends ship: modflow6,
modflow_nwt, and boussinesq. This page documents how to add a
fourth flow backend or, equivalently, a new transport /
postprocess solver.
Contract#
A solver implements the SolverAdapter Protocol declared in
hydromodpy/solver/base/adapter_protocol.py:
class SolverAdapter(Protocol):
process_type: ClassVar[str] # "flow", "transport", "postprocess"
solver_name: ClassVar[str] # "modflow6", "modflow_nwt", "boussinesq", ...
requires: ClassVar[tuple[tuple[str, str], ...]] = () # upstream (process, solver) pairs
def validate(self, ctx: RunContext) -> None: ...
def execute(self, ctx: RunContext) -> RunExecutionResult: ...
def cleanup(self, ctx: RunContext) -> None: ...
def extract_observables(
self,
ctx: RunContext,
store,
requests: "Sequence[ObservableRequest]",
*,
time_index=None,
) -> "dict[str, ObservableResult]": ...
RunExecutionResult is a small dataclass exposing
success: bool and outputs: dict | None.
A separate output adapter (the extractor) ingests the persisted
outputs into the catalog. It is registered on the same
(process_type, solver_name) pair as the adapter and implements:
class SolverOutputAdapter:
def extract(self, sim_id: str, solver_output_dir: Path, store) -> None: ...
def derive(self, sim_id: str, store, ...) -> None: ...
Files to create#
For a new flow backend called mysolver:
hydromodpy/solver/mysolver/
|-- __init__.py
|-- mysolver.py # core Solver class
|-- mysolver_config.py # Pydantic config block
|-- adapters/
| |-- __init__.py
| `-- flow.py # MysolverFlowAdapter (SolverAdapter)
|-- extractors/
| |-- __init__.py
| `-- flow.py # MysolverOutputAdapter (extract())
`-- README.md # short rationale and code map
If your backend shares MODFLOW-family lifecycle helpers, reuse
solver/modflow_common/flow_adapter_helpers.py instead of
duplicating preprocess/postprocess flow.
Minimal adapter skeleton#
# hydromodpy/solver/mysolver/adapters/flow.py
from typing import ClassVar
from hydromodpy.solver.base.adapter_protocol import RunExecutionResult
class MysolverFlowAdapter:
process_type: ClassVar[str] = "flow"
solver_name: ClassVar[str] = "mysolver"
requires: ClassVar[tuple[tuple[str, str], ...]] = ()
def validate(self, ctx) -> None:
# cheap pre-flight checks on ctx.run, ctx.state.setup
...
def execute(self, ctx) -> RunExecutionResult:
# build, run, persist
outputs = {...}
return RunExecutionResult(success=True, outputs=outputs)
def cleanup(self, ctx) -> None:
# remove scratch files; leave persisted outputs alone
...
def extract_observables(self, ctx, store, requests, *, time_index=None):
# cheap extraction inside the calibration loop: the whole batch
# arrives at once, so each binary file is opened once. Refuse what
# the backend cannot produce with ObservableNotAvailableError.
...
Registration#
Two paths, depending on whether the backend ships inside the HydroModPy repository or as an external plugin.
In-tree backend. Register through the two built-in path tables in
hydromodpy/solver/base/registry.py. Both tables are keyed on the
same ``(process_type, solver_name)`` tuple — the extractor table is
not keyed on the solver name alone, because one backend can carry a
different extractor per process (flow vs transport for MODFLOW 6):
_BUILTIN_PATHS[("flow", "mysolver")] = (
"hydromodpy.solver.mysolver.adapters.flow:MysolverFlowAdapter"
)
_BUILTIN_EXTRACTOR_PATHS[("flow", "mysolver")] = (
"hydromodpy.solver.mysolver.extractors.flow:MysolverOutputAdapter"
)
A string key such as _BUILTIN_EXTRACTOR_PATHS["mysolver"] registers
nothing that get_extractor("flow", "mysolver") can find:
get_extractor_instance then returns None and post_run_results
treats it as a configuration error. The backend solves and the run is
lost on the way to the catalog.
External plugin. Declare the entry points in your distribution’s
pyproject.toml. Ship both groups, for the same reason:
[project.entry-points."hydromodpy.solver"]
flow_mysolver = "mypkg.adapters:MysolverFlowAdapter"
[project.entry-points."hydromodpy.solver.extractor"]
flow_mysolver = "mypkg.extractors:MysolverOutputAdapter"
Entry-point names are split at the first underscore, so
flow_mysolver yields ("flow", "mysolver"). Never declare an
in-tree backend through an entry point as well: flow_modflownwt
would mint a second pair flow/modflownwt next to the real
flow/modflow_nwt, which the config accepts but which has an adapter
and no extractor.
Nothing is loaded at import time. registry.get(process_type,
solver_name) serves explicit registrations first, then lazy-imports
the _BUILTIN_PATHS entry, and only calls load_plugins() once if
the pair is still unknown. Launchers may also call
SolverConfig.validate_registry() to force that scan up front and
fail early on an unknown backend name. The runner instantiates the
adapter through registry.get_solver_adapter(process_type,
solver_name).
Capabilities (optional but recommended)#
Declare the capabilities your backend exposes so the planner can report them and tests can opt in.
_BUILTIN_CAPABILITIES[("flow", "mysolver")] = frozenset({
"flow",
"flow:heads",
"flow:budget",
})
Config block#
If the backend needs its own [mysolver] TOML section, add a
Pydantic model:
# hydromodpy/solver/mysolver/mysolver_config.py
from pydantic import BaseModel, ConfigDict, Field
from hydromodpy.core.config_kit.profile import Profile
from typing import Annotated
class MysolverConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
max_iter: Annotated[int, Profile.USER] = Field(
default=200, description="Newton-iteration ceiling."
)
Then wire it into HydroModPyConfig (see
Add a Config Field).
Tests to add#
Three tiers, smallest first:
Unit under
tests/unit/solver/mysolver/forvalidate,execute, andcleanupagainst a small in-memoryctx.Integration under
tests/integration/solver/for one end-to-endhmp runon a tiny case.Validation under
tests/validation/(analytical or numerical) with atolerances.tomlcompanion if a scientific reference exists.
If the backend wraps a binary, mark the validation test
@pytest.mark.solver_sanity so it can be selected separately.
See Add a Test for the full ladder.
Pitfalls flagged by the layer matrix#
solvermay importphysics,spatial,simulation, but notdata,calibration,results,display,analysis, orworkflow. If your adapter needs results, hand the data through thectxrather than importingresults.Do not cross-import another backend (
solver/modflow6/cannot importsolver/modflow_nwt/and vice versa). Shared helpers go insolver/modflow_common/orsolver/modflow_grid/.Underscored modules of another package (
data/_*,simulation/_*) are off-limits.
See also#
solver for the solver package map.
Add a Process if you also need a new
ProcessSpatialfor the runtime payload your adapter consumes.Add a Calibration Method if your backend changes how the ask/tell loop should observe outputs.
Layered Architecture for the layer matrix that constrains the imports above.