Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
654fb14
docs(examples): add a Coello distributed run driven from one NetCDF
MAfarrag Aug 29, 2026
6f8ca71
feat(config): build a Catchment from a YAML run configuration
MAfarrag Aug 29, 2026
f953493
style(examples): drop the unneeded quotes around results_dir
MAfarrag Aug 29, 2026
0b96045
docs(examples): drive the lumped and MAXBAS runs from YAML too
MAfarrag Aug 29, 2026
59197e7
refactor(config): make config a pure schema and own the building in C…
MAfarrag Aug 30, 2026
b0f97d1
refactor(inputs): let MeteoInputs build itself from a meteo configura…
MAfarrag Aug 30, 2026
f7add50
fix(inputs): raise instead of asserting the fields from_config requires
MAfarrag Aug 30, 2026
ece31ea
fix(catchment): raise instead of asserting the input checks
MAfarrag Aug 30, 2026
12df630
fix(run): raise instead of asserting the input-dimension checks
MAfarrag Aug 30, 2026
1b4c6b6
fix: raise instead of asserting the remaining input checks
MAfarrag Aug 30, 2026
08c96c3
feat(config): make the parameters and gauges blocks optional
MAfarrag Aug 30, 2026
598d386
test(config): cover the run-configuration schema and the model it builds
MAfarrag Aug 30, 2026
742e7ed
fix(config): require a flow-direction raster for a Muskingum run
MAfarrag Aug 30, 2026
cec4bcc
fix(catchment): decode the run configuration as UTF-8
MAfarrag Aug 30, 2026
8d5cadb
fix(catchment): canonicalise routing_method in the constructor
MAfarrag Aug 30, 2026
9b6e8d7
fix(inputs): parse each meteo bound with the format it was written in
MAfarrag Aug 30, 2026
fc13fc6
fix(config): require the routing method and the parameter set to agree
MAfarrag Aug 30, 2026
03e2e4c
feat(catchment): keep the run configuration on the model it built
MAfarrag Aug 30, 2026
c8fcfb2
docs: name the exception each converted check actually raises
MAfarrag Aug 30, 2026
65e9244
fix(rrm): keep the maxbas guard firing on NaN
MAfarrag Aug 30, 2026
e93e75a
fix(config): validate the dates, and reject blocks a lumped run canno…
MAfarrag Aug 30, 2026
d7c5264
refactor(catchment): resolve the conceptual model before reading any …
MAfarrag Aug 30, 2026
859154e
fix(config): let a netcdf_files run pick its variable, and describe c…
MAfarrag Aug 30, 2026
3bff289
fix(catchment): accept a Path, and name the file when it is empty
MAfarrag Aug 30, 2026
0d280e9
test(inputs): cover the raster and netcdf-files branches of from_config
MAfarrag Aug 30, 2026
d4be1f8
docs(config): publish the schema reference and say where the examples…
MAfarrag Aug 30, 2026
cdb1f70
docs(catchment): document the datetime the date guards now accept
MAfarrag Aug 30, 2026
b36b366
test: close the last uncovered branches this round introduced
MAfarrag Aug 30, 2026
dc4d12a
docs(config): describe the constraints routing_method and flow_networ…
MAfarrag Aug 30, 2026
48d5569
fix(config): resolve relative paths against the configuration file
MAfarrag Aug 30, 2026
9b52f69
refactor(catchment)!: restrict routing_method to the three known methods
MAfarrag Aug 30, 2026
6a8abe6
fix(catchment): fill QGauges by the label the frame was built with
MAfarrag Aug 30, 2026
5976a86
feat(config): accept a date YAML has already parsed
MAfarrag Aug 30, 2026
0d4aae2
feat(config): refuse fields the chosen run shape never reads
MAfarrag Aug 30, 2026
429d194
fix(config): derive routing_method from the parameter set when it is …
MAfarrag Aug 30, 2026
73354f4
feat(config): separate the gauge table's date format from the dischar…
MAfarrag Aug 30, 2026
90c073b
fix(catchment): name the argument when a mode argument is not a string
MAfarrag Aug 30, 2026
b58b386
docs(config): link the schema's routing methods to the constructor's
MAfarrag Aug 30, 2026
808e630
fix(routing): reject a MAXBAS below one in calculate_weights
MAfarrag Aug 30, 2026
3fd0b43
fix(catchment): join the results directory instead of concatenating it
MAfarrag Aug 30, 2026
809aae5
chore(examples): ignore the output the Coello examples write
MAfarrag Aug 30, 2026
206c296
feat(config): name every missing input path before the first reader runs
MAfarrag Aug 30, 2026
b78b07d
fix(config): validate the resolved meteorological window, not the two…
MAfarrag Aug 30, 2026
20e48c7
feat(run): refuse Run.from_yaml with a message that names the pattern
MAfarrag Aug 30, 2026
74a80be
build: cap pydantic at the major boundary
MAfarrag Aug 30, 2026
0c42e45
test(config): move the config tests beside the fixtures they use
MAfarrag Aug 30, 2026
e1e777e
build: record the pydantic cap in the lockfile
MAfarrag Aug 30, 2026
7017c30
docs(config): correct three claims the code no longer supports
MAfarrag Aug 30, 2026
512b56d
refactor(config): phrase the two duplicated checks once
MAfarrag Aug 30, 2026
259258a
ci: run the docstring examples that actually execute
MAfarrag Aug 30, 2026
31d0c23
style(pixi): wrap the doctests command within the line limit
MAfarrag Aug 30, 2026
cd737e7
docs: document the YAML run configuration
MAfarrag Aug 30, 2026
b0e08d8
test(config): cover the round-2 surface and close the review's test gaps
MAfarrag Aug 30, 2026
538373e
docs(run): add executable examples to the two symbols that lacked them
MAfarrag Aug 30, 2026
01de124
style: apply ruff-format and normalise line endings
MAfarrag Aug 30, 2026
1b42b5e
fix(sonar): repair the two defects the PR analysis found
MAfarrag Aug 30, 2026
fcac42e
refactor(sonar): give each spatial resolution its own method
MAfarrag Aug 30, 2026
2e1b909
test(sonar): leave one throwing call inside each pytest.raises block
MAfarrag Aug 30, 2026
f189ed0
refactor(sonar): declare the optimisation variables in one place
MAfarrag Aug 30, 2026
6251b4e
test(calibration): cover the three guards this session's Sonar fixes …
MAfarrag Aug 30, 2026
676682e
test(sonar): split the new guard test's raises block and assertion
MAfarrag Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,16 @@ jobs:
# fix to apply.
run: pre-commit run --all-files --show-diff-on-failure --color=always
env:
SKIP: no-commit-to-branch,mypy,pytest-check,notebook-check,pixi-lock-check
SKIP: no-commit-to-branch,mypy,pytest-check,notebook-check,pixi-lock-check,doctest-executable-modules

# Type check. mypy needs the full `dev` pixi env (typed deps and an importable
# package) but is neither platform- nor Python-version-sensitive, so one runner
# covers it. It runs nowhere else in CI today.
#
# No doctest step yet: the `>>>` examples in the HBV modules are stale and the
# matching pre-commit hook is disabled for the same reason. Add
# `pixi run -e dev pytest --doctest-modules src -p no:cacheprovider` here once they
# are repaired, and re-enable the hook alongside it.
# The doctest step below covers only the modules whose examples run; the `>>>` examples
# in the HBV modules are stale, which is why the whole-`src` pre-commit hook is still
# disabled. Widen the `doctests` task in pyproject.toml as those are repaired, and
# re-enable that hook once it covers everything.
static:
runs-on: ubuntu-latest
timeout-minutes: 20
Expand All @@ -89,3 +89,12 @@ jobs:

- name: Type check with mypy
run: pixi run -e dev mypy

# These examples assert on concrete values read from real data, so running them is
# what keeps them from drifting into prose. Needs the repo root as the working
# directory, which is where the paths in them are written from.
- name: Run the executable docstring examples
run: pixi run -e dev doctests
env:
HAPI_DATA_DIR: src/hapi/parameters
MPLBACKEND: Agg
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,9 @@ tests/mo/*
/tests/rrm/data/test_results/prepare_inputs/7_precip_2009.01.08.tif
/tests/rrm/data/test_results/prepare_inputs/8_precip_2009.01.09.tif
/tests/rrm/data/test_results/prepare_inputs/9_precip_2009.01.10.tif

# Output of the four YAML-driven Coello examples. They write into the same data tree they
# read from, so their results sit beside tracked inputs; the inputs stay tracked and the
# per-run artefacts do not.
/examples/hydrological-model/data/distributed_model/results/Result_*.tif
/examples/hydrological-model/data/lumped_model/*Results-Lumped-Model_*.txt
12 changes: 12 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,18 @@ repos:
pass_filenames: false
always_run: true

# The narrow half of the hook below: the modules whose examples do run. They assert on
# concrete values read from real data, so leaving them unexecuted means they drift silently.
# Add a module to the `doctests` task in pyproject.toml once its examples pass.
- repo: local
hooks:
- id: doctest-executable-modules
name: "[py - doctest] executable examples"
entry: pixi run --frozen -e dev doctests
language: system
pass_filenames: false
always_run: true

# - repo: local
# hooks:
# # Disabled until the docstring examples are repaired: 19 doctests in the
Expand Down
22 changes: 22 additions & 0 deletions docs/api/catchment.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,26 @@
# Catchment

## Routing methods

`Catchment` and `Calibration` accept exactly three routing methods, matched case-insensitively
and stored in the one spelling the internals compare against:

| Written as | Stored as | Routes |
|---|---|---|
| `muskingum` | `Muskingum` | Cell to cell along the flow-direction network. |
| `maxbas` | `MAXBAS` | Every cell straight to the outlet through a triangular function. |
| `kinematic` | `Kinematic` | The flood model's own path (`Run.RunFloodModel`). |

Anything else raises a `ValueError` naming the three. Up to and including version 1.7.0 the
constructor stored whatever string it was handed, so a run configured as `"Max_bas"` — or as a
descriptive label such
as `"Muskingum-Cunge"` — was accepted and then silently routed with Muskingum, because
`distrrm.SpatialRouting` compares against `"Muskingum"` exactly. Rejecting the spelling is what
makes that comparison trustworthy; a script passing a spelling outside the table has to be updated
to one of the three.

A YAML run configuration reaches only the first two: `kinematic` selects the flood model, which
[`hapi.config`](config.md) does not describe.

## Catchment
::: hapi.catchment.Catchment
32 changes: 32 additions & 0 deletions docs/api/config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Config

The schema of a YAML run configuration. Each block below is one top-level key of the file; the
rules tying them together — which blocks a given `spatial_resolution` requires, and which it
refuses — live on `RunConfig`.

Build a model from a file with
[`Catchment.from_yaml`](catchment.md#hapi.catchment.Catchment.from_yaml).

## RunConfig
::: hapi.config.RunConfig

## CatchmentConfig
::: hapi.config.CatchmentConfig

## MeteoConfig
::: hapi.config.MeteoConfig

## FlowNetworkConfig
::: hapi.config.FlowNetworkConfig

## ParametersConfig
::: hapi.config.ParametersConfig

## ConceptualModelConfig
::: hapi.config.ConceptualModelConfig

## GaugesConfig
::: hapi.config.GaugesConfig

## OutputsConfig
::: hapi.config.OutputsConfig
7 changes: 7 additions & 0 deletions docs/examples/distributed-model-run.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# Distributed Hydrological Model

!!! tip "Or drive it from a YAML file"

Everything this page assembles in Python can live in a run configuration instead --
one file holding the paths, dates and settings, read by `Catchment.from_yaml`. See
[Run configuration](run-configuration.md).

After preparing all the meteorological, GIS inputs required for the model, and Extracting the parameters for the catchment

```python
Expand Down
7 changes: 7 additions & 0 deletions docs/examples/lumped-model-run.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# Lumped Model Run

!!! tip "Or drive it from a YAML file"

Everything this page assembles in Python can live in a run configuration instead --
one file holding the paths, dates and settings, read by `Catchment.from_yaml`. See
[Run configuration](run-configuration.md).

To run the HBV lumped model inside Hapi you need to prepare the meteorological inputs (rainfall, temperature and potential evapotranspiration), HBV parameters, and the HBV model (you can load Bergström, 1992 version of HBV from Hapi )

- First load the prepared lumped version of the HBV module inside Hapi, the triangular routing function and the wrapper function that runs the lumped model `RUN`.
Expand Down
167 changes: 167 additions & 0 deletions docs/examples/run-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Run Configuration (YAML)

The other example pages assemble a model in Python: construct a `Catchment`, then call
`read_lumped_inputs`, `read_parameters`, `read_lumped_model` and the rest in the right order.
That works, but it puts every path, date and area inside the script, so a script is only ever
about one catchment — and the call order is something you have to know.

A run configuration moves all of it into a YAML file that sits beside the data it names.
`Catchment.from_yaml` reads the file, validates it, and makes the same `read_*` calls in the
same order:

```python
from hapi.catchment import Catchment
from hapi.run import Run

Coello = Catchment.from_yaml("coello-lumped-model-run.yaml")
Run.runLumped(Coello, Routing.triangular_routing_1)
```

The four shipped examples under `examples/hydrological-model/coello/run/` are each a pair — a
`.py` that runs the model and a `.yaml` beside it holding everything the run needs.

## A complete lumped configuration

```yaml
# Paths are relative to this file, so the run works from any working directory.
catchment:
name: Coello
start: "2009-01-01"
end: "2011-12-31"
spatial_resolution: lumped
temporal_resolution: daily

# Lumped mode reads one CSV of catchment-average drivers, not a grid: columns are
# [date, precipitation, ET, temperature], optionally followed by the long-term average.
meteo:
path: ../../data/lumped_model/meteo_data-MSWEP.csv

parameters:
path: ../../data/lumped_model/Coello_Lumped2021-03-08_muskingum.txt
snow: false
maxbas: false

conceptual_model:
model_class: HBVBergestrom92
catchment_area: 1530
initial_condition: [0, 10, 10, 10, 0]

# One discharge file, and no gauge table: locating gauges on a grid is a distributed concern.
gauges:
discharge: ../../data/lumped_model/Qout_c.csv
fmt: "%Y-%m-%d"

outputs:
results_dir: ../../data/lumped_model
```

`catchment`, `meteo` and `conceptual_model` are required. `parameters`, `gauges` and `outputs`
are optional: omit `parameters` for a calibration, which derives them from the bounds given to
`read_parameters_bound`, and omit `gauges` for a run that is not scored against observations.

## What changes for a distributed run

`spatial_resolution: distributed` changes the shape of two blocks and requires a third. `meteo`
becomes a grid, described by its `source`:

```yaml
catchment:
name: Coello
start: "2009-01-01"
end: "2009-04-10"
spatial_resolution: distributed
routing_method: maxbas

meteo:
source: rasters
precipitation: ../../data/distributed_model/prec
temperature: ../../data/distributed_model/temp
evapotranspiration: ../../data/distributed_model/evap
file_name_data_fmt: "%Y.%m.%d"

# MAXBAS sends every cell straight to the outlet, so no flow-direction raster is read.
flow_network:
flow_accumulation: ../../data/distributed_model/GIS/acc4000.tif

gauges:
table: ../../data/distributed_model/stations/gauges.csv
discharge: ../../data/distributed_model/stations/
```

`meteo.source` picks which `MeteoInputs` loader builds the grid, and what the three driver
fields mean:

| `source` | The three driver fields name | Reads |
|---|---|---|
| `rasters` (default) | A folder of dated GeoTIFFs each | `MeteoInputs.from_rasters` |
| `netcdf_files` | One NetCDF each | `MeteoInputs.from_netcdf_files` |
| `netcdf` | A variable inside `meteo.path` | `MeteoInputs.from_netcdf` |

The last is the fastest: one file, opened once, with the calendar inside it. See
[Meteorological inputs](meteo-inputs.md) for how to pack a folder of rasters into one.

## Paths are relative to the file

A relative path in a configuration is resolved against the configuration's own directory, not
against whatever directory you happen to run from. That is what makes a configuration portable:
it travels with the data it names, and the run works from anywhere. Absolute paths are used as
written.

The example scripts rely on this — each loads the YAML sitting next to it:

```python
Coello = Catchment.from_yaml(__file__.removesuffix(".py") + ".yaml")
```

## What the file is checked for

The file is validated in full before anything is opened, so a mistake is reported as a mistake
in the file rather than as a failure deep inside a reader:

- **Unknown keys are refused.** A misspelled `precipitaton` fails at parse time instead of being
dropped and reappearing as a missing input.
- **So are keys that do not apply.** A `flow_network` block on a lumped run, `glob` under
`source: netcdf`, `gauges.table` on a lumped run — each is a line that would do nothing, and
each is named in the error. Only keys you actually wrote count; defaults are never held
against you.
- **Required blocks are checked per shape.** A distributed run needs `flow_network` and all
three drivers; Muskingum additionally needs `flow_network.flow_direction`, which MAXBAS never
reads. A lumped run needs `meteo.path`.
- **`routing_method` must agree with `parameters.maxbas`.** The two parameter counts differ by
one and `maxbas` selects which is expected, so a disagreeing pair still counts correctly and
then reads the wrong parameter as the routing one. Leave `routing_method` out and it is
derived from the parameter set.
- **Every date is parsed against its own `fmt`**, and the period must run forwards — including
the meteorological window, whose bounds fall back to the catchment's when unstated.
- **Every path is checked for existence** before the first reader runs, and all the missing ones
are reported together.

Dates may be quoted or not: `start: 2009-01-01` is a date to YAML, and it is written back out in
the block's `fmt`.

## Reading the configuration back

The parsed configuration stays on the model as `model.config`, so the blocks the build does not
itself consume remain reachable — `outputs` above all:

```python
outputs = Coello.config.outputs
save_to = (outputs.results_dir if outputs is not None else None) or ""
Coello.save_results(
flow_acc_path=Coello.config.flow_network.flow_accumulation,
result=1,
path=save_to,
)
```

## Out of scope

The schema describes a `Catchment` run. It carries no field for a lake record, a river geometry,
or a flow-path-length raster, so lake-aware runs (`Run.RunHapiwithLake`), the flood model
(`Run.RunFloodModel`) and `DistMaxbas2` are still assembled in Python.

`Calibration.from_yaml` works — it takes the same constructor arguments — and gives back a
`Calibration` to call the calibration methods on. `Run.from_yaml` does not: `Run` holds entry
points called on a model built elsewhere, so it refuses and says so.

The full field-by-field reference is on the [Config API page](../api/config.md).
Loading