From cc45621786862b68a0a8255f1ae4636c2fc40370 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Tue, 29 Sep 2026 15:59:39 +0200 Subject: [PATCH 1/7] Rename file to mf6_package_instance_creation --- ...ge_instance_creation.py => mf6_package_instance_creation.py} | 0 imod/tests/test_common/test_utilities/test_mask_util.py | 2 +- imod/tests/test_mf6/test_package_sanity.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename imod/tests/fixtures/{package_instance_creation.py => mf6_package_instance_creation.py} (100%) diff --git a/imod/tests/fixtures/package_instance_creation.py b/imod/tests/fixtures/mf6_package_instance_creation.py similarity index 100% rename from imod/tests/fixtures/package_instance_creation.py rename to imod/tests/fixtures/mf6_package_instance_creation.py diff --git a/imod/tests/test_common/test_utilities/test_mask_util.py b/imod/tests/test_common/test_utilities/test_mask_util.py index 72a15edab..e9322e10d 100644 --- a/imod/tests/test_common/test_utilities/test_mask_util.py +++ b/imod/tests/test_common/test_utilities/test_mask_util.py @@ -10,7 +10,7 @@ broadcast_and_mask_arrays, mask_arrays, ) -from imod.tests.fixtures.package_instance_creation import get_grid_da +from imod.tests.fixtures.mf6_package_instance_creation import get_grid_da class DataArrayCases: diff --git a/imod/tests/test_mf6/test_package_sanity.py b/imod/tests/test_mf6/test_package_sanity.py index cdadf2005..bb8c1dd2a 100644 --- a/imod/tests/test_mf6/test_package_sanity.py +++ b/imod/tests/test_mf6/test_package_sanity.py @@ -20,7 +20,7 @@ import imod from imod.mf6.boundary_condition import AdvancedBoundaryCondition, BoundaryCondition from imod.mf6.package import Package -from imod.tests.fixtures.package_instance_creation import ALL_PACKAGE_INSTANCES +from imod.tests.fixtures.mf6_package_instance_creation import ALL_PACKAGE_INSTANCES ALL_PACKAGES = [ item From 357d7a18cd1f899f10cf9c97fd48624cf1adee25 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Tue, 29 Sep 2026 16:02:08 +0200 Subject: [PATCH 2/7] Rename constant var to include MODFLOW6 --- imod/tests/fixtures/mf6_package_instance_creation.py | 2 +- imod/tests/test_mf6/test_package_sanity.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/imod/tests/fixtures/mf6_package_instance_creation.py b/imod/tests/fixtures/mf6_package_instance_creation.py index 086623cda..64d1c3fd6 100644 --- a/imod/tests/fixtures/mf6_package_instance_creation.py +++ b/imod/tests/fixtures/mf6_package_instance_creation.py @@ -313,7 +313,7 @@ def create_exchange_package() -> list[GWFGWF]: ] -ALL_PACKAGE_INSTANCES = ( +ALL_MF6_PACKAGE_INSTANCES = ( GRIDLESS_PACKAGES + STRUCTURED_GRID_PACKAGES + UNSTRUCTURED_GRID_PACKAGES diff --git a/imod/tests/test_mf6/test_package_sanity.py b/imod/tests/test_mf6/test_package_sanity.py index bb8c1dd2a..7a20a5331 100644 --- a/imod/tests/test_mf6/test_package_sanity.py +++ b/imod/tests/test_mf6/test_package_sanity.py @@ -20,7 +20,7 @@ import imod from imod.mf6.boundary_condition import AdvancedBoundaryCondition, BoundaryCondition from imod.mf6.package import Package -from imod.tests.fixtures.mf6_package_instance_creation import ALL_PACKAGE_INSTANCES +from imod.tests.fixtures.mf6_package_instance_creation import ALL_MF6_PACKAGE_INSTANCES ALL_PACKAGES = [ item @@ -72,7 +72,7 @@ def test_adv_boundary_class_attributes(pkg_class): check_attributes(pkg_class) -@pytest.mark.parametrize("instance", ALL_PACKAGE_INSTANCES) +@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES) def test_render_twice(instance, tmp_path): globaltimes = [np.datetime64("2000-01-01")] modeldir = tmp_path / "testdir" @@ -96,7 +96,7 @@ def test_render_twice(instance, tmp_path): assert text1 == text2 -@pytest.mark.parametrize("instance", ALL_PACKAGE_INSTANCES) +@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES) @pytest.mark.parametrize("engine", ["netcdf4", "zarr", "zarr.zip"]) def test_save_and_load(instance, engine, tmp_path): pkg_class = type(instance) @@ -105,13 +105,13 @@ def test_save_and_load(instance, engine, tmp_path): assert instance.dataset.equals(back.dataset) -@pytest.mark.parametrize("instance", ALL_PACKAGE_INSTANCES) +@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES) def test_repr(instance): assert isinstance(instance.__repr__(), str) assert isinstance(instance._repr_html_(), str) -@pytest.mark.parametrize("instance", ALL_PACKAGE_INSTANCES) +@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES) def test_from_dataset(instance): pkg_class = type(instance) ds = instance.dataset From 704a6b6505658e1eca65b7cf3dac75f92936b9e4 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 11:40:10 +0200 Subject: [PATCH 3/7] Rename package sanity module --- .../{test_package_sanity.py => test_mf6_package_sanity.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename imod/tests/test_mf6/{test_package_sanity.py => test_mf6_package_sanity.py} (100%) diff --git a/imod/tests/test_mf6/test_package_sanity.py b/imod/tests/test_mf6/test_mf6_package_sanity.py similarity index 100% rename from imod/tests/test_mf6/test_package_sanity.py rename to imod/tests/test_mf6/test_mf6_package_sanity.py From 849088d7714a2ec45ed8e4d940cdad276f98f525 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 13:11:49 +0200 Subject: [PATCH 4/7] Add ids to test parametrization --- imod/tests/test_mf6/test_mf6_package_sanity.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/imod/tests/test_mf6/test_mf6_package_sanity.py b/imod/tests/test_mf6/test_mf6_package_sanity.py index 7a20a5331..1a1f24945 100644 --- a/imod/tests/test_mf6/test_mf6_package_sanity.py +++ b/imod/tests/test_mf6/test_mf6_package_sanity.py @@ -45,6 +45,8 @@ imod.mf6.LayeredWell, ] +NAMES = [type(instance).__name__ for instance in ALL_MF6_PACKAGE_INSTANCES] + def check_attributes(pkg_class): class_attributes = { @@ -72,7 +74,7 @@ def test_adv_boundary_class_attributes(pkg_class): check_attributes(pkg_class) -@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES) +@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES, ids=NAMES) def test_render_twice(instance, tmp_path): globaltimes = [np.datetime64("2000-01-01")] modeldir = tmp_path / "testdir" @@ -96,7 +98,7 @@ def test_render_twice(instance, tmp_path): assert text1 == text2 -@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES) +@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES, ids=NAMES) @pytest.mark.parametrize("engine", ["netcdf4", "zarr", "zarr.zip"]) def test_save_and_load(instance, engine, tmp_path): pkg_class = type(instance) @@ -105,13 +107,13 @@ def test_save_and_load(instance, engine, tmp_path): assert instance.dataset.equals(back.dataset) -@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES) +@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES, ids=NAMES) def test_repr(instance): assert isinstance(instance.__repr__(), str) assert isinstance(instance._repr_html_(), str) -@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES) +@pytest.mark.parametrize("instance", ALL_MF6_PACKAGE_INSTANCES, ids=NAMES) def test_from_dataset(instance): pkg_class = type(instance) ds = instance.dataset From 020d1a8966ed76c7b1831a4abc6c791e898dd384 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 13:31:55 +0200 Subject: [PATCH 5/7] Enforce strings in unittests --- imod/msw/copy_files.py | 20 ++++++++++++++++++-- imod/msw/meteo_grid.py | 10 +++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/imod/msw/copy_files.py b/imod/msw/copy_files.py index c5615b5fb..4db630bb9 100644 --- a/imod/msw/copy_files.py +++ b/imod/msw/copy_files.py @@ -1,6 +1,6 @@ from pathlib import Path from shutil import copy2 -from typing import cast +from typing import Sequence, cast import numpy as np import xarray as xr @@ -16,8 +16,24 @@ class FileCopier(MetaSwapPackage): - def __init__(self, paths: list[str]): + """ + Class to handle copying of files for the MetaSWAP package. + + This class filters out certain files that will be generated by iMOD Python + and copies the remaining files to a specified directory. + + Parameters + ---------- + paths: Sequence[str | Path] + List of file paths to be copied. These will be enforced to string upon + initialization. + """ + + def __init__(self, paths: Sequence[str | Path]): super().__init__() + # Enforce string so that it can be easily serialized and retrieved from + # the dataset. + paths = [str(p) for p in paths] paths_da = xr.DataArray( paths, coords={"file_nr": np.arange(len(paths))}, dims=("file_nr",) ) diff --git a/imod/msw/meteo_grid.py b/imod/msw/meteo_grid.py index 151d9943e..8ec04dd0a 100644 --- a/imod/msw/meteo_grid.py +++ b/imod/msw/meteo_grid.py @@ -210,7 +210,9 @@ class MeteoGridCopy(MetaSwapPackage, IRegridPackage): Parameters ---------- - path: Path to mete_grid.inp file + path: str | Path + Path to mete_grid.inp file. This will be enforced to string upon storing + in the dataset. """ _file_name = "mete_grid.inp" @@ -220,11 +222,13 @@ class MeteoGridCopy(MetaSwapPackage, IRegridPackage): def __init__(self, path: Path | str): super().__init__() - self.dataset["path"] = path + # Store as string so that it can be easily serialized and retrieved from + # the dataset. + self.dataset["path"] = str(path) def write(self, directory: Path | str, *args): directory = Path(directory) - path_metegrid = Path(str(self.dataset["path"].values[()])) + path_metegrid = Path(self.dataset["path"].item()) new_path = directory / self._file_name copyfile(path_metegrid, new_path) From 864e7fb83ce1222abbe6c878d5207aa62a22395d Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 13:32:15 +0200 Subject: [PATCH 6/7] Add package sanity checks for MetaSWAP --- .../fixtures/msw_package_instance_creation.py | 196 ++++++++++++++++++ .../tests/test_msw/test_msw_package_sanity.py | 14 ++ 2 files changed, 210 insertions(+) create mode 100644 imod/tests/fixtures/msw_package_instance_creation.py create mode 100644 imod/tests/test_msw/test_msw_package_sanity.py diff --git a/imod/tests/fixtures/msw_package_instance_creation.py b/imod/tests/fixtures/msw_package_instance_creation.py new file mode 100644 index 000000000..52f243165 --- /dev/null +++ b/imod/tests/fixtures/msw_package_instance_creation.py @@ -0,0 +1,196 @@ +from pathlib import Path + +import numpy as np +import pandas as pd +import xarray as xr + +from imod.msw import ( + AnnualCropFactors, + CouplerMapping, + EvapotranspirationMapping, + FileCopier, + GridData, + IdfMapping, + Infiltration, + InitialConditionsEquilibrium, + InitialConditionsPercolation, + InitialConditionsRootzonePressureHead, + InitialConditionsSavedState, + LanduseOptions, + MeteoGrid, + MeteoGridCopy, + Ponding, + PrecipitationMapping, + ScalingFactors, + SprinklingGrid, + SprinklingPoints, + TimeOutputControl, + VariableOutputControl, +) + + +def get_grid_da(dtype, value=1, subunit=True): + """ + This function creates a dataarray with scalar values for a grid of 2 subunits and 9 rows and columns. + """ + shape = nsub, nrow, ncol = 2, 9, 9 + dims = ("subunit", "y", "x") + + dx = 10.0 + dy = -10.0 + xmin = 0.0 + xmax = dx * ncol + ymin = 0.0 + ymax = abs(dy) * nrow + + subunits = np.arange(0, nsub) + y = np.arange(ymax, ymin, dy) + 0.5 * dy + x = np.arange(xmin, xmax, dx) + 0.5 * dx + coords = {"subunit": subunits, "y": y, "x": x} + + values = np.full(shape, fill_value=value, dtype=dtype) + + da = xr.DataArray(values, coords=coords, dims=dims) + if subunit is False: + da = da.sel(subunit=0, drop=True) + return da + + +def get_time_grid(dtype, value=1): + """ + This function creates a dataarray with scalar values for a time grid of ntimes steps. + """ + ntimes = 10 + shape = (ntimes,) + dims = ("time",) + coords = {"time": pd.date_range("2000-01-01", periods=ntimes)} + + da_time = xr.DataArray( + np.ones(shape, dtype=dtype) * value, coords=coords, dims=dims + ) + + da_grid = get_grid_da(dtype, value, subunit=False) + + return da_time * da_grid + + +def get_landuse_da(dtype, value=1): + landuse_index = np.arange(1, 4) + coords = {"landuse_index": landuse_index} + + values = np.full((3,), fill_value=value, dtype=dtype) + + lu_da = xr.DataArray(data=values, coords=coords, dims=("landuse_index",)) + return lu_da + + +def get_vegetation_da(dtype, value=1): + vegetation_index = np.arange(1, 4) + day_of_year = np.arange(1, 367) + coords = {"vegetation_index": vegetation_index, "day_of_year": day_of_year} + + values = np.full((3, 366), fill_value=value, dtype=dtype) + + veg_da = xr.DataArray( + data=values, coords=coords, dims=("vegetation_index", "day_of_year") + ) + return veg_da + + +def _paths(): + return [Path("path"), "path"] + + +def get_package_instances(): + return [ + FileCopier(_paths()), + CouplerMapping(), + GridData( + area=get_grid_da(float), + landuse=get_grid_da(int), + rootzone_depth=get_grid_da(float), + surface_elevation=get_grid_da(float, subunit=False), + soil_physical_unit=get_grid_da(int, subunit=False), + active=get_grid_da(bool, subunit=False), + ), + IdfMapping( + area=get_grid_da(float), + nodata=-9999.0, + ), + Infiltration( + infiltration_capacity=get_grid_da(float), + downward_resistance=get_grid_da(float), + upward_resistance=get_grid_da(float), + bottom_resistance=get_grid_da(float, subunit=False), + extra_storage_coefficient=get_grid_da(float, subunit=False), + ), + InitialConditionsEquilibrium(), + InitialConditionsPercolation(), + InitialConditionsRootzonePressureHead(), + InitialConditionsSavedState(Path("path")), + LanduseOptions( + landuse_name=get_landuse_da(str), + vegetation_index=get_landuse_da(int), + jarvis_o2_stress=get_landuse_da(float), + jarvis_drought_stress=get_landuse_da(float), + feddes_p1=get_landuse_da(float), + feddes_p2=get_landuse_da(float), + feddes_p3h=get_landuse_da(float), + feddes_p3l=get_landuse_da(float), + feddes_p4=get_landuse_da(float), + feddes_t3h=get_landuse_da(float), + feddes_t3l=get_landuse_da(float), + threshold_sprinkling=get_landuse_da(float), + fraction_evaporated_sprinkling=get_landuse_da(float), + gift=get_landuse_da(float), + gift_duration=get_landuse_da(float), + rotational_period=get_landuse_da(float), + start_sprinkling_season=get_landuse_da(float), + end_sprinkling_season=get_landuse_da(float), + interception_option=get_landuse_da(int), + ), + MeteoGrid( + get_time_grid(float), + get_time_grid(float), + ), + MeteoGridCopy(Path("path")), + EvapotranspirationMapping(get_time_grid(float)), + PrecipitationMapping(get_time_grid(float)), + TimeOutputControl(get_time_grid(float)), + VariableOutputControl(), + Ponding( + get_grid_da(float), + get_grid_da(float), + get_grid_da(float), + ), + ScalingFactors( + get_grid_da(float), + get_grid_da(float), + get_grid_da(float), + get_grid_da(float, subunit=False), + ), + SprinklingGrid( + get_grid_da(float), + get_grid_da(float), + ), + SprinklingPoints( + get_grid_da(int), + [1.0, 2.0, 3.0], + [1.0, 2.0, 3.0], + [1, 2, 3], + [1, 1, 1], + [1.0, 2.0, 3.0], + ), + AnnualCropFactors( + get_vegetation_da(float), + get_vegetation_da(float), + get_vegetation_da(float), + get_vegetation_da(float), + get_vegetation_da(float), + get_vegetation_da(float), + get_vegetation_da(float), + ), + ] + + +MSW_PACKAGE_INSTANCES = get_package_instances() diff --git a/imod/tests/test_msw/test_msw_package_sanity.py b/imod/tests/test_msw/test_msw_package_sanity.py new file mode 100644 index 000000000..2cd032045 --- /dev/null +++ b/imod/tests/test_msw/test_msw_package_sanity.py @@ -0,0 +1,14 @@ +import pytest + +from imod.tests.fixtures.msw_package_instance_creation import MSW_PACKAGE_INSTANCES + +NAMES = [type(instance).__name__ for instance in MSW_PACKAGE_INSTANCES] + + +@pytest.mark.parametrize("instance", MSW_PACKAGE_INSTANCES, ids=NAMES) +@pytest.mark.parametrize("engine", ["netcdf4", "zarr", "zarr.zip"]) +def test_msw_save_and_load(instance, engine, tmp_path): + pkg_class = type(instance) + path = instance.to_file(tmp_path, instance._file_name, engine=engine) + back = pkg_class.from_file(path) + assert instance.dataset.equals(back.dataset) From e216e2c5f5189e0d2c848d7fe4cca3ce7998613e Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 13:48:34 +0200 Subject: [PATCH 7/7] Update changelog --- docs/api/changelog.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index 1ecc7c0d5..aac18f99b 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -35,6 +35,9 @@ Fixed :class:`imod.mf6.Recharge`) where IBOUND is less than 0. - :meth:`imod.msw.MetaSwapModel.from_imod5_data` now masks cells where IBOUND is less than 0. +- :class:`imod.msw.FileCopier` and :class:`imod.msw.MeteoGridCopy` now force + paths to be stored as strings in the dataset. ``pathlib.Path`` objects could + cause errors when calling :meth:`imod.msw.MetaSwapModel.dump`. Changed ~~~~~~~