diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 648247a1b..5f27a8757 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index dc5279c15..1aa03c350 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 459840564..3a89d13c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/docs/api/catchment.md b/docs/api/catchment.md index cbf6054d0..9ac34321e 100644 --- a/docs/api/catchment.md +++ b/docs/api/catchment.md @@ -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 diff --git a/docs/api/config.md b/docs/api/config.md new file mode 100644 index 000000000..d020a4f1f --- /dev/null +++ b/docs/api/config.md @@ -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 diff --git a/docs/examples/distributed-model-run.md b/docs/examples/distributed-model-run.md index 76dfa1695..59f8ecc36 100644 --- a/docs/examples/distributed-model-run.md +++ b/docs/examples/distributed-model-run.md @@ -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 diff --git a/docs/examples/lumped-model-run.md b/docs/examples/lumped-model-run.md index 45466ba65..f3dcb24bb 100644 --- a/docs/examples/lumped-model-run.md +++ b/docs/examples/lumped-model-run.md @@ -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`. diff --git a/docs/examples/run-configuration.md b/docs/examples/run-configuration.md new file mode 100644 index 000000000..98180ecb1 --- /dev/null +++ b/docs/examples/run-configuration.md @@ -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). diff --git a/examples/hydrological-model/coello/run/coello-distributed-model-run-maxbas.py b/examples/hydrological-model/coello/run/coello-distributed-model-run-maxbas.py index 3a8dd2446..fb7e71461 100644 --- a/examples/hydrological-model/coello/run/coello-distributed-model-run-maxbas.py +++ b/examples/hydrological-model/coello/run/coello-distributed-model-run-maxbas.py @@ -1,44 +1,22 @@ -"""Distributed model with a maxbas routing scheme.""" +"""Distributed model with a maxbas routing scheme, built from YAML. -import datetime as dt +Everything that used to be a "Paths" block of hardcoded assignments now lives in +`coello-distributed-model-run-maxbas.yaml`, next to this script -- `Catchment.from_yaml` reads +it and assembles the model. Running it stays here, as in any hand-wired script. -import pandas as pd -from pyramids.dataset import Dataset +MAXBAS sends every cell straight to the outlet, so the config loads no flow-direction raster and +`extract_discharge` needs `frame_work_1=True`: a cell of `Qtot` is that cell's contribution to +the outlet rather than the discharge at it, which makes the per-gauge shortcut invalid. +""" + +from __future__ import annotations from hapi.catchment import Catchment -from hapi.inputs import FlowNetwork, MeteoInputs -from hapi.rrm.hbv_bergestrom92 import HBVBergestrom92 as HBV from hapi.run import Run -# %% Paths -Path = "examples/hydrological-model/data/distributed_model" -PrecPath = f"{Path}/prec" -Evap_Path = f"{Path}/evap" -TempPath = f"{Path}/temp" -FlowAccPath = f"{Path}/GIS/acc4000.tif" -FlowDPath = f"{Path}/GIS/fd4000.tif" -ParPath = f"{Path}/parameters_initial_maxbas" -# %% Meteorological data -AreaCoeff = 1530 -InitialCond = [0, 5, 5, 5, 0] -Snow = 0 -""" -Create the model object and read the input data -""" -start = "2009-01-01" -end = "2009-04-10" -name = "Coello" -Coello = Catchment(name, start, end, spatial_resolution="Distributed") -Coello.meteo = MeteoInputs.from_rasters( - PrecPath, TempPath, Evap_Path, file_name_data_fmt="%Y.%m.%d" -) +# %% Load the configuration and build the model +Coello = Catchment.from_yaml(__file__.removesuffix(".py") + ".yaml") -Coello.flow_network = FlowNetwork.from_rasters(FlowAccPath) -Coello.read_parameters(ParPath, Snow, maxbas=True) -Coello.read_lumped_model(HBV, AreaCoeff, InitialCond) -# %% Gauges -Coello.read_gauge_table(f"{Path}/stations/gauges.csv", FlowAccPath) -Coello.read_discharge_gauges(f"{Path}/stations/", column="id", fmt="%Y-%m-%d") # %% Run the model """ Outputs: @@ -59,6 +37,7 @@ 3D array of the lower zone discharge translated at each time step """ Run.runFW1(Coello) + # %% calculate performance criteria Coello.extract_discharge(calculate_metrics=True, frame_work_1=True) @@ -70,23 +49,6 @@ print("NSEhf= " + str(round(Coello.metrics.loc["NSEhf", gaugeid], 2))) print("KGE= " + str(round(Coello.metrics.loc["KGE", gaugeid], 2))) print("WB= " + str(round(Coello.metrics.loc["WB", gaugeid], 2))) -# %% plot -i = 5 -gaugei = 5 -plotstart = "2009-01-01" -plotend = "2011-12-31" - -Coello.plot_hydrograph(plotstart, plotend, gaugei) -# %% store the result into rasters -# create list of names -src = Dataset.read_file(FlowAccPath) -s = dt.datetime(2012, 6, 14, 19, 00, 00) -e = dt.datetime(2013, 12, 23, 00, 00, 00) -index = pd.date_range(s, e, freq="1H") -resultspath = "results/" -names = [resultspath + str(i)[:-6] for i in index] -names = [i.replace("-", "_") for i in names] -names = [i.replace(" ", "_") for i in names] -names = [i + ".tif" for i in names] -# Raster.RastersLike(src,q_uz_routed[:,:,:-1],names) +# %% plot the hydrograph at the outlet gauge (row position, not the gauge id) +Coello.plot_hydrograph(Coello.start, Coello.end, Coello.GaugesTable.index[-1]) diff --git a/examples/hydrological-model/coello/run/coello-distributed-model-run-maxbas.yaml b/examples/hydrological-model/coello/run/coello-distributed-model-run-maxbas.yaml new file mode 100644 index 000000000..6b3112988 --- /dev/null +++ b/examples/hydrological-model/coello/run/coello-distributed-model-run-maxbas.yaml @@ -0,0 +1,35 @@ +# Paths are relative to this file, so the run works from any working directory. +catchment: + name: Coello + start: "2009-01-01" + end: "2009-04-10" + spatial_resolution: distributed + temporal_resolution: daily + 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 + +parameters: + path: ../../data/distributed_model/parameters_initial_maxbas + snow: false + maxbas: true + +conceptual_model: + model_class: HBVBergestrom92 + catchment_area: 1530 + initial_condition: [0, 5, 5, 5, 0] + +gauges: + table: ../../data/distributed_model/stations/gauges.csv + discharge: ../../data/distributed_model/stations/ + column: id + fmt: "%Y-%m-%d" diff --git a/examples/hydrological-model/coello/run/coello-distributed-model-run-netcdf.py b/examples/hydrological-model/coello/run/coello-distributed-model-run-netcdf.py new file mode 100644 index 000000000..400e3a145 --- /dev/null +++ b/examples/hydrological-model/coello/run/coello-distributed-model-run-netcdf.py @@ -0,0 +1,95 @@ +"""Distributed Muskingum model driven from a single combined NetCDF, built from YAML. + +Standalone version of the workflow behind +`tests/rrm/catchment/test_e2e_coello_from_netcdf.py::TestMuskingumPipeline:: +test_the_drivers_come_from_the_file_and_cover_the_model`: one `MeteoInputs.from_netcdf` call +replaces the three raster-folder reads, so the model touches no meteorological raster at all. +`meteo.nc` packs the rainfall, temperature and evapotranspiration folders bundled under +`tests/rrm/data/coello/{prec,temp,evap}` into one file with the calendar inside it -- see +`tests/rrm/data/coello/convert_and_combine_meteo_inputs_to_netcdf.py` for how it was built. + +Everything that used to be a "Paths" block of hardcoded assignments now lives in +`coello-distributed-model-run-netcdf.yaml`, next to this script -- `Catchment.from_yaml` reads +it and assembles the `Catchment` the same way `_build` did in the e2e test. +""" + +from __future__ import annotations + +import numpy as np + +from hapi.catchment import Catchment +from hapi.run import Run + +# %% Load the configuration and build the model +Coello = Catchment.from_yaml(__file__.removesuffix(".py") + ".yaml") + +# %% Check the drivers actually came from the file and cover the model +print(f"meteo grid + steps : {Coello.meteo.shape}") +print(f"model steps : {len(Coello.date_index)}") +print(f"meteo period : {Coello.meteo.time[0]} -> {Coello.meteo.time[-1]}") +print(f"model period : {Coello.date_index[0]} -> {Coello.date_index[-1]}") +if Coello.meteo.time_steps != len(Coello.date_index): + raise ValueError("the drivers must hold exactly as many steps as the model spans") +if Coello.meteo.time[0] != Coello.date_index[0]: + raise ValueError("the drivers must start where the model does") +if Coello.meteo.time[-1] != Coello.date_index[-1]: + raise ValueError("the drivers must end where the model does") + +# %% Run the model +""" +Outputs: + ---------- + 1-state_variables: [numpy attribute] + 4D array (rows,cols,time,states) states are [sp,wc,sm,uz,lv] + 2-qlz: [numpy attribute] + 3D array of the lower zone discharge + 3-quz: [numpy attribute] + 3D array of the upper zone discharge + 4-qout: [numpy attribute] + 1D timeseries of discharge at the outlet of the catchment + of unit m3/sec + 5-quz_routed: [numpy attribute] + 3D array of the upper zone discharge accumulated and + routed at each time step + 6-qlz_translated: [numpy attribute] + 3D array of the lower zone discharge translated at each time step +""" +Run.RunHapi(Coello) + +# %% Routed fields cover the grid, finite inside the catchment +inside = ~np.isnan(Coello.flow_network.flow_acc_arr) +for field_name in ("Qtot", "quz_routed", "qlz_translated"): + field = getattr(Coello, field_name) + print( + f"{field_name:15s} shape {field.shape}, finite inside: {np.isfinite(field[inside]).all()}" + ) + +# %% Extract discharge at every gauge and score against the observations +Coello.extract_discharge(calculate_metrics=True) + +for gauge_id in Coello.GaugesTable["id"]: + print("----------------------------------") + print(f"Gauge - {gauge_id}") + print(f"RMSE= {Coello.metrics.loc['RMSE', gauge_id]:.2f}") + print(f"NSE= {Coello.metrics.loc['NSE', gauge_id]:.2f}") + print(f"NSEhf= {Coello.metrics.loc['NSEhf', gauge_id]:.2f}") + print(f"KGE= {Coello.metrics.loc['KGE', gauge_id]:.2f}") + print(f"WB= {Coello.metrics.loc['WB', gauge_id]:.2f}") + +# %% Save the routed discharge to rasters, one per time step +# Both paths come from the configuration rather than being restated here: `save_results` +# re-reads the flow-accumulation raster for georeferencing (FlowNetwork keeps only the arrays, +# not the source path), and `outputs.results_dir` says where the rasters go. The block is +# optional, so a configuration without one writes beside the script rather than failing on a +# missing attribute. +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, +) +print(f"rasters written to : {save_to}") + +# %% Plot the hydrograph at the outlet gauge (row position, not the gauge id) +Coello.plot_hydrograph(Coello.start, Coello.end, Coello.GaugesTable.index[-1]) diff --git a/examples/hydrological-model/coello/run/coello-distributed-model-run-netcdf.yaml b/examples/hydrological-model/coello/run/coello-distributed-model-run-netcdf.yaml new file mode 100644 index 000000000..56179ddad --- /dev/null +++ b/examples/hydrological-model/coello/run/coello-distributed-model-run-netcdf.yaml @@ -0,0 +1,40 @@ +# Paths are relative to this file, so the run works from any working directory. +catchment: + name: Coello + start: "2009-01-01" + end: "2009-01-10" + spatial_resolution: distributed + temporal_resolution: daily + routing_method: muskingum + +# One file per rainfall product, each holding all three drivers with the calendar inside it. +meteo: + source: netcdf + path: ../../data/distributed_model/meteo.nc + precipitation: precipitation + temperature: temperature + evapotranspiration: evapotranspiration + +flow_network: + flow_accumulation: ../../data/distributed_model/GIS/acc4000.tif + flow_direction: ../../data/distributed_model/GIS/fd4000.tif + +parameters: + path: ../../data/distributed_model/Parameter set-Avg + snow: false + maxbas: false + +conceptual_model: + model_class: HBVBergestrom92 + catchment_area: 1530 + initial_condition: [0, 5, 5, 5, 0] + +gauges: + table: ../../data/distributed_model/stations/gauges.csv + discharge: ../../data/distributed_model/stations + column: id + fmt: "%Y-%m-%d" + +# Written beside the other example outputs rather than into the repository root. +outputs: + results_dir: ../../data/distributed_model/results diff --git a/examples/hydrological-model/coello/run/coello-lumped-model-run-maxbas.py b/examples/hydrological-model/coello/run/coello-lumped-model-run-maxbas.py index 8de7c1a75..374d94aca 100644 --- a/examples/hydrological-model/coello/run/coello-lumped-model-run-maxbas.py +++ b/examples/hydrological-model/coello/run/coello-lumped-model-run-maxbas.py @@ -1,63 +1,38 @@ -import datetime as dt +"""Lumped model with a triangular (MAXBAS) routing scheme, built from YAML. + +Everything that used to be a "Paths" block of hardcoded assignments now lives in +`coello-lumped-model-run-maxbas.yaml`, next to this script -- `Catchment.from_yaml` reads it and +assembles the model. Running it stays here, as in any hand-wired script: the routing function is +a run-time choice rather than an input, so it is picked below and handed to `Run.runLumped`. + +The config's `parameters.maxbas: true` says the parameter file carries the triangular-routing +parameter; picking `Routing.triangular_routing_1` below is what actually routes with it. +""" -import matplotlib +from __future__ import annotations + +import datetime as dt -matplotlib.use("TkAgg") import statista.descriptors as metrics from hapi.catchment import Catchment from hapi.routing import Routing -from hapi.rrm.hbv_bergestrom92 import HBVBergestrom92 as HBVLumped from hapi.run import Run -# %% data -Parameterpath = "examples/hydrological-model/data/lumped_model/coello-lumped-parameters2022-03-13-maxbas.txt" -MeteoDataPath = "examples/hydrological-model/data/lumped_model/meteo_data-MSWEP.csv" -Path = "examples/hydrological-model/data/lumped_model/" -SaveTo = "examples/hydrological-model/data/lumped_model/" -### Meteorological data -start = "2009-01-01" -end = "2011-12-31" -name = "Coello" -Coello = Catchment(name, start, end) -Coello.read_lumped_inputs(MeteoDataPath) -# %% Lumped model -# catchment area -AreaCoeff = 1530 -# [Snow pack, Soil moisture, Upper zone, Lower Zone, Water content] -InitialCond = [0, 10, 10, 10, 0] - -Coello.read_lumped_model(HBVLumped, AreaCoeff, InitialCond) -# %% ### Model Parameters -# no snow subroutine -Snow = False -Maxbas = True -Coello.read_parameters(Parameterpath, Snow, maxbas=Maxbas) -# Coello.parameters -# %% ### Observed flow -Coello.read_discharge_gauges(Path + "Qout_c.csv", fmt="%Y-%m-%d") +# %% Load the configuration and build the model +Coello = Catchment.from_yaml(__file__.removesuffix(".py") + ".yaml") + # %% Routing # RoutingFn = Routing.triangular_routing_2 RoutingFn = Routing.triangular_routing_1 Route = 1 -# %% ### Run The Model -# Coello.parameters = [1.0171762638840873, -# 358.6427125027168, -# 1.459834925116025, -# 0.2031178594731058, -# 1.0171762638840873, -# 0.7767401680547908, -# 0.24471700755374745, -# 0.03648724503470574, -# 46.41655903500876, -# 3.126313569552141, -# 1.9894177368962747] +# %% Run the model Run.runLumped(Coello, Route, RoutingFn) -# %% ### Calculate performance criteria + +# %% Calculate performance criteria scores = dict() -# gaugeid = Coello.QGauges.columns[-1] Qobs = Coello.QGauges["q"] scores["RMSE"] = metrics.rmse(Qobs, Coello.Qsim["q"]) @@ -71,17 +46,16 @@ print("NSEhf= " + str(round(scores["NSEhf"], 2))) print("KGE= " + str(round(scores["KGE"], 2))) print("WB= " + str(round(scores["WB"], 2))) -# %% ### Plot Hydrograph + +# %% Plot Hydrograph gaugei = 0 -plotstart = "2009-01-01" -plotend = "2011-12-31" -fig, ax = Coello.plot_hydrograph(plotstart, plotend, gaugei, title="Lumped Model") -# %% ### Save Results +fig, ax = Coello.plot_hydrograph(Coello.start, Coello.end, gaugei, title="Lumped Model") +# %% Save Results +SaveTo = Coello.config.outputs.results_dir StartDate = "2009-01-01" EndDate = "2010-04-20" -Path = f"{SaveTo}{Coello.name}Results-Lumped-Model_{str(dt.datetime.now())[0:10]}.txt" -Coello.save_results(result=5, start=StartDate, end=EndDate, path=Path) - -# %% +path = f"{SaveTo}/{Coello.name}Results-Lumped-Model_{str(dt.datetime.now())[0:10]}.txt" +Coello.save_results(result=5, start=StartDate, end=EndDate, path=path) +print(f"results written to : {path}") diff --git a/examples/hydrological-model/coello/run/coello-lumped-model-run-maxbas.yaml b/examples/hydrological-model/coello/run/coello-lumped-model-run-maxbas.yaml new file mode 100644 index 000000000..5336691a1 --- /dev/null +++ b/examples/hydrological-model/coello/run/coello-lumped-model-run-maxbas.yaml @@ -0,0 +1,33 @@ +# 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 + +# A single parameter file rather than a folder of rasters. `maxbas: true` says the set carries +# the triangular-routing parameter; the routing function itself is chosen at run time. +parameters: + path: ../../data/lumped_model/coello-lumped-parameters2022-03-13-maxbas.txt + snow: false + maxbas: true + +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" + +# Written beside the other example outputs rather than into the working directory. +outputs: + results_dir: ../../data/lumped_model diff --git a/examples/hydrological-model/coello/run/coello-lumped-model-run.py b/examples/hydrological-model/coello/run/coello-lumped-model-run.py index b21e9fb02..16a9bf0c6 100644 --- a/examples/hydrological-model/coello/run/coello-lumped-model-run.py +++ b/examples/hydrological-model/coello/run/coello-lumped-model-run.py @@ -1,86 +1,61 @@ -import datetime as dt +"""Lumped model with Muskingum routing, built from YAML. + +Everything that used to be a "Paths" block of hardcoded assignments now lives in +`coello-lumped-model-run.yaml`, next to this script -- `Catchment.from_yaml` reads it and +assembles the model. Running it stays here, as in any hand-wired script: the routing function is +a run-time choice rather than an input, so it is picked below and handed to `Run.runLumped`. + +Lumped mode reads one CSV of catchment-average drivers instead of a grid, and one discharge file +instead of a gauge table plus a folder -- see the config for both. +""" + +from __future__ import annotations -import matplotlib +import datetime as dt -matplotlib.use("TkAgg") -import statista.descriptors as PC +import statista.descriptors as metrics from hapi.catchment import Catchment from hapi.routing import Routing -from hapi.rrm.hbv_bergestrom92 import HBVBergestrom92 as HBVLumped from hapi.run import Run -# %% data -parameter_path = "examples/hydrological-model/data/lumped_model/Coello_Lumped2021-03-08_muskingum.txt" -meteo_data_path = "examples/hydrological-model/data/lumped_model/meteo_data-MSWEP.csv" -path = "examples/hydrological-model/data/lumped_model/" -save_to = "examples/hydrological-model/data/lumped_model/" -### Meteorological data -start = "2009-01-01" -end = "2011-12-31" -name = "Coello" -Coello = Catchment(name, start, end) -Coello.read_lumped_inputs(meteo_data_path) -# %% Lumped model -# catchment area -AreaCoeff = 1530 -# [Snow pack, Soil moisture, Upper zone, Lower Zone, Water content] -InitialCond = [0, 10, 10, 10, 0] - -Coello.read_lumped_model(HBVLumped, AreaCoeff, InitialCond) -# %% ### Model Parameters - -Snow = False # no snow subroutine -Coello.read_parameters(parameter_path, Snow) -# Coello.parameters -# %% ### Observed flow -Coello.read_discharge_gauges(path + "Qout_c.csv", fmt="%Y-%m-%d") -# %% ### Routing +# %% Load the configuration and build the model +Coello = Catchment.from_yaml(__file__.removesuffix(".py") + ".yaml") +# %% Routing # RoutingFn = Routing.triangular_routing_2 RoutingFn = Routing.muskingum_v Route = 1 -# %% ### Run The Model -# Coello.parameters = [1.0171762638840873, -# 358.6427125027168, -# 1.459834925116025, -# 0.2031178594731058, -# 1.0171762638840873, -# 0.7767401680547908, -# 0.24471700755374745, -# 0.03648724503470574, -# 46.41655903500876, -# 3.126313569552141, -# 1.9894177368962747] +# %% Run the model Run.runLumped(Coello, Route, RoutingFn) -# %% ### Calculate performance criteria -# Coello.extractDischarge(OnlyOutlet=True) -metrics = dict() -# gaugeid = Coello.QGauges.columns[-1] +# %% Calculate performance criteria +scores = {} + Qobs = Coello.QGauges["q"] -metrics["RMSE"] = PC.rmse(Qobs, Coello.Qsim["q"]) -metrics["NSE"] = PC.nse(Qobs, Coello.Qsim["q"]) -metrics["NSEhf"] = PC.nse_hf(Qobs, Coello.Qsim["q"]) -metrics["KGE"] = PC.kge(Qobs, Coello.Qsim["q"]) -metrics["WB"] = PC.wb(Qobs, Coello.Qsim["q"]) - -print("RMSE= " + str(round(metrics["RMSE"], 2))) -print("NSE= " + str(round(metrics["NSE"], 2))) -print("NSEhf= " + str(round(metrics["NSEhf"], 2))) -print("KGE= " + str(round(metrics["KGE"], 2))) -print("WB= " + str(round(metrics["WB"], 2))) -# %% ### Plot Hydrograph +scores["RMSE"] = metrics.rmse(Qobs, Coello.Qsim["q"]) +scores["NSE"] = metrics.nse(Qobs, Coello.Qsim["q"]) +scores["NSEhf"] = metrics.nse_hf(Qobs, Coello.Qsim["q"]) +scores["KGE"] = metrics.kge(Qobs, Coello.Qsim["q"]) +scores["WB"] = metrics.wb(Qobs, Coello.Qsim["q"]) + +print("RMSE= " + str(round(scores["RMSE"], 2))) +print("NSE= " + str(round(scores["NSE"], 2))) +print("NSEhf= " + str(round(scores["NSEhf"], 2))) +print("KGE= " + str(round(scores["KGE"], 2))) +print("WB= " + str(round(scores["WB"], 2))) + +# %% Plot Hydrograph gaugei = 0 -plotstart = "2009-01-01" -plotend = "2011-12-31" -fig, ax = Coello.plot_hydrograph(plotstart, plotend, gaugei, title="Lumped Model") -# %% ### Save Results +fig, ax = Coello.plot_hydrograph(Coello.start, Coello.end, gaugei, title="Lumped Model") +# %% Save Results +SaveTo = Coello.config.outputs.results_dir StartDate = "2009-01-01" EndDate = "2010-04-20" -path = save_to + "Results-Lumped-Model_" + str(dt.datetime.now())[0:10] + ".txt" +path = f"{SaveTo}/Results-Lumped-Model_{str(dt.datetime.now())[0:10]}.txt" Coello.save_results(result=5, start=StartDate, end=EndDate, path=path) +print(f"results written to : {path}") diff --git a/examples/hydrological-model/coello/run/coello-lumped-model-run.yaml b/examples/hydrological-model/coello/run/coello-lumped-model-run.yaml new file mode 100644 index 000000000..6eb7a47a9 --- /dev/null +++ b/examples/hydrological-model/coello/run/coello-lumped-model-run.yaml @@ -0,0 +1,32 @@ +# 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 + +# A single parameter file rather than a folder of rasters. +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" + +# Written beside the other example outputs rather than into the working directory. +outputs: + results_dir: ../../data/lumped_model diff --git a/examples/hydrological-model/data/distributed_model/meteo.nc b/examples/hydrological-model/data/distributed_model/meteo.nc new file mode 100644 index 000000000..386273bae Binary files /dev/null and b/examples/hydrological-model/data/distributed_model/meteo.nc differ diff --git a/mkdocs.yml b/mkdocs.yml index 9e10cca62..4dd268d51 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,6 +104,7 @@ nav: - dev/Installation.md - API Reference: - api/catchment.md + - api/config.md - api/dem.md - api/calibration.md - api/inputs.md @@ -125,6 +126,7 @@ nav: - examples/meteo-inputs.md - examples/muskingum.md - examples/parameters.md + - examples/run-configuration.md - Change logs: change-log.md extra: diff --git a/pixi.lock b/pixi.lock index cd77a5429..265f693a1 100644 --- a/pixi.lock +++ b/pixi.lock @@ -49,14 +49,17 @@ environments: - pypi: https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/20/c2/247c150f5ca12f8593c20e39115db551b18de5c6cb383006de21b57399e4/pyogrio-0.13.0-cp311-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/22/c0/a27bc82efc120ee8dc4bb266a0345c47cb4200b1b3fc2b57c46e2f1e23ce/pyramids_gis-0.54.0-cp314-cp314-manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/4f/2f1794a3544ab9251ad2dc026fddbf4faad5785a1f23bd4a15d8649154e4/cleopatra-0.32.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/59/a8/bd530cc264e62ddbc1d1bb7225823992e6f2432c664693e9281bb6b9c359/geopandas-1.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/76/2a/d6dcec4a68ebd9056cd3bede600a5a085d21cc8df37c8b269d3570e1fc05/Oasis_Optimization-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl @@ -64,6 +67,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/7f/89/648397f9936e0b330999c4e776ebf296ec3c6a65f9901687dbca4ab820da/cftime-1.6.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl @@ -72,6 +76,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -106,11 +111,13 @@ environments: - pypi: https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/51/4f/2f1794a3544ab9251ad2dc026fddbf4faad5785a1f23bd4a15d8649154e4/cleopatra-0.32.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/59/a8/bd530cc264e62ddbc1d1bb7225823992e6f2432c664693e9281bb6b9c359/geopandas-1.1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/76/2a/d6dcec4a68ebd9056cd3bede600a5a085d21cc8df37c8b269d3570e1fc05/Oasis_Optimization-1.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl @@ -118,6 +125,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl @@ -131,9 +139,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/e1/f9/24b322756aee5798d3d1a4561c04e37128cb4ee7b2344fd601777fde35ae/pyramids_gis-0.54.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/f9/7f/d8cbabf3ba7ada509c0282a6e9c2183d01c8c1b037bba0583a5c68255e05/statista-0.8.0-py3-none-any.whl dev: channels: @@ -192,6 +202,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/5c/eb1e3ce54c4e94c7734b3831756c63f21badb3de91a98d77b9e23c0ca76a/nbval-0.11.0-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl @@ -233,6 +244,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -272,6 +284,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl @@ -323,6 +336,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/7d/f50f8c478977d4aed0902e1ca558a8b457fc985937ee751304abd6f45a1f/commitizen-4.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl @@ -431,6 +445,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -469,6 +484,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl @@ -520,6 +536,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/7d/f50f8c478977d4aed0902e1ca558a8b457fc985937ee751304abd6f45a1f/commitizen-4.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl @@ -536,6 +553,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f9/7f/d8cbabf3ba7ada509c0282a6e9c2183d01c8c1b037bba0583a5c68255e05/statista-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl @@ -595,6 +613,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl @@ -630,6 +649,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -664,6 +684,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl @@ -705,6 +726,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl @@ -802,6 +824,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -835,6 +858,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl @@ -879,6 +903,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/dc/55481808fd70ef1567cf13540ffd4702af3f74b112e35427564b03f79c2d/narwhals-2.25.0-py3-none-any.whl @@ -891,6 +916,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f9/7f/d8cbabf3ba7ada509c0282a6e9c2183d01c8c1b037bba0583a5c68255e05/statista-0.8.0-py3-none-any.whl @@ -1451,6 +1477,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -1488,6 +1515,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl @@ -1518,6 +1546,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl @@ -1536,6 +1565,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/7d/f50f8c478977d4aed0902e1ca558a8b457fc985937ee751304abd6f45a1f/commitizen-4.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl @@ -1643,6 +1673,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -1680,10 +1711,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl @@ -1734,6 +1767,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/7d/f50f8c478977d4aed0902e1ca558a8b457fc985937ee751304abd6f45a1f/commitizen-4.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl @@ -1850,6 +1884,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -1891,6 +1926,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl @@ -1915,6 +1951,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/bc/8e/1239df488393d61076653bfb29f759d0f60cab8e030abdf7c17c31539b51/ipython-9.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c3/55/298e9b3b864a198234997e87a1471c1b17d7f3546ace6d18fb5cf1ce24b2/ipywidgets-8.1.9-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl @@ -1945,6 +1982,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/7d/f50f8c478977d4aed0902e1ca558a8b457fc985937ee751304abd6f45a1f/commitizen-4.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl @@ -2048,6 +2086,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -2087,6 +2126,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl @@ -2115,6 +2155,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d2/ba/3757e312a98c428ac5d8b787f3608ae325174ebef6897930a42e21dd057a/pyogrio-0.13.0-cp311-abi3-win_amd64.whl @@ -2136,6 +2177,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/7d/f50f8c478977d4aed0902e1ca558a8b457fc985937ee751304abd6f45a1f/commitizen-4.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl @@ -2254,6 +2296,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -2292,6 +2335,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl @@ -2326,6 +2370,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl @@ -2343,6 +2388,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/7d/f50f8c478977d4aed0902e1ca558a8b457fc985937ee751304abd6f45a1f/commitizen-4.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl @@ -2409,6 +2455,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/5c/eb1e3ce54c4e94c7734b3831756c63f21badb3de91a98d77b9e23c0ca76a/nbval-0.11.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl @@ -2450,6 +2497,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl @@ -2488,6 +2536,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/97/1b/295bf2fa3e740131778065e5ffa2c481f0e7210182d408e9a2c244ff5b0c/readme_renderer-45.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl @@ -2541,6 +2590,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e9/47/242f46de028074651c9bd6d8000fc340ed0d3cdd1a0eae4387826123413a/jupyterlab-4.6.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/7d/f50f8c478977d4aed0902e1ca558a8b457fc985937ee751304abd6f45a1f/commitizen-4.18.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl @@ -3374,6 +3424,8 @@ packages: - oasis-optimization>=1.0.3 - cleopatra>=0.32.0 - matplotlib>=3.11.0 + - pyyaml>=6.0 + - pydantic>=2.0,<3 - earthlens[ecmwf]>=0.12.0 ; extra == 'inputs' requires_python: '>=3.11,<4' - pypi: https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -4341,6 +4393,13 @@ packages: version: 1.3.4 sha256: 70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307 requires_python: '>=3.6' +- pypi: https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl + name: pydantic-core + version: 2.46.5 + sha256: 15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl name: referencing version: 0.37.0 @@ -4361,6 +4420,13 @@ packages: - ipykernel - coverage requires_python: '>=3.7,<4' +- pypi: https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: pydantic-core + version: 2.46.5 + sha256: 54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl name: overrides version: 7.7.0 @@ -7683,6 +7749,13 @@ packages: version: 3.1.1 sha256: 8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl + name: pydantic-core + version: 2.46.5 + sha256: 40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl name: pexpect version: 4.9.0 @@ -8381,6 +8454,13 @@ packages: version: 0.15.0 sha256: f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8 requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: pydantic-core + version: 2.46.5 + sha256: 0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl name: openpyxl version: 3.1.5 @@ -8648,6 +8728,20 @@ packages: requires_dist: - cached-property>=1.3.0 ; python_full_version < '3.8' requires_python: '>=2.7,!=3.0,!=3.1,!=3.2,!=3.3,!=3.4,<4' +- pypi: https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: pydantic-core + version: 2.46.5 + sha256: 49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl + name: pydantic-core + version: 2.46.5 + sha256: 5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl name: jupyter-server-terminals version: 0.5.4 @@ -8793,6 +8887,13 @@ packages: - pytest-benchmark ; extra == 'benchmark' - geopandas ; extra == 'geopandas' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: pydantic-core + version: 2.46.5 + sha256: 6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl name: asttokens version: 3.0.2 @@ -9308,6 +9409,18 @@ packages: - pyyaml-include<3.0 ; extra == 'upgrade-extension' - tomli-w<2.0 ; extra == 'upgrade-extension' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl + name: pydantic + version: 2.13.5 + sha256: 346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 + requires_dist: + - annotated-types>=0.6.0 + - pydantic-core==2.46.5 + - typing-extensions>=4.14.1 + - typing-inspection>=0.4.2 + - email-validator>=2.0.0 ; extra == 'email' + - tzdata ; python_full_version >= '3.9' and sys_platform == 'win32' and extra == 'timezone' + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl name: jupyter-events version: 0.12.1 @@ -9724,6 +9837,13 @@ packages: - platformdirs>=4.2 ; extra == 'pypi' - wheel>=0.42 ; extra == 'pypi' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl + name: pydantic-core + version: 2.46.5 + sha256: fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 + requires_dist: + - typing-extensions>=4.14.1 + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl name: pillow version: 12.3.0 diff --git a/pyproject.toml b/pyproject.toml index b6a2163ca..0b78ed0a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,12 @@ dependencies = [ "statista >=0.8.0", "Oasis-Optimization >=1.0.3", "cleopatra >=0.32.0", - "matplotlib >=3.11.0" + "matplotlib >=3.11.0", + "pyyaml >=6.0", + # Capped at the major boundary: `hapi.config` is built on pydantic 2's API -- ConfigDict + # spreading, `protected_namespaces=()`, `model_fields_set`, `Field(min_length=...)` on a + # list -- none of which a pydantic 3 is obliged to keep. + "pydantic >=2.0,<3" ] [project.optional-dependencies] @@ -249,6 +254,18 @@ description = "Run the main test suite" cmd = "pytest -vvv --cov=src/hapi -sv -m 'not fig_share' --cov-report=xml --cov-report=term-missing" description = "Run all test suite" +[tool.pixi.tasks.doctests] +# Not `src` as a whole: 19 doctests in the hbv / hbv_bergestrom92 / hbv_lake modules fail and +# are why the pre-commit doctest hook is commented out. These four are green, and their +# examples assert on concrete values read from real data, so they are worth running rather +# than leaving as prose that drifts. Add a module here once its examples pass. +cmd = [ + "pytest", "--doctest-modules", "-p", "no:cacheprovider", "--no-cov", + "src/hapi/config.py", "src/hapi/catchment.py", + "src/hapi/inputs.py", "src/hapi/routing.py", "src/hapi/run.py", +] +description = "Run the doctests of the modules whose examples are executable" + [tool.pixi.tasks.notebooks] cmd = "pytest --nbval --nbval-lax --verbose -p no:cacheprovider examples" description = "Check notebooks (they still need migration off the pre-rename Hapi package)" diff --git a/src/hapi/calibration.py b/src/hapi/calibration.py index 850d08ba0..8324d7552 100644 --- a/src/hapi/calibration.py +++ b/src/hapi/calibration.py @@ -26,6 +26,30 @@ ) +def _check_optimization_args(api_obj_args: Any, api_solve_args: Any) -> None: + """Check the two argument bundles the optimizer is handed are mappings. + + Both are unpacked with `**` inside Oasis, so anything else fails there rather than at the + call that supplied it. Every calibration entry point makes the same pair of checks. + + Args: + api_obj_args: Keyword arguments forwarded to the objective function. + api_solve_args: Keyword arguments forwarded to the solver. + + Raises: + TypeError: Either bundle is not a dict. + """ + if not isinstance(api_obj_args, dict): + raise TypeError( + f"the objective-function arguments should be a dict, got " + f"{type(api_obj_args).__name__}" + ) + if not isinstance(api_solve_args, dict): + raise TypeError( + f"the solver arguments should be a dict, got {type(api_solve_args).__name__}" + ) + + class Calibration(Catchment): """Calibration class for distributed hydrological model parameter optimization. @@ -46,9 +70,9 @@ def __init__( start: str, end: str, fmt: str = "%Y-%m-%d", - spatial_resolution: str | None = "Lumped", - temporal_resolution: str | None = "Daily", - routing_method: str | None = "Muskingum", + spatial_resolution: str = "Lumped", + temporal_resolution: str = "Daily", + routing_method: str = "Muskingum", ): """Initialize the Calibration object. @@ -78,6 +102,38 @@ def __init__( self.OFArgs: list | None = None self.OFvalue: float | None = None + def _declare_the_parameter_variables( + self, opt_prob: Optimization, initial_values: list | None = None + ) -> None: + """Add one continuous optimisation variable per parameter, bounded by LB and UB. + + Every calibration entry point declares the same variables the same way; only the + lumped one can also seed them with a starting point. + + Args: + opt_prob: The problem being built. + initial_values: One starting value per parameter, or None to let the optimiser + choose. + + Raises: + ValueError: `initial_values` is given and does not hold one value per parameter. + """ + # One starting value per parameter. A shorter list used to index out of range + # part-way through building the problem, naming neither argument and leaving + # `opt_prob` half-populated. + seeded = initial_values is not None and len(initial_values) > 0 + if seeded and len(initial_values) != len(self.LB): + raise ValueError( + f"initial_values must hold one value per parameter; the bounds define " + f"{len(self.LB)} and {len(initial_values)} were given" + ) + + for i in range(len(self.LB)): + seed = {"value": initial_values[i]} if seeded else {} + opt_prob.addVar( + f"x{i}", type="c", lower=self.LB[i], upper=self.UB[i], **seed + ) + def read_objective_function( self, objective_function: Callable[..., Any], args: list | None ): @@ -93,12 +149,14 @@ def read_objective_function( objective function. If None, defaults to an empty list. Raises: - AssertionError: If objective_function is not callable. + TypeError: If objective_function is not callable. """ # check objective_function - assert callable(objective_function), ( - "The Objective function should be a function" - ) + if not callable(objective_function): + raise TypeError( + f"The Objective function should be a function, got " + f"{type(objective_function).__name__}" + ) self.objective_function = objective_function if args is None: @@ -215,15 +273,15 @@ def run_calibration( - res[1]: The optimal parameter set. Raises: - AssertionError: If input dimensions are inconsistent or if - optimization arguments are not dictionaries. + ValueError: If input dimensions are inconsistent. + TypeError: If either bundle of optimization arguments is not a + dict. """ # input dimensions # [rows,cols] = self.FlowAcc.ReadAsArray().shape [fd_rows, fd_cols] = self.flow_network.flow_dir_arr.shape - assert ( - fd_rows == self.flow_network.rows and fd_cols == self.flow_network.cols - ), ROWS_MISMATCH_ERROR + if fd_rows != self.flow_network.rows or fd_cols != self.flow_network.cols: + raise ValueError(ROWS_MISMATCH_ERROR) # The three cubes already agree with each other (checked when MeteoInputs was # built); this is the other half -- that they cover the model's grid. @@ -243,8 +301,7 @@ def run_calibration( pll_type = optimization_args[1] api_solve_args = optimization_args[2] # check optimization arguement - assert type(api_obj_args) is dict, "store_history should be 0 or 1" - assert type(api_solve_args) is dict, "history_fname should be of type string " + _check_optimization_args(api_obj_args, api_solve_args) print("Calibration starts") @@ -290,8 +347,7 @@ def opt_fun(par): ### define the optimization components opt_prob = Optimization("HBV Calibration", opt_fun) - for i in range(len(self.LB)): - opt_prob.addVar(f"x{i}", type="c", lower=self.LB[i], upper=self.UB[i]) + self._declare_the_parameter_variables(opt_prob) opt_prob.addObj("f") @@ -364,8 +420,9 @@ def FW1Calibration( - res[1]: The optimal parameter set. Raises: - AssertionError: If input dimensions are inconsistent or if - optimization arguments are not dictionaries. + ValueError: If input dimensions are inconsistent. + TypeError: If either bundle of optimization arguments is not a + dict. """ # input dimensions # [rows,cols] = self.FlowAcc.ReadAsArray().shape @@ -390,8 +447,7 @@ def FW1Calibration( pll_type = optimization_args[1] api_solve_args = optimization_args[2] # check optimization arguement - assert type(api_obj_args) is dict, "store_history should be 0 or 1" - assert type(api_solve_args) is dict, "history_fname should be of type string " + _check_optimization_args(api_obj_args, api_solve_args) print("Calibration starts") @@ -428,8 +484,7 @@ def opt_fun(par): # define the optimization components opt_prob = Optimization("HBV Calibration", opt_fun) - for i in range(len(self.LB)): - opt_prob.addVar(f"x{i}", type="c", lower=self.LB[i], upper=self.UB[i]) + self._declare_the_parameter_variables(opt_prob) print(opt_prob) @@ -500,15 +555,20 @@ def lumpedCalibration( - res[1]: The optimal parameter set. Raises: - AssertionError: If `basic_inputs` is missing required keys - `"Route"` or `"RoutingFn"`, or if optimization - arguments are not dictionaries. + ValueError: If `basic_inputs` is missing required keys + `"Route"` or `"RoutingFn"`, or if `"InitialValues"` is + given and does not hold one value per parameter. + TypeError: If either bundle of optimization arguments is not a + dict. """ # basic inputs # check if all inputs are included - assert all(["Route", "RoutingFn"][i] in basic_inputs for i in range(2)), ( - "basic_inputs should contain ['p2','init_st','UB','LB'] " - ) + missing = [key for key in ("Route", "RoutingFn") if key not in basic_inputs] + if missing: + raise ValueError( + f"basic_inputs should contain 'Route' and 'RoutingFn'; " + f"{', '.join(missing)} is missing" + ) route = basic_inputs["Route"] routing_fn = basic_inputs["RoutingFn"] @@ -524,10 +584,7 @@ def lumpedCalibration( pll_type = optimization_args[1] api_solve_args = optimization_args[2] # check optimization arguement - assert isinstance(api_obj_args, dict), "store_history should be 0 or 1" - assert isinstance(api_solve_args, dict), ( - "history_fname should be of type string " - ) + _check_optimization_args(api_obj_args, api_solve_args) print("Calibration starts") @@ -566,18 +623,7 @@ def opt_fun(par): ### define the optimization components opt_prob = Optimization("HBV Calibration", opt_fun) - if initial_values != []: - for i in range(len(self.LB)): - opt_prob.addVar( - f"x{i}", - type="c", - lower=self.LB[i], - upper=self.UB[i], - value=initial_values[i], - ) - else: - for i in range(len(self.LB)): - opt_prob.addVar(f"x{i}", type="c", lower=self.LB[i], upper=self.UB[i]) + self._declare_the_parameter_variables(opt_prob, initial_values) opt_prob.addObj("f") diff --git a/src/hapi/catchment.py b/src/hapi/catchment.py index 2ed84afc1..7be98faae 100644 --- a/src/hapi/catchment.py +++ b/src/hapi/catchment.py @@ -20,25 +20,31 @@ import os from collections.abc import Iterator from contextlib import contextmanager -from typing import TYPE_CHECKING, Any +from pathlib import Path +from typing import TYPE_CHECKING, Any, Self import matplotlib.dates as dates import matplotlib.pyplot as plt import numpy as np import pandas as pd import statista.descriptors as metrics +import yaml from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph, PointOverlay from loguru import logger from pyramids.dataset import Dataset from pyramids.dataset import DatasetCollection as Datacube from pyramids.feature import FeatureCollection +from hapi.config import RunConfig from hapi.inputs import ( + METEO_VARIABLES, FlowNetwork, MeteoInputs, _warn_if_no_sentinel, read_rasters, ) +from hapi.rrm.hbv import HBV +from hapi.rrm.hbv_bergestrom92 import HBVBergestrom92 if TYPE_CHECKING: import matplotlib.animation @@ -55,6 +61,131 @@ (False, False): 12, } +#: Conceptual models `conceptual_model.model_class` can name in a YAML configuration. +#: `read_lumped_model` still takes any `type[BaseConceptualModel]`, so this only bounds what the +#: YAML shorthand can reach, not what the class accepts. +CONCEPTUAL_MODELS: dict[str, type[BaseConceptualModel]] = { + "HBVBergestrom92": HBVBergestrom92, + "HBV": HBV, +} + +#: Accepted routing methods, mapped to the one spelling the internals compare against. +#: `distrrm.SpatialRouting` tests `routing_method != "Muskingum"` exactly, so the constructor +#: canonicalises rather than storing what it was handed. `"Kinematic"` belongs here because +#: that comparison is also how the flood model selects its own path: a non-Muskingum method +#: with a real `bankfull_depth` skips the cell, which `Run.RunFloodModel` relies on. +#: `hapi.config.CatchmentConfig.routing_method` exposes the first two to YAML and says why the +#: third is not; a method added here needs a decision there too. +ROUTING_METHODS = { + "muskingum": "Muskingum", + "maxbas": "MAXBAS", + "kinematic": "Kinematic", +} + + +def _resolve_config_paths(config: RunConfig, base: Path) -> None: + """Rewrite every relative path in a configuration against the file it came from. + + A configuration names its inputs relative to itself, so it runs from any working directory + and can be moved with the data it points at. Resolving against the process's working + directory instead would make a file valid only from the one place it happened to be written + for. An absolute path is left alone. + + `meteo`'s three driver fields are paths only for the folder and per-file sources; under + `source="netcdf"` they name variables inside `meteo.path` and must not be touched. + + Args: + config: The parsed configuration, rewritten in place. + base: Directory of the configuration file. + """ + + def resolve(value: str | None) -> str | None: + if value is None: + return value + return value if Path(value).is_absolute() else str((base / value).resolve()) + + if config.meteo.source != "netcdf": + for field in METEO_VARIABLES: + setattr(config.meteo, field, resolve(getattr(config.meteo, field))) + config.meteo.path = resolve(config.meteo.path) + + if config.flow_network is not None: + # `flow_accumulation` and the two below are required fields, so `resolve` cannot + # return None for them; the cast keeps that visible rather than widening the model. + config.flow_network.flow_accumulation = str( + resolve(config.flow_network.flow_accumulation) + ) + config.flow_network.flow_direction = resolve(config.flow_network.flow_direction) + if config.parameters is not None: + config.parameters.path = str(resolve(config.parameters.path)) + if config.gauges is not None: + config.gauges.table = resolve(config.gauges.table) + config.gauges.discharge = str(resolve(config.gauges.discharge)) + if config.outputs is not None: + config.outputs.results_dir = resolve(config.outputs.results_dir) + + +def _check_the_configured_paths_exist(config: RunConfig, distributed: bool) -> None: + """Check every input path the run will open, before the first reader runs. + + The readers fail one at a time and in the order the build happens to call them, so a typo + in the gauge table is only reported after the whole meteorological cube and the parameter + folder have been read -- minutes, on a real grid, to learn about a line the file could have + been checked for at once. Every missing path is named together instead, so one pass over + the message fixes the file. + + Only the paths the chosen shape will actually open are checked, which is the same set the + schema validated the configuration against. + + Args: + config: The parsed configuration, with its paths already resolved. + distributed: Whether this is a distributed run. + + Raises: + FileNotFoundError: One or more configured paths do not exist. + """ + candidates: list[tuple[str, str | None]] = [] + if config.parameters is not None: + candidates.append(("parameters.path", config.parameters.path)) + + if distributed: + # Under `source="netcdf"` the three driver fields name variables inside `meteo.path`, + # not paths, so only the file itself is checked. + if config.meteo.source == "netcdf": + candidates.append(("meteo.path", config.meteo.path)) + else: + candidates += [ + (f"meteo.{name}", getattr(config.meteo, name)) + for name in METEO_VARIABLES + ] + if config.flow_network is not None: + candidates += [ + ( + "flow_network.flow_accumulation", + config.flow_network.flow_accumulation, + ), + ("flow_network.flow_direction", config.flow_network.flow_direction), + ] + else: + candidates.append(("meteo.path", config.meteo.path)) + + if config.gauges is not None: + candidates += [ + ("gauges.discharge", config.gauges.discharge), + ("gauges.table", config.gauges.table), + ] + + missing = [ + f"{field} -> {value}" + for field, value in candidates + if value is not None and not Path(value).exists() + ] + if missing: + raise FileNotFoundError( + "the run configuration names inputs that do not exist:\n " + + "\n ".join(missing) + ) + @contextmanager def _name_the_path(path) -> Iterator[None]: @@ -112,19 +243,37 @@ def __init__( "Distributed". Default is "Lumped". temporal_resolution (str, optional): "Hourly" or "Daily". Default is "Daily". - routing_method (str, optional): Routing method name. - Default is "Muskingum". + routing_method (str, optional): "Muskingum", "MAXBAS" or + "Kinematic", matched case-insensitively and stored + canonicalised. Default is "Muskingum". Raises: + TypeError: If `spatial_resolution`, `temporal_resolution` or + `routing_method` is not a string. ValueError: If `spatial_resolution` is not "lumped" or "distributed". ValueError: If `temporal_resolution` is not "daily" or "hourly". + ValueError: If `routing_method` is not "Muskingum", "MAXBAS" or + "Kinematic". """ self.name = name self.start = dt.datetime.strptime(start_data, fmt) self.end = dt.datetime.strptime(end, fmt) + # All three of the mode arguments are lower-cased below, so a non-string reaches + # `.lower()` and raises an `AttributeError` naming neither the argument nor the class. + # Checked together, once, rather than three times over. + for argument, value in ( + ("spatial_resolution", spatial_resolution), + ("temporal_resolution", temporal_resolution), + ("routing_method", routing_method), + ): + if not isinstance(value, str): + raise TypeError( + f"{argument} must be a string, got {type(value).__name__}" + ) + if spatial_resolution.lower() not in ["lumped", "distributed"]: raise ValueError( "available spatial resolutions are 'lumped' and 'distributed'" @@ -148,7 +297,17 @@ def __init__( self.conversion_factor = CONVERSION_FACTOR * 1 / 24 self.date_index = pd.date_range(self.start, self.end, freq="h") - self.routing_method = routing_method + # Canonicalised like the two resolutions above, and for a sharper reason: + # `distrrm.SpatialRouting` tests `routing_method != "Muskingum"` case-sensitively, and + # the false branch reads `bankfull_depth`, which is None outside the flood model. Left + # verbatim, a lower-case "muskingum" therefore routed every cell down the MAXBAS branch + # and raised `TypeError: 'NoneType' object is not subscriptable`. + if routing_method.lower() not in ROUTING_METHODS: + raise ValueError( + f"available routing methods are {', '.join(map(repr, ROUTING_METHODS))}, " + f"got {routing_method!r}" + ) + self.routing_method = ROUTING_METHODS[routing_method.lower()] self.parameters: np.ndarray | list | None = None self.data: np.ndarray | None = None #: The three meteorological drivers. Assign a :class:`~hapi.inputs.MeteoInputs` @@ -189,6 +348,176 @@ def __init__( self.qlz: np.ndarray | None = None self.Qsim: np.ndarray | None = None self.metrics: pd.DataFrame | None = None + #: The configuration this model was built from, when it came from + #: :meth:`from_yaml`; `None` for a model assembled by hand. Carries the blocks the + #: build itself does not consume, such as `outputs`, so a caller need not restate a + #: path the file already gives. + self.config: RunConfig | None = None + + @classmethod + def from_yaml(cls, path: str | Path) -> Self: + """Read a YAML run configuration and assemble a model from it. + + The alternate constructor for the build-then-mutate pattern this class documents: it + constructs the model, assigns `meteo` and (distributed only) `flow_network`, then makes + the `read_*` calls in the order they depend on each other -- the sequence a hand-written + script's block of path assignments used to drive by hand. + + `hapi.config` only parses and validates; every assignment onto the model happens here. + Running the model stays the caller's job, through whichever `Run.*` entry point suits + `routing_method` and `spatial_resolution`. + + Builds `cls`, so `Calibration.from_yaml(...)` returns a `Calibration` -- it takes the + same constructor arguments. `Run` does not: it overrides `__init__` to take none, and + its entry points are called unbound on a catchment (`Run.RunHapi(model)`), so it + overrides this method to refuse the call and say so. + + Args: + path: Path to the YAML file, as a string or a `Path`. See :mod:`hapi.config` for + the schema. + + Returns: + Self: The model, with every input read, parsed and assigned. + + Raises: + FileNotFoundError: No file at `path`. + yaml.YAMLError: The file is not valid YAML. + pydantic.ValidationError: The file is missing a required field, carries an unknown + one, or breaks one of the cross-field rules in :class:`hapi.config.RunConfig`. + ValueError: The file is empty, or `conceptual_model.model_class` names a model + that is not in `CONCEPTUAL_MODELS`. + + Examples: + The configurations below ship with the Hapi repository, so these run from a + checkout rather than an installed wheel; point at your own file to try them + elsewhere. + + - Build a lumped model and inspect what the configuration gave it: + ```python + >>> from hapi.catchment import Catchment + >>> model = Catchment.from_yaml( + ... "examples/hydrological-model/coello/run/coello-lumped-model-run.yaml" + ... ) + >>> model.name + 'Coello' + >>> model.spatial_resolution + 'lumped' + >>> len(model.date_index) + 1095 + + ``` + - Build a distributed model, whose drivers and routing network come from the + `meteo` and `flow_network` blocks: + ```python + >>> from hapi.catchment import Catchment + >>> model = Catchment.from_yaml( + ... "examples/hydrological-model/coello/run/" + ... "coello-distributed-model-run-netcdf.yaml" + ... ) + >>> model.meteo.shape + (13, 14, 10) + >>> model.flow_network.rows, model.flow_network.cols + (13, 14) + >>> model.routing_method + 'Muskingum' + + ``` + """ + # Explicit encoding: without it the file is decoded with the locale codec, so a + # non-ASCII catchment name or path mojibakes on a machine whose default is not UTF-8 + # -- and does so silently, since the corrupted text is still valid YAML. + text = Path(path).read_text(encoding="utf-8") + mapping = yaml.safe_load(text) + # An empty file parses to None, which pydantic would report as the opaque + # "Input should be a valid dictionary" without saying which file was empty. + if mapping is None: + raise ValueError(f"the run configuration at {path} is empty") + config = RunConfig.model_validate(mapping) + # Relative paths belong to the file, not to whatever directory the process happens to + # be in, so a configuration runs from anywhere and travels with the data it names. + _resolve_config_paths(config, Path(path).resolve().parent) + catchment = config.catchment + + # The first three go positionally on purpose: `Catchment.__init__` calls its second + # parameter `start_data` and `Calibration.__init__` calls it `start`, so naming them + # would break `Calibration.from_yaml` -- which this method is documented to support -- + # while still working here. Renaming the parameter is the fix, and is breaking. + model = cls( + catchment.name, + catchment.start, + catchment.end, + fmt=catchment.fmt, + spatial_resolution=catchment.spatial_resolution, + temporal_resolution=catchment.temporal_resolution, + routing_method=catchment.routing_method, + ) + + # Resolved before any reader runs: it needs nothing but the config, and a typo here + # would otherwise cost the whole parameter folder read before failing. + conceptual_model = config.conceptual_model + if conceptual_model.model_class not in CONCEPTUAL_MODELS: + raise ValueError( + f"conceptual_model.model_class {conceptual_model.model_class!r} is not " + f"registered; known models are {sorted(CONCEPTUAL_MODELS)}" + ) + model_class = CONCEPTUAL_MODELS[conceptual_model.model_class] + + distributed = catchment.spatial_resolution == "distributed" + _check_the_configured_paths_exist(config, distributed) + if distributed: + model.meteo = MeteoInputs.from_config( + config.meteo, + start=catchment.start, + end=catchment.end, + fmt=catchment.fmt, + ) + model.flow_network = FlowNetwork.from_rasters( + config.flow_network.flow_accumulation, + config.flow_network.flow_direction, + ) + else: + model.read_lumped_inputs(config.meteo.path) + + # A calibration derives its parameters from the bounds `read_parameters_bound` is + # given rather than reading a fitted set, so the block is optional. + if config.parameters is not None: + model.read_parameters( + config.parameters.path, + config.parameters.snow, + maxbas=config.parameters.maxbas, + ) + + model.read_lumped_model( + model_class, + conceptual_model.catchment_area, + conceptual_model.initial_condition, + conceptual_model.q_init, + ) + + # Equally optional: a run that is not scored against observations has no gauges. + gauges = config.gauges + if gauges is not None: + if distributed: + # The table's validity-period columns and the discharge files' index are two + # different files' date layouts, so they get two fields -- with the table + # falling back to the discharge format, which is right whenever one hand wrote + # both. + model.read_gauge_table( + gauges.table, + config.flow_network.flow_accumulation, + fmt=gauges.table_fmt or gauges.fmt, + ) + model.read_discharge_gauges( + gauges.discharge, + delimiter=gauges.delimiter, + column=gauges.column, + fmt=gauges.fmt, + ) + + # Kept so the blocks the build does not itself consume stay reachable -- `outputs` + # above all, which describes where results go rather than what the model reads. + model.config = config + return model def read_flow_path_length(self, path: str): """Read the flow path length raster. @@ -388,6 +717,8 @@ def read_lumped_model( None. Raises: + TypeError: If `initial_condition` is not a list, or if + `q_init` is given and is not a float. ValueError: If `lumped_model` is not a class or if `initial_condition` does not contain exactly 5 values. @@ -400,6 +731,14 @@ def read_lumped_model( self.lumped_model = lumped_model() self.area = catchment_area + # Typed before it is measured. The other order called `len` first, so None reported + # "object of type 'NoneType' has no len()" rather than naming the argument, and the + # `is not None` the type check then carried could never be false -- `len` would + # already have raised. + if not isinstance(initial_condition, list): + raise TypeError( + f"init_st should be of type list, got {type(initial_condition).__name__}" + ) if len(initial_condition) != 5: raise ValueError( f"state variables are 5 and the given initial values are {len(initial_condition)}" @@ -407,13 +746,12 @@ def read_lumped_model( self.initial_cond = initial_condition - if q_init is not None: - assert isinstance(q_init, float), "q_init should be of type float" + if q_init is not None and not isinstance(q_init, float): + raise TypeError( + f"q_init should be of type float, got {type(q_init).__name__}" + ) self.q_init = q_init - if self.initial_cond is not None: - assert isinstance(self.initial_cond, list), "init_st should be of type list" - logger.debug("Lumped model is read successfully") def read_lumped_inputs(self, path: str): @@ -612,15 +950,19 @@ def read_discharge_gauges( (distributed) or file (lumped). delimiter (str, optional): Delimiter between the date and the discharge column. Default is ",". - column (str, optional): Name of the column in the gauge - table containing the file names. Default is "id". + column (str, optional): Gauge-table column naming the columns of the + resulting `QGauges` frame. It does not select the file names -- + those always come from the "id" column. Default is "id". fmt (str, optional): Date format in the discharge files. Default is "%Y-%m-%d". split (bool, optional): True to subset the data between `start_date` and `end_date`. Default is False. - start_date (str, optional): Start date for subsetting. + start_date (str | dt.datetime, optional): Start date for + subsetting. A string is parsed with `fmt`; a datetime is + used as it is. Default is "". - end_date (str, optional): End date for subsetting. + end_date (str | dt.datetime, optional): End date for + subsetting. See `start_date`. Default is "". readfrom (str, optional): Number of rows to skip when reading the CSV. Default is "". @@ -628,7 +970,7 @@ def read_discharge_gauges( Raises: FileNotFoundError: If the discharge file does not exist (lumped mode). - AssertionError: If the gauge table has not been read yet + ValueError: If the gauge table has not been read yet (distributed mode). """ if self.temporal_resolution.lower() == "daily": @@ -637,48 +979,103 @@ def read_discharge_gauges( ind = pd.date_range(self.start, self.end, freq="h") if self.spatial_resolution.lower() == "distributed": - assert hasattr(self, "GaugesTable"), "please read the gauges' table first" - - self.QGauges = pd.DataFrame( - index=ind, columns=self.GaugesTable[column].tolist() + self._read_one_discharge_file_per_gauge( + path, ind, delimiter, column, fmt, readfrom ) + else: + self._read_the_single_discharge_file(path, ind, delimiter, fmt) - for i in range(len(self.GaugesTable)): - name = self.GaugesTable.loc[i, "id"] - if readfrom != "": - f = pd.read_csv( - f"{path}/{name}.csv", - index_col=0, - delimiter=delimiter, - skiprows=readfrom, - ) # ,#delimiter="\t" - else: - f = pd.read_csv( - f"{path}/{name}.csv", - header=0, - index_col=0, - delimiter=delimiter, - ) + if split: + if isinstance(start_date, str): + start_date = dt.datetime.strptime(start_date, fmt) + if isinstance(end_date, str): + end_date = dt.datetime.strptime(end_date, fmt) + self.QGauges = self.QGauges.loc[start_date:end_date] - f.index = [dt.datetime.strptime(i, fmt) for i in f.index.tolist()] - self.QGauges[int(name)] = f.loc[self.start : self.end, f.columns[-1]] - else: - if not os.path.exists(path): - raise FileNotFoundError( - f"The file you have entered{path} does not exist" + logger.debug("Gauges data are read successfully") + + def _read_one_discharge_file_per_gauge( + self, + path: str, + index: pd.DatetimeIndex, + delimiter: str, + column: str, + fmt: str, + readfrom: str, + ) -> None: + """Fill `QGauges` from a folder holding one CSV per gauge id. + + Args: + path: Folder of per-gauge CSVs, each named after a gauge id. + index: The model's date index, which the frame is built on. + delimiter: Discharge CSV delimiter. + column: Gauge-table column naming the frame's columns. + fmt: `strptime` format for each file's date column. + readfrom: Rows to skip, or "" to read from the header. + + Raises: + ValueError: `read_gauge_table` has not been called yet. + """ + # `__init__` sets GaugesTable to None, so the `hasattr` this replaced was always + # true and never guarded anything: a caller who skipped `read_gauge_table` got a + # `TypeError` on None a few lines down instead. + if self.GaugesTable is None: + raise ValueError( + "the gauge table has not been read yet; call read_gauge_table before " + "read_discharge_gauges in distributed mode" + ) + + # The frame is labelled from `column` but every file is named after `id`, so the + # two are tracked separately: filling by `int(name)` instead of by the label the + # frame was built with left a `column != "id"` table with the requested columns + # all-NaN and a second set of id-named ones beside them, silently. + labels = self.GaugesTable[column].tolist() + self.QGauges = pd.DataFrame(index=index, columns=labels) + + for i in range(len(self.GaugesTable)): + name = self.GaugesTable.loc[i, "id"] + if readfrom != "": + f = pd.read_csv( + f"{path}/{name}.csv", + index_col=0, + delimiter=delimiter, + skiprows=readfrom, + ) + else: + f = pd.read_csv( + f"{path}/{name}.csv", + header=0, + index_col=0, + delimiter=delimiter, ) - self.QGauges = pd.DataFrame(index=ind) - f = pd.read_csv(path, header=0, index_col=0, delimiter=delimiter) f.index = [dt.datetime.strptime(i, fmt) for i in f.index.tolist()] - self.QGauges[f.columns[0]] = f.loc[self.start : self.end, f.columns[0]] + self.QGauges[labels[i]] = f.loc[self.start : self.end, f.columns[-1]] - if split: - start_date = dt.datetime.strptime(start_date, fmt) - end_date = dt.datetime.strptime(end_date, fmt) - self.QGauges = self.QGauges.loc[start_date:end_date] + def _read_the_single_discharge_file( + self, path: str, index: pd.DatetimeIndex, delimiter: str, fmt: str + ) -> None: + """Fill `QGauges` from one CSV, the lumped case. - logger.debug("Gauges data are read successfully") + A lumped run has no grid to locate gauges on, so there is one hydrograph and no + gauge table: the frame takes the file's own first column as its only column. + + Args: + path: The discharge CSV. + index: The model's date index, which the frame is built on. + delimiter: Discharge CSV delimiter. + fmt: `strptime` format for the file's date column. + + Raises: + FileNotFoundError: `path` does not exist. + """ + if not os.path.exists(path): + raise FileNotFoundError(f"The file you have entered{path} does not exist") + + self.QGauges = pd.DataFrame(index=index) + f = pd.read_csv(path, header=0, index_col=0, delimiter=delimiter) + f.index = [dt.datetime.strptime(i, fmt) for i in f.index.tolist()] + self.QGauges[f.columns[0]] = f.loc[self.start : self.end, f.columns[0]] def read_parameters_bound( self, @@ -701,13 +1098,15 @@ def read_parameters_bound( maxbas. Default is False. Raises: - AssertionError: If the lengths of `upper_bound` and + ValueError: If the lengths of `upper_bound` and `lower_bound` are not equal. ValueError: If `snow` is not a boolean. """ - assert len(upper_bound) == len(lower_bound), ( - "the length of UB should be the same as LB" - ) + if len(upper_bound) != len(lower_bound): + raise ValueError( + f"the length of UB should be the same as LB, got {len(upper_bound)} and " + f"{len(lower_bound)}" + ) self.UB = np.array(upper_bound) self.LB = np.array(lower_bound) @@ -859,8 +1258,10 @@ def plot_hydrograph( r"""Plot simulated and observed hydrographs for a given gauge. Args: - start_date (str): Starting date for the plot. - end_date (str): End date for the plot. + start_date (str | dt.datetime): Starting date for the plot. A + string is parsed with `fmt`; a datetime is used as it is. + end_date (str | dt.datetime): End date for the plot. See + `start_date`. gauge (int): Index of the gauge in the GaugesTable. hapi_color (tuple | str, optional): Color of the simulated hydrograph. Default is "#004c99". @@ -890,8 +1291,10 @@ def plot_hydrograph( tuple: A tuple of (fig, ax) where fig is the matplotlib Figure and ax is the matplotlib Axes object. """ - start_date = dt.datetime.strptime(start_date, fmt) - end_date = dt.datetime.strptime(end_date, fmt) + if isinstance(start_date, str): + start_date = dt.datetime.strptime(start_date, fmt) + if isinstance(end_date, str): + end_date = dt.datetime.strptime(end_date, fmt) fig, ax = plt.subplots(ncols=1, nrows=1, figsize=(6, 5)) @@ -1144,12 +1547,16 @@ def save_results( 5 - Soil moisture, 6 - Upper zone, 7 - Lower zone, 8 - Water content. For lumped mode, 5 saves all variables. Default is 1. - start (str, optional): Start date for the output period. + start (str | dt.datetime, optional): Start date for the + output period. A string is parsed with `fmt`; a datetime + is used as it is. If empty, uses the first index. Default is "". - end (str, optional): End date for the output period. If + end (str | dt.datetime, optional): End date for the output + period. See `start`. If empty, uses the last index. Default is "". - path (str, optional): Path to the output directory - (distributed) or file (lumped). Default is "". + path (str, optional): Output directory (distributed, created + if it does not exist) or the CSV file itself (lumped). + Default is "", the working directory. prefix (str, optional): Prefix for the output file names. Default is "". fmt (str, optional): Date format for parsing `start` and @@ -1158,16 +1565,25 @@ def save_results( Raises: Exception: If `flow_acc_path` is not provided in distributed mode. + TypeError: If `path` is not a string. `outputs.results_dir` + is optional in a run configuration, so a caller + forwarding it can hold None. ValueError: If `result` is not a valid option. """ + if not isinstance(path, str): + raise TypeError( + f"path must be a string naming a directory (distributed) or a file " + f"(lumped), got {type(path).__name__}" + ) + if start == "": start = self.date_index[0] - else: + elif isinstance(start, str): start = dt.datetime.strptime(start, fmt) if end == "": end = self.date_index[-1] - else: + elif isinstance(end, str): end = dt.datetime.strptime(end, fmt) start_i = np.nonzero(self.date_index == start)[0][0] @@ -1184,12 +1600,16 @@ def save_results( if prefix == "": prefix = "Result_" - # create a list of names - path = path + prefix - names = [path + str(i)[:10] for i in self.date_index[start_i:end_i]] - # names = [i.replace("-", "_") for i in names] - # names = [i.replace(" ", "_") for i in names] - names = [i + ".tif" for i in names] + # `path` names a directory here, unlike the lumped branch below where it is the + # CSV itself. Joined rather than concatenated: the old `path + prefix` wrote + # `some/dirResult_2009-01-01.tif` for any directory given without a trailing + # separator, which is how a directory is normally written. + if path and not os.path.isdir(path): + os.makedirs(path, exist_ok=True) + names = [ + os.path.join(path, f"{prefix}{str(i)[:10]}.tif") + for i in self.date_index[start_i:end_i] + ] if result == 1: arr = self.Qtot[:, :, start_i:end_i] elif result == 2: @@ -1243,7 +1663,10 @@ def save_results( data[STATE_VARIABLES] = self.state_variables[start_i:end_i, :] data.to_csv(path, index=False, float_format="%.3f") else: - assert False, "the possible options are from 1 to 5" + raise ValueError( + f"in lumped mode the result parameter takes a value between 1 and 5, " + f"given: {result}" + ) logger.debug("Data is saved successfully") @@ -1288,7 +1711,10 @@ def __init__( elif temporal_resolution.lower() == "hourly": self.Index = pd.date_range(start, end, freq="h") else: - assert False, "Error" + raise ValueError( + f"available temporal resolutions are 'daily' and 'hourly', got " + f"{temporal_resolution!r}" + ) self.MeteoData: np.ndarray | None = None self.Parameters: list | None = None @@ -1360,7 +1786,7 @@ def read_lumped_model( Raises: ValueError: If `lumped_model` is not a class. - AssertionError: If `initial_condition` is not a list. + TypeError: If `initial_condition` is not a list. """ if not inspect.isclass(lumped_model): raise ValueError( @@ -1373,8 +1799,10 @@ def read_lumped_model( self.LakeArea = lake_area self.InitialCond = initial_condition - if self.InitialCond is not None: - assert isinstance(self.InitialCond, list), "init_st should be of type list" + if self.InitialCond is not None and not isinstance(self.InitialCond, list): + raise TypeError( + f"init_st should be of type list, got {type(self.InitialCond).__name__}" + ) self.Snow = snow self.OutflowCell = outflow_cell diff --git a/src/hapi/config.py b/src/hapi/config.py new file mode 100644 index 000000000..0fea56dc8 --- /dev/null +++ b/src/hapi/config.py @@ -0,0 +1,702 @@ +"""The schema of a YAML run configuration. + +This module describes data and nothing else: `RunConfig` and the blocks it nests validate a +parsed YAML mapping and hold the result. It imports nothing from `hapi`, which keeps it a leaf +of the import graph and lets `hapi.catchment` import it at module level. Reading the file and +building a model out of it -- the `Catchment` construction, the `MeteoInputs` / `FlowNetwork` +loaders, the `read_*` call order -- belongs to :meth:`hapi.catchment.Catchment.from_yaml`. + +The schema covers lumped and distributed runs, which disagree on the shape of two blocks: + +- `meteo`: a grid for distributed (raster folders or NetCDF, per `source`), and a single CSV of + catchment-average drivers for lumped. +- `gauges`: a gauge table plus a folder of per-gauge discharge files for distributed, and one + discharge file with no table for lumped. + +Which fields are required therefore depends on `catchment.spatial_resolution` and, for a +distributed run, on `meteo.source`. Those cross-field rules are enforced here by model +validators, so the builder can consume a validated `RunConfig` without re-deriving them. Two +things are still left to it, because neither can be settled from the file alone: resolving +`conceptual_model.model_class` against the registry of model classes, and checking that the +paths exist. `Catchment.from_yaml` does both before it opens anything. + +The same dependency decides which fields are *refused*: a field the chosen shape never reads is +rejected rather than dropped. `extra="forbid"` already does that for a misspelled key, and a +correctly spelled but inapplicable one is the same mistake -- a line in the file with no effect +-- so it fails the same way. Only fields written explicitly count, so defaults are never held +against an author. + +Out of scope, each because the schema carries no field that reaches it: + +- Lake-aware runs (`hapi.catchment.Lake`) and the flood model (`Run.RunFloodModel`), which need + a lake record and a river geometry respectively -- so `read_river_geometry` is unreachable. +- `read_flow_path_length`, and with it `DistMaxbas2`, which scales each cell's MAXBAS by its + distance to the outlet. +- Reading a driver folder by numeric file order rather than by date. `MeteoConfig` has no + `date` field, so `date=False` can only be reached through `per_variable` -- where it now + meets the catchment window this schema always passes down, and `read_rasters` refuses the + combination. + +Examples: + - Validate a lumped configuration and read back what it holds: + ```python + >>> from hapi.config import RunConfig + >>> config = RunConfig.model_validate( + ... { + ... "catchment": { + ... "name": "Coello", + ... "start": "2009-01-01", + ... "end": "2011-12-31", + ... }, + ... "meteo": {"path": "meteo_data.csv"}, + ... "parameters": {"path": "parameters.txt"}, + ... "conceptual_model": { + ... "model_class": "HBVBergestrom92", + ... "catchment_area": 1530, + ... "initial_condition": [0, 10, 10, 10, 0], + ... }, + ... "gauges": {"discharge": "Qout_c.csv"}, + ... } + ... ) + >>> config.catchment.spatial_resolution + 'lumped' + >>> config.conceptual_model.catchment_area + 1530.0 + >>> config.gauges.fmt + '%Y-%m-%d' + + ``` + - A distributed run needs a routing network, so one without it is refused: + ```python + >>> from pydantic import ValidationError + >>> from hapi.config import RunConfig + >>> try: + ... RunConfig.model_validate( + ... { + ... "catchment": { + ... "name": "Coello", + ... "start": "2009-01-01", + ... "end": "2011-12-31", + ... "spatial_resolution": "distributed", + ... }, + ... "meteo": {"path": "meteo_data.csv"}, + ... "parameters": {"path": "parameters.txt"}, + ... "conceptual_model": { + ... "model_class": "HBVBergestrom92", + ... "catchment_area": 1530, + ... "initial_condition": [0, 10, 10, 10, 0], + ... }, + ... "gauges": {"discharge": "Qout_c.csv"}, + ... } + ... ) + ... except ValidationError as error: + ... print(error.errors()[0]["msg"]) + Value error, catchment.spatial_resolution is 'distributed', which needs a flow_network block + + ``` +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import date, datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +#: Rejects unknown keys, so a misspelled field in the YAML fails loudly at parse time rather +#: than being dropped and surfacing later as a missing input. +_STRICT = ConfigDict(extra="forbid") + + +#: Which `MeteoConfig` fields each `source` actually reaches, mirroring the three branches of +#: `MeteoInputs.from_config`. A field outside its source's set is one the reader never looks at, +#: so setting it says something the run will not do. +_METEO_FIELDS_BY_SOURCE: dict[str, frozenset[str]] = { + "rasters": frozenset( + { + "source", + "precipitation", + "temperature", + "evapotranspiration", + "start", + "end", + "fmt", + "glob", + "regex_string", + "file_name_data_fmt", + "per_variable", + "gdal_env", + } + ), + "netcdf": frozenset( + { + "source", + "path", + "precipitation", + "temperature", + "evapotranspiration", + "start", + "end", + "fmt", + } + ), + "netcdf_files": frozenset( + { + "source", + "precipitation", + "temperature", + "evapotranspiration", + "variable", + "start", + "end", + "fmt", + } + ), +} + +#: A lumped run reads `meteo.path` as one CSV of catchment-average drivers -- no grid, no +#: window, no reader knobs -- and locates nothing, so the gauge table and the column that names +#: hydrographs from it have nothing to act on. +_LUMPED_METEO_FIELDS = frozenset({"source", "path"}) +_LUMPED_GAUGES_FIELDS = frozenset({"discharge", "delimiter", "fmt"}) + + +#: The three fields every distributed `meteo.source` needs, in the order they are reported. +METEO_DRIVERS = ("precipitation", "temperature", "evapotranspiration") + +#: What `source="netcdf"` needs beyond the drivers, since it reads all three from one file. +NETCDF_PATH_MESSAGE = ( + "meteo.source is 'netcdf', which reads the three drivers out of one file, so meteo.path " + "must be set" +) + + +def missing_drivers_message(missing: Sequence[str]) -> str: + """Phrase the "not all three drivers are named" error. + + `RunConfig` raises this for a configuration and `MeteoInputs.from_config` raises it again + for a `MeteoConfig` built by hand, which the schema never saw. Two sites, one rule -- so + the wording lives here rather than being written out twice and drifting. + + Args: + missing: Names of the unset drivers. + + Returns: + str: The message. + + Examples: + - One driver missing reads as a singular: + ```python + >>> from hapi.config import missing_drivers_message + >>> missing_drivers_message(["temperature"]) + 'a distributed run needs all three meteorological drivers; temperature is unset' + + ``` + - Several are listed in the order they are given: + ```python + >>> from hapi.config import missing_drivers_message + >>> message = missing_drivers_message(["temperature", "evapotranspiration"]) + >>> message.split("; ")[1] + 'temperature, evapotranspiration are unset' + + ``` + """ + return ( + f"a distributed run needs all three meteorological drivers; " + f"{', '.join(missing)} {'is' if len(missing) == 1 else 'are'} unset" + ) + + +def _reject_fields_the_run_will_not_read( + block: BaseModel, applicable: frozenset[str], block_name: str, because: str +) -> None: + """Refuse fields of a block that the chosen run shape never reads. + + `extra="forbid"` catches a misspelled key; this catches a correctly spelled one that does + not apply. Both are the same failure from the author's side -- a line in the file that has + no effect -- and silently dropping the second is what makes a configuration hard to trust. + Only explicitly set fields count, so a default the author never wrote is not held against + them. + + Args: + block: The block to check. + applicable: Names the run shape actually reads. + block_name: The block's key in the file, for the message. + because: Why the rest do not apply, phrased to follow "which". + + Raises: + ValueError: One or more set fields fall outside `applicable`. + """ + inapplicable = sorted(block.model_fields_set - applicable) + if inapplicable: + named = ", ".join(f"{block_name}.{name}" for name in inapplicable) + raise ValueError(f"{because}, so {named} would be read by nothing") + + +def _write_dates_in_the_block_format(values: Any, fields: tuple[str, ...]) -> Any: + """Render any date YAML already parsed back into a string in the block's own format. + + An unquoted `start: 2009-01-01` is a `datetime.date` by the time pydantic sees it, and the + date fields are strings because the readers downstream parse them with `fmt`. Rejecting the + unquoted spelling would be rejecting the one a YAML author writes first, over a difference + that carries no information: a parsed date has no format ambiguity left to preserve, so it + is simply written back out in `fmt`. + + Args: + values: The raw mapping pydantic is about to validate. Anything else is passed through + for pydantic to reject with its own message. + fields: Names of the date fields in this block. + + Returns: + Any: The mapping, with any parsed date in `fields` replaced by its `fmt` rendering. + """ + if not isinstance(values, dict): + return values + + fmt = values.get("fmt", "%Y-%m-%d") + if not isinstance(fmt, str): + return values + + rendered = dict(values) + for field in fields: + value = rendered.get(field) + # `datetime` subclasses `date`, so this covers `2009-01-01 06:00:00` too. + if isinstance(value, date): + rendered[field] = value.strftime(fmt) + return rendered + + +class CatchmentConfig(BaseModel): + """The `Catchment` constructor arguments. + + Attributes: + name: Catchment name. + start: Start date, parsed with `fmt`. Held as a string, because the constructor does + the parsing; an unquoted YAML date arrives here already a `date` and is written + back out in `fmt`, so both spellings work. + end: End date, parsed with `fmt`. See `start`. + fmt: `strptime` format for `start` / `end`. + spatial_resolution: `"lumped"` or `"distributed"`. Selects the shape of `meteo` and + `gauges`, and whether `flow_network` is required. + temporal_resolution: `"daily"` or `"hourly"`. + routing_method: `"muskingum"` or `"maxbas"`. Assigned onto `model.routing_method`, and + constrains one other block: Muskingum routes along the network, so a distributed + run needs `flow_network.flow_direction`. Left unwritten it is derived from + `parameters.maxbas`, which describes the same choice from the parameter set's + side; written, it must agree with it. Which `Run.*` entry point actually routes + with it is still the caller's choice. + """ + + model_config = _STRICT + + name: str + start: str + end: str + fmt: str = "%Y-%m-%d" + spatial_resolution: Literal["lumped", "distributed"] = "lumped" + temporal_resolution: Literal["daily", "hourly"] = "daily" + # Two of the three keys of `hapi.catchment.ROUTING_METHODS`, which is what the constructor + # accepts. `kinematic` is left out deliberately: it selects the flood model, whose inputs + # (`read_river_geometry`, `bankfull_depth`) this schema does not carry, so a configuration + # naming it would validate and then build a model that cannot run. Adding a method there + # means deciding here whether the schema can describe a run that uses it. + routing_method: Literal["muskingum", "maxbas"] = "muskingum" + + @model_validator(mode="before") + @classmethod + def _accept_a_date_yaml_already_parsed(cls, values: Any) -> Any: + """Render an unquoted YAML date back into `fmt` before the string fields see it. + + Args: + values: The raw mapping. + + Returns: + Any: The mapping, with `start` and `end` as strings. + """ + return _write_dates_in_the_block_format(values, ("start", "end")) + + +class MeteoConfig(BaseModel): + """The meteorological drivers: a distributed grid or a lumped CSV. + + Attributes: + source: Which `MeteoInputs` loader builds the grid. Ignored for a lumped run, which + always reads `path` as a single CSV. + precipitation: Rainfall folder (`"rasters"`), NetCDF path (`"netcdf_files"`), or the + variable name holding rainfall inside `path` (`"netcdf"`). + temperature: As `precipitation`, for temperature. + evapotranspiration: As `precipitation`, for evapotranspiration. + path: The combined NetCDF (`source="netcdf"`) or the lumped meteo CSV. + variable: Which variable to take from each file, `source="netcdf_files"` only. `None` + takes the single variable a file holds, which is an error if it holds several. + start: Window start; `None` falls back to `catchment.start`. Distributed only. + end: Window end; `None` falls back to `catchment.end`. Distributed only. + fmt: `strptime` format for `start` / `end`. + glob: Raster glob, `source="rasters"` only. + regex_string: Date regex within file names, `source="rasters"` only. + file_name_data_fmt: `strptime` format for the matched date; inferred if `None`. + per_variable: Per-folder overrides of the reader arguments, `source="rasters"` only. + gdal_env: GDAL environment overrides for the raster read, `source="rasters"` only. + """ + + model_config = _STRICT + + source: Literal["rasters", "netcdf", "netcdf_files"] = "rasters" + precipitation: str | None = None + temperature: str | None = None + evapotranspiration: str | None = None + path: str | None = None + variable: str | None = None + start: str | None = None + end: str | None = None + fmt: str = "%Y-%m-%d" + glob: str = "*.tif" + regex_string: str = r"\d{4}.\d{2}.\d{2}" + file_name_data_fmt: str | None = None + per_variable: dict[str, dict[str, Any]] | None = None + gdal_env: dict[str, str] | None = None + + @model_validator(mode="before") + @classmethod + def _accept_a_date_yaml_already_parsed(cls, values: Any) -> Any: + """Render an unquoted YAML date back into `fmt` before the string fields see it. + + Args: + values: The raw mapping. + + Returns: + Any: The mapping, with `start` and `end` as strings. + """ + return _write_dates_in_the_block_format(values, ("start", "end")) + + +class FlowNetworkConfig(BaseModel): + """The routing network. Distributed runs only. + + Attributes: + flow_accumulation: Path to the flow-accumulation raster. + flow_direction: Path to the flow-direction raster. Muskingum needs it; MAXBAS sends + every cell straight to the outlet and never reads one, so it may be omitted. + """ + + model_config = _STRICT + + flow_accumulation: str + flow_direction: str | None = None + + +class ParametersConfig(BaseModel): + """Where the conceptual-model parameters live. + + Attributes: + path: Folder of parameter rasters (distributed) or a single file (lumped). + snow: Whether the parameter set includes the snow routine (15 parameters against 10). + maxbas: Whether the set carries the triangular-routing parameter. It describes the + parameter set rather than the run, but `RunConfig` requires it to agree with + `catchment.routing_method`: the two counts differ (11 against 12) and `maxbas` + is what selects which is expected, so a disagreeing pair still passes the count + check and then reads the wrong parameter as the routing one. + """ + + model_config = _STRICT + + path: str + snow: bool = False + maxbas: bool = False + + +class ConceptualModelConfig(BaseModel): + """The lumped conceptual model, run per cell (distributed) or per catchment (lumped). + + Attributes: + model_class: Name of the conceptual model, e.g. `"HBVBergestrom92"`. Resolved to a class + by the builder, which owns the registry of available models. + catchment_area: Catchment area, km2. + initial_condition: `[sp, sm, uz, lz, wc]`, exactly five values. + q_init: Initial discharge; `None` derives it from the initial condition. + """ + + # `model_class` would collide with pydantic's protected `model_` namespace, so the namespace + # is cleared rather than renaming a field the YAML already uses. + model_config = ConfigDict(**_STRICT, protected_namespaces=()) + + model_class: str + catchment_area: float = Field(gt=0) + initial_condition: list[float] = Field(min_length=5, max_length=5) + q_init: float | None = None + + +class GaugesConfig(BaseModel): + """The observed discharge the run is scored against. + + Attributes: + discharge: Folder of one CSV per gauge id (distributed) or a single CSV (lumped). + table: Gauge locations and properties. Distributed only; a lumped run has no grid to + locate gauges on. + column: Gauge-table column naming the columns of the resulting hydrograph frame. It + does not select the discharge file names -- `read_discharge_gauges` reads + `.csv` regardless -- so a table can label its hydrographs with human-readable + names while the files stay named after the ids. + delimiter: Discharge CSV delimiter. + fmt: `strptime` format for the discharge CSV's date column. + table_fmt: `strptime` format for the gauge table's optional `start` / `end` columns, + which bound each gauge's validity period. A separate field because the table is a + separate file that a separate hand may have written; `None` falls back to `fmt`, + which is right whenever the two were written together. + """ + + model_config = _STRICT + + discharge: str + table: str | None = None + column: str = "id" + delimiter: str = "," + fmt: str = "%Y-%m-%d" + table_fmt: str | None = None + + +class OutputsConfig(BaseModel): + """Where to write results after the run. + + Attributes: + results_dir: Folder `save_results` writes into. + """ + + model_config = _STRICT + + results_dir: str | None = None + + +class RunConfig(BaseModel): + """The full input set for one `Catchment` build. + + Attributes: + catchment: Constructor arguments. + meteo: The meteorological drivers. + conceptual_model: The lumped conceptual model. + parameters: Where the conceptual-model parameters live. Omit for a calibration, which + derives them from the bounds handed to `read_parameters_bound` rather than reading + a fitted set. + gauges: The observed discharge. Omit for a run that is not scored against gauges. + flow_network: The routing network. Required for a distributed run and refused for a + lumped one, which has no grid to put it on. + outputs: Where to write results. + """ + + model_config = _STRICT + + catchment: CatchmentConfig + meteo: MeteoConfig + conceptual_model: ConceptualModelConfig + parameters: ParametersConfig | None = None + gauges: GaugesConfig | None = None + flow_network: FlowNetworkConfig | None = None + outputs: OutputsConfig | None = None + + @model_validator(mode="after") + def _check_the_routing_method_matches_the_parameter_set(self) -> RunConfig: + """Make `routing_method` agree with the parameter set, deriving it where it is unstated. + + The parameter-count check downstream cannot catch a disagreement: a MAXBAS set holds 11 + parameters and a Muskingum set 12, and `parameters.maxbas` is what selects which count + is expected, so a set that contradicts the routing method still counts correctly. The + run then completes, reading the Muskingum X as the MAXBAS value (or K and X out of a + MAXBAS set), and produces a hydrograph that is quietly wrong. + + A lumped run picks its routing function at the call site rather than from this + attribute, so `routing_method` is not load-bearing there -- but it is public, it is what + `distrrm.SpatialRouting` keys off, and leaving it saying `Muskingum` on a run using a + MAXBAS parameter set would mislead the next reader. An unstated one is therefore + derived from the parameter set rather than left at its default. + + Returns: + RunConfig: This config, with `catchment.routing_method` filled in where it was + unstated and the parameter set says which it is. + + Raises: + ValueError: `catchment.routing_method` and `parameters.maxbas` disagree. + """ + if self.parameters is None: + return self + + if "routing_method" not in self.catchment.model_fields_set: + self.catchment.routing_method = ( + "maxbas" if self.parameters.maxbas else "muskingum" + ) + return self + + if (self.catchment.routing_method == "maxbas") != self.parameters.maxbas: + raise ValueError( + f"catchment.routing_method is {self.catchment.routing_method!r} but " + f"parameters.maxbas is {self.parameters.maxbas}; the parameter set and the " + f"routing method must agree, or the run reads the wrong parameter as the " + f"routing one" + ) + return self + + @model_validator(mode="after") + def _check_blocks_match_the_spatial_resolution(self) -> RunConfig: + """Enforce the fields each spatial resolution requires. + + The two resolutions share no rule -- one describes a grid and a routing network, the + other a pair of CSVs -- so each owns a method and this one only chooses between them. + + Returns: + RunConfig: This config, unchanged. + + Raises: + ValueError: A block the chosen `spatial_resolution` needs is missing, or one it + will never read is present. + """ + if self.catchment.spatial_resolution == "distributed": + self._check_the_distributed_blocks() + else: + self._check_the_lumped_blocks() + return self + + def _check_the_distributed_blocks(self) -> None: + """Enforce what a distributed run needs and what its `meteo.source` can read. + + Raises: + ValueError: The routing network is missing or incomplete, the gauge table is + absent while gauges are configured, a driver is unset, `source="netcdf"` + names no file, or a field outside the source's own set is present. + """ + if self.flow_network is None: + raise ValueError( + "catchment.spatial_resolution is 'distributed', which needs a flow_network " + "block" + ) + # `flow_direction` is optional on the block because MAXBAS sends every cell straight + # to the outlet and never reads one. Muskingum routes along the network, so without + # it the build succeeds and `Run.RunHapi` dereferences a None array after every + # raster has been read. + if ( + self.catchment.routing_method == "muskingum" + and self.flow_network.flow_direction is None + ): + raise ValueError( + "catchment.routing_method is 'muskingum', which routes along the network, " + "so flow_network.flow_direction is required" + ) + # Only when gauges are configured at all: a distributed run that is not scored + # against observations omits the block entirely. + if self.gauges is not None and self.gauges.table is None: + raise ValueError( + "catchment.spatial_resolution is 'distributed', which needs gauges.table " + "to locate the gauges on the grid" + ) + + missing = [name for name in METEO_DRIVERS if getattr(self.meteo, name) is None] + if missing: + raise ValueError(missing_drivers_message(missing)) + if self.meteo.source == "netcdf" and self.meteo.path is None: + raise ValueError(NETCDF_PATH_MESSAGE) + + _reject_fields_the_run_will_not_read( + self.meteo, + _METEO_FIELDS_BY_SOURCE[self.meteo.source], + "meteo", + f"meteo.source is {self.meteo.source!r}", + ) + + def _check_the_lumped_blocks(self) -> None: + """Enforce what a lumped run needs, and refuse the grid it has no use for. + + `extra="forbid"` exists so a misspelled key fails rather than being dropped; + accepting a correctly spelled but inapplicable block would be the same silence by + another route. A lumped run has no grid, so nothing that describes one applies. + + Raises: + ValueError: `meteo.path` is unset, a routing network or a grid `meteo.source` is + present, or a `meteo` / `gauges` field this run will never read is set. + """ + if self.meteo.path is None: + raise ValueError( + "catchment.spatial_resolution is 'lumped', which needs meteo.path -- the " + "CSV of catchment-average drivers" + ) + if self.flow_network is not None: + raise ValueError( + "catchment.spatial_resolution is 'lumped', which has no grid, so a " + "flow_network block cannot be used" + ) + if self.meteo.source != "rasters": + raise ValueError( + f"catchment.spatial_resolution is 'lumped', which reads meteo.path as a " + f"CSV of catchment-average drivers; meteo.source " + f"{self.meteo.source!r} does not apply" + ) + + lumped = "catchment.spatial_resolution is 'lumped'" + _reject_fields_the_run_will_not_read( + self.meteo, + _LUMPED_METEO_FIELDS, + "meteo", + f"{lumped}, which reads meteo.path as one CSV of catchment-average drivers", + ) + if self.gauges is not None: + _reject_fields_the_run_will_not_read( + self.gauges, + _LUMPED_GAUGES_FIELDS, + "gauges", + f"{lumped}, which reads one discharge file and locates no gauges", + ) + + @model_validator(mode="after") + def _check_the_dates_parse_and_are_ordered(self) -> RunConfig: + """Check every date against the format it is written in, and that the period runs. + + Returns: + RunConfig: This config, unchanged. + + Raises: + ValueError: A date does not match its format, or a period ends before it starts. + """ + for label, value, fmt in ( + ("catchment.start", self.catchment.start, self.catchment.fmt), + ("catchment.end", self.catchment.end, self.catchment.fmt), + ("meteo.start", self.meteo.start, self.meteo.fmt), + ("meteo.end", self.meteo.end, self.meteo.fmt), + ): + if value is None: + continue + try: + datetime.strptime(value, fmt) + except ValueError as error: + raise ValueError( + f"{label} {value!r} does not match its format {fmt!r}: {error}" + ) from error + + if datetime.strptime( + self.catchment.start, self.catchment.fmt + ) > datetime.strptime(self.catchment.end, self.catchment.fmt): + raise ValueError( + f"catchment.start {self.catchment.start!r} is after catchment.end " + f"{self.catchment.end!r}" + ) + + # The window the run actually uses, not the two literal pairs: `MeteoInputs.from_config` + # takes each bound from `meteo` when it is stated and falls back to `catchment` + # otherwise, so a `meteo` block stating only an end can invert the effective window + # while neither pair is inverted on its own. + window_start, start_fmt = ( + (self.meteo.start, self.meteo.fmt) + if self.meteo.start is not None + else (self.catchment.start, self.catchment.fmt) + ) + window_end, end_fmt = ( + (self.meteo.end, self.meteo.fmt) + if self.meteo.end is not None + else (self.catchment.end, self.catchment.fmt) + ) + if datetime.strptime(window_start, start_fmt) > datetime.strptime( + window_end, end_fmt + ): + raise ValueError( + f"the meteorological window runs from {window_start!r} to {window_end!r}, " + f"which ends before it starts; each bound is taken from meteo when stated " + f"and from catchment otherwise" + ) + return self diff --git a/src/hapi/inputs.py b/src/hapi/inputs.py index ca0a2da91..31a69b372 100644 --- a/src/hapi/inputs.py +++ b/src/hapi/inputs.py @@ -39,6 +39,11 @@ from pyramids.feature import FeatureCollection from pyramids.netcdf import NetCDF +from hapi.config import ( + NETCDF_PATH_MESSAGE, + MeteoConfig, + missing_drivers_message, +) from hapi.dem import DEM @@ -155,8 +160,8 @@ def read_rasters( regex_string: str = r"\d{4}.\d{2}.\d{2}", date: bool = True, file_name_data_fmt: str | None = None, - start: str | int | None = None, - end: str | int | None = None, + start: str | int | dt.datetime | None = None, + end: str | int | dt.datetime | None = None, fmt: str = "%Y-%m-%d", gdal_env: dict[str, str] | None = None, ) -> Datacube: @@ -230,6 +235,13 @@ def read_rasters( ) if not date: + # The numeric ordering bounds by the index in the file name, so a datetime has no + # meaning here -- `int()` would fail on it several frames down. + if isinstance(start, dt.datetime) or isinstance(end, dt.datetime): + raise TypeError( + "a datetime bound needs date=True; with date=False the rasters are ordered " + "by the number in their name, so start/end are indices" + ) return _read_by_index(path, glob, regex_string, start, end, gdal_env) return Datacube.from_files(path, glob=glob, gdal_env=gdal_env) @@ -949,8 +961,8 @@ def from_rasters( regex_string: str = r"\d{4}.\d{2}.\d{2}", date: bool = True, file_name_data_fmt: str | None = None, - start: str | int | None = None, - end: str | int | None = None, + start: str | int | dt.datetime | None = None, + end: str | int | dt.datetime | None = None, fmt: str = "%Y-%m-%d", gdal_env: dict[str, str] | None = None, ) -> MeteoInputs: @@ -1138,6 +1150,162 @@ def from_netcdf( cubes, calendar = cls._window(cubes, cls._calendar(nc), start, end, fmt) return cls(**cubes, time=calendar) + @classmethod + def from_config( + cls, + config: MeteoConfig, + start: str | None = None, + end: str | None = None, + fmt: str = "%Y-%m-%d", + ) -> MeteoInputs: + """Build the drivers with whichever loader the configuration's `source` names. + + The dispatch behind a `meteo` block of a YAML run configuration: `"rasters"` reads three + folders, `"netcdf_files"` one file per driver, and `"netcdf"` a single combined file + whose variables the block names. `hapi.config.RunConfig` has already checked that the + fields the chosen source needs are set, so this calls the loader directly. + + Each bound is parsed with the format it was written in -- `config.fmt` for a bound the + block states, `fmt` for one inherited from the caller -- and handed on as a `datetime`. + The two formats are independent fields, so parsing an inherited bound with the block's + format would either fail loudly or, between two mutually parseable layouts such as + `"%d-%m-%Y"` and `"%m-%d-%Y"`, silently window the drivers to the wrong period. + + Args: + config: The `meteo` block of a distributed configuration. + start: Window start used when `config.start` is unset, so the drivers can default to + the period the model spans. `None` leaves the lower bound open. + end: Window end used when `config.end` is unset. `None` leaves it open. + fmt: `strptime` format of `start` / `end`, the inherited bounds. Bounds stated by + the block are parsed with `config.fmt` instead. + + Returns: + MeteoInputs: The three cubes plus the calendar, windowed to the requested period. + + Raises: + ValueError: A field the chosen source needs is unset -- one of the three drivers, or + `path` for `source="netcdf"`. `RunConfig` rejects such a configuration, so this + only fires for a `MeteoConfig` built by hand. + + Examples: + The paths below are fixtures in the Hapi repository, so these run from a checkout + rather than an installed wheel; substitute your own file to try them elsewhere. + + - Load a combined NetCDF by naming the variable each driver sits in: + ```python + >>> from hapi.config import MeteoConfig + >>> from hapi.inputs import MeteoInputs + >>> meteo = MeteoInputs.from_config( + ... MeteoConfig( + ... source="netcdf", + ... path="tests/rrm/data/coello/meteo.nc", + ... precipitation="precipitation", + ... temperature="temperature", + ... evapotranspiration="evapotranspiration", + ... ) + ... ) + >>> meteo.shape + (13, 14, 10) + >>> meteo.time[0].strftime("%Y-%m-%d") + '2009-01-01' + + ``` + - Narrow the same file to part of its record with the fallback window: + ```python + >>> from hapi.config import MeteoConfig + >>> from hapi.inputs import MeteoInputs + >>> meteo = MeteoInputs.from_config( + ... MeteoConfig( + ... source="netcdf", + ... path="tests/rrm/data/coello/meteo.nc", + ... precipitation="precipitation", + ... temperature="temperature", + ... evapotranspiration="evapotranspiration", + ... ), + ... start="2009-01-03", + ... end="2009-01-07", + ... ) + >>> meteo.time_steps + 5 + >>> meteo.time[-1].strftime("%Y-%m-%d") + '2009-01-07' + + ``` + + See Also: + from_rasters: The loader `source="rasters"` dispatches to. + from_netcdf: The loader `source="netcdf"` dispatches to. + from_netcdf_files: The loader `source="netcdf_files"` dispatches to. + """ + # Resolve each bound with the format it was written in, then pass datetimes on so the + # loader's own `fmt` cannot re-parse them. + window_start = ( + _as_datetime(config.start, config.fmt) + if config.start is not None + else _as_datetime(start, fmt) + ) + window_end = ( + _as_datetime(config.end, config.fmt) + if config.end is not None + else _as_datetime(end, fmt) + ) + + # The three are optional on the model because a lumped configuration sets none of them, + # while every distributed source needs all three. `RunConfig` enforces that, so reaching + # the raise means a `MeteoConfig` was built by hand -- and it raises the same sentence, + # phrased once in `hapi.config`, because it is the same rule. Bound to locals so the + # check both reports what is missing and narrows the type for the calls below. + precipitation = config.precipitation + temperature = config.temperature + evapotranspiration = config.evapotranspiration + if precipitation is None or temperature is None or evapotranspiration is None: + missing = [ + name for name in METEO_VARIABLES if getattr(config, name) is None + ] + raise ValueError(missing_drivers_message(missing)) + + if config.source == "rasters": + extra: dict[str, Any] = {} + if config.per_variable is not None: + extra["per_variable"] = config.per_variable + if config.gdal_env is not None: + extra["gdal_env"] = config.gdal_env + return cls.from_rasters( + precipitation, + temperature, + evapotranspiration, + glob=config.glob, + regex_string=config.regex_string, + file_name_data_fmt=config.file_name_data_fmt, + start=window_start, + end=window_end, + fmt=config.fmt, + **extra, + ) + + if config.source == "netcdf": + if config.path is None: + raise ValueError(NETCDF_PATH_MESSAGE) + return cls.from_netcdf( + config.path, + precipitation=precipitation, + temperature=temperature, + evapotranspiration=evapotranspiration, + start=window_start, + end=window_end, + fmt=config.fmt, + ) + + return cls.from_netcdf_files( + precipitation, + temperature, + evapotranspiration, + variable=config.variable, + start=window_start, + end=window_end, + fmt=config.fmt, + ) + @staticmethod def raster_folder_to_netcdf( path: str | Path, @@ -1147,8 +1315,8 @@ def raster_folder_to_netcdf( regex_string: str = r"\d{4}.\d{2}.\d{2}", date: bool = True, file_name_data_fmt: str | None = None, - start: str | int | None = None, - end: str | int | None = None, + start: str | int | dt.datetime | None = None, + end: str | int | dt.datetime | None = None, fmt: str = "%Y-%m-%d", gdal_env: dict[str, str] | None = None, ) -> Path: diff --git a/src/hapi/routing.py b/src/hapi/routing.py index a052cc0f2..572ee7c4e 100644 --- a/src/hapi/routing.py +++ b/src/hapi/routing.py @@ -207,7 +207,7 @@ def triangular_routing_2(q, maxbas=1): length as ``q``. Raises: - AssertionError: If ``maxbas`` is less than 1. + ValueError: If ``maxbas`` is less than 1. Examples: >>> import numpy as np @@ -216,7 +216,13 @@ def triangular_routing_2(q, maxbas=1): >>> q_routed = Routing.triangular_routing_2(q, maxbas=3) """ # input data validation - assert maxbas >= 1, "Maxbas value has to be larger than 1" + # `not maxbas >= 1` rather than `maxbas < 1`: both are false for NaN, but + # the original assert fired on it. Keeping the negated form means a NaN + # maxbas -- which a calibration can produce in a masked cell -- still fails + # here, naming the parameter, instead of surfacing as a bare + # "cannot convert float NaN to integer" further in. + if not maxbas >= 1: + raise ValueError(f"Maxbas value has to be at least 1, got {maxbas}") # Get integer part of maxbas maxbas = int(round(maxbas, 0)) @@ -252,12 +258,25 @@ def calculate_weights(maxbas): length is ``floor(MAXBAS)`` for integer values, or ``floor(MAXBAS) + 1`` for non-integer values. + Raises: + ValueError: If ``maxbas`` is less than 1. + Examples: >>> from hapi.routing import Routing >>> weights = Routing.calculate_weights(5) >>> print(weights) [0.08 0.24 0.36 0.24 0.08] """ + # The same guard `triangular_routing_2` and the three conceptual models carry, at the + # one point `triangular_routing_1` -- which the lumped MAXBAS example routes with -- + # passes through. Below 1 the triangle has no whole step to spread over: 0.5 produced a + # single weight and an all-zero hydrograph with nothing raised, 0 an `IndexError` about + # axis 1, and NaN "cannot convert float NaN to integer". `not maxbas >= 1` rather than + # `maxbas < 1` because both are false for NaN, and a calibration can produce one in a + # masked cell. + if not maxbas >= 1: + raise ValueError(f"Maxbas value has to be at least 1, got {maxbas}") + yant = 0 total = 0 # Just to verify how far from the unit is the result @@ -346,6 +365,10 @@ def triangular_routing_1(Q, MAXBAS): numpy.ndarray: Routed output hydrograph with the same length as ``Q``. + Raises: + ValueError: If ``MAXBAS`` is less than 1, raised by + `calculate_weights`. + Examples: >>> import numpy as np >>> from hapi.routing import Routing diff --git a/src/hapi/rrm/base_model.py b/src/hapi/rrm/base_model.py index 281457a29..02190a3ec 100644 --- a/src/hapi/rrm/base_model.py +++ b/src/hapi/rrm/base_model.py @@ -272,7 +272,7 @@ def routing(self, q: np.ndarray, maxbas: int = 1) -> np.ndarray: length as ``q``. Raises: - AssertionError: If ``maxbas`` is less than 1. + ValueError: If ``maxbas`` is less than 1. Examples: >>> import numpy as np diff --git a/src/hapi/rrm/hbv.py b/src/hapi/rrm/hbv.py index 7c4b9959b..1325aecc2 100644 --- a/src/hapi/rrm/hbv.py +++ b/src/hapi/rrm/hbv.py @@ -485,7 +485,7 @@ def routing(self, q, maxbas=1): length as ``q``. Raises: - AssertionError: If ``maxbas`` is less than 1. + ValueError: If ``maxbas`` is less than 1. Examples: >>> import numpy as np @@ -496,7 +496,13 @@ def routing(self, q, maxbas=1): >>> print(q_routed.round(4)) [0. 0. 2.5 4. 2.5 0.5] """ - assert maxbas >= 1, "Maxbas value has to be larger than 1" + # `not maxbas >= 1` rather than `maxbas < 1`: both are false for NaN, but + # the original assert fired on it. Keeping the negated form means a NaN + # maxbas -- which a calibration can produce in a masked cell -- still fails + # here, naming the parameter, instead of surfacing as a bare + # "cannot convert float NaN to integer" further in. + if not maxbas >= 1: + raise ValueError(f"Maxbas value has to be at least 1, got {maxbas}") # Get integer part of maxbas # maxbas = int(maxbas) maxbas = int(round(maxbas, 0)) @@ -556,7 +562,7 @@ def step_run( updated state variables ``[sp, sm, uz, lz, wc]``. Raises: - AssertionError: If ``snow=1`` and the parameter vector + ValueError: If ``snow=1`` and the parameter vector does not have 18 elements. Examples: @@ -580,10 +586,11 @@ def step_run( ## Parse of parameters from input vector to model # picipitation function if snow == 1: - assert len(p) == 18, ( - "current version of HBV (with snow) takes 18 parameter you have entered " - + str(len(p)) - ) + if len(p) != 18: + raise ValueError( + "current version of HBV (with snow) takes 18 parameter you have " + f"entered {len(p)}" + ) ltt = p[0] utt = p[1] rfcf = p[2] @@ -732,7 +739,7 @@ def simulate( ``(n+1, 5)`` (float32). Raises: - AssertionError: If ``init_st`` does not have 5 elements + ValueError: If ``init_st`` does not have 5 elements or if ``snow`` is not 0 or 1. Examples: @@ -758,15 +765,17 @@ def simulate( q_uz length=6, first=0.0727 """ # data type - assert len(init_st) == 5, ( - "state variables are 5 and the given initial values are " - + str(len(init_st)) - ) + if len(init_st) != 5: + raise ValueError( + f"state variables are 5 and the given initial values are {len(init_st)}" + ) # assert type(p2) == list, " p2 should be of type list" # assert len(p2) == 2, "p2 should contains tfac and catchment area" - assert snow == 0 or snow == 1, ( - " snow input defines whether to consider snow subroutine or not it has to be 0 or 1" - ) + if snow not in (0, 1): + raise ValueError( + "snow input defines whether to consider snow subroutine or not it has " + f"to be 0 or 1, got {snow}" + ) if init_st is None: # 0 1 2 3 4 5 st = [DEF_ST] # [sp,sm,uz,lz,wc,LA] diff --git a/src/hapi/rrm/hbv_bergestrom92.py b/src/hapi/rrm/hbv_bergestrom92.py index 6dcce656f..63236ca3b 100644 --- a/src/hapi/rrm/hbv_bergestrom92.py +++ b/src/hapi/rrm/hbv_bergestrom92.py @@ -422,7 +422,7 @@ def routing(self, q, maxbas=1): shape as ``q``. Raises: - AssertionError: If ``maxbas`` is less than 1. + ValueError: If ``maxbas`` is less than 1. Examples: >>> from hapi.rrm.hbv_bergestrom92 import HBVBergestrom92 @@ -433,7 +433,13 @@ def routing(self, q, maxbas=1): >>> len(q_r) == len(q) True """ - assert maxbas >= 1, "Maxbas value has to be larger than 1" + # `not maxbas >= 1` rather than `maxbas < 1`: both are false for NaN, but + # the original assert fired on it. Keeping the negated form means a NaN + # maxbas -- which a calibration can produce in a masked cell -- still fails + # here, naming the parameter, instead of surfacing as a bare + # "cannot convert float NaN to integer" further in. + if not maxbas >= 1: + raise ValueError(f"Maxbas value has to be at least 1, got {maxbas}") # Get integer part of maxbas maxbas = int(round(maxbas, 0)) diff --git a/src/hapi/rrm/hbv_lake.py b/src/hapi/rrm/hbv_lake.py index 6fc0e062e..ac66e3c6b 100644 --- a/src/hapi/rrm/hbv_lake.py +++ b/src/hapi/rrm/hbv_lake.py @@ -483,7 +483,7 @@ def _routing(self, q, maxbas=1): length as ``q``. Raises: - AssertionError: If ``maxbas`` < 1. + ValueError: If ``maxbas`` < 1. Examples: >>> import numpy as np @@ -493,7 +493,13 @@ def _routing(self, q, maxbas=1): >>> len(q_r) == len(q) True """ - assert maxbas >= 1, "Maxbas value has to be larger than 1" + # `not maxbas >= 1` rather than `maxbas < 1`: both are false for NaN, but + # the original assert fired on it. Keeping the negated form means a NaN + # maxbas -- which a calibration can produce in a masked cell -- still fails + # here, naming the parameter, instead of surfacing as a bare + # "cannot convert float NaN to integer" further in. + if not maxbas >= 1: + raise ValueError(f"Maxbas value has to be at least 1, got {maxbas}") # Get integer part of maxbas maxbas = int(round(maxbas, 0)) diff --git a/src/hapi/rrm/parameters.py b/src/hapi/rrm/parameters.py index 19b01925f..7739eb8cf 100644 --- a/src/hapi/rrm/parameters.py +++ b/src/hapi/rrm/parameters.py @@ -87,9 +87,10 @@ def __init__( ValueError: If `function` is not one of the ints 1, 2, 3 or 4. A `bool`, a `float` such as `2.0`, and an unhashable value are all rejected rather than coerced or allowed to raise `TypeError`. - AssertionError: If `no_parameters` is not an integer, if - `no_lumped_par` is not an integer, or if the length of - `lumped_par_pos` does not match `no_lumped_par`. + TypeError: If `no_parameters` or `no_lumped_par` is not an + integer. + ValueError: If the length of `lumped_par_pos` does not match + `no_lumped_par`. ValueError: If `lumped_par_pos` is not a list when `no_lumped_par` >= 1. @@ -149,17 +150,24 @@ def __init__( raise TypeError( "raster should be a pyramids Dataset, read it using pyramids.dataset.Dataset.read_file" ) - assert isinstance(no_parameters, int), " no_parameters should be integer number" - assert isinstance(no_lumped_par, int), ( - "no of lumped parameters should be integer" - ) + if not isinstance(no_parameters, int): + raise TypeError( + f"no_parameters should be integer number, got " + f"{type(no_parameters).__name__}" + ) + if not isinstance(no_lumped_par, int): + raise TypeError( + f"no of lumped parameters should be integer, got " + f"{type(no_lumped_par).__name__}" + ) if no_lumped_par >= 1: if isinstance(lumped_par_pos, list): - assert no_lumped_par == len(lumped_par_pos), ( - f"you have to entered {no_lumped_par} no of lumped parameters but only {len(lumped_par_pos)} " - f"position " - ) + if no_lumped_par != len(lumped_par_pos): + raise ValueError( + f"you have to entered {no_lumped_par} no of lumped parameters " + f"but only {len(lumped_par_pos)} position " + ) else: # if not int or list raise ValueError( "you have one or more lumped parameters, so the position has to be entered as a list" @@ -289,7 +297,7 @@ def par3d(self, par_g: list | np.ndarray): # , kub=1,klb=0.5, Maskingum=True lumped parameter values should be appended at the end. Raises: - AssertionError: If the length of `par_g` does not match the + ValueError: If the length of `par_g` does not match the expected number of parameters based on the number of elements and lumped parameters. """ @@ -303,20 +311,25 @@ def par3d(self, par_g: list | np.ndarray): # , kub=1,klb=0.5, Maskingum=True if self.no_lumped_par > 0: par_no = (self.no_elem * self.no_parameters) + self.no_lumped_par - assert len(par_g) == par_no, ( - f"As there is {self.no_lumped_par} lumped parameters, length of input parameters should be " - f"{self.no_elem}" - + f"*({self.no_parameters + self.no_lumped_par} - {self.no_lumped_par}) + {self.no_lumped_par} = " - + f"{self.no_elem * (self.no_parameters - self.no_lumped_par) + self.no_lumped_par} not {len(par_g)}" - + " probably you have to add the value of the lumped parameter at the end of the list" - ) + if len(par_g) != par_no: + raise ValueError( + f"As there is {self.no_lumped_par} lumped parameters, length of " + f"input parameters should be {self.no_elem}" + f"*({self.no_parameters + self.no_lumped_par} - " + f"{self.no_lumped_par}) + {self.no_lumped_par} = " + f"{self.no_elem * (self.no_parameters - self.no_lumped_par) + self.no_lumped_par}" + f" not {len(par_g)} probably you have to add the value of the " + f"lumped parameter at the end of the list" + ) else: # if there are no lumped parameters par_no = self.no_elem * self.no_parameters - assert len(par_g) == par_no, ( - f"As there is no lumped parameters length of input parameters should be {self.no_elem} * " - + f"{self.no_parameters} = {self.no_elem * self.no_parameters}" - ) + if len(par_g) != par_no: + raise ValueError( + f"As there is no lumped parameters length of input parameters " + f"should be {self.no_elem} * {self.no_parameters} = " + f"{self.no_elem * self.no_parameters}" + ) # parameters in array # create a 2d array [no_parameters, no_cells] @@ -523,7 +536,7 @@ def hydrologic_response_units(self, par_g: list | np.ndarray): # ,kub=1,klb=0.5 ValueError: If `par_g` is not a numpy ndarray or a list, or if the length of `par_g` does not match the expected number of parameters. - AssertionError: If there are lumped parameters and the length + ValueError: If there are lumped parameters and the length of `par_g` does not match the expected total. """ # input data validation @@ -536,15 +549,15 @@ def hydrologic_response_units(self, par_g: list | np.ndarray): # ,kub=1,klb=0.5 # input values if self.no_lumped_par > 0: par_no = (self.no_elem * self.no_parameters) + self.no_lumped_par - assert len(par_g) == par_no, ( - f"As there is {self.no_lumped_par} lumped parameters, length of input parameters should be " - f"{self.no_elem}*({self.no_parameters}-{self.no_lumped_par})+{self.no_lumped_par}=" - + str( - self.no_elem * (self.no_parameters - self.no_lumped_par) - + self.no_lumped_par + if len(par_g) != par_no: + raise ValueError( + f"As there is {self.no_lumped_par} lumped parameters, length of " + f"input parameters should be {self.no_elem}*" + f"({self.no_parameters}-{self.no_lumped_par})+{self.no_lumped_par}=" + f"{self.no_elem * (self.no_parameters - self.no_lumped_par) + self.no_lumped_par}" + f" not {len(par_g)} probably you have to add the value of the " + f"lumped parameter at the end of the list" ) - + f" not {len(par_g)} probably you have to add the value of the lumped parameter at the end of the list" - ) else: # if there is no lumped parameters if not len(par_g) == self.no_elem * self.no_parameters: diff --git a/src/hapi/run.py b/src/hapi/run.py index a220d63a2..07f07ef0d 100644 --- a/src/hapi/run.py +++ b/src/hapi/run.py @@ -9,7 +9,8 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any +from pathlib import Path +from typing import Any, NoReturn import numpy as np import pandas as pd @@ -26,6 +27,47 @@ GRID_MISMATCH_ERROR = "all input data should have the same number of rows" +def _check_parameters_cover_grid(model: Catchment) -> None: + """Check the parameter array spans the catchment grid. + + The same two checks every distributed entry point makes before handing the model to the + wrapper: a parameter array smaller than the grid is indexed out of range inside the + per-cell loop, far from the call that supplied it. + + Args: + model: The model about to run, carrying `parameters` and `flow_network`. + + Raises: + ValueError: The parameter array has the wrong number of rows or columns. + """ + if np.shape(model.parameters)[0] != model.flow_network.rows: + raise ValueError(ROWS_MISMATCH_ERROR) + if np.shape(model.parameters)[1] != model.flow_network.cols: + raise ValueError(COLS_MISMATCH_ERROR) + + +def _check_lake_meteo(model: Catchment, lake: LakeType) -> None: + """Check the lake's record lines up with the distributed drivers. + + Args: + model: The model about to run, whose `meteo` sets the expected length. + lake: The lake whose `MeteoData` is checked. + + Raises: + ValueError: The lake record is a different length from the distributed drivers, or + carries fewer than the three columns the lake model reads. + """ + if np.shape(lake.MeteoData)[0] != model.meteo.time_steps: + raise ValueError( + "Lake meteorological data has to have the same length as the distributed " + "raster data" + ) + if np.shape(lake.MeteoData)[1] < 3: + raise ValueError( + "Lake Meteo data has to have at least three columns of rain, ET, and Temp" + ) + + class Run(Catchment): """Run the catchment model. @@ -45,6 +87,55 @@ def __init__(self): """Initialize the Run class.""" self.Qsim: np.ndarray | pd.DataFrame | None = None + @classmethod + def from_yaml(cls, path: str | Path) -> NoReturn: + """Refuse to build a `Run`, explaining the pattern instead. + + `Run` subclasses `Catchment` to hold its entry points, not to be a catchment: its + `__init__` takes no arguments, so the inherited `Catchment.from_yaml` could only fail + with a `TypeError` about constructor arity -- an error saying nothing about what to do + instead. The methods here are called on a model built elsewhere. + + Args: + path: Ignored; present so the signature matches the one it overrides. + + Raises: + TypeError: Always. + + Examples: + - The refusal names the pattern to use instead: + ```python + >>> from hapi.run import Run + >>> try: + ... Run.from_yaml("coello-lumped-model-run.yaml") + ... except TypeError as error: + ... print(str(error).split(";")[0]) + Run cannot be built from a configuration + + ``` + - Build the model with `Catchment.from_yaml` and hand it to the entry point: + ```python + >>> from hapi.catchment import Catchment + >>> from hapi.routing import Routing + >>> from hapi.run import Run + >>> model = Catchment.from_yaml( + ... "examples/hydrological-model/coello/run/coello-lumped-model-run.yaml" + ... ) + >>> Run.runLumped(model, 1, Routing.muskingum_v) + >>> len(model.Qsim) + 1095 + + ``` + + See Also: + hapi.catchment.Catchment.from_yaml: The classmethod that does build a model. + """ + raise TypeError( + "Run cannot be built from a configuration; it holds the entry points that run a " + "model built elsewhere. Build the model with Catchment.from_yaml(path) and pass " + "it in, e.g. Run.RunHapi(model)." + ) + def RunHapi(self): """Run the distributed hydrological model. @@ -67,14 +158,13 @@ def RunHapi(self): translated at each time step. Raises: - AssertionError: If input data arrays have inconsistent + ValueError: If input data arrays have inconsistent row counts, column counts, or temporal lengths. """ # input dimensions fd_rows, fd_cols = self.flow_network.flow_dir_arr.shape - assert ( - fd_rows == self.flow_network.rows and fd_cols == self.flow_network.cols - ), GRID_MISMATCH_ERROR + if fd_rows != self.flow_network.rows or fd_cols != self.flow_network.cols: + raise ValueError(GRID_MISMATCH_ERROR) # input dimensions # The three cubes already agree with each other (checked when MeteoInputs was @@ -82,13 +172,7 @@ def RunHapi(self): self.meteo.validate_against( self.flow_network.rows, self.flow_network.cols, self.date_index ) - assert np.shape(self.parameters)[0] == self.flow_network.rows, ( - ROWS_MISMATCH_ERROR - ) - assert np.shape(self.parameters)[1] == self.flow_network.cols, ( - COLS_MISMATCH_ERROR - ) - + _check_parameters_cover_grid(self) # run the model Wrapper.RRMModel(self) @@ -102,15 +186,14 @@ def RunFloodModel(self): river width, river roughness, and flood plain roughness). Raises: - AssertionError: If meteorological input arrays, parameter + ValueError: If meteorological input arrays, parameter arrays, or river geometry arrays have inconsistent dimensions. """ # input dimensions [fd_rows, fd_cols] = self.flow_network.flow_dir_arr.shape - assert ( - fd_rows == self.flow_network.rows and fd_cols == self.flow_network.cols - ), GRID_MISMATCH_ERROR + if fd_rows != self.flow_network.rows or fd_cols != self.flow_network.cols: + raise ValueError(GRID_MISMATCH_ERROR) # input dimensions # The three cubes already agree with each other (checked when MeteoInputs was @@ -118,25 +201,27 @@ def RunFloodModel(self): self.meteo.validate_against( self.flow_network.rows, self.flow_network.cols, self.date_index ) - assert np.shape(self.parameters)[0] == self.flow_network.rows, ( - ROWS_MISMATCH_ERROR - ) - assert np.shape(self.parameters)[1] == self.flow_network.cols, ( - COLS_MISMATCH_ERROR - ) - - assert ( - np.shape(self.bankfull_depth)[0] == self.flow_network.rows - and np.shape(self.river_width)[0] == self.flow_network.rows - and np.shape(self.river_roughness)[0] == self.flow_network.rows - and np.shape(self.flood_plain_roughness)[0] == self.flow_network.rows - ), GRID_MISMATCH_ERROR - assert ( - np.shape(self.bankfull_depth)[1] == self.flow_network.cols - and np.shape(self.river_width)[1] == self.flow_network.cols - and np.shape(self.river_roughness)[1] == self.flow_network.cols - and np.shape(self.flood_plain_roughness)[1] == self.flow_network.cols - ), "all input data should have the same number of columns" + _check_parameters_cover_grid(self) + if any( + np.shape(arr)[0] != self.flow_network.rows + for arr in ( + self.bankfull_depth, + self.river_width, + self.river_roughness, + self.flood_plain_roughness, + ) + ): + raise ValueError(GRID_MISMATCH_ERROR) + if any( + np.shape(arr)[1] != self.flow_network.cols + for arr in ( + self.bankfull_depth, + self.river_width, + self.river_roughness, + self.flood_plain_roughness, + ) + ): + raise ValueError("all input data should have the same number of columns") # run the model Wrapper.RRMModel(self) @@ -160,15 +245,16 @@ def runHAPIwithLake(self, lake: LakeType): rain, ET, and temperature. Raises: - AssertionError: If input data arrays have inconsistent + ValueError: If input data arrays have inconsistent dimensions or if the lake meteorological data length does not match the distributed raster data length. """ # input dimensions [fd_rows, fd_cols] = self.flow_network.flow_dir_arr.shape - assert ( - fd_rows == self.flow_network.rows and fd_cols == self.flow_network.cols - ), "all input data should have the same number of rows and columns" + if fd_rows != self.flow_network.rows or fd_cols != self.flow_network.cols: + raise ValueError( + "all input data should have the same number of rows and columns" + ) # input dimensions # The three cubes already agree with each other (checked when MeteoInputs was @@ -176,20 +262,8 @@ def runHAPIwithLake(self, lake: LakeType): self.meteo.validate_against( self.flow_network.rows, self.flow_network.cols, self.date_index ) - assert np.shape(self.parameters)[0] == self.flow_network.rows, ( - ROWS_MISMATCH_ERROR - ) - assert np.shape(self.parameters)[1] == self.flow_network.cols, ( - COLS_MISMATCH_ERROR - ) - - assert np.shape(lake.MeteoData)[0] == self.meteo.time_steps, ( - "Lake meteorological data has to have the same length as the distributed raster data" - ) - assert np.shape(lake.MeteoData)[1] >= 3, ( - "Lake Meteo data has to have at least three columns of rain, ET, and Temp" - ) - + _check_parameters_cover_grid(self) + _check_lake_meteo(self, lake) # run the model Wrapper.RRMWithlake(self, lake) @@ -216,7 +290,7 @@ def runFW1(self): shortcut is invalid for this path and raises. Raises: - AssertionError: If input data arrays have inconsistent + ValueError: If input data arrays have inconsistent row counts, column counts, or temporal lengths. """ # The three cubes already agree with each other (checked when MeteoInputs was @@ -224,13 +298,7 @@ def runFW1(self): self.meteo.validate_against( self.flow_network.rows, self.flow_network.cols, self.date_index ) - assert np.shape(self.parameters)[0] == self.flow_network.rows, ( - ROWS_MISMATCH_ERROR - ) - assert np.shape(self.parameters)[1] == self.flow_network.cols, ( - COLS_MISMATCH_ERROR - ) - + _check_parameters_cover_grid(self) # run the model Wrapper.FW1(self) @@ -267,7 +335,7 @@ def RunFW1withLake(self, lake: LakeType): is tfac and `p2[1]` is catchment area in km2. Raises: - AssertionError: If input data arrays have inconsistent + ValueError: If input data arrays have inconsistent dimensions or if the lake meteorological data length does not match the distributed raster data length. """ @@ -279,19 +347,8 @@ def RunFW1withLake(self, lake: LakeType): self.meteo.validate_against( self.flow_network.rows, self.flow_network.cols, self.date_index ) - assert np.shape(self.parameters)[0] == self.flow_network.rows, ( - ROWS_MISMATCH_ERROR - ) - assert np.shape(self.parameters)[1] == self.flow_network.cols, ( - COLS_MISMATCH_ERROR - ) - - assert np.shape(lake.MeteoData)[0] == self.meteo.time_steps, ( - "Lake meteorological data has to have the same length as the distributed raster data" - ) - assert np.shape(lake.MeteoData)[1] >= 3, ( - "Lake Meteo data has to have at least three columns rain, ET, and Temp" - ) + _check_parameters_cover_grid(self) + _check_lake_meteo(self, lake) # run the model Wrapper.FW1Withlake(self, lake) diff --git a/src/hapi/wrapper.py b/src/hapi/wrapper.py index adf0c375a..ad1aa8b0f 100644 --- a/src/hapi/wrapper.py +++ b/src/hapi/wrapper.py @@ -390,14 +390,16 @@ def Lumped(Model: Catchment, Routing: int = 0, RoutingFn: Callable | None = None discharge hydrograph. Must be callable. Raises: - AssertionError: If `RoutingFn` is not callable when + TypeError: If `RoutingFn` is not callable when routing is enabled. """ ### input data validation if Routing != 0: - assert callable(RoutingFn), ( - "routing function should be of type callable (function that takes arguments)" - ) + if not callable(RoutingFn): + raise TypeError( + "routing function should be of type callable (function that takes " + f"arguments), got {type(RoutingFn).__name__}" + ) # data p = Model.data[:, 0] diff --git a/tests/rrm/calibration/test_calibration_distributed.py b/tests/rrm/calibration/test_calibration_distributed.py index b23eb6642..c4515351c 100644 --- a/tests/rrm/calibration/test_calibration_distributed.py +++ b/tests/rrm/calibration/test_calibration_distributed.py @@ -333,6 +333,54 @@ def test_stores_the_optimizer_result_on_the_instance( ) +class TestCheckOptimizationArgs: + """Tests for `_check_optimization_args`, the guard every entry point runs first.""" + + @pytest.mark.parametrize( + "args, bad_index, bad_kind", + [ + (["not-a-dict", None, {}], 0, "objective-function"), + ([{}, None, "not-a-dict"], 2, "solver"), + ], + ids=["objective-args", "solver-args"], + ) + def test_a_non_dict_bundle_is_refused_before_the_optimizer_is_built( + self, + gauged_calibration: Calibration, + stub_optimizer: dict, + spatial_var_stub, + args, + bad_index, + bad_kind, + ): + """Test that each of the two argument bundles is checked, naming which one. + + Args: + gauged_calibration: A ready-to-run distributed Calibration. + stub_optimizer: Records whether the optimiser was reached. + spatial_var_stub: Stand-in for the spatial parameter function. + args: The `[api_obj_args, pll_type, api_solve_args]` triple, one entry bad. + bad_index: Which position in `args` carries the non-dict value. + bad_kind: The word the error message should use for that position. + + Test scenario: + Both bundles are unpacked with `**` inside Oasis, so anything but a dict fails + there instead of at the call that supplied it -- unless this guard catches it + first, before `Optimization(...)` and the harmony-search engine are built at all. + """ + coello = gauged_calibration + coello.read_objective_function(metrics.rmse, []) + coello.LB = np.zeros(12) + coello.UB = np.ones(12) + + with pytest.raises(TypeError, match=f"{bad_kind} arguments should be a dict"): + coello.run_calibration(spatial_var_stub, args) + + assert "solve_kwargs" not in stub_optimizer, ( + "the optimiser must not be reached when an argument bundle is malformed" + ) + + class TestLumpedCalibration: """Tests for `Calibration.lumpedCalibration`.""" @@ -398,6 +446,50 @@ def test_initial_values_are_seeded_into_the_problem( f"Expected one variable per bound (12), got {stub_optimizer['n_vars']}" ) + def test_a_mismatched_initial_values_length_is_refused( + self, + coello_rrm_date: list, + lumped_meteo_data_path: str, + stub_optimizer: dict, + ): + """Test that `InitialValues` shorter than the bounds is rejected, not indexed out of range. + + Args: + coello_rrm_date: [start, end] dates for the lumped fixture. + lumped_meteo_data_path: CSV of catchment-average drivers. + stub_optimizer: Records whether the optimiser was reached. + + Test scenario: + The seeded branch loops `range(len(self.LB))` and indexes `initial_values[i]`, so + a shorter list used to run past its end partway through building the problem, + leaving `opt_prob` half-populated and raising `IndexError` far from the call that + supplied the list. The length is now compared up front. + """ + coello = Calibration("rrm", coello_rrm_date[0], coello_rrm_date[1]) + coello.read_lumped_inputs(lumped_meteo_data_path) + coello.LB = np.zeros(12) + coello.UB = np.ones(12) + basic_inputs = dict( + Route=0, + RoutingFn=Routing.triangular_routing_1, + InitialValues=[0.5, 0.5, 0.5], + ) + + optimization_args = _optimization_args() + + with pytest.raises(ValueError, match="one value per parameter") as exc: + coello.lumpedCalibration(basic_inputs, optimization_args) + + assert "3" in str(exc.value), ( + f"the error should name the given length: {exc.value}" + ) + assert "12" in str(exc.value), ( + f"the error should name the expected length too: {exc.value}" + ) + assert "n_vars" not in stub_optimizer, ( + "the optimiser must not be reached when the seed does not match the bounds" + ) + def _pairwise_objective(qgauges, gauges_table) -> float: """Score a trial the way `run_calibration` actually calls the objective. diff --git a/tests/rrm/catchment/test_config.py b/tests/rrm/catchment/test_config.py new file mode 100644 index 000000000..1757815d3 --- /dev/null +++ b/tests/rrm/catchment/test_config.py @@ -0,0 +1,2087 @@ +"""Tests for the YAML run-configuration schema and the model it builds. + +`hapi.config` is pure data: pydantic models that validate a parsed YAML mapping. The rules that +matter are the cross-field ones, because which blocks a configuration needs depends on +`catchment.spatial_resolution` and on `meteo.source` -- a distributed run needs a routing +network and all three drivers, a lumped one needs the single averaged-driver CSV. Those rules +exist so `Catchment.from_yaml` can consume a validated config without re-checking anything, so +they are pinned here per rule rather than in aggregate. + +The second half covers `Catchment.from_yaml` itself: that it makes the `read_*` calls in the +order the build-then-mutate pattern requires, that it skips the two optional blocks when they +are absent, and that it builds `cls` so the subclasses return their own type. +""" + +from __future__ import annotations + +import copy +import datetime as dt +import os +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from hapi.calibration import Calibration +from hapi.catchment import Catchment +from hapi.config import ( + CatchmentConfig, + ConceptualModelConfig, + FlowNetworkConfig, + GaugesConfig, + MeteoConfig, + OutputsConfig, + ParametersConfig, + RunConfig, +) +from hapi.inputs import MeteoInputs +from hapi.run import Run + +COMBINED_NC = "tests/rrm/data/coello/meteo.nc" + + +@pytest.fixture +def distributed_mapping( + coello_start_date: str, + coello_end_date: str, + coello_acc_path: str, + coello_fd_path: str, + coello_dist_parameters_muskingum: str, + coello_cat_area: int, + coello_initial_cond: list, + coello_gauges_table: str, + coello_gauges_path: str, +) -> dict: + """A complete distributed configuration, as a plain mapping. + + Returns: + dict: A mapping that validates, for tests to mutate one field at a time. + """ + return { + "catchment": { + "name": "Coello", + "start": coello_start_date, + "end": coello_end_date, + "spatial_resolution": "distributed", + }, + "meteo": { + "source": "netcdf", + "path": COMBINED_NC, + "precipitation": "precipitation", + "temperature": "temperature", + "evapotranspiration": "evapotranspiration", + }, + "conceptual_model": { + "model_class": "HBVBergestrom92", + "catchment_area": coello_cat_area, + "initial_condition": coello_initial_cond, + }, + "parameters": {"path": coello_dist_parameters_muskingum, "snow": False}, + "gauges": {"table": coello_gauges_table, "discharge": coello_gauges_path}, + "flow_network": { + "flow_accumulation": coello_acc_path, + "flow_direction": coello_fd_path, + }, + } + + +@pytest.fixture +def lumped_mapping( + coello_start_date: str, + coello_end_date: str, + lumped_meteo_data_path: str, + lumped_parameters_path: str, + lumped_gauges_path: str, +) -> dict: + """A complete lumped configuration, as a plain mapping. + + Points at the bundled lumped fixtures, so the mapping both validates and builds. + + Returns: + dict: A mapping that validates and can be handed to `from_yaml`. + """ + return { + "catchment": { + "name": "Coello", + "start": coello_start_date, + "end": coello_end_date, + "spatial_resolution": "lumped", + }, + "meteo": {"path": lumped_meteo_data_path}, + "conceptual_model": { + "model_class": "HBVBergestrom92", + "catchment_area": 1530, + "initial_condition": [0, 10, 10, 10, 0], + }, + "parameters": {"path": lumped_parameters_path}, + "gauges": {"discharge": lumped_gauges_path}, + } + + +PATH_FIELDS = ( + ("meteo", "path"), + ("parameters", "path"), + ("gauges", "table"), + ("gauges", "discharge"), + ("flow_network", "flow_accumulation"), + ("flow_network", "flow_direction"), +) + + +def write_yaml(mapping: dict, tmp_path) -> str: + """Dump a mapping to a YAML file, with its input paths made absolute. + + The fixtures name their inputs relative to the repository root, but `from_yaml` resolves a + relative path against the configuration's own directory -- and these configurations are + written to a temporary one. Absolutising here keeps each test about the field it is + exercising; the resolution rule itself is covered by its own test. + + Args: + mapping: The configuration to write. + tmp_path: pytest temporary directory. + + Returns: + str: Path to the written file. + """ + mapping = copy.deepcopy(mapping) + for block, field in PATH_FIELDS: + value = mapping.get(block, {}).get(field) + if isinstance(value, str) and not Path(value).is_absolute(): + mapping[block][field] = str(Path(value).resolve()) + + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump(mapping), encoding="utf-8") + return str(path) + + +class TestCatchmentConfig: + """Tests for the `catchment` block.""" + + def test_defaults_fill_the_optional_fields(self): + """Test that only name and the two dates are required. + + Test scenario: + The remaining fields describe the common case -- a daily lumped run parsed with + ISO dates -- so a caller should not have to restate them. + """ + config = CatchmentConfig(name="Coello", start="2009-01-01", end="2009-01-10") + + assert config.fmt == "%Y-%m-%d", f"unexpected default fmt: {config.fmt}" + assert config.spatial_resolution == "lumped", ( + f"expected 'lumped' by default, got {config.spatial_resolution}" + ) + assert config.temporal_resolution == "daily", ( + f"expected 'daily' by default, got {config.temporal_resolution}" + ) + assert config.routing_method == "muskingum", ( + f"expected 'muskingum' by default, got {config.routing_method}" + ) + + @pytest.mark.parametrize( + "field, value, allowed", + [ + ("spatial_resolution", "semi", "'lumped' or 'distributed'"), + ("temporal_resolution", "weekly", "'daily' or 'hourly'"), + ("routing_method", "kinematic", "'muskingum' or 'maxbas'"), + ], + ids=["spatial", "temporal", "routing"], + ) + def test_an_unknown_value_names_the_accepted_ones(self, field, value, allowed): + """Test that each enumerated field rejects an unknown value and lists the valid set. + + Args: + field: The field under test. + value: An unrecognised value for it. + allowed: The wording the error is expected to carry. + + Test scenario: + These three select whole code paths downstream, so a typo has to fail at parse + time naming what was expected, rather than selecting a branch by accident. + """ + kwargs = { + "name": "Coello", + "start": "2009-01-01", + "end": "2009-01-10", + field: value, + } + + with pytest.raises(ValidationError, match="Input should be") as exc: + CatchmentConfig(**kwargs) + + assert allowed in str(exc.value), ( + f"the error should list the accepted values {allowed}: {exc.value}" + ) + + def test_dates_stay_strings(self): + """Test that a date is kept as text rather than coerced to a date object. + + Test scenario: + `Catchment.__init__` parses the dates itself with `fmt`. If the schema coerced + them, `strptime` would be handed a `date` and raise, which is exactly why the + YAML quotes them. + """ + config = CatchmentConfig(name="Coello", start="2009-01-01", end="2009-01-10") + + assert isinstance(config.start, str), ( + f"start should be str, got {type(config.start)}" + ) + assert config.start == "2009-01-01", f"start was altered: {config.start}" + + @pytest.mark.parametrize( + "value, fmt, expected", + [ + (dt.date(2009, 1, 1), "%Y-%m-%d", "2009-01-01"), + (dt.date(2009, 1, 1), "%d/%m/%Y", "01/01/2009"), + (dt.datetime(2009, 1, 1, 6, 0), "%Y-%m-%d", "2009-01-01"), + ], + ids=["date", "custom-fmt", "timestamp"], + ) + def test_a_date_yaml_already_parsed_is_written_back_in_fmt( + self, value, fmt, expected + ): + """Test that an unquoted YAML date is accepted and rendered in the block's format. + + Args: + value: What YAML hands pydantic for an unquoted date or timestamp. + fmt: The block's date format. + expected: The string the field should end up holding. + + Test scenario: + `start: 2009-01-01` written without quotes is a `datetime.date` by the time the + schema sees it, and the field is a string because the constructor parses it with + `fmt`. Rejecting it would reject the spelling a YAML author writes first, over a + difference that carries no information. + """ + config = CatchmentConfig(name="Coello", start=value, end=value, fmt=fmt) + + assert config.start == expected, f"expected {expected!r}, got {config.start!r}" + assert isinstance(config.start, str), ( + f"the field must still hold a string, got {type(config.start)}" + ) + + def test_a_quoted_date_is_left_exactly_as_written(self): + """Test that the normaliser does not touch a date that is already text. + + Test scenario: + A string may be in any format the author declares, including ones no `date` + round-trips through, so it has to pass through untouched. + """ + config = CatchmentConfig( + name="Coello", start="01/01/2009", end="31/12/2011", fmt="%d/%m/%Y" + ) + + assert config.start == "01/01/2009", f"start was rewritten: {config.start}" + + @pytest.mark.parametrize( + "values, why", + [ + ("not-a-mapping", "a scalar block"), + ( + { + "name": "Coello", + "start": "2009-01-01", + "end": "2009-01-10", + "fmt": 5, + }, + "a non-string fmt", + ), + ], + ids=["scalar", "non-string-fmt"], + ) + def test_the_normaliser_defers_to_pydantic_for_what_it_cannot_render( + self, values, why + ): + """Test that unrenderable input is passed through for pydantic to report. + + Args: + values: Raw input the normaliser cannot act on. + why: What is wrong with it, for the failure message. + + Test scenario: + The normaliser runs before validation, so it sees raw input. Raising its own + error for a malformed block would replace pydantic's precise, field-located + message with a vaguer one. + """ + with pytest.raises(ValidationError): + CatchmentConfig.model_validate(values) + + +class TestMeteoConfig: + """Tests for the `meteo` block.""" + + def test_raster_defaults_match_the_reader(self): + """Test that the raster-reading defaults are the ones `from_rasters` uses. + + Test scenario: + A configuration that names three folders and nothing else must read them the way + the loader would by default, or the YAML would silently change the date parsing. + """ + config = MeteoConfig() + + assert config.source == "rasters", f"expected 'rasters', got {config.source}" + assert config.glob == "*.tif", f"unexpected glob default: {config.glob}" + assert config.regex_string == r"\d{4}.\d{2}.\d{2}", ( + f"unexpected regex default: {config.regex_string}" + ) + assert config.file_name_data_fmt is None, ( + "the date format should be inferred by default" + ) + + def test_an_unknown_source_is_refused(self): + """Test that `source` accepts only the three loaders that exist. + + Test scenario: + `source` selects which `MeteoInputs` constructor runs, so an unknown value has no + loader to dispatch to and must fail here rather than fall through. + """ + with pytest.raises(ValidationError, match="Input should be") as exc: + MeteoConfig(source="zarr") + + assert "'rasters', 'netcdf' or 'netcdf_files'" in str(exc.value), ( + f"the error should name the three loaders: {exc.value}" + ) + + +class TestConceptualModelConfig: + """Tests for the `conceptual_model` block.""" + + def test_initial_condition_must_hold_five_states(self): + """Test that the state vector is required to be exactly five long. + + Test scenario: + HBV indexes `[sp, sm, uz, lz, wc]` by position, so a shorter vector does not + raise where it is built -- it reads the wrong state, or runs off the end deep in + the per-cell loop. + """ + with pytest.raises(ValidationError, match="at least 5 items") as exc: + ConceptualModelConfig( + model_class="HBVBergestrom92", + catchment_area=1530, + initial_condition=[0, 5, 5, 5], + ) + + assert "initial_condition" in str(exc.value), ( + f"the error should name the field: {exc.value}" + ) + + def test_catchment_area_must_be_positive(self): + """Test that a non-positive catchment area is refused. + + Test scenario: + The area scales depth to discharge, so zero or a negative value produces a + hydrograph that is silently zero or sign-flipped rather than an error. + """ + with pytest.raises(ValidationError, match="greater than 0"): + ConceptualModelConfig( + model_class="HBVBergestrom92", + catchment_area=-5, + initial_condition=[0, 5, 5, 5, 0], + ) + + def test_model_class_keeps_its_name(self): + """Test that the `model_class` field survives pydantic's protected namespace. + + Test scenario: + Pydantic reserves the `model_` prefix. The YAML key is `model_class`, so the + namespace is cleared rather than the field renamed -- this pins that it stayed. + """ + config = ConceptualModelConfig( + model_class="HBVBergestrom92", + catchment_area=1530, + initial_condition=[0, 5, 5, 5, 0], + ) + + assert config.model_class == "HBVBergestrom92", ( + f"model_class did not round-trip: {config.model_class}" + ) + assert config.q_init is None, "q_init should default to None" + + +class TestStrictKeys: + """Tests for the `extra=forbid` setting shared by every block.""" + + @pytest.mark.parametrize( + "model, kwargs", + [ + ( + CatchmentConfig, + {"name": "c", "start": "2009-01-01", "end": "2009-01-10"}, + ), + (MeteoConfig, {}), + (FlowNetworkConfig, {"flow_accumulation": "acc.tif"}), + (ParametersConfig, {"path": "p"}), + (GaugesConfig, {"discharge": "q.csv"}), + (OutputsConfig, {}), + ], + ids=["catchment", "meteo", "flow_network", "parameters", "gauges", "outputs"], + ) + def test_an_unknown_key_is_refused(self, model, kwargs): + """Test that every block rejects a key it does not define. + + Args: + model: The block under test. + kwargs: The minimum valid arguments for it. + + Test scenario: + A misspelled key would otherwise be dropped silently, and the input it was meant + to supply would go missing far from the typo that caused it. + """ + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + model(**{**kwargs, "definitely_not_a_field": 1}) + + +class TestRunConfigCrossFieldRules: + """Tests for the rules tying the blocks to `catchment.spatial_resolution`.""" + + def test_a_complete_distributed_configuration_validates(self, distributed_mapping): + """Test that the fixture itself is accepted, so the negative cases mean something. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + Every rejection test below mutates one field of this mapping, so it has to be + valid to begin with or those tests would pass for the wrong reason. + """ + config = RunConfig.model_validate(distributed_mapping) + + assert config.catchment.spatial_resolution == "distributed" + assert config.flow_network is not None, "the flow network should be parsed" + assert config.gauges is not None, "the gauges block should be parsed" + + def test_a_complete_lumped_configuration_validates(self, lumped_mapping): + """Test that a lumped configuration with no grid blocks is accepted. + + Args: + lumped_mapping: A complete lumped configuration. + + Test scenario: + The lumped shape is the other half of the schema: one CSV of averaged drivers, + no flow network, and no gauge table. + """ + config = RunConfig.model_validate(lumped_mapping) + + assert config.flow_network is None, "a lumped run carries no flow network" + assert config.meteo.path == lumped_mapping["meteo"]["path"], ( + f"the lumped meteo CSV was not kept: {config.meteo.path}" + ) + assert config.gauges.table is None, "a lumped run needs no gauge table" + + def test_distributed_requires_a_flow_network(self, distributed_mapping): + """Test that a distributed run without a routing network is refused. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + The network supplies the grid every cube is checked against, so without it the + run fails later on a shape mismatch that says nothing about the missing block. + """ + del distributed_mapping["flow_network"] + + with pytest.raises(ValidationError, match="needs a flow_network block") as exc: + RunConfig.model_validate(distributed_mapping) + + assert "distributed" in str(exc.value), ( + f"the error should name the resolution that requires it: {exc.value}" + ) + + def test_muskingum_requires_a_flow_direction_raster(self, distributed_mapping): + """Test that a Muskingum run without a direction raster is refused at parse time. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + `flow_direction` is optional on the block because MAXBAS never reads one. Muskingum + does, and it is the default routing method -- so a config copied from the MAXBAS + example builds fine and then dereferences a None array inside the routing loop, + after every raster has been read. The rule has to be stated here instead. + """ + del distributed_mapping["flow_network"]["flow_direction"] + + with pytest.raises( + ValidationError, match="flow_network.flow_direction is required" + ): + RunConfig.model_validate(distributed_mapping) + + def test_maxbas_does_not_require_a_flow_direction_raster(self, distributed_mapping): + """Test that the triangular path still accepts a network without a direction raster. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + The counterpart to the rule above: MAXBAS routes every cell straight to the outlet, + so requiring the raster there would reject the configuration the shipped MAXBAS + example uses. + """ + distributed_mapping["catchment"]["routing_method"] = "maxbas" + distributed_mapping["parameters"]["maxbas"] = True + del distributed_mapping["flow_network"]["flow_direction"] + + config = RunConfig.model_validate(distributed_mapping) + + assert config.flow_network.flow_direction is None, ( + "MAXBAS should be allowed to omit the direction raster" + ) + + @pytest.mark.parametrize( + "routing_method, maxbas", + [("maxbas", False), ("muskingum", True)], + ids=["maxbas-run-muskingum-set", "muskingum-run-maxbas-set"], + ) + def test_the_routing_method_and_the_parameter_set_must_agree( + self, distributed_mapping, routing_method, maxbas + ): + """Test that a routing method mismatched to its parameter set is refused. + + Args: + distributed_mapping: A complete distributed configuration. + routing_method: The routing method declared on the catchment. + maxbas: The flag declared on the parameter set, deliberately disagreeing. + + Test scenario: + The parameter-count check cannot catch this: a MAXBAS set holds 11 parameters and + a Muskingum set 12, and `parameters.maxbas` selects which count is expected, so a + disagreeing pair counts correctly. The run then completes and reads the Muskingum + X as the MAXBAS value -- a hydrograph that is quietly wrong, the worst failure + mode for a modelling tool. + """ + distributed_mapping["catchment"]["routing_method"] = routing_method + distributed_mapping["parameters"]["maxbas"] = maxbas + if routing_method == "maxbas": + del distributed_mapping["flow_network"]["flow_direction"] + + with pytest.raises(ValidationError, match="must agree"): + RunConfig.model_validate(distributed_mapping) + + def test_a_configuration_without_parameters_skips_the_routing_cross_check( + self, distributed_mapping + ): + """Test that the cross-check does not fire when no parameter set is configured. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + A calibration declares no `parameters` block -- its parameters come from the + bounds -- so there is nothing to disagree with the routing method, and requiring + agreement would reject every calibration configuration. + """ + del distributed_mapping["parameters"] + + config = RunConfig.model_validate(distributed_mapping) + + assert config.parameters is None + + def test_distributed_requires_a_gauge_table_when_gauges_are_given( + self, distributed_mapping + ): + """Test that a distributed `gauges` block without a table is refused. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + The table is what locates each gauge on the grid. Supplying discharge without it + is a half-configured block, distinct from omitting gauges altogether. + """ + del distributed_mapping["gauges"]["table"] + + with pytest.raises(ValidationError, match="needs gauges.table"): + RunConfig.model_validate(distributed_mapping) + + @pytest.mark.parametrize( + "driver", ["precipitation", "temperature", "evapotranspiration"] + ) + def test_distributed_requires_all_three_drivers(self, distributed_mapping, driver): + """Test that each missing driver is reported by name. + + Args: + distributed_mapping: A complete distributed configuration. + driver: The driver removed from the meteo block. + + Test scenario: + The conceptual model reads all three every step, so a configuration missing one + cannot run -- and the error has to say which, since the three are interchangeable + in shape. + """ + del distributed_mapping["meteo"][driver] + + with pytest.raises( + ValidationError, match="all three meteorological drivers" + ) as exc: + RunConfig.model_validate(distributed_mapping) + + assert driver in str(exc.value), ( + f"the error should name the missing driver {driver}: {exc.value}" + ) + + def test_netcdf_source_requires_a_path(self, distributed_mapping): + """Test that `source: netcdf` without `meteo.path` is refused. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + For this source the three driver fields are variable *names* inside one file, so + without the file there is nothing to read them from. + """ + del distributed_mapping["meteo"]["path"] + + with pytest.raises(ValidationError, match="meteo.path must be set"): + RunConfig.model_validate(distributed_mapping) + + def test_lumped_requires_the_meteo_csv(self, lumped_mapping): + """Test that a lumped run without `meteo.path` is refused. + + Args: + lumped_mapping: A complete lumped configuration. + + Test scenario: + Lumped mode has no grid to fall back on: `read_lumped_inputs` needs the single + CSV of catchment-average drivers, and nothing else supplies them. + """ + del lumped_mapping["meteo"]["path"] + + with pytest.raises(ValidationError, match="needs meteo.path"): + RunConfig.model_validate(lumped_mapping) + + @pytest.mark.parametrize( + "block, field", + [("catchment", "start"), ("catchment", "end"), ("meteo", "start")], + ) + def test_a_date_that_does_not_match_its_format_is_refused( + self, distributed_mapping, block, field + ): + """Test that every date is checked against the format it is written in. + + Args: + distributed_mapping: A complete distributed configuration. + block: The block carrying the date. + field: The date field to corrupt. + + Test scenario: + The module promises a validated config is consumable without re-checking, but an + unparseable date slipped through to fail later inside `Catchment.__init__` -- or, + for the meteo bounds, deep in the loader. + """ + distributed_mapping[block][field] = "not-a-date" + + with pytest.raises(ValidationError, match="does not match its format") as exc: + RunConfig.model_validate(distributed_mapping) + + assert f"{block}.{field}" in str(exc.value), ( + f"the error should name the offending field: {exc.value}" + ) + + def test_a_period_that_ends_before_it_starts_is_refused(self, distributed_mapping): + """Test that a reversed period is caught at parse time. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + Nothing checked the order, so a reversed period produced an empty date index and + failed far downstream on a shape mismatch that said nothing about the dates. + """ + distributed_mapping["catchment"]["start"] = "2009-01-10" + distributed_mapping["catchment"]["end"] = "2009-01-01" + + with pytest.raises(ValidationError, match="is after"): + RunConfig.model_validate(distributed_mapping) + + def test_a_lumped_configuration_may_not_carry_a_flow_network(self, lumped_mapping): + """Test that a grid block on a lumped run is refused rather than ignored. + + Args: + lumped_mapping: A complete lumped configuration. + + Test scenario: + `extra="forbid"` exists so a misspelled key fails loudly rather than being + dropped. Silently discarding a correctly spelled but inapplicable block is the + same silence by another route -- the user's block simply never took effect. + """ + lumped_mapping["flow_network"] = {"flow_accumulation": "acc.tif"} + + with pytest.raises(ValidationError, match="has no grid"): + RunConfig.model_validate(lumped_mapping) + + def test_a_lumped_configuration_may_not_name_a_grid_meteo_source( + self, lumped_mapping + ): + """Test that a grid loader on a lumped run is refused. + + Args: + lumped_mapping: A complete lumped configuration. + + Test scenario: + Lumped mode reads `meteo.path` with `pd.read_csv` regardless of `source`, so a + `source: netcdf` config would hand a `.nc` file to the CSV reader. + """ + lumped_mapping["meteo"]["source"] = "netcdf" + + with pytest.raises(ValidationError, match="does not apply"): + RunConfig.model_validate(lumped_mapping) + + def test_parameters_and_gauges_may_both_be_omitted(self, distributed_mapping): + """Test that a configuration carrying neither optional block still validates. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + A calibration derives its parameters from the bounds it is given rather than + reading a fitted set, and a run that is not scored against observations has no + gauges. Both blocks are therefore optional. + """ + del distributed_mapping["parameters"] + del distributed_mapping["gauges"] + + config = RunConfig.model_validate(distributed_mapping) + + assert config.parameters is None, "parameters should be absent, not defaulted" + assert config.gauges is None, "gauges should be absent, not defaulted" + + @pytest.mark.parametrize( + "block, field, value", + [ + ("gauges", "table", "gauges.csv"), + ("gauges", "column", "name"), + ("meteo", "precipitation", "prec"), + ("meteo", "start", "2009-01-01"), + ("meteo", "end", "2011-12-31"), + ("meteo", "glob", "*.tif"), + ], + ids=["table", "column", "driver", "window-start", "window-end", "glob"], + ) + def test_a_lumped_configuration_refuses_a_field_it_would_not_read( + self, lumped_mapping, block, field, value + ): + """Test that each field a lumped run never reads is named and refused. + + Args: + lumped_mapping: A complete lumped configuration. + block: Which block the field lives in. + field: The inapplicable field. + value: A plausible value for it. + + Test scenario: + A lumped run reads one CSV of catchment-average drivers and locates no gauges, + so a grid driver, a reader knob, a window or a gauge table is a line with no + effect. Dropping it silently is the failure `extra="forbid"` exists to prevent, + arrived at by another route. + """ + lumped_mapping[block][field] = value + + with pytest.raises(ValidationError, match="read by nothing") as exc: + RunConfig.model_validate(lumped_mapping) + + assert f"{block}.{field}" in str(exc.value), ( + f"the error should name the offending field: {exc.value}" + ) + + @pytest.mark.parametrize( + "source, patch, refused", + [ + ("netcdf", {"path": "m.nc", "glob": "*.tif"}, "meteo.glob"), + ( + "netcdf", + {"path": "m.nc", "per_variable": {"p": {}}}, + "meteo.per_variable", + ), + ("netcdf", {"path": "m.nc", "variable": "pre"}, "meteo.variable"), + ("netcdf_files", {"path": "m.nc"}, "meteo.path"), + ("rasters", {"path": "m.nc"}, "meteo.path"), + ("rasters", {"variable": "pre"}, "meteo.variable"), + ], + ids=[ + "netcdf-glob", + "netcdf-per-variable", + "netcdf-variable", + "netcdf-files-path", + "rasters-path", + "rasters-variable", + ], + ) + def test_a_source_refuses_the_fields_its_own_branch_never_reads( + self, distributed_mapping, source, patch, refused + ): + """Test that each `meteo.source` refuses the knobs belonging to the other two. + + Args: + distributed_mapping: A complete distributed configuration. + source: The source under test. + patch: Fields to set on the `meteo` block, including the inapplicable one. + refused: The field the error must name. + + Test scenario: + The three branches of `MeteoInputs.from_config` read different fields: the + raster reader takes `glob` and `per_variable` and no `path`, `netcdf` takes a + `path` and no reader knobs, `netcdf_files` takes a `variable`. Setting one + outside its source's set says something the run will not do. + """ + distributed_mapping["meteo"]["source"] = source + distributed_mapping["meteo"].update(patch) + + with pytest.raises(ValidationError, match="read by nothing") as exc: + RunConfig.model_validate(distributed_mapping) + + assert refused in str(exc.value), ( + f"the error should name {refused}: {exc.value}" + ) + + def test_a_default_the_author_never_wrote_is_not_refused(self, lumped_mapping): + """Test that only explicitly written fields count as inapplicable. + + Args: + lumped_mapping: A complete lumped configuration. + + Test scenario: + Every refused field has a default, so testing the value rather than whether it + was set would reject every lumped configuration ever written -- `meteo.glob` + alone defaults to `"*.tif"`. The check reads `model_fields_set`. + """ + config = RunConfig.model_validate(lumped_mapping) + + assert config.meteo.glob == "*.tif", ( + f"the default should still be there, got {config.meteo.glob}" + ) + + @pytest.mark.parametrize( + "maxbas, expected", + [(True, "maxbas"), (False, "muskingum")], + ids=["maxbas-set", "muskingum-set"], + ) + def test_an_unstated_routing_method_is_derived_from_the_parameter_set( + self, lumped_mapping, maxbas, expected + ): + """Test that `routing_method` follows `parameters.maxbas` when it is not written. + + Args: + lumped_mapping: A complete lumped configuration. + maxbas: What the parameter set carries. + expected: The routing method that should be derived from it. + + Test scenario: + The two describe the same choice from opposite sides. Left at its `muskingum` + default, a MAXBAS run carried a `routing_method` contradicting what it does -- + and that attribute is what `distrrm.SpatialRouting` keys off. + """ + lumped_mapping["parameters"]["maxbas"] = maxbas + assert "routing_method" not in lumped_mapping["catchment"], ( + "this test is about the unstated case" + ) + + config = RunConfig.model_validate(lumped_mapping) + + assert config.catchment.routing_method == expected, ( + f"expected {expected!r} derived from maxbas={maxbas}, got " + f"{config.catchment.routing_method!r}" + ) + + def test_a_lumped_routing_method_must_still_agree_with_the_parameter_set( + self, lumped_mapping + ): + """Test that the agreement check is no longer scoped to distributed runs. + + Args: + lumped_mapping: A complete lumped configuration. + + Test scenario: + The parameter-count check cannot catch this: the two counts differ by one and + `maxbas` is what selects which is expected, so a contradicting pair still counts + correctly and then reads the wrong parameter as the routing one. + """ + lumped_mapping["catchment"]["routing_method"] = "muskingum" + lumped_mapping["parameters"]["maxbas"] = True + + with pytest.raises(ValidationError, match="must agree"): + RunConfig.model_validate(lumped_mapping) + + def test_the_derivation_runs_before_the_flow_direction_check( + self, distributed_mapping + ): + """Test that a derived MAXBAS run may omit the flow-direction raster. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + MAXBAS sends every cell straight to the outlet and never reads a flow direction, + but that requirement is keyed off `routing_method` -- so if the block checks ran + before the derivation, the still-defaulted `muskingum` would demand a raster the + run has no use for. + """ + distributed_mapping["parameters"]["maxbas"] = True + del distributed_mapping["flow_network"]["flow_direction"] + distributed_mapping["catchment"].pop("routing_method", None) + + config = RunConfig.model_validate(distributed_mapping) + + assert config.catchment.routing_method == "maxbas", ( + f"expected the derived method, got {config.catchment.routing_method!r}" + ) + + @pytest.mark.parametrize( + "window", + [ + {"start": "2012-06-01"}, + {"end": "2008-01-01"}, + {"start": "2010-01-01", "end": "2009-01-01"}, + ], + ids=[ + "start-after-catchment-end", + "end-before-catchment-start", + "both-inverted", + ], + ) + def test_the_resolved_meteorological_window_must_run_forwards( + self, distributed_mapping, window + ): + """Test that the window the run will use is checked, not the two literal pairs. + + Args: + distributed_mapping: A complete distributed configuration. + window: A `meteo` window that inverts the effective period. + + Test scenario: + `MeteoInputs.from_config` takes each bound from `meteo` when stated and falls + back to `catchment` otherwise, so a block stating only one half can invert the + effective window while neither pair is inverted on its own. + """ + distributed_mapping["meteo"].update(window) + + with pytest.raises(ValidationError, match="ends before it starts"): + RunConfig.model_validate(distributed_mapping) + + def test_a_meteo_window_inside_the_catchment_period_is_accepted( + self, distributed_mapping + ): + """Test that a genuine sub-period is not caught by the window check. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + Narrowing the drivers to part of the catchment's period is the reason the two + `meteo` bounds exist, so the stricter check must not cost that. + """ + distributed_mapping["meteo"]["start"] = "2009-01-02" + distributed_mapping["meteo"]["end"] = "2009-01-05" + + config = RunConfig.model_validate(distributed_mapping) + + assert config.meteo.start == "2009-01-02", ( + f"the stated window should survive: {config.meteo}" + ) + + def test_the_gauge_table_format_falls_back_to_the_discharge_format( + self, distributed_mapping + ): + """Test that `table_fmt` defaults to `fmt` rather than to a format of its own. + + Args: + distributed_mapping: A complete distributed configuration. + + Test scenario: + The two parse different files -- the table's validity-period columns and the + discharge CSV index -- but one hand usually writes both, so the common case + should stay a single field. + """ + distributed_mapping["gauges"]["fmt"] = "%d/%m/%Y" + + config = RunConfig.model_validate(distributed_mapping) + + assert config.gauges.table_fmt is None, ( + f"table_fmt should stay unset so the builder can fall back: " + f"{config.gauges.table_fmt}" + ) + assert config.gauges.fmt == "%d/%m/%Y", "the discharge format should be kept" + + def test_a_lumped_configuration_may_omit_gauges(self, lumped_mapping): + """Test that a lumped run with no gauges block validates. + + Args: + lumped_mapping: A complete lumped configuration. + + Test scenario: + The inapplicable-field check for `gauges` only runs when the block is present, so + a lumped run that is not scored against observations has to pass through it + untouched rather than tripping on a block that is not there. + """ + del lumped_mapping["gauges"] + + config = RunConfig.model_validate(lumped_mapping) + + assert config.gauges is None, "gauges should be absent, not defaulted" + + +class TestMeteoInputsFromConfig: + """Tests for the `meteo.source` dispatch in `MeteoInputs.from_config`.""" + + def test_the_raster_source_reads_the_three_folders( + self, + coello_prec_path: str, + coello_temp_path: str, + coello_evap_path: str, + coello_start_date: str, + coello_end_date: str, + ): + """Test that `source: rasters` reads the folders and dates them. + + Args: + coello_prec_path: Rainfall raster folder. + coello_temp_path: Temperature raster folder. + coello_evap_path: Evapotranspiration raster folder. + coello_start_date: Simulation start date. + coello_end_date: Simulation end date. + + Test scenario: + This is the branch the shipped MAXBAS example depends on, and the one with the + most argument plumbing -- seven forwarded keywords plus two conditional ones -- + so a mis-forwarded argument would be invisible until a user's file names stopped + parsing. + """ + meteo = MeteoInputs.from_config( + MeteoConfig( + source="rasters", + precipitation=coello_prec_path, + temperature=coello_temp_path, + evapotranspiration=coello_evap_path, + file_name_data_fmt="%Y.%m.%d", + ), + start=coello_start_date, + end=coello_end_date, + ) + + assert meteo.shape == (13, 14, 10), ( + f"unexpected grid or step count: {meteo.shape}" + ) + assert meteo.time is not None, "the calendar should come from the file names" + assert meteo.time[0].strftime("%Y-%m-%d") == coello_start_date + + def test_the_raster_source_forwards_the_reader_arguments( + self, + coello_prec_path: str, + coello_temp_path: str, + coello_evap_path: str, + ): + """Test that `glob`, `per_variable` and `gdal_env` reach the loader. + + Args: + coello_prec_path: Rainfall raster folder. + coello_temp_path: Temperature raster folder. + coello_evap_path: Evapotranspiration raster folder. + + Test scenario: + The conditional pass-throughs are the easiest to drop silently. A `glob` that + matches nothing must surface as a read error, which proves it was forwarded + rather than ignored. + """ + config = MeteoConfig( + source="rasters", + precipitation=coello_prec_path, + temperature=coello_temp_path, + evapotranspiration=coello_evap_path, + file_name_data_fmt="%Y.%m.%d", + glob="*.nothing", + gdal_env={"GDAL_PAM_ENABLED": "NO"}, + per_variable={"temperature": {"glob": "*.tif"}}, + ) + + with pytest.raises((FileNotFoundError, ValueError)): + MeteoInputs.from_config(config) + + def test_the_netcdf_files_source_reads_one_file_per_driver( + self, coello_start_date: str, coello_end_date: str + ): + """Test that `source: netcdf_files` reads a separate file for each driver. + + Args: + coello_start_date: Simulation start date. + coello_end_date: Simulation end date. + + Test scenario: + The third branch, and the only one whose driver fields are per-driver *paths* + rather than folders or variable names -- so a branch that confused them would + still find files but read the wrong ones. + """ + meteo = MeteoInputs.from_config( + MeteoConfig( + source="netcdf_files", + precipitation="tests/rrm/data/coello/prec.nc", + temperature="tests/rrm/data/coello/temp.nc", + evapotranspiration="tests/rrm/data/coello/evap.nc", + ), + start=coello_start_date, + end=coello_end_date, + ) + + assert meteo.shape == (13, 14, 10), ( + f"unexpected grid or step count: {meteo.shape}" + ) + + def test_a_config_missing_a_driver_is_refused(self): + """Test that the defensive guard fires for a hand-built config. + + Test scenario: + `RunConfig` rejects such a configuration, so this only fires for a `MeteoConfig` + built directly -- which is exactly when the message naming the missing drivers is + the only thing the caller has to go on. + """ + config = MeteoConfig(source="rasters", precipitation="p") + + with pytest.raises(ValueError, match="all three meteorological drivers") as exc: + MeteoInputs.from_config(config) + + assert "temperature" in str(exc.value), ( + f"the error should name what is unset: {exc.value}" + ) + + def test_the_netcdf_source_needs_a_path(self): + """Test that `source: netcdf` without a path is refused. + + Test scenario: + For this source the driver fields are variable names inside one file, so without + the file there is nothing to read them from. + """ + config = MeteoConfig( + source="netcdf", + precipitation="precipitation", + temperature="temperature", + evapotranspiration="evapotranspiration", + ) + + with pytest.raises(ValueError, match="meteo.path must be set"): + MeteoInputs.from_config(config) + + def test_the_netcdf_source_reads_the_three_variables_from_one_file( + self, coello_start_date: str, coello_end_date: str + ): + """Test that `source="netcdf"` builds a grid from one file's three variables. + + Args: + coello_start_date: Simulation start date. + coello_end_date: Simulation end date. + + Test scenario: + The other two sources have direct success tests; this branch was covered only + transitively through `from_yaml`, so a change to how `meteo.path` or the variable + names are forwarded would have surfaced only there. + """ + config = MeteoConfig( + source="netcdf", + path=COMBINED_NC, + precipitation="precipitation", + temperature="temperature", + evapotranspiration="evapotranspiration", + ) + + meteo = MeteoInputs.from_config( + config, start=coello_start_date, end="2009-01-10" + ) + + assert meteo.precipitation.shape == meteo.temperature.shape, ( + f"the three cubes must agree: {meteo.shape}" + ) + assert meteo.time_steps == 10, ( + f"the window should hold ten steps, got {meteo.time_steps}" + ) + + +class TestRoutingMethodNormalisation: + """Tests for the `routing_method` canonicalisation in `Catchment.__init__`.""" + + @pytest.mark.parametrize( + "given, stored", + [ + ("muskingum", "Muskingum"), + ("Muskingum", "Muskingum"), + ("MUSKINGUM", "Muskingum"), + ("maxbas", "MAXBAS"), + ("MAXBAS", "MAXBAS"), + ("kinematic", "Kinematic"), + ], + ) + def test_any_casing_is_stored_canonically(self, given, stored): + """Test that the constructor stores one spelling whatever casing it is handed. + + Args: + given: The spelling passed to the constructor. + stored: The canonical spelling expected on the model. + + Test scenario: + `distrrm.SpatialRouting` compares `routing_method != "Muskingum"` exactly, and its + false branch reads `bankfull_depth`, which is None outside the flood model. A + lower-case "muskingum" stored verbatim therefore routed every cell down the MAXBAS + branch and raised `TypeError: 'NoneType' object is not subscriptable`. + """ + model = Catchment("coello", "2009-01-01", "2009-01-10", routing_method=given) + + assert model.routing_method == stored, ( + f"{given!r} should be stored as {stored!r}, got {model.routing_method!r}" + ) + + def test_kinematic_is_accepted_for_the_flood_model(self): + """Test that the flood model's routing method is still a legal value. + + Test scenario: + `Run.RunFloodModel` relies on the same `!= "Muskingum"` comparison to skip cells + with a real `bankfull_depth`, so "Kinematic" is a working value and must not be + rejected by the new validation. + """ + model = Catchment( + "coello", "2009-01-01", "2009-01-10", routing_method="Kinematic" + ) + + assert model.routing_method == "Kinematic" + + def test_an_unknown_spatial_resolution_is_refused(self): + """Test that the sibling enumerated argument is validated the same way. + + Test scenario: + `spatial_resolution` selects which half of the build runs, so an unrecognised + value has no branch to take and must fail at construction rather than silently + choosing the lumped one. + """ + with pytest.raises(ValueError, match="'lumped' and 'distributed'"): + Catchment("coello", "2009-01-01", "2009-01-10", spatial_resolution="semi") + + def test_an_unknown_routing_method_is_refused(self): + """Test that an unrecognised routing method fails at construction. + + Test scenario: + Any unknown spelling silently selects the non-Muskingum branch downstream, so it + has to be caught here rather than surface as a `TypeError` on `bankfull_depth`. + """ + with pytest.raises(ValueError, match="available routing methods") as exc: + Catchment("coello", "2009-01-01", "2009-01-10", routing_method="diffusive") + + assert "diffusive" in str(exc.value), ( + f"the error should echo the value given: {exc.value}" + ) + + @pytest.mark.parametrize( + "argument", + ["spatial_resolution", "temporal_resolution", "routing_method"], + ) + def test_a_mode_argument_that_is_not_a_string_names_itself(self, argument): + """Test that each mode argument reports its own name when handed a non-string. + + Args: + argument: The constructor argument under test. + + Test scenario: + All three are lower-cased, so a non-string used to reach `.lower()` and raise an + `AttributeError` naming neither the argument nor the class. `Calibration` made + that reachable through its own signature, which declared all three `str | None`. + """ + with pytest.raises(TypeError, match=f"{argument} must be a string") as exc: + Catchment("Coello", "2009-01-01", "2009-01-10", **{argument: None}) + + assert "NoneType" in str(exc.value), ( + f"the error should name what it got: {exc.value}" + ) + + def test_calibration_accepts_the_same_three_arguments(self): + """Test that the guard reaches `Calibration`, which passes the arguments down. + + Test scenario: + `Calibration.__init__` forwards all three to `Catchment.__init__` unchanged, and + its annotations used to advertise a `None` that crashed. + """ + with pytest.raises(TypeError, match="routing_method must be a string"): + Calibration("Coello", "2009-01-01", "2009-01-10", routing_method=None) + + +class TestCatchmentFromYaml: + """Tests for `Catchment.from_yaml`, which turns a configuration into a built model.""" + + def test_a_distributed_configuration_populates_every_input( + self, distributed_mapping, tmp_path, coello_cat_area, coello_initial_cond + ): + """Test that the builder makes each `read_*` call the configuration asks for. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + coello_cat_area: Expected catchment area. + coello_initial_cond: Expected initial state. + + Test scenario: + This is the whole point of the alternate constructor: the attributes a + hand-written script assembled by calling the readers in order must all be + populated by one call. + """ + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.name == "Coello", f"name not set: {model.name}" + assert model.spatial_resolution == "distributed" + assert model.meteo is not None, "meteo was not assigned" + assert model.flow_network is not None, "flow_network was not assigned" + assert model.parameters is not None, "parameters were not read" + assert model.lumped_model is not None, "the conceptual model was not read" + assert model.GaugesTable is not None, "the gauge table was not read" + assert model.QGauges is not None, "the discharge was not read" + assert model.area == coello_cat_area, f"area not set: {model.area}" + assert model.initial_cond == coello_initial_cond, ( + f"initial condition not set: {model.initial_cond}" + ) + + def test_the_drivers_cover_the_model_period(self, distributed_mapping, tmp_path): + """Test that the meteo window defaults to the catchment's own dates. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The drivers pair with the model's date index by position, so a file spanning a + longer record has to be trimmed to the run. Omitting `meteo.start` / `meteo.end` + should fall back to the catchment block rather than read the file whole. + """ + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.meteo.time_steps == len(model.date_index), ( + f"drivers hold {model.meteo.time_steps} steps, model spans " + f"{len(model.date_index)}" + ) + assert model.meteo.time[0] == model.date_index[0], ( + f"drivers start at {model.meteo.time[0]}, model at {model.date_index[0]}" + ) + + def test_an_explicit_meteo_window_overrides_the_catchment_dates( + self, distributed_mapping, tmp_path + ): + """Test that `meteo.start` / `meteo.end` win when given. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The fallback is a convenience, not a rule: a caller who states the window + explicitly is asking for that slice of the record. + """ + distributed_mapping["meteo"]["start"] = "2009-01-03" + distributed_mapping["meteo"]["end"] = "2009-01-07" + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.meteo.time_steps == 5, ( + f"03 to 07 inclusive is five steps, got {model.meteo.time_steps}" + ) + + def test_omitting_parameters_leaves_them_unread( + self, distributed_mapping, tmp_path + ): + """Test that no parameter set is read when the block is absent. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The calibration shape. Reading a fitted set here would overwrite what the + optimiser is about to supply, so the builder must skip the call entirely. + """ + del distributed_mapping["parameters"] + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.parameters is None, ( + f"parameters should be unread, got {type(model.parameters)}" + ) + assert model.meteo is not None, "the rest of the build should still have run" + + def test_omitting_gauges_leaves_them_unread(self, distributed_mapping, tmp_path): + """Test that no gauge data is read when the block is absent. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + A run that is only inspected, never scored, carries no observations -- and the + gauge readers would otherwise fail on a path that was never configured. + """ + del distributed_mapping["gauges"] + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.GaugesTable is None, "no gauge table should have been read" + assert model.QGauges is None, "no discharge should have been read" + + def test_the_routing_label_is_the_literal_the_router_compares( + self, distributed_mapping, tmp_path + ): + """Test that lower-case `muskingum` reaches the model as `"Muskingum"`. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + `distrrm.SpatialRouting` tests `routing_method != "Muskingum"` case-sensitively, + and `Catchment.__init__` stores whatever it is given verbatim. A lower-case + spelling would send every cell down the MAXBAS branch and read `bankfull_depth`, + which is None outside the flood model. + """ + distributed_mapping["catchment"]["routing_method"] = "muskingum" + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.routing_method == "Muskingum", ( + f"the router compares against 'Muskingum' exactly, got {model.routing_method!r}" + ) + + def test_an_unregistered_model_class_is_refused_by_name( + self, distributed_mapping, tmp_path + ): + """Test that a conceptual model the registry does not know is rejected. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The YAML names the model as a string, so the builder has to resolve it. An + unknown name must say what is available rather than fail on a None class later. + """ + distributed_mapping["conceptual_model"]["model_class"] = "HBV97" + + path = write_yaml(distributed_mapping, tmp_path) + + with pytest.raises(ValueError, match="not.*registered") as exc: + Catchment.from_yaml(path) + + assert "HBVBergestrom92" in str(exc.value), ( + f"the error should list the known models: {exc.value}" + ) + + def test_an_unregistered_model_class_is_refused_before_the_readers_run( + self, distributed_mapping, tmp_path + ): + """Test that the registry lookup happens before any input is read. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The lookup needs nothing but the config, so running it after the parameter folder + read charged a typo the full cost of that I/O and left a partly-populated model + behind. Pointing every input at a path that does not exist isolates the ordering: + if the readers ran first the failure would name a missing file instead. + """ + distributed_mapping["conceptual_model"]["model_class"] = "HBV97" + distributed_mapping["parameters"]["path"] = "no/such/parameters" + distributed_mapping["meteo"]["path"] = "no/such/meteo.nc" + distributed_mapping["flow_network"]["flow_accumulation"] = "no/such/acc.tif" + distributed_mapping["flow_network"]["flow_direction"] = "no/such/fd.tif" + + path = write_yaml(distributed_mapping, tmp_path) + + with pytest.raises(ValueError, match="not.*registered"): + Catchment.from_yaml(path) + + @pytest.mark.parametrize("cls", [Catchment, Calibration]) + def test_the_builder_returns_the_class_it_was_called_on( + self, distributed_mapping, tmp_path, cls + ): + """Test that a subclass taking the same constructor arguments builds its own type. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + cls: The class the classmethod is called on. + + Test scenario: + `Calibration` extends `Catchment` with the same constructor signature, so + `Calibration.from_yaml(...)` should hand back a `Calibration` the calibration + methods can be called on, not a bare `Catchment`. + """ + model = cls.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert isinstance(model, cls), ( + f"expected a {cls.__name__}, got {type(model).__name__}" + ) + + def test_run_cannot_be_built_because_it_takes_no_constructor_arguments( + self, distributed_mapping, tmp_path + ): + """Test that `Run.from_yaml` fails loudly rather than building something unusable. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + `Run` inherits the classmethod but overrides `__init__` to take only `self`, and + its entry points are called unbound on a catchment (`Run.RunHapi(model)`). The + override refuses the call with a message naming that pattern, rather than letting + constructor arity produce a `TypeError` about an unexpected keyword argument -- + an error that says nothing about what to do instead. + """ + path = write_yaml(distributed_mapping, tmp_path) + + with pytest.raises(TypeError, match="Catchment.from_yaml"): + Run.from_yaml(path) + + def test_a_lumped_configuration_reads_the_averaged_driver_csv( + self, lumped_mapping, tmp_path + ): + """Test that the lumped branch reads one CSV instead of a grid. + + Args: + lumped_mapping: A complete lumped configuration. + tmp_path: pytest temporary directory. + + Test scenario: + Lumped mode is the other half of the builder: `read_lumped_inputs` fills `data` + from a single file of catchment-average drivers, and no `MeteoInputs` grid or + flow network is built at all. + """ + model = Catchment.from_yaml(write_yaml(lumped_mapping, tmp_path)) + + assert model.spatial_resolution == "lumped" + assert model.data is not None, "the averaged drivers were not read" + assert model.meteo is None, "a lumped run should build no driver grid" + assert model.flow_network is None, "a lumped run should build no flow network" + assert model.parameters is not None, "the lumped parameter file was not read" + + def test_a_lumped_run_reads_discharge_without_a_gauge_table( + self, lumped_mapping, tmp_path + ): + """Test that lumped gauges are read from one file, with no table lookup. + + Args: + lumped_mapping: A complete lumped configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The gauge table exists to locate gauges on a grid, which lumped mode has none + of, so the builder must skip `read_gauge_table` and still read the discharge. + """ + model = Catchment.from_yaml(write_yaml(lumped_mapping, tmp_path)) + + assert model.QGauges is not None, "the observed discharge was not read" + assert model.GaugesTable is None, "a lumped run should read no gauge table" + + def test_the_inherited_window_is_parsed_with_the_catchment_format( + self, distributed_mapping, tmp_path + ): + """Test that fallback dates are read with `catchment.fmt`, not `meteo.fmt`. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + `catchment.fmt` and `meteo.fmt` are independent fields. When the meteo block + states no window it inherits the catchment's dates, which are written in the + catchment's format -- so parsing them with the meteo format either fails on a + date the user never wrote that way, or, between two mutually parseable layouts, + silently windows the drivers to the wrong period. + """ + distributed_mapping["catchment"]["fmt"] = "%d/%m/%Y" + distributed_mapping["catchment"]["start"] = "01/01/2009" + distributed_mapping["catchment"]["end"] = "10/01/2009" + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.meteo.time_steps == len(model.date_index), ( + f"drivers hold {model.meteo.time_steps} steps, model spans " + f"{len(model.date_index)}" + ) + assert model.meteo.time[0] == model.date_index[0], ( + f"window start {model.meteo.time[0]} does not match the model's " + f"{model.date_index[0]}" + ) + + def test_a_stated_meteo_window_uses_the_meteo_format( + self, distributed_mapping, tmp_path + ): + """Test that a window the block states is parsed with the block's own format. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The other half of the rule: `meteo.fmt` governs `meteo.start` / `meteo.end`, so a + block may describe its window in a different layout from the catchment's without + either being reinterpreted. + """ + distributed_mapping["meteo"]["fmt"] = "%d/%m/%Y" + distributed_mapping["meteo"]["start"] = "03/01/2009" + distributed_mapping["meteo"]["end"] = "07/01/2009" + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.meteo.time_steps == 5, ( + f"03 to 07 January inclusive is five steps, got {model.meteo.time_steps}" + ) + + def test_a_non_ascii_name_survives_the_read(self, distributed_mapping, tmp_path): + """Test that the configuration is decoded as UTF-8 whatever the platform default is. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + Without an explicit encoding the file is decoded with the locale codec, so a + non-ASCII name mojibakes on a machine that does not default to UTF-8 -- silently, + because the corrupted text is still valid YAML. The name reaches result filenames + and plot titles, and the same risk applies to every path field. + """ + distributed_mapping["catchment"]["name"] = "Río Coello" + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.name == "Río Coello", ( + f"the name was corrupted on read: {model.name!r}" + ) + + def test_the_configuration_stays_reachable_on_the_model( + self, distributed_mapping, tmp_path + ): + """Test that blocks the build does not consume survive on `model.config`. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + `outputs` describes where results go, so nothing in the build reads it. Discarding + the config would leave it parsed but unreachable, forcing a caller to restate in + Python a path the file already gives -- which is what the shipped example used to + do. + """ + distributed_mapping["outputs"] = {"results_dir": "somewhere/else/"} + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.config is not None, "the configuration should be kept on the model" + assert model.config.outputs.results_dir.replace("\\", "/").endswith( + "somewhere/else" + ), f"outputs did not survive: {model.config.outputs}" + assert model.config.flow_network.flow_accumulation is not None, ( + "the flow-accumulation path should stay reachable for save_results" + ) + + def test_a_hand_built_model_has_no_configuration( + self, coello_start_date, coello_end_date + ): + """Test that `config` is None on a model that was not built from a file. + + Args: + coello_start_date: Simulation start date. + coello_end_date: Simulation end date. + + Test scenario: + The attribute has to be safe to check on any catchment, so a hand-assembled one + reports no configuration rather than raising `AttributeError`. + """ + model = Catchment("coello", coello_start_date, coello_end_date) + + assert model.config is None, f"expected no configuration, got {model.config}" + + def test_relative_paths_resolve_against_the_configuration_file( + self, distributed_mapping, tmp_path, monkeypatch + ): + """Test that a configuration runs from a working directory that is not its own. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + monkeypatch: Used to move the process out of the repository root. + + Test scenario: + Resolving against the process's working directory would make a file valid only + from the one place it happened to be written for. Rewriting the paths relative to + the config and then running from elsewhere is the case that distinguishes the two. + """ + repo_root = Path.cwd() + config_dir = tmp_path / "configs" + config_dir.mkdir() + for block, field in ( + ("meteo", "path"), + ("parameters", "path"), + ("gauges", "table"), + ("gauges", "discharge"), + ): + distributed_mapping[block][field] = os.path.relpath( + repo_root / distributed_mapping[block][field], config_dir + ) + for field in ("flow_accumulation", "flow_direction"): + distributed_mapping["flow_network"][field] = os.path.relpath( + repo_root / distributed_mapping["flow_network"][field], config_dir + ) + path = config_dir / "config.yaml" + path.write_text(yaml.safe_dump(distributed_mapping), encoding="utf-8") + + monkeypatch.chdir(tmp_path) + model = Catchment.from_yaml(str(path)) + + assert model.meteo is not None, ( + "the drivers should resolve from the config's own dir" + ) + assert model.flow_network is not None, "the network should resolve too" + + def test_a_netcdf_variable_name_is_not_treated_as_a_path( + self, distributed_mapping, tmp_path + ): + """Test that the driver fields survive path resolution when they name variables. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + Under `source: netcdf` the three driver fields are variable names inside + `meteo.path`, not paths. Resolving them would turn `precipitation` into an + absolute directory and the read would fail looking for a variable of that name. + """ + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.config.meteo.precipitation == "precipitation", ( + f"the variable name was rewritten as a path: {model.config.meteo.precipitation}" + ) + + def test_a_path_object_is_accepted(self, distributed_mapping, tmp_path): + """Test that the path may be a `Path`, not only a string. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The body immediately wraps the argument in `Path`, and the rest of the package + annotates such arguments `str | Path`, so a caller holding a `Path` should not + have to stringify it. + """ + write_yaml(distributed_mapping, tmp_path) + + model = Catchment.from_yaml(tmp_path / "config.yaml") + + assert model.name == "Coello", ( + f"the config was not read from the Path: {model.name}" + ) + + def test_an_empty_file_names_itself(self, tmp_path): + """Test that an empty configuration file is reported as such. + + Args: + tmp_path: pytest temporary directory. + + Test scenario: + An empty file parses to `None`, which pydantic reports as "Input should be a valid + dictionary" without naming the file -- unhelpful when a run names several. + """ + path = tmp_path / "empty.yaml" + path.write_text("", encoding="utf-8") + + with pytest.raises(ValueError, match="is empty") as exc: + Catchment.from_yaml(str(path)) + + assert "empty.yaml" in str(exc.value), ( + f"the error should name the file: {exc.value}" + ) + + def test_an_invalid_configuration_fails_before_anything_is_read( + self, distributed_mapping, tmp_path + ): + """Test that validation runs ahead of the first reader. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + Reading rasters is the slow part of a build. A configuration that cannot work + should be rejected on the parsed mapping, not after minutes of I/O. + """ + distributed_mapping["catchment"]["spatial_resolution"] = "semi" + + path = write_yaml(distributed_mapping, tmp_path) + + with pytest.raises(ValidationError, match="Input should be"): + Catchment.from_yaml(path) + + def test_the_configuration_is_read_from_the_given_path( + self, distributed_mapping, tmp_path + ): + """Test that two different files build two differently-named models. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + Guards against the path being ignored in favour of something cached or + hard-coded -- the name has to come from the file that was named. + """ + first = write_yaml(distributed_mapping, tmp_path) + renamed = copy.deepcopy(distributed_mapping) + renamed["catchment"]["name"] = "Elsewhere" + other_dir = tmp_path / "other" + other_dir.mkdir() + second = write_yaml(renamed, other_dir) + + assert Catchment.from_yaml(first).name == "Coello" + assert Catchment.from_yaml(second).name == "Elsewhere" + + def test_a_missing_file_names_the_path(self, tmp_path): + """Test that a configuration path that does not exist raises `FileNotFoundError`. + + Args: + tmp_path: pytest temporary directory. + + Test scenario: + Documented in `Raises` but untested. The path is opened directly, so the error + comes from `read_text` and carries the name. + """ + missing = tmp_path / "not-here.yaml" + + with pytest.raises(FileNotFoundError): + Catchment.from_yaml(str(missing)) + + def test_malformed_yaml_is_reported_as_malformed(self, tmp_path): + """Test that a file YAML cannot parse raises `yaml.YAMLError`. + + Args: + tmp_path: pytest temporary directory. + + Test scenario: + Also documented and untested. An unclosed bracket is a parse error, which has to + surface as one rather than as a validation error about a missing block. + """ + path = tmp_path / "broken.yaml" + path.write_text("catchment: {name: Coello\n", encoding="utf-8") + + with pytest.raises(yaml.YAMLError): + Catchment.from_yaml(str(path)) + + def test_a_top_level_scalar_is_refused(self, tmp_path): + """Test that a file holding a bare scalar is refused rather than indexed. + + Args: + tmp_path: pytest temporary directory. + + Test scenario: + A one-word file parses to a string, not to None, so it bypasses the empty-file + message and reaches pydantic. Pins that it fails there rather than raising an + `AttributeError` somewhere in the build. + """ + path = tmp_path / "scalar.yaml" + path.write_text("hello\n", encoding="utf-8") + + with pytest.raises(ValidationError, match="valid dictionary"): + Catchment.from_yaml(str(path)) + + def test_every_missing_input_path_is_named_at_once( + self, distributed_mapping, tmp_path + ): + """Test that the pre-flight check reports all the missing paths together. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The readers fail one at a time and in the order the build calls them, so a typo + in the gauge table used to be reported only after the whole meteorological cube + and the parameter folder had been read. Two typos should now be reported in one + message, before anything is opened. + """ + distributed_mapping["parameters"]["path"] = "no/such/parameters" + distributed_mapping["gauges"]["table"] = "no/such/gauges.csv" + + path = write_yaml(distributed_mapping, tmp_path) + + with pytest.raises(FileNotFoundError) as exc: + Catchment.from_yaml(path) + + message = str(exc.value) + assert "parameters.path" in message, ( + f"the missing parameter folder should be named: {message}" + ) + assert "gauges.table" in message, ( + f"the missing gauge table should be named in the same message: {message}" + ) + + def test_a_netcdf_variable_name_is_not_checked_for_existence( + self, distributed_mapping, tmp_path + ): + """Test that the pre-flight check does not treat a variable name as a path. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + Under `source: netcdf` the three driver fields name variables inside + `meteo.path`, so checking them as paths would fail every NetCDF configuration. + The fixture already uses that source. + """ + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.meteo is not None, "the build should have reached the readers" + + def test_the_gauge_columns_come_from_the_configured_column( + self, distributed_mapping, tmp_path + ): + """Test that `gauges.column` labels the hydrograph frame and every column is filled. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + The frame is labelled from `column` but each file is named after `id`. Filling by + `int(name)` instead of by the label left a `column != "id"` table with the + requested columns all-NaN and a second, id-named set beside them -- silently, and + the metrics were then computed over both. + """ + distributed_mapping["gauges"]["column"] = "name" + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + columns = list(model.QGauges.columns) + assert all(isinstance(name, str) for name in columns), ( + f"the frame should carry the table's names, got {columns}" + ) + all_nan = [name for name in columns if model.QGauges[name].isna().all()] + assert not all_nan, f"no column should be left unfilled, got {all_nan}" + + def test_the_gauge_table_format_can_differ_from_the_discharge_one( + self, distributed_mapping, tmp_path + ): + """Test that `table_fmt` is what reaches `read_gauge_table`. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + One field used to feed both readers, so the two files could not disagree. The + Coello table carries no `start` / `end` columns, so an unused `table_fmt` must + simply be accepted and forwarded rather than applied to the discharge index. + """ + distributed_mapping["gauges"]["table_fmt"] = "%d/%m/%Y" + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.config.gauges.table_fmt == "%d/%m/%Y", ( + f"the table format should be kept on the config: {model.config.gauges}" + ) + assert not model.QGauges.isna().all().all(), ( + "the discharge index must still be parsed with gauges.fmt" + ) + + def test_an_unquoted_date_builds_the_same_model_as_a_quoted_one( + self, distributed_mapping, tmp_path + ): + """Test the unquoted-date path end to end, not only at the schema. + + Args: + distributed_mapping: A complete distributed configuration. + tmp_path: pytest temporary directory. + + Test scenario: + `start: 2009-01-01` without quotes is what a YAML author writes first, and it + reaches pydantic as a `date`. The model built from it must be identical to the + one built from the quoted spelling. + """ + quoted = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + unquoted_mapping = copy.deepcopy(distributed_mapping) + unquoted_mapping["catchment"]["start"] = dt.date.fromisoformat( + distributed_mapping["catchment"]["start"] + ) + other = tmp_path / "unquoted" + other.mkdir() + unquoted = Catchment.from_yaml(write_yaml(unquoted_mapping, other)) + + assert unquoted.start == quoted.start, ( + f"expected {quoted.start}, got {unquoted.start}" + ) + assert len(unquoted.date_index) == len(quoted.date_index), ( + "both spellings should span the same period" + ) + + def test_a_raster_source_configuration_reads_the_three_folders( + self, + distributed_mapping, + coello_prec_path: str, + coello_temp_path: str, + coello_evap_path: str, + tmp_path, + ): + """Test the raster branch of the build, including its path check. + + Args: + distributed_mapping: A complete distributed configuration. + coello_prec_path: Folder of precipitation rasters. + coello_temp_path: Folder of temperature rasters. + coello_evap_path: Folder of evapotranspiration rasters. + tmp_path: pytest temporary directory. + + Test scenario: + The fixture drives every other `from_yaml` test from one combined NetCDF, where + the three driver fields are variable names. Under `rasters` they are folders that + the pre-flight check does look for, and `MeteoInputs.from_rasters` is a different + loader -- so both are covered only here. + """ + distributed_mapping["meteo"] = { + "source": "rasters", + "precipitation": str(Path(coello_prec_path).resolve()), + "temperature": str(Path(coello_temp_path).resolve()), + "evapotranspiration": str(Path(coello_evap_path).resolve()), + "file_name_data_fmt": "%Y.%m.%d", + } + + model = Catchment.from_yaml(write_yaml(distributed_mapping, tmp_path)) + + assert model.meteo.time_steps == len(model.date_index), ( + f"the drivers must span the model period: {model.meteo.time_steps} against " + f"{len(model.date_index)}" + ) + + def test_a_missing_driver_folder_is_reported_before_anything_is_read( + self, + distributed_mapping, + coello_temp_path: str, + coello_evap_path: str, + tmp_path, + ): + """Test that a raster driver folder is checked for existence like any other path. + + Args: + distributed_mapping: A complete distributed configuration. + coello_temp_path: Folder of temperature rasters. + coello_evap_path: Folder of evapotranspiration rasters. + tmp_path: pytest temporary directory. + + Test scenario: + A misspelled folder used to be reported from inside pyramids, after the reader + had opened whatever it could. Under a NetCDF source the driver fields are + variable names and must not be checked; under this one they are paths and must + be. + """ + distributed_mapping["meteo"] = { + "source": "rasters", + "precipitation": str(tmp_path / "no-such-folder"), + "temperature": str(Path(coello_temp_path).resolve()), + "evapotranspiration": str(Path(coello_evap_path).resolve()), + } + + path = write_yaml(distributed_mapping, tmp_path) + + with pytest.raises(FileNotFoundError, match="meteo.precipitation"): + Catchment.from_yaml(path) diff --git a/tests/rrm/catchment/test_maxbas_routing_variants.py b/tests/rrm/catchment/test_maxbas_routing_variants.py index 693798ca3..07275a9a4 100644 --- a/tests/rrm/catchment/test_maxbas_routing_variants.py +++ b/tests/rrm/catchment/test_maxbas_routing_variants.py @@ -286,3 +286,70 @@ def test_routing_without_a_function_is_rejected( with pytest.raises(ValueError, match="routing_fn"): Run.runLumped(model, Route=1) + + +class TestCalculateWeightsGuard: + """Tests for the MAXBAS lower bound in `Routing.calculate_weights`.""" + + @pytest.mark.parametrize( + "maxbas, symptom", + [ + (0.5, "one weight and an all-zero hydrograph, silently"), + (0, "an IndexError about axis 1"), + (float("nan"), "'cannot convert float NaN to integer'"), + ], + ids=["fractional", "zero", "nan"], + ) + def test_a_maxbas_below_one_is_refused(self, maxbas, symptom): + """Test that each below-one MAXBAS is refused by name. + + Args: + maxbas: A MAXBAS value below the one whole step a triangle needs. + symptom: What it produced before the guard, for the failure message. + + Test scenario: + `triangular_routing_2` and the three conceptual models already carried this + check; `calculate_weights` -- which `triangular_routing_1` and `DistMaxbas1` + resolve their weights through -- did not, and each value failed differently or + not at all. + """ + with pytest.raises(ValueError, match="at least 1") as exc: + Routing.calculate_weights(maxbas) + + assert str(maxbas) in str(exc.value), ( + f"the error should name the value, given it used to produce {symptom}: {exc.value}" + ) + + def test_the_guard_reaches_triangular_routing_1(self): + """Test that the routing function itself refuses, not only the weights helper. + + Test scenario: + `triangular_routing_1` is what the lumped MAXBAS example and `DistMaxbas1` call, + and it delegates to `calculate_weights` on its first line -- so the guard has to + surface there rather than being swallowed. + """ + q = np.array([0.0, 1.0, 3.0, 7.0, 10.0]) + + with pytest.raises(ValueError, match="at least 1"): + Routing.triangular_routing_1(q, 0.2) + + @pytest.mark.parametrize( + "maxbas", [1, 4.5, 5], ids=["minimum", "fractional", "whole"] + ) + def test_a_valid_maxbas_still_routes(self, maxbas): + """Test that the guard leaves every accepted MAXBAS working. + + Args: + maxbas: A MAXBAS at or above the bound, integer and fractional. + + Test scenario: + The bound is `not maxbas >= 1` so that NaN fails it; the boundary value itself + must still pass, and fractional support is what separates this function from + `triangular_routing_2`. + """ + weights = Routing.calculate_weights(maxbas) + + assert len(weights) >= 1, f"expected weights for maxbas={maxbas}, got {weights}" + assert np.isclose(weights.sum(), 1.0, atol=0.05), ( + f"triangular weights should be normalised, got {weights.sum()}" + ) diff --git a/tests/rrm/catchment/test_read_parameters_validation.py b/tests/rrm/catchment/test_read_parameters_validation.py index 8e3fef515..b06d01bb3 100644 --- a/tests/rrm/catchment/test_read_parameters_validation.py +++ b/tests/rrm/catchment/test_read_parameters_validation.py @@ -321,14 +321,68 @@ def test_a_non_float_initial_discharge_is_refused( Test scenario: The value is divided in two inside the conceptual model, so a string or a list - fails there rather than here -- far from the call that supplied it. + fails there rather than here -- far from the call that supplied it. A `TypeError` + rather than an `AssertionError`, so the check survives `python -O`. """ model = Catchment("coello", coello_start_date, coello_end_date) - with pytest.raises(AssertionError, match="q_init should be of type float"): + with pytest.raises(TypeError, match="q_init should be of type float"): model.read_lumped_model(HBVLumped, 1530.0, coello_initial_cond, q_init=bad) +class TestReadLumpedModelInitialCondition: + """Tests for the `initial_condition` type guard in `Catchment.read_lumped_model`.""" + + def test_a_list_of_five_is_accepted( + self, coello_start_date: str, coello_end_date: str, coello_initial_cond: list + ): + """Test that the documented type and length pass through unchanged. + + Test scenario: + The happy path the guard must not disturb: a five-element list is stored as + `initial_cond` verbatim. + """ + model = Catchment("coello", coello_start_date, coello_end_date) + + model.read_lumped_model(HBVLumped, 1530.0, coello_initial_cond) + + assert model.initial_cond == coello_initial_cond, ( + f"the initial condition must be stored unchanged, got {model.initial_cond}" + ) + + @pytest.mark.parametrize( + "bad", + [(0, 10, 10, 10, 0), np.zeros(5), "01010", None], + ids=["tuple", "array", "str", "none"], + ) + def test_a_non_list_initial_condition_is_refused( + self, coello_start_date: str, coello_end_date: str, bad + ): + """Test that anything other than a list is rejected before its length is measured. + + Args: + coello_start_date: Simulation start date. + coello_end_date: Simulation end date. + bad: A value of the wrong type, including one with the right effective length. + + Test scenario: + The check used to measure `len(initial_condition)` before typing it, so a value + `len()` cannot take -- `None` -- reported "object of type 'NoneType' has no + len()" rather than naming the argument, and the `is not None` a later type check + carried could never be false, because `len` would already have raised. Typing + first means every non-list is refused the same way, including a tuple or array + that happens to hold five elements. + """ + model = Catchment("coello", coello_start_date, coello_end_date) + + with pytest.raises(TypeError, match="init_st should be of type list") as exc: + model.read_lumped_model(HBVLumped, 1530.0, bad) + + assert type(bad).__name__ in str(exc.value), ( + f"the error should name what it got: {exc.value}" + ) + + class TestReadLumpedInputs: """Tests for the column handling in `Catchment.read_lumped_inputs`.""" diff --git a/tests/rrm/catchment/test_read_raster_inputs.py b/tests/rrm/catchment/test_read_raster_inputs.py index 6cd947bd3..b53102400 100644 --- a/tests/rrm/catchment/test_read_raster_inputs.py +++ b/tests/rrm/catchment/test_read_raster_inputs.py @@ -14,6 +14,7 @@ from __future__ import annotations +import datetime as dt import re from pathlib import Path @@ -22,7 +23,7 @@ from pyramids.dataset import Dataset from hapi.catchment import Catchment -from hapi.inputs import FlowNetwork, MeteoInputs +from hapi.inputs import FlowNetwork, MeteoInputs, read_rasters CELL_SIZE = 4000.0 """Cell size in metres used by every synthetic raster here (matches the Coello grid).""" @@ -591,6 +592,28 @@ def test_empty_directory_error_names_the_path(self, tmp_path): str(empty), str(empty), str(empty), file_name_data_fmt="%Y.%m.%d" ) + @pytest.mark.parametrize("bound", ["start", "end"]) + def test_a_datetime_bound_needs_the_date_ordering(self, coello_prec_path, bound): + """Test that a datetime bound is refused when the rasters are ordered by index. + + Args: + coello_prec_path: A folder of dated rasters. + bound: Which bound to pass as a datetime. + + Test scenario: + With `date=False` the rasters are ordered by the number in their name, so the + bounds are indices and a datetime has no meaning -- `int()` would fail on it + several frames down, in a message naming neither the argument nor the mode. + """ + bounds = {bound: dt.datetime(2009, 1, 1)} + + with pytest.raises(TypeError, match="needs date=True") as exc: + read_rasters(coello_prec_path, date=False, **bounds) + + assert "indices" in str(exc.value), ( + f"the error should say what the bounds mean in this mode: {exc.value}" + ) + class TestReadFlowDir: """Tests for the flow-direction half of ``FlowNetwork.from_rasters``.""" diff --git a/tests/rrm/catchment/test_run_validation.py b/tests/rrm/catchment/test_run_validation.py index 8a2659321..8c54f6c93 100644 --- a/tests/rrm/catchment/test_run_validation.py +++ b/tests/rrm/catchment/test_run_validation.py @@ -137,7 +137,7 @@ def test_rejects_river_geometry_off_the_catchment_grid( _load_flat_river_geometry(coello_loaded) coello_loaded.river_width = coello_loaded.river_width[:-1, :] - with pytest.raises(AssertionError, match="number of rows"): + with pytest.raises(ValueError, match="number of rows"): Run.RunFloodModel(coello_loaded) assert "RRMModel" not in spied_wrapper, ( @@ -205,7 +205,7 @@ def test_rejects_a_lake_record_of_the_wrong_length( """ lake = _LakeStub(coello_loaded.meteo.time_steps - 1) - with pytest.raises(AssertionError, match="same length"): + with pytest.raises(ValueError, match="same length"): Run.runHAPIwithLake(coello_loaded, lake) assert "RRMWithlake" not in spied_wrapper, ( @@ -223,7 +223,7 @@ def test_rejects_a_lake_record_missing_a_column( """ lake = _LakeStub(coello_loaded.meteo.time_steps, columns=2) - with pytest.raises(AssertionError, match="three columns"): + with pytest.raises(ValueError, match="three columns"): Run.runHAPIwithLake(coello_loaded, lake) def test_rejects_a_flow_direction_grid_of_the_wrong_shape( @@ -241,7 +241,7 @@ def test_rejects_a_flow_direction_grid_of_the_wrong_shape( ) lake = _LakeStub(coello_loaded.meteo.time_steps) - with pytest.raises(AssertionError, match="rows and columns"): + with pytest.raises(ValueError, match="rows and columns"): Run.runHAPIwithLake(coello_loaded, lake) @@ -279,7 +279,7 @@ def test_rejects_parameters_off_the_catchment_grid( coello_loaded.parameters = coello_loaded.parameters[:-1, :, :] lake = _LakeStub(coello_loaded.meteo.time_steps) - with pytest.raises(AssertionError, match="as many rows as the catchment grid"): + with pytest.raises(ValueError, match="as many rows as the catchment grid"): Run.RunFW1withLake(coello_loaded, lake) assert "FW1Withlake" not in spied_wrapper, ( diff --git a/tests/rrm/catchment/test_save_results_distributed.py b/tests/rrm/catchment/test_save_results_distributed.py index f926ffc4b..1df6e380d 100644 --- a/tests/rrm/catchment/test_save_results_distributed.py +++ b/tests/rrm/catchment/test_save_results_distributed.py @@ -128,3 +128,84 @@ def test_save_results_distributed_values_match_the_model_array( np.testing.assert_allclose( actual, expected, rtol=1e-5, err_msg="the first raster must hold the first step" ) + + +def test_save_results_joins_a_directory_written_without_a_separator( + coello_run: Catchment, coello_acc_path: str, tmp_path +): + """Test that a directory given without a trailing separator still writes inside it. + + Args: + coello_run: Distributed Coello catchment with a completed run. + coello_acc_path: Path to the flow-accumulation raster used as the template. + tmp_path: Parent of the destination directory. + + Test scenario: + The names used to be built by concatenation, so `some/dir` produced + `some/dirResult_2009-01-01.tif` -- a sibling of the directory rather than a file in + it. The other tests in this file all pass a trailing separator, so none of them + would notice. + """ + out = tmp_path / "no-separator" + out.mkdir() + + coello_run.save_results( + flow_acc_path=coello_acc_path, + result=4, + start="2009-01-01", + end="2009-01-02", + path=str(out), + ) + + assert len(sorted(out.glob("*.tif"))) == 2, ( + f"the rasters must land inside the directory, found {sorted(tmp_path.iterdir())}" + ) + + +def test_save_results_creates_the_directory_it_is_given( + coello_run: Catchment, coello_acc_path: str, tmp_path +): + """Test that a destination directory that does not exist yet is created. + + Args: + coello_run: Distributed Coello catchment with a completed run. + coello_acc_path: Path to the flow-accumulation raster used as the template. + tmp_path: Parent of the destination directory. + + Test scenario: + `outputs.results_dir` in a run configuration names where results go, and nothing + guarantees it exists before the first run. Covers a nested path, so a single + `mkdir` would not be enough. + """ + out = tmp_path / "nested" / "results" + + coello_run.save_results( + flow_acc_path=coello_acc_path, + result=4, + start="2009-01-01", + end="2009-01-02", + path=str(out), + ) + + assert len(sorted(out.glob("*.tif"))) == 2, ( + f"the directory must be created and written into, got {out.exists()}" + ) + + +def test_save_results_refuses_a_path_that_is_not_a_string(coello_run: Catchment): + """Test that a non-string `path` is refused by name rather than by concatenation. + + Args: + coello_run: Distributed Coello catchment with a completed run. + + Test scenario: + `outputs.results_dir` is optional in a run configuration, so a caller forwarding it + straight through can hold None. That used to surface as a `TypeError` from a string + concatenation, naming neither the argument nor what it should be. + """ + with pytest.raises(TypeError, match="path must be a string") as exc: + coello_run.save_results(flow_acc_path="unused", result=1, path=None) + + assert "NoneType" in str(exc.value), ( + f"the error should name what it got: {exc.value}" + ) diff --git a/tests/rrm/catchment/test_wrapper_with_lake.py b/tests/rrm/catchment/test_wrapper_with_lake.py index 79f7c054e..ee03c05bf 100644 --- a/tests/rrm/catchment/test_wrapper_with_lake.py +++ b/tests/rrm/catchment/test_wrapper_with_lake.py @@ -63,6 +63,7 @@ def _build_coello( parameters: str, area: int, initial_cond: list, + maxbas: bool = False, ) -> Catchment: """Assemble a distributed Coello catchment with no run behind it. @@ -77,6 +78,9 @@ def _build_coello( parameters: Folder of distributed parameter rasters. area: Catchment area in km2. initial_cond: Initial HBV state. + maxbas: Whether `parameters` carries the triangular-routing parameter. The two sets + differ in their last band -- MAXBAS against the Muskingum X -- and `DistMaxbas1` + routes with whatever sits there, so a triangular run needs the MAXBAS set. Returns: Catchment: Model with `meteo`, `flow_network`, parameters, a lumped model and a @@ -100,7 +104,7 @@ def _build_coello( file_name_data_fmt="%Y.%m.%d", ) coello.flow_network = FlowNetwork.from_rasters(acc, fd) - coello.read_parameters(parameters, False) + coello.read_parameters(parameters, False, maxbas=maxbas) coello.read_lumped_model(HBVLumped, area, initial_cond) coello.flow_path_length_arr = _flow_path_length_like(coello) return coello @@ -173,6 +177,45 @@ def coello_no_lake( ) +@pytest.fixture +def coello_with_lake_inputs_maxbas( + coello_start_date: str, + coello_end_date: str, + coello_prec_path: str, + coello_temp_path: str, + coello_evap_path: str, + coello_acc_path: str, + coello_fd_path: str, + coello_dist_parameters_maxbas: str, + coello_cat_area: int, + coello_initial_cond: list, +) -> Catchment: + """Provide the same catchment carrying a MAXBAS parameter set. + + `DistMaxbas1` routes each cell with `parameters[..., -1]`, which is MAXBAS in this set and + the Muskingum X in the other. Handed the Muskingum set, every cell routed with X = 0.2 -- + below the one whole step a triangle needs -- and `triangular_routing_1` returned an all-zero + hydrograph without raising, so the triangular tests asserted their shapes and flags against + output that carried nothing. In-domain MAXBAS here runs 1.4 to 2.4. + + Returns: + Catchment: Model ready for a triangular lake-aware run. + """ + return _build_coello( + coello_start_date, + coello_end_date, + coello_prec_path, + coello_temp_path, + coello_evap_path, + coello_acc_path, + coello_fd_path, + coello_dist_parameters_maxbas, + coello_cat_area, + coello_initial_cond, + maxbas=True, + ) + + def _flow_path_length_like(model: Catchment) -> np.ndarray: """Build a flow-path-length grid masked to the catchment's active cells. @@ -320,7 +363,8 @@ def test_the_routed_lake_series_covers_every_simulation_step( def test_a_muskingum_lake_run_clears_a_flag_a_triangular_run_set( self, - coello_with_lake_inputs: Catchment, + coello_with_lake_inputs_maxbas: Catchment, + coello_dist_parameters_muskingum: str, coello_start_date: str, coello_end_date: str, ): @@ -329,9 +373,12 @@ def test_a_muskingum_lake_run_clears_a_flag_a_triangular_run_set( Test scenario: The flag tells `extract_discharge` that a cell of `Qtot` is a contribution rather than a discharge. Setting it by hand would test the assignment against itself, so - drive both paths on the same model in the order that makes the flag matter. + drive both paths on the same model in the order that makes the flag matter. The + two read different parameter layouts -- Muskingum takes bands 10 and 11, the + triangular path the last one -- so each leg is given the set it indexes into. The + model, and therefore the flag under test, is the same object throughout. """ - model = coello_with_lake_inputs + model = coello_with_lake_inputs_maxbas lake = _make_lake(model, coello_start_date, coello_end_date, seed=13) Wrapper.FW1Withlake(model, lake) @@ -339,6 +386,7 @@ def test_a_muskingum_lake_run_clears_a_flag_a_triangular_run_set( "the triangular path must mark the model before this test means anything" ) + model.read_parameters(coello_dist_parameters_muskingum, False) Wrapper.RRMWithlake(model, lake) assert model._maxbas_routed is False, ( @@ -351,7 +399,7 @@ class TestFW1WithLake: def test_fills_the_distributed_output_fields( self, - coello_with_lake_inputs: Catchment, + coello_with_lake_inputs_maxbas: Catchment, coello_start_date: str, coello_end_date: str, ): @@ -363,7 +411,7 @@ def test_fills_the_distributed_output_fields( has been through `FW1Withlake` and nothing else -- with a shared instance an earlier Muskingum run would have filled them and deleting the fix left this green. """ - model = coello_with_lake_inputs + model = coello_with_lake_inputs_maxbas lake = _make_lake(model, coello_start_date, coello_end_date, seed=17) assert model.Qtot is None, "the fixture must arrive with no run behind it" @@ -380,7 +428,7 @@ def test_fills_the_distributed_output_fields( def test_the_outlet_series_carries_the_lake_and_drops_the_extra_slot( self, - coello_with_lake_inputs: Catchment, + coello_with_lake_inputs_maxbas: Catchment, coello_start_date: str, coello_end_date: str, ): @@ -392,7 +440,7 @@ def test_the_outlet_series_carries_the_lake_and_drops_the_extra_slot( entry, so the lake series has to be trimmed the same way -- it was not, and the two could not be added at all. """ - model = coello_with_lake_inputs + model = coello_with_lake_inputs_maxbas lake = _make_lake(model, coello_start_date, coello_end_date, seed=19) Wrapper.FW1Withlake(model, lake) @@ -417,7 +465,7 @@ def test_the_outlet_series_carries_the_lake_and_drops_the_extra_slot( def test_marks_the_model_as_maxbas_routed( self, - coello_with_lake_inputs: Catchment, + coello_with_lake_inputs_maxbas: Catchment, coello_start_date: str, coello_end_date: str, ): @@ -428,7 +476,7 @@ def test_marks_the_model_as_maxbas_routed( cell of `Qtot` under-reports. The flag is what makes `extract_discharge` refuse rather than return the wrong hydrograph. """ - model = coello_with_lake_inputs + model = coello_with_lake_inputs_maxbas lake = _make_lake(model, coello_start_date, coello_end_date, seed=23) assert model._maxbas_routed is False, "a fresh model must start unflagged"