diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index a4a098fd1..a2a5c37ba 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -20,6 +20,10 @@ Fixed - Improved performance for merging structured multimodel Modflow 6 output - Bug where :func:`imod.formats.idf.open_subdomains` did not properly support custom patterns +- Added missing validation for ``concentration`` for :class:`imod.mf6.Drainage` and + :class:`imod.mf6.EvapoTranspiration` package +- Added validation :class:`imod.mf6.Well` package, no ``np.nan`` values are + allowed Changed ~~~~~~~ diff --git a/imod/mf6/drn.py b/imod/mf6/drn.py index ec04afb17..80adf816e 100644 --- a/imod/mf6/drn.py +++ b/imod/mf6/drn.py @@ -2,7 +2,7 @@ from imod.mf6.boundary_condition import BoundaryCondition from imod.mf6.regridding_utils import RegridderType -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA from imod.schemata import ( AllInsideNoDataSchema, AllNoDataSchema, @@ -78,6 +78,17 @@ class Drainage(BoundaryCondition): CoordsSchema(("layer",)), BOUNDARY_DIMS_SCHEMA, ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + ], "print_flows": [DTypeSchema(np.bool_), DimsSchema()], "save_flows": [DTypeSchema(np.bool_), DimsSchema()], } @@ -88,6 +99,7 @@ class Drainage(BoundaryCondition): AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), ], "conductance": [IdentityNoDataSchema("elevation"), AllValueSchema(">", 0.0)], + "concentration": [IdentityNoDataSchema("elevation"), AllValueSchema(">=", 0.0)], } _period_data = ("elevation", "conductance") diff --git a/imod/mf6/evt.py b/imod/mf6/evt.py index 7cf6d2894..f8fb64d30 100644 --- a/imod/mf6/evt.py +++ b/imod/mf6/evt.py @@ -4,7 +4,7 @@ from imod.mf6.boundary_condition import BoundaryCondition from imod.mf6.regridding_utils import RegridderType -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA from imod.schemata import ( AllInsideNoDataSchema, AllNoDataSchema, @@ -128,6 +128,17 @@ class Evapotranspiration(BoundaryCondition): CoordsSchema(("layer",)), SEGMENT_BOUNDARY_DIMS_SCHEMA, ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + ], "print_flows": [DTypeSchema(np.bool_), DimsSchema()], "save_flows": [DTypeSchema(np.bool_), DimsSchema()], } @@ -145,6 +156,7 @@ class Evapotranspiration(BoundaryCondition): AllValueSchema(">=", 0.0), AllValueSchema("<=", 1.0), ], + "concentration": [IdentityNoDataSchema("surface"), AllValueSchema(">=", 0.0)], } _period_data = ("surface", "rate", "depth", "proportion_depth", "proportion_rate") diff --git a/imod/mf6/wel.py b/imod/mf6/wel.py index 1af603f09..caa2977e6 100644 --- a/imod/mf6/wel.py +++ b/imod/mf6/wel.py @@ -23,9 +23,15 @@ from imod.mf6.utilities.clip import clip_by_grid from imod.mf6.utilities.dataset import remove_inactive from imod.mf6.utilities.grid import create_layered_top +from imod.mf6.validation import validation_pkg_error_message from imod.mf6.write_context import WriteContext from imod.prepare import assign_wells -from imod.schemata import AllNoDataSchema, DTypeSchema +from imod.schemata import ( + AnyNoDataSchema, + DTypeSchema, + EmptyIndexesSchema, + ValidationError, +) from imod.select.points import points_indices, points_values from imod.typing import GridDataArray from imod.typing.grid import is_spatial_2D, ones_like @@ -144,8 +150,12 @@ def y(self) -> npt.NDArray[float]: "concentration": [DTypeSchema(np.floating)], } _write_schemata = { - "y": [AllNoDataSchema()], - "x": [AllNoDataSchema()], + "screen_top": [AnyNoDataSchema(), EmptyIndexesSchema()], + "screen_bottom": [AnyNoDataSchema(), EmptyIndexesSchema()], + "y": [AnyNoDataSchema(), EmptyIndexesSchema()], + "x": [AnyNoDataSchema(), EmptyIndexesSchema()], + "rate": [AnyNoDataSchema(), EmptyIndexesSchema()], + "concentration": [AnyNoDataSchema(), EmptyIndexesSchema()], } _regrid_method: dict[str, Tuple[RegridderType, str]] = {} @@ -190,6 +200,9 @@ def __init__( "concentration_boundary_type": concentration_boundary_type, } super().__init__(dict_dataset) + # Set index as coordinate + index_coord = np.arange(self.dataset.dims["index"]) + self.dataset = self.dataset.assign_coords(index=index_coord) self._validate_init_schemata(validate) @classmethod @@ -512,7 +525,10 @@ def to_mf6_pkg( Object with wells as list based input. """ if validate: - self._validate(self._write_schemata) + errors = self._validate(self._write_schemata) + if len(errors) > 0: + message = validation_pkg_error_message(errors) + raise ValidationError(message) minimum_k = self.dataset["minimum_k"].item() minimum_thickness = self.dataset["minimum_thickness"].item() diff --git a/imod/schemata.py b/imod/schemata.py index 1e22316a6..2b3a27a20 100644 --- a/imod/schemata.py +++ b/imod/schemata.py @@ -471,6 +471,17 @@ def validate(self, obj: Union[xr.DataArray, xu.UgridDataArray], **kwargs): raise ValidationError("all nodata") +class AnyNoDataSchema(NoDataSchema): + """ + Fails when any data is NoData. + """ + + def validate(self, obj: Union[xr.DataArray, xu.UgridDataArray], **kwargs): + valid = self.is_notnull(obj) + if ~valid.all(): + raise ValidationError("found a nodata value") + + class NoDataComparisonSchema(BaseSchema): """ Base class for IdentityNoDataSchema and AllInsideNoDataSchema. diff --git a/imod/tests/test_mf6/test_mf6_drn.py b/imod/tests/test_mf6/test_mf6_drn.py index 1f8220700..d38cb6859 100644 --- a/imod/tests/test_mf6/test_mf6_drn.py +++ b/imod/tests/test_mf6/test_mf6_drn.py @@ -49,6 +49,35 @@ def transient_drainage(): return drn +@pytest.fixture(scope="function") +def transient_concentration_drainage(): + layer = np.arange(1, 4) + y = np.arange(4.5, 0.0, -1.0) + x = np.arange(0.5, 5.0, 1.0) + elevation = xr.DataArray( + np.full((3, 5, 5), 1.0), + coords={"layer": layer, "y": y, "x": x, "dx": 1.0, "dy": -1.0}, + dims=("layer", "y", "x"), + ) + time_multiplier = xr.DataArray( + data=np.arange(1.0, 7.0, 1.0), + coords={"time": pd.date_range("2000-01-01", "2005-01-01", freq="YS")}, + dims=("time",), + ) + species_multiplier = xr.DataArray( + data=[35.0, 1.0], + coords={"species": ["salinity", "temperature"]}, + dims=("species",), + ) + conductance = time_multiplier * elevation + concentration = species_multiplier * conductance + + drn = dict( + elevation=elevation, conductance=conductance, concentration=concentration + ) + return drn + + def test_write(drainage, tmp_path): drn = imod.mf6.Drainage(**drainage) write_context = WriteContext(simulation_directory=tmp_path, use_binary=True) @@ -95,16 +124,42 @@ def test_check_conductance_zero(drainage): top = 1.0 bottom = top - idomain.coords["layer"] - dis = imod.mf6.StructuredDiscretization(top=1.0, bottom=bottom, idomain=idomain) - + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) drn = imod.mf6.Drainage(**drainage) + errors = drn._validate(drn._write_schemata, **dis.dataset) + assert len(errors) == 1 + for var, error in errors.items(): + assert var == "conductance" + +def test_validate_concentration(transient_concentration_drainage): + idomain = transient_concentration_drainage["elevation"].astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + drn = imod.mf6.Drainage(**transient_concentration_drainage) + + # No errors at start errors = drn._validate(drn._write_schemata, **dis.dataset) + assert len(errors) == 0 + # Error with incongruent data + # Rivers are located everywhere in the grid. + drn.dataset["concentration"][0, 2, 2] = np.nan + errors = drn._validate(drn._write_schemata, **dis.dataset) assert len(errors) == 1 + for var, error in errors.items(): + assert var == "concentration" + # Error with smaller than zero + drn.dataset["concentration"] = idomain.where( + False, -200.0 + ) # Set concentrations negative + errors = drn._validate(drn._write_schemata, **dis.dataset) + assert len(errors) == 1 for var, error in errors.items(): - assert var == "conductance" + assert var == "concentration" def test_discontinuous_layer(drainage): diff --git a/imod/tests/test_mf6/test_mf6_wel.py b/imod/tests/test_mf6/test_mf6_wel.py index de1afcfcf..1aa72ec60 100644 --- a/imod/tests/test_mf6/test_mf6_wel.py +++ b/imod/tests/test_mf6/test_mf6_wel.py @@ -54,6 +54,20 @@ def test_to_mf6_pkg__high_lvl_stationary(basic_dis, well_high_lvl_test_data_stat np.testing.assert_equal(mf6_ds["rate"].values, rate_expected) +def test_to_mf6_pkg__validate(well_high_lvl_test_data_stationary): + # Arrange + wel = imod.mf6.Well(*well_high_lvl_test_data_stationary) + + # Act + errors = wel._validate(wel._write_schemata) + assert len(errors) == 0 + + # Set rates with index exceeding 3 to NaN. + wel.dataset["rate"] = wel.dataset["rate"].where(wel.dataset.coords["index"] < 3) + errors = wel._validate(wel._write_schemata) + assert len(errors) == 1 + + def test_to_mf6_pkg__high_lvl_multilevel(basic_dis, well_high_lvl_test_data_stationary): """ Test with stationary wells where the first 4 well screens extend over 2 layers. @@ -142,6 +156,17 @@ def test_to_mf6_pkg__high_lvl_transient(basic_dis, well_high_lvl_test_data_trans np.testing.assert_equal(mf6_ds["rate"].values, rate_expected) +def test_is_empty(well_high_lvl_test_data_transient): + # Arrange + wel = imod.mf6.Well(*well_high_lvl_test_data_transient) + empty_wel_args = ([] for i in range(len(well_high_lvl_test_data_transient))) + wel_empty = imod.mf6.Well(*empty_wel_args, validate=False) + + # Act/Assert + assert not wel.is_empty() + assert wel_empty.is_empty() + + class ClipBoxCases: @staticmethod def case_clip_xy(parameterizable_basic_dis):