From d8fc79569a43009d9fdb3967aa3b06f1f5456c08 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 31 Aug 2026 13:59:50 +0200 Subject: [PATCH 01/23] Improved performance for allocation & sparse/empty layer handling. - Add drop_empty_layers: bool = False to llocate_x_cells functions in imod.prepare.topsystem.allocation to remove fully empty layers from the grids to prevent passing them along to reprojection/regridding operations. - Improved imod.prepare.layerregrid._regrid_layers to precompute valid layer indices once per column, instead of re-checking isnan for every (ii, jj) pair. --- imod/prepare/layerregrid.py | 56 ++- imod/prepare/topsystem/allocation.py | 111 +++++- .../test_topsystem_layer_preservation.py | 358 ++++++++++++++++++ 3 files changed, 500 insertions(+), 25 deletions(-) create mode 100644 imod/tests/test_prepare/test_topsystem_layer_preservation.py diff --git a/imod/prepare/layerregrid.py b/imod/prepare/layerregrid.py index c239e3240..46360ea1e 100644 --- a/imod/prepare/layerregrid.py +++ b/imod/prepare/layerregrid.py @@ -11,15 +11,36 @@ METHODS.pop("multilinear") +@numba.njit(cache=True) +def _valid_layer_indices(top_col, bot_col, out): + """ + Fill `out` (int64 array, same length as top_col) with the indices of + layers that have non-nan top AND bottom, at a single (row, col) column. + Returns the count of valid entries. Avoids allocating a new array per + column call. + """ + count = 0 + n = top_col.shape[0] + for k in range(n): + if not (np.isnan(top_col[k]) or np.isnan(bot_col[k])): + out[count] = k + count += 1 + return count + + @numba.njit(cache=True) def _regrid_layers(src, dst, src_top, dst_top, src_bot, dst_bot, method): """ - Maps one set of layers unto the other. + Maps one set of layers onto the other, skipping all-nan layers up front + per column instead of checking nan inside the nested layer loop. """ nlayer_src, nrow, ncol = src.shape nlayer_dst = dst.shape[0] + values = np.zeros(nlayer_src) weights = np.zeros(nlayer_src) + src_valid_idx = np.empty(nlayer_src, dtype=np.int64) + dst_valid_idx = np.empty(nlayer_dst, dtype=np.int64) for i in range(nrow): for j in range(ncol): @@ -28,23 +49,27 @@ def _regrid_layers(src, dst, src_top, dst_top, src_bot, dst_bot, method): src_b = src_bot[:, i, j] dst_b = dst_bot[:, i, j] - # ii is index of dst - for ii in range(nlayer_dst): + # Precompute valid layer indices ONCE per column, instead of + # re-checking isnan for every (ii, jj) pair. + n_src_valid = _valid_layer_indices(src_t, src_b, src_valid_idx) + if n_src_valid == 0: + continue + n_dst_valid = _valid_layer_indices(dst_t, dst_b, dst_valid_idx) + if n_dst_valid == 0: + continue + + for di in range(n_dst_valid): + ii = dst_valid_idx[di] dt = dst_t[ii] db = dst_b[ii] - if np.isnan(dt) or np.isnan(db): - continue count = 0 has_value = False - # jj is index of src - for jj in range(nlayer_src): + for sj in range(n_src_valid): + jj = src_valid_idx[sj] st = src_t[jj] sb = src_b[jj] - if np.isnan(st) or np.isnan(sb): - continue - overlap = common._overlap((db, dt), (sb, st)) if overlap == 0: continue @@ -53,12 +78,11 @@ def _regrid_layers(src, dst, src_top, dst_top, src_bot, dst_bot, method): values[count] = src[jj, i, j] weights[count] = overlap count += 1 - else: - if has_value: - dst[ii, i, j] = method(values, weights) - # Reset - values[:count] = 0 - weights[:count] = 0 + + if has_value: + dst[ii, i, j] = method(values, weights) + values[:count] = 0 + weights[:count] = 0 return dst diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index b74bec2f9..0f6dc0a1b 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -71,6 +71,7 @@ def allocate_riv_cells( bottom: GridDataArray, stage: GridDataArray, bottom_elevation: GridDataArray, + drop_empty_layers: bool = False, ) -> tuple[GridDataArray, Optional[GridDataArray]]: """ Allocate river cells from a planar grid across the vertical dimension. @@ -96,6 +97,14 @@ def allocate_riv_cells( bottom_elevation: DataArray | UgridDatarray Planar grid containing river bottom elevations. Is not allowed to have a layer dimension. + drop_empty_layers: bool, default False + If True, drop layers from the result that contain no allocated + cells anywhere in the domain. This avoids carrying the package's + arrays at full model-layer size through downstream regridding, + clipping, masking, and splitting, which can otherwise become + expensive for models with many layers relative to how many + layers the topsystem package actually occupies. Set to False to + keep the previous full-layer-coordinate behaviour. Returns ------- @@ -111,21 +120,25 @@ def allocate_riv_cells( """ match allocation_option: case ALLOCATION_OPTION.stage_to_riv_bot: - return _allocate_cells__stage_to_riv_bot( + riv_cells, drn_cells = _allocate_cells__stage_to_riv_bot( top, bottom, stage, bottom_elevation ) case ALLOCATION_OPTION.first_active_to_elevation: - return _allocate_cells__first_active_to_elevation( + riv_cells, drn_cells = _allocate_cells__first_active_to_elevation( active, top, bottom, bottom_elevation ) case ALLOCATION_OPTION.stage_to_riv_bot_drn_above: - return _allocate_cells__stage_to_riv_bot_drn_above( + riv_cells, drn_cells = _allocate_cells__stage_to_riv_bot_drn_above( active, top, bottom, stage, bottom_elevation ) case ALLOCATION_OPTION.at_elevation: - return _allocate_cells__at_elevation(top, bottom, bottom_elevation) + riv_cells, drn_cells = _allocate_cells__at_elevation( + top, bottom, bottom_elevation + ) case ALLOCATION_OPTION.at_first_active: - return _allocate_cells__at_first_active(active, bottom_elevation) + riv_cells, drn_cells = _allocate_cells__at_first_active( + active, bottom_elevation + ) case _: raise ValueError( "Received incompatible setting for rivers, only" @@ -137,6 +150,13 @@ def allocate_riv_cells( f"got: '{allocation_option.name}'" ) + if drop_empty_layers: + riv_cells = _drop_empty_layers(riv_cells) + if drn_cells is not None: + drn_cells = _drop_empty_layers(drn_cells) + + return riv_cells, drn_cells + def allocate_drn_cells( allocation_option: ALLOCATION_OPTION, @@ -144,6 +164,7 @@ def allocate_drn_cells( top: GridDataArray, bottom: GridDataArray, elevation: GridDataArray, + drop_empty_layers: bool = False, ) -> GridDataArray: """ Allocate drain cells from a planar grid across the vertical dimension. @@ -166,6 +187,14 @@ def allocate_drn_cells( elevation: DataArray | UgridDatarray Planar grid containing drain elevation. Is not allowed to have a layer dimension. + drop_empty_layers: bool, default False + If True, drop layers from the result that contain no allocated + cells anywhere in the domain. This avoids carrying the package's + arrays at full model-layer size through downstream regridding, + clipping, masking, and splitting, which can otherwise become + expensive for models with many layers relative to how many + layers the topsystem package actually occupies. Set to False to + keep the previous full-layer-coordinate behaviour. Returns ------- @@ -181,13 +210,13 @@ def allocate_drn_cells( """ match allocation_option: case ALLOCATION_OPTION.first_active_to_elevation: - return _allocate_cells__first_active_to_elevation( + result = _allocate_cells__first_active_to_elevation( active, top, bottom, elevation )[0] case ALLOCATION_OPTION.at_elevation: - return _allocate_cells__at_elevation(top, bottom, elevation)[0] + result = _allocate_cells__at_elevation(top, bottom, elevation)[0] case ALLOCATION_OPTION.at_first_active: - return _allocate_cells__at_first_active(active, elevation)[0] + result = _allocate_cells__at_first_active(active, elevation)[0] case _: raise ValueError( "Received incompatible setting for drains, only" @@ -197,6 +226,8 @@ def allocate_drn_cells( f"got: '{allocation_option.name}'" ) + return _drop_empty_layers(result) if drop_empty_layers else result + def allocate_ghb_cells( allocation_option: ALLOCATION_OPTION, @@ -263,6 +294,7 @@ def allocate_rch_cells( allocation_option: ALLOCATION_OPTION, active: GridDataArray, rate: GridDataArray, + drop_empty_layers: bool = False, ) -> GridDataArray: """ Allocate recharge cells from a planar grid across the vertical dimension. @@ -279,6 +311,14 @@ def allocate_rch_cells( rate: DataArray | UgridDataArray Array with recharge rates. This will only be used to infer where recharge cells are defined. + drop_empty_layers: bool, default False + If True, drop layers from the result that contain no allocated + cells anywhere in the domain. This avoids carrying the package's + arrays at full model-layer size through downstream regridding, + clipping, masking, and splitting, which can otherwise become + expensive for models with many layers relative to how many + layers the topsystem package actually occupies. Set to False to + keep the previous full-layer-coordinate behaviour. Returns ------- @@ -294,7 +334,7 @@ def allocate_rch_cells( """ match allocation_option: case ALLOCATION_OPTION.at_first_active: - return _allocate_cells__at_first_active(active, rate)[0] + result = _allocate_cells__at_first_active(active, rate)[0] case _: raise ValueError( "Received incompatible setting for recharge, only" @@ -302,6 +342,8 @@ def allocate_rch_cells( f"got: '{allocation_option.name}'" ) + return _drop_empty_layers(result) if drop_empty_layers else result + def _is_layered(grid: GridDataArray): return "layer" in grid.sizes and grid.sizes["layer"] > 1 @@ -537,3 +579,54 @@ def _allocate_cells__at_first_active( topsystem_upper_active = upper_active & ~np.isnan(planar_topsystem_grid) return topsystem_upper_active, None + + +def _drop_empty_layers( + grid: GridDataArray, spatial_dims: tuple[str, ...] = ("y", "x") +) -> GridDataArray: + """ + Drop layers that contain no True/non-nan values in any spatial cell + (and, if present, at any timestep). Keeps the `layer` coordinate but + only for layers that actually contain data - this is what lets + downstream regridding/clipping/masking/splitting operate over a much + smaller layer range when the topsystem package only spans a handful + of the model's total layers. + + Parameters + ---------- + grid: GridDataArray + Array with a "layer" dimension, typically the output of one of the + ``_allocate_cells__*`` functions. + spatial_dims: tuple[str, ...] + Dimensions to reduce over when checking "is this layer used + anywhere". Does not include "layer" or "time" by design - a layer + used at any timestep, anywhere in the domain, is kept. + + Returns + ------- + GridDataArray + Same array, subset to layers with data. + """ + if "layer" not in grid.dims: + return grid + + reduce_dims = [d for d in grid.dims if d != "layer"] + + if grid.dtype == bool: + has_data_per_layer = grid.any(dim=reduce_dims) + else: + has_data_per_layer = (~grid.isnull()).any(dim=reduce_dims) + + # Force to plain numpy/bool to avoid triggering a dask compute deep + # inside indexing logic more than once. + has_data_per_layer = ( + has_data_per_layer.compute() + if hasattr(has_data_per_layer, "compute") + else has_data_per_layer + ) + + if bool(has_data_per_layer.all()): + return grid # nothing to trim, skip the extra indexing op + + used_layers = grid["layer"].where(has_data_per_layer, drop=True) + return grid.sel(layer=used_layers) diff --git a/imod/tests/test_prepare/test_topsystem_layer_preservation.py b/imod/tests/test_prepare/test_topsystem_layer_preservation.py new file mode 100644 index 000000000..084acd2a8 --- /dev/null +++ b/imod/tests/test_prepare/test_topsystem_layer_preservation.py @@ -0,0 +1,358 @@ +# tests/test_prepare/test_topsystem_layer_preservation.py + +import numpy as np +import pytest +import xarray as xr + +from imod.prepare import LayerRegridder +from imod.prepare.topsystem import ( + ALLOCATION_OPTION, + allocate_drn_cells, + allocate_rch_cells, + allocate_riv_cells, +) +from imod.typing import GridDataArray + + +def make_model_grid(n_layers, nrow=10, ncol=10, dx=100.0): + x = np.arange(ncol) * dx + y = np.arange(nrow) * -dx + layer = np.arange(1, n_layers + 1) + + top = xr.DataArray( + np.stack([np.full((nrow, ncol), -float(k)) for k in range(n_layers)]), + {"layer": layer, "y": y, "x": x}, + ("layer", "y", "x"), + ) + bottom = top - 1.0 + active = xr.full_like(top, True, dtype=bool) + return active, top, bottom + + +def make_sparse_riv(nrow=10, ncol=10, dx=100.0, active_layer=1): + """Planar river stage/bottom_elevation - genuinely intersects only + ~1 layer of a deep model.""" + x = np.arange(ncol) * dx + y = np.arange(nrow) * -dx + stage = xr.DataArray(np.full((nrow, ncol), -0.2), {"y": y, "x": x}, ("y", "x")) + bottom_elevation = xr.DataArray( + np.full((nrow, ncol), -0.8), {"y": y, "x": x}, ("y", "x") + ) + return stage, bottom_elevation + + +@pytest.fixture(params=[2, 10, 30]) +def n_layers(request): + return request.param + + +def n_nonempty_layers( + da: GridDataArray, spatial_dims: tuple[str, ...] = ("y", "x") +) -> int: + """ + Number of layers that contain at least one meaningfully "present" value. + + Handles the fact that boolean arrays get upcast to float by xarray's + .where() (NaN has no bool representation) - after such a coercion, + False becomes 0.0, which must still be treated as "no data", not as + a valid float value. + """ + reduce_dims = [d for d in da.dims if d != "layer"] + + if da.dtype == bool: + has_data_per_layer = da.any(dim=reduce_dims) + else: + # Treat both NaN and 0.0 as "no data" - this covers arrays that + # started boolean and were upcast to float by .where()/masking. + is_present = (~da.isnull()) & (da != 0) + has_data_per_layer = is_present.any(dim=reduce_dims) + + return int(has_data_per_layer.sum()) + + +def reindex_to_full_layers( + da: xr.DataArray, full_layer: xr.DataArray, dtype +) -> xr.DataArray: + """ + Re-expand a layer-trimmed result back onto the full model layer + coordinate, so it can be compared against expectations written for + the untrimmed (drop_empty_layers=False) behaviour. + """ + fill_value = False if dtype is bool else np.nan + return da.reindex(layer=full_layer, fill_value=fill_value) + + +def take_nth_layer_column(grid, n): + if "time" in grid.dims: + grid = grid.isel(time=-1) + return grid.values[:, n, n] + + +@pytest.fixture +def basic_riv_inputs(): + nlayer, nrow, ncol = 4, 3, 3 + layer = np.array([1, 2, 3, 4]) + y = np.arange(nrow) * -10.0 + x = np.arange(ncol) * 10.0 + + top = xr.DataArray( + np.stack([np.full((nrow, ncol), -float(k)) for k in range(nlayer)]), + {"layer": layer, "y": y, "x": x}, + ("layer", "y", "x"), + ) + bottom = top - 1.0 + active = xr.full_like(top, True, dtype=bool) + + # Stage/bottom_elevation only intersect layer 1: planar, no layer dim. + stage = xr.DataArray(np.full((nrow, ncol), -0.2), {"y": y, "x": x}, ("y", "x")) + bottom_elevation = xr.DataArray( + np.full((nrow, ncol), -0.8), {"y": y, "x": x}, ("y", "x") + ) + return active, top, bottom, stage, bottom_elevation, layer + + +def test_allocate_riv_cells_drop_empty_layers_matches_full(basic_riv_inputs): + active, top, bottom, stage, bottom_elevation, layer = basic_riv_inputs + + full, _ = allocate_riv_cells( + ALLOCATION_OPTION.stage_to_riv_bot, + active, + top, + bottom, + stage, + bottom_elevation, + drop_empty_layers=False, + ) + trimmed, _ = allocate_riv_cells( + ALLOCATION_OPTION.stage_to_riv_bot, + active, + top, + bottom, + stage, + bottom_elevation, + drop_empty_layers=True, + ) + + # Trimmed result should have fewer (or equal) layers than the full one. + assert trimmed.sizes["layer"] <= full.sizes["layer"] + + # Once re-expanded, trimmed result must be identical to the full one. + re_expanded = reindex_to_full_layers(trimmed, full["layer"], dtype=bool) + xr.testing.assert_equal(re_expanded, full) + + +def test_allocate_drn_cells_drop_empty_layers_matches_full(basic_riv_inputs): + active, top, bottom, _, elevation, layer = basic_riv_inputs + + full = allocate_drn_cells( + ALLOCATION_OPTION.at_elevation, + active, + top, + bottom, + elevation, + drop_empty_layers=False, + ) + trimmed = allocate_drn_cells( + ALLOCATION_OPTION.at_elevation, + active, + top, + bottom, + elevation, + drop_empty_layers=True, + ) + + assert trimmed.sizes["layer"] <= full.sizes["layer"] + re_expanded = reindex_to_full_layers(trimmed, full["layer"], dtype=bool) + xr.testing.assert_equal(re_expanded, full) + + +def test_allocate_rch_cells_drop_empty_layers_matches_full(basic_riv_inputs): + active, _, _, _, _, layer = basic_riv_inputs + nrow, ncol = active.sizes["y"], active.sizes["x"] + rate = xr.DataArray( + np.full((nrow, ncol), 0.001), {"y": active.y, "x": active.x}, ("y", "x") + ) + active2d_active = ( + active # active already has layer dim, at_first_active uses it directly + ) + + full = allocate_rch_cells( + ALLOCATION_OPTION.at_first_active, + active2d_active, + rate, + drop_empty_layers=False, + ) + trimmed = allocate_rch_cells( + ALLOCATION_OPTION.at_first_active, active2d_active, rate, drop_empty_layers=True + ) + + assert trimmed.sizes["layer"] <= full.sizes["layer"] + re_expanded = reindex_to_full_layers(trimmed, full["layer"], dtype=bool) + xr.testing.assert_equal(re_expanded, full) + + +class TestAllocationLayerCount: + def test_allocate_riv_cells_does_not_grow_beyond_real_extent(self, n_layers): + """ + The allocated result may keep the full model `layer` coordinate + (that part is unavoidable, see investigation doc), but the number + of layers that actually contain True values should not depend on + total model layer count - it should stay pinned to how many + layers the stage/bottom_elevation genuinely intersect (here: 1). + """ + active, top, bottom = make_model_grid(n_layers) + stage, bottom_elevation = make_sparse_riv() + + riv_cells, _ = allocate_riv_cells( + ALLOCATION_OPTION.stage_to_riv_bot, + active, + top, + bottom, + stage, + bottom_elevation, + ) + + assert n_nonempty_layers(riv_cells) == 1, ( + "Number of allocated layers should not scale with total model " + f"layers (got layers with True values for n_layers={n_layers})" + ) + + +class TestRegridLayerCount: + def test_regrid_preserves_sparse_layer_count(self, n_layers): + """ + A source array pre-trimmed to 2 real layers should not become + denser after regridding onto a destination grid with n_layers + model layers - only 2 destination layers should end up non-nan. + """ + real_layers = 2 + _, src_top, src_bot = make_model_grid(n_layers) + _, dst_top, dst_bot = make_model_grid(n_layers) # same discretization here + + # Sparse source: only first `real_layers` layers have data, rest all-nan. + source = xr.full_like(src_top, np.nan) + source.values[:real_layers] = 1.0 + + regridder = LayerRegridder(method="mean") + result = regridder.regrid(source, src_top, src_bot, dst_top, dst_bot) + + assert n_nonempty_layers(result) == real_layers, ( + "Regridding introduced extra non-nan layers beyond the " + f"source's real extent (n_layers={n_layers})" + ) + + def test_regrid_sparse_input_matches_dense_input_result(self, n_layers): + """ + Regression guard: regridding a package pre-trimmed to its real + layers should give the same numerical result as regridding the + same package padded out to the full model layer range with nan. + This is the property that allows allocation to safely trim layers + before regridding without changing behaviour. + """ + real_layers = 2 + _, src_top, src_bot = make_model_grid(n_layers) + _, dst_top, dst_bot = make_model_grid(n_layers) + + dense_source = xr.full_like(src_top, np.nan) + dense_source.values[:real_layers] = 1.0 + + sparse_source = dense_source.isel(layer=slice(0, real_layers)) + sparse_top = src_top.isel(layer=slice(0, real_layers)) + sparse_bot = src_bot.isel(layer=slice(0, real_layers)) + + regridder = LayerRegridder(method="mean") + dense_result = regridder.regrid( + dense_source, src_top, src_bot, dst_top, dst_bot + ) + sparse_result = regridder.regrid( + sparse_source, sparse_top, sparse_bot, dst_top, dst_bot + ) + + xr.testing.assert_allclose(dense_result, sparse_result) + + +class TestClipLayerCount: + def test_clip_by_grid_preserves_sparse_layers(self, n_layers): + """ + Clipping a package to a smaller planar extent should not + reintroduce layers that had no data before clipping. + """ + active, top, bottom = make_model_grid(n_layers) + stage, bottom_elevation = make_sparse_riv() + + riv_cells, _ = allocate_riv_cells( + ALLOCATION_OPTION.stage_to_riv_bot, + active, + top, + bottom, + stage, + bottom_elevation, + ) + + # Clip to a smaller planar window. + x_slice = slice(0, 500.0) + y_slice = slice(0.0, -500.0) + clipped = riv_cells.sel(x=x_slice, y=y_slice) + + assert n_nonempty_layers(clipped) <= n_nonempty_layers(riv_cells), ( + "Clipping should never increase the number of non-empty layers" + ) + + +class TestMaskLayerCount: + def test_mask_does_not_densify_layers(self, n_layers): + """ + Masking with idomain (full n_layers) should not turn a + sparse-layer package dense via alignment/broadcasting. + """ + active, top, bottom = make_model_grid(n_layers) + stage, bottom_elevation = make_sparse_riv() + + riv_cells, _ = allocate_riv_cells( + ALLOCATION_OPTION.stage_to_riv_bot, + active, + top, + bottom, + stage, + bottom_elevation, + ) + + idomain = active.astype(int) # full n_layers, all active + masked = riv_cells.where(idomain > 0) + + assert n_nonempty_layers(masked) == n_nonempty_layers(riv_cells), ( + "Masking against a full-layer idomain changed the number of " + "non-empty layers - likely due to alignment/broadcasting" + ) + + +class TestSplitLayerCount: + def test_split_preserves_sparse_layers_per_partition(self, n_layers): + """ + Partitioning by a planar label array should not force a + sparse-layer package to become dense in any partition. + """ + active, top, bottom = make_model_grid(n_layers) + stage, bottom_elevation = make_sparse_riv() + + riv_cells, _ = allocate_riv_cells( + ALLOCATION_OPTION.stage_to_riv_bot, + active, + top, + bottom, + stage, + bottom_elevation, + ) + + # Simple 2-partition planar label: left half / right half. + label = xr.zeros_like(riv_cells.isel(layer=0, drop=True), dtype=int) + ncol = label.sizes["x"] + label[:, ncol // 2 :] = 1 + + for part in [0, 1]: + part_mask = label == part + partitioned = riv_cells.where(part_mask) + assert n_nonempty_layers(partitioned) <= n_nonempty_layers(riv_cells), ( + f"Partition {part} has more non-empty layers than the " + "original unpartitioned array" + ) From abec816bffe1920d9a2807847d67332c5e15cb80 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 31 Aug 2026 14:09:56 +0200 Subject: [PATCH 02/23] add changelog entry --- docs/api/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index a5b40a49a..03684c90f 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -18,6 +18,13 @@ Added :meth:`imod.msw.SprinklingPoints.from_imod5_data`. - :class:`imod.mf6.LayeredWell.from_imod5_cap_data` now also supports loading wells from IPF files in an iMOD5 CAP dataset. +- Added ``drop_empty_layers: bool = False`` to various cell allocation functions + in :mod:`imod.prepare.topsystem.allocation` to remove fully empty layers from the grids. + Setting this to True, strips the empty layers before they are passed along to + reprojection/regridding operations. Which can save considerable time for models with + many empty layers. :meth:`imod.prepare.topsystem.allocation.allocate_riv_cells`, + :meth:`imod.prepare.topsystem.allocation.allocate_drn_cells`, + :meth:`imod.prepare.topsystem.allocation.allocate_rch_cells` Fixed ~~~~~ From 34cf8dfceb037fbaecd9a36b31bd22a20bdbd738 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 31 Aug 2026 14:41:45 +0200 Subject: [PATCH 03/23] remove unused `spatial_dims` arg --- imod/prepare/layerregrid.py | 5 +---- imod/prepare/topsystem/allocation.py | 8 +------- .../test_prepare/test_topsystem_layer_preservation.py | 9 ++------- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/imod/prepare/layerregrid.py b/imod/prepare/layerregrid.py index 46360ea1e..00e4b5c42 100644 --- a/imod/prepare/layerregrid.py +++ b/imod/prepare/layerregrid.py @@ -30,10 +30,7 @@ def _valid_layer_indices(top_col, bot_col, out): @numba.njit(cache=True) def _regrid_layers(src, dst, src_top, dst_top, src_bot, dst_bot, method): - """ - Maps one set of layers onto the other, skipping all-nan layers up front - per column instead of checking nan inside the nested layer loop. - """ + """Maps one set of layers onto the other.""" nlayer_src, nrow, ncol = src.shape nlayer_dst = dst.shape[0] diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index 0f6dc0a1b..2bd12cdd2 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -581,9 +581,7 @@ def _allocate_cells__at_first_active( return topsystem_upper_active, None -def _drop_empty_layers( - grid: GridDataArray, spatial_dims: tuple[str, ...] = ("y", "x") -) -> GridDataArray: +def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: """ Drop layers that contain no True/non-nan values in any spatial cell (and, if present, at any timestep). Keeps the `layer` coordinate but @@ -597,10 +595,6 @@ def _drop_empty_layers( grid: GridDataArray Array with a "layer" dimension, typically the output of one of the ``_allocate_cells__*`` functions. - spatial_dims: tuple[str, ...] - Dimensions to reduce over when checking "is this layer used - anywhere". Does not include "layer" or "time" by design - a layer - used at any timestep, anywhere in the domain, is kept. Returns ------- diff --git a/imod/tests/test_prepare/test_topsystem_layer_preservation.py b/imod/tests/test_prepare/test_topsystem_layer_preservation.py index 084acd2a8..d6de6873e 100644 --- a/imod/tests/test_prepare/test_topsystem_layer_preservation.py +++ b/imod/tests/test_prepare/test_topsystem_layer_preservation.py @@ -1,5 +1,3 @@ -# tests/test_prepare/test_topsystem_layer_preservation.py - import numpy as np import pytest import xarray as xr @@ -46,11 +44,8 @@ def n_layers(request): return request.param -def n_nonempty_layers( - da: GridDataArray, spatial_dims: tuple[str, ...] = ("y", "x") -) -> int: - """ - Number of layers that contain at least one meaningfully "present" value. +def n_nonempty_layers(da: GridDataArray) -> int: + """Number of layers that contain at least one meaningfully "present" value. Handles the fact that boolean arrays get upcast to float by xarray's .where() (NaN has no bool representation) - after such a coercion, From c39005a72222e6e3d247a85b99843182f7d53efa Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 28 Sep 2026 12:09:49 +0200 Subject: [PATCH 04/23] set default for drop_empty_layers to True and update tests. Fixed a bug in 'imod.prepare.cleanup.align_interface_levels' to prevent raising alignment error during cleanup. --- docs/api/changelog.rst | 3957 +++++++++++---------- imod/mf6/drn.py | 707 ++-- imod/mf6/ghb.py | 709 ++-- imod/mf6/rch.py | 638 ++-- imod/mf6/riv.py | 1094 +++--- imod/mf6/topsystem.py | 367 +- imod/prepare/cleanup.py | 808 ++--- imod/prepare/topsystem/allocation.py | 137 +- imod/tests/test_mf6/test_mf6_drn.py | 1595 +++++---- imod/tests/test_mf6/test_mf6_ghb.py | 522 +-- imod/tests/test_mf6/test_mf6_rch.py | 1363 +++---- imod/tests/test_mf6/test_mf6_riv.py | 1927 +++++----- imod/tests/test_prepare/test_cleanup.py | 714 ++-- imod/tests/test_prepare/test_topsystem.py | 1382 +++---- 14 files changed, 8218 insertions(+), 7702 deletions(-) diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index 03684c90f..dfc659cf5 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -1,1971 +1,1986 @@ -Changelog -========= - -All notable changes to this project will be documented in this file. - -The format is based on `Keep a Changelog`_, and this project adheres to -`Semantic Versioning`_. - -[Unreleased] ------------- - -Added -~~~~~ - -- Experimental class :class:`imod.msw.SprinklingPoints` to specify sprinkling - from points for MetaSWAP models, instead of from grid. You can use this to - specify sprinkling wells from IPF files in an iMOD5 CAP dataset with - :meth:`imod.msw.SprinklingPoints.from_imod5_data`. -- :class:`imod.mf6.LayeredWell.from_imod5_cap_data` now also supports loading - wells from IPF files in an iMOD5 CAP dataset. -- Added ``drop_empty_layers: bool = False`` to various cell allocation functions - in :mod:`imod.prepare.topsystem.allocation` to remove fully empty layers from the grids. - Setting this to True, strips the empty layers before they are passed along to - reprojection/regridding operations. Which can save considerable time for models with - many empty layers. :meth:`imod.prepare.topsystem.allocation.allocate_riv_cells`, - :meth:`imod.prepare.topsystem.allocation.allocate_drn_cells`, - :meth:`imod.prepare.topsystem.allocation.allocate_rch_cells` - -Fixed -~~~~~ - -- Fixed resampling in :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` when simulation timesteps precede - the first well timestep. - -Changed -~~~~~~~ - -- Deprecated :class:`imod.msw.Sprinkling` in favor of - :class:`imod.msw.SprinklingGrid`. Call :class:`imod.msw.SprinklingGrid` to get - the same behavior as you were used to. - -[1.1.0] - 2026-08-03 --------------------- - -Added -~~~~~ - -- Added ``ignore_time_purge_empty`` argument to - :meth:`imod.mf6.Modflow6Simulation.mask_all_models` and - :meth:`imod.mf6.Modflow6Simulation.clip_box` to consider a package empty if - its first times step is all nodata. This can save a lot of clipping or masking - transient models with many timesteps. -- Added :meth:`imod.msw.MetaSwapModel.split` to split MetaSWAP models. -- Added :meth:`imod.mf6.HorizontalFlowBarrierResistance.from_imod5_data` to load - barriers from 3D GEN files. -- Added ``name`` argument to :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` - to provide custom name to imported simulation and model. -- Added :meth:`imod.msw.MetaSwapModel.mask_all_packages` to mask all packages of - a MetaSWAP model. -- Added optional ``target_grid`` argument to - :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, - :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data`, - :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` to specify a target - grid for regridding the iMOD5 data to. If not provided, the first IBOUND layer - is used as target grid, like in iMOD5. - -Fixed -~~~~~ - -- Fixed bug in :class:`imod.mf6.GroundwaterFlowModel` and :class:`imod.formats.prf.IpfResult` - where names of wels were duplicated by increasing the character limit to 40 - and enumerating wel names. -- Fixed bug where :class:`imod.mf6.Evapotranspiration` package would write files - to binary, which could not be parsed by MODFLOW 6 when ``proportion_depth`` - and ``proportion_rate`` were provided without segments. -- Fixed bug where :class:`imod.mf6.ConstantConcentration` package could not be written - for multiple timesteps. -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` where a ValidationError - was thrown when clipping a model with a :class:`imod.mf6.ConstantHead` or - :class:`imod.mf6.ConstantConcentration` package with a ``time`` dimension and - providing ``states_for_boundary``. -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` would drop layers if - ``states_for_boundary`` were provided and the model already contained a - :class:`imod.mf6.ConstantHead` or :class:`imod.mf6.ConstantConcentration` with - less layers. -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` would not properly - align timesteps and forward fill data if ``states_for_boundary`` were provided - and the model already contained a :class:`imod.mf6.ConstantHead` or - :class:`imod.mf6.ConstantConcentration`, both with timesteps, which were - unaligned. -- Fixed bug where :class:`imod.mf6.Lake` package did not pass ``budgetfile``, - ``budgetcsvfile``, ``stagefile`` options to the written MODFLOW 6 package. -- Fixed bug where :func:`imod.evaluate.convert_pointwaterhead_freshwaterhead` - produced incorrect results when point water heads were below elevation levels - for unstructured grids. -- Support pandas 3.0. -- :class:`imod.msw.IdfMapping` when model clipping is applied, the global - row/column indices are converted to local indices, as written in - ``idf_svat.inp``. -- Fixed edge case where allocation of :class:`imod.mf6.River` package with the - ``stage_to_riv_bot`` or ``stage_to_riv_bot_drn_above`` option of - :func:`imod.prepare.ALLOCATION_OPTION` would assign river cells to the wrong - layer, when the stage and bottom_elevation were exactly equal to the bottom of - a layer in the model discretization, which would cause these cells to be - dropped when distributing conductances later. -- Fixed :func:`imod.prepare.spatial.polygonize` for polygons with holes. -- :func:`imod.formats.prj.open_projectfile_data` now drops empty wells from the - dataset, and logs a warning about it. -- :meth:`imod.mf6.NodePropertyFlow.regrid_like` now regrids ``k33`` using the - correct method, namely ``mean`` instead of ``harmonic_mean``. As this is the - appropriate method for horizontal regridding of ``k33``. -- :meth:`imod.msw.MetaSwapModel.from_imod5_data`, - :meth:`imod.mf6.Recharge.from_imod5_cap_data`, - :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` now regrids the iMOD5 CAP - data to the MODFLOW6 target discretization. -- Fixed confusing warning about inconsistent IPF columns when loading GEN files. -- Fix bug where iMOD Python would error on writing a model where package - settings were specified as dask array, which could happen when loading a model - lazily with :meth:`imod.mf6.Modflow6Simulation.from_file` and not - computing the data before writing. -- Fixed bug where ``concentration`` variables were needlessly loaded into - memory. Affected :class:`imod.mf6.River`, :class:`imod.mf6.Drain`, - :class:`imod.mf6.ConstantHeadBoundary`, :class:`imod.mf6.Recharge`, - :class:`imod.mf6.Well` and :class:`imod.mf6.GeneralHeadBoundary`. -- Fix bug where ``maxbound`` of the ``.wel`` file computed by - :class:`imod.mf6.Mf6Wel` was twice or thrice too large. -- Fixed bug where :func:`imod.evaluate.facebudget` raised an error when the - ``front`` budget was left out, even though you only need to provide one of - ``front``, ``lower`` or ``right``. Leaving out ``front`` now works as - described in the documentation. -- Fixed big performance degradation with :func:`imod.idf.open_subdomains` where - it would take a long time to open lots of idf files. Performance is now - significantly improved up to the same speed as before the change that caused - the performance degradation. - -Changed -~~~~~~~ - -- Increased the character limit to 40 in :class:`imod.mf6.Modflow6Model` for all - keys assigned to a Modflow6 model. -- ``proportion_depth`` and ``proportion_rate`` in - :class:`imod.mf6.Evapotranspiration` are now optional variables. If provided, - now require ``"segment"`` dimension when ``proportion_depth`` and - ``proportion_rate``. -- :meth:`imod.msw.GridData.generate_index_array` is now deprecated, use - :meth:`imod.msw.GridData.generate_isactive_svat_arrays` instead. -- If no ``target_grid`` is provided, - :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` chooses a grid the - same as iMOD5 did: the first IBOUND layer. This is different from previous - versions of iMOD Python, which defaulted to the smallest possible extent - and finest resolution, based on the iMOD5 IBOUND, TOP and BOTTOM data. - - -[1.0.0] - 2025-11-11 --------------------- - -Fixed -~~~~~ - -- Improved performance of :meth:`imod.mf6.Modflow6Simulation.split` for large - models loaded lazily into memory. Reduced a splitting operation of 2 hours to - a few minutes for a test case. -- Issue where :meth:`imod.mf6.LayeredWell.from_imod5_data` would result in wells - with a mismatch between coordinates and rates. - -Added -~~~~~ - -- Added :class:`imod.mf6.Viscosity` package to specify the viscosity of the - groundwater flow model. -- Functionality to dump and load MODFLOW 6 simulations to/from zarr and zipstore - formats. See :meth:`imod.mf6.Modflow6Simulation.dump` and - :meth:`imod.mf6.Modflow6Simulation.from_file` for more information. -- Functionality to dump and load MetaSwap models to/from netcdf - format. See :meth:`imod.msw.MetaSwapModel.dump` and - :meth:`imod.msw.MetaSwapModel.from_file` for more information. - -Changed -~~~~~~~ - -- :class:`imod.mf6.Well` and :func:`imod.prepare.assign_wells` now distribute - the well rates over the screened cells using a correction factor based on the - mismatch between the well screen center and the cell center, equal to iMOD5's - correction factor. - - -[1.0.0rc7] - 2025-10-28 ------------------------ - -Added -~~~~~ - -- :meth:`imod.mf6.Modflow6Simulation.set_validation_settings` to set validation - settings for a MODFLOW 6 simulation. See :class:`imod.mf6.ValidationSettings` - for more information. - -Changed -~~~~~~~ - -- No automatic validation upon calling :meth:`imod.mf6.Modflow6Simulation.regrid_like` anymore. - Use the ``validate`` argument of :meth:`imod.mf6.Modflow6Simulation.write` to - validate the regridded model upon writing instead. -- :class:`imod.mf6.River` now ignore confined cells (``icelltype == 0``) when - validating whether the river bottom elevation is below the model bottom - elevation. -- Moved :func:`imod.select.get_upper_active_layer_number`, - :func:`imod.select.get_upper_active_cells`, - :func:`imod.select.get_lower_active_cells`, and - :func:`imod.select.get_lower_active_layer_number` from :mod:`imod.prepare`. to - :mod:`imod.select`. -- :class:`imod.mf6.Dispersion` now is not a required package for - :class:`imod.mf6.GroundwaterTransportModel` anymore. -- No validation anymore for ``icelltype`` upon writing - :class:`imod.mf6.SpecificStorage` and :class:`imod.mf6.StorageCoefficient`. - - -Removed -~~~~~~~ - -- Removed ``imod.select.upper_active_layer`` function, use - :func:`imod.select.get_upper_active_layer_number` instead. - -Fixed -~~~~~ - -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.split` could result in - empty exchanges being present in the ``split_exchanges`` package list, when - two models were isolated by inactive cells from each other. These empty - exchanges are now removed. -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.split`, - :meth:`imod.mf6.Modflow6Simulation.regrid_like`, and - :meth:`imod.mf6.Modflow6Simulation.clip_box` would not copy - :class:`imod.mf6.ValidationSettings`. -- ``landuse``, ``soil_physical_unit``, ``active`` for :class:`imod.msw.GridData` - are now properly regridded with the ``mode`` statistic when using - :meth:`imod.msw.GridData.regrid_like`. - -[1.0.0rc6] - 2025-08-28 ------------------------ - -Small post-release to fix rendering of documentation online. - -[1.0.0rc5] - 2025-08-27 ------------------------ - -Added -~~~~~ - -- :meth:`imod.mf6.River.reallocate`, :meth:`imod.mf6.Drainage.reallocate`, - :meth:`imod.mf6.GeneralHeadBoundary.reallocate`, - :meth:`imod.mf6.Recharge.reallocate` to reallocate the package data to a new - discretization or :class:`imod.mf6.NodePropertyFlow` package, or to use a - different :class:`imod.prepare.ALLOCATION_OPTION` or - :class:`imod.prepare.DISTRIBUTING_OPTION`. -- Added :meth:`imod.mf6.HorizontalFlowBarrierResistance.snap_to_grid` and - :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.snap_to_grid` to - debug how horizontal flow barriers are snapped to a grid. -- Added :meth:`imod.mf6.Modflow6Simulation.create_partition_labels` to create - partition labels for a MODFLOW 6 simulation from its idomain. This is useful - for splitting a simulation into multiple submodels. -- :class:`imod.mf6.AdaptiveTimeStepping` to specify adaptive time stepping - settings for MODFLOW 6 simulations. -- The ``ats_percel`` argument to :class:`imod.mf6.AdvectionTVD`, - :class:`imod.mf6.AdvectionUpstream`, :class:`imod.mf6.AdvectionCentral` to - adapt the time step based on the maximum fraction of a cell that a solute - parcel is allowed to travel. - -Fixed -~~~~~ - -- Reduce noisy warnings in models loaded with - :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` which have layers with - cells with zero thicknesses. -- Issue where regridding lead to excessively large inactive areas. -- Issue where regridding would lead to very large negative integer values (like - IDOMAIN) for inactive areas. -- Issue where :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` would throw a KeyError 0 upon - trying to resample timeseries with a non-zero index. -- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB - packages would have resistances that were double the expected value with - xugrid >= 0.14.2 -- The ``states_for_boundary`` argument now also works for tranport models in - :meth:`imod.mf6.Modflow6Simulation.clip_box`. -- Fix bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` and the - ``states_for_boundary`` argument would place these bc at the - incorrect places with unstructured grids. -- Fixed bug where :meth:`imod.mf6.SourceSinkMixing.from_flow_model` would return - an error upon adding a package which cannot have a ``concentration``, such as - :class:`imod.mf6.HorizontalFlowBarrierResistance`. -- Broken names for ``outer_csvfile`` and ``inner_csvfile`` in the - :class:`imod.mf6.Solution` MODFLOW 6 template file. - -Changed -~~~~~~~ - -- :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` and - :meth:`imod.mf6.NodePropertyFlow.from_imod5_data` now automatically load the - dataset into memory. This improves performance when loading models with - multiple topsystem packages. -- No upper limit anymore for ``mod_id`` in ``mod2svat.inp`` for - :class:`imod.msw.CouplerMapping`. -- :func:`imod.prepare.create_partition_labels` now takes an ``idomain`` grid - instead of :class:`imod.mf6.Modflow6Simulation` as first argument. To generate - partition labels from a :class:`imod.mf6.Modflow6Simulation` straightaway, use - the newly added :meth:`imod.mf6.Modflow6Simulation.create_partition_labels` - method instead. - -Removed -~~~~~~~ - -- Removed ``imod.mf6.WellDisStructured`` and ``imod.mf6.WellDisVertices``. Use - :class:`imod.mf6.Well` and :class:`imod.mf6.LayeredWell` instead. The - :class:`imod.mf6.Well` package can be used to specify wells with filters, - :class:`imod.mf6.LayeredWell` directly to layers. -- Removed ``imod.mf6.multimodel.partition_generator.get_label_array``, use - :func:`imod.prepare.create_partition_labels` instead. -- Removed ``imod.formats.idf.read`` use :func:`imod.formats.idf.open` instead. -- Removed ``imod.formats.rasterio.read`` use :func:`imod.formats.rasterio.open` instead. -- Removed ``head`` argument for :class:`imod.mf6.InitialConditions`, use - ``start`` instead. -- Removed ``cell_averaging`` argument for :class:`imod.mf6.NodePropertyFlow`, - use ``alternative_cell_averaging`` instead. -- Removed ``set_repeat_stress`` method from boundary condition packages like - :class:`imod.mf6.River`. Use ``repeat_stress`` argument instead. -- Removed ``time_discretization`` method from - :class:`imod.mf6.Modflow6Simulation` and :class:`imod.wq.SeawatModel`. Use - :meth:`imod.mf6.Modflow6Simulation.create_time_discretization` and - :meth:`imod.wq.SeawatModel.create_time_discretization` instead. -- Removed ``imod.util.round_extent``, use :func:`imod.prepare.round_extent` - instead. -- Removed :class:`imod.prepare.Regridder`. Use the `xugrid regridder - `_ instead. - - -[1.0.0rc4] - 2025-06-20 ------------------------ - -Added -~~~~~ - -- Added ``weights`` argument to :func:`imod.prepare.create_partition_labels` to - weigh how the simulation should be partioned. Areas with higher weights will - result in smaller partions. -- iMOD Python version is now written in a comment line to MODFLOW6 and - MetaSWAP's ``para_sim.inp`` files. This is useful for debugging purposes. -- Added option ``ignore_time_purge_empty`` to - :meth:`imod.mf6.Modflow6Simulation.split` to consider a package empty if its - first times step is all nodata. This can save a lot of time splitting - transient models. -- Add :class:`imod.mf6.ValidationSettings` to specify validation settings for - MODFLOW 6 simulations. You can provide it to the - :class:`imod.mf6.Modflow6Simulation` constructor. - -Fixed -~~~~~ - -- Upon providing an unexpected coordinate in the mask or regridding grid, - :meth:`imod.mf6.Modflow6Simulation.regrid_like` and - :meth:`imod.mf6.Modflow6Simulation.mask_all_models` now present the unexpected - coordinates in the error message. -- :class:`imod.mf6.VerticesDiscretization` now correctly sets the ``xorigins`` - and ``yorigins`` options in the ``.disv`` file. Incorrect origins cause issues - when splitting models and computing with XT3D on the exchanges. -- :func:`imod.mf6.open_cbc` and :func:`imod.mf6.open_hds` now account for - xorigins and yorigins for models ran with - :class:`imod.mf6.VerticesDiscretization`. **WARNING**: Given that these were - set incorrectly in previous versions of iMOD Python (see previous item in this - list), this means that reading MODFLOW6 DISV output of models generated with a - previous version of iMOD Python will result in a grid with an erroneous - offset. You can work around this by creating the model again with this - version of iMOD Python or newer. -- :meth:`imod.mf6.Modflow6Simulation.split` supports label array with a - different name than ``"idomain"``. -- :func:`imod.msw.MetaSwapModel.from_imod5_data` now supports the usage of - relative paths for the extra files block. -- Bug in :meth:`imod.msw.Sprinkling.write` where MetaSWAP svats with surface - water sprinkling and no groundwater sprinkling activated were not written to - ``scap_svat.inp``. -- :class:`imod.msw.IdfMapping` swapped order of y_grid and x_grid in dictionary - for writing the correct order of coordinates in idf_svat.inp. -- Improved performance of :meth:`imod.mf6.Modflow6Simulation.split` and - :meth:`imod.mf6.Modflow6Simulation.mask_all_models` when using dask. -- Fixed bug in :meth:`imod.mf6.Modflow6Simulation.mask_all_models` for unstructured grids - with a spatial dimension that differs from the default ``"mesh2d_nFaces"``. -- Fixed bug in :meth:`imod.mf6.Well.cleanup` and - :meth:`imod.mf6.LayeredWell.cleanup` which caused an error when called with an - unstructured discretization. -- Fixed bug in :func:`imod.formats.prj.open_projectfile_data` which caused an - error when a periods keyword was used having an upper case. -- Poor performance of :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` when the ``imod5_data`` contained - a well system with a large number of wells (>10k). -- :meth:`imod.mf6.River.from_imod5_data`, - :meth:`imod.mf6.Drainage.from_imod5_data`, - :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data` can now deal with - constant values for variables. One variable per package still needs to be a - grid. -- Fix bug where an error was thrown in ``get_non_grid_data`` when calling the - ``.cleanup`` and ``regrid_like`` methods on a boundary condition package with - a repeated stress. For example, :meth:`imod.mf6.River.cleanup` or - :meth:`imod.mf6.River.regrid_like`. -- Fix bug where an error was thrown in :class:`imod.mf6.Well` when an entry had - to be filtered and its ``id`` didn't match the index. -- Improved performance of :class:`imod.mf6.Modflow6Simulation.split` for - structured models, as unnecessary masking is avoided. -- Fixed warning thrown by type dispatcher about ``~GeoDataFrameType`` -- Fixed bug where variables in a package with only a ``"layer"`` coordinate - could not be regridded or masked. - -Changed -~~~~~~~ - -- :meth:`imod.wq.SeawatModel.write` now throws an error if trying to write in a - directory with a space in the path. (iMOD-WQ does not support this.) -- `imod.mf6.multimodel.partition_generator.get_label_array` moved to - :func:`imod.prepare.create_partition_labels`. -- :func:`imod.prepare.create_partition_labels` structured grids are now - partioned by METIS instead (just like already was the case for unstructured - grids). This results in more balanced partitions for grids with non-square - domains or lots of inactive cells. Downside is that the partitions are more - often than not perfectly rectangular in shape. -- :func:`imod.prepare.create_partition_labels` now returns a griddata with the - name ``"label"`` instead of ``"idomain"``. -- Upon providing the wrong type to one of the options of - :class:`imod.mf6.GroundwaterFlowModel`, - :class:`imod.mf6.GroundwaterTransportModel`, this will throw a - ``ValidationError`` upon initialization and writing. -- You can now also provide ``repeat_stress`` as dictionary to imod.mf6 - boundary conditions, such as :class:`imod.mf6.River`, :class:`imod.mf6.Drainage`, and - :class:`imod.mf6.GeneralHeadBoundary`. -- :meth:`imod.mf6.ConstantHead.from_imod5_data`, - :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data`, - :meth:`imod.mf6.River.from_imod5_data`, - :meth:`imod.mf6.Recharge.from_imod5_data`, and - :meth:`imod.mf6.Drainage.from_imod5_data` now forward fill data over time, - instead of clipping, when selecting a start time that is inbetween two data - records. -- :meth:`imod.mf6.ConstantHead.from_imod5_data` and - :meth:`imod.mf6.Recharge.from_imod5_data` got extra arguments for - ``period_data``, ``time_min`` and ``time_max``. -- :func:`imod.visualize.read_imod_legend` now also returns the labels as an extra - argument. Update your code by changing - ``colors, levels = read_imod_legend(...)`` to - ``colors, levels, labels = read_imod_legend(...)``. - - -[1.0.0rc3] - 2025-04-17 ------------------------ - -Added -~~~~~ - -- :meth:`imod.msw.MetaSwapModel.clip_box` to clip MetaSWAP models. -- Methods of class :class:`imod.mf6.Modflow6Simulation` can now be logged. -- :func:`imod.prepare.cleanup.cleanup_wel_layered` to clean up wells assigned - to layers. - - -Fixed -~~~~~ - -- Fixed bug where :meth:`imod.mf6.River.clip_box`, - :meth:`imod.mf6.Drainage.clip_box`, and - :meth:`imod.mf6.GeneralHeadBoundary.clip_box` threw an error when - ``time_start`` or ``time_end`` were set to ``None`` and a ``"repeat_stress"`` - was included in the dataset. -- Fixed bug where :meth:`imod.mf6.package.copy` threw an error. -- Sorting issue in :func:`imod.prepare.assign_wells`. This could cause - :class:`imod.mf6.Well` to assign wells to the wrong cells. -- Fixed crash upon calling :meth:`imod.mf6.Well.clip_box` when the top/bottom - arguments are specified. This could cause :class:`imod.mf6.Well` to crash - when wells are located outside the extent of the layer model. - - -[1.0.0rc2] - 2025-03-05 ------------------------ - -From this release on, we recommend using `xugrid's regridding utilities -`_ for -regridding individual grids instead of :class:`imod.prepare.Regridder`. Xugrid's -regridders are tested to be about 10 times faster than -:class:`imod.prepare.Regridder`. There is one small difference: xugrid's -``xugrid.BaryCentricInterpolator`` considers sample points of the destination -grid that lie on the source grid's cell edges to be inside, whereas -:class:`imod.prepare.Regridder` considers them to be outside. This difference is -negligible for most applications, but might create slightly fewer ``np.nan`` -values than before. - -Removed -~~~~~~~ -- ``imod.flow`` module has been removed for generating iMODFLOW models. Use - ``imod.mf6`` instead to generate MODFLOW 6 models. - -Added -~~~~~ - -- Support for Python 3.13. -- :meth:`imod.mf6.Recharge.from_imod5_data`, - :meth:`imod.mf6.River.from_imod5_data`, - :meth:`imod.mf6.Drainage.from_imod5_data`, and - :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data` now assign negative layer - numbers to the first active layer. -- :func:`imod.prepare.DISTRIBUTING_OPTION` got a new setting - ``by_corrected_thickness``. This matches DISTRCOND=-1 in iMOD5. -- :func:`imod.prepare.cleanup_hfb` to clean up HFB geometries. -- :meth:`imod.mf6.HorizontalFlowBarrierResistance.cleanup`, - :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.cleanup`, - to clean up HFB geometries crossing inactive model cells. -- :class:`imod.util.RegridderWeightsCache` to store regridder weights for - regridding multiple times. -- :class:`imod.util.RegridderType` to specify regridder types. - -Changed -~~~~~~~ - -- :func:`imod.formats.prj.open_projectfile_data` now also assigns negative and - zero layer numbers to grid coordinates. -- In :class:`imod.mf6.StructuredDiscretization`, IDOMAIN can now respectively be - > 0 to indicate an active cell and <0 to indicate a vertical passthrough cell, - consistent with MODFLOW 6. Previously this could only be indicated with 1 and - -1. -- :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` now also accept the argument - ``times = "steady-state"``, for the simulation is assumed to be "steady-state" - and well timeseries are averaged. -- The ``drn`` attribute of :class:`imod.prepare.SimulationAllocationOptions` has - the ``at_elevation`` of :func:`imod.prepare.ALLOCATION_OPTION` option now set - as default. This means by default drainage cells are placed differently in - :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`. -- :class:`imod.mf6.Well`, :class:`imod.mf6.LayeredWell`, - :func:`imod.prepare.assign_wells`, :meth:`imod.mf6.Well.from_imod5_data` - and :meth:`imod.mf6.LayeredWell.from_imod5_data` now have default values for - ``minimum_thickness`` and ``minimum_k`` set to 0.0. -- When intitating a MODFLOW 6 package with a ``layer`` coordinate with - values <= 0, iMOD Python will throw an error. -- :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB now - validate whether proper type of geometry is provided, respectively Polygon for - :class:`imod.mf6.HorizontalFlowBarrierResistance`, and LineString for - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance`. -- Relaxed validation for :class:`imod.msw.MetaSwapModel` if ``FileCopier`` - package is present. -- Change aterisk to dash and tabs to four spaces in ``ValidationError`` messages. -- :func:`imod.prepare.laplace_interpolate` has been simplified, using - ``scipy.sparse.linalg.cg`` as the backend. We've remove the support for the - ``ibound`` argument, the ``iter1`` argument has been dropped, ``mxiter`` has - been renamed to ``maxiter``, ``close`` has been renamed to ``rtol``. -- Moved ``imod.mf6.utilities.regrid.RegridderWeightsCache`` to the - :class:`imod.util.regrid.RegridderWeightsCache`. - -Fixed -~~~~~ - -- :meth:`imod.mf6.GroundwaterFlowModel.mask_all_packages` now preserves the ``dx`` and - ``dy`` coordinates -- :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` ignore well rates preceding first - element of ``times``. -- :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` now sum the rates of well entries - that are on the exact same location (same x, y, and depth) instead of taking - the values of the first entry. -- :meth:`imod.mf6.River.from_imod5_data` now preserves the drainage cells - created with the ``stage_to_riv_bot_drn_above`` option of - :func:`imod.prepare.ALLOCATION_OPTION`. -- Bug in :func:`imod.prepare.distribute_riv_conductance` where conductances were - set to ``np.nan`` for cells where ``stage`` equals ``bottom_elevation`` when - :func:`imod.prepare.DISTRIBUTING_OPTION` was set to ``by_crosscut_thickness``, - ``by_crosscut_transmissivity``, ``by_corrected_transmissivity``. -- :meth:`imod.mf6.NodePropertyFlow.from_imod5_data` now defaults to 90 degrees - for missing layers ``imod5_data`` instead of 0 degrees. -- Bug in :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` where an error was - raised in case the ``"cap"`` package was present in the ``imod5_data``. -- Bug where :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` and - :meth:`imod.mf6.Recharge.from_imod5_cap_data` threw an error if the ``"cap"`` - in the ``imod5_data`` had a ``"layer"`` dimension and coordinate. -- :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` will convert the - ``max_abstraction_groundwater`` and ``max_abstraction_surfacewater`` capacity - from mm/d to m3/d. -- :class:`imod.msw.TimeOutputControl` now starts counting at 0.0 instead of 1.0, - like MetaSWAP expects. -- Models imported with :meth:`imod.msw.MetaSwapModel.from_imod5_data` can be - written with ``validate`` set to True. -- :meth:`imod.mf6.Recharge.from_imod5_cap_data` now returns a 2D array with a - ``"layer"`` coordinate of ``1`` as otherwise ``primod`` throws an error when - trying to derive recharge-svat mappings. -- Fixed part of the code that made Pandas, Geopandas, and xarray throw a lot of - ``FutureWarning`` and ``DeprecationWarning``. -- Fixed performance issue when converting very large wells (>10k) with - :meth:`imod.mf6.Well.to_mf6_pkg` and :meth:`imod.mf6.LayeredWell.to_mf6_pkg`, - such as those created with :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` - for a large grid. -- Fixed issue where an error was thrown when deriving couplings for - :class:`imod.msw.CouplerMapping` and computing svats in - :class:`imod.msw.GridData` with ``dask>=2025.2.0``. -- Fixed a bug where :func:`imod.mf6.out.open_cbc` did not properly sum fluxes - for a single boundary condition package when multiple entries were present in - the same cell. This never happened with models generated by iMOD Python, as it - cannot generate these boundary conditions, but could be a problem with models - generated by iMOD5 and Flopy. -- Removed duplicate entries in ``mod2svat.inp`` generated by - :class:`imod.msw.CouplerMapping` as MetaSWAP cannot handle this. - - -[1.0.0rc1] - 2024-12-20 ------------------------ - -Small post-release fix for installation instructions in documentation. - -[1.0.0rc0] - 2024-12-20 ------------------------ - -Added -~~~~~ - -- :class:`imod.msw.MeteoGridCopy` to copy existing `mete_grid.inp` files, so - ASCII grids in large existing meteo databases do not have to be read. -- :class:`imod.msw.FileCopier` to copy settings and lookup tables in existing - ``.inp`` files. -- :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` to construct a - :class:`imod.mf6.LayeredWell` package from iMOD5 data in the CAP package (for - MetaSWAP). Currently only griddata (IDF) is supported. -- :meth:`imod.mf6.Recharge.from_imod5_cap_data` to construct a recharge package - for coupling a MODFLOW 6 model to MetaSWAP. -- :meth:`imod.msw.MetaSwapModel.from_imod5_data` to construct a MetaSWAP model - from data in an iMOD5 projectfile. -- :meth:`imod.msw.MetaSwapModel.write` has a ``validate`` argument, which can be - used to turn off validation upon writing, use at your own risk! -- :class:`imod.msw.MetaSwapModel` got ``settings`` argument to set simulation - settings. -- :func:`imod.data.tutorial_03` to load data for the iMOD Documentation - tutorial. -- :meth:`imod.mf6.Modflow6Simulation.dump` now saves iMOD Python version number. - -Fixed -~~~~~ - -- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB - packages could not be allocated to cell edges when idomain in layer 1 was - largely inactive. -- Fixed bug where :meth:`imod.mf6.HorizontalFlowBarrierResistance.clip_box`, - :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.clip_box` methods - only returned deepcopy instead of actually clipping the line geometries. -- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB - packages could not be clipped or copied with xarray >= 2024.10.0. -- Fixed crash upon calling :meth:`imod.mf6.GroundwaterFlowModel.dump`, when a - :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` or other HFB - package was assigned to the model. -- :meth:`imod.mf6.Modflow6Simulation.regrid_like` can now regrid a structured - model to an unstructured grid. -- :meth:`imod.mf6.Modflow6Simulation.regrid_like` throws a - ``NotImplementedError`` when attempting to regrid an unstructured model to a - structured grid. -- :class:`imod.msw.Sprinkling` now correctly writes source svats to - scap_svat.inp file. -- :func:`imod.evaluate.calculate_gxg`, upon providing a head dataarray chunked - over time, will no longer error with ``ValueError: Object has inconsistent - chunks along dimension bimonth. This can be fixed by calling unify_chunks().`` -- Improved performance of regridding package data. - - -Changed -~~~~~~~ - -- :class:`imod.msw.Infiltration`'s variables ``upward_resistance`` and - ``downward_resistance`` now require a ``subunit`` coordinate. -- Variables ``max_abstraction_groundwater`` and ``max_abstraction_surfacewater`` - in :class:`imod.msw.Sprinkling` now needs to have a subunit coordinate. -- If ``"cap"`` package present in ``imod5_data``, - :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data` now automatically adds a - well for metaswap sprinkling named ``"msw-sprinkling"`` -- Less strict validation for :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB packages for - simulations which are imported with - :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` -- DeprecationWarning thrown upon initializing :class:`imod.prepare.Regridder`. - We plan to remove this object in the final 1.0 release. `Use the xugrid - regridder to regrid individual grids instead. - `_ To - regrid entire MODFLOW 6 packages or simulations, `see the user guide here. - `_. - -[0.18.1] - 2024-11-20 ---------------------- - -Added -~~~~~ - -- :class:`imod.prepare.SimulationAllocationOptions`, - :class:`imod.prepare.SimulationDistributingOptions`, which are used to store - default allocation and distributing options respectively. - -Fixed -~~~~~ - -- Relaxed validation for `imod.mf6.StructuredDiscretization` to also support - cells with zero thickness where IDOMAIN = 0. Before, only cells with a zero - thickness and IDOMAIN = -1 were supported, else the software threw a ``not all - values comply with criterion: > bottom``. -- Fix bug where no ``ValidationError`` was thrown if there is an active RCH, DRN, - GHB, or RIV cell where idomain = -1. - -Changed -~~~~~~~ - -- In :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, and - :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data` the arguments - ``allocation_options``, ``distributing_options`` are now optional. -- The order of arguments of :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, - and :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data`. It now is - ``imod5_data, period_data, times, allocation_options, distributing_options, regridder_types`` - instead of: - ``imod5_data, period_data, allocation_options, distributing_options, times, regridder_types`` - - -[0.18.0] - 2024-11-11 ---------------------- - -Fixed -~~~~~ - -- Multiple ``HorizontalFlowBarrier`` objects attached to - :class:`imod.mf6.GroundwaterFlowModel` are merged into a single horizontal - flow barrier for MODFLOW 6. -- Bug where error would be thrown when barriers in a ``HorizontalFlowBarrier`` - would be snapped to the same cell edge. These are now summed. -- Improve performance validation upon Package initialization -- Improve performance writing ``HorizontalFlowBarrier`` objects -- :func:`imod.mf6.open_cbc` failing with ``flowja=False`` on budget output for - DISV models if the model contained inactive cells. -- :func:`imod.mf6.open_cbc` now works for 2D and 1D models. -- :func:`imod.prepare.fill` previously assigned to the result of an xarray - ``.sel`` operation. This might not work for dask backed data and has been - addressed. -- Added :func:`imod.mf6.open_dvs` to read dependent variable output files like - the water content file of :class:`imod.mf6.UnsaturatedZoneFlow`. -- `imod.prj.open_projectfile_data` is now able to also read IPF data for - sprinkling wells in the CAP package. -- Fix that caused iMOD Python to break upon import with numpy >=1.23, <2.0 . -- ValidationError message now contains a suggestion to use the cleanup method, - if available in the erroneous package. -- Bug where error was thrown when :class:`imod.mf6.NodePropertyFlow` was - assigned to :class:`imod.mf6.GroundwaterFlowModel` with key different from - ``"npf"`` upon writing, along with well or horizontal flow barrier packages. - - -Changed -~~~~~~~ - -- :class:`imod.mf6.Well` now also validates that well filter top is above well - filter bottom -- :func:`imod.formats.prj.open_projectfile_data` now also imports well filter - top and bottom. -- :class:`imod.mf6.Well` now logs a warning if any wells are removed during writing. -- :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.HorizontalFlowBarrierMultiplier`, - :class:`imod.mf6.HorizontalFlowBarrierHydraulicCharacteristic` now uses - vertical Polygons instead of Linestrings as geometry, and ``"ztop"`` and - ``"zbottom"`` variables are not used anymore. See - :func:`imod.prepare.linestring_to_square_zpolygons` and - :func:`imod.prepare.linestring_to_trapezoid_zpolygons` to generate these - polygons. -- :func:`imod.formats.prj.open_projectfile_data` now returns well data grouped - by ipf name, instead of generic, separate number per entry. -- :class:`imod.mf6.Well` now supports wells which have a filter with zero - length, where ``"screen_top"`` equals ``"screen_bottom"``. -- :class:`imod.mf6.Well` shares the same default ``minimum_thickness`` as - :func:`imod.prepare.assign_wells`, which is 0.05, before this was 1.0. -- :func:`imod.prepare.allocate_drn_cells`, - :func:`imod.prepare.allocate_ghb_cells`, - :func:`imod.prepare.allocate_riv_cells`, now allocate to the first model layer - when elevations are above or equal to model top for all methods in - :func:`imod.prepare.ALLOCATION_OPTION`. -- :meth:`imod.mf6.Well.to_mf6_pkg` got a new argument: - ``strict_well_validation``, which controls the behavior for when wells are - removed entirely during their assignment to layers. This replaces the - ``is_partitioned`` argument. -- :func:`imod.prepare.fill` now takes a ``dims`` argument instead of ``by``, - and will fill over N dimensions. Secondly, the function no longer takes - an ``invalid`` argument, but instead always treats NaNs as missing. -- Reverted the need for providing WriteContext objects to MODFLOW 6 Model and - Package objects' ``write`` method. These now use similar arguments to the - :meth:`imod.mf6.Modflow6Simulation.write` method. -- :class:`imod.msw.CouplingMapping`, :class:`imod.msw.Sprinkling`, - `imod.msw.Sprinkling.MetaSwapModel`, now take the - :class:`imod.mf6.mf6_wel_adapter.Mf6Wel` and the - :class:`imod.mf6.StructuredDiscretization` packages as arguments at their - respective ``write`` method, instead of upon initializing these MetaSWAP - objects. -- :class:`imod.msw.CouplingMapping` and :class:`imod.msw.Sprinkling` now take - the :class:`imod.mf6.mf6_wel_adapter.Mf6Wel` as well argument instead of the - deprecated ``imod.mf6.WellDisStructured``. - - -Added -~~~~~ - -- :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` to import imod5 data - loaded with :func:`imod.formats.prj.open_projectfile_data` as a MODFLOW 6 - simulation. -- :func:`imod.prepare.linestring_to_square_zpolygons` and - :func:`imod.prepare.linestring_to_trapezoid_zpolygons` to generate vertical - polygons that can be used to specify horizontal flow barriers, specifically: - :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.HorizontalFlowBarrierMultiplier`, - :class:`imod.mf6.HorizontalFlowBarrierHydraulicCharacteristic`. -- :class:`imod.mf6.LayeredWell` to specify wells directly to layers instead - assigning them with filter depths. -- :func:`imod.prepare.cleanup_drn`, :func:`imod.prepare.cleanup_ghb`, - :func:`imod.prepare.cleanup_riv`, :func:`imod.prepare.cleanup_wel`. These are - utility functions to clean up drainage, general head boundaries, and rivers, - respectively. -- :meth:`imod.mf6.Drainage.cleanup`, - :meth:`imod.mf6.GeneralHeadboundary.cleanup`, :meth:`imod.mf6.River.cleanup`, - :meth:`imod.mf6.Well.cleanup` convenience methods to call the corresponding - cleanup utility functions with the appropriate arguments. -- :meth:`imod.msw.MetaSwapModel.regrid_like` to regrid MetaSWAP models. This is - still experimental functionality, regridding the :class:`imod.msw.Sprinkling` - is not yet supported. -- The context :func:`imod.util.context.print_if_error` to print an error instead - of raising it in a ``with`` statement. This is useful for code snippets which - definitely will fail. -- :meth:`imod.msw.MetaSwapModel.regrid_like` to regrid MetaSWAP models. -- :meth:`imod.mf6.GroundwaterFlowModel.prepare_wel_for_mf6` to prepare wells for - MODFLOW 6, for debugging purposes. - -Removed -~~~~~~~ - -- :func:`imod.formats.prj.convert_to_disv` has been removed. This functionality - has been replaced by :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`. To - convert a structured simulation to an unstructured simulation, call: - :meth:`imod.mf6.Modflow6Simulation.regrid_like` - - -[0.17.2] - 2024-09-17 ---------------------- - -Fixed -~~~~~ -- :func:`imod.formats.prj.open_projectfile_data` now reports the path to a - faulty IPF or IDF file in the error message. -- Support for Numpy 2.0 - -Added -~~~~~ -- Added objects with regrid settings. These can be used to provide custom - settings: :class:`imod.mf6.regrid.ConstantHeadRegridMethod`, - :class:`imod.mf6.regrid.DiscretizationRegridMethod`, - :class:`imod.mf6.regrid.DispersionRegridMethod`, - :class:`imod.mf6.regrid.DrainageRegridMethod`, - :class:`imod.mf6.regrid.EmptyRegridMethod`, - :class:`imod.mf6.regrid.EvapotranspirationRegridMethod`, - :class:`imod.mf6.regrid.GeneralHeadBoundaryRegridMethod`, - :class:`imod.mf6.regrid.InitialConditionsRegridMethod`, - :class:`imod.mf6.regrid.MobileStorageTransferRegridMethod`, - :class:`imod.mf6.regrid.NodePropertyFlowRegridMethod`, - :class:`imod.mf6.regrid.RechargeRegridMethod`, - :class:`imod.mf6.regrid.RiverRegridMethod`, - :class:`imod.mf6.regrid.SpecificStorageRegridMethod`, - :class:`imod.mf6.regrid.StorageCoefficientRegridMethod`. - -Changed -~~~~~~~ -- Instead of providing a dictionary with settings to ``Package.regrid_like``, - provide one of the following ``RegridMethod`` objects: - :class:`imod.mf6.regrid.ConstantHeadRegridMethod`, - :class:`imod.mf6.regrid.DiscretizationRegridMethod`, - :class:`imod.mf6.regrid.DispersionRegridMethod`, - :class:`imod.mf6.regrid.DrainageRegridMethod`, - :class:`imod.mf6.regrid.EmptyRegridMethod`, - :class:`imod.mf6.regrid.EvapotranspirationRegridMethod`, - :class:`imod.mf6.regrid.GeneralHeadBoundaryRegridMethod`, - :class:`imod.mf6.regrid.InitialConditionsRegridMethod`, - :class:`imod.mf6.regrid.MobileStorageTransferRegridMethod`, - :class:`imod.mf6.regrid.NodePropertyFlowRegridMethod`, - :class:`imod.mf6.regrid.RechargeRegridMethod`, - :class:`imod.mf6.regrid.RiverRegridMethod`, - :class:`imod.mf6.regrid.SpecificStorageRegridMethod`, - :class:`imod.mf6.regrid.StorageCoefficientRegridMethod`. -- Renamed ``imod.mf6.LayeredHorizontalFlowBarrier`` classes to - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierHydraulicCharacteristic`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierMultiplier`, - -Fixed -~~~~~ -- :func:`imod.formats.prj.open_projectfile_data` now reports the path to a - faulty IPF or IDF file in the error message. - - - - -[0.17.1] - 2024-05-16 ---------------------- - -Added -~~~~~ -- Added function :func:`imod.util.spatial.gdal_compliant_grid` to make spatial - coordinates of a NetCDF interpretable for GDAL (and so QGIS). -- Added ``crs`` argument to :func:`imod.util.spatial.mdal_compliant_ugrid2d`, - :meth:`imod.mf6.Simulation.dump`, :meth:`imod.mf6.GroundwaterFlowModel.dump`, - :meth:`imod.mf6.GroundwaterTransportModel.dump`, to add a coordinate reference - system to dumped files, to ease loading them in QGIS. - -Changed -~~~~~~~ -- :meth:`imod.mf6.Simulation.dump`, :meth:`imod.mf6.GroundwaterFlowModel.dump`, - :meth:`imod.mf6.GroundwaterTransportModel.dump` write with necessary - attributes to NetCDF to make these files interpretable for GDAL (and so QGIS). - -Fixed -~~~~~ -- Fix missing API docs for ``dump`` and ``write`` methods. - - -[0.17.0] - 2024-05-13 ---------------------- - -Added -~~~~~ -- Added functions to allocate planar grids over layers for the topsystem in - :func:`imod.prepare.allocate_drn_cells`, - :func:`imod.prepare.allocate_ghb_cells`, - :func:`imod.prepare.allocate_rch_cells`, - :func:`imod.prepare.allocate_riv_cells`, for this multiple options can be - selected, available in :func:`imod.prepare.ALLOCATION_OPTION`. -- Added functions to distribute conductances of planar grids over layers for the - topsystem in :func:`imod.prepare.distribute_riv_conductance`, - :func:`imod.prepare.distribute_drn_conductance`, - :func:`imod.prepare.distribute_ghb_conductance`, for this multiple options can - be selected, available in :func:`imod.prepare.DISTRIBUTING_OPTION`. -- :func:`imod.prepare.celltable` supports an optional ``dtype`` argument. This - can be used, for example, to create celltables of float values. -- Added ``fixed_cell`` option to :class:`imod.mf6.Recharge`. This option is - relevant for phreatic models, not using the Newton formulation and model cells - can become inactive. The prefered method for phreatic models is to use the - Newton formulation, where cells remain active, and this option irrelevant. -- Added support for ``ats_outer_maximum_fraction`` in :class:`imod.mf6.Solution`. -- Added validation for ``linear_acceleration``, ``rclose_option``, - ``scaling_method``, ``reordering_method``, ``print_option`` and ``no_ptc`` - entries in :class:`imod.mf6.Solution`. - -Fixed -~~~~~ -- No ``ValidationError`` thrown anymore in :class:`imod.mf6.River` when - ``bottom_elevation`` equals ``bottom`` in the model discretization. -- When wells outside of the domain are added, an exception is raised with an - error message stating a well is outside of the domain. -- When importing data from a .prj file, the multipliers and additions specified for - ipf and idf files are now applied -- Fix bug where y-coords were flipped in :class:`imod.msw.MeteoMapping` - -Changed -~~~~~~~ -- Replaced csv_output by outer_csvfile and inner_csvfile in - :class:`imod.mf6.Solution` to match newer MODFLOW 6 releases. -- Changed no_ptc from a bool to an option string in :class:`imod.mf6.Solution`. -- Removed constructor arguments `source` and `target` from - ``imod.mf6.utilities.regrid.RegridderWeightsCache``, as they were not - used. -- :func:`imod.mf6.open_cbc` now returns arrays which contain np.nan for cells where - budget variables are not defined. Based on new budget output a disquisition between - active cells but zero flow and inactive cells can be made. -- :func:`imod.mf6.open_cbc` now returns package type in return budget names. New format - is "package type"-"optional package variable"_"package name". E.g. a River package - named ``primary-sys`` will get a budget name ``riv_primary-sys``. An UZF package - with name ``uzf-sys1`` will get a budget name ``uzf-gwrch_uzf-sys1`` for the - groundwater recharge budget from the UZF-CBC. - - -[0.16.0] - 2024-03-29 ---------------------- - -Added -~~~~~ -- The :func:`imod.mf6.model.mask_all_packages` now also masks the idomain array - of the model discretization, and can be used with a mask array without a layer - dimension, to mask all layers the same way -- Validation for incompatible settings in the :class:`imod.mf6.NodePropertyFlow` - and :class:`imod.mf6.Dispersion` packages. -- Checks that only one flow model is present in a simulation when calling - :func:`imod.mf6.Modflow6Simulation.regrid_like`, - :func:`imod.mf6.Modflow6Simulation.clip_box` or - :func:`imod.mf6.Modflow6Simulation.split` -- Added support for coupling a GroundwaterFlowModel and Transport Model i.c.w. - the 6.4.3 release of MODFLOW. Using an older version of iMOD Python with this - version of MODFLOW will result in an error. -- :meth:`imod.mf6.Modflow6Simulation.split` supports splitting transport models, - including multi-species simulations. -- :meth:`imod.mf6.Modflow6Simulation.open_concentration` and - :meth:`imod.mf6.Modflow6Simulation.open_transport_budget` support opening - split multi-species simulations. - :meth:`imod.mf6.Modflow6Simulation.regrid_like` can now regrid simulations - that have 1 or more transport models. -- added logging to various initialization methods, write methods and dump - methods. `See the documentation - `_ - how to activate logging. -- added :func:`imod.data.hondsrug_simulation` and - :func:`imod.data.hondsrug_crosssection` data. -- simulations and models that include a lake package now raise an exception on - clipping, partitioning or regridding. - -Changed -~~~~~~~ -- :meth:`imod.mf6.Modflow6Simulation.open_concentration` and - :meth:`imod.mf6.Modflow6Simulation.open_transport_budget` raise a - ``ValueError`` if ``species_ls`` is provided with incorrect length. - -Fixed -~~~~~ -- Incorrect validation error ``data values found at nodata values of idomain`` - for boundary condition packages with a scalar coordinate not set as dimension. -- Fix issue where :func:`imod.formats.idf.open_subdomains` and - :func:`imod.mf6.Modflow6Simulation.open_head` (for split simulations) would - return arrays with incorrect ``dx`` and ``dy`` coordinates for equidistant - data. -- Fix issue where :func:`imod.formats.idf.open_subdomains` returned a flipped ``dy`` - coordinate for nonequidistant data. -- Made :func:`imod.util.round_extent` available again, as it was moved without - notice. Function now throws a DeprecationWarning to use - :func:`imod.prepare.spatial.round_extent` instead. -- :meth'`imod.mf6.Modflow6Simulation.write` failed after splitting the - simulation. This has been fixed. -- modflow options like "print flow", "save flow", and "print input" can now be - set on :class:`imod.mf6.Well` -- when regridding a :class:`imod.mf6.Modflow6Simulation`, - :class:`imod.mf6.GroundwaterFlowModel`, - :class:`imod.mf6.GroundwaterTransportModel` or a :class:`imod.mf6.package`, - regridding weights are now cached and can be re-used over the different - objects that are regridded. This improves performance considerably in most use - cases: when regridding is applied over the same grid cells with the same - regridder type, but with different values/methods, multiple times. - -[0.15.3] - 2024-02-22 ---------------------- - -Fixed -~~~~~ -- Add missing required dependencies for installing with ``pip``: loguru and tomli. -- Ensure geopandas and shapely are optional dependencies again when - installing with ``pip``, and no import errors are thrown. -- Fixed bug where calling ``copy.deepcopy`` on - :class:`imod.mf6.Modflow6Simulation`, :class:`imod.mf6.GroundwaterFlowModel` - and :class:`imod.mf6.GroundwaterTransportModel` objects threw an error. - - -Added -~~~~~ -- Developer environment: Added pixi environment ``interactive`` to interactively - run code. Can be useful to plot data. -- :class:`imod.mf6.ApiPackage` was added. It can be added to both flow and - transport models, and its presence allows users to interact with libMF6.dll - through its API. -- Developer environment: Empty python 3.10, 3.11, 3.12 environments where pip - install and import imod can be tested. - - - -[0.15.2] - 2024-02-16 ---------------------- - -Fixed -~~~~~ -- iMOD Python now supports versions of pandas >= 2 -- Fixed bugs with clipping :class:`imod.mf6.HorizontalFlowBarrier` for - structured grids -- Packages and boundary conditions in the ``imod.mf6`` module will now throw an - error upon initialization if coordinate labels are inconsistent amongst - variables -- 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 -- Fix support for coupling a GroundwaterFlowModel and Transport Model i.c.w. - the 6.4.3 release of MODFLOW. Using an older version of iMOD Python - with this version of MODFLOW will result in an error. - - -Changed -~~~~~~~ -- We moved to using `pixi `_ to create development - environments. This replaces the ``imod-environment.yml`` conda environment. We - advice doing development installations with pixi from now on. `See the - documentation. `_ - This does not affect users who installed with ``pip install imod``, ``mamba - install imod`` or ``conda install imod``. -- Changed build system from ``setuptools`` to ``hatchling``. Users who did a - development install are adviced to run ``pip uninstall imod`` and ``pip - install -e .`` again. This does not affect users who installed with ``pip - install imod``, ``mamba install imod`` or ``conda install imod``. -- Decreased lower limit of MetaSWAP validation for x and y limits in the - ``IdfMapping`` from 0 to -9999999.0. - - -[0.15.1] - 2023-12-22 ---------------------- - -Fixed -~~~~~ -- Made ``specific_yield`` optional argument in - :class:`imod.mf6.SpecificStorage`, :class:`imod.mf6.StorageCoefficient`. -- Fixed bug where simulations with :class:`imod.mf6.Well` were not partitioned - into multiple models. -- Fixed erroneous default value for the ``out_of_bounds`` in - :func:`imod.select.points.point_values` -- Fixed bug where :class:`imod.mf6.Well` could not be assigned to the first cell - of an unstructured grid. -- HorizontalFlowBarrier package now dropped if completely outside partition in a - split model. -- HorizontalFlowBarrier package clipped with ``clip_by_grid`` based on active - cells, consistent with how other packages are treated by this function. This - affects the :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` and - :meth:`imod.mf6.Modflow6Simulation.split` methods. - - -Changed -~~~~~~~ -- All the references to GitLab have been replaced by GitHub references as - part of the GitHub migration. - -Added -~~~~~ -- Added comment in Modflow6 exchanges file (GWFGWF) denoting column header. -- Added Python 3.11 support. -- The GWF-GWF exchange options are derived from user created packages (NPF, OC) and - set automatically. -- Added the ``simulation_start_time`` and ``time_unit`` arguments. To the - ``Modflow6Simulation.open_`` methods, and ``imod.mf6.out.open_`` functions. - This converts the ``"time"`` coordinate to datetimes. -- added :meth:`imod.mf6.Modflow6Simulation.mask_all_models` to apply a mask to - all models under a simulation, provided the simulation is not split and the - models use the same discretization. - - -Changed -~~~~~~~ -- :meth:`imod.mf6.Well.mask` masks with a 2D grid instead of returning a - deepcopy of the package. - - -[0.15.0] - 2023-11-25 ---------------------- - -Fixed -~~~~~ -- The Newton option for a :class:`imod.mf6.GroundwaterFlowModel` was being ignored. This has been - corrected. -- The Contextily packages started throwing errors. This was caused because the - default tile provider being used was Stamen. However Stamen is no longer free - which caused Contextily to fail. The default tile provider has been changed to - OpenStreetMap to resolve this issue. -- :func:`imod.mf6.open_cbc` now reads saved cell saturations and specific discharges. -- :func:`imod.mf6.open_cbc` failed to read unstructured budgets stored - following IMETH1, most importantly the storage fluxes. -- Fixed support of Python 3.11 by dropping the obsolete ``qgs`` module. -- Bug in :class:`imod.mf6.SourceSinkMixing` where, in case of multiple active - boundary conditions with assigned concentrations, it would write a ``.ssm`` - file with all sources/sinks on one single row. -- Fixed bug where TypeError was thrown upond calling - :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` and - :meth:`imod.mf6.HorizontalFlowBarrier.mask`. -- Fixed bug where calling :meth:`imod.mf6.Well.clip_box` over only the time - dimension would remove the index coordinate. -- Validation errors are rendered properly when writing a simulation object or - regridding a model object. - -Changed -~~~~~~~ -- The imod-environment.yml file has been split in an imod-environment.yml - (containing all packages required to run imod-python) and a - imod-environment-dev.yml file (containing additional packages for developers). -- Changed the way :class:`imod.mf6.Modflow6Simulation`, - :class:`imod.mf6.GroundwaterFlowModel`, - :class:`imod.mf6.GroundwaterTransportModel`, and MODFLOW 6 packages are - represented while printing. -- The grid-agnostic packages :meth:`imod.mf6.Well.regrid_like` and - :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` now return a clip with the - grid exterior of the target grid - -Added -~~~~~ -- The unit tests results are now published on GitLab -- A ``save_saturation`` option to :class:`imod.mf6.NodePropertyFlow` which saves - cell saturations for unconfined flow. -- Functions :func:`imod.prepare.layer.get_upper_active_layer_number` and - :func:`imod.prepare.layer.get_lower_active_layer_number` to return planar - grids with numbers of the highest and lowest active cells respectively. -- Functions :func:`imod.prepare.layer.get_upper_active_grid_cells` and - :func:`imod.prepare.layer.get_lower_active_grid_cells` to return boolean - grids designating respectively the highest and lowest active cells in a grid. -- validation of ``transient`` argument in :class:`imod.mf6.StorageCoefficient` - and :class:`imod.mf6.SpecificStorage`. -- :meth:`imod.mf6.Modflow6Simulation.open_concentration`, - :meth:`imod.mf6.Modflow6Simulation.open_head`, - :meth:`imod.mf6.Modflow6Simulation.open_transport_budget`, and - :meth:`imod.mf6.Modflow6Simulation.open_flow_budget`, were added as convenience - methods to open simulation output easier (without having to specify paths). -- The :meth:`imod.mf6.Modflow6Simulation.split` method has been added. This method makes - it possible for a user to create a Multi-Model simulation. A user needs to - provide a submodel label array in which they specify to which submodel a cell - belongs. The method will then create the submodels and split the nested - packages. The split method will create the gwfgwf exchanges required to - connect the submodels. At the moment auxiliary variables ``cdist`` and - ``angldegx`` are only computed for structured grids. -- The label array can be generated through a convenience function - :func:`imod.mf6.partition_generator.get_label_array` -- Once a split simulation has been executed by MF6, we find head and balance - results in each of the partition models. These can now be merged into head and - balance datasets for the original domain using - :meth:`imod.mf6.Modflow6Simulation.open_concentration`, - :meth:`imod.mf6.Modflow6Simulation.open_head`, - :meth:`imod.mf6.Modflow6Simulation.open_transport_budget`, - :meth:`imod.mf6.Modflow6Simulation.open_flow_budget`. - In the case of balances, the exchanges through the partition boundary are not - yet added to this merged balance. -- Settings such as ``save_flows`` can be passed through - :meth:`imod.mf6.SourceSinkMixing.from_flow_model` -- Added :class:`imod.mf6.LayeredHorizontalFlowBarrierHydraulicCharacteristic`, - :class:`imod.mf6.LayeredHorizontalFlowBarrierMultiplier`, - :class:`imod.mf6.LayeredHorizontalFlowBarrierResistance`, for horizontal flow - barriers with a specified layer number. - - -Removed -~~~~~~~ -- Tox has been removed from the project. -- Dropped support for writing .qgs files directly for QGIS, as this was hard to - maintain and rarely used. To export your model to QGIS readable files, call - the ``dump`` method :class:`imod.mf6.Modflow6Simulation` with ``mdal_compliant=True``. - This writes UGRID NetCDFs which can read as meshes in QGIS. -- Removed ``declxml`` from repository. - -[0.14.1] - 2023-09-07 ---------------------- - -Changed -~~~~~~~ - -- TWRI MODFLOW 6 example uses the grid-agnostic :class:`imod.mf6.Well` - package instead of the ``imod.mf6.WellDisStructured`` package. - -Fixed -~~~~~ - -- :class:`imod.mf6.HorizontalFlowBarrier` would write to a binary file by - default. However, the current version of MODFLOW 6 does not support this. - Therefore, this class now always writes to text file. - - -[0.14.0] - 2023-09-06 ---------------------- - -Changed -~~~~~~~ - -- :class:`imod.mf6.HorizontalFlowBarrier` is specified by providing a geopandas - `GeoDataFrame - `_ - - -Added -~~~~~ - -- :meth:`imod.mf6.Modflow6Simulation.regrid_like` to regrid a Modflow6 simulation to a - new grid (structured or unstructured), using `xugrid's regridding - functionality. - `_ - Variables are regridded with pre-selected methods. The regridding - functionality is useful for a variety of applications, for example to test the - effect of different grid sizes, to add detail to a simulation (by refining the - grid) or to speed up a simulation (by coarsening the grid) to name a few -- :meth:`imod.mf6.Package.regrid_like` to regrid packages. The user can - specify their own custom regridder types and methods for variables. -- :meth:`imod.mf6.Modflow6Simulation.clip_box` got an extra argument - ``states_for_boundary``, which takes a dictionary with modelname as key and - griddata as value. This data is specified as fixed state on the model - boundary. At present only `imod.mf6.GroundwaterFlowModel` is supported, grid - data is specified as a :class:`imod.mf6.ConstantHead` at the model boundary. -- :class:`imod.mf6.Well`, a grid-agnostic well package, where wells can be - specified based on their x,y coordinates and filter top and bottom. - - -[0.13.2] - 2023-07-26 ---------------------- - -Changed -~~~~~~~ - -- :func:`imod.formats.rasterio.save` will now write ESRII ASCII rasters, even if - rasterio is not installed. A fallback function has been added specifically - for ASCII rasters. - -Fixed -~~~~~ - -- Geopandas and rasterio were imported at the top of a module in some places. - This has been fixed so that both are not optional dependencies when - installing via pip (installing via conda or mamba will always pull all - dependencies and supports full functionality). -- :meth:`imod.mf6.Modflow6Simulation._validate` now print all validation errors for all - models and packages in one message. -- The gen file reader can now handle feature id's that contain commas and spaces -- :class:`imod.mf6.EvapoTranspiration` now supports segments, by adding a - ``segment`` dimension to the ``proportion_depth`` and ``proportion_rate`` - variables. -- :class:`imod.mf6.EvapoTranspiration` template for ``.evt`` file now properly - formats ``nseg`` option. -- Fixed bug in :class:`imod.wq.Well` preventing saving wells without a time - dimension, but with a layer dimension. -- :class:`imod.mf6.DiscretizationVertices._validate` threw ``KeyError`` for - ``"bottom"`` when validating the package separately. - -Added -~~~~~ - -- :func:`imod.select.grid.active_grid_boundary_xy` & - :func:`imod.select.grid.grid_boundary_xy` are added to find grid boundaries. - -[0.13.1] - 2023-05-05 ---------------------- - -Added -~~~~~ - -- :class:`imod.mf6.SpecificStorage` and :class:`imod.mf6.StorageCoefficient` - now have a ``save_flow`` argument. - -Fixed -~~~~~ - -- :func:`imod.mf6.open_cbc` can now read storage fluxes without error. - - -[0.13.0] - 2023-05-02 ---------------------- - -Added -~~~~~ - -- :class:`imod.mf6.OutputControl` now takes parameters ``head_file``, - ``concentration_file``, and ``budget_file`` to specify where to store - MODFLOW 6 output files. -- :func:`imod.util.spatial.from_mdal_compliant_ugrid2d` to "restack" the variables that - have have been "unstacked" in :func:`imod.util.spatial.mdal_compliant_ugrid2d`. -- Added support for the Modflow6 Lake package -- :func:`imod.select.points_in_bounds`, :func:`imod.select.points_indices`, - :func:`imod.select.points_values` now support unstructured grids. -- Added support for the MODFLOW 6 Lake package: :class:`imod.mf6.Lake`, - :class:`imod.mf6.LakeData`, :class:`imod.mf6.OutletManning`, :class:`OutletSpecified`, - :class:`OutletWeir`. See the examples for an application of the Lake package. -- :meth:`imod.mf6.simulation.Modflow6Simulation.dump` now supports dumping to MDAL compliant - ugrids. These can be used to view and explore Modlfow 6 simulations in QGIS. - -Fixed -~~~~~ - -- :meth:`imod.wq.bas.BasicFlow.thickness` returns a DataArray with the correct - dimension order again. This confusingly resulted in an error when writing the - :class:`imod.wq.btn.BasicTransport` package. -- Fixed bug in :class:`imod.mf6.dis.StructuredDiscretization` and - :class:`imod.mf6.dis.VerticesDiscretization` where - ``inactive bottom above active cell`` was incorrectly raised. - -[0.12.0] - 2023-03-17 ---------------------- - -Added -~~~~~ - -- :func:`imod.prj.read_projectfile` to read the contents of a project file into - a Python dictionary. -- :func:`imod.prj.open_projectfile_data` to read/open the data that is pointed - to in a project file. -- :func:`imod.gen.read_ascii` to read the geometry stored in ASCII text .gen files. -- :class:`imod.mf6.hfb.HorizontalFlowBarrier` to support Modflow6's HFB - package, works well with `xugrid.snap_to_grid` function. -- :meth:`imod.mf6.simulation.Modflow6Simulation.dump` to dump a simulation to a toml file - which acts as a definition file, pointing to packages written as netcdf files. This - can be used to intermediately store Modflow6 simulations. - -Fixed -~~~~~ - -- :func:`imod.evaluate.budget.flow_velocity` now properly computes velocity by - dividing by the porosity. Before, this function computed the Darcian velocity. - -Changed -~~~~~~~ - -- :func:`imod.formats.ipf.save` will error on duplicate IDs for associated files if a - ``"layer"`` column is present. As a dataframe is automatically broken down - into a single IPF per layer, associated files for the first layer would be - overwritten by the second, and so forth. -- :meth:`imod.wq.Well.save` will now write time varying data to associated - files for extration rate and concentration. -- Choosing ``method="geometric_mean"`` in the Regridder will now result in NaN - values in the regridded result if a geometric mean is computed over negative - values; in general, a geometric mean should only be computed over physical - quantities with a "true zero" (e.g. conductivity, but not elevation). - -[0.11.6] - 2023-02-01 ---------------------- - -Added -~~~~~ - -- Added an extra optional argument in - :meth:`imod.couplers.metamod.MetaMod.write` named ``modflow6_write_kwargs``, - which can be used to provide keyword arguments to the writing of the MODFLOW 6 - Simulation. - -Fixed -~~~~~ - -- :func:`imod.mf6.out.disv.read_grb` Remove repeated construction of - ``UgridDataArray`` for ``top`` - -[0.11.5] - 2022-12-15 ---------------------- - -Fixed -~~~~~ - -- :meth:`imod.mf6.Modflow6Simulation.write` with ``binary=False`` no longer - results in invalid MODFLOW 6 input for 2D grid data, such as DIS top. -- ``imod.flow.ImodflowModel.write`` no longer writes incorrect project - files for non-grid values with a time and layer dimension. -- :func:`imod.evaluate.interpolate_value_boundaries`: Fix edge case when - successive values in z direction are exactly equal to the boundary value. - -Changed -~~~~~~~ - -- Removed ``meshzoo`` dependency. -- Minor changes to :mod:`imod.gen.gen` backend, to support `Shapely 2.0 - `_ , Shapely - version above equal v1.8 is now required. - -Added -~~~~~ - -- ``imod.flow.ImodflowModel.write`` now supports writing a - ``config_run.ini`` to convert the projectfile to a runfile or modflow 6 - namfile with iMOD5. -- Added validation of Modflow6 Flow and Transport models. Incorrect model input - will now throw a ``ValidationError``. To turn off the validation, set - ``validate=False`` upon package initialization and/or when calling - :meth:`imod.mf6.Modflow6Simulation.write`. - -[0.11.4] - 2022-09-05 ---------------------- - -Fixed -~~~~~ - -- :meth:`imod.mf6.GroundwaterFlowModel.write` will no longer error when a 3D - DataArray with a single layer is written. It will now accept both 2D and 3D - arrays with a single layer coordinate. -- Hotfixes for :meth:`imod.wq.model.SeawatModel.clip`, until `this merge request - `_ is - fulfilled. -- ``imod.flow.ImodflowModel.write`` will set the timestring in the - projectfile to ``steady-state`` for ``BoundaryConditions`` without a time - dimension. -- Added ``imod.flow.OutputControl`` as this was still missing. -- :func:`imod.formats.ipf.read` will no longer error when an associated files with 0 - rows is read. -- :func:`imod.evaluate.calculate_gxg` now correctly uses (March 14, March - 28, April 14) to calculate GVG rather than (March 28, April 14, April 28). -- :func:`imod.mf6.out.open_cbc` now correctly loads boundary fluxes. -- :meth:`imod.prepare.LayerRegridder.regrid` will now correctly skip values - if ``top_source`` or ``bottom_source`` are NaN. -- :func:`imod.gen.write` no longer errors on dataframes with empty columns. -- ``imod.mf6.BoundaryCondition.set_repeat_stress`` reinstated. This is - a temporary measure, it gives a deprecation warning. - -Changed -~~~~~~~ - -- Deprecate the current documentation URL: https://imod.xyz. For the coming - months, redirection is automatic to: - https://deltares.gitlab.io/imod/imod-python/. -- :func:`imod.formats.ipf.save` will now store associated files in separate directories - named ``layer1``, ``layer2``, etc. The ID in the main IPF file is updated - accordingly. Previously, if IDs were shared between different layers, the - associated files would be overwritten as the IDs would result in the same - file name being used over and over. -- ``imod.flow.ImodflowModel.time_discretization``, - :meth:`imod.wq.SeawatModel.time_discretization`, - :meth:`imod.mf6.Modflow6Simulation.time_discretization`, - are renamed to: - ``imod.flow.ImodflowModel.create_time_discretization``, - :meth:`imod.wq.SeawatModel.create_time_discretization`, - :meth:`imod.mf6.Modflow6Simulation.create_time_discretization`, -- Moved tests inside `imod` directory, added an entry point for pytest fixtures. - Running the tests now requires an editable install, and also existing - installations have to be reinstalled to run the tests. -- The ``imod.mf6`` model packages now all run type checks on input. This is a - breaking change for scripts which provide input with an incorrect dtype. -- :class:`imod.mf6.Solution` now requires a `model_names` argument to specify - which models should be solved in a single numerical solution. This is - required to simulate groundwater flow and transport as they should be - in separate solutions. -- When writing MODFLOW 6 input option blocks, a NaN value is now recognized as - an alternative to None (and the entry will not be included in the options - block). - -Added -~~~~~ - -- Added support to write MetaSWAP models, :class:`imod.msw.MetaSwapModel`. -- Addes support to write coupled MetaSWAP and Modflow6 simulations, - :class:`imod.couplers.MetaMod` -- :func:`imod.util.replace` has been added to find and replace different values - in a DataArray. -- :func:`imod.evaluate.calculate_gxg_points` has been added to compute GXG - values for time varying point data (i.e. loaded from IPF and presented as a - Pandas dataframe). -- :func:`imod.evaluate.calculate_gxg` will return the number of years used - in the GxG calculation as separate variables in the output dataset. -- :func:`imod.visualize.spatial.plot_map` now accepts a `fix` and `ax` argument, - to enable adding maps to existing axes. -- ``imod.flow.ImodflowModel.create_time_discretization``, - :meth:`imod.wq.SeawatModel.create_time_discretization`, - :meth:`imod.mf6.Modflow6Simulation.create_time_discretization`, now have a - documentation section. -- :class:`imod.mf6.GroundwaterTransportModel` has been added with associated - simple classes to allow creation of solute transport models. Advanced - boundary conditions such as LAK or UZF are not yet supported. -- :class:`imod.mf6.Buoyancy` has been added to simulate density dependent - groundwater flow. - -[0.11.1] - 2021-12-23 ---------------------- - -Fixed -~~~~~ - -- ``contextily``, ``geopandas``, ``pyvista``, ``rasterio``, and ``shapely`` - are now fully optional dependencies. Import errors are only raised when - accessing functionality that requires their use. -- Include declxml as ``imod.declxml`` (should be internal use only!): declxml - is no longer maintained on the official repository: - https://github.com/gatkin/declxml. Furthermore, it has no conda feedstock, - which makes distribution via conda difficult. - -[0.11.0] - 2021-12-21 ---------------------- - -Fixed -~~~~~ - -- :func:`imod.formats.ipf.read` accepts list of file names. -- :func:`imod.mf6.open_hds` did not read the appropriate bytes from the - heads file, apart for the first timestep. It will now read the right records. -- Use the appropriate array for modflow6 timestep duration: the - :meth:`imod.mf6.GroundwaterFlowModel.write` would write the timesteps - multiplier in place of the duration array. -- :meth:`imod.mf6.GroundwaterFlowModel.write` will now respect the layer - coordinate of DataArrays that had multiple coordinates, but were - discontinuous from 1; e.g. layers [1, 3, 5] would've been transformed to [1, - 2, 3] incorrectly. -- :meth:`imod.mf6.Modflow6Simulation.write` will no longer change working directory - while writing model input -- this could lead to errors when multiple - processes are writing models in parallel. -- :func:`imod.prepare.laplace_interpolate` will no longer ZeroDivisionError - when given a value for ``ibound``. - -Added -~~~~~ - -- :func:`imod.formats.idf.open_subdomains` will now also accept iMOD-WQ output of - multiple species runs. -- :meth:`imod.wq.SeawatModel.to_netcdf` has been added to write all model - packages to netCDF files. -- :func:`imod.mf6.open_cbc` has been added to read the budget data of - structured (DIS) MODFLOW 6 models. The data is read lazily into xarray - DataArrays per timestep. -- :func:`imod.visualize.streamfunction` and :func:`imod.visualize.quiver` - were added to plot a 2D representation of the groundwater flow field using - either streamlines or quivers over a cross section plot - (:func:`imod.visualize.cross_section`). -- :func:`imod.evaluate.streamfunction_line` and - :func:`imod.evaluate.streamfunction_linestring` were added to extract the - 2D projected streamfunction of the 3D flow field for a given cross section. -- :func:`imod.evaluate.quiver_line` and :func:`imod.evaluate.quiver_linestring` - were added to extract the u and v components of the 3D flow field for a given - cross section. -- Added :meth:`imod.mf6.GroundwaterFlowModel.write_qgis_project` to write a - QGIS project for easier inspection of model input in QGIS. -- Added :meth:`imod.wq.SeawatModel.clip` to clip a model to a provided extent. - Boundary conditions of clipped model can be automatically derived from parent - model calculation results and are applied along the edges of the extent. -- Added :py:func:`imod.gen.read` and :py:func:`imod.gen.write` for reading - and writing binary iMOD GEN files to and from geopandas GeoDataFrames. -- Added :py:func:`imod.prepare.zonal_aggregate_raster` and - :py:func:`imod.prepare.zonal_aggregate_polygons` to efficiently compute zonal - aggregates for many polygons (e.g. the properties every individual ditch in - the Netherlands). -- Added ``imod.flow.ImodflowModel`` to write to model iMODFLOW project - file. -- :meth:`imod.mf6.Modflow6Simulation.write` now has a ``binary`` keyword. When set - to ``False``, all MODFLOW 6 input is written to text rather than binary files. -- Added :class:`imod.mf6.DiscretizationVertices` to write MODFLOW 6 DISV model - input. -- Packages for :class:`imod.mf6.GroundwaterFlowModel` will now accept - :class:`xugrid.UgridDataArray` objects for (DISV) unstructured grids, next to - :class:`xarray.DataArray` objects for structured (DIS) grids. -- Transient wells are now supported in ``imod.mf6.WellDisStructured`` and - ``imod.mf6.WellDisVertices``. -- :func:`imod.util.to_ugrid2d` has been added to convert a (structured) xarray - DataArray or Dataset to a quadrilateral UGRID dataset. -- Functions created to create empty DataArrays with greater ease: - :func:`imod.util.empty_2d`, :func:`imod.util.empty_2d_transient`, - :func:`imod.util.empty_3d`, and :func:`imod.util.empty_3d_transient`. -- :func:`imod.util.where` has been added for easier if-then-else operations, - especially for preserving NaN nodata values. -- :meth:`imod.mf6.Modflow6Simulation.run` has been added to more easily run a model, - especially in examples and tests. -- :func:`imod.mf6.open_cbc` and :func:`imod.mf6.open_hds` will automatically - return a ``xugrid.UgridDataArray`` for MODFLOW 6 DISV model output. - -Changed -~~~~~~~ - -- Documentation overhaul: different theme, add sample data for examples, add - Frequently Asked Questions (FAQ) section, restructure API Reference. Examples - now ru -- Datetime columns in IPF associated files (via - :func:`imod.formats.ipf.write_assoc`) will not be placed within quotes, as this can - break certain iMOD batch functions. -- :class:`imod.mf6.Well` has been renamed into ``imod.mf6.WellDisStructured``. -- :meth:`imod.mf6.GroundwaterFlowModel.write` will now write package names - into the simulation namefile. -- :func:`imod.mf6.open_cbc` will now return a dictionary with keys - ``flow-front-face, flow-lower-face, flow-right-face`` for the face flows, - rather than ``front-face-flow`` for better consistency. -- Switched to composition from inheritance for all model packages: all model - packages now contain an internal (xarray) Dataset, rather than inheriting - from the xarray Dataset. -- :class:`imod.mf6.SpecificStorage` or :class:`imod.mf6.StorageCoefficient` is - now mandatory for every MODFLOW 6 model to avoid accidental steady-state - configuration. - -Removed -~~~~~~~ - -- Module ``imod.tec`` for reading Tecplot files has been removed. - -[0.10.1] - 2020-10-19 ---------------------- - -Changed -~~~~~~~ - -- :meth:`imod.wq.SeawatModel.write` now generates iMOD-WQ runfiles with - more intelligent use of the "macro tokens". ``:`` is used exclusively for - ranges; ``$`` is used to signify all layers. (This makes runfiles shorter, - speeding up parsing, which takes a significant amount of time in the runfile - to namefile conversion of iMOD-WQ.) -- Datetime formats are inferred based on length of the time string according to - ``%Y%m%d%H%M%S``; supported lengths 4 (year only) to 14 (full format string). - -Added -~~~~~ - -- :class:`imod.wq.MassLoading` and - :class:`imod.wq.TimeVaryingConstantConcentration` have been added to allow - additional concentration boundary conditions. -- IPF writing methods support an ``assoc_columns`` keyword to allow greater - flexibility in including and renaming columns of the associated files. -- Optional basemap plotting has been added to :meth:`imod.visualize.plot_map`. - -Fixed -~~~~~ - -- IO methods for IDF files will now correctly identify double precision IDFs. - The correct record length identifier is 2295 rather than 2296 (2296 was a - typo in the iMOD manual). -- :meth:`imod.wq.SeawatModel.write` will now write the correct path for - recharge package concentration given in IDF files. It did not prepend the - name of the package correctly (resulting in paths like - ``concentration_l1.idf`` instead of ``rch/concentration_l1.idf``). -- :meth:`imod.formats.idf.save` will simplify constant cellsize arrays to a scalar - value -- this greatly speeds up drawing in the iMOD-GUI. - -[0.10.0] - 2020-05-23 ---------------------- - -Changed -~~~~~~~ - -- :meth:`imod.wq.SeawatModel.write` no longer automatically appends the model - name to the directory where the input is written. Instead, it simply writes - to the directory as specified. -- :func:`imod.select.points_set_values` returns a new DataArray rather than - mutating the input ``da``. -- :func:`imod.select.points_values` returns a DataArray with an index taken - from the data of the first provided dimensions if it is a ``pandas.Series``. -- :meth:`imod.wq.SeawatModel.write` now writes a runfile with ``start_hour`` - and ``start_minute`` (this results in output IDFs with datetime format - ``"%Y%m%d%H%M"``). - -Added -~~~~~ - -- :meth:`from_file` constructors have been added to all `imod.wq.Package`. - This allows loading directly package from a netCDF file (or any file supported by - ``xarray.open_dataset``), or a path to a Zarr directory with suffix ".zarr" or ".zip". -- This can be combined with the `cache` argument in :meth:`from_file` to - enable caching of answers to avoid repeated computation during - :meth:`imod.wq.SeawatModel.write`; it works by checking whether input and - output files have changed. -- The ``resultdir_is_workspace`` argument has been added to :meth:`imod.wq.SeawatModel.write`. - iMOD-wq writes a number of files (e.g. list file) in the directory where the - runfile is located. This results in mixing of input and output. By setting it - ``True``, **all** model output is written in the results directory. -- :func:`imod.visualize.imshow_topview` has been added to visualize a complete - DataArray with atleast dimensions ``x`` and ``y``; it dumps PNGs into a - specified directory. -- Some support for 3D visualization has been added. - :func:`imod.visualize.grid_3d` and :func:`imod.visualize.line_3d` have been - added to produce ``pyvista`` meshes from ``xarray.DataArray``'s and - ``shapely`` polygons, respectively. - :class:`imod.visualize.GridAnimation3D` and :class:`imod.visualize.StaticGridAnimation3D` - have been added to setup 3D animations of DataArrays with transient data. -- Support for out of core computation by ``imod.prepare.Regridder`` if ``source`` - is chunked. -- :func:`imod.formats.ipf.read` now reports the problematic file if reading errors occur. -- :func:`imod.prepare.polygonize` added to polygonize DataArrays to GeoDataFrames. -- Added more support for multiple species imod-wq models, specifically: scalar concentration - for boundary condition packages and well IPFs. - -Fixed -~~~~~ - -- :meth:`imod.prepare.Regridder` detects if the ``like`` DataArray is a subset - along a dimension, in which case the dimension is not regridded. -- :meth:`imod.prepare.Regridder` now slices the ``source`` array accurately - before regridding, taking cell boundaries into account rather than only - cell midpoints. -- ``density`` is no longer an optional argument in :class:`imod.wq.GeneralHeadboundary` and - :class:`imod.wq.River`. The reason is that iMOD-WQ fully removes (!) these packages if density - is not present. -- :func:`imod.formats.idf.save` and :func:`imod.formats.rasterio.save` will now also save DataArrays in - which a coordinate other than ``x`` or ``y`` is descending. -- :func:`imod.visualize.plot_map` enforces decreasing ``y``, which ensures maps are not plotted - upside down. -- :func:`imod.util.spatial.coord_reference` now returns a scalar cellsize if coordinate is equidistant. -- :meth:`imod.prepare.Regridder.regrid` returns cellsizes as scalar when coordinates are - equidistant. -- Raise proper ValueError in :meth:`imod.prepare.Regridder.regrid` consistenly when the number - of dimensions to regrid does not match the regridder dimensions. -- When writing DataArrays that have size 1 in dimension ``x`` or ``y``: raise error if cellsize - (``dx`` or ``dy``) is not specified; and actually use ``dy`` or ``dx`` when size is 1. - -[0.9.0] - 2020-01-19 --------------------- - -Added -~~~~~ - -- IDF files representing data of arbitrary dimensionality can be opened and - saved. This enables reading and writing files with more dimensions than just x, - y, layer, and time. -- Added multi-species support for (:mod:`imod.wq`) -- GDAL rasters representing N-dimensional data can be opened and saved similar to (:mod:`imod.idf`) in (:mod:`imod.rasterio`) -- Writing GDAL rasters using :meth:`imod.formats.rasterio.save` and (:meth:`imod.formats.rasterio.write`) auto-detects GDAL driver based on file extension -- 64-bit IDF files can be opened :meth:`imod.formats.idf.open` -- 64-bit IDF files can be written using :meth:`imod.formats.idf.save` and (:meth:`imod.formats.idf.write`) using keyword ``dtype=np.float64`` -- ``sel`` and ``isel`` methods to ``SeawatModel`` to support taking out a subdomain -- Docstrings for the MODFLOW 6 classes in :mod:`imod.mf6` -- :meth:`imod.select.upper_active_layer` function to get the upper active layer from ibound ``xr.DataArray`` - -Changed -~~~~~~~ - -- ``imod.formats.idf.read`` is deprecated, use :func:`imod.formats.idf.open` instead -- ``imod.formats.rasterio.read`` is deprecated, use :func:`imod.formats.rasterio.open` instead - -Fixed -~~~~~ - -- :meth:`imod.prepare.reproject` working instead of silently failing when given a ``"+init=ESPG:XXXX`` CRS string - -[0.8.0] - 2019-10-14 --------------------- - -Added -~~~~~ -- Laplace grid interpolation :meth:`imod.prepare.laplace_interpolate` -- Experimental MODFLOW 6 structured model write support :mod:`imod.mf6` -- More supported visualizations :mod:`imod.visualize` -- More extensive reading and writing of GDAL raster in :mod:`imod.rasterio` - -Changed -~~~~~~~ - -- The documentation moved to a custom domain name: https://imod.xyz/ - -[0.7.1] - 2019-08-07 --------------------- - -Added -~~~~~ -- ``"multilinear"`` has been added as a regridding option to ``imod.prepare.Regridder`` to do linear interpolation up to three dimensions. -- Boundary condition packages in ``imod.wq`` support a method called ``add_timemap`` to do cyclical boundary conditions, such as summer and winter stages. - -Fixed -~~~~~ - -- ``imod.idf.save`` no longer fails on a single IDF when it is a voxel IDF (when it has top and bottom data). -- ``imod.prepare.celltable`` now succesfully does parallel chunkwise operations, rather than raising an error. -- ``imod.Regridder``'s ``regrid`` method now succesfully returns ``source`` if all dimensions already have the right cell sizes, rather than raising an error. -- ``imod.idf.open_subdomains`` is much faster now at merging different subdomain IDFs of a parallel modflow simulation. -- ``imod.idf.save`` no longer suffers from extremely slow execution when the DataArray to save is chunked (it got extremely slow in some cases). -- Package checks in ``imod.wq.SeawatModel`` succesfully reduces over dimensions. -- Fix last case in ``imod.prepare.reproject`` where it did not allocate a new array yet, but returned ``like`` instead of the reprojected result. - -[0.7.0] - 2019-07-23 --------------------- - -Added -~~~~~ - -- :mod:`imod.wq` module to create iMODFLOW Water Quality models -- conda-forge recipe to install imod (https://github.com/conda-forge/imod-feedstock/) -- significantly extended documentation and examples -- :mod:`imod.prepare` module with many data mangling functions -- :mod:`imod.select` module for extracting data along cross sections or at points -- :mod:`imod.visualize` module added to visualize results -- :func:`imod.idf.open_subdomains` function to open and merge the IDF results of a parallelized run -- :func:`imod.formats.ipf.read` now infers delimeters for the headers and the body -- :func:`imod.formats.ipf.read` can now deal with heterogeneous delimiters between multiple IPF files, and between the headers and body in a single file - -Changed -~~~~~~~ - -- Namespaces: lift many functions one level, such that you can use e.g. the function ``imod.prepare.reproject`` instead of ``imod.prepare.reproject.reproject`` - -Removed -~~~~~~~ - -- All that was deprecated in v0.6.0 - -Deprecated -~~~~~~~~~~ - -- :func:`imod.seawat_write` is deprecated, use the write method of :class:`imod.wq.SeawatModel` instead -- :func:`imod.run.seawat_get_runfile` is deprecated, use :mod:`imod.wq` instead -- :func:`imod.run.seawat_write_runfile` is deprecated, use :mod:`imod.wq` instead - -[0.6.1] - 2019-04-17 --------------------- - -Added -~~~~~ - -- Support nonequidistant models in runfile - -Fixed -~~~~~ - -- Time conversion in runfile now also accepts cftime objects - -[0.6.0] - 2019-03-15 --------------------- - -The primary change is that a number of functions have been renamed to -better communicate what they do. - -The ``load`` function name was not appropriate for IDFs, since the IDFs -are not loaded into memory. Rather, they are opened and the headers are -read; the data is only loaded when needed, in accordance with -``xarray``'s design; compare for example ``xarray.open_dataset``. The -function has been renamed to ``open``. - -Similarly, ``load`` for IPFs has been deprecated. ``imod.ipf.read`` now -reads both single and multiple IPF files into a single -``pandas.DataFrame``. - -Removed -~~~~~~~ - -- ``imod.idf.setnodataheader`` - -Deprecated -~~~~~~~~~~ - -- Opening IDFs with ``imod.idf.load``, use ``imod.idf.open`` instead -- Opening a set of IDFs with ``imod.idf.loadset``, use - ``imod.idf.open_dataset`` instead -- Reading IPFs with ``imod.ipf.load``, use ``imod.ipf.read`` -- Reading IDF data into a dask array with ``imod.idf.dask``, use - ``imod.idf._dask`` instead -- Reading an iMOD-seawat .tec file, use ``imod.tec.read`` instead. - -Changed -~~~~~~~ - -- Use ``np.datetime64`` when dates are within time bounds, use - ``cftime.DatetimeProlepticGregorian`` when they are not (matches - ``xarray`` defaults) -- ``assert`` is no longer used to catch faulty input arguments, - appropriate exceptions are raised instead - -Fixed -~~~~~ - -- ``idf.open``: sorts both paths and headers consistently so data does - not end up mixed up in the DataArray -- ``idf.open``: Return an ``xarray.CFTimeIndex`` rather than an array - of ``cftime.DatimeProlepticGregorian`` objects -- ``idf.save`` properly forwards ``nodata`` argument to ``write`` -- ``idf.write`` coerces coordinates to floats before writing -- ``ipf.read``: Significant performance increase for reading IPF - timeseries by specifying the datetime format -- ``ipf.write`` no longer writes ``,,`` for missing data (which iMOD - does not accept) - -[0.5.0] - 2019-02-26 --------------------- - -Removed -~~~~~~~ - -- Reading IDFs with the ``chunks`` option - -Deprecated -~~~~~~~~~~ - -- Reading IDFs with the ``memmap`` option -- ``imod.idf.dataarray``, use ``imod.idf.load`` instead - -Changed -~~~~~~~ - -- Reading IDFs gives delayed objects, which are only read on demand by - dask -- IDF: instead of ``res`` and ``transform`` attributes, use ``dx`` and - ``dy`` coordinates (0D or 1D) -- Use ``cftime.DatetimeProlepticGregorian`` to support time instead of - ``np.datetime64``, allowing longer timespans -- Repository moved from ``https://gitlab.com/deltares/`` to - ``https://gitlab.com/deltares/imod/`` - -Added -~~~~~ - -- Notebook in ``examples`` folder for synthetic model example -- Support for nonequidistant IDF files, by adding ``dx`` and ``dy`` - coordinates - -Fixed -~~~~~ - -- IPF support implicit ``itype`` - -.. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/ -.. _Semantic Versioning: https://semver.org/spec/v2.0.0.html +Changelog +========= + +All notable changes to this project will be documented in this file. + +The format is based on `Keep a Changelog`_, and this project adheres to +`Semantic Versioning`_. + +[Unreleased] +------------ + +Added +~~~~~ + +- Experimental class :class:`imod.msw.SprinklingPoints` to specify sprinkling + from points for MetaSWAP models, instead of from grid. You can use this to + specify sprinkling wells from IPF files in an iMOD5 CAP dataset with + :meth:`imod.msw.SprinklingPoints.from_imod5_data`. +- :class:`imod.mf6.LayeredWell.from_imod5_cap_data` now also supports loading + wells from IPF files in an iMOD5 CAP dataset. +- Added ``drop_empty_layers: bool = True`` to various cell allocation functions + in :mod:`imod.prepare.topsystem.allocation` to remove fully empty layers from the grids. + Strips the empty layers before they are passed along to + reprojection/regridding operations, which can save considerable time for models with + many empty layers. Set to False to keep the previous full-layer-coordinate + behaviour. :meth:`imod.prepare.topsystem.allocation.allocate_riv_cells`, + :meth:`imod.prepare.topsystem.allocation.allocate_drn_cells`, + :meth:`imod.prepare.topsystem.allocation.allocate_ghb_cells`, + :meth:`imod.prepare.topsystem.allocation.allocate_rch_cells` +- Added ``drop_empty_layers: bool = True`` to + :meth:`imod.mf6.River.reallocate`, :meth:`imod.mf6.Drainage.reallocate`, + :meth:`imod.mf6.GeneralHeadBoundary.reallocate`, and + :meth:`imod.mf6.Recharge.reallocate`. Allocation and conductance + distribution are always computed over the full layer range first; only + the final package has fully empty layers trimmed off afterwards, so this + does not affect computed values. Set to False to keep the previous + full-layer-coordinate behaviour. + +Fixed +~~~~~ + +- Fixed resampling in :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` when simulation timesteps precede + the first well timestep. +- Fixed :func:`imod.prepare.cleanup.align_interface_levels` (used by + ``cleanup_riv``, and therefore :meth:`imod.mf6.River.cleanup`) raising an + alignment error when a package's own layer coordinate is a subset of the + model's full layer range, e.g. after :meth:`imod.mf6.River.reallocate` with + ``drop_empty_layers=True``. + +Changed +~~~~~~~ + +- Deprecated :class:`imod.msw.Sprinkling` in favor of + :class:`imod.msw.SprinklingGrid`. Call :class:`imod.msw.SprinklingGrid` to get + the same behavior as you were used to. + +[1.1.0] - 2026-08-03 +-------------------- + +Added +~~~~~ + +- Added ``ignore_time_purge_empty`` argument to + :meth:`imod.mf6.Modflow6Simulation.mask_all_models` and + :meth:`imod.mf6.Modflow6Simulation.clip_box` to consider a package empty if + its first times step is all nodata. This can save a lot of clipping or masking + transient models with many timesteps. +- Added :meth:`imod.msw.MetaSwapModel.split` to split MetaSWAP models. +- Added :meth:`imod.mf6.HorizontalFlowBarrierResistance.from_imod5_data` to load + barriers from 3D GEN files. +- Added ``name`` argument to :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` + to provide custom name to imported simulation and model. +- Added :meth:`imod.msw.MetaSwapModel.mask_all_packages` to mask all packages of + a MetaSWAP model. +- Added optional ``target_grid`` argument to + :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, + :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data`, + :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` to specify a target + grid for regridding the iMOD5 data to. If not provided, the first IBOUND layer + is used as target grid, like in iMOD5. + +Fixed +~~~~~ + +- Fixed bug in :class:`imod.mf6.GroundwaterFlowModel` and :class:`imod.formats.prf.IpfResult` + where names of wels were duplicated by increasing the character limit to 40 + and enumerating wel names. +- Fixed bug where :class:`imod.mf6.Evapotranspiration` package would write files + to binary, which could not be parsed by MODFLOW 6 when ``proportion_depth`` + and ``proportion_rate`` were provided without segments. +- Fixed bug where :class:`imod.mf6.ConstantConcentration` package could not be written + for multiple timesteps. +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` where a ValidationError + was thrown when clipping a model with a :class:`imod.mf6.ConstantHead` or + :class:`imod.mf6.ConstantConcentration` package with a ``time`` dimension and + providing ``states_for_boundary``. +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` would drop layers if + ``states_for_boundary`` were provided and the model already contained a + :class:`imod.mf6.ConstantHead` or :class:`imod.mf6.ConstantConcentration` with + less layers. +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` would not properly + align timesteps and forward fill data if ``states_for_boundary`` were provided + and the model already contained a :class:`imod.mf6.ConstantHead` or + :class:`imod.mf6.ConstantConcentration`, both with timesteps, which were + unaligned. +- Fixed bug where :class:`imod.mf6.Lake` package did not pass ``budgetfile``, + ``budgetcsvfile``, ``stagefile`` options to the written MODFLOW 6 package. +- Fixed bug where :func:`imod.evaluate.convert_pointwaterhead_freshwaterhead` + produced incorrect results when point water heads were below elevation levels + for unstructured grids. +- Support pandas 3.0. +- :class:`imod.msw.IdfMapping` when model clipping is applied, the global + row/column indices are converted to local indices, as written in + ``idf_svat.inp``. +- Fixed edge case where allocation of :class:`imod.mf6.River` package with the + ``stage_to_riv_bot`` or ``stage_to_riv_bot_drn_above`` option of + :func:`imod.prepare.ALLOCATION_OPTION` would assign river cells to the wrong + layer, when the stage and bottom_elevation were exactly equal to the bottom of + a layer in the model discretization, which would cause these cells to be + dropped when distributing conductances later. +- Fixed :func:`imod.prepare.spatial.polygonize` for polygons with holes. +- :func:`imod.formats.prj.open_projectfile_data` now drops empty wells from the + dataset, and logs a warning about it. +- :meth:`imod.mf6.NodePropertyFlow.regrid_like` now regrids ``k33`` using the + correct method, namely ``mean`` instead of ``harmonic_mean``. As this is the + appropriate method for horizontal regridding of ``k33``. +- :meth:`imod.msw.MetaSwapModel.from_imod5_data`, + :meth:`imod.mf6.Recharge.from_imod5_cap_data`, + :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` now regrids the iMOD5 CAP + data to the MODFLOW6 target discretization. +- Fixed confusing warning about inconsistent IPF columns when loading GEN files. +- Fix bug where iMOD Python would error on writing a model where package + settings were specified as dask array, which could happen when loading a model + lazily with :meth:`imod.mf6.Modflow6Simulation.from_file` and not + computing the data before writing. +- Fixed bug where ``concentration`` variables were needlessly loaded into + memory. Affected :class:`imod.mf6.River`, :class:`imod.mf6.Drain`, + :class:`imod.mf6.ConstantHeadBoundary`, :class:`imod.mf6.Recharge`, + :class:`imod.mf6.Well` and :class:`imod.mf6.GeneralHeadBoundary`. +- Fix bug where ``maxbound`` of the ``.wel`` file computed by + :class:`imod.mf6.Mf6Wel` was twice or thrice too large. +- Fixed bug where :func:`imod.evaluate.facebudget` raised an error when the + ``front`` budget was left out, even though you only need to provide one of + ``front``, ``lower`` or ``right``. Leaving out ``front`` now works as + described in the documentation. +- Fixed big performance degradation with :func:`imod.idf.open_subdomains` where + it would take a long time to open lots of idf files. Performance is now + significantly improved up to the same speed as before the change that caused + the performance degradation. + +Changed +~~~~~~~ + +- Increased the character limit to 40 in :class:`imod.mf6.Modflow6Model` for all + keys assigned to a Modflow6 model. +- ``proportion_depth`` and ``proportion_rate`` in + :class:`imod.mf6.Evapotranspiration` are now optional variables. If provided, + now require ``"segment"`` dimension when ``proportion_depth`` and + ``proportion_rate``. +- :meth:`imod.msw.GridData.generate_index_array` is now deprecated, use + :meth:`imod.msw.GridData.generate_isactive_svat_arrays` instead. +- If no ``target_grid`` is provided, + :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` chooses a grid the + same as iMOD5 did: the first IBOUND layer. This is different from previous + versions of iMOD Python, which defaulted to the smallest possible extent + and finest resolution, based on the iMOD5 IBOUND, TOP and BOTTOM data. + + +[1.0.0] - 2025-11-11 +-------------------- + +Fixed +~~~~~ + +- Improved performance of :meth:`imod.mf6.Modflow6Simulation.split` for large + models loaded lazily into memory. Reduced a splitting operation of 2 hours to + a few minutes for a test case. +- Issue where :meth:`imod.mf6.LayeredWell.from_imod5_data` would result in wells + with a mismatch between coordinates and rates. + +Added +~~~~~ + +- Added :class:`imod.mf6.Viscosity` package to specify the viscosity of the + groundwater flow model. +- Functionality to dump and load MODFLOW 6 simulations to/from zarr and zipstore + formats. See :meth:`imod.mf6.Modflow6Simulation.dump` and + :meth:`imod.mf6.Modflow6Simulation.from_file` for more information. +- Functionality to dump and load MetaSwap models to/from netcdf + format. See :meth:`imod.msw.MetaSwapModel.dump` and + :meth:`imod.msw.MetaSwapModel.from_file` for more information. + +Changed +~~~~~~~ + +- :class:`imod.mf6.Well` and :func:`imod.prepare.assign_wells` now distribute + the well rates over the screened cells using a correction factor based on the + mismatch between the well screen center and the cell center, equal to iMOD5's + correction factor. + + +[1.0.0rc7] - 2025-10-28 +----------------------- + +Added +~~~~~ + +- :meth:`imod.mf6.Modflow6Simulation.set_validation_settings` to set validation + settings for a MODFLOW 6 simulation. See :class:`imod.mf6.ValidationSettings` + for more information. + +Changed +~~~~~~~ + +- No automatic validation upon calling :meth:`imod.mf6.Modflow6Simulation.regrid_like` anymore. + Use the ``validate`` argument of :meth:`imod.mf6.Modflow6Simulation.write` to + validate the regridded model upon writing instead. +- :class:`imod.mf6.River` now ignore confined cells (``icelltype == 0``) when + validating whether the river bottom elevation is below the model bottom + elevation. +- Moved :func:`imod.select.get_upper_active_layer_number`, + :func:`imod.select.get_upper_active_cells`, + :func:`imod.select.get_lower_active_cells`, and + :func:`imod.select.get_lower_active_layer_number` from :mod:`imod.prepare`. to + :mod:`imod.select`. +- :class:`imod.mf6.Dispersion` now is not a required package for + :class:`imod.mf6.GroundwaterTransportModel` anymore. +- No validation anymore for ``icelltype`` upon writing + :class:`imod.mf6.SpecificStorage` and :class:`imod.mf6.StorageCoefficient`. + + +Removed +~~~~~~~ + +- Removed ``imod.select.upper_active_layer`` function, use + :func:`imod.select.get_upper_active_layer_number` instead. + +Fixed +~~~~~ + +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.split` could result in + empty exchanges being present in the ``split_exchanges`` package list, when + two models were isolated by inactive cells from each other. These empty + exchanges are now removed. +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.split`, + :meth:`imod.mf6.Modflow6Simulation.regrid_like`, and + :meth:`imod.mf6.Modflow6Simulation.clip_box` would not copy + :class:`imod.mf6.ValidationSettings`. +- ``landuse``, ``soil_physical_unit``, ``active`` for :class:`imod.msw.GridData` + are now properly regridded with the ``mode`` statistic when using + :meth:`imod.msw.GridData.regrid_like`. + +[1.0.0rc6] - 2025-08-28 +----------------------- + +Small post-release to fix rendering of documentation online. + +[1.0.0rc5] - 2025-08-27 +----------------------- + +Added +~~~~~ + +- :meth:`imod.mf6.River.reallocate`, :meth:`imod.mf6.Drainage.reallocate`, + :meth:`imod.mf6.GeneralHeadBoundary.reallocate`, + :meth:`imod.mf6.Recharge.reallocate` to reallocate the package data to a new + discretization or :class:`imod.mf6.NodePropertyFlow` package, or to use a + different :class:`imod.prepare.ALLOCATION_OPTION` or + :class:`imod.prepare.DISTRIBUTING_OPTION`. +- Added :meth:`imod.mf6.HorizontalFlowBarrierResistance.snap_to_grid` and + :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.snap_to_grid` to + debug how horizontal flow barriers are snapped to a grid. +- Added :meth:`imod.mf6.Modflow6Simulation.create_partition_labels` to create + partition labels for a MODFLOW 6 simulation from its idomain. This is useful + for splitting a simulation into multiple submodels. +- :class:`imod.mf6.AdaptiveTimeStepping` to specify adaptive time stepping + settings for MODFLOW 6 simulations. +- The ``ats_percel`` argument to :class:`imod.mf6.AdvectionTVD`, + :class:`imod.mf6.AdvectionUpstream`, :class:`imod.mf6.AdvectionCentral` to + adapt the time step based on the maximum fraction of a cell that a solute + parcel is allowed to travel. + +Fixed +~~~~~ + +- Reduce noisy warnings in models loaded with + :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` which have layers with + cells with zero thicknesses. +- Issue where regridding lead to excessively large inactive areas. +- Issue where regridding would lead to very large negative integer values (like + IDOMAIN) for inactive areas. +- Issue where :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` would throw a KeyError 0 upon + trying to resample timeseries with a non-zero index. +- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB + packages would have resistances that were double the expected value with + xugrid >= 0.14.2 +- The ``states_for_boundary`` argument now also works for tranport models in + :meth:`imod.mf6.Modflow6Simulation.clip_box`. +- Fix bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` and the + ``states_for_boundary`` argument would place these bc at the + incorrect places with unstructured grids. +- Fixed bug where :meth:`imod.mf6.SourceSinkMixing.from_flow_model` would return + an error upon adding a package which cannot have a ``concentration``, such as + :class:`imod.mf6.HorizontalFlowBarrierResistance`. +- Broken names for ``outer_csvfile`` and ``inner_csvfile`` in the + :class:`imod.mf6.Solution` MODFLOW 6 template file. + +Changed +~~~~~~~ + +- :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` and + :meth:`imod.mf6.NodePropertyFlow.from_imod5_data` now automatically load the + dataset into memory. This improves performance when loading models with + multiple topsystem packages. +- No upper limit anymore for ``mod_id`` in ``mod2svat.inp`` for + :class:`imod.msw.CouplerMapping`. +- :func:`imod.prepare.create_partition_labels` now takes an ``idomain`` grid + instead of :class:`imod.mf6.Modflow6Simulation` as first argument. To generate + partition labels from a :class:`imod.mf6.Modflow6Simulation` straightaway, use + the newly added :meth:`imod.mf6.Modflow6Simulation.create_partition_labels` + method instead. + +Removed +~~~~~~~ + +- Removed ``imod.mf6.WellDisStructured`` and ``imod.mf6.WellDisVertices``. Use + :class:`imod.mf6.Well` and :class:`imod.mf6.LayeredWell` instead. The + :class:`imod.mf6.Well` package can be used to specify wells with filters, + :class:`imod.mf6.LayeredWell` directly to layers. +- Removed ``imod.mf6.multimodel.partition_generator.get_label_array``, use + :func:`imod.prepare.create_partition_labels` instead. +- Removed ``imod.formats.idf.read`` use :func:`imod.formats.idf.open` instead. +- Removed ``imod.formats.rasterio.read`` use :func:`imod.formats.rasterio.open` instead. +- Removed ``head`` argument for :class:`imod.mf6.InitialConditions`, use + ``start`` instead. +- Removed ``cell_averaging`` argument for :class:`imod.mf6.NodePropertyFlow`, + use ``alternative_cell_averaging`` instead. +- Removed ``set_repeat_stress`` method from boundary condition packages like + :class:`imod.mf6.River`. Use ``repeat_stress`` argument instead. +- Removed ``time_discretization`` method from + :class:`imod.mf6.Modflow6Simulation` and :class:`imod.wq.SeawatModel`. Use + :meth:`imod.mf6.Modflow6Simulation.create_time_discretization` and + :meth:`imod.wq.SeawatModel.create_time_discretization` instead. +- Removed ``imod.util.round_extent``, use :func:`imod.prepare.round_extent` + instead. +- Removed :class:`imod.prepare.Regridder`. Use the `xugrid regridder + `_ instead. + + +[1.0.0rc4] - 2025-06-20 +----------------------- + +Added +~~~~~ + +- Added ``weights`` argument to :func:`imod.prepare.create_partition_labels` to + weigh how the simulation should be partioned. Areas with higher weights will + result in smaller partions. +- iMOD Python version is now written in a comment line to MODFLOW6 and + MetaSWAP's ``para_sim.inp`` files. This is useful for debugging purposes. +- Added option ``ignore_time_purge_empty`` to + :meth:`imod.mf6.Modflow6Simulation.split` to consider a package empty if its + first times step is all nodata. This can save a lot of time splitting + transient models. +- Add :class:`imod.mf6.ValidationSettings` to specify validation settings for + MODFLOW 6 simulations. You can provide it to the + :class:`imod.mf6.Modflow6Simulation` constructor. + +Fixed +~~~~~ + +- Upon providing an unexpected coordinate in the mask or regridding grid, + :meth:`imod.mf6.Modflow6Simulation.regrid_like` and + :meth:`imod.mf6.Modflow6Simulation.mask_all_models` now present the unexpected + coordinates in the error message. +- :class:`imod.mf6.VerticesDiscretization` now correctly sets the ``xorigins`` + and ``yorigins`` options in the ``.disv`` file. Incorrect origins cause issues + when splitting models and computing with XT3D on the exchanges. +- :func:`imod.mf6.open_cbc` and :func:`imod.mf6.open_hds` now account for + xorigins and yorigins for models ran with + :class:`imod.mf6.VerticesDiscretization`. **WARNING**: Given that these were + set incorrectly in previous versions of iMOD Python (see previous item in this + list), this means that reading MODFLOW6 DISV output of models generated with a + previous version of iMOD Python will result in a grid with an erroneous + offset. You can work around this by creating the model again with this + version of iMOD Python or newer. +- :meth:`imod.mf6.Modflow6Simulation.split` supports label array with a + different name than ``"idomain"``. +- :func:`imod.msw.MetaSwapModel.from_imod5_data` now supports the usage of + relative paths for the extra files block. +- Bug in :meth:`imod.msw.Sprinkling.write` where MetaSWAP svats with surface + water sprinkling and no groundwater sprinkling activated were not written to + ``scap_svat.inp``. +- :class:`imod.msw.IdfMapping` swapped order of y_grid and x_grid in dictionary + for writing the correct order of coordinates in idf_svat.inp. +- Improved performance of :meth:`imod.mf6.Modflow6Simulation.split` and + :meth:`imod.mf6.Modflow6Simulation.mask_all_models` when using dask. +- Fixed bug in :meth:`imod.mf6.Modflow6Simulation.mask_all_models` for unstructured grids + with a spatial dimension that differs from the default ``"mesh2d_nFaces"``. +- Fixed bug in :meth:`imod.mf6.Well.cleanup` and + :meth:`imod.mf6.LayeredWell.cleanup` which caused an error when called with an + unstructured discretization. +- Fixed bug in :func:`imod.formats.prj.open_projectfile_data` which caused an + error when a periods keyword was used having an upper case. +- Poor performance of :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` when the ``imod5_data`` contained + a well system with a large number of wells (>10k). +- :meth:`imod.mf6.River.from_imod5_data`, + :meth:`imod.mf6.Drainage.from_imod5_data`, + :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data` can now deal with + constant values for variables. One variable per package still needs to be a + grid. +- Fix bug where an error was thrown in ``get_non_grid_data`` when calling the + ``.cleanup`` and ``regrid_like`` methods on a boundary condition package with + a repeated stress. For example, :meth:`imod.mf6.River.cleanup` or + :meth:`imod.mf6.River.regrid_like`. +- Fix bug where an error was thrown in :class:`imod.mf6.Well` when an entry had + to be filtered and its ``id`` didn't match the index. +- Improved performance of :class:`imod.mf6.Modflow6Simulation.split` for + structured models, as unnecessary masking is avoided. +- Fixed warning thrown by type dispatcher about ``~GeoDataFrameType`` +- Fixed bug where variables in a package with only a ``"layer"`` coordinate + could not be regridded or masked. + +Changed +~~~~~~~ + +- :meth:`imod.wq.SeawatModel.write` now throws an error if trying to write in a + directory with a space in the path. (iMOD-WQ does not support this.) +- `imod.mf6.multimodel.partition_generator.get_label_array` moved to + :func:`imod.prepare.create_partition_labels`. +- :func:`imod.prepare.create_partition_labels` structured grids are now + partioned by METIS instead (just like already was the case for unstructured + grids). This results in more balanced partitions for grids with non-square + domains or lots of inactive cells. Downside is that the partitions are more + often than not perfectly rectangular in shape. +- :func:`imod.prepare.create_partition_labels` now returns a griddata with the + name ``"label"`` instead of ``"idomain"``. +- Upon providing the wrong type to one of the options of + :class:`imod.mf6.GroundwaterFlowModel`, + :class:`imod.mf6.GroundwaterTransportModel`, this will throw a + ``ValidationError`` upon initialization and writing. +- You can now also provide ``repeat_stress`` as dictionary to imod.mf6 + boundary conditions, such as :class:`imod.mf6.River`, :class:`imod.mf6.Drainage`, and + :class:`imod.mf6.GeneralHeadBoundary`. +- :meth:`imod.mf6.ConstantHead.from_imod5_data`, + :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data`, + :meth:`imod.mf6.River.from_imod5_data`, + :meth:`imod.mf6.Recharge.from_imod5_data`, and + :meth:`imod.mf6.Drainage.from_imod5_data` now forward fill data over time, + instead of clipping, when selecting a start time that is inbetween two data + records. +- :meth:`imod.mf6.ConstantHead.from_imod5_data` and + :meth:`imod.mf6.Recharge.from_imod5_data` got extra arguments for + ``period_data``, ``time_min`` and ``time_max``. +- :func:`imod.visualize.read_imod_legend` now also returns the labels as an extra + argument. Update your code by changing + ``colors, levels = read_imod_legend(...)`` to + ``colors, levels, labels = read_imod_legend(...)``. + + +[1.0.0rc3] - 2025-04-17 +----------------------- + +Added +~~~~~ + +- :meth:`imod.msw.MetaSwapModel.clip_box` to clip MetaSWAP models. +- Methods of class :class:`imod.mf6.Modflow6Simulation` can now be logged. +- :func:`imod.prepare.cleanup.cleanup_wel_layered` to clean up wells assigned + to layers. + + +Fixed +~~~~~ + +- Fixed bug where :meth:`imod.mf6.River.clip_box`, + :meth:`imod.mf6.Drainage.clip_box`, and + :meth:`imod.mf6.GeneralHeadBoundary.clip_box` threw an error when + ``time_start`` or ``time_end`` were set to ``None`` and a ``"repeat_stress"`` + was included in the dataset. +- Fixed bug where :meth:`imod.mf6.package.copy` threw an error. +- Sorting issue in :func:`imod.prepare.assign_wells`. This could cause + :class:`imod.mf6.Well` to assign wells to the wrong cells. +- Fixed crash upon calling :meth:`imod.mf6.Well.clip_box` when the top/bottom + arguments are specified. This could cause :class:`imod.mf6.Well` to crash + when wells are located outside the extent of the layer model. + + +[1.0.0rc2] - 2025-03-05 +----------------------- + +From this release on, we recommend using `xugrid's regridding utilities +`_ for +regridding individual grids instead of :class:`imod.prepare.Regridder`. Xugrid's +regridders are tested to be about 10 times faster than +:class:`imod.prepare.Regridder`. There is one small difference: xugrid's +``xugrid.BaryCentricInterpolator`` considers sample points of the destination +grid that lie on the source grid's cell edges to be inside, whereas +:class:`imod.prepare.Regridder` considers them to be outside. This difference is +negligible for most applications, but might create slightly fewer ``np.nan`` +values than before. + +Removed +~~~~~~~ +- ``imod.flow`` module has been removed for generating iMODFLOW models. Use + ``imod.mf6`` instead to generate MODFLOW 6 models. + +Added +~~~~~ + +- Support for Python 3.13. +- :meth:`imod.mf6.Recharge.from_imod5_data`, + :meth:`imod.mf6.River.from_imod5_data`, + :meth:`imod.mf6.Drainage.from_imod5_data`, and + :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data` now assign negative layer + numbers to the first active layer. +- :func:`imod.prepare.DISTRIBUTING_OPTION` got a new setting + ``by_corrected_thickness``. This matches DISTRCOND=-1 in iMOD5. +- :func:`imod.prepare.cleanup_hfb` to clean up HFB geometries. +- :meth:`imod.mf6.HorizontalFlowBarrierResistance.cleanup`, + :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.cleanup`, + to clean up HFB geometries crossing inactive model cells. +- :class:`imod.util.RegridderWeightsCache` to store regridder weights for + regridding multiple times. +- :class:`imod.util.RegridderType` to specify regridder types. + +Changed +~~~~~~~ + +- :func:`imod.formats.prj.open_projectfile_data` now also assigns negative and + zero layer numbers to grid coordinates. +- In :class:`imod.mf6.StructuredDiscretization`, IDOMAIN can now respectively be + > 0 to indicate an active cell and <0 to indicate a vertical passthrough cell, + consistent with MODFLOW 6. Previously this could only be indicated with 1 and + -1. +- :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` now also accept the argument + ``times = "steady-state"``, for the simulation is assumed to be "steady-state" + and well timeseries are averaged. +- The ``drn`` attribute of :class:`imod.prepare.SimulationAllocationOptions` has + the ``at_elevation`` of :func:`imod.prepare.ALLOCATION_OPTION` option now set + as default. This means by default drainage cells are placed differently in + :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`. +- :class:`imod.mf6.Well`, :class:`imod.mf6.LayeredWell`, + :func:`imod.prepare.assign_wells`, :meth:`imod.mf6.Well.from_imod5_data` + and :meth:`imod.mf6.LayeredWell.from_imod5_data` now have default values for + ``minimum_thickness`` and ``minimum_k`` set to 0.0. +- When intitating a MODFLOW 6 package with a ``layer`` coordinate with + values <= 0, iMOD Python will throw an error. +- :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB now + validate whether proper type of geometry is provided, respectively Polygon for + :class:`imod.mf6.HorizontalFlowBarrierResistance`, and LineString for + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance`. +- Relaxed validation for :class:`imod.msw.MetaSwapModel` if ``FileCopier`` + package is present. +- Change aterisk to dash and tabs to four spaces in ``ValidationError`` messages. +- :func:`imod.prepare.laplace_interpolate` has been simplified, using + ``scipy.sparse.linalg.cg`` as the backend. We've remove the support for the + ``ibound`` argument, the ``iter1`` argument has been dropped, ``mxiter`` has + been renamed to ``maxiter``, ``close`` has been renamed to ``rtol``. +- Moved ``imod.mf6.utilities.regrid.RegridderWeightsCache`` to the + :class:`imod.util.regrid.RegridderWeightsCache`. + +Fixed +~~~~~ + +- :meth:`imod.mf6.GroundwaterFlowModel.mask_all_packages` now preserves the ``dx`` and + ``dy`` coordinates +- :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` ignore well rates preceding first + element of ``times``. +- :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` now sum the rates of well entries + that are on the exact same location (same x, y, and depth) instead of taking + the values of the first entry. +- :meth:`imod.mf6.River.from_imod5_data` now preserves the drainage cells + created with the ``stage_to_riv_bot_drn_above`` option of + :func:`imod.prepare.ALLOCATION_OPTION`. +- Bug in :func:`imod.prepare.distribute_riv_conductance` where conductances were + set to ``np.nan`` for cells where ``stage`` equals ``bottom_elevation`` when + :func:`imod.prepare.DISTRIBUTING_OPTION` was set to ``by_crosscut_thickness``, + ``by_crosscut_transmissivity``, ``by_corrected_transmissivity``. +- :meth:`imod.mf6.NodePropertyFlow.from_imod5_data` now defaults to 90 degrees + for missing layers ``imod5_data`` instead of 0 degrees. +- Bug in :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` where an error was + raised in case the ``"cap"`` package was present in the ``imod5_data``. +- Bug where :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` and + :meth:`imod.mf6.Recharge.from_imod5_cap_data` threw an error if the ``"cap"`` + in the ``imod5_data`` had a ``"layer"`` dimension and coordinate. +- :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` will convert the + ``max_abstraction_groundwater`` and ``max_abstraction_surfacewater`` capacity + from mm/d to m3/d. +- :class:`imod.msw.TimeOutputControl` now starts counting at 0.0 instead of 1.0, + like MetaSWAP expects. +- Models imported with :meth:`imod.msw.MetaSwapModel.from_imod5_data` can be + written with ``validate`` set to True. +- :meth:`imod.mf6.Recharge.from_imod5_cap_data` now returns a 2D array with a + ``"layer"`` coordinate of ``1`` as otherwise ``primod`` throws an error when + trying to derive recharge-svat mappings. +- Fixed part of the code that made Pandas, Geopandas, and xarray throw a lot of + ``FutureWarning`` and ``DeprecationWarning``. +- Fixed performance issue when converting very large wells (>10k) with + :meth:`imod.mf6.Well.to_mf6_pkg` and :meth:`imod.mf6.LayeredWell.to_mf6_pkg`, + such as those created with :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` + for a large grid. +- Fixed issue where an error was thrown when deriving couplings for + :class:`imod.msw.CouplerMapping` and computing svats in + :class:`imod.msw.GridData` with ``dask>=2025.2.0``. +- Fixed a bug where :func:`imod.mf6.out.open_cbc` did not properly sum fluxes + for a single boundary condition package when multiple entries were present in + the same cell. This never happened with models generated by iMOD Python, as it + cannot generate these boundary conditions, but could be a problem with models + generated by iMOD5 and Flopy. +- Removed duplicate entries in ``mod2svat.inp`` generated by + :class:`imod.msw.CouplerMapping` as MetaSWAP cannot handle this. + + +[1.0.0rc1] - 2024-12-20 +----------------------- + +Small post-release fix for installation instructions in documentation. + +[1.0.0rc0] - 2024-12-20 +----------------------- + +Added +~~~~~ + +- :class:`imod.msw.MeteoGridCopy` to copy existing `mete_grid.inp` files, so + ASCII grids in large existing meteo databases do not have to be read. +- :class:`imod.msw.FileCopier` to copy settings and lookup tables in existing + ``.inp`` files. +- :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` to construct a + :class:`imod.mf6.LayeredWell` package from iMOD5 data in the CAP package (for + MetaSWAP). Currently only griddata (IDF) is supported. +- :meth:`imod.mf6.Recharge.from_imod5_cap_data` to construct a recharge package + for coupling a MODFLOW 6 model to MetaSWAP. +- :meth:`imod.msw.MetaSwapModel.from_imod5_data` to construct a MetaSWAP model + from data in an iMOD5 projectfile. +- :meth:`imod.msw.MetaSwapModel.write` has a ``validate`` argument, which can be + used to turn off validation upon writing, use at your own risk! +- :class:`imod.msw.MetaSwapModel` got ``settings`` argument to set simulation + settings. +- :func:`imod.data.tutorial_03` to load data for the iMOD Documentation + tutorial. +- :meth:`imod.mf6.Modflow6Simulation.dump` now saves iMOD Python version number. + +Fixed +~~~~~ + +- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB + packages could not be allocated to cell edges when idomain in layer 1 was + largely inactive. +- Fixed bug where :meth:`imod.mf6.HorizontalFlowBarrierResistance.clip_box`, + :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.clip_box` methods + only returned deepcopy instead of actually clipping the line geometries. +- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB + packages could not be clipped or copied with xarray >= 2024.10.0. +- Fixed crash upon calling :meth:`imod.mf6.GroundwaterFlowModel.dump`, when a + :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` or other HFB + package was assigned to the model. +- :meth:`imod.mf6.Modflow6Simulation.regrid_like` can now regrid a structured + model to an unstructured grid. +- :meth:`imod.mf6.Modflow6Simulation.regrid_like` throws a + ``NotImplementedError`` when attempting to regrid an unstructured model to a + structured grid. +- :class:`imod.msw.Sprinkling` now correctly writes source svats to + scap_svat.inp file. +- :func:`imod.evaluate.calculate_gxg`, upon providing a head dataarray chunked + over time, will no longer error with ``ValueError: Object has inconsistent + chunks along dimension bimonth. This can be fixed by calling unify_chunks().`` +- Improved performance of regridding package data. + + +Changed +~~~~~~~ + +- :class:`imod.msw.Infiltration`'s variables ``upward_resistance`` and + ``downward_resistance`` now require a ``subunit`` coordinate. +- Variables ``max_abstraction_groundwater`` and ``max_abstraction_surfacewater`` + in :class:`imod.msw.Sprinkling` now needs to have a subunit coordinate. +- If ``"cap"`` package present in ``imod5_data``, + :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data` now automatically adds a + well for metaswap sprinkling named ``"msw-sprinkling"`` +- Less strict validation for :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB packages for + simulations which are imported with + :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` +- DeprecationWarning thrown upon initializing :class:`imod.prepare.Regridder`. + We plan to remove this object in the final 1.0 release. `Use the xugrid + regridder to regrid individual grids instead. + `_ To + regrid entire MODFLOW 6 packages or simulations, `see the user guide here. + `_. + +[0.18.1] - 2024-11-20 +--------------------- + +Added +~~~~~ + +- :class:`imod.prepare.SimulationAllocationOptions`, + :class:`imod.prepare.SimulationDistributingOptions`, which are used to store + default allocation and distributing options respectively. + +Fixed +~~~~~ + +- Relaxed validation for `imod.mf6.StructuredDiscretization` to also support + cells with zero thickness where IDOMAIN = 0. Before, only cells with a zero + thickness and IDOMAIN = -1 were supported, else the software threw a ``not all + values comply with criterion: > bottom``. +- Fix bug where no ``ValidationError`` was thrown if there is an active RCH, DRN, + GHB, or RIV cell where idomain = -1. + +Changed +~~~~~~~ + +- In :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, and + :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data` the arguments + ``allocation_options``, ``distributing_options`` are now optional. +- The order of arguments of :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, + and :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data`. It now is + ``imod5_data, period_data, times, allocation_options, distributing_options, regridder_types`` + instead of: + ``imod5_data, period_data, allocation_options, distributing_options, times, regridder_types`` + + +[0.18.0] - 2024-11-11 +--------------------- + +Fixed +~~~~~ + +- Multiple ``HorizontalFlowBarrier`` objects attached to + :class:`imod.mf6.GroundwaterFlowModel` are merged into a single horizontal + flow barrier for MODFLOW 6. +- Bug where error would be thrown when barriers in a ``HorizontalFlowBarrier`` + would be snapped to the same cell edge. These are now summed. +- Improve performance validation upon Package initialization +- Improve performance writing ``HorizontalFlowBarrier`` objects +- :func:`imod.mf6.open_cbc` failing with ``flowja=False`` on budget output for + DISV models if the model contained inactive cells. +- :func:`imod.mf6.open_cbc` now works for 2D and 1D models. +- :func:`imod.prepare.fill` previously assigned to the result of an xarray + ``.sel`` operation. This might not work for dask backed data and has been + addressed. +- Added :func:`imod.mf6.open_dvs` to read dependent variable output files like + the water content file of :class:`imod.mf6.UnsaturatedZoneFlow`. +- `imod.prj.open_projectfile_data` is now able to also read IPF data for + sprinkling wells in the CAP package. +- Fix that caused iMOD Python to break upon import with numpy >=1.23, <2.0 . +- ValidationError message now contains a suggestion to use the cleanup method, + if available in the erroneous package. +- Bug where error was thrown when :class:`imod.mf6.NodePropertyFlow` was + assigned to :class:`imod.mf6.GroundwaterFlowModel` with key different from + ``"npf"`` upon writing, along with well or horizontal flow barrier packages. + + +Changed +~~~~~~~ + +- :class:`imod.mf6.Well` now also validates that well filter top is above well + filter bottom +- :func:`imod.formats.prj.open_projectfile_data` now also imports well filter + top and bottom. +- :class:`imod.mf6.Well` now logs a warning if any wells are removed during writing. +- :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.HorizontalFlowBarrierMultiplier`, + :class:`imod.mf6.HorizontalFlowBarrierHydraulicCharacteristic` now uses + vertical Polygons instead of Linestrings as geometry, and ``"ztop"`` and + ``"zbottom"`` variables are not used anymore. See + :func:`imod.prepare.linestring_to_square_zpolygons` and + :func:`imod.prepare.linestring_to_trapezoid_zpolygons` to generate these + polygons. +- :func:`imod.formats.prj.open_projectfile_data` now returns well data grouped + by ipf name, instead of generic, separate number per entry. +- :class:`imod.mf6.Well` now supports wells which have a filter with zero + length, where ``"screen_top"`` equals ``"screen_bottom"``. +- :class:`imod.mf6.Well` shares the same default ``minimum_thickness`` as + :func:`imod.prepare.assign_wells`, which is 0.05, before this was 1.0. +- :func:`imod.prepare.allocate_drn_cells`, + :func:`imod.prepare.allocate_ghb_cells`, + :func:`imod.prepare.allocate_riv_cells`, now allocate to the first model layer + when elevations are above or equal to model top for all methods in + :func:`imod.prepare.ALLOCATION_OPTION`. +- :meth:`imod.mf6.Well.to_mf6_pkg` got a new argument: + ``strict_well_validation``, which controls the behavior for when wells are + removed entirely during their assignment to layers. This replaces the + ``is_partitioned`` argument. +- :func:`imod.prepare.fill` now takes a ``dims`` argument instead of ``by``, + and will fill over N dimensions. Secondly, the function no longer takes + an ``invalid`` argument, but instead always treats NaNs as missing. +- Reverted the need for providing WriteContext objects to MODFLOW 6 Model and + Package objects' ``write`` method. These now use similar arguments to the + :meth:`imod.mf6.Modflow6Simulation.write` method. +- :class:`imod.msw.CouplingMapping`, :class:`imod.msw.Sprinkling`, + `imod.msw.Sprinkling.MetaSwapModel`, now take the + :class:`imod.mf6.mf6_wel_adapter.Mf6Wel` and the + :class:`imod.mf6.StructuredDiscretization` packages as arguments at their + respective ``write`` method, instead of upon initializing these MetaSWAP + objects. +- :class:`imod.msw.CouplingMapping` and :class:`imod.msw.Sprinkling` now take + the :class:`imod.mf6.mf6_wel_adapter.Mf6Wel` as well argument instead of the + deprecated ``imod.mf6.WellDisStructured``. + + +Added +~~~~~ + +- :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` to import imod5 data + loaded with :func:`imod.formats.prj.open_projectfile_data` as a MODFLOW 6 + simulation. +- :func:`imod.prepare.linestring_to_square_zpolygons` and + :func:`imod.prepare.linestring_to_trapezoid_zpolygons` to generate vertical + polygons that can be used to specify horizontal flow barriers, specifically: + :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.HorizontalFlowBarrierMultiplier`, + :class:`imod.mf6.HorizontalFlowBarrierHydraulicCharacteristic`. +- :class:`imod.mf6.LayeredWell` to specify wells directly to layers instead + assigning them with filter depths. +- :func:`imod.prepare.cleanup_drn`, :func:`imod.prepare.cleanup_ghb`, + :func:`imod.prepare.cleanup_riv`, :func:`imod.prepare.cleanup_wel`. These are + utility functions to clean up drainage, general head boundaries, and rivers, + respectively. +- :meth:`imod.mf6.Drainage.cleanup`, + :meth:`imod.mf6.GeneralHeadboundary.cleanup`, :meth:`imod.mf6.River.cleanup`, + :meth:`imod.mf6.Well.cleanup` convenience methods to call the corresponding + cleanup utility functions with the appropriate arguments. +- :meth:`imod.msw.MetaSwapModel.regrid_like` to regrid MetaSWAP models. This is + still experimental functionality, regridding the :class:`imod.msw.Sprinkling` + is not yet supported. +- The context :func:`imod.util.context.print_if_error` to print an error instead + of raising it in a ``with`` statement. This is useful for code snippets which + definitely will fail. +- :meth:`imod.msw.MetaSwapModel.regrid_like` to regrid MetaSWAP models. +- :meth:`imod.mf6.GroundwaterFlowModel.prepare_wel_for_mf6` to prepare wells for + MODFLOW 6, for debugging purposes. + +Removed +~~~~~~~ + +- :func:`imod.formats.prj.convert_to_disv` has been removed. This functionality + has been replaced by :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`. To + convert a structured simulation to an unstructured simulation, call: + :meth:`imod.mf6.Modflow6Simulation.regrid_like` + + +[0.17.2] - 2024-09-17 +--------------------- + +Fixed +~~~~~ +- :func:`imod.formats.prj.open_projectfile_data` now reports the path to a + faulty IPF or IDF file in the error message. +- Support for Numpy 2.0 + +Added +~~~~~ +- Added objects with regrid settings. These can be used to provide custom + settings: :class:`imod.mf6.regrid.ConstantHeadRegridMethod`, + :class:`imod.mf6.regrid.DiscretizationRegridMethod`, + :class:`imod.mf6.regrid.DispersionRegridMethod`, + :class:`imod.mf6.regrid.DrainageRegridMethod`, + :class:`imod.mf6.regrid.EmptyRegridMethod`, + :class:`imod.mf6.regrid.EvapotranspirationRegridMethod`, + :class:`imod.mf6.regrid.GeneralHeadBoundaryRegridMethod`, + :class:`imod.mf6.regrid.InitialConditionsRegridMethod`, + :class:`imod.mf6.regrid.MobileStorageTransferRegridMethod`, + :class:`imod.mf6.regrid.NodePropertyFlowRegridMethod`, + :class:`imod.mf6.regrid.RechargeRegridMethod`, + :class:`imod.mf6.regrid.RiverRegridMethod`, + :class:`imod.mf6.regrid.SpecificStorageRegridMethod`, + :class:`imod.mf6.regrid.StorageCoefficientRegridMethod`. + +Changed +~~~~~~~ +- Instead of providing a dictionary with settings to ``Package.regrid_like``, + provide one of the following ``RegridMethod`` objects: + :class:`imod.mf6.regrid.ConstantHeadRegridMethod`, + :class:`imod.mf6.regrid.DiscretizationRegridMethod`, + :class:`imod.mf6.regrid.DispersionRegridMethod`, + :class:`imod.mf6.regrid.DrainageRegridMethod`, + :class:`imod.mf6.regrid.EmptyRegridMethod`, + :class:`imod.mf6.regrid.EvapotranspirationRegridMethod`, + :class:`imod.mf6.regrid.GeneralHeadBoundaryRegridMethod`, + :class:`imod.mf6.regrid.InitialConditionsRegridMethod`, + :class:`imod.mf6.regrid.MobileStorageTransferRegridMethod`, + :class:`imod.mf6.regrid.NodePropertyFlowRegridMethod`, + :class:`imod.mf6.regrid.RechargeRegridMethod`, + :class:`imod.mf6.regrid.RiverRegridMethod`, + :class:`imod.mf6.regrid.SpecificStorageRegridMethod`, + :class:`imod.mf6.regrid.StorageCoefficientRegridMethod`. +- Renamed ``imod.mf6.LayeredHorizontalFlowBarrier`` classes to + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierHydraulicCharacteristic`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierMultiplier`, + +Fixed +~~~~~ +- :func:`imod.formats.prj.open_projectfile_data` now reports the path to a + faulty IPF or IDF file in the error message. + + + + +[0.17.1] - 2024-05-16 +--------------------- + +Added +~~~~~ +- Added function :func:`imod.util.spatial.gdal_compliant_grid` to make spatial + coordinates of a NetCDF interpretable for GDAL (and so QGIS). +- Added ``crs`` argument to :func:`imod.util.spatial.mdal_compliant_ugrid2d`, + :meth:`imod.mf6.Simulation.dump`, :meth:`imod.mf6.GroundwaterFlowModel.dump`, + :meth:`imod.mf6.GroundwaterTransportModel.dump`, to add a coordinate reference + system to dumped files, to ease loading them in QGIS. + +Changed +~~~~~~~ +- :meth:`imod.mf6.Simulation.dump`, :meth:`imod.mf6.GroundwaterFlowModel.dump`, + :meth:`imod.mf6.GroundwaterTransportModel.dump` write with necessary + attributes to NetCDF to make these files interpretable for GDAL (and so QGIS). + +Fixed +~~~~~ +- Fix missing API docs for ``dump`` and ``write`` methods. + + +[0.17.0] - 2024-05-13 +--------------------- + +Added +~~~~~ +- Added functions to allocate planar grids over layers for the topsystem in + :func:`imod.prepare.allocate_drn_cells`, + :func:`imod.prepare.allocate_ghb_cells`, + :func:`imod.prepare.allocate_rch_cells`, + :func:`imod.prepare.allocate_riv_cells`, for this multiple options can be + selected, available in :func:`imod.prepare.ALLOCATION_OPTION`. +- Added functions to distribute conductances of planar grids over layers for the + topsystem in :func:`imod.prepare.distribute_riv_conductance`, + :func:`imod.prepare.distribute_drn_conductance`, + :func:`imod.prepare.distribute_ghb_conductance`, for this multiple options can + be selected, available in :func:`imod.prepare.DISTRIBUTING_OPTION`. +- :func:`imod.prepare.celltable` supports an optional ``dtype`` argument. This + can be used, for example, to create celltables of float values. +- Added ``fixed_cell`` option to :class:`imod.mf6.Recharge`. This option is + relevant for phreatic models, not using the Newton formulation and model cells + can become inactive. The prefered method for phreatic models is to use the + Newton formulation, where cells remain active, and this option irrelevant. +- Added support for ``ats_outer_maximum_fraction`` in :class:`imod.mf6.Solution`. +- Added validation for ``linear_acceleration``, ``rclose_option``, + ``scaling_method``, ``reordering_method``, ``print_option`` and ``no_ptc`` + entries in :class:`imod.mf6.Solution`. + +Fixed +~~~~~ +- No ``ValidationError`` thrown anymore in :class:`imod.mf6.River` when + ``bottom_elevation`` equals ``bottom`` in the model discretization. +- When wells outside of the domain are added, an exception is raised with an + error message stating a well is outside of the domain. +- When importing data from a .prj file, the multipliers and additions specified for + ipf and idf files are now applied +- Fix bug where y-coords were flipped in :class:`imod.msw.MeteoMapping` + +Changed +~~~~~~~ +- Replaced csv_output by outer_csvfile and inner_csvfile in + :class:`imod.mf6.Solution` to match newer MODFLOW 6 releases. +- Changed no_ptc from a bool to an option string in :class:`imod.mf6.Solution`. +- Removed constructor arguments `source` and `target` from + ``imod.mf6.utilities.regrid.RegridderWeightsCache``, as they were not + used. +- :func:`imod.mf6.open_cbc` now returns arrays which contain np.nan for cells where + budget variables are not defined. Based on new budget output a disquisition between + active cells but zero flow and inactive cells can be made. +- :func:`imod.mf6.open_cbc` now returns package type in return budget names. New format + is "package type"-"optional package variable"_"package name". E.g. a River package + named ``primary-sys`` will get a budget name ``riv_primary-sys``. An UZF package + with name ``uzf-sys1`` will get a budget name ``uzf-gwrch_uzf-sys1`` for the + groundwater recharge budget from the UZF-CBC. + + +[0.16.0] - 2024-03-29 +--------------------- + +Added +~~~~~ +- The :func:`imod.mf6.model.mask_all_packages` now also masks the idomain array + of the model discretization, and can be used with a mask array without a layer + dimension, to mask all layers the same way +- Validation for incompatible settings in the :class:`imod.mf6.NodePropertyFlow` + and :class:`imod.mf6.Dispersion` packages. +- Checks that only one flow model is present in a simulation when calling + :func:`imod.mf6.Modflow6Simulation.regrid_like`, + :func:`imod.mf6.Modflow6Simulation.clip_box` or + :func:`imod.mf6.Modflow6Simulation.split` +- Added support for coupling a GroundwaterFlowModel and Transport Model i.c.w. + the 6.4.3 release of MODFLOW. Using an older version of iMOD Python with this + version of MODFLOW will result in an error. +- :meth:`imod.mf6.Modflow6Simulation.split` supports splitting transport models, + including multi-species simulations. +- :meth:`imod.mf6.Modflow6Simulation.open_concentration` and + :meth:`imod.mf6.Modflow6Simulation.open_transport_budget` support opening + split multi-species simulations. + :meth:`imod.mf6.Modflow6Simulation.regrid_like` can now regrid simulations + that have 1 or more transport models. +- added logging to various initialization methods, write methods and dump + methods. `See the documentation + `_ + how to activate logging. +- added :func:`imod.data.hondsrug_simulation` and + :func:`imod.data.hondsrug_crosssection` data. +- simulations and models that include a lake package now raise an exception on + clipping, partitioning or regridding. + +Changed +~~~~~~~ +- :meth:`imod.mf6.Modflow6Simulation.open_concentration` and + :meth:`imod.mf6.Modflow6Simulation.open_transport_budget` raise a + ``ValueError`` if ``species_ls`` is provided with incorrect length. + +Fixed +~~~~~ +- Incorrect validation error ``data values found at nodata values of idomain`` + for boundary condition packages with a scalar coordinate not set as dimension. +- Fix issue where :func:`imod.formats.idf.open_subdomains` and + :func:`imod.mf6.Modflow6Simulation.open_head` (for split simulations) would + return arrays with incorrect ``dx`` and ``dy`` coordinates for equidistant + data. +- Fix issue where :func:`imod.formats.idf.open_subdomains` returned a flipped ``dy`` + coordinate for nonequidistant data. +- Made :func:`imod.util.round_extent` available again, as it was moved without + notice. Function now throws a DeprecationWarning to use + :func:`imod.prepare.spatial.round_extent` instead. +- :meth'`imod.mf6.Modflow6Simulation.write` failed after splitting the + simulation. This has been fixed. +- modflow options like "print flow", "save flow", and "print input" can now be + set on :class:`imod.mf6.Well` +- when regridding a :class:`imod.mf6.Modflow6Simulation`, + :class:`imod.mf6.GroundwaterFlowModel`, + :class:`imod.mf6.GroundwaterTransportModel` or a :class:`imod.mf6.package`, + regridding weights are now cached and can be re-used over the different + objects that are regridded. This improves performance considerably in most use + cases: when regridding is applied over the same grid cells with the same + regridder type, but with different values/methods, multiple times. + +[0.15.3] - 2024-02-22 +--------------------- + +Fixed +~~~~~ +- Add missing required dependencies for installing with ``pip``: loguru and tomli. +- Ensure geopandas and shapely are optional dependencies again when + installing with ``pip``, and no import errors are thrown. +- Fixed bug where calling ``copy.deepcopy`` on + :class:`imod.mf6.Modflow6Simulation`, :class:`imod.mf6.GroundwaterFlowModel` + and :class:`imod.mf6.GroundwaterTransportModel` objects threw an error. + + +Added +~~~~~ +- Developer environment: Added pixi environment ``interactive`` to interactively + run code. Can be useful to plot data. +- :class:`imod.mf6.ApiPackage` was added. It can be added to both flow and + transport models, and its presence allows users to interact with libMF6.dll + through its API. +- Developer environment: Empty python 3.10, 3.11, 3.12 environments where pip + install and import imod can be tested. + + + +[0.15.2] - 2024-02-16 +--------------------- + +Fixed +~~~~~ +- iMOD Python now supports versions of pandas >= 2 +- Fixed bugs with clipping :class:`imod.mf6.HorizontalFlowBarrier` for + structured grids +- Packages and boundary conditions in the ``imod.mf6`` module will now throw an + error upon initialization if coordinate labels are inconsistent amongst + variables +- 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 +- Fix support for coupling a GroundwaterFlowModel and Transport Model i.c.w. + the 6.4.3 release of MODFLOW. Using an older version of iMOD Python + with this version of MODFLOW will result in an error. + + +Changed +~~~~~~~ +- We moved to using `pixi `_ to create development + environments. This replaces the ``imod-environment.yml`` conda environment. We + advice doing development installations with pixi from now on. `See the + documentation. `_ + This does not affect users who installed with ``pip install imod``, ``mamba + install imod`` or ``conda install imod``. +- Changed build system from ``setuptools`` to ``hatchling``. Users who did a + development install are adviced to run ``pip uninstall imod`` and ``pip + install -e .`` again. This does not affect users who installed with ``pip + install imod``, ``mamba install imod`` or ``conda install imod``. +- Decreased lower limit of MetaSWAP validation for x and y limits in the + ``IdfMapping`` from 0 to -9999999.0. + + +[0.15.1] - 2023-12-22 +--------------------- + +Fixed +~~~~~ +- Made ``specific_yield`` optional argument in + :class:`imod.mf6.SpecificStorage`, :class:`imod.mf6.StorageCoefficient`. +- Fixed bug where simulations with :class:`imod.mf6.Well` were not partitioned + into multiple models. +- Fixed erroneous default value for the ``out_of_bounds`` in + :func:`imod.select.points.point_values` +- Fixed bug where :class:`imod.mf6.Well` could not be assigned to the first cell + of an unstructured grid. +- HorizontalFlowBarrier package now dropped if completely outside partition in a + split model. +- HorizontalFlowBarrier package clipped with ``clip_by_grid`` based on active + cells, consistent with how other packages are treated by this function. This + affects the :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` and + :meth:`imod.mf6.Modflow6Simulation.split` methods. + + +Changed +~~~~~~~ +- All the references to GitLab have been replaced by GitHub references as + part of the GitHub migration. + +Added +~~~~~ +- Added comment in Modflow6 exchanges file (GWFGWF) denoting column header. +- Added Python 3.11 support. +- The GWF-GWF exchange options are derived from user created packages (NPF, OC) and + set automatically. +- Added the ``simulation_start_time`` and ``time_unit`` arguments. To the + ``Modflow6Simulation.open_`` methods, and ``imod.mf6.out.open_`` functions. + This converts the ``"time"`` coordinate to datetimes. +- added :meth:`imod.mf6.Modflow6Simulation.mask_all_models` to apply a mask to + all models under a simulation, provided the simulation is not split and the + models use the same discretization. + + +Changed +~~~~~~~ +- :meth:`imod.mf6.Well.mask` masks with a 2D grid instead of returning a + deepcopy of the package. + + +[0.15.0] - 2023-11-25 +--------------------- + +Fixed +~~~~~ +- The Newton option for a :class:`imod.mf6.GroundwaterFlowModel` was being ignored. This has been + corrected. +- The Contextily packages started throwing errors. This was caused because the + default tile provider being used was Stamen. However Stamen is no longer free + which caused Contextily to fail. The default tile provider has been changed to + OpenStreetMap to resolve this issue. +- :func:`imod.mf6.open_cbc` now reads saved cell saturations and specific discharges. +- :func:`imod.mf6.open_cbc` failed to read unstructured budgets stored + following IMETH1, most importantly the storage fluxes. +- Fixed support of Python 3.11 by dropping the obsolete ``qgs`` module. +- Bug in :class:`imod.mf6.SourceSinkMixing` where, in case of multiple active + boundary conditions with assigned concentrations, it would write a ``.ssm`` + file with all sources/sinks on one single row. +- Fixed bug where TypeError was thrown upond calling + :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` and + :meth:`imod.mf6.HorizontalFlowBarrier.mask`. +- Fixed bug where calling :meth:`imod.mf6.Well.clip_box` over only the time + dimension would remove the index coordinate. +- Validation errors are rendered properly when writing a simulation object or + regridding a model object. + +Changed +~~~~~~~ +- The imod-environment.yml file has been split in an imod-environment.yml + (containing all packages required to run imod-python) and a + imod-environment-dev.yml file (containing additional packages for developers). +- Changed the way :class:`imod.mf6.Modflow6Simulation`, + :class:`imod.mf6.GroundwaterFlowModel`, + :class:`imod.mf6.GroundwaterTransportModel`, and MODFLOW 6 packages are + represented while printing. +- The grid-agnostic packages :meth:`imod.mf6.Well.regrid_like` and + :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` now return a clip with the + grid exterior of the target grid + +Added +~~~~~ +- The unit tests results are now published on GitLab +- A ``save_saturation`` option to :class:`imod.mf6.NodePropertyFlow` which saves + cell saturations for unconfined flow. +- Functions :func:`imod.prepare.layer.get_upper_active_layer_number` and + :func:`imod.prepare.layer.get_lower_active_layer_number` to return planar + grids with numbers of the highest and lowest active cells respectively. +- Functions :func:`imod.prepare.layer.get_upper_active_grid_cells` and + :func:`imod.prepare.layer.get_lower_active_grid_cells` to return boolean + grids designating respectively the highest and lowest active cells in a grid. +- validation of ``transient`` argument in :class:`imod.mf6.StorageCoefficient` + and :class:`imod.mf6.SpecificStorage`. +- :meth:`imod.mf6.Modflow6Simulation.open_concentration`, + :meth:`imod.mf6.Modflow6Simulation.open_head`, + :meth:`imod.mf6.Modflow6Simulation.open_transport_budget`, and + :meth:`imod.mf6.Modflow6Simulation.open_flow_budget`, were added as convenience + methods to open simulation output easier (without having to specify paths). +- The :meth:`imod.mf6.Modflow6Simulation.split` method has been added. This method makes + it possible for a user to create a Multi-Model simulation. A user needs to + provide a submodel label array in which they specify to which submodel a cell + belongs. The method will then create the submodels and split the nested + packages. The split method will create the gwfgwf exchanges required to + connect the submodels. At the moment auxiliary variables ``cdist`` and + ``angldegx`` are only computed for structured grids. +- The label array can be generated through a convenience function + :func:`imod.mf6.partition_generator.get_label_array` +- Once a split simulation has been executed by MF6, we find head and balance + results in each of the partition models. These can now be merged into head and + balance datasets for the original domain using + :meth:`imod.mf6.Modflow6Simulation.open_concentration`, + :meth:`imod.mf6.Modflow6Simulation.open_head`, + :meth:`imod.mf6.Modflow6Simulation.open_transport_budget`, + :meth:`imod.mf6.Modflow6Simulation.open_flow_budget`. + In the case of balances, the exchanges through the partition boundary are not + yet added to this merged balance. +- Settings such as ``save_flows`` can be passed through + :meth:`imod.mf6.SourceSinkMixing.from_flow_model` +- Added :class:`imod.mf6.LayeredHorizontalFlowBarrierHydraulicCharacteristic`, + :class:`imod.mf6.LayeredHorizontalFlowBarrierMultiplier`, + :class:`imod.mf6.LayeredHorizontalFlowBarrierResistance`, for horizontal flow + barriers with a specified layer number. + + +Removed +~~~~~~~ +- Tox has been removed from the project. +- Dropped support for writing .qgs files directly for QGIS, as this was hard to + maintain and rarely used. To export your model to QGIS readable files, call + the ``dump`` method :class:`imod.mf6.Modflow6Simulation` with ``mdal_compliant=True``. + This writes UGRID NetCDFs which can read as meshes in QGIS. +- Removed ``declxml`` from repository. + +[0.14.1] - 2023-09-07 +--------------------- + +Changed +~~~~~~~ + +- TWRI MODFLOW 6 example uses the grid-agnostic :class:`imod.mf6.Well` + package instead of the ``imod.mf6.WellDisStructured`` package. + +Fixed +~~~~~ + +- :class:`imod.mf6.HorizontalFlowBarrier` would write to a binary file by + default. However, the current version of MODFLOW 6 does not support this. + Therefore, this class now always writes to text file. + + +[0.14.0] - 2023-09-06 +--------------------- + +Changed +~~~~~~~ + +- :class:`imod.mf6.HorizontalFlowBarrier` is specified by providing a geopandas + `GeoDataFrame + `_ + + +Added +~~~~~ + +- :meth:`imod.mf6.Modflow6Simulation.regrid_like` to regrid a Modflow6 simulation to a + new grid (structured or unstructured), using `xugrid's regridding + functionality. + `_ + Variables are regridded with pre-selected methods. The regridding + functionality is useful for a variety of applications, for example to test the + effect of different grid sizes, to add detail to a simulation (by refining the + grid) or to speed up a simulation (by coarsening the grid) to name a few +- :meth:`imod.mf6.Package.regrid_like` to regrid packages. The user can + specify their own custom regridder types and methods for variables. +- :meth:`imod.mf6.Modflow6Simulation.clip_box` got an extra argument + ``states_for_boundary``, which takes a dictionary with modelname as key and + griddata as value. This data is specified as fixed state on the model + boundary. At present only `imod.mf6.GroundwaterFlowModel` is supported, grid + data is specified as a :class:`imod.mf6.ConstantHead` at the model boundary. +- :class:`imod.mf6.Well`, a grid-agnostic well package, where wells can be + specified based on their x,y coordinates and filter top and bottom. + + +[0.13.2] - 2023-07-26 +--------------------- + +Changed +~~~~~~~ + +- :func:`imod.formats.rasterio.save` will now write ESRII ASCII rasters, even if + rasterio is not installed. A fallback function has been added specifically + for ASCII rasters. + +Fixed +~~~~~ + +- Geopandas and rasterio were imported at the top of a module in some places. + This has been fixed so that both are not optional dependencies when + installing via pip (installing via conda or mamba will always pull all + dependencies and supports full functionality). +- :meth:`imod.mf6.Modflow6Simulation._validate` now print all validation errors for all + models and packages in one message. +- The gen file reader can now handle feature id's that contain commas and spaces +- :class:`imod.mf6.EvapoTranspiration` now supports segments, by adding a + ``segment`` dimension to the ``proportion_depth`` and ``proportion_rate`` + variables. +- :class:`imod.mf6.EvapoTranspiration` template for ``.evt`` file now properly + formats ``nseg`` option. +- Fixed bug in :class:`imod.wq.Well` preventing saving wells without a time + dimension, but with a layer dimension. +- :class:`imod.mf6.DiscretizationVertices._validate` threw ``KeyError`` for + ``"bottom"`` when validating the package separately. + +Added +~~~~~ + +- :func:`imod.select.grid.active_grid_boundary_xy` & + :func:`imod.select.grid.grid_boundary_xy` are added to find grid boundaries. + +[0.13.1] - 2023-05-05 +--------------------- + +Added +~~~~~ + +- :class:`imod.mf6.SpecificStorage` and :class:`imod.mf6.StorageCoefficient` + now have a ``save_flow`` argument. + +Fixed +~~~~~ + +- :func:`imod.mf6.open_cbc` can now read storage fluxes without error. + + +[0.13.0] - 2023-05-02 +--------------------- + +Added +~~~~~ + +- :class:`imod.mf6.OutputControl` now takes parameters ``head_file``, + ``concentration_file``, and ``budget_file`` to specify where to store + MODFLOW 6 output files. +- :func:`imod.util.spatial.from_mdal_compliant_ugrid2d` to "restack" the variables that + have have been "unstacked" in :func:`imod.util.spatial.mdal_compliant_ugrid2d`. +- Added support for the Modflow6 Lake package +- :func:`imod.select.points_in_bounds`, :func:`imod.select.points_indices`, + :func:`imod.select.points_values` now support unstructured grids. +- Added support for the MODFLOW 6 Lake package: :class:`imod.mf6.Lake`, + :class:`imod.mf6.LakeData`, :class:`imod.mf6.OutletManning`, :class:`OutletSpecified`, + :class:`OutletWeir`. See the examples for an application of the Lake package. +- :meth:`imod.mf6.simulation.Modflow6Simulation.dump` now supports dumping to MDAL compliant + ugrids. These can be used to view and explore Modlfow 6 simulations in QGIS. + +Fixed +~~~~~ + +- :meth:`imod.wq.bas.BasicFlow.thickness` returns a DataArray with the correct + dimension order again. This confusingly resulted in an error when writing the + :class:`imod.wq.btn.BasicTransport` package. +- Fixed bug in :class:`imod.mf6.dis.StructuredDiscretization` and + :class:`imod.mf6.dis.VerticesDiscretization` where + ``inactive bottom above active cell`` was incorrectly raised. + +[0.12.0] - 2023-03-17 +--------------------- + +Added +~~~~~ + +- :func:`imod.prj.read_projectfile` to read the contents of a project file into + a Python dictionary. +- :func:`imod.prj.open_projectfile_data` to read/open the data that is pointed + to in a project file. +- :func:`imod.gen.read_ascii` to read the geometry stored in ASCII text .gen files. +- :class:`imod.mf6.hfb.HorizontalFlowBarrier` to support Modflow6's HFB + package, works well with `xugrid.snap_to_grid` function. +- :meth:`imod.mf6.simulation.Modflow6Simulation.dump` to dump a simulation to a toml file + which acts as a definition file, pointing to packages written as netcdf files. This + can be used to intermediately store Modflow6 simulations. + +Fixed +~~~~~ + +- :func:`imod.evaluate.budget.flow_velocity` now properly computes velocity by + dividing by the porosity. Before, this function computed the Darcian velocity. + +Changed +~~~~~~~ + +- :func:`imod.formats.ipf.save` will error on duplicate IDs for associated files if a + ``"layer"`` column is present. As a dataframe is automatically broken down + into a single IPF per layer, associated files for the first layer would be + overwritten by the second, and so forth. +- :meth:`imod.wq.Well.save` will now write time varying data to associated + files for extration rate and concentration. +- Choosing ``method="geometric_mean"`` in the Regridder will now result in NaN + values in the regridded result if a geometric mean is computed over negative + values; in general, a geometric mean should only be computed over physical + quantities with a "true zero" (e.g. conductivity, but not elevation). + +[0.11.6] - 2023-02-01 +--------------------- + +Added +~~~~~ + +- Added an extra optional argument in + :meth:`imod.couplers.metamod.MetaMod.write` named ``modflow6_write_kwargs``, + which can be used to provide keyword arguments to the writing of the MODFLOW 6 + Simulation. + +Fixed +~~~~~ + +- :func:`imod.mf6.out.disv.read_grb` Remove repeated construction of + ``UgridDataArray`` for ``top`` + +[0.11.5] - 2022-12-15 +--------------------- + +Fixed +~~~~~ + +- :meth:`imod.mf6.Modflow6Simulation.write` with ``binary=False`` no longer + results in invalid MODFLOW 6 input for 2D grid data, such as DIS top. +- ``imod.flow.ImodflowModel.write`` no longer writes incorrect project + files for non-grid values with a time and layer dimension. +- :func:`imod.evaluate.interpolate_value_boundaries`: Fix edge case when + successive values in z direction are exactly equal to the boundary value. + +Changed +~~~~~~~ + +- Removed ``meshzoo`` dependency. +- Minor changes to :mod:`imod.gen.gen` backend, to support `Shapely 2.0 + `_ , Shapely + version above equal v1.8 is now required. + +Added +~~~~~ + +- ``imod.flow.ImodflowModel.write`` now supports writing a + ``config_run.ini`` to convert the projectfile to a runfile or modflow 6 + namfile with iMOD5. +- Added validation of Modflow6 Flow and Transport models. Incorrect model input + will now throw a ``ValidationError``. To turn off the validation, set + ``validate=False`` upon package initialization and/or when calling + :meth:`imod.mf6.Modflow6Simulation.write`. + +[0.11.4] - 2022-09-05 +--------------------- + +Fixed +~~~~~ + +- :meth:`imod.mf6.GroundwaterFlowModel.write` will no longer error when a 3D + DataArray with a single layer is written. It will now accept both 2D and 3D + arrays with a single layer coordinate. +- Hotfixes for :meth:`imod.wq.model.SeawatModel.clip`, until `this merge request + `_ is + fulfilled. +- ``imod.flow.ImodflowModel.write`` will set the timestring in the + projectfile to ``steady-state`` for ``BoundaryConditions`` without a time + dimension. +- Added ``imod.flow.OutputControl`` as this was still missing. +- :func:`imod.formats.ipf.read` will no longer error when an associated files with 0 + rows is read. +- :func:`imod.evaluate.calculate_gxg` now correctly uses (March 14, March + 28, April 14) to calculate GVG rather than (March 28, April 14, April 28). +- :func:`imod.mf6.out.open_cbc` now correctly loads boundary fluxes. +- :meth:`imod.prepare.LayerRegridder.regrid` will now correctly skip values + if ``top_source`` or ``bottom_source`` are NaN. +- :func:`imod.gen.write` no longer errors on dataframes with empty columns. +- ``imod.mf6.BoundaryCondition.set_repeat_stress`` reinstated. This is + a temporary measure, it gives a deprecation warning. + +Changed +~~~~~~~ + +- Deprecate the current documentation URL: https://imod.xyz. For the coming + months, redirection is automatic to: + https://deltares.gitlab.io/imod/imod-python/. +- :func:`imod.formats.ipf.save` will now store associated files in separate directories + named ``layer1``, ``layer2``, etc. The ID in the main IPF file is updated + accordingly. Previously, if IDs were shared between different layers, the + associated files would be overwritten as the IDs would result in the same + file name being used over and over. +- ``imod.flow.ImodflowModel.time_discretization``, + :meth:`imod.wq.SeawatModel.time_discretization`, + :meth:`imod.mf6.Modflow6Simulation.time_discretization`, + are renamed to: + ``imod.flow.ImodflowModel.create_time_discretization``, + :meth:`imod.wq.SeawatModel.create_time_discretization`, + :meth:`imod.mf6.Modflow6Simulation.create_time_discretization`, +- Moved tests inside `imod` directory, added an entry point for pytest fixtures. + Running the tests now requires an editable install, and also existing + installations have to be reinstalled to run the tests. +- The ``imod.mf6`` model packages now all run type checks on input. This is a + breaking change for scripts which provide input with an incorrect dtype. +- :class:`imod.mf6.Solution` now requires a `model_names` argument to specify + which models should be solved in a single numerical solution. This is + required to simulate groundwater flow and transport as they should be + in separate solutions. +- When writing MODFLOW 6 input option blocks, a NaN value is now recognized as + an alternative to None (and the entry will not be included in the options + block). + +Added +~~~~~ + +- Added support to write MetaSWAP models, :class:`imod.msw.MetaSwapModel`. +- Addes support to write coupled MetaSWAP and Modflow6 simulations, + :class:`imod.couplers.MetaMod` +- :func:`imod.util.replace` has been added to find and replace different values + in a DataArray. +- :func:`imod.evaluate.calculate_gxg_points` has been added to compute GXG + values for time varying point data (i.e. loaded from IPF and presented as a + Pandas dataframe). +- :func:`imod.evaluate.calculate_gxg` will return the number of years used + in the GxG calculation as separate variables in the output dataset. +- :func:`imod.visualize.spatial.plot_map` now accepts a `fix` and `ax` argument, + to enable adding maps to existing axes. +- ``imod.flow.ImodflowModel.create_time_discretization``, + :meth:`imod.wq.SeawatModel.create_time_discretization`, + :meth:`imod.mf6.Modflow6Simulation.create_time_discretization`, now have a + documentation section. +- :class:`imod.mf6.GroundwaterTransportModel` has been added with associated + simple classes to allow creation of solute transport models. Advanced + boundary conditions such as LAK or UZF are not yet supported. +- :class:`imod.mf6.Buoyancy` has been added to simulate density dependent + groundwater flow. + +[0.11.1] - 2021-12-23 +--------------------- + +Fixed +~~~~~ + +- ``contextily``, ``geopandas``, ``pyvista``, ``rasterio``, and ``shapely`` + are now fully optional dependencies. Import errors are only raised when + accessing functionality that requires their use. +- Include declxml as ``imod.declxml`` (should be internal use only!): declxml + is no longer maintained on the official repository: + https://github.com/gatkin/declxml. Furthermore, it has no conda feedstock, + which makes distribution via conda difficult. + +[0.11.0] - 2021-12-21 +--------------------- + +Fixed +~~~~~ + +- :func:`imod.formats.ipf.read` accepts list of file names. +- :func:`imod.mf6.open_hds` did not read the appropriate bytes from the + heads file, apart for the first timestep. It will now read the right records. +- Use the appropriate array for modflow6 timestep duration: the + :meth:`imod.mf6.GroundwaterFlowModel.write` would write the timesteps + multiplier in place of the duration array. +- :meth:`imod.mf6.GroundwaterFlowModel.write` will now respect the layer + coordinate of DataArrays that had multiple coordinates, but were + discontinuous from 1; e.g. layers [1, 3, 5] would've been transformed to [1, + 2, 3] incorrectly. +- :meth:`imod.mf6.Modflow6Simulation.write` will no longer change working directory + while writing model input -- this could lead to errors when multiple + processes are writing models in parallel. +- :func:`imod.prepare.laplace_interpolate` will no longer ZeroDivisionError + when given a value for ``ibound``. + +Added +~~~~~ + +- :func:`imod.formats.idf.open_subdomains` will now also accept iMOD-WQ output of + multiple species runs. +- :meth:`imod.wq.SeawatModel.to_netcdf` has been added to write all model + packages to netCDF files. +- :func:`imod.mf6.open_cbc` has been added to read the budget data of + structured (DIS) MODFLOW 6 models. The data is read lazily into xarray + DataArrays per timestep. +- :func:`imod.visualize.streamfunction` and :func:`imod.visualize.quiver` + were added to plot a 2D representation of the groundwater flow field using + either streamlines or quivers over a cross section plot + (:func:`imod.visualize.cross_section`). +- :func:`imod.evaluate.streamfunction_line` and + :func:`imod.evaluate.streamfunction_linestring` were added to extract the + 2D projected streamfunction of the 3D flow field for a given cross section. +- :func:`imod.evaluate.quiver_line` and :func:`imod.evaluate.quiver_linestring` + were added to extract the u and v components of the 3D flow field for a given + cross section. +- Added :meth:`imod.mf6.GroundwaterFlowModel.write_qgis_project` to write a + QGIS project for easier inspection of model input in QGIS. +- Added :meth:`imod.wq.SeawatModel.clip` to clip a model to a provided extent. + Boundary conditions of clipped model can be automatically derived from parent + model calculation results and are applied along the edges of the extent. +- Added :py:func:`imod.gen.read` and :py:func:`imod.gen.write` for reading + and writing binary iMOD GEN files to and from geopandas GeoDataFrames. +- Added :py:func:`imod.prepare.zonal_aggregate_raster` and + :py:func:`imod.prepare.zonal_aggregate_polygons` to efficiently compute zonal + aggregates for many polygons (e.g. the properties every individual ditch in + the Netherlands). +- Added ``imod.flow.ImodflowModel`` to write to model iMODFLOW project + file. +- :meth:`imod.mf6.Modflow6Simulation.write` now has a ``binary`` keyword. When set + to ``False``, all MODFLOW 6 input is written to text rather than binary files. +- Added :class:`imod.mf6.DiscretizationVertices` to write MODFLOW 6 DISV model + input. +- Packages for :class:`imod.mf6.GroundwaterFlowModel` will now accept + :class:`xugrid.UgridDataArray` objects for (DISV) unstructured grids, next to + :class:`xarray.DataArray` objects for structured (DIS) grids. +- Transient wells are now supported in ``imod.mf6.WellDisStructured`` and + ``imod.mf6.WellDisVertices``. +- :func:`imod.util.to_ugrid2d` has been added to convert a (structured) xarray + DataArray or Dataset to a quadrilateral UGRID dataset. +- Functions created to create empty DataArrays with greater ease: + :func:`imod.util.empty_2d`, :func:`imod.util.empty_2d_transient`, + :func:`imod.util.empty_3d`, and :func:`imod.util.empty_3d_transient`. +- :func:`imod.util.where` has been added for easier if-then-else operations, + especially for preserving NaN nodata values. +- :meth:`imod.mf6.Modflow6Simulation.run` has been added to more easily run a model, + especially in examples and tests. +- :func:`imod.mf6.open_cbc` and :func:`imod.mf6.open_hds` will automatically + return a ``xugrid.UgridDataArray`` for MODFLOW 6 DISV model output. + +Changed +~~~~~~~ + +- Documentation overhaul: different theme, add sample data for examples, add + Frequently Asked Questions (FAQ) section, restructure API Reference. Examples + now ru +- Datetime columns in IPF associated files (via + :func:`imod.formats.ipf.write_assoc`) will not be placed within quotes, as this can + break certain iMOD batch functions. +- :class:`imod.mf6.Well` has been renamed into ``imod.mf6.WellDisStructured``. +- :meth:`imod.mf6.GroundwaterFlowModel.write` will now write package names + into the simulation namefile. +- :func:`imod.mf6.open_cbc` will now return a dictionary with keys + ``flow-front-face, flow-lower-face, flow-right-face`` for the face flows, + rather than ``front-face-flow`` for better consistency. +- Switched to composition from inheritance for all model packages: all model + packages now contain an internal (xarray) Dataset, rather than inheriting + from the xarray Dataset. +- :class:`imod.mf6.SpecificStorage` or :class:`imod.mf6.StorageCoefficient` is + now mandatory for every MODFLOW 6 model to avoid accidental steady-state + configuration. + +Removed +~~~~~~~ + +- Module ``imod.tec`` for reading Tecplot files has been removed. + +[0.10.1] - 2020-10-19 +--------------------- + +Changed +~~~~~~~ + +- :meth:`imod.wq.SeawatModel.write` now generates iMOD-WQ runfiles with + more intelligent use of the "macro tokens". ``:`` is used exclusively for + ranges; ``$`` is used to signify all layers. (This makes runfiles shorter, + speeding up parsing, which takes a significant amount of time in the runfile + to namefile conversion of iMOD-WQ.) +- Datetime formats are inferred based on length of the time string according to + ``%Y%m%d%H%M%S``; supported lengths 4 (year only) to 14 (full format string). + +Added +~~~~~ + +- :class:`imod.wq.MassLoading` and + :class:`imod.wq.TimeVaryingConstantConcentration` have been added to allow + additional concentration boundary conditions. +- IPF writing methods support an ``assoc_columns`` keyword to allow greater + flexibility in including and renaming columns of the associated files. +- Optional basemap plotting has been added to :meth:`imod.visualize.plot_map`. + +Fixed +~~~~~ + +- IO methods for IDF files will now correctly identify double precision IDFs. + The correct record length identifier is 2295 rather than 2296 (2296 was a + typo in the iMOD manual). +- :meth:`imod.wq.SeawatModel.write` will now write the correct path for + recharge package concentration given in IDF files. It did not prepend the + name of the package correctly (resulting in paths like + ``concentration_l1.idf`` instead of ``rch/concentration_l1.idf``). +- :meth:`imod.formats.idf.save` will simplify constant cellsize arrays to a scalar + value -- this greatly speeds up drawing in the iMOD-GUI. + +[0.10.0] - 2020-05-23 +--------------------- + +Changed +~~~~~~~ + +- :meth:`imod.wq.SeawatModel.write` no longer automatically appends the model + name to the directory where the input is written. Instead, it simply writes + to the directory as specified. +- :func:`imod.select.points_set_values` returns a new DataArray rather than + mutating the input ``da``. +- :func:`imod.select.points_values` returns a DataArray with an index taken + from the data of the first provided dimensions if it is a ``pandas.Series``. +- :meth:`imod.wq.SeawatModel.write` now writes a runfile with ``start_hour`` + and ``start_minute`` (this results in output IDFs with datetime format + ``"%Y%m%d%H%M"``). + +Added +~~~~~ + +- :meth:`from_file` constructors have been added to all `imod.wq.Package`. + This allows loading directly package from a netCDF file (or any file supported by + ``xarray.open_dataset``), or a path to a Zarr directory with suffix ".zarr" or ".zip". +- This can be combined with the `cache` argument in :meth:`from_file` to + enable caching of answers to avoid repeated computation during + :meth:`imod.wq.SeawatModel.write`; it works by checking whether input and + output files have changed. +- The ``resultdir_is_workspace`` argument has been added to :meth:`imod.wq.SeawatModel.write`. + iMOD-wq writes a number of files (e.g. list file) in the directory where the + runfile is located. This results in mixing of input and output. By setting it + ``True``, **all** model output is written in the results directory. +- :func:`imod.visualize.imshow_topview` has been added to visualize a complete + DataArray with atleast dimensions ``x`` and ``y``; it dumps PNGs into a + specified directory. +- Some support for 3D visualization has been added. + :func:`imod.visualize.grid_3d` and :func:`imod.visualize.line_3d` have been + added to produce ``pyvista`` meshes from ``xarray.DataArray``'s and + ``shapely`` polygons, respectively. + :class:`imod.visualize.GridAnimation3D` and :class:`imod.visualize.StaticGridAnimation3D` + have been added to setup 3D animations of DataArrays with transient data. +- Support for out of core computation by ``imod.prepare.Regridder`` if ``source`` + is chunked. +- :func:`imod.formats.ipf.read` now reports the problematic file if reading errors occur. +- :func:`imod.prepare.polygonize` added to polygonize DataArrays to GeoDataFrames. +- Added more support for multiple species imod-wq models, specifically: scalar concentration + for boundary condition packages and well IPFs. + +Fixed +~~~~~ + +- :meth:`imod.prepare.Regridder` detects if the ``like`` DataArray is a subset + along a dimension, in which case the dimension is not regridded. +- :meth:`imod.prepare.Regridder` now slices the ``source`` array accurately + before regridding, taking cell boundaries into account rather than only + cell midpoints. +- ``density`` is no longer an optional argument in :class:`imod.wq.GeneralHeadboundary` and + :class:`imod.wq.River`. The reason is that iMOD-WQ fully removes (!) these packages if density + is not present. +- :func:`imod.formats.idf.save` and :func:`imod.formats.rasterio.save` will now also save DataArrays in + which a coordinate other than ``x`` or ``y`` is descending. +- :func:`imod.visualize.plot_map` enforces decreasing ``y``, which ensures maps are not plotted + upside down. +- :func:`imod.util.spatial.coord_reference` now returns a scalar cellsize if coordinate is equidistant. +- :meth:`imod.prepare.Regridder.regrid` returns cellsizes as scalar when coordinates are + equidistant. +- Raise proper ValueError in :meth:`imod.prepare.Regridder.regrid` consistenly when the number + of dimensions to regrid does not match the regridder dimensions. +- When writing DataArrays that have size 1 in dimension ``x`` or ``y``: raise error if cellsize + (``dx`` or ``dy``) is not specified; and actually use ``dy`` or ``dx`` when size is 1. + +[0.9.0] - 2020-01-19 +-------------------- + +Added +~~~~~ + +- IDF files representing data of arbitrary dimensionality can be opened and + saved. This enables reading and writing files with more dimensions than just x, + y, layer, and time. +- Added multi-species support for (:mod:`imod.wq`) +- GDAL rasters representing N-dimensional data can be opened and saved similar to (:mod:`imod.idf`) in (:mod:`imod.rasterio`) +- Writing GDAL rasters using :meth:`imod.formats.rasterio.save` and (:meth:`imod.formats.rasterio.write`) auto-detects GDAL driver based on file extension +- 64-bit IDF files can be opened :meth:`imod.formats.idf.open` +- 64-bit IDF files can be written using :meth:`imod.formats.idf.save` and (:meth:`imod.formats.idf.write`) using keyword ``dtype=np.float64`` +- ``sel`` and ``isel`` methods to ``SeawatModel`` to support taking out a subdomain +- Docstrings for the MODFLOW 6 classes in :mod:`imod.mf6` +- :meth:`imod.select.upper_active_layer` function to get the upper active layer from ibound ``xr.DataArray`` + +Changed +~~~~~~~ + +- ``imod.formats.idf.read`` is deprecated, use :func:`imod.formats.idf.open` instead +- ``imod.formats.rasterio.read`` is deprecated, use :func:`imod.formats.rasterio.open` instead + +Fixed +~~~~~ + +- :meth:`imod.prepare.reproject` working instead of silently failing when given a ``"+init=ESPG:XXXX`` CRS string + +[0.8.0] - 2019-10-14 +-------------------- + +Added +~~~~~ +- Laplace grid interpolation :meth:`imod.prepare.laplace_interpolate` +- Experimental MODFLOW 6 structured model write support :mod:`imod.mf6` +- More supported visualizations :mod:`imod.visualize` +- More extensive reading and writing of GDAL raster in :mod:`imod.rasterio` + +Changed +~~~~~~~ + +- The documentation moved to a custom domain name: https://imod.xyz/ + +[0.7.1] - 2019-08-07 +-------------------- + +Added +~~~~~ +- ``"multilinear"`` has been added as a regridding option to ``imod.prepare.Regridder`` to do linear interpolation up to three dimensions. +- Boundary condition packages in ``imod.wq`` support a method called ``add_timemap`` to do cyclical boundary conditions, such as summer and winter stages. + +Fixed +~~~~~ + +- ``imod.idf.save`` no longer fails on a single IDF when it is a voxel IDF (when it has top and bottom data). +- ``imod.prepare.celltable`` now succesfully does parallel chunkwise operations, rather than raising an error. +- ``imod.Regridder``'s ``regrid`` method now succesfully returns ``source`` if all dimensions already have the right cell sizes, rather than raising an error. +- ``imod.idf.open_subdomains`` is much faster now at merging different subdomain IDFs of a parallel modflow simulation. +- ``imod.idf.save`` no longer suffers from extremely slow execution when the DataArray to save is chunked (it got extremely slow in some cases). +- Package checks in ``imod.wq.SeawatModel`` succesfully reduces over dimensions. +- Fix last case in ``imod.prepare.reproject`` where it did not allocate a new array yet, but returned ``like`` instead of the reprojected result. + +[0.7.0] - 2019-07-23 +-------------------- + +Added +~~~~~ + +- :mod:`imod.wq` module to create iMODFLOW Water Quality models +- conda-forge recipe to install imod (https://github.com/conda-forge/imod-feedstock/) +- significantly extended documentation and examples +- :mod:`imod.prepare` module with many data mangling functions +- :mod:`imod.select` module for extracting data along cross sections or at points +- :mod:`imod.visualize` module added to visualize results +- :func:`imod.idf.open_subdomains` function to open and merge the IDF results of a parallelized run +- :func:`imod.formats.ipf.read` now infers delimeters for the headers and the body +- :func:`imod.formats.ipf.read` can now deal with heterogeneous delimiters between multiple IPF files, and between the headers and body in a single file + +Changed +~~~~~~~ + +- Namespaces: lift many functions one level, such that you can use e.g. the function ``imod.prepare.reproject`` instead of ``imod.prepare.reproject.reproject`` + +Removed +~~~~~~~ + +- All that was deprecated in v0.6.0 + +Deprecated +~~~~~~~~~~ + +- :func:`imod.seawat_write` is deprecated, use the write method of :class:`imod.wq.SeawatModel` instead +- :func:`imod.run.seawat_get_runfile` is deprecated, use :mod:`imod.wq` instead +- :func:`imod.run.seawat_write_runfile` is deprecated, use :mod:`imod.wq` instead + +[0.6.1] - 2019-04-17 +-------------------- + +Added +~~~~~ + +- Support nonequidistant models in runfile + +Fixed +~~~~~ + +- Time conversion in runfile now also accepts cftime objects + +[0.6.0] - 2019-03-15 +-------------------- + +The primary change is that a number of functions have been renamed to +better communicate what they do. + +The ``load`` function name was not appropriate for IDFs, since the IDFs +are not loaded into memory. Rather, they are opened and the headers are +read; the data is only loaded when needed, in accordance with +``xarray``'s design; compare for example ``xarray.open_dataset``. The +function has been renamed to ``open``. + +Similarly, ``load`` for IPFs has been deprecated. ``imod.ipf.read`` now +reads both single and multiple IPF files into a single +``pandas.DataFrame``. + +Removed +~~~~~~~ + +- ``imod.idf.setnodataheader`` + +Deprecated +~~~~~~~~~~ + +- Opening IDFs with ``imod.idf.load``, use ``imod.idf.open`` instead +- Opening a set of IDFs with ``imod.idf.loadset``, use + ``imod.idf.open_dataset`` instead +- Reading IPFs with ``imod.ipf.load``, use ``imod.ipf.read`` +- Reading IDF data into a dask array with ``imod.idf.dask``, use + ``imod.idf._dask`` instead +- Reading an iMOD-seawat .tec file, use ``imod.tec.read`` instead. + +Changed +~~~~~~~ + +- Use ``np.datetime64`` when dates are within time bounds, use + ``cftime.DatetimeProlepticGregorian`` when they are not (matches + ``xarray`` defaults) +- ``assert`` is no longer used to catch faulty input arguments, + appropriate exceptions are raised instead + +Fixed +~~~~~ + +- ``idf.open``: sorts both paths and headers consistently so data does + not end up mixed up in the DataArray +- ``idf.open``: Return an ``xarray.CFTimeIndex`` rather than an array + of ``cftime.DatimeProlepticGregorian`` objects +- ``idf.save`` properly forwards ``nodata`` argument to ``write`` +- ``idf.write`` coerces coordinates to floats before writing +- ``ipf.read``: Significant performance increase for reading IPF + timeseries by specifying the datetime format +- ``ipf.write`` no longer writes ``,,`` for missing data (which iMOD + does not accept) + +[0.5.0] - 2019-02-26 +-------------------- + +Removed +~~~~~~~ + +- Reading IDFs with the ``chunks`` option + +Deprecated +~~~~~~~~~~ + +- Reading IDFs with the ``memmap`` option +- ``imod.idf.dataarray``, use ``imod.idf.load`` instead + +Changed +~~~~~~~ + +- Reading IDFs gives delayed objects, which are only read on demand by + dask +- IDF: instead of ``res`` and ``transform`` attributes, use ``dx`` and + ``dy`` coordinates (0D or 1D) +- Use ``cftime.DatetimeProlepticGregorian`` to support time instead of + ``np.datetime64``, allowing longer timespans +- Repository moved from ``https://gitlab.com/deltares/`` to + ``https://gitlab.com/deltares/imod/`` + +Added +~~~~~ + +- Notebook in ``examples`` folder for synthetic model example +- Support for nonequidistant IDF files, by adding ``dx`` and ``dy`` + coordinates + +Fixed +~~~~~ + +- IPF support implicit ``itype`` + +.. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/ +.. _Semantic Versioning: https://semver.org/spec/v2.0.0.html diff --git a/imod/mf6/drn.py b/imod/mf6/drn.py index b58ac0dd9..544e82a4c 100644 --- a/imod/mf6/drn.py +++ b/imod/mf6/drn.py @@ -1,346 +1,361 @@ -from datetime import datetime -from typing import Optional - -import numpy as np - -from imod.common.interfaces.iregridpackage import IRegridPackage -from imod.common.utilities.dataclass_type import DataclassType -from imod.common.utilities.mask import broadcast_and_mask_arrays -from imod.logging import init_log_decorator, standard_log_decorator -from imod.mf6.aggregate.aggregate_schemes import DrainageAggregationMethod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.regrid.regrid_schemes import DrainageRegridMethod -from imod.mf6.topsystem import TopSystemBoundaryCondition -from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data -from imod.mf6.utilities.package import set_repeat_stress_if_available -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA -from imod.prepare.cleanup import cleanup_drn -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION, allocate_drn_cells -from imod.prepare.topsystem.conductance import ( - DISTRIBUTING_OPTION, - distribute_drn_conductance, -) -from imod.schemata import ( - AllCoordsValueSchema, - AllInsideNoDataSchema, - AllNoDataSchema, - AllValueSchema, - CoordsSchema, - DimsSchema, - DTypeSchema, - IdentityNoDataSchema, - IndexesSchema, - OtherCoordsSchema, -) -from imod.typing import GridDataArray -from imod.typing.grid import enforce_dim_order, has_negative_layer, is_planar_grid -from imod.util.regrid import RegridderWeightsCache - - -class Drainage(TopSystemBoundaryCondition, IRegridPackage): - """ - The Drain package is used to simulate head-dependent flux boundaries. - https://water.usgs.gov/ogw/modflow/mf6io.pdf#page=67 - - Parameters - ---------- - elevation: array of floats (xr.DataArray) - elevation of the drain. (elev) - conductance: array of floats (xr.DataArray) - is the conductance of the drain. (cond) - concentration: array of floats (xr.DataArray, optional) - if this flow package is used in simulations also involving transport, then this array is used - as the concentration for inflow over this boundary. - concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) - if this flow package is used in simulations also involving transport, then this keyword specifies - how outflow over this boundary is computed. - print_input: ({True, False}, optional) - keyword to indicate that the list of drain information will be written - to the listing file immediately after it is read. Default is False. - print_flows: ({True, False}, optional) - Indicates that the list of drain flow rates will be printed to the - listing file for every stress period time step in which "BUDGET PRINT" - is specified in Output Control. If there is no Output Control option and - PRINT FLOWS is specified, then flow rates are printed for the last time - step of each stress period. - Default is False. - save_flows: ({True, False}, optional) - Indicates that drain flow terms will be written to the file specified - with "BUDGET FILEOUT" in Output Control. Default is False. - observations: [Not yet supported.] - Default is None. - validate: {True, False} - Flag to indicate whether the package should be validated upon - initialization. This raises a ValidationError if package input is - provided in the wrong manner. Defaults to True. - repeat_stress: dict or xr.DataArray of datetimes, optional - Used to repeat data for e.g. repeating stress periods such as - seasonality without duplicating the values. If provided as dict, it - should map new dates to old dates present in the dataset. - ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided - as DataArray, it should have dimensions ``("repeat", "repeat_items")``. - The ``repeat_items`` dimension should have size 2: the first value is - the "key", the second value is the "value". For the "key" datetime, the - data of the "value" datetime will be used. - """ - - _pkg_id = "drn" - - # has to be ordered as in the list - _init_schemata = { - "elevation": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "conductance": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "concentration": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema( - ( - "species", - "layer", - ) - ), - CONC_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "print_flows": [DTypeSchema(np.bool_), DimsSchema()], - "save_flows": [DTypeSchema(np.bool_), DimsSchema()], - } - _write_schemata = { - "elevation": [ - OtherCoordsSchema("idomain"), - AllNoDataSchema(), # Check for all nan, can occur while clipping - AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), - ], - "conductance": [IdentityNoDataSchema("elevation"), AllValueSchema(">", 0.0)], - "concentration": [IdentityNoDataSchema("elevation"), AllValueSchema(">=", 0.0)], - } - - _period_data = ("elevation", "conductance") - _keyword_map = {} - _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) - _auxiliary_data = {"concentration": "species"} - _regrid_method = DrainageRegridMethod() - _aggregate_method: DataclassType = DrainageAggregationMethod() - - @init_log_decorator() - def __init__( - self, - elevation, - conductance, - concentration=None, - concentration_boundary_type="aux", - print_input=False, - print_flows=False, - save_flows=False, - observations=None, - validate: bool = True, - repeat_stress=None, - ): - dict_dataset = { - "elevation": elevation, - "conductance": conductance, - "concentration": concentration, - "concentration_boundary_type": concentration_boundary_type, - "print_input": print_input, - "print_flows": print_flows, - "save_flows": save_flows, - "observations": observations, - "repeat_stress": repeat_stress, - } - super().__init__(dict_dataset) - self._validate_init_schemata(validate) - - def _validate(self, schemata, **kwargs): - # Insert additional kwargs - kwargs["elevation"] = self["elevation"] - errors = super()._validate(schemata, **kwargs) - - return errors - - @standard_log_decorator() - def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: - """ - Clean up package inplace. This method calls - :func:`imod.prepare.cleanup_drn`, see documentation of that - function for details on cleanup. - - dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization - Model discretization package. - """ - dis_dict = {"idomain": dis.dataset["idomain"]} - cleaned_dict = self._call_func_on_grids(cleanup_drn, dis_dict) - super().__init__(cleaned_dict) - - @classmethod - def _allocate_and_distribute_planar_data( - cls, - planar_data: dict[str, GridDataArray], - dis: StructuredDiscretization | VerticesDiscretization, - npf: NodePropertyFlow, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - ) -> dict[str, GridDataArray]: - """ - Allocate and distribute planar data for given discretization and npf - package. If layer number of ``planar_data`` is negative, - ``allocation_option`` is overrided and set to - ALLOCATION_OPTION.at_first_active. - - Parameters - ---------- - planar_data: dict[str, GridDataArray] - Dictionary with planar grid data. - dis: imod.mf6.StructuredDiscretization - Model discretization package. - npf: imod.mf6.NodePropertyFlow - Node property flow package. - allocation_option: ALLOCATION_OPTION - allocation option. If planar data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: DISTRIBUTING_OPTION - distributing option. - - Returns - ------- - dict[str, GridDataArray] - Dictionary with layered grid data. - """ - - top = dis.dataset["top"] - bottom = dis.dataset["bottom"] - idomain = dis.dataset["idomain"] - - if has_negative_layer(planar_data["elevation"]): - allocation_option = ALLOCATION_OPTION.at_first_active - - # Enforce planar data, remove all layer dimension information - planar_data = { - key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") - for key, grid in planar_data.items() - } - - drn_allocation = allocate_drn_cells( - allocation_option, - idomain > 0, - top, - bottom, - planar_data["elevation"], - ) - layered_data = {} - layered_data["conductance"] = distribute_drn_conductance( - distributing_option, - drn_allocation, - planar_data["conductance"], - top, - bottom, - npf.dataset["k"], - planar_data["elevation"], - ) - layered_data["elevation"] = planar_data["elevation"].where(drn_allocation) - layered_data["elevation"] = enforce_dim_order(layered_data["elevation"]) - return layered_data - - @classmethod - def from_imod5_data( - cls, - key: str, - imod5_data: dict[str, dict[str, GridDataArray]], - period_data: dict[str, list[datetime]], - target_dis: StructuredDiscretization, - target_npf: NodePropertyFlow, - time_min: datetime, - time_max: datetime, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - regridder_types: Optional[DrainageRegridMethod] = None, - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> "Drainage": - """ - Construct a drainage-package from iMOD5 data, loaded with the - :func:`imod.formats.prj.open_projectfile_data` function. - - .. note:: - - The method expects the iMOD5 model to be fully 3D, not quasi-3D. - - Parameters - ---------- - key: str - Packagename of the iMOD5 data to use. - imod5_data: dict - Dictionary with iMOD5 data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - period_data: dict - Dictionary with iMOD5 period data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - target_dis: StructuredDiscretization package - The grid that should be used for the new package. Does not - need to be identical to one of the input grids. - target_npf: NodePropertyFlow package - The conductivity information, used to compute drainage flux - allocation_option: ALLOCATION_OPTION - allocation option. If package data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: dict[str, DISTRIBUTING_OPTION] - distributing option. - time_min: datetime - Begin-time of the simulation. Used for expanding period data. - time_max: datetime - End-time of the simulation. Used for expanding period data. - regridder_types: DrainageRegridMethod, optional - Optional dataclass with regridder types for a specific variable. - Use this to override default regridding methods. - regrid_cache: RegridderWeightsCache, optional - stores regridder weights for different regridders. Can be used to speed up regridding, - if the same regridders are used several times for regridding different arrays. - - Returns - ------- - A Modflow 6 Drainage package. - """ - data = { - "elevation": imod5_data[key]["elevation"], - "conductance": imod5_data[key]["conductance"], - } - mask = data["conductance"] > 0 - data["conductance"] = data["conductance"].where(mask) - # Regrid the input data - regridded_package_data = regrid_imod5_pkg_data( - cls, data, target_dis, regridder_types, regrid_cache - ) - regridded_package_data = broadcast_and_mask_arrays(regridded_package_data) - is_planar = is_planar_grid(regridded_package_data["elevation"]) - if is_planar: - layered_data = cls._allocate_and_distribute_planar_data( - regridded_package_data, - target_dis, - target_npf, - allocation_option, - distributing_option, - ) - regridded_package_data.update(layered_data) - - drn = cls(**regridded_package_data, validate=True) - repeat = period_data.get(key) - set_repeat_stress_if_available(repeat, time_min, time_max, drn) - # Clip the drain package to the time range of the simulation and ensure - # time is forward filled. - drn = drn.clip_box(time_min=time_min, time_max=time_max) - - return drn +from datetime import datetime +from typing import Optional + +import numpy as np + +from imod.common.interfaces.iregridpackage import IRegridPackage +from imod.common.utilities.dataclass_type import DataclassType +from imod.common.utilities.mask import broadcast_and_mask_arrays +from imod.logging import init_log_decorator, standard_log_decorator +from imod.mf6.aggregate.aggregate_schemes import DrainageAggregationMethod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.regrid.regrid_schemes import DrainageRegridMethod +from imod.mf6.topsystem import TopSystemBoundaryCondition +from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data +from imod.mf6.utilities.package import set_repeat_stress_if_available +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA +from imod.prepare.cleanup import cleanup_drn +from imod.prepare.topsystem.allocation import ( + ALLOCATION_OPTION, + allocate_drn_cells, + drop_empty_layers_from_dict, +) +from imod.prepare.topsystem.conductance import ( + DISTRIBUTING_OPTION, + distribute_drn_conductance, +) +from imod.schemata import ( + AllCoordsValueSchema, + AllInsideNoDataSchema, + AllNoDataSchema, + AllValueSchema, + CoordsSchema, + DimsSchema, + DTypeSchema, + IdentityNoDataSchema, + IndexesSchema, + OtherCoordsSchema, +) +from imod.typing import GridDataArray +from imod.typing.grid import enforce_dim_order, has_negative_layer, is_planar_grid +from imod.util.regrid import RegridderWeightsCache + + +class Drainage(TopSystemBoundaryCondition, IRegridPackage): + """ + The Drain package is used to simulate head-dependent flux boundaries. + https://water.usgs.gov/ogw/modflow/mf6io.pdf#page=67 + + Parameters + ---------- + elevation: array of floats (xr.DataArray) + elevation of the drain. (elev) + conductance: array of floats (xr.DataArray) + is the conductance of the drain. (cond) + concentration: array of floats (xr.DataArray, optional) + if this flow package is used in simulations also involving transport, then this array is used + as the concentration for inflow over this boundary. + concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) + if this flow package is used in simulations also involving transport, then this keyword specifies + how outflow over this boundary is computed. + print_input: ({True, False}, optional) + keyword to indicate that the list of drain information will be written + to the listing file immediately after it is read. Default is False. + print_flows: ({True, False}, optional) + Indicates that the list of drain flow rates will be printed to the + listing file for every stress period time step in which "BUDGET PRINT" + is specified in Output Control. If there is no Output Control option and + PRINT FLOWS is specified, then flow rates are printed for the last time + step of each stress period. + Default is False. + save_flows: ({True, False}, optional) + Indicates that drain flow terms will be written to the file specified + with "BUDGET FILEOUT" in Output Control. Default is False. + observations: [Not yet supported.] + Default is None. + validate: {True, False} + Flag to indicate whether the package should be validated upon + initialization. This raises a ValidationError if package input is + provided in the wrong manner. Defaults to True. + repeat_stress: dict or xr.DataArray of datetimes, optional + Used to repeat data for e.g. repeating stress periods such as + seasonality without duplicating the values. If provided as dict, it + should map new dates to old dates present in the dataset. + ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided + as DataArray, it should have dimensions ``("repeat", "repeat_items")``. + The ``repeat_items`` dimension should have size 2: the first value is + the "key", the second value is the "value". For the "key" datetime, the + data of the "value" datetime will be used. + """ + + _pkg_id = "drn" + + # has to be ordered as in the list + _init_schemata = { + "elevation": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "conductance": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "print_flows": [DTypeSchema(np.bool_), DimsSchema()], + "save_flows": [DTypeSchema(np.bool_), DimsSchema()], + } + _write_schemata = { + "elevation": [ + OtherCoordsSchema("idomain"), + AllNoDataSchema(), # Check for all nan, can occur while clipping + AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), + ], + "conductance": [IdentityNoDataSchema("elevation"), AllValueSchema(">", 0.0)], + "concentration": [IdentityNoDataSchema("elevation"), AllValueSchema(">=", 0.0)], + } + + _period_data = ("elevation", "conductance") + _keyword_map = {} + _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) + _auxiliary_data = {"concentration": "species"} + _regrid_method = DrainageRegridMethod() + _aggregate_method: DataclassType = DrainageAggregationMethod() + + @init_log_decorator() + def __init__( + self, + elevation, + conductance, + concentration=None, + concentration_boundary_type="aux", + print_input=False, + print_flows=False, + save_flows=False, + observations=None, + validate: bool = True, + repeat_stress=None, + ): + dict_dataset = { + "elevation": elevation, + "conductance": conductance, + "concentration": concentration, + "concentration_boundary_type": concentration_boundary_type, + "print_input": print_input, + "print_flows": print_flows, + "save_flows": save_flows, + "observations": observations, + "repeat_stress": repeat_stress, + } + super().__init__(dict_dataset) + self._validate_init_schemata(validate) + + def _validate(self, schemata, **kwargs): + # Insert additional kwargs + kwargs["elevation"] = self["elevation"] + errors = super()._validate(schemata, **kwargs) + + return errors + + @standard_log_decorator() + def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: + """ + Clean up package inplace. This method calls + :func:`imod.prepare.cleanup_drn`, see documentation of that + function for details on cleanup. + + dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization + Model discretization package. + """ + dis_dict = {"idomain": dis.dataset["idomain"]} + cleaned_dict = self._call_func_on_grids(cleanup_drn, dis_dict) + super().__init__(cleaned_dict) + + @classmethod + def _allocate_and_distribute_planar_data( + cls, + planar_data: dict[str, GridDataArray], + dis: StructuredDiscretization | VerticesDiscretization, + npf: NodePropertyFlow, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + drop_empty_layers: bool = True, + ) -> dict[str, GridDataArray]: + """ + Allocate and distribute planar data for given discretization and npf + package. If layer number of ``planar_data`` is negative, + ``allocation_option`` is overrided and set to + ALLOCATION_OPTION.at_first_active. + + Parameters + ---------- + planar_data: dict[str, GridDataArray] + Dictionary with planar grid data. + dis: imod.mf6.StructuredDiscretization + Model discretization package. + npf: imod.mf6.NodePropertyFlow + Node property flow package. + allocation_option: ALLOCATION_OPTION + allocation option. If planar data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: DISTRIBUTING_OPTION + distributing option. + drop_empty_layers: bool + If True, drop layers without any allocated cells from the + returned grids. Allocation and distribution are always computed + over the full layer range first, so this does not affect the + computed values. + + Returns + ------- + dict[str, GridDataArray] + Dictionary with layered grid data. + """ + + top = dis.dataset["top"] + bottom = dis.dataset["bottom"] + idomain = dis.dataset["idomain"] + + if has_negative_layer(planar_data["elevation"]): + allocation_option = ALLOCATION_OPTION.at_first_active + + # Enforce planar data, remove all layer dimension information + planar_data = { + key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") + for key, grid in planar_data.items() + } + + drn_allocation = allocate_drn_cells( + allocation_option, + idomain > 0, + top, + bottom, + planar_data["elevation"], + drop_empty_layers=False, # Keep full layer range, drop empty layers below + ) + layered_data = {} + layered_data["conductance"] = distribute_drn_conductance( + distributing_option, + drn_allocation, + planar_data["conductance"], + top, + bottom, + npf.dataset["k"], + planar_data["elevation"], + ) + layered_data["elevation"] = planar_data["elevation"].where(drn_allocation) + layered_data["elevation"] = enforce_dim_order(layered_data["elevation"]) + + if drop_empty_layers: + layered_data = drop_empty_layers_from_dict(layered_data, drn_allocation) + + return layered_data + + @classmethod + def from_imod5_data( + cls, + key: str, + imod5_data: dict[str, dict[str, GridDataArray]], + period_data: dict[str, list[datetime]], + target_dis: StructuredDiscretization, + target_npf: NodePropertyFlow, + time_min: datetime, + time_max: datetime, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + regridder_types: Optional[DrainageRegridMethod] = None, + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> "Drainage": + """ + Construct a drainage-package from iMOD5 data, loaded with the + :func:`imod.formats.prj.open_projectfile_data` function. + + .. note:: + + The method expects the iMOD5 model to be fully 3D, not quasi-3D. + + Parameters + ---------- + key: str + Packagename of the iMOD5 data to use. + imod5_data: dict + Dictionary with iMOD5 data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + period_data: dict + Dictionary with iMOD5 period data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + target_dis: StructuredDiscretization package + The grid that should be used for the new package. Does not + need to be identical to one of the input grids. + target_npf: NodePropertyFlow package + The conductivity information, used to compute drainage flux + allocation_option: ALLOCATION_OPTION + allocation option. If package data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: dict[str, DISTRIBUTING_OPTION] + distributing option. + time_min: datetime + Begin-time of the simulation. Used for expanding period data. + time_max: datetime + End-time of the simulation. Used for expanding period data. + regridder_types: DrainageRegridMethod, optional + Optional dataclass with regridder types for a specific variable. + Use this to override default regridding methods. + regrid_cache: RegridderWeightsCache, optional + stores regridder weights for different regridders. Can be used to speed up regridding, + if the same regridders are used several times for regridding different arrays. + + Returns + ------- + A Modflow 6 Drainage package. + """ + data = { + "elevation": imod5_data[key]["elevation"], + "conductance": imod5_data[key]["conductance"], + } + mask = data["conductance"] > 0 + data["conductance"] = data["conductance"].where(mask) + # Regrid the input data + regridded_package_data = regrid_imod5_pkg_data( + cls, data, target_dis, regridder_types, regrid_cache + ) + regridded_package_data = broadcast_and_mask_arrays(regridded_package_data) + is_planar = is_planar_grid(regridded_package_data["elevation"]) + if is_planar: + layered_data = cls._allocate_and_distribute_planar_data( + regridded_package_data, + target_dis, + target_npf, + allocation_option, + distributing_option, + ) + regridded_package_data.update(layered_data) + + drn = cls(**regridded_package_data, validate=True) + repeat = period_data.get(key) + set_repeat_stress_if_available(repeat, time_min, time_max, drn) + # Clip the drain package to the time range of the simulation and ensure + # time is forward filled. + drn = drn.clip_box(time_min=time_min, time_max=time_max) + + return drn diff --git a/imod/mf6/ghb.py b/imod/mf6/ghb.py index 955a9e89b..93f0ae7ef 100644 --- a/imod/mf6/ghb.py +++ b/imod/mf6/ghb.py @@ -1,348 +1,361 @@ -from datetime import datetime -from typing import Optional - -import numpy as np - -from imod.common.interfaces.iregridpackage import IRegridPackage -from imod.common.utilities.dataclass_type import DataclassType -from imod.common.utilities.mask import broadcast_and_mask_arrays -from imod.logging import init_log_decorator, standard_log_decorator -from imod.mf6.aggregate.aggregate_schemes import GeneralHeadBoundaryAggregationMethod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.regrid.regrid_schemes import ( - GeneralHeadBoundaryRegridMethod, -) -from imod.mf6.topsystem import TopSystemBoundaryCondition -from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data -from imod.mf6.utilities.package import set_repeat_stress_if_available -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA -from imod.prepare.cleanup import cleanup_ghb -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION, allocate_ghb_cells -from imod.prepare.topsystem.conductance import ( - DISTRIBUTING_OPTION, - distribute_ghb_conductance, -) -from imod.schemata import ( - AllCoordsValueSchema, - AllInsideNoDataSchema, - AllNoDataSchema, - AllValueSchema, - CoordsSchema, - DimsSchema, - DTypeSchema, - IdentityNoDataSchema, - IndexesSchema, - OtherCoordsSchema, -) -from imod.typing import GridDataArray -from imod.typing.grid import enforce_dim_order, has_negative_layer, is_planar_grid -from imod.util.regrid import RegridderWeightsCache - - -class GeneralHeadBoundary(TopSystemBoundaryCondition, IRegridPackage): - """ - The General-Head Boundary package is used to simulate head-dependent flux - boundaries. - https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=75 - - Parameters - ---------- - head: array of floats (xr.DataArray) - is the boundary head. (bhead) - conductance: array of floats (xr.DataArray) - is the hydraulic conductance of the interface between the aquifer cell and - the boundary.(cond) - concentration: array of floats (xr.DataArray, optional) - if this flow package is used in simulations also involving transport, then this array is used - as the concentration for inflow over this boundary. - concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) - if this flow package is used in simulations also involving transport, then this keyword specifies - how outflow over this boundary is computed. - print_input: ({True, False}, optional) - keyword to indicate that the list of general head boundary information - will be written to the listing file immediately after it is read. - Default is False. - print_flows: ({True, False}, optional) - Indicates that the list of general head boundary flow rates will be - printed to the listing file for every stress period time step in which - "BUDGET PRINT" is specified in Output Control. If there is no Output - Control option and PRINT FLOWS is specified, then flow rates are printed - for the last time step of each stress period. - Default is False. - save_flows: ({True, False}, optional) - Indicates that general head boundary flow terms will be written to the - file specified with "BUDGET FILEOUT" in Output Control. - Default is False. - observations: [Not yet supported.] - Default is None. - validate: {True, False} - Flag to indicate whether the package should be validated upon - initialization. This raises a ValidationError if package input is - provided in the wrong manner. Defaults to True. - repeat_stress: dict or xr.DataArray of datetimes, optional - Used to repeat data for e.g. repeating stress periods such as - seasonality without duplicating the values. If provided as dict, it - should map new dates to old dates present in the dataset. - ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided - as DataArray, it should have dimensions ``("repeat", "repeat_items")``. - The ``repeat_items`` dimension should have size 2: the first value is - the "key", the second value is the "value". For the "key" datetime, the - data of the "value" datetime will be used. - """ - - _pkg_id = "ghb" - _period_data = ("head", "conductance") - - _init_schemata = { - "head": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "conductance": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "concentration": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema( - ( - "species", - "layer", - ) - ), - CONC_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "print_flows": [DTypeSchema(np.bool_), DimsSchema()], - "save_flows": [DTypeSchema(np.bool_), DimsSchema()], - } - _write_schemata = { - "head": [ - OtherCoordsSchema("idomain"), - AllNoDataSchema(), # Check for all nan, can occur while clipping - AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), - ], - "conductance": [IdentityNoDataSchema("head"), AllValueSchema(">", 0.0)], - "concentration": [IdentityNoDataSchema("head"), AllValueSchema(">=", 0.0)], - } - - _keyword_map = {} - _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) - _auxiliary_data = {"concentration": "species"} - _regrid_method = GeneralHeadBoundaryRegridMethod() - _aggregate_method: DataclassType = GeneralHeadBoundaryAggregationMethod() - - @init_log_decorator() - def __init__( - self, - head, - conductance, - concentration=None, - concentration_boundary_type="aux", - print_input=False, - print_flows=False, - save_flows=False, - observations=None, - validate: bool = True, - repeat_stress=None, - ): - dict_dataset = { - "head": head, - "conductance": conductance, - "concentration": concentration, - "concentration_boundary_type": concentration_boundary_type, - "print_input": print_input, - "print_flows": print_flows, - "save_flows": save_flows, - "observations": observations, - "repeat_stress": repeat_stress, - } - super().__init__(dict_dataset) - self._validate_init_schemata(validate) - - def _validate(self, schemata, **kwargs): - # Insert additional kwargs - kwargs["head"] = self["head"] - errors = super()._validate(schemata, **kwargs) - - return errors - - @standard_log_decorator() - def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: - """ - Clean up package inplace. This method calls - :func:`imod.prepare.cleanup_ghb`, see documentation of that - function for details on cleanup. - - dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization - Model discretization package. - """ - dis_dict = {"idomain": dis.dataset["idomain"]} - cleaned_dict = self._call_func_on_grids(cleanup_ghb, dis_dict) - super().__init__(cleaned_dict) - - @classmethod - def _allocate_and_distribute_planar_data( - cls, - planar_data: dict[str, GridDataArray], - dis: StructuredDiscretization | VerticesDiscretization, - npf: NodePropertyFlow, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - ) -> dict[str, GridDataArray]: - """ - Allocate and distribute planar data for given discretization and npf - package. If layer number of ``planar_data`` is negative, - ``allocation_option`` is overrided and set to - ALLOCATION_OPTION.at_first_active. - - Parameters - ---------- - planar_data: dict[str, GridDataArray] - Dictionary with planar grid data. - dis: imod.mf6.StructuredDiscretization - Model discretization package. - npf: imod.mf6.NodePropertyFlow - Node property flow package. - allocation_option: ALLOCATION_OPTION - allocation option. If planar data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: DISTRIBUTING_OPTION - distributing option. - - Returns - ------- - dict[str, GridDataArray] - Dictionary with layered grid data. - """ - - top = dis.dataset["top"] - bottom = dis.dataset["bottom"] - idomain = dis.dataset["idomain"] - - if has_negative_layer(planar_data["head"]): - allocation_option = ALLOCATION_OPTION.at_first_active - - # Enforce planar data, remove all layer dimension information - planar_data = { - key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") - for key, grid in planar_data.items() - } - - ghb_allocation = allocate_ghb_cells( - allocation_option, - idomain > 0, - top, - bottom, - planar_data["head"], - ) - - layered_data = {} - layered_data["head"] = planar_data["head"].where(ghb_allocation) - layered_data["head"] = enforce_dim_order(layered_data["head"]) - - layered_data["conductance"] = distribute_ghb_conductance( - distributing_option, - ghb_allocation, - planar_data["conductance"], - top, - bottom, - npf.dataset["k"], - ) - return layered_data - - @classmethod - def from_imod5_data( - cls, - key: str, - imod5_data: dict[str, dict[str, GridDataArray]], - period_data: dict[str, list[datetime]], - target_dis: StructuredDiscretization, - target_npf: NodePropertyFlow, - time_min: datetime, - time_max: datetime, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - regridder_types: Optional[DataclassType] = None, - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> "GeneralHeadBoundary": - """ - Construct a GeneralHeadBoundary-package from iMOD5 data, loaded with the - :func:`imod.formats.prj.open_projectfile_data` function. - - .. note:: - - The method expects the iMOD5 model to be fully 3D, not quasi-3D. - - Parameters - ---------- - imod5_data: dict - Dictionary with iMOD5 data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - period_data: dict - Dictionary with iMOD5 period data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - target_dis: StructuredDiscretization package - The grid that should be used for the new package. Does not - need to be identical to one of the input grids. - target_npf: NodePropertyFlow package - The conductivity information, used to compute GHB flux - allocation_option: ALLOCATION_OPTION - allocation option. If package data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - time_min: datetime - Begin-time of the simulation. Used for expanding period data. - time_max: datetime - End-time of the simulation. Used for expanding period data. - distributing_option: dict[str, DISTRIBUTING_OPTION] - distributing option. - regrid_cache: RegridderWeightsCache, optional - stores regridder weights for different regridders. Can be used to speed up regridding, - if the same regridders are used several times for regridding different arrays. - regridder_types: RegridMethodType, optional - Optional dataclass with regridder types for a specific variable. - Use this to override default regridding methods. - - Returns - ------- - A Modflow 6 GeneralHeadBoundary packages. - """ - data = { - "head": imod5_data[key]["head"], - "conductance": imod5_data[key]["conductance"], - } - mask = data["conductance"] > 0 - data["conductance"] = data["conductance"].where(mask) - regridded_package_data = regrid_imod5_pkg_data( - cls, data, target_dis, regridder_types, regrid_cache - ) - regridded_package_data = broadcast_and_mask_arrays(regridded_package_data) - is_planar = is_planar_grid(regridded_package_data["conductance"]) - if is_planar: - layered_data = cls._allocate_and_distribute_planar_data( - regridded_package_data, - target_dis, - target_npf, - allocation_option, - distributing_option, - ) - regridded_package_data.update(layered_data) - - ghb = cls(**regridded_package_data, validate=True) - repeat = period_data.get(key) - set_repeat_stress_if_available(repeat, time_min, time_max, ghb) - # Clip the ghb package to the time range of the simulation and ensure - # time is forward filled. - ghb = ghb.clip_box(time_min=time_min, time_max=time_max) - return ghb +from datetime import datetime +from typing import Optional + +import numpy as np + +from imod.common.interfaces.iregridpackage import IRegridPackage +from imod.common.utilities.dataclass_type import DataclassType +from imod.common.utilities.mask import broadcast_and_mask_arrays +from imod.logging import init_log_decorator, standard_log_decorator +from imod.mf6.aggregate.aggregate_schemes import GeneralHeadBoundaryAggregationMethod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.regrid.regrid_schemes import ( + GeneralHeadBoundaryRegridMethod, +) +from imod.mf6.topsystem import TopSystemBoundaryCondition +from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data +from imod.mf6.utilities.package import set_repeat_stress_if_available +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA +from imod.prepare.cleanup import cleanup_ghb +from imod.prepare.topsystem.allocation import ( + ALLOCATION_OPTION, + allocate_ghb_cells, + drop_empty_layers_from_dict, +) +from imod.prepare.topsystem.conductance import ( + DISTRIBUTING_OPTION, + distribute_ghb_conductance, +) +from imod.schemata import ( + AllCoordsValueSchema, + AllInsideNoDataSchema, + AllNoDataSchema, + AllValueSchema, + CoordsSchema, + DimsSchema, + DTypeSchema, + IdentityNoDataSchema, + IndexesSchema, + OtherCoordsSchema, +) +from imod.typing import GridDataArray +from imod.typing.grid import enforce_dim_order, has_negative_layer, is_planar_grid +from imod.util.regrid import RegridderWeightsCache + + +class GeneralHeadBoundary(TopSystemBoundaryCondition, IRegridPackage): + """ + The General-Head Boundary package is used to simulate head-dependent flux + boundaries. + https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=75 + + Parameters + ---------- + head: array of floats (xr.DataArray) + is the boundary head. (bhead) + conductance: array of floats (xr.DataArray) + is the hydraulic conductance of the interface between the aquifer cell and + the boundary.(cond) + concentration: array of floats (xr.DataArray, optional) + if this flow package is used in simulations also involving transport, then this array is used + as the concentration for inflow over this boundary. + concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) + if this flow package is used in simulations also involving transport, then this keyword specifies + how outflow over this boundary is computed. + print_input: ({True, False}, optional) + keyword to indicate that the list of general head boundary information + will be written to the listing file immediately after it is read. + Default is False. + print_flows: ({True, False}, optional) + Indicates that the list of general head boundary flow rates will be + printed to the listing file for every stress period time step in which + "BUDGET PRINT" is specified in Output Control. If there is no Output + Control option and PRINT FLOWS is specified, then flow rates are printed + for the last time step of each stress period. + Default is False. + save_flows: ({True, False}, optional) + Indicates that general head boundary flow terms will be written to the + file specified with "BUDGET FILEOUT" in Output Control. + Default is False. + observations: [Not yet supported.] + Default is None. + validate: {True, False} + Flag to indicate whether the package should be validated upon + initialization. This raises a ValidationError if package input is + provided in the wrong manner. Defaults to True. + repeat_stress: dict or xr.DataArray of datetimes, optional + Used to repeat data for e.g. repeating stress periods such as + seasonality without duplicating the values. If provided as dict, it + should map new dates to old dates present in the dataset. + ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided + as DataArray, it should have dimensions ``("repeat", "repeat_items")``. + The ``repeat_items`` dimension should have size 2: the first value is + the "key", the second value is the "value". For the "key" datetime, the + data of the "value" datetime will be used. + """ + + _pkg_id = "ghb" + _period_data = ("head", "conductance") + + _init_schemata = { + "head": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "conductance": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "print_flows": [DTypeSchema(np.bool_), DimsSchema()], + "save_flows": [DTypeSchema(np.bool_), DimsSchema()], + } + _write_schemata = { + "head": [ + OtherCoordsSchema("idomain"), + AllNoDataSchema(), # Check for all nan, can occur while clipping + AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), + ], + "conductance": [IdentityNoDataSchema("head"), AllValueSchema(">", 0.0)], + "concentration": [IdentityNoDataSchema("head"), AllValueSchema(">=", 0.0)], + } + + _keyword_map = {} + _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) + _auxiliary_data = {"concentration": "species"} + _regrid_method = GeneralHeadBoundaryRegridMethod() + _aggregate_method: DataclassType = GeneralHeadBoundaryAggregationMethod() + + @init_log_decorator() + def __init__( + self, + head, + conductance, + concentration=None, + concentration_boundary_type="aux", + print_input=False, + print_flows=False, + save_flows=False, + observations=None, + validate: bool = True, + repeat_stress=None, + ): + dict_dataset = { + "head": head, + "conductance": conductance, + "concentration": concentration, + "concentration_boundary_type": concentration_boundary_type, + "print_input": print_input, + "print_flows": print_flows, + "save_flows": save_flows, + "observations": observations, + "repeat_stress": repeat_stress, + } + super().__init__(dict_dataset) + self._validate_init_schemata(validate) + + def _validate(self, schemata, **kwargs): + # Insert additional kwargs + kwargs["head"] = self["head"] + errors = super()._validate(schemata, **kwargs) + + return errors + + @standard_log_decorator() + def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: + """ + Clean up package inplace. This method calls + :func:`imod.prepare.cleanup_ghb`, see documentation of that + function for details on cleanup. + + dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization + Model discretization package. + """ + dis_dict = {"idomain": dis.dataset["idomain"]} + cleaned_dict = self._call_func_on_grids(cleanup_ghb, dis_dict) + super().__init__(cleaned_dict) + + @classmethod + def _allocate_and_distribute_planar_data( + cls, + planar_data: dict[str, GridDataArray], + dis: StructuredDiscretization | VerticesDiscretization, + npf: NodePropertyFlow, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + drop_empty_layers: bool = True, + ) -> dict[str, GridDataArray]: + """ + Allocate and distribute planar data for given discretization and npf + package. If layer number of ``planar_data`` is negative, + ``allocation_option`` is overrided and set to + ALLOCATION_OPTION.at_first_active. + + Parameters + ---------- + planar_data: dict[str, GridDataArray] + Dictionary with planar grid data. + dis: imod.mf6.StructuredDiscretization + Model discretization package. + npf: imod.mf6.NodePropertyFlow + Node property flow package. + allocation_option: ALLOCATION_OPTION + allocation option. If planar data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: DISTRIBUTING_OPTION + distributing option. + drop_empty_layers: bool + If True, drop layers without any allocated cells from the + returned grids. Allocation and distribution are always computed + over the full layer range first, so this does not affect the + computed values. + + Returns + ------- + dict[str, GridDataArray] + Dictionary with layered grid data. + """ + + top = dis.dataset["top"] + bottom = dis.dataset["bottom"] + idomain = dis.dataset["idomain"] + + if has_negative_layer(planar_data["head"]): + allocation_option = ALLOCATION_OPTION.at_first_active + + # Enforce planar data, remove all layer dimension information + planar_data = { + key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") + for key, grid in planar_data.items() + } + + ghb_allocation = allocate_ghb_cells( + allocation_option, + idomain > 0, + top, + bottom, + planar_data["head"], + drop_empty_layers=False, # Keep full layer range, drop empty layers below + ) + + layered_data = {} + layered_data["head"] = planar_data["head"].where(ghb_allocation) + layered_data["head"] = enforce_dim_order(layered_data["head"]) + + layered_data["conductance"] = distribute_ghb_conductance( + distributing_option, + ghb_allocation, + planar_data["conductance"], + top, + bottom, + npf.dataset["k"], + ) + if drop_empty_layers: + layered_data = drop_empty_layers_from_dict(layered_data, ghb_allocation) + return layered_data + + @classmethod + def from_imod5_data( + cls, + key: str, + imod5_data: dict[str, dict[str, GridDataArray]], + period_data: dict[str, list[datetime]], + target_dis: StructuredDiscretization, + target_npf: NodePropertyFlow, + time_min: datetime, + time_max: datetime, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + regridder_types: Optional[DataclassType] = None, + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> "GeneralHeadBoundary": + """ + Construct a GeneralHeadBoundary-package from iMOD5 data, loaded with the + :func:`imod.formats.prj.open_projectfile_data` function. + + .. note:: + + The method expects the iMOD5 model to be fully 3D, not quasi-3D. + + Parameters + ---------- + imod5_data: dict + Dictionary with iMOD5 data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + period_data: dict + Dictionary with iMOD5 period data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + target_dis: StructuredDiscretization package + The grid that should be used for the new package. Does not + need to be identical to one of the input grids. + target_npf: NodePropertyFlow package + The conductivity information, used to compute GHB flux + allocation_option: ALLOCATION_OPTION + allocation option. If package data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + time_min: datetime + Begin-time of the simulation. Used for expanding period data. + time_max: datetime + End-time of the simulation. Used for expanding period data. + distributing_option: dict[str, DISTRIBUTING_OPTION] + distributing option. + regrid_cache: RegridderWeightsCache, optional + stores regridder weights for different regridders. Can be used to speed up regridding, + if the same regridders are used several times for regridding different arrays. + regridder_types: RegridMethodType, optional + Optional dataclass with regridder types for a specific variable. + Use this to override default regridding methods. + + Returns + ------- + A Modflow 6 GeneralHeadBoundary packages. + """ + data = { + "head": imod5_data[key]["head"], + "conductance": imod5_data[key]["conductance"], + } + mask = data["conductance"] > 0 + data["conductance"] = data["conductance"].where(mask) + regridded_package_data = regrid_imod5_pkg_data( + cls, data, target_dis, regridder_types, regrid_cache + ) + regridded_package_data = broadcast_and_mask_arrays(regridded_package_data) + is_planar = is_planar_grid(regridded_package_data["conductance"]) + if is_planar: + layered_data = cls._allocate_and_distribute_planar_data( + regridded_package_data, + target_dis, + target_npf, + allocation_option, + distributing_option, + ) + regridded_package_data.update(layered_data) + + ghb = cls(**regridded_package_data, validate=True) + repeat = period_data.get(key) + set_repeat_stress_if_available(repeat, time_min, time_max, ghb) + # Clip the ghb package to the time range of the simulation and ensure + # time is forward filled. + ghb = ghb.clip_box(time_min=time_min, time_max=time_max) + return ghb diff --git a/imod/mf6/rch.py b/imod/mf6/rch.py index 1933f480e..1e92122c6 100644 --- a/imod/mf6/rch.py +++ b/imod/mf6/rch.py @@ -1,313 +1,325 @@ -from datetime import datetime -from typing import Optional - -import numpy as np -import xarray as xr - -from imod.common.interfaces.iregridpackage import IRegridPackage -from imod.common.utilities.dataclass_type import DataclassType -from imod.common.utilities.regrid import regrid_imod5_cap_data -from imod.logging import init_log_decorator -from imod.mf6.aggregate.aggregate_schemes import RechargeAggregationMethod -from imod.mf6.dis import StructuredDiscretization, VerticesDiscretization -from imod.mf6.regrid.regrid_schemes import ( - CapDataRechargeRegridMethod, - RechargeRegridMethod, -) -from imod.mf6.topsystem import TopSystemBoundaryCondition -from imod.mf6.utilities.imod5_converter import ( - convert_unit_rch_rate, - regrid_imod5_pkg_data, -) -from imod.mf6.utilities.package import set_repeat_stress_if_available -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA -from imod.msw.utilities.imod5_converter import ( - get_cell_area_from_imod5_data, - is_msw_active_cell, -) -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION, allocate_rch_cells -from imod.schemata import ( - AllCoordsValueSchema, - AllInsideNoDataSchema, - AllNoDataSchema, - AllValueSchema, - CoordsSchema, - DimsSchema, - DTypeSchema, - IdentityNoDataSchema, - IndexesSchema, - OtherCoordsSchema, -) -from imod.typing import GridDataArray, Imod5DataDict -from imod.typing.grid import ( - enforce_dim_order, - is_planar_grid, -) -from imod.util.regrid import RegridderWeightsCache - - -class Recharge(TopSystemBoundaryCondition, IRegridPackage): - """ - Recharge Package. - Any number of RCH Packages can be specified for a single groundwater flow - model. - https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=79 - - Parameters - ---------- - rate: array of floats (xr.DataArray) - is the recharge flux rate (LT −1). This rate is multiplied inside the - program by the surface area of the cell to calculate the volumetric - recharge rate. A time-series name may be specified. - concentration: array of floats (xr.DataArray, optional) - if this flow package is used in simulations also involving transport, then this array is used - as the concentration for inflow over this boundary. - concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) - if this flow package is used in simulations also involving transport, then this keyword specifies - how outflow over this boundary is computed. - print_input: ({True, False}, optional) - keyword to indicate that the list of recharge information will be - written to the listing file immediately after it is read. - Default is False. - print_flows: ({True, False}, optional) - Indicates that the list of recharge flow rates will be printed to the - listing file for every stress period time step in which "BUDGET PRINT"is - specified in Output Control. If there is no Output Control option and - PRINT FLOWS is specified, then flow rates are printed for the last time - step of each stress period. - Default is False. - save_flows: ({True, False}, optional) - Indicates that recharge flow terms will be written to the file specified - with "BUDGET FILEOUT" in Output Control. - Default is False. - observations: [Not yet supported.] - Default is None. - validate: {True, False} - Flag to indicate whether the package should be validated upon - initialization. This raises a ValidationError if package input is - provided in the wrong manner. Defaults to True. - repeat_stress: dict or xr.DataArray of datetimes, optional - Used to repeat data for e.g. repeating stress periods such as - seasonality without duplicating the values. If provided as dict, it - should map new dates to old dates present in the dataset. - ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided - as DataArray, it should have dimensions ``("repeat", "repeat_items")``. - The ``repeat_items`` dimension should have size 2: the first value is - the "key", the second value is the "value". For the "key" datetime, the - data of the "value" datetime will be used. - fixed_cell: ({True, False}, optional) - indicates that recharge will not be reassigned to a cell underlying the - cell specified in the list if the specified cell is inactive. - """ - - _pkg_id = "rch" - _period_data = ("rate",) - _keyword_map = {} - - _init_schemata = { - "rate": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "concentration": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema( - ( - "species", - "layer", - ) - ), - CONC_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "print_flows": [DTypeSchema(np.bool_), DimsSchema()], - "save_flows": [DTypeSchema(np.bool_), DimsSchema()], - } - _write_schemata = { - "rate": [ - OtherCoordsSchema("idomain"), - AllNoDataSchema(), # Check for all nan, can occur while clipping - AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), - ], - "concentration": [IdentityNoDataSchema("rate"), AllValueSchema(">=", 0.0)], - } - - _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) - _auxiliary_data = {"concentration": "species"} - _regrid_method = RechargeRegridMethod() - _aggregate_method: DataclassType = RechargeAggregationMethod() - - @init_log_decorator() - def __init__( - self, - rate, - concentration=None, - concentration_boundary_type="auxmixed", - print_input=False, - print_flows=False, - save_flows=False, - observations=None, - validate: bool = True, - repeat_stress=None, - fixed_cell: bool = False, - ): - dict_dataset = { - "rate": rate, - "concentration": concentration, - "concentration_boundary_type": concentration_boundary_type, - "print_input": print_input, - "print_flows": print_flows, - "save_flows": save_flows, - "observations": observations, - "repeat_stress": repeat_stress, - "fixed_cell": fixed_cell, - } - super().__init__(dict_dataset) - self._validate_init_schemata(validate) - - def _validate(self, schemata, **kwargs): - # Insert additional kwargs - kwargs["rate"] = self["rate"] - errors = super()._validate(schemata, **kwargs) - - return errors - - @classmethod - def _allocate_planar_data( - cls, - planar_data: dict[str, GridDataArray], - dis: StructuredDiscretization | VerticesDiscretization, - allocation_option: ALLOCATION_OPTION, - ) -> dict[str, GridDataArray]: - """ - Allocate and distribute planar data for given discretization and npf - package. To allocate cells, the allocation option - ALLOCATION_OPTION.at_first_active is set. - - Parameters - ---------- - planar_data: dict[str, GridDataArray] - Dictionary with planar grid data. - dis: imod.mf6.StructuredDiscretization - Model discretization package. - allocation_option: ALLOCATION_OPTION - The allocation option to use for the reallocation. - - Returns - ------- - dict[str, GridDataArray] - Dictionary with layered grid data. - """ - idomain = dis.dataset["idomain"] - if "layer" in planar_data["rate"].dims: - planar_data["rate"] = planar_data["rate"].isel(layer=0, drop=True) - # create an array indicating in which cells rch is active - is_rch_cell = allocate_rch_cells( - allocation_option, - idomain > 0, - planar_data["rate"], - ) - # remove rch from cells where it is not allocated and broadcast over layers. - layered_data = {} - layered_data["rate"] = planar_data["rate"].where(is_rch_cell) - layered_data["rate"] = enforce_dim_order(layered_data["rate"]) - return layered_data - - @classmethod - def from_imod5_data( - cls, - imod5_data: dict[str, dict[str, GridDataArray]], - period_data: dict[str, list[datetime]], - target_dis: StructuredDiscretization, - time_min: datetime, - time_max: datetime, - regridder_types: Optional[RechargeRegridMethod] = None, - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> "Recharge": - """ - Construct an rch-package from iMOD5 data, loaded with the - :func:`imod.formats.prj.open_projectfile_data` function. - - .. note:: - - The method expects the iMOD5 model to be fully 3D, not quasi-3D. - - Parameters - ---------- - imod5_data: dict - Dictionary with iMOD5 data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - period_data: dict - Dictionary with iMOD5 period data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - target_dis: GridDataArray - The discretization package for the simulation. Its grid does not - need to be identical to one of the input grids. - time_min: datetime - Begin-time of the simulation. Used for expanding period data. - time_max: datetime - End-time of the simulation. Used for expanding period data. - regridder_types: RechargeRegridMethod, optional - Optional dataclass with regridder types for a specific variable. - Use this to override default regridding methods. - regrid_cache: RegridderWeightsCache, optional - stores regridder weights for different regridders. Can be used to speed up regridding, - if the same regridders are used several times for regridding different arrays. - - Returns - ------- - Modflow 6 rch package. - - """ - data = { - "rate": convert_unit_rch_rate(imod5_data["rch"]["rate"]), - } - regridded_package_data = regrid_imod5_pkg_data( - cls, data, target_dis, regridder_types, regrid_cache - ) - # if rate has only layer 0, then it is planar. - if is_planar_grid(regridded_package_data["rate"]): - allocation_option = ALLOCATION_OPTION.at_first_active - layered_data = cls._allocate_planar_data( - regridded_package_data, target_dis, allocation_option - ) - regridded_package_data.update(layered_data) - rch = cls(**regridded_package_data, validate=True, fixed_cell=False) - repeat = period_data.get("rch") - set_repeat_stress_if_available(repeat, time_min, time_max, rch) - # Clip the rch package to the time range of the simulation and ensure - # time is forward filled. - rch = rch.clip_box(time_min=time_min, time_max=time_max) - return rch - - @classmethod - def from_imod5_cap_data( - cls, - imod5_data: Imod5DataDict, - target_dis: StructuredDiscretization, - regridder_types: CapDataRechargeRegridMethod = CapDataRechargeRegridMethod(), - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> "Recharge": - """ - Construct an rch-package from iMOD5 data in the CAP package, loaded with - the :func:`imod.formats.prj.open_projectfile_data` function. Package is - used to couple MODFLOW6 to MetaSWAP models. Active cells will have a - recharge rate of 0.0. - """ - cap_data = regrid_imod5_cap_data( - imod5_data, target_dis, regridder_types, regrid_cache - )["cap"] - - msw_area = get_cell_area_from_imod5_data(cap_data) - msw_active = is_msw_active_cell(target_dis, cap_data, msw_area) - active = msw_active.all - - data = {} - zero_scalar = xr.DataArray(0.0, coords={"layer": 1}) - data["rate"] = zero_scalar.where(active) - - return cls(**data, validate=True, fixed_cell=False) +from datetime import datetime +from typing import Optional + +import numpy as np +import xarray as xr + +from imod.common.interfaces.iregridpackage import IRegridPackage +from imod.common.utilities.dataclass_type import DataclassType +from imod.common.utilities.regrid import regrid_imod5_cap_data +from imod.logging import init_log_decorator +from imod.mf6.aggregate.aggregate_schemes import RechargeAggregationMethod +from imod.mf6.dis import StructuredDiscretization, VerticesDiscretization +from imod.mf6.regrid.regrid_schemes import ( + CapDataRechargeRegridMethod, + RechargeRegridMethod, +) +from imod.mf6.topsystem import TopSystemBoundaryCondition +from imod.mf6.utilities.imod5_converter import ( + convert_unit_rch_rate, + regrid_imod5_pkg_data, +) +from imod.mf6.utilities.package import set_repeat_stress_if_available +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA +from imod.msw.utilities.imod5_converter import ( + get_cell_area_from_imod5_data, + is_msw_active_cell, +) +from imod.prepare.topsystem.allocation import ( + ALLOCATION_OPTION, + allocate_rch_cells, + drop_empty_layers_from_dict, +) +from imod.schemata import ( + AllCoordsValueSchema, + AllInsideNoDataSchema, + AllNoDataSchema, + AllValueSchema, + CoordsSchema, + DimsSchema, + DTypeSchema, + IdentityNoDataSchema, + IndexesSchema, + OtherCoordsSchema, +) +from imod.typing import GridDataArray, Imod5DataDict +from imod.typing.grid import ( + enforce_dim_order, + is_planar_grid, +) +from imod.util.regrid import RegridderWeightsCache + + +class Recharge(TopSystemBoundaryCondition, IRegridPackage): + """ + Recharge Package. + Any number of RCH Packages can be specified for a single groundwater flow + model. + https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=79 + + Parameters + ---------- + rate: array of floats (xr.DataArray) + is the recharge flux rate (LT −1). This rate is multiplied inside the + program by the surface area of the cell to calculate the volumetric + recharge rate. A time-series name may be specified. + concentration: array of floats (xr.DataArray, optional) + if this flow package is used in simulations also involving transport, then this array is used + as the concentration for inflow over this boundary. + concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) + if this flow package is used in simulations also involving transport, then this keyword specifies + how outflow over this boundary is computed. + print_input: ({True, False}, optional) + keyword to indicate that the list of recharge information will be + written to the listing file immediately after it is read. + Default is False. + print_flows: ({True, False}, optional) + Indicates that the list of recharge flow rates will be printed to the + listing file for every stress period time step in which "BUDGET PRINT"is + specified in Output Control. If there is no Output Control option and + PRINT FLOWS is specified, then flow rates are printed for the last time + step of each stress period. + Default is False. + save_flows: ({True, False}, optional) + Indicates that recharge flow terms will be written to the file specified + with "BUDGET FILEOUT" in Output Control. + Default is False. + observations: [Not yet supported.] + Default is None. + validate: {True, False} + Flag to indicate whether the package should be validated upon + initialization. This raises a ValidationError if package input is + provided in the wrong manner. Defaults to True. + repeat_stress: dict or xr.DataArray of datetimes, optional + Used to repeat data for e.g. repeating stress periods such as + seasonality without duplicating the values. If provided as dict, it + should map new dates to old dates present in the dataset. + ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided + as DataArray, it should have dimensions ``("repeat", "repeat_items")``. + The ``repeat_items`` dimension should have size 2: the first value is + the "key", the second value is the "value". For the "key" datetime, the + data of the "value" datetime will be used. + fixed_cell: ({True, False}, optional) + indicates that recharge will not be reassigned to a cell underlying the + cell specified in the list if the specified cell is inactive. + """ + + _pkg_id = "rch" + _period_data = ("rate",) + _keyword_map = {} + + _init_schemata = { + "rate": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "print_flows": [DTypeSchema(np.bool_), DimsSchema()], + "save_flows": [DTypeSchema(np.bool_), DimsSchema()], + } + _write_schemata = { + "rate": [ + OtherCoordsSchema("idomain"), + AllNoDataSchema(), # Check for all nan, can occur while clipping + AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), + ], + "concentration": [IdentityNoDataSchema("rate"), AllValueSchema(">=", 0.0)], + } + + _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) + _auxiliary_data = {"concentration": "species"} + _regrid_method = RechargeRegridMethod() + _aggregate_method: DataclassType = RechargeAggregationMethod() + + @init_log_decorator() + def __init__( + self, + rate, + concentration=None, + concentration_boundary_type="auxmixed", + print_input=False, + print_flows=False, + save_flows=False, + observations=None, + validate: bool = True, + repeat_stress=None, + fixed_cell: bool = False, + ): + dict_dataset = { + "rate": rate, + "concentration": concentration, + "concentration_boundary_type": concentration_boundary_type, + "print_input": print_input, + "print_flows": print_flows, + "save_flows": save_flows, + "observations": observations, + "repeat_stress": repeat_stress, + "fixed_cell": fixed_cell, + } + super().__init__(dict_dataset) + self._validate_init_schemata(validate) + + def _validate(self, schemata, **kwargs): + # Insert additional kwargs + kwargs["rate"] = self["rate"] + errors = super()._validate(schemata, **kwargs) + + return errors + + @classmethod + def _allocate_planar_data( + cls, + planar_data: dict[str, GridDataArray], + dis: StructuredDiscretization | VerticesDiscretization, + allocation_option: ALLOCATION_OPTION, + drop_empty_layers: bool = True, + ) -> dict[str, GridDataArray]: + """ + Allocate and distribute planar data for given discretization and npf + package. To allocate cells, the allocation option + ALLOCATION_OPTION.at_first_active is set. + + Parameters + ---------- + planar_data: dict[str, GridDataArray] + Dictionary with planar grid data. + dis: imod.mf6.StructuredDiscretization + Model discretization package. + allocation_option: ALLOCATION_OPTION + The allocation option to use for the reallocation. + drop_empty_layers: bool + If True, drop layers without any allocated cells from the + returned grids. Allocation is always computed over the full + layer range first, so this does not affect the computed values. + + Returns + ------- + dict[str, GridDataArray] + Dictionary with layered grid data. + """ + idomain = dis.dataset["idomain"] + if "layer" in planar_data["rate"].dims: + planar_data["rate"] = planar_data["rate"].isel(layer=0, drop=True) + # create an array indicating in which cells rch is active + is_rch_cell = allocate_rch_cells( + allocation_option, + idomain > 0, + planar_data["rate"], + drop_empty_layers=False, # Keep full here, drop empty layers below + ) + # remove rch from cells where it is not allocated and broadcast over layers. + layered_data = {} + layered_data["rate"] = planar_data["rate"].where(is_rch_cell) + layered_data["rate"] = enforce_dim_order(layered_data["rate"]) + if drop_empty_layers: + layered_data = drop_empty_layers_from_dict(layered_data, is_rch_cell) + return layered_data + + @classmethod + def from_imod5_data( + cls, + imod5_data: dict[str, dict[str, GridDataArray]], + period_data: dict[str, list[datetime]], + target_dis: StructuredDiscretization, + time_min: datetime, + time_max: datetime, + regridder_types: Optional[RechargeRegridMethod] = None, + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> "Recharge": + """ + Construct an rch-package from iMOD5 data, loaded with the + :func:`imod.formats.prj.open_projectfile_data` function. + + .. note:: + + The method expects the iMOD5 model to be fully 3D, not quasi-3D. + + Parameters + ---------- + imod5_data: dict + Dictionary with iMOD5 data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + period_data: dict + Dictionary with iMOD5 period data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + target_dis: GridDataArray + The discretization package for the simulation. Its grid does not + need to be identical to one of the input grids. + time_min: datetime + Begin-time of the simulation. Used for expanding period data. + time_max: datetime + End-time of the simulation. Used for expanding period data. + regridder_types: RechargeRegridMethod, optional + Optional dataclass with regridder types for a specific variable. + Use this to override default regridding methods. + regrid_cache: RegridderWeightsCache, optional + stores regridder weights for different regridders. Can be used to speed up regridding, + if the same regridders are used several times for regridding different arrays. + + Returns + ------- + Modflow 6 rch package. + + """ + data = { + "rate": convert_unit_rch_rate(imod5_data["rch"]["rate"]), + } + regridded_package_data = regrid_imod5_pkg_data( + cls, data, target_dis, regridder_types, regrid_cache + ) + # if rate has only layer 0, then it is planar. + if is_planar_grid(regridded_package_data["rate"]): + allocation_option = ALLOCATION_OPTION.at_first_active + layered_data = cls._allocate_planar_data( + regridded_package_data, target_dis, allocation_option + ) + regridded_package_data.update(layered_data) + rch = cls(**regridded_package_data, validate=True, fixed_cell=False) + repeat = period_data.get("rch") + set_repeat_stress_if_available(repeat, time_min, time_max, rch) + # Clip the rch package to the time range of the simulation and ensure + # time is forward filled. + rch = rch.clip_box(time_min=time_min, time_max=time_max) + return rch + + @classmethod + def from_imod5_cap_data( + cls, + imod5_data: Imod5DataDict, + target_dis: StructuredDiscretization, + regridder_types: CapDataRechargeRegridMethod = CapDataRechargeRegridMethod(), + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> "Recharge": + """ + Construct an rch-package from iMOD5 data in the CAP package, loaded with + the :func:`imod.formats.prj.open_projectfile_data` function. Package is + used to couple MODFLOW6 to MetaSWAP models. Active cells will have a + recharge rate of 0.0. + """ + cap_data = regrid_imod5_cap_data( + imod5_data, target_dis, regridder_types, regrid_cache + )["cap"] + + msw_area = get_cell_area_from_imod5_data(cap_data) + msw_active = is_msw_active_cell(target_dis, cap_data, msw_area) + active = msw_active.all + + data = {} + zero_scalar = xr.DataArray(0.0, coords={"layer": 1}) + data["rate"] = zero_scalar.where(active) + + return cls(**data, validate=True, fixed_cell=False) diff --git a/imod/mf6/riv.py b/imod/mf6/riv.py index 0957ebbad..2dd0249fd 100644 --- a/imod/mf6/riv.py +++ b/imod/mf6/riv.py @@ -1,537 +1,557 @@ -from datetime import datetime -from typing import Optional, Tuple, cast - -import numpy as np - -from imod import logging -from imod.common.interfaces.iregridpackage import IRegridPackage -from imod.common.utilities.dataclass_type import DataclassType -from imod.common.utilities.mask import broadcast_and_mask_arrays -from imod.logging import init_log_decorator, standard_log_decorator -from imod.mf6.aggregate.aggregate_schemes import RiverAggregationMethod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.drn import Drainage -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.regrid.regrid_schemes import RiverRegridMethod -from imod.mf6.topsystem import TopSystemBoundaryCondition -from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data -from imod.mf6.utilities.package import set_repeat_stress_if_available -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA -from imod.prepare.cleanup import AlignLevelsMode, align_interface_levels, cleanup_riv -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION, allocate_riv_cells -from imod.prepare.topsystem.conductance import ( - DISTRIBUTING_OPTION, - distribute_drn_conductance, - distribute_riv_conductance, - split_conductance_with_infiltration_factor, -) -from imod.schemata import ( - AllCoordsValueSchema, - AllInsideNoDataSchema, - AllNoDataSchema, - AllValueSchema, - CoordsSchema, - DimsSchema, - DTypeSchema, - IdentityNoDataSchema, - IndexesSchema, - OtherCoordsSchema, -) -from imod.typing import GridDataArray, GridDataDict -from imod.typing.grid import ( - concat, - enforce_dim_order, - has_negative_layer, - is_planar_grid, -) -from imod.util.regrid import ( - RegridderWeightsCache, -) - - -def mask_package__drop_if_empty( - package: TopSystemBoundaryCondition, -) -> Optional[TopSystemBoundaryCondition]: - """ " - Create an optional package from a package if it has data. Return None if - package is inactive everywhere. - """ - # remove River package if its mask is False everywhere - mask = ~np.isnan(package["conductance"]) - return package.mask(mask) if np.any(mask) else None - - -def clip_time_if_package( - package: Optional[TopSystemBoundaryCondition], - time_min: datetime, - time_max: datetime, -) -> Optional[TopSystemBoundaryCondition]: - if package is not None: - package = package.clip_box(time_min=time_min, time_max=time_max) - return package - - -def rise_bottom_elevation_if_needed( - bottom_elevation: GridDataArray, bottom: GridDataArray -) -> GridDataArray: - """ - Due to regridding, the bottom_elevation could be less than the - layer bottom, so here we overwrite it with bottom if that's - the case. - """ - is_layer_bottom_above_bottom_elevation = (bottom > bottom_elevation).any() - - if is_layer_bottom_above_bottom_elevation: - logging.logger.warning( - "Note: riv bottom was detected below model bottom. Updated the riv's bottom." - ) - bottom_elevation, _ = align_interface_levels( - bottom_elevation, bottom, AlignLevelsMode.BOTTOMUP - ) - return bottom_elevation - - -def _separate_infiltration_data( - riv_pkg_data: GridDataDict, infiltration_factor: GridDataArray -) -> tuple[GridDataDict, GridDataDict]: - """ - Account for the infiltration factor in the river package data. This function - updates the riv_pkg_data with an infiltration conductance. The extra - exfiltration conductance is separated into a data dict for drainage - """ - # update the conductance of the river package to account for the - # infiltration factor - drain_conductance, river_conductance = split_conductance_with_infiltration_factor( - riv_pkg_data["conductance"], infiltration_factor - ) - riv_pkg_data["conductance"] = river_conductance - # create a drainage package with the conductance we computed from the - # infiltration factor - drn_pkg_data = { - "elevation": riv_pkg_data["stage"], - "conductance": drain_conductance, - } - return riv_pkg_data, drn_pkg_data - - -def _create_drain_from_leftover_riv_imod5_data( - allocation_drn_data: GridDataDict, - infiltration_drn_data: GridDataDict, -) -> Drainage: - """ - Create a drainage package from leftover imod5 river package data, - stemming from: - - * If ``ALLOCATION_OPTION.stage_to_riv_bottom_drn_above`` is chosen, - drain cells are allocated from the first active cell to river - stage. In this case ``allocation_drn_data`` is not empty. - * Infiltration factor. This factor is optional in imod5, but it - does not exist in MF6, so we mimic its effect with a Drainage - boundary. This data is stored in ``infiltration_drn_data``. - """ - - if allocation_drn_data: - drain_leftover_data: GridDataDict = {} - for key, allocation_grid in allocation_drn_data.items(): - concatenated = concat( - [allocation_grid, infiltration_drn_data[key]], dim="leftover" - ) - drain_leftover_data[key] = concatenated.mean(dim="leftover") - else: - drain_leftover_data = infiltration_drn_data - - return Drainage(**drain_leftover_data) # type: ignore - - -class River(TopSystemBoundaryCondition, IRegridPackage): - """ - River package. - Any number of RIV Packages can be specified for a single groundwater flow - model. - https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=71 - - Parameters - ---------- - stage: array of floats (xr.DataArray) - is the head in the river. - conductance: array of floats (xr.DataArray) - is the riverbed hydraulic conductance. - bottom_elevation: array of floats (xr.DataArray) - is the elevation of the bottom of the riverbed. - concentration: array of floats (xr.DataArray, optional) - if this flow package is used in simulations also involving transport, then this array is used - as the concentration for inflow over this boundary. - concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) - if this flow package is used in simulations also involving transport, then this keyword specifies - how outflow over this boundary is computed. - print_input: ({True, False}, optional) - keyword to indicate that the list of river information will be written - to the listing file immediately after it is read. Default is False. - print_flows: ({True, False}, optional) - Indicates that the list of river flow rates will be printed to the - listing file for every stress period time step in which "BUDGET PRINT" - is specified in Output Control. If there is no Output Control option and - PRINT FLOWS is specified, then flow rates are printed for the last time - step of each stress period. Default is False. - save_flows: ({True, False}, optional) - Indicates that river flow terms will be written to the file specified - with "BUDGET FILEOUT" in Output Control. Default is False. - observations: [Not yet supported.] - Default is None. - validate: {True, False} - Flag to indicate whether the package should be validated upon - initialization. This raises a ValidationError if package input is - provided in the wrong manner. Defaults to True. - repeat_stress: dict or xr.DataArray of datetimes, optional - Used to repeat data for e.g. repeating stress periods such as - seasonality without duplicating the values. If provided as dict, it - should map new dates to old dates present in the dataset. - ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided - as DataArray, it should have dimensions ``("repeat", "repeat_items")``. - The ``repeat_items`` dimension should have size 2: the first value is - the "key", the second value is the "value". For the "key" datetime, the - data of the "value" datetime will be used. - """ - - _pkg_id = "riv" - _period_data = ("stage", "conductance", "bottom_elevation") - _keyword_map = {} - - _init_schemata = { - "stage": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "conductance": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "bottom_elevation": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "concentration": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema( - ( - "species", - "layer", - ) - ), - CONC_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "print_input": [DTypeSchema(np.bool_), DimsSchema()], - "print_flows": [DTypeSchema(np.bool_), DimsSchema()], - "save_flows": [DTypeSchema(np.bool_), DimsSchema()], - } - _write_schemata = { - "stage": [ - AllValueSchema(">=", "bottom_elevation"), - OtherCoordsSchema("idomain"), - AllNoDataSchema(), # Check for all nan, can occur while clipping - AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), - ], - "conductance": [IdentityNoDataSchema("stage"), AllValueSchema(">", 0.0)], - "bottom_elevation": [ - IdentityNoDataSchema("stage"), - # Check river bottom above layer bottom, else Modflow throws error. - AllValueSchema(">=", "bottom", ignore=("icelltype", "==", 0)), - ], - "concentration": [IdentityNoDataSchema("stage"), AllValueSchema(">=", 0.0)], - } - - _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) - _auxiliary_data = {"concentration": "species"} - _regrid_method = RiverRegridMethod() - _aggregate_method: DataclassType = RiverAggregationMethod() - - @init_log_decorator() - def __init__( - self, - stage, - conductance, - bottom_elevation, - concentration=None, - concentration_boundary_type="aux", - print_input=False, - print_flows=False, - save_flows=False, - observations=None, - validate: bool = True, - repeat_stress=None, - ): - dict_dataset = { - "stage": stage, - "conductance": conductance, - "bottom_elevation": bottom_elevation, - "concentration": concentration, - "concentration_boundary_type": concentration_boundary_type, - "print_input": print_input, - "print_flows": print_flows, - "save_flows": save_flows, - "observations": observations, - "repeat_stress": repeat_stress, - } - super().__init__(dict_dataset) - self._validate_init_schemata(validate) - - def _validate(self, schemata, **kwargs): - # Insert additional kwargs - kwargs["stage"] = self["stage"] - kwargs["bottom_elevation"] = self["bottom_elevation"] - errors = super()._validate(schemata, **kwargs) - - return errors - - @standard_log_decorator() - def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: - """ - Clean up package inplace. This method calls - :func:`imod.prepare.cleanup_riv`, see documentation of that - function for details on cleanup. - - dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization - Model discretization package. - """ - dis_dict = {"idomain": dis.dataset["idomain"], "bottom": dis.dataset["bottom"]} - cleaned_dict = self._call_func_on_grids(cleanup_riv, dis_dict) - super().__init__(cleaned_dict) - - @classmethod - def _allocate_and_distribute_planar_data( - cls, - planar_data: GridDataDict, - dis: StructuredDiscretization | VerticesDiscretization, - npf: NodePropertyFlow, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - ) -> tuple[GridDataDict, GridDataDict]: - """ - Allocate and distribute planar data for given discretization and npf - package. If layer number of ``planar_data`` is negative, - ``allocation_option`` is overrided and set to - ALLOCATION_OPTION.at_first_active. - - Parameters - ---------- - planar_data: GridDataDict - Dictionary with planar grid data. - dis: imod.mf6.StructuredDiscretization - Model discretization package. - npf: imod.mf6.NodePropertyFlow - Node property flow package. - allocation_option: ALLOCATION_OPTION - allocation option. If planar data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: DISTRIBUTING_OPTION - distributing option. - - Returns - ------- - GridDataDict - Dictionary with layered grid data. - """ - top = dis.dataset["top"] - bottom = dis.dataset["bottom"] - idomain = dis.dataset["idomain"] - - if has_negative_layer(planar_data["stage"]): - allocation_option = ALLOCATION_OPTION.at_first_active - - # Enforce planar data, remove all layer dimension information - planar_data = { - key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") - for key, grid in planar_data.items() - } - # Allocation of cells - riv_allocated, drn_allocated = allocate_riv_cells( - allocation_option, - idomain > 0, - top, - bottom, - planar_data["stage"], - planar_data["bottom_elevation"], - ) - drn_is_allocated = drn_allocated is not None - # Distribution of conductances - allocated_for_distribution = ( - riv_allocated | drn_allocated if drn_is_allocated else riv_allocated # type: ignore - ) - distribute_func = ( - distribute_drn_conductance - if drn_is_allocated - else distribute_riv_conductance - ) - distribute_args = ( - distributing_option, - allocated_for_distribution, - planar_data["conductance"], - top, - bottom, - npf.dataset["k"], - ) - riv_distribute_grids = (planar_data["stage"], planar_data["bottom_elevation"]) - drn_distribute_grids = (planar_data["bottom_elevation"],) - bc_distribute_grids = ( - drn_distribute_grids if drn_is_allocated else riv_distribute_grids - ) - conductance = distribute_func(*distribute_args, *bc_distribute_grids) - # Create layered data dicts - layered_data_riv = {} - # create layered arrays of stage and bottom elevation - for key in ["stage", "bottom_elevation"]: - layered_data_riv[key] = enforce_dim_order( - planar_data[key].where(riv_allocated) - ) - layered_data_riv["conductance"] = conductance.where(riv_allocated) - - layered_data_drn = {} - if drn_allocated is not None: - layered_data_drn["elevation"] = enforce_dim_order( - planar_data["stage"].where(drn_allocated) - ) - layered_data_drn["conductance"] = conductance.where(drn_allocated) - - layered_data_riv["bottom_elevation"] = rise_bottom_elevation_if_needed( - layered_data_riv["bottom_elevation"], bottom - ) - - return layered_data_riv, layered_data_drn - - @classmethod - def from_imod5_data( - cls, - key: str, - imod5_data: dict[str, GridDataDict], - period_data: dict[str, list[datetime]], - target_dis: StructuredDiscretization, - target_npf: NodePropertyFlow, - time_min: datetime, - time_max: datetime, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - regridder_types: Optional[RiverRegridMethod] = None, - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> Tuple[Optional["River"], Optional[Drainage]]: - """ - Construct a river-package from iMOD5 data, loaded with the - :func:`imod.formats.prj.open_projectfile_data` function. - - .. note:: - - The method expects the iMOD5 model to be fully 3D, not quasi-3D. - - Parameters - ---------- - key: str - Packagename of the package that needs to be converted to river - package. - imod5_data: dict - Dictionary with iMOD5 data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - period_data: dict - Dictionary with iMOD5 period data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - target_dis: StructuredDiscretization package - The grid that should be used for the new package. Does not - need to be identical to one of the input grids. - time_min: datetime - Begin-time of the simulation. Used for expanding period data. - time_max: datetime - End-time of the simulation. Used for expanding period data. - allocation_option: ALLOCATION_OPTION - allocation option. If package data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: DISTRIBUTING_OPTION - distributing option. - regridder_types: RiverRegridMethod, optional - Optional dataclass with regridder types for a specific variable. - Use this to override default regridding methods. - regrid_cache: RegridderWeightsCache, optional - stores regridder weights for different regridders. Can be used to speed up regridding, - if the same regridders are used several times for regridding different arrays. - - Returns - ------- - A tuple containing a River package and a Drainage package. The Drainage - package accounts for the infiltration factor which exists in iMOD5 but - not in MF6. It furthermore potentially contains drainage cells above - river stage if ``ALLOCATION_OPTION.stage_to_riv_bot_drn_above`` is - chosen. Both the river package and the drainage package can be None, - this can happen if the infiltration factor is 0 or 1 everywhere. - """ - # gather input data - varnames = ["conductance", "stage", "bottom_elevation", "infiltration_factor"] - data = {varname: imod5_data[key][varname] for varname in varnames} - mask = data["conductance"] > 0 - data["conductance"] = data["conductance"].where(mask) - # Regrid the input data - regridded_riv_pkg_data = regrid_imod5_pkg_data( - cls, data, target_dis, regridder_types, regrid_cache - ) - regridded_riv_pkg_data = broadcast_and_mask_arrays(regridded_riv_pkg_data) - # Pop infiltration_factor to avoid unnecessarily allocating and - # distributing it. - infiltration_factor = regridded_riv_pkg_data.pop("infiltration_factor") - # Allocate and distribute planar data if the grid is planar - is_planar_xy = is_planar_grid(regridded_riv_pkg_data["conductance"]) - allocation_drn_data: GridDataDict = {} - if is_planar_xy: - # allocate and distribute planar data - allocation_riv_data, allocation_drn_data = ( - cls._allocate_and_distribute_planar_data( - regridded_riv_pkg_data, - target_dis, - target_npf, - allocation_option, - distributing_option, - ) - ) - regridded_riv_pkg_data.update(allocation_riv_data) - infiltration_factor = infiltration_factor.isel( - {"layer": 0}, drop=True, missing_dims="ignore" - ) - regridded_riv_pkg_data["bottom_elevation"] = enforce_dim_order( - regridded_riv_pkg_data["bottom_elevation"] - ) - # Create packages - regridded_riv_pkg_data, infiltration_drn_data = _separate_infiltration_data( - regridded_riv_pkg_data, infiltration_factor - ) - riv_pkg = cls(**regridded_riv_pkg_data, validate=True) - drn_pkg = _create_drain_from_leftover_riv_imod5_data( - allocation_drn_data, - infiltration_drn_data, - ) - # Mask the river and drainage packages to drop empty data. - optional_riv_pkg = mask_package__drop_if_empty(riv_pkg) - optional_drn_pkg = mask_package__drop_if_empty(drn_pkg) - - # Account for periods with repeat stresses. - repeat = period_data.get(key) - set_repeat_stress_if_available(repeat, time_min, time_max, optional_riv_pkg) - set_repeat_stress_if_available(repeat, time_min, time_max, optional_drn_pkg) - # Clip the river package to the time range of the simulation and ensure - # time is forward filled. - optional_riv_pkg = clip_time_if_package(optional_riv_pkg, time_min, time_max) - optional_drn_pkg = clip_time_if_package(optional_drn_pkg, time_min, time_max) - - # Cast for mypy checks - optional_riv_pkg = cast(Optional[River], optional_riv_pkg) - optional_drn_pkg = cast(Optional[Drainage], optional_drn_pkg) - - return (optional_riv_pkg, optional_drn_pkg) +from datetime import datetime +from typing import Optional, Tuple, cast + +import numpy as np + +from imod import logging +from imod.common.interfaces.iregridpackage import IRegridPackage +from imod.common.utilities.dataclass_type import DataclassType +from imod.common.utilities.mask import broadcast_and_mask_arrays +from imod.logging import init_log_decorator, standard_log_decorator +from imod.mf6.aggregate.aggregate_schemes import RiverAggregationMethod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.drn import Drainage +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.regrid.regrid_schemes import RiverRegridMethod +from imod.mf6.topsystem import TopSystemBoundaryCondition +from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data +from imod.mf6.utilities.package import set_repeat_stress_if_available +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA +from imod.prepare.cleanup import AlignLevelsMode, align_interface_levels, cleanup_riv +from imod.prepare.topsystem.allocation import ( + ALLOCATION_OPTION, + allocate_riv_cells, + drop_empty_layers_from_dict, +) +from imod.prepare.topsystem.conductance import ( + DISTRIBUTING_OPTION, + distribute_drn_conductance, + distribute_riv_conductance, + split_conductance_with_infiltration_factor, +) +from imod.schemata import ( + AllCoordsValueSchema, + AllInsideNoDataSchema, + AllNoDataSchema, + AllValueSchema, + CoordsSchema, + DimsSchema, + DTypeSchema, + IdentityNoDataSchema, + IndexesSchema, + OtherCoordsSchema, +) +from imod.typing import GridDataArray, GridDataDict +from imod.typing.grid import ( + concat, + enforce_dim_order, + has_negative_layer, + is_planar_grid, +) +from imod.util.regrid import ( + RegridderWeightsCache, +) + + +def mask_package__drop_if_empty( + package: TopSystemBoundaryCondition, +) -> Optional[TopSystemBoundaryCondition]: + """ " + Create an optional package from a package if it has data. Return None if + package is inactive everywhere. + """ + # remove River package if its mask is False everywhere + mask = ~np.isnan(package["conductance"]) + return package.mask(mask) if np.any(mask) else None + + +def clip_time_if_package( + package: Optional[TopSystemBoundaryCondition], + time_min: datetime, + time_max: datetime, +) -> Optional[TopSystemBoundaryCondition]: + if package is not None: + package = package.clip_box(time_min=time_min, time_max=time_max) + return package + + +def rise_bottom_elevation_if_needed( + bottom_elevation: GridDataArray, bottom: GridDataArray +) -> GridDataArray: + """ + Due to regridding, the bottom_elevation could be less than the + layer bottom, so here we overwrite it with bottom if that's + the case. + """ + is_layer_bottom_above_bottom_elevation = (bottom > bottom_elevation).any() + + if is_layer_bottom_above_bottom_elevation: + logging.logger.warning( + "Note: riv bottom was detected below model bottom. Updated the riv's bottom." + ) + bottom_elevation, _ = align_interface_levels( + bottom_elevation, bottom, AlignLevelsMode.BOTTOMUP + ) + return bottom_elevation + + +def _separate_infiltration_data( + riv_pkg_data: GridDataDict, infiltration_factor: GridDataArray +) -> tuple[GridDataDict, GridDataDict]: + """ + Account for the infiltration factor in the river package data. This function + updates the riv_pkg_data with an infiltration conductance. The extra + exfiltration conductance is separated into a data dict for drainage + """ + # update the conductance of the river package to account for the + # infiltration factor + drain_conductance, river_conductance = split_conductance_with_infiltration_factor( + riv_pkg_data["conductance"], infiltration_factor + ) + riv_pkg_data["conductance"] = river_conductance + # create a drainage package with the conductance we computed from the + # infiltration factor + drn_pkg_data = { + "elevation": riv_pkg_data["stage"], + "conductance": drain_conductance, + } + return riv_pkg_data, drn_pkg_data + + +def _create_drain_from_leftover_riv_imod5_data( + allocation_drn_data: GridDataDict, + infiltration_drn_data: GridDataDict, +) -> Drainage: + """ + Create a drainage package from leftover imod5 river package data, + stemming from: + + * If ``ALLOCATION_OPTION.stage_to_riv_bottom_drn_above`` is chosen, + drain cells are allocated from the first active cell to river + stage. In this case ``allocation_drn_data`` is not empty. + * Infiltration factor. This factor is optional in imod5, but it + does not exist in MF6, so we mimic its effect with a Drainage + boundary. This data is stored in ``infiltration_drn_data``. + """ + + if allocation_drn_data: + drain_leftover_data: GridDataDict = {} + for key, allocation_grid in allocation_drn_data.items(): + concatenated = concat( + [allocation_grid, infiltration_drn_data[key]], dim="leftover" + ) + drain_leftover_data[key] = concatenated.mean(dim="leftover") + else: + drain_leftover_data = infiltration_drn_data + + return Drainage(**drain_leftover_data) # type: ignore + + +class River(TopSystemBoundaryCondition, IRegridPackage): + """ + River package. + Any number of RIV Packages can be specified for a single groundwater flow + model. + https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=71 + + Parameters + ---------- + stage: array of floats (xr.DataArray) + is the head in the river. + conductance: array of floats (xr.DataArray) + is the riverbed hydraulic conductance. + bottom_elevation: array of floats (xr.DataArray) + is the elevation of the bottom of the riverbed. + concentration: array of floats (xr.DataArray, optional) + if this flow package is used in simulations also involving transport, then this array is used + as the concentration for inflow over this boundary. + concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) + if this flow package is used in simulations also involving transport, then this keyword specifies + how outflow over this boundary is computed. + print_input: ({True, False}, optional) + keyword to indicate that the list of river information will be written + to the listing file immediately after it is read. Default is False. + print_flows: ({True, False}, optional) + Indicates that the list of river flow rates will be printed to the + listing file for every stress period time step in which "BUDGET PRINT" + is specified in Output Control. If there is no Output Control option and + PRINT FLOWS is specified, then flow rates are printed for the last time + step of each stress period. Default is False. + save_flows: ({True, False}, optional) + Indicates that river flow terms will be written to the file specified + with "BUDGET FILEOUT" in Output Control. Default is False. + observations: [Not yet supported.] + Default is None. + validate: {True, False} + Flag to indicate whether the package should be validated upon + initialization. This raises a ValidationError if package input is + provided in the wrong manner. Defaults to True. + repeat_stress: dict or xr.DataArray of datetimes, optional + Used to repeat data for e.g. repeating stress periods such as + seasonality without duplicating the values. If provided as dict, it + should map new dates to old dates present in the dataset. + ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided + as DataArray, it should have dimensions ``("repeat", "repeat_items")``. + The ``repeat_items`` dimension should have size 2: the first value is + the "key", the second value is the "value". For the "key" datetime, the + data of the "value" datetime will be used. + """ + + _pkg_id = "riv" + _period_data = ("stage", "conductance", "bottom_elevation") + _keyword_map = {} + + _init_schemata = { + "stage": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "conductance": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "bottom_elevation": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "print_input": [DTypeSchema(np.bool_), DimsSchema()], + "print_flows": [DTypeSchema(np.bool_), DimsSchema()], + "save_flows": [DTypeSchema(np.bool_), DimsSchema()], + } + _write_schemata = { + "stage": [ + AllValueSchema(">=", "bottom_elevation"), + OtherCoordsSchema("idomain"), + AllNoDataSchema(), # Check for all nan, can occur while clipping + AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), + ], + "conductance": [IdentityNoDataSchema("stage"), AllValueSchema(">", 0.0)], + "bottom_elevation": [ + IdentityNoDataSchema("stage"), + # Check river bottom above layer bottom, else Modflow throws error. + AllValueSchema(">=", "bottom", ignore=("icelltype", "==", 0)), + ], + "concentration": [IdentityNoDataSchema("stage"), AllValueSchema(">=", 0.0)], + } + + _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) + _auxiliary_data = {"concentration": "species"} + _regrid_method = RiverRegridMethod() + _aggregate_method: DataclassType = RiverAggregationMethod() + + @init_log_decorator() + def __init__( + self, + stage, + conductance, + bottom_elevation, + concentration=None, + concentration_boundary_type="aux", + print_input=False, + print_flows=False, + save_flows=False, + observations=None, + validate: bool = True, + repeat_stress=None, + ): + dict_dataset = { + "stage": stage, + "conductance": conductance, + "bottom_elevation": bottom_elevation, + "concentration": concentration, + "concentration_boundary_type": concentration_boundary_type, + "print_input": print_input, + "print_flows": print_flows, + "save_flows": save_flows, + "observations": observations, + "repeat_stress": repeat_stress, + } + super().__init__(dict_dataset) + self._validate_init_schemata(validate) + + def _validate(self, schemata, **kwargs): + # Insert additional kwargs + kwargs["stage"] = self["stage"] + kwargs["bottom_elevation"] = self["bottom_elevation"] + errors = super()._validate(schemata, **kwargs) + + return errors + + @standard_log_decorator() + def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: + """ + Clean up package inplace. This method calls + :func:`imod.prepare.cleanup_riv`, see documentation of that + function for details on cleanup. + + dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization + Model discretization package. + """ + dis_dict = {"idomain": dis.dataset["idomain"], "bottom": dis.dataset["bottom"]} + cleaned_dict = self._call_func_on_grids(cleanup_riv, dis_dict) + super().__init__(cleaned_dict) + + @classmethod + def _allocate_and_distribute_planar_data( + cls, + planar_data: GridDataDict, + dis: StructuredDiscretization | VerticesDiscretization, + npf: NodePropertyFlow, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + drop_empty_layers: bool = True, + ) -> tuple[GridDataDict, GridDataDict]: + """ + Allocate and distribute planar data for given discretization and npf + package. If layer number of ``planar_data`` is negative, + ``allocation_option`` is overrided and set to + ALLOCATION_OPTION.at_first_active. + + Parameters + ---------- + planar_data: GridDataDict + Dictionary with planar grid data. + dis: imod.mf6.StructuredDiscretization + Model discretization package. + npf: imod.mf6.NodePropertyFlow + Node property flow package. + allocation_option: ALLOCATION_OPTION + allocation option. If planar data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: DISTRIBUTING_OPTION + distributing option. + drop_empty_layers: bool + If True, drop layers without any allocated cells from the + returned grids. Allocation and distribution are always computed + over the full layer range first, so this does not affect the + computed values. + + Returns + ------- + GridDataDict + Dictionary with layered grid data. + """ + top = dis.dataset["top"] + bottom = dis.dataset["bottom"] + idomain = dis.dataset["idomain"] + + if has_negative_layer(planar_data["stage"]): + allocation_option = ALLOCATION_OPTION.at_first_active + + # Enforce planar data, remove all layer dimension information + planar_data = { + key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") + for key, grid in planar_data.items() + } + # Allocation of cells + riv_allocated, drn_allocated = allocate_riv_cells( + allocation_option, + idomain > 0, + top, + bottom, + planar_data["stage"], + planar_data["bottom_elevation"], + drop_empty_layers=False, # Keep full layer range, drop empty layers below + ) + drn_is_allocated = drn_allocated is not None + # Distribution of conductances + allocated_for_distribution = ( + riv_allocated | drn_allocated if drn_is_allocated else riv_allocated # type: ignore + ) + distribute_func = ( + distribute_drn_conductance + if drn_is_allocated + else distribute_riv_conductance + ) + distribute_args = ( + distributing_option, + allocated_for_distribution, + planar_data["conductance"], + top, + bottom, + npf.dataset["k"], + ) + riv_distribute_grids = (planar_data["stage"], planar_data["bottom_elevation"]) + drn_distribute_grids = (planar_data["bottom_elevation"],) + bc_distribute_grids = ( + drn_distribute_grids if drn_is_allocated else riv_distribute_grids + ) + conductance = distribute_func(*distribute_args, *bc_distribute_grids) + # Create layered data dicts + layered_data_riv = {} + # create layered arrays of stage and bottom elevation + for key in ["stage", "bottom_elevation"]: + layered_data_riv[key] = enforce_dim_order( + planar_data[key].where(riv_allocated) + ) + layered_data_riv["conductance"] = conductance.where(riv_allocated) + + layered_data_drn = {} + if drn_allocated is not None: + layered_data_drn["elevation"] = enforce_dim_order( + planar_data["stage"].where(drn_allocated) + ) + layered_data_drn["conductance"] = conductance.where(drn_allocated) + + layered_data_riv["bottom_elevation"] = rise_bottom_elevation_if_needed( + layered_data_riv["bottom_elevation"], bottom + ) + + if drop_empty_layers: + layered_data_riv = drop_empty_layers_from_dict( + layered_data_riv, riv_allocated + ) + if drn_allocated is not None: + layered_data_drn = drop_empty_layers_from_dict( + layered_data_drn, drn_allocated + ) + + return layered_data_riv, layered_data_drn + + @classmethod + def from_imod5_data( + cls, + key: str, + imod5_data: dict[str, GridDataDict], + period_data: dict[str, list[datetime]], + target_dis: StructuredDiscretization, + target_npf: NodePropertyFlow, + time_min: datetime, + time_max: datetime, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + regridder_types: Optional[RiverRegridMethod] = None, + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> Tuple[Optional["River"], Optional[Drainage]]: + """ + Construct a river-package from iMOD5 data, loaded with the + :func:`imod.formats.prj.open_projectfile_data` function. + + .. note:: + + The method expects the iMOD5 model to be fully 3D, not quasi-3D. + + Parameters + ---------- + key: str + Packagename of the package that needs to be converted to river + package. + imod5_data: dict + Dictionary with iMOD5 data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + period_data: dict + Dictionary with iMOD5 period data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + target_dis: StructuredDiscretization package + The grid that should be used for the new package. Does not + need to be identical to one of the input grids. + time_min: datetime + Begin-time of the simulation. Used for expanding period data. + time_max: datetime + End-time of the simulation. Used for expanding period data. + allocation_option: ALLOCATION_OPTION + allocation option. If package data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: DISTRIBUTING_OPTION + distributing option. + regridder_types: RiverRegridMethod, optional + Optional dataclass with regridder types for a specific variable. + Use this to override default regridding methods. + regrid_cache: RegridderWeightsCache, optional + stores regridder weights for different regridders. Can be used to speed up regridding, + if the same regridders are used several times for regridding different arrays. + + Returns + ------- + A tuple containing a River package and a Drainage package. The Drainage + package accounts for the infiltration factor which exists in iMOD5 but + not in MF6. It furthermore potentially contains drainage cells above + river stage if ``ALLOCATION_OPTION.stage_to_riv_bot_drn_above`` is + chosen. Both the river package and the drainage package can be None, + this can happen if the infiltration factor is 0 or 1 everywhere. + """ + # gather input data + varnames = ["conductance", "stage", "bottom_elevation", "infiltration_factor"] + data = {varname: imod5_data[key][varname] for varname in varnames} + mask = data["conductance"] > 0 + data["conductance"] = data["conductance"].where(mask) + # Regrid the input data + regridded_riv_pkg_data = regrid_imod5_pkg_data( + cls, data, target_dis, regridder_types, regrid_cache + ) + regridded_riv_pkg_data = broadcast_and_mask_arrays(regridded_riv_pkg_data) + # Pop infiltration_factor to avoid unnecessarily allocating and + # distributing it. + infiltration_factor = regridded_riv_pkg_data.pop("infiltration_factor") + # Allocate and distribute planar data if the grid is planar + is_planar_xy = is_planar_grid(regridded_riv_pkg_data["conductance"]) + allocation_drn_data: GridDataDict = {} + if is_planar_xy: + # allocate and distribute planar data + allocation_riv_data, allocation_drn_data = ( + cls._allocate_and_distribute_planar_data( + regridded_riv_pkg_data, + target_dis, + target_npf, + allocation_option, + distributing_option, + ) + ) + regridded_riv_pkg_data.update(allocation_riv_data) + infiltration_factor = infiltration_factor.isel( + {"layer": 0}, drop=True, missing_dims="ignore" + ) + regridded_riv_pkg_data["bottom_elevation"] = enforce_dim_order( + regridded_riv_pkg_data["bottom_elevation"] + ) + # Create packages + regridded_riv_pkg_data, infiltration_drn_data = _separate_infiltration_data( + regridded_riv_pkg_data, infiltration_factor + ) + riv_pkg = cls(**regridded_riv_pkg_data, validate=True) + drn_pkg = _create_drain_from_leftover_riv_imod5_data( + allocation_drn_data, + infiltration_drn_data, + ) + # Mask the river and drainage packages to drop empty data. + optional_riv_pkg = mask_package__drop_if_empty(riv_pkg) + optional_drn_pkg = mask_package__drop_if_empty(drn_pkg) + + # Account for periods with repeat stresses. + repeat = period_data.get(key) + set_repeat_stress_if_available(repeat, time_min, time_max, optional_riv_pkg) + set_repeat_stress_if_available(repeat, time_min, time_max, optional_drn_pkg) + # Clip the river package to the time range of the simulation and ensure + # time is forward filled. + optional_riv_pkg = clip_time_if_package(optional_riv_pkg, time_min, time_max) + optional_drn_pkg = clip_time_if_package(optional_drn_pkg, time_min, time_max) + + # Cast for mypy checks + optional_riv_pkg = cast(Optional[River], optional_riv_pkg) + optional_drn_pkg = cast(Optional[Drainage], optional_drn_pkg) + + return (optional_riv_pkg, optional_drn_pkg) diff --git a/imod/mf6/topsystem.py b/imod/mf6/topsystem.py index 14d67e0f3..b7c1d1b6a 100644 --- a/imod/mf6/topsystem.py +++ b/imod/mf6/topsystem.py @@ -1,175 +1,192 @@ -import abc -from copy import deepcopy -from dataclasses import asdict -from typing import Optional, Self, cast - -from imod.common.utilities.dataclass_type import DataclassType -from imod.mf6.aggregate.aggregate_schemes import EmptyAggregationMethod -from imod.mf6.boundary_condition import BoundaryCondition -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.prepare.topsystem import ( - ALLOCATION_OPTION, - DISTRIBUTING_OPTION, - SimulationAllocationOptions, - SimulationDistributingOptions, -) -from imod.typing import GridDataDict, GridDataset - - -def _handle_reallocate_arguments( - pkg_id: str, - has_conductance: bool, - npf: Optional[NodePropertyFlow], - allocation_option: Optional[ALLOCATION_OPTION], - distributing_option: Optional[DISTRIBUTING_OPTION], -) -> tuple[ALLOCATION_OPTION, Optional[DISTRIBUTING_OPTION]]: - if allocation_option is None: - allocation_option = asdict(SimulationAllocationOptions())[pkg_id] - elif allocation_option == ALLOCATION_OPTION.stage_to_riv_bot_drn_above: - raise ValueError( - f"Allocation option {allocation_option} is not supported for " - "reallocation of boundary conditions." - ) - if has_conductance and distributing_option is None: - distributing_option = asdict(SimulationDistributingOptions())[pkg_id] - if has_conductance and npf is None: - raise ValueError( - "NodePropertyFlow must be provided for packages with conductance variable." - ) - return allocation_option, distributing_option - - -class TopSystemBoundaryCondition(BoundaryCondition, abc.ABC): - """ - Base class to add some extra functionality for topsystem packages, such as - RCH, DRN, RIV, and GHB. - """ - - _aggregate_method: DataclassType = EmptyAggregationMethod() - - def reallocate( - self, - dis: StructuredDiscretization | VerticesDiscretization, - npf: Optional[NodePropertyFlow] = None, - allocation_option: Optional[ALLOCATION_OPTION] = None, - distributing_option: Optional[DISTRIBUTING_OPTION] = None, - ) -> Self: - """ - Reallocates topsystem data across layers and create new package with it. - Aggregate data to planar data first, by taking either the mean for state - variables (e.g. river stage), or the sum for fluxes and the - conductance. Consequently allocate and distribute the planar data to the - provided model layer schematization. - - Parameters - ---------- - dis : StructuredDiscretization | VerticesDiscretization - The discretization of the model to which the data should be - reallocated. - npf : NodePropertyFlow, optional - The node property flow package of the model to which the conductance - should be distributed (if applicable). Required for packages with a - conductance variable. - allocation_option : ALLOCATION_OPTION, optional - The allocation option to use for the reallocation. If None, the - default allocation option is taken from - :class:`imod.prepare.SimulationAllocationOptions`. - distributing_option : DISTRIBUTING_OPTION, optional - The distributing option to use for the reallocation. Required for - packages with a conductance variable. If None, the default is taken - from :class:`imod.prepare.SimulationDistributingOptions`. - - Returns - ------- - BoundaryCondition - A new instance of the boundary condition class with the reallocated - data. The original instance remains unchanged. - """ - # Handle input arguments - has_conductance = "conductance" in self.dataset.data_vars - allocation_option, distributing_option = _handle_reallocate_arguments( - self._pkg_id, has_conductance, npf, allocation_option, distributing_option - ) - # Aggregate data to planar data first - planar_data = self.aggregate_layers(self.dataset) - # Then allocate and distribute the planar data to the model layers - if has_conductance: - npf = cast(NodePropertyFlow, npf) - distributing_option = cast(DISTRIBUTING_OPTION, distributing_option) - grid_dict = self._allocate_and_distribute_planar_data( - planar_data, dis, npf, allocation_option, distributing_option - ) - else: - grid_dict = self._allocate_planar_data(planar_data, dis, allocation_option) - # River package returns a tuple (second argument can also be Drainage - # package) - if isinstance(grid_dict, tuple): - grid_dict, _ = grid_dict - options = self._get_unfiltered_pkg_options({}) - data_dict = grid_dict | options - return self.__class__(**data_dict) - - @classmethod - def _allocate_and_distribute_planar_data( - cls, - planar_data: GridDataDict, - dis: StructuredDiscretization | VerticesDiscretization, - npf: NodePropertyFlow, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - ) -> tuple[GridDataDict, GridDataDict] | GridDataDict: - raise NotImplementedError( - "This method should be implemented in the specific boundary condition " - "class that inherits from BoundaryCondition." - ) - - @classmethod - def _allocate_planar_data( - cls, - planar_data: GridDataDict, - dis: StructuredDiscretization | VerticesDiscretization, - allocation_option: ALLOCATION_OPTION, - ) -> tuple[GridDataDict, GridDataDict] | GridDataDict: - raise NotImplementedError( - "This method should be implemented in the specific boundary condition " - "class that inherits from BoundaryCondition." - ) - - @classmethod - def _get_aggregate_methods(cls) -> DataclassType: - """ - Returns the aggregation methods used for aggregating data over layers - into planar data. - - Returns - ------- - DataclassType - The aggregation methods used for the package. - """ - return deepcopy(cls._aggregate_method) - - @classmethod - def aggregate_layers(cls, dataset: GridDataset) -> GridDataDict: - """ - Aggregate data over layers into planar dataset. - - Returns - ------- - dict - Dict of aggregated data arrays, where the keys are the variable - names and the values are aggregated across the "layer" dimension. - """ - aggr_methods = cls._get_aggregate_methods() - if isinstance(aggr_methods, EmptyAggregationMethod): - raise TypeError( - f"Aggregation methods for {cls._pkg_id} package are not defined." - ) - aggr_methods_dict = asdict(aggr_methods) - planar_data = { - key: dataset[key].reduce(func, dim="layer") - for key, func in aggr_methods_dict.items() - if key in dataset.data_vars - } - return planar_data +import abc +from copy import deepcopy +from dataclasses import asdict +from typing import Optional, Self, cast + +from imod.common.utilities.dataclass_type import DataclassType +from imod.mf6.aggregate.aggregate_schemes import EmptyAggregationMethod +from imod.mf6.boundary_condition import BoundaryCondition +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.prepare.topsystem import ( + ALLOCATION_OPTION, + DISTRIBUTING_OPTION, + SimulationAllocationOptions, + SimulationDistributingOptions, +) +from imod.typing import GridDataDict, GridDataset + + +def _handle_reallocate_arguments( + pkg_id: str, + has_conductance: bool, + npf: Optional[NodePropertyFlow], + allocation_option: Optional[ALLOCATION_OPTION], + distributing_option: Optional[DISTRIBUTING_OPTION], +) -> tuple[ALLOCATION_OPTION, Optional[DISTRIBUTING_OPTION]]: + if allocation_option is None: + allocation_option = asdict(SimulationAllocationOptions())[pkg_id] + elif allocation_option == ALLOCATION_OPTION.stage_to_riv_bot_drn_above: + raise ValueError( + f"Allocation option {allocation_option} is not supported for " + "reallocation of boundary conditions." + ) + if has_conductance and distributing_option is None: + distributing_option = asdict(SimulationDistributingOptions())[pkg_id] + if has_conductance and npf is None: + raise ValueError( + "NodePropertyFlow must be provided for packages with conductance variable." + ) + return allocation_option, distributing_option + + +class TopSystemBoundaryCondition(BoundaryCondition, abc.ABC): + """ + Base class to add some extra functionality for topsystem packages, such as + RCH, DRN, RIV, and GHB. + """ + + _aggregate_method: DataclassType = EmptyAggregationMethod() + + def reallocate( + self, + dis: StructuredDiscretization | VerticesDiscretization, + npf: Optional[NodePropertyFlow] = None, + allocation_option: Optional[ALLOCATION_OPTION] = None, + distributing_option: Optional[DISTRIBUTING_OPTION] = None, + drop_empty_layers: bool = True, + ) -> Self: + """ + Reallocates topsystem data across layers and create new package with it. + Aggregate data to planar data first, by taking either the mean for state + variables (e.g. river stage), or the sum for fluxes and the + conductance. Consequently allocate and distribute the planar data to the + provided model layer schematization. + + Parameters + ---------- + dis : StructuredDiscretization | VerticesDiscretization + The discretization of the model to which the data should be + reallocated. + npf : NodePropertyFlow, optional + The node property flow package of the model to which the conductance + should be distributed (if applicable). Required for packages with a + conductance variable. + allocation_option : ALLOCATION_OPTION, optional + The allocation option to use for the reallocation. If None, the + default allocation option is taken from + :class:`imod.prepare.SimulationAllocationOptions`. + distributing_option : DISTRIBUTING_OPTION, optional + The distributing option to use for the reallocation. Required for + packages with a conductance variable. If None, the default is taken + from :class:`imod.prepare.SimulationDistributingOptions`. + drop_empty_layers : bool, default True + If True, drop layers from the resulting package that contain no + allocated cells anywhere in the domain. Allocation and + distribution are always computed over the full layer range + first; layers are only trimmed off the final result, so this + does not affect the computed values, only the package's layer + coordinate. + + Returns + ------- + BoundaryCondition + A new instance of the boundary condition class with the reallocated + data. The original instance remains unchanged. + """ + # Handle input arguments + has_conductance = "conductance" in self.dataset.data_vars + allocation_option, distributing_option = _handle_reallocate_arguments( + self._pkg_id, has_conductance, npf, allocation_option, distributing_option + ) + # Aggregate data to planar data first + planar_data = self.aggregate_layers(self.dataset) + # Then allocate and distribute the planar data to the model layers + if has_conductance: + npf = cast(NodePropertyFlow, npf) + distributing_option = cast(DISTRIBUTING_OPTION, distributing_option) + grid_dict = self._allocate_and_distribute_planar_data( + planar_data, + dis, + npf, + allocation_option, + distributing_option, + drop_empty_layers, + ) + else: + grid_dict = self._allocate_planar_data( + planar_data, dis, allocation_option, drop_empty_layers + ) + # River package returns a tuple (second argument can also be Drainage + # package) + if isinstance(grid_dict, tuple): + grid_dict, _ = grid_dict + options = self._get_unfiltered_pkg_options({}) + data_dict = grid_dict | options + return self.__class__(**data_dict) + + @classmethod + def _allocate_and_distribute_planar_data( + cls, + planar_data: GridDataDict, + dis: StructuredDiscretization | VerticesDiscretization, + npf: NodePropertyFlow, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + drop_empty_layers: bool = True, + ) -> tuple[GridDataDict, GridDataDict] | GridDataDict: + raise NotImplementedError( + "This method should be implemented in the specific boundary condition " + "class that inherits from BoundaryCondition." + ) + + @classmethod + def _allocate_planar_data( + cls, + planar_data: GridDataDict, + dis: StructuredDiscretization | VerticesDiscretization, + allocation_option: ALLOCATION_OPTION, + drop_empty_layers: bool = True, + ) -> tuple[GridDataDict, GridDataDict] | GridDataDict: + raise NotImplementedError( + "This method should be implemented in the specific boundary condition " + "class that inherits from BoundaryCondition." + ) + + @classmethod + def _get_aggregate_methods(cls) -> DataclassType: + """ + Returns the aggregation methods used for aggregating data over layers + into planar data. + + Returns + ------- + DataclassType + The aggregation methods used for the package. + """ + return deepcopy(cls._aggregate_method) + + @classmethod + def aggregate_layers(cls, dataset: GridDataset) -> GridDataDict: + """ + Aggregate data over layers into planar dataset. + + Returns + ------- + dict + Dict of aggregated data arrays, where the keys are the variable + names and the values are aggregated across the "layer" dimension. + """ + aggr_methods = cls._get_aggregate_methods() + if isinstance(aggr_methods, EmptyAggregationMethod): + raise TypeError( + f"Aggregation methods for {cls._pkg_id} package are not defined." + ) + aggr_methods_dict = asdict(aggr_methods) + planar_data = { + key: dataset[key].reduce(func, dim="layer") + for key, func in aggr_methods_dict.items() + if key in dataset.data_vars + } + return planar_data diff --git a/imod/prepare/cleanup.py b/imod/prepare/cleanup.py index 4200eba59..fcb78984a 100644 --- a/imod/prepare/cleanup.py +++ b/imod/prepare/cleanup.py @@ -1,399 +1,409 @@ -"""Cleanup utilities""" - -from enum import Enum -from typing import Optional - -import pandas as pd -import xarray as xr - -from imod.common.utilities.clip import clip_line_gdf_by_grid -from imod.common.utilities.mask import mask_arrays -from imod.prepare.wells import locate_wells, validate_well_columnnames -from imod.schemata import scalar_None -from imod.typing import GeoDataFrameType, GridDataArray - - -class AlignLevelsMode(Enum): - TOPDOWN = 0 - BOTTOMUP = 1 - - -def align_nodata(grids: dict[str, xr.DataArray]) -> dict[str, xr.DataArray]: - return mask_arrays(grids) - - -def align_interface_levels( - top: GridDataArray, - bottom: GridDataArray, - method: AlignLevelsMode = AlignLevelsMode.TOPDOWN, -) -> tuple[GridDataArray, GridDataArray]: - to_align = top < bottom - - match method: - case AlignLevelsMode.BOTTOMUP: - return top.where(~to_align, bottom), bottom - case AlignLevelsMode.TOPDOWN: - return top, bottom.where(~to_align, top) - case _: - raise TypeError(f"Unmatched case for method, got {method}") - - -def _cleanup_robin_boundary( - idomain: GridDataArray, grids: dict[str, GridDataArray] -) -> dict[str, GridDataArray]: - """Cleanup robin boundary condition (i.e. bc with conductance)""" - active = idomain > 0 - # Deactivate conductance cells outside active domain; this nodata - # inconsistency will be aligned in the final call to align_nodata - conductance = grids["conductance"].where(active) - concentration = grids["concentration"] - # Make conductance cells with erronous values inactive - grids["conductance"] = conductance.where(conductance > 0.0) - # Clip negative concentration cells to 0.0 - if (concentration is not None) and not scalar_None(concentration): - grids["concentration"] = concentration.clip(min=0.0) - else: - grids.pop("concentration") - - # Align nodata - return align_nodata(grids) - - -def cleanup_riv( - idomain: GridDataArray, - bottom: GridDataArray, - stage: GridDataArray, - conductance: GridDataArray, - bottom_elevation: GridDataArray, - concentration: Optional[GridDataArray] = None, -) -> dict[str, GridDataArray]: - """ - Clean up river data, fixes some common mistakes causing ValidationErrors by - doing the following: - - - Cells where conductance <= 0 are deactivated. - - Cells where concentration < 0 are set to 0.0. - - Cells outside active domain (idomain==1) are removed. - - Align NoData: If one variable has an inactive cell in one cell, ensure - this cell is deactivated for all variables. - - River bottom elevations below model bottom of a layer are set to model - bottom of that layer. - - River bottom elevations which exceed river stage are lowered to river - stage. - - Parameters - ---------- - idomain: xarray.DataArray | xugrid.UgridDataArray - MODFLOW 6 model domain. idomain==1 is considered active domain. - bottom: xarray.DataArray | xugrid.UgridDataArray - Grid with model bottoms - stage: xarray.DataArray | xugrid.UgridDataArray - Grid with river stages - conductance: xarray.DataArray | xugrid.UgridDataArray - Grid with conductances - bottom_elevation: xarray.DataArray | xugrid.UgridDataArray - Grid with river bottom elevations - concentration: xarray.DataArray | xugrid.UgridDataArray, optional - Optional grid with concentrations - - Returns - ------- - dict[str, xarray.DataArray | xugrid.UgridDataArray] - Dict of cleaned up grids. Has keys: "stage", "conductance", - "bottom_elevation", "concentration". - """ - # Output dict - output_dict = { - "stage": stage, - "conductance": conductance, - "bottom_elevation": bottom_elevation, - "concentration": concentration, - } - output_dict = _cleanup_robin_boundary(idomain, output_dict) - if (output_dict["stage"] < bottom).any(): - raise ValueError( - "River stage below bottom of model layer, cannot fix this. " - "Probably rivers are assigned to the wrong layer, you can reallocate " - "river data to model layers with: " - "``imod.prepare.topsystem.allocate_riv_cells``." - ) - # Ensure bottom elevation above model bottom - output_dict["bottom_elevation"], _ = align_interface_levels( - output_dict["bottom_elevation"], bottom, AlignLevelsMode.BOTTOMUP - ) - # Ensure stage above bottom_elevation - output_dict["stage"], output_dict["bottom_elevation"] = align_interface_levels( - output_dict["stage"], output_dict["bottom_elevation"], AlignLevelsMode.TOPDOWN - ) - return output_dict - - -def cleanup_drn( - idomain: GridDataArray, - elevation: GridDataArray, - conductance: GridDataArray, - concentration: Optional[GridDataArray] = None, -) -> dict[str, GridDataArray]: - """ - Clean up drain data, fixes some common mistakes causing ValidationErrors by - doing the following: - - - Cells where conductance <= 0 are deactivated. - - Cells where concentration < 0 are set to 0.0. - - Cells outside active domain (idomain==1) are removed. - - Align NoData: If one variable has an inactive cell in one cell, ensure - this cell is deactivated for all variables. - - Parameters - ---------- - idomain: xarray.DataArray | xugrid.UgridDataArray - MODFLOW 6 model domain. idomain==1 is considered active domain. - elevation: xarray.DataArray | xugrid.UgridDataArray - Grid with drain elevations - conductance: xarray.DataArray | xugrid.UgridDataArray - Grid with conductances - concentration: xarray.DataArray | xugrid.UgridDataArray, optional - Optional grid with concentrations - - Returns - ------- - dict[str, xarray.DataArray | xugrid.UgridDataArray] - Dict of cleaned up grids. Has keys: "elevation", "conductance", - "concentration". - """ - # Output dict - output_dict = { - "elevation": elevation, - "conductance": conductance, - "concentration": concentration, - } - return _cleanup_robin_boundary(idomain, output_dict) - - -def cleanup_ghb( - idomain: GridDataArray, - head: GridDataArray, - conductance: GridDataArray, - concentration: Optional[GridDataArray] = None, -) -> dict[str, GridDataArray]: - """ - Clean up general head boundary data, fixes some common mistakes causing - ValidationErrors by doing the following: - - - Cells where conductance <= 0 are deactivated. - - Cells where concentration < 0 are set to 0.0. - - Cells outside active domain (idomain==1) are removed. - - Align NoData: If one variable has an inactive cell in one cell, ensure - this cell is deactivated for all variables. - - Parameters - ---------- - idomain: xarray.DataArray | xugrid.UgridDataArray - MODFLOW 6 model domain. idomain==1 is considered active domain. - head: xarray.DataArray | xugrid.UgridDataArray - Grid with heads - conductance: xarray.DataArray | xugrid.UgridDataArray - Grid with conductances - concentration: xarray.DataArray | xugrid.UgridDataArray, optional - Optional grid with concentrations - - Returns - ------- - dict[str, xarray.DataArray | xugrid.UgridDataArray] - Dict of cleaned up grids. Has keys: "head", "conductance", - "concentration". - """ - # Output dict - output_dict = { - "head": head, - "conductance": conductance, - "concentration": concentration, - } - return _cleanup_robin_boundary(idomain, output_dict) - - -def _locate_wells_in_bounds( - wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray -) -> tuple[pd.DataFrame, pd.Series, pd.Series]: - """ - Locate wells in model bounds, wells outside bounds are dropped. Returned - dataframes and series have well "id" as index. - - Returns - ------- - wells_in_bounds: pd.DataFrame - wells in model boundaries. Has "id" as index. - xy_top_series: pd.Series - model top at well xy location. Has "id" as index. - xy_base_series: pd.Series - model base at well xy location. Has "id" as index. - """ - id_in_bounds, xy_top, xy_bottom, _ = locate_wells( - wells, top, bottom, validate=False - ) - xy_base_model = xy_bottom.isel(layer=-1, drop=True) - - # Assign id as coordinates - xy_top = xy_top.assign_coords(id=("index", id_in_bounds)) - xy_base_model = xy_base_model.assign_coords(id=("index", id_in_bounds)) - # Create pandas dataframes/series with "id" as index. - xy_top_series = xy_top.to_dataframe(name="top").set_index("id")["top"] - xy_base_series = xy_base_model.to_dataframe(name="bottom").set_index("id")["bottom"] - wells_in_bounds = wells.set_index("id").loc[id_in_bounds] - return wells_in_bounds, xy_top_series, xy_base_series - - -def _clip_filter_screen_to_surface_level( - cleaned_wells: pd.DataFrame, xy_top_series: pd.Series -) -> pd.DataFrame: - cleaned_wells["screen_top"] = cleaned_wells["screen_top"].clip(upper=xy_top_series) - return cleaned_wells - - -def _drop_wells_below_model_base( - cleaned_wells: pd.DataFrame, xy_base_series: pd.Series -) -> pd.DataFrame: - is_below_base = cleaned_wells["screen_top"] >= xy_base_series - return cleaned_wells.loc[is_below_base] - - -def _clip_filter_bottom_to_model_base( - cleaned_wells: pd.DataFrame, xy_base_series: pd.Series -) -> pd.DataFrame: - cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip( - lower=xy_base_series - ) - return cleaned_wells - - -def _set_inverted_filters_to_point_filters(cleaned_wells: pd.DataFrame) -> pd.DataFrame: - # Convert all filters where screen bottom exceeds screen top to - # point filters - cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip( - upper=cleaned_wells["screen_top"] - ) - return cleaned_wells - - -def _set_ultrathin_filters_to_point_filters( - cleaned_wells: pd.DataFrame, minimum_thickness: float -) -> pd.DataFrame: - not_ultrathin_layer = ( - cleaned_wells["screen_top"] - cleaned_wells["screen_bottom"] - ) > minimum_thickness - cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].where( - not_ultrathin_layer, cleaned_wells["screen_top"] - ) - return cleaned_wells - - -def cleanup_wel_layered( - wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray -) -> pd.DataFrame: - """ - Clean up dataframe with wells, fixes some common mistakes in the following - order: - - 1. Wells outside grid bounds are dropped - - Parameters - ---------- - wells: pandas.Dataframe - Dataframe with wells to be cleaned up. Requires columns ``"x", "y", - "id"`` - top: xarray.DataArray | xugrid.UgridDataArray - Grid with model top - bottom: xarray.DataArray | xugrid.UgridDataArray - Grid with model bottoms - - Returns - ------- - pandas.DataFrame - Cleaned well dataframe. - """ - validate_well_columnnames(wells, names={"x", "y", "id"}) - - cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds( - wells, top, bottom - ) - return cleaned_wells - - -def cleanup_wel( - wells: pd.DataFrame, - top: GridDataArray, - bottom: GridDataArray, - minimum_thickness: float = 0.05, -) -> pd.DataFrame: - """ - Clean up dataframe with wells, fixes some common mistakes in the following - order: - - 1. Wells outside grid bounds are dropped - 2. Filters above surface level are set to surface level - 3. Drop wells with filters entirely below base - 4. Clip filter screen_bottom to model base - 5. Clip filter screen_bottom to screen_top - 6. Well filters thinner than minimum thickness are made point filters - - Parameters - ---------- - wells: pandas.Dataframe - Dataframe with wells to be cleaned up. Requires columns ``"x", "y", - "id", "screen_top", "screen_bottom"`` - top: xarray.DataArray | xugrid.UgridDataArray - Grid with model top - bottom: xarray.DataArray | xugrid.UgridDataArray - Grid with model bottoms - minimum_thickness: float - Minimum thickness, filter thinner than this thickness are set to point - filters - - Returns - ------- - pandas.DataFrame - Cleaned well dataframe. - """ - validate_well_columnnames( - wells, names={"x", "y", "id", "screen_top", "screen_bottom"} - ) - - cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds( - wells, top, bottom - ) - cleaned_wells = _clip_filter_screen_to_surface_level(cleaned_wells, xy_top_series) - cleaned_wells = _drop_wells_below_model_base(cleaned_wells, xy_base_series) - cleaned_wells = _clip_filter_bottom_to_model_base(cleaned_wells, xy_base_series) - cleaned_wells = _set_inverted_filters_to_point_filters(cleaned_wells) - cleaned_wells = _set_ultrathin_filters_to_point_filters( - cleaned_wells, minimum_thickness - ) - return cleaned_wells - - -def cleanup_hfb( - barrier: GeoDataFrameType, idomain_2d: GridDataArray -) -> GeoDataFrameType: - """ - Clean up HFB data, fixes some common mistakes causing ValidationErrors by - doing the following: - - - Drop HFB segments outside active domain (idomain==1) - - Parameters - ---------- - barrier: geopandas.GeoDataFrame - GeoDataFrame with HFB data - idomain_2d: xarray.DataArray | xugrid.UgridDataArray - MODFLOW 6 model domain of a single layer. idomain==1 is considered active domain. - - Returns - ------- - geopandas.GeoDataFrame - Cleaned up GeoDataFrame with HFB data. - """ - - active = idomain_2d > 0 - # Drop HFB cells outside active domain - clipped_barrier = clip_line_gdf_by_grid(barrier, active) - return clipped_barrier +"""Cleanup utilities""" + +from enum import Enum +from typing import Optional + +import pandas as pd +import xarray as xr + +from imod.common.utilities.clip import clip_line_gdf_by_grid +from imod.common.utilities.mask import mask_arrays +from imod.prepare.wells import locate_wells, validate_well_columnnames +from imod.schemata import scalar_None +from imod.typing import GeoDataFrameType, GridDataArray + + +class AlignLevelsMode(Enum): + TOPDOWN = 0 + BOTTOMUP = 1 + + +def align_nodata(grids: dict[str, xr.DataArray]) -> dict[str, xr.DataArray]: + return mask_arrays(grids) + + +def align_interface_levels( + top: GridDataArray, + bottom: GridDataArray, + method: AlignLevelsMode = AlignLevelsMode.TOPDOWN, +) -> tuple[GridDataArray, GridDataArray]: + # `bottom` (e.g. a model's full layer range) may have more layers than + # `top` (e.g. a package trimmed to only its allocated layers, see + # ``drop_empty_layers`` in ``imod.prepare.topsystem``). Reindex `bottom` + # down to `top`'s own layers first, so the comparison below doesn't fail + # with an alignment error; `top`'s layers are always the ones we want to + # keep, matching the ``join="left"`` pattern used in + # ``imod.common.utilities.mask.mask_da``. + if "layer" in top.dims and "layer" in bottom.dims: + bottom = bottom.sel(layer=top["layer"]) + + to_align = top < bottom + + match method: + case AlignLevelsMode.BOTTOMUP: + return top.where(~to_align, bottom), bottom + case AlignLevelsMode.TOPDOWN: + return top, bottom.where(~to_align, top) + case _: + raise TypeError(f"Unmatched case for method, got {method}") + + +def _cleanup_robin_boundary( + idomain: GridDataArray, grids: dict[str, GridDataArray] +) -> dict[str, GridDataArray]: + """Cleanup robin boundary condition (i.e. bc with conductance)""" + active = idomain > 0 + # Deactivate conductance cells outside active domain; this nodata + # inconsistency will be aligned in the final call to align_nodata + conductance = grids["conductance"].where(active) + concentration = grids["concentration"] + # Make conductance cells with erronous values inactive + grids["conductance"] = conductance.where(conductance > 0.0) + # Clip negative concentration cells to 0.0 + if (concentration is not None) and not scalar_None(concentration): + grids["concentration"] = concentration.clip(min=0.0) + else: + grids.pop("concentration") + + # Align nodata + return align_nodata(grids) + + +def cleanup_riv( + idomain: GridDataArray, + bottom: GridDataArray, + stage: GridDataArray, + conductance: GridDataArray, + bottom_elevation: GridDataArray, + concentration: Optional[GridDataArray] = None, +) -> dict[str, GridDataArray]: + """ + Clean up river data, fixes some common mistakes causing ValidationErrors by + doing the following: + + - Cells where conductance <= 0 are deactivated. + - Cells where concentration < 0 are set to 0.0. + - Cells outside active domain (idomain==1) are removed. + - Align NoData: If one variable has an inactive cell in one cell, ensure + this cell is deactivated for all variables. + - River bottom elevations below model bottom of a layer are set to model + bottom of that layer. + - River bottom elevations which exceed river stage are lowered to river + stage. + + Parameters + ---------- + idomain: xarray.DataArray | xugrid.UgridDataArray + MODFLOW 6 model domain. idomain==1 is considered active domain. + bottom: xarray.DataArray | xugrid.UgridDataArray + Grid with model bottoms + stage: xarray.DataArray | xugrid.UgridDataArray + Grid with river stages + conductance: xarray.DataArray | xugrid.UgridDataArray + Grid with conductances + bottom_elevation: xarray.DataArray | xugrid.UgridDataArray + Grid with river bottom elevations + concentration: xarray.DataArray | xugrid.UgridDataArray, optional + Optional grid with concentrations + + Returns + ------- + dict[str, xarray.DataArray | xugrid.UgridDataArray] + Dict of cleaned up grids. Has keys: "stage", "conductance", + "bottom_elevation", "concentration". + """ + # Output dict + output_dict = { + "stage": stage, + "conductance": conductance, + "bottom_elevation": bottom_elevation, + "concentration": concentration, + } + output_dict = _cleanup_robin_boundary(idomain, output_dict) + if (output_dict["stage"] < bottom).any(): + raise ValueError( + "River stage below bottom of model layer, cannot fix this. " + "Probably rivers are assigned to the wrong layer, you can reallocate " + "river data to model layers with: " + "``imod.prepare.topsystem.allocate_riv_cells``." + ) + # Ensure bottom elevation above model bottom + output_dict["bottom_elevation"], _ = align_interface_levels( + output_dict["bottom_elevation"], bottom, AlignLevelsMode.BOTTOMUP + ) + # Ensure stage above bottom_elevation + output_dict["stage"], output_dict["bottom_elevation"] = align_interface_levels( + output_dict["stage"], output_dict["bottom_elevation"], AlignLevelsMode.TOPDOWN + ) + return output_dict + + +def cleanup_drn( + idomain: GridDataArray, + elevation: GridDataArray, + conductance: GridDataArray, + concentration: Optional[GridDataArray] = None, +) -> dict[str, GridDataArray]: + """ + Clean up drain data, fixes some common mistakes causing ValidationErrors by + doing the following: + + - Cells where conductance <= 0 are deactivated. + - Cells where concentration < 0 are set to 0.0. + - Cells outside active domain (idomain==1) are removed. + - Align NoData: If one variable has an inactive cell in one cell, ensure + this cell is deactivated for all variables. + + Parameters + ---------- + idomain: xarray.DataArray | xugrid.UgridDataArray + MODFLOW 6 model domain. idomain==1 is considered active domain. + elevation: xarray.DataArray | xugrid.UgridDataArray + Grid with drain elevations + conductance: xarray.DataArray | xugrid.UgridDataArray + Grid with conductances + concentration: xarray.DataArray | xugrid.UgridDataArray, optional + Optional grid with concentrations + + Returns + ------- + dict[str, xarray.DataArray | xugrid.UgridDataArray] + Dict of cleaned up grids. Has keys: "elevation", "conductance", + "concentration". + """ + # Output dict + output_dict = { + "elevation": elevation, + "conductance": conductance, + "concentration": concentration, + } + return _cleanup_robin_boundary(idomain, output_dict) + + +def cleanup_ghb( + idomain: GridDataArray, + head: GridDataArray, + conductance: GridDataArray, + concentration: Optional[GridDataArray] = None, +) -> dict[str, GridDataArray]: + """ + Clean up general head boundary data, fixes some common mistakes causing + ValidationErrors by doing the following: + + - Cells where conductance <= 0 are deactivated. + - Cells where concentration < 0 are set to 0.0. + - Cells outside active domain (idomain==1) are removed. + - Align NoData: If one variable has an inactive cell in one cell, ensure + this cell is deactivated for all variables. + + Parameters + ---------- + idomain: xarray.DataArray | xugrid.UgridDataArray + MODFLOW 6 model domain. idomain==1 is considered active domain. + head: xarray.DataArray | xugrid.UgridDataArray + Grid with heads + conductance: xarray.DataArray | xugrid.UgridDataArray + Grid with conductances + concentration: xarray.DataArray | xugrid.UgridDataArray, optional + Optional grid with concentrations + + Returns + ------- + dict[str, xarray.DataArray | xugrid.UgridDataArray] + Dict of cleaned up grids. Has keys: "head", "conductance", + "concentration". + """ + # Output dict + output_dict = { + "head": head, + "conductance": conductance, + "concentration": concentration, + } + return _cleanup_robin_boundary(idomain, output_dict) + + +def _locate_wells_in_bounds( + wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray +) -> tuple[pd.DataFrame, pd.Series, pd.Series]: + """ + Locate wells in model bounds, wells outside bounds are dropped. Returned + dataframes and series have well "id" as index. + + Returns + ------- + wells_in_bounds: pd.DataFrame + wells in model boundaries. Has "id" as index. + xy_top_series: pd.Series + model top at well xy location. Has "id" as index. + xy_base_series: pd.Series + model base at well xy location. Has "id" as index. + """ + id_in_bounds, xy_top, xy_bottom, _ = locate_wells( + wells, top, bottom, validate=False + ) + xy_base_model = xy_bottom.isel(layer=-1, drop=True) + + # Assign id as coordinates + xy_top = xy_top.assign_coords(id=("index", id_in_bounds)) + xy_base_model = xy_base_model.assign_coords(id=("index", id_in_bounds)) + # Create pandas dataframes/series with "id" as index. + xy_top_series = xy_top.to_dataframe(name="top").set_index("id")["top"] + xy_base_series = xy_base_model.to_dataframe(name="bottom").set_index("id")["bottom"] + wells_in_bounds = wells.set_index("id").loc[id_in_bounds] + return wells_in_bounds, xy_top_series, xy_base_series + + +def _clip_filter_screen_to_surface_level( + cleaned_wells: pd.DataFrame, xy_top_series: pd.Series +) -> pd.DataFrame: + cleaned_wells["screen_top"] = cleaned_wells["screen_top"].clip(upper=xy_top_series) + return cleaned_wells + + +def _drop_wells_below_model_base( + cleaned_wells: pd.DataFrame, xy_base_series: pd.Series +) -> pd.DataFrame: + is_below_base = cleaned_wells["screen_top"] >= xy_base_series + return cleaned_wells.loc[is_below_base] + + +def _clip_filter_bottom_to_model_base( + cleaned_wells: pd.DataFrame, xy_base_series: pd.Series +) -> pd.DataFrame: + cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip( + lower=xy_base_series + ) + return cleaned_wells + + +def _set_inverted_filters_to_point_filters(cleaned_wells: pd.DataFrame) -> pd.DataFrame: + # Convert all filters where screen bottom exceeds screen top to + # point filters + cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip( + upper=cleaned_wells["screen_top"] + ) + return cleaned_wells + + +def _set_ultrathin_filters_to_point_filters( + cleaned_wells: pd.DataFrame, minimum_thickness: float +) -> pd.DataFrame: + not_ultrathin_layer = ( + cleaned_wells["screen_top"] - cleaned_wells["screen_bottom"] + ) > minimum_thickness + cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].where( + not_ultrathin_layer, cleaned_wells["screen_top"] + ) + return cleaned_wells + + +def cleanup_wel_layered( + wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray +) -> pd.DataFrame: + """ + Clean up dataframe with wells, fixes some common mistakes in the following + order: + + 1. Wells outside grid bounds are dropped + + Parameters + ---------- + wells: pandas.Dataframe + Dataframe with wells to be cleaned up. Requires columns ``"x", "y", + "id"`` + top: xarray.DataArray | xugrid.UgridDataArray + Grid with model top + bottom: xarray.DataArray | xugrid.UgridDataArray + Grid with model bottoms + + Returns + ------- + pandas.DataFrame + Cleaned well dataframe. + """ + validate_well_columnnames(wells, names={"x", "y", "id"}) + + cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds( + wells, top, bottom + ) + return cleaned_wells + + +def cleanup_wel( + wells: pd.DataFrame, + top: GridDataArray, + bottom: GridDataArray, + minimum_thickness: float = 0.05, +) -> pd.DataFrame: + """ + Clean up dataframe with wells, fixes some common mistakes in the following + order: + + 1. Wells outside grid bounds are dropped + 2. Filters above surface level are set to surface level + 3. Drop wells with filters entirely below base + 4. Clip filter screen_bottom to model base + 5. Clip filter screen_bottom to screen_top + 6. Well filters thinner than minimum thickness are made point filters + + Parameters + ---------- + wells: pandas.Dataframe + Dataframe with wells to be cleaned up. Requires columns ``"x", "y", + "id", "screen_top", "screen_bottom"`` + top: xarray.DataArray | xugrid.UgridDataArray + Grid with model top + bottom: xarray.DataArray | xugrid.UgridDataArray + Grid with model bottoms + minimum_thickness: float + Minimum thickness, filter thinner than this thickness are set to point + filters + + Returns + ------- + pandas.DataFrame + Cleaned well dataframe. + """ + validate_well_columnnames( + wells, names={"x", "y", "id", "screen_top", "screen_bottom"} + ) + + cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds( + wells, top, bottom + ) + cleaned_wells = _clip_filter_screen_to_surface_level(cleaned_wells, xy_top_series) + cleaned_wells = _drop_wells_below_model_base(cleaned_wells, xy_base_series) + cleaned_wells = _clip_filter_bottom_to_model_base(cleaned_wells, xy_base_series) + cleaned_wells = _set_inverted_filters_to_point_filters(cleaned_wells) + cleaned_wells = _set_ultrathin_filters_to_point_filters( + cleaned_wells, minimum_thickness + ) + return cleaned_wells + + +def cleanup_hfb( + barrier: GeoDataFrameType, idomain_2d: GridDataArray +) -> GeoDataFrameType: + """ + Clean up HFB data, fixes some common mistakes causing ValidationErrors by + doing the following: + + - Drop HFB segments outside active domain (idomain==1) + + Parameters + ---------- + barrier: geopandas.GeoDataFrame + GeoDataFrame with HFB data + idomain_2d: xarray.DataArray | xugrid.UgridDataArray + MODFLOW 6 model domain of a single layer. idomain==1 is considered active domain. + + Returns + ------- + geopandas.GeoDataFrame + Cleaned up GeoDataFrame with HFB data. + """ + + active = idomain_2d > 0 + # Drop HFB cells outside active domain + clipped_barrier = clip_line_gdf_by_grid(barrier, active) + return clipped_barrier diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index 2bd12cdd2..ec24ed721 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -13,7 +13,7 @@ get_upper_active_grid_cells, get_upper_active_layer_number, ) -from imod.typing import GridDataArray +from imod.typing import GridDataArray, GridDataDict from imod.util.dims import enforced_dim_order @@ -71,7 +71,7 @@ def allocate_riv_cells( bottom: GridDataArray, stage: GridDataArray, bottom_elevation: GridDataArray, - drop_empty_layers: bool = False, + drop_empty_layers: bool = True, ) -> tuple[GridDataArray, Optional[GridDataArray]]: """ Allocate river cells from a planar grid across the vertical dimension. @@ -164,7 +164,7 @@ def allocate_drn_cells( top: GridDataArray, bottom: GridDataArray, elevation: GridDataArray, - drop_empty_layers: bool = False, + drop_empty_layers: bool = True, ) -> GridDataArray: """ Allocate drain cells from a planar grid across the vertical dimension. @@ -187,7 +187,7 @@ def allocate_drn_cells( elevation: DataArray | UgridDatarray Planar grid containing drain elevation. Is not allowed to have a layer dimension. - drop_empty_layers: bool, default False + drop_empty_layers: bool, default True If True, drop layers from the result that contain no allocated cells anywhere in the domain. This avoids carrying the package's arrays at full model-layer size through downstream regridding, @@ -235,6 +235,7 @@ def allocate_ghb_cells( top: GridDataArray, bottom: GridDataArray, head: GridDataArray, + drop_empty_layers: bool = True, ) -> GridDataArray: """ Allocate general head boundary (GHB) cells from a planar grid across the @@ -258,6 +259,14 @@ def allocate_ghb_cells( head: DataArray | UgridDatarray Planar grid containing general head boundary's head. Is not allowed to have a layer dimension. + drop_empty_layers: bool, default True + If True, drop layers from the result that contain no allocated + cells anywhere in the domain. This avoids carrying the package's + arrays at full model-layer size through downstream regridding, + clipping, masking, and splitting, which can otherwise become + expensive for models with many layers relative to how many + layers the topsystem package actually occupies. Set to False to + keep the previous full-layer-coordinate behaviour. Returns ------- @@ -273,13 +282,13 @@ def allocate_ghb_cells( """ match allocation_option: case ALLOCATION_OPTION.first_active_to_elevation: - return _allocate_cells__first_active_to_elevation( + result = _allocate_cells__first_active_to_elevation( active, top, bottom, head )[0] case ALLOCATION_OPTION.at_elevation: - return _allocate_cells__at_elevation(top, bottom, head)[0] + result = _allocate_cells__at_elevation(top, bottom, head)[0] case ALLOCATION_OPTION.at_first_active: - return _allocate_cells__at_first_active(active, head)[0] + result = _allocate_cells__at_first_active(active, head)[0] case _: raise ValueError( "Received incompatible setting for general head boundary, only" @@ -289,12 +298,14 @@ def allocate_ghb_cells( f"got: '{allocation_option.name}'" ) + return _drop_empty_layers(result) if drop_empty_layers else result + def allocate_rch_cells( allocation_option: ALLOCATION_OPTION, active: GridDataArray, rate: GridDataArray, - drop_empty_layers: bool = False, + drop_empty_layers: bool = True, ) -> GridDataArray: """ Allocate recharge cells from a planar grid across the vertical dimension. @@ -311,7 +322,7 @@ def allocate_rch_cells( rate: DataArray | UgridDataArray Array with recharge rates. This will only be used to infer where recharge cells are defined. - drop_empty_layers: bool, default False + drop_empty_layers: bool, default True If True, drop layers from the result that contain no allocated cells anywhere in the domain. This avoids carrying the package's arrays at full model-layer size through downstream regridding, @@ -581,46 +592,100 @@ def _allocate_cells__at_first_active( return topsystem_upper_active, None -def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: +def _used_layers(mask: GridDataArray) -> Optional[GridDataArray]: """ - Drop layers that contain no True/non-nan values in any spatial cell - (and, if present, at any timestep). Keeps the `layer` coordinate but - only for layers that actually contain data - this is what lets - downstream regridding/clipping/masking/splitting operate over a much - smaller layer range when the topsystem package only spans a handful - of the model's total layers. + Return the layer coordinate values of ``mask`` that contain at least one + True value anywhere in the domain (and, if present, at any timestep), or + None if there is nothing to trim (no layer dimension, or every layer has + data). Parameters ---------- - grid: GridDataArray - Array with a "layer" dimension, typically the output of one of the - ``_allocate_cells__*`` functions. + mask: GridDataArray + Boolean array with a "layer" dimension, typically one of the + ``allocated`` grids returned by an ``_allocate_cells__*`` function. Returns ------- - GridDataArray - Same array, subset to layers with data. + GridDataArray | None + Layer coordinate values with data, or None if nothing should be + trimmed. """ - if "layer" not in grid.dims: - return grid + if "layer" not in mask.dims: + return None - reduce_dims = [d for d in grid.dims if d != "layer"] + if mask.dtype != bool: + raise ValueError( + f"Expected a boolean grid to drop empty layers from, got: {mask.dtype}" + ) - if grid.dtype == bool: - has_data_per_layer = grid.any(dim=reduce_dims) - else: - has_data_per_layer = (~grid.isnull()).any(dim=reduce_dims) + reduce_dims = [d for d in mask.dims if d != "layer"] + has_data_per_layer = mask.any(dim=reduce_dims) # Force to plain numpy/bool to avoid triggering a dask compute deep # inside indexing logic more than once. - has_data_per_layer = ( - has_data_per_layer.compute() - if hasattr(has_data_per_layer, "compute") - else has_data_per_layer - ) + has_data_per_layer = has_data_per_layer.compute() if bool(has_data_per_layer.all()): - return grid # nothing to trim, skip the extra indexing op + return None # nothing to trim + + return mask["layer"].where(has_data_per_layer, drop=True) + - used_layers = grid["layer"].where(has_data_per_layer, drop=True) - return grid.sel(layer=used_layers) +def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: + """ + Drop layers that contain no True values in any spatial cell (and, if + present, at any timestep). Keeps the `layer` coordinate but only for + layers that actually contain data - this is what lets downstream + regridding/clipping/masking/splitting operate over a much smaller layer + range when the topsystem package only spans a handful of the model's + total layers. + + Parameters + ---------- + grid: GridDataArray + Boolean array with a "layer" dimension, typically the output of one + of the ``_allocate_cells__*`` functions. + + Returns + ------- + GridDataArray + Same array, subset to layers with data. + """ + used_layers = _used_layers(grid) + return grid if used_layers is None else grid.sel(layer=used_layers) + + +def drop_empty_layers_from_dict( + data: GridDataDict, mask: GridDataArray +) -> GridDataDict: + """ + Trim every layered grid in ``data`` down to the layers that contain data + in ``mask``. Unlike :func:`_drop_empty_layers`, this can be applied to + grids (such as distributed conductances) that are not themselves + boolean, as long as a boolean ``mask`` with the same (full) layer range + is available to decide which layers to keep. + + Parameters + ---------- + data: GridDataDict + Dictionary of grids, e.g. the layered package data returned by + ``_allocate_and_distribute_planar_data``. Grids without a "layer" + dimension are returned unchanged. + mask: GridDataArray + Boolean array with a "layer" dimension, e.g. the ``allocated`` grid + that was used to build the grids in ``data``. + + Returns + ------- + GridDataDict + Same dictionary, with every layered grid subset to layers with data. + """ + used_layers = _used_layers(mask) + if used_layers is None: + return data + + return { + key: grid.sel(layer=used_layers) if "layer" in grid.dims else grid + for key, grid in data.items() + } diff --git a/imod/tests/test_mf6/test_mf6_drn.py b/imod/tests/test_mf6/test_mf6_drn.py index 62d2138aa..417450e10 100644 --- a/imod/tests/test_mf6/test_mf6_drn.py +++ b/imod/tests/test_mf6/test_mf6_drn.py @@ -1,781 +1,814 @@ -import pathlib -import textwrap -from datetime import datetime - -import numpy as np -import pandas as pd -import pytest -import xarray as xr -from pytest_cases import parametrize_with_cases - -import imod -import imod.mf6.drn -from imod.common.utilities.version import get_version -from imod.logging import LoggerType, LogLevel -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.utilities.package import get_repeat_stress -from imod.mf6.write_context import WriteContext -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION -from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION -from imod.prepare.topsystem.default_allocation_methods import ( - SimulationAllocationOptions, - SimulationDistributingOptions, -) -from imod.schemata import ValidationError - - -@pytest.fixture(scope="function") -def 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"), - ) - conductance = elevation.copy() - - drn = {"elevation": elevation, "conductance": conductance} - return drn - - -@pytest.fixture(scope="function") -def transient_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",), - ) - conductance = time_multiplier * elevation - - drn = {"elevation": elevation, "conductance": conductance} - 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 = { - "elevation": elevation, - "conductance": conductance, - "concentration": concentration, - } - return drn - - -def test_write(drainage, tmp_path): - imod.logging.configure( - LoggerType.PYTHON, - log_level=LogLevel.DEBUG, - add_default_file_handler=True, - add_default_stream_handler=False, - ) - - drn = imod.mf6.Drainage(**drainage) - write_context = WriteContext(simulation_directory=tmp_path, use_binary=True) - drn._write("mydrn", [1], write_context) - - version = get_version() - block_expected = textwrap.dedent( - f"""\ - # File written with iMOD Python version: {version} - - begin options - end options - - begin dimensions - maxbound 75 - end dimensions - - begin period 1 - open/close mydrn/drn.bin (binary) - end period - """ - ) - - with open(tmp_path / "mydrn.drn") as f: - block = f.read() - - assert block == block_expected - - -def test_wrong_dtype(drainage): - drainage["elevation"] = drainage["elevation"].astype(np.int32) - - with pytest.raises(ValidationError): - imod.mf6.Drainage(**drainage) - - -def test_wrong_layer_coord(drainage): - ds = xr.merge([drainage], join="exact") - ds = ds.assign_coords(layer=[0, 1, 2]) - - with pytest.raises(ValidationError): - imod.mf6.Drainage(**ds) - - -def test_validate_false(drainage): - drainage["elevation"] = drainage["elevation"].astype(np.int32) - - imod.mf6.Drainage(validate=False, **drainage) - - -def test_check_conductance_zero(drainage): - drainage["conductance"] = drainage["conductance"] * 0.0 - - idomain = 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(**drainage) - errors = drn._validate(drn._write_schemata, **dis.dataset) - assert len(errors) == 1 - for var, error in errors.items(): - assert var == "conductance" - - -@pytest.mark.parametrize("nodata_idomain", [0, -1]) -def test_validate_inside_nodata(drainage, nodata_idomain): - idomain = drainage["elevation"].astype(np.int16) - top = 1.0 - bottom = top - idomain.coords["layer"] - - idomain[:, 2, 2] = nodata_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 == "elevation" - - -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 == "concentration" - - -def test_discontinuous_layer(drainage): - drn = imod.mf6.Drainage(**drainage) - drn["layer"] = [1, 3, 5] - bin_ds = drn[list(drn._period_data)] - layer = bin_ds["layer"].values - arrdict = drn._ds_to_arrdict(bin_ds) - struct_array = drn._to_struct_array(arrdict, layer) - assert np.array_equal(np.unique(struct_array["layer"]), [1, 3, 5]) - - -def test_3d_singelayer(): - # Introduced because of Issue #224 - layer = [1] - y = np.arange(4.5, 0.0, -1.0) - x = np.arange(0.5, 5.0, 1.0) - elevation = xr.DataArray( - np.full((1, 5, 5), 1.0), - coords={"layer": layer, "y": y, "x": x, "dx": 1.0, "dy": -1.0}, - dims=("layer", "y", "x"), - ) - conductance = elevation.copy() - drn = imod.mf6.Drainage(elevation=elevation, conductance=conductance) - - bin_ds = drn[list(drn._period_data)] - layer = bin_ds["layer"].values - arrdict = drn._ds_to_arrdict(bin_ds) - struct_array = drn._to_struct_array(arrdict, layer) - assert isinstance(struct_array, np.ndarray) - - -def test_aggregate_layers(drainage): - river = imod.mf6.Drainage(**drainage) - - planar_dict = river.aggregate_layers(river.dataset) - assert isinstance(planar_dict, dict) - for value in planar_dict.values(): - assert isinstance(value, xr.DataArray) - assert "layer" not in value.dims - assert "layer" not in value.coords - - # Conductance should be summed, stage averaged - assert not (planar_dict["elevation"] > planar_dict["conductance"]).any() - - -def test_render_concentration( - concentration_fc, - elevation_fc, - conductance_fc, -): - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64[ns]", - ) - - drn = imod.mf6.Drainage( - elevation=elevation_fc, - conductance=conductance_fc, - concentration=concentration_fc, - concentration_boundary_type="AUX", - ) - - actual = drn._render(directory, "drn", globaltimes, False) - - expected = textwrap.dedent( - """\ - begin options - auxiliary salinity temperature - end options - - begin dimensions - maxbound 2 - end dimensions - - begin period 1 - open/close mymodel/drn/drn-0.dat - end period - begin period 2 - open/close mymodel/drn/drn-1.dat - end period - begin period 3 - open/close mymodel/drn/drn-2.dat - end period - """ - ) - assert actual == expected - - -def test_repeat_stress( - elevation_fc, - conductance_fc, -): - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - "2000-01-04", - "2000-01-05", - ], - dtype="datetime64[ns]", - ) - - repeat_stress = xr.DataArray( - [ - [globaltimes[3], globaltimes[0]], - [globaltimes[4], globaltimes[1]], - ], - dims=("repeat", "repeat_items"), - ) - - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 2 - end dimensions - - begin period 1 - open/close mymodel/drn/drn-0.dat - end period - begin period 2 - open/close mymodel/drn/drn-1.dat - end period - begin period 3 - open/close mymodel/drn/drn-2.dat - end period - begin period 4 - open/close mymodel/drn/drn-0.dat - end period - begin period 5 - open/close mymodel/drn/drn-1.dat - end period - """ - ) - - drn = imod.mf6.Drainage( - elevation=elevation_fc, - conductance=conductance_fc, - repeat_stress=repeat_stress, - ) - actual = drn._render(directory, "drn", globaltimes, False) - assert actual == expected - - drn = imod.mf6.Drainage( - elevation=elevation_fc, - conductance=conductance_fc, - ) - drn.dataset["repeat_stress"] = get_repeat_stress( - times={ - globaltimes[3]: globaltimes[0], - globaltimes[4]: globaltimes[1], - }, - ) - actual = drn._render(directory, "drn", globaltimes, False) - assert actual == expected - - drn = imod.mf6.Drainage( - elevation=elevation_fc, - conductance=conductance_fc, - repeat_stress={ - globaltimes[3]: globaltimes[0], - globaltimes[4]: globaltimes[1], - }, - ) - actual = drn._render(directory, "drn", globaltimes, False) - assert actual == expected - - -def test_clip_box(drainage): - drn = imod.mf6.Drainage(**drainage) - - selection = drn.clip_box() - assert isinstance(selection, imod.mf6.Drainage) - assert selection.dataset.identical(drn.dataset) - - selection = drn.clip_box(x_min=None, x_max=None) - assert isinstance(selection, imod.mf6.Drainage) - assert selection.dataset.identical(drn.dataset) - - selection = drn.clip_box( - layer_min=1, - layer_max=2, - y_min=1.0, - y_max=4.0, - x_min=1.0, - x_max=4.0, - ) - assert isinstance(selection, imod.mf6.Drainage) - assert selection["conductance"].dims == ("layer", "y", "x") - assert selection["conductance"].shape == (2, 3, 3) - - -def test_clip_box_transient(transient_drainage): - drn = imod.mf6.Drainage(**transient_drainage) - - # First test the standard case: clip into existing times. - selection = drn.clip_box(time_min="2001-01-01", time_max="2004-01-01") - expected = np.array( - [ - "2001-01-01T00:00:00.000000", - "2002-01-01T00:00:00.000000", - "2003-01-01T00:00:00.000000", - "2004-01-01T00:00:00.000000", - ], - dtype="datetime64[ns]", - ) - assert isinstance(selection, imod.mf6.Drainage) - assert selection["elevation"].dims == ("layer", "y", "x") - assert selection["conductance"].dims == ("time", "layer", "y", "x") - assert np.array_equal(selection.dataset["time"], expected) - - # Now test a succesfull forward fill. - selection = drn.clip_box(time_min="2000-06-01", time_max="2002-06-01") - expected = np.array( - [ - "2000-06-01T00:00:00.000000", - "2001-01-01T00:00:00.000000", - "2002-01-01T00:00:00.000000", - ], - dtype="datetime64[ns]", - ) - assert np.array_equal(selection.dataset["time"], expected) - assert (selection["conductance"].sel(time="2000-06-01") == 1.0).all() - - # And a backfill. - selection = drn.clip_box(time_min="1990-06-01", time_max="2002-06-01") - expected = np.array( - [ - "1990-06-01T00:00:00.000000", - "2000-01-01T00:00:00.000000", - "2001-01-01T00:00:00.000000", - "2002-01-01T00:00:00.000000", - ], - dtype="datetime64[ns]", - ) - assert np.array_equal(selection.dataset["time"].values, expected) - assert (selection["conductance"].sel(time="1990-06-01") == 1.0).all() - assert (selection["conductance"].sel(time="2000-01-01") == 1.0).all() - - -def test_reallocate(drainage): - drn = imod.mf6.Drainage(**drainage) - idomain = drainage["elevation"].astype(np.int16) - top = 1.0 - bottom = top - idomain.coords["layer"] - - dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - allocation_option = ALLOCATION_OPTION.first_active_to_elevation - distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity - # Act - drn_reallocated = drn.reallocate(dis, npf, allocation_option, distributing_option) - # Assert - assert isinstance(drn_reallocated, imod.mf6.Drainage) - assert not drn_reallocated.dataset.equals(drn.dataset) - assert ( - drn_reallocated["conductance"] - .sum("layer") - .equals(drn["conductance"].sum("layer")) - ) - assert ( - drn_reallocated["elevation"] - .mean("layer") - .equals(drn["elevation"].mean("layer")) - ) - - -def test_repr(drainage): - repr_string = imod.mf6.Drainage(**drainage).__repr__() - assert isinstance(repr_string, str) - assert repr_string.split("\n")[0] == "Drainage" - - -def test_html_repr(drainage): - html_string = imod.mf6.Drainage(**drainage)._repr_html_() - assert isinstance(html_string, str) - assert html_string.split("")[0] == "
Drainage" - - -class AllocationSettings: - def case_default(self): - return SimulationAllocationOptions.drn, SimulationDistributingOptions.drn - - def case_custom(self): - return ALLOCATION_OPTION.at_elevation, DISTRIBUTING_OPTION.by_crosscut_thickness - - -@pytest.mark.unittest_jit -@parametrize_with_cases( - ["allocation_setting", "distribution_setting"], cases=AllocationSettings -) -def test_from_imod5( - imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting -): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - drn_2 = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=allocation_setting, - distributing_option=distribution_setting, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - assert isinstance(drn_2, imod.mf6.Drainage) - - drn_time = drn_2.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(drn_time, expected_times) - drn_repeat_stress = drn_2.dataset["repeat_stress"].data - assert np.all(drn_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(drn_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - pkg_errors = drn_2._validate( - schemata=drn_2._write_schemata, - idomain=target_dis["idomain"], - bottom=target_dis["bottom"], - ) - assert len(pkg_errors) == 0 - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - drn_2._write("mydrn", [1], write_context) - - -@pytest.mark.unittest_jit -@parametrize_with_cases( - ["allocation_setting", "distribution_setting"], cases=AllocationSettings -) -def test_from_imod5_and_cleanup( - imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting -): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - drn_2 = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=allocation_setting, - distributing_option=distribution_setting, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - drn_2.cleanup(target_dis) - - -@pytest.mark.unittest_jit -@parametrize_with_cases( - ["allocation_setting", "distribution_setting"], cases=AllocationSettings -) -def test_from_imod5__with_constant( - imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting -): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - original_drn_2 = imod5_dataset["drn-2"].copy() - imod5_dataset["drn-2"]["elevation"] = xr.DataArray( - [0.0], dims=("layer",), coords={"layer": [0]} - ) - - drn_2 = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=allocation_setting, - distributing_option=distribution_setting, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - assert isinstance(drn_2, imod.mf6.Drainage) - - pkg_errors = drn_2._validate( - schemata=drn_2._write_schemata, - idomain=target_dis["idomain"], - bottom=target_dis["bottom"], - ) - assert len(pkg_errors) == 0 - - # Tear down - imod5_dataset["drn-2"] = original_drn_2 - - -@pytest.mark.unittest_jit -@parametrize_with_cases( - ["allocation_setting", "distribution_setting"], cases=AllocationSettings -) -def test_from_imod5_and_cleanup__with_constant( - imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting -): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - original_drn_2 = imod5_dataset["drn-2"].copy() - imod5_dataset["drn-2"]["elevation"] = xr.DataArray( - [0.0], dims=("layer",), coords={"layer": [0]} - ) - - drn_2 = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=allocation_setting, - distributing_option=distribution_setting, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - drn_2.cleanup(target_dis) - # Teardown - imod5_dataset["drn-2"] = original_drn_2 - - -@pytest.mark.unittest_jit -def test_from_imod5__negative_layer(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - drn_reference = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=ALLOCATION_OPTION.at_first_active, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - original_drn_2 = imod5_dataset["drn-2"].copy() - imod5_dataset["drn-2"] = { - key: da.assign_coords(layer=[-1]) for key, da in imod5_dataset["drn-2"].items() - } - - drn_negative_layer = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - assert isinstance(drn_negative_layer, imod.mf6.Drainage) - - pkg_errors = drn_negative_layer._validate( - schemata=drn_negative_layer._write_schemata, - idomain=target_dis["idomain"], - bottom=target_dis["bottom"], - ) - assert len(pkg_errors) == 0 - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - drn_negative_layer._write("mydrn", [1], write_context) - - assert drn_negative_layer.dataset.identical(drn_reference.dataset) - - # Tear down - imod5_dataset["drn-2"] = original_drn_2 - - -@pytest.mark.unittest_jit -def test_from_imod5_and_cleanup__negative_layer(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - drn_reference = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=ALLOCATION_OPTION.at_first_active, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - original_drn_2 = imod5_dataset["drn-2"].copy() - imod5_dataset["drn-2"] = { - key: da.assign_coords(layer=[-1]) for key, da in imod5_dataset["drn-2"].items() - } - - drn_negative_layer = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - drn_negative_layer.cleanup(target_dis) - - assert drn_negative_layer.dataset.identical(drn_reference.dataset) - - # Teardown - imod5_dataset["drn-2"] = original_drn_2 +import pathlib +import textwrap +from datetime import datetime + +import numpy as np +import pandas as pd +import pytest +import xarray as xr +from pytest_cases import parametrize_with_cases + +import imod +from imod.common.utilities.version import get_version +from imod.logging import LoggerType, LogLevel +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.utilities.package import get_repeat_stress +from imod.mf6.write_context import WriteContext +from imod.prepare.topsystem.allocation import ALLOCATION_OPTION +from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION +from imod.prepare.topsystem.default_allocation_methods import ( + SimulationAllocationOptions, + SimulationDistributingOptions, +) +from imod.schemata import ValidationError + + +@pytest.fixture(scope="function") +def 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"), + ) + conductance = elevation.copy() + + drn = {"elevation": elevation, "conductance": conductance} + return drn + + +@pytest.fixture(scope="function") +def transient_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",), + ) + conductance = time_multiplier * elevation + + drn = {"elevation": elevation, "conductance": conductance} + 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 = { + "elevation": elevation, + "conductance": conductance, + "concentration": concentration, + } + return drn + + +def test_write(drainage, tmp_path): + imod.logging.configure( + LoggerType.PYTHON, + log_level=LogLevel.DEBUG, + add_default_file_handler=True, + add_default_stream_handler=False, + ) + + drn = imod.mf6.Drainage(**drainage) + write_context = WriteContext(simulation_directory=tmp_path, use_binary=True) + drn._write("mydrn", [1], write_context) + + version = get_version() + block_expected = textwrap.dedent( + f"""\ + # File written with iMOD Python version: {version} + + begin options + end options + + begin dimensions + maxbound 75 + end dimensions + + begin period 1 + open/close mydrn/drn.bin (binary) + end period + """ + ) + + with open(tmp_path / "mydrn.drn") as f: + block = f.read() + + assert block == block_expected + + +def test_wrong_dtype(drainage): + drainage["elevation"] = drainage["elevation"].astype(np.int32) + + with pytest.raises(ValidationError): + imod.mf6.Drainage(**drainage) + + +def test_wrong_layer_coord(drainage): + ds = xr.merge([drainage], join="exact") + ds = ds.assign_coords(layer=[0, 1, 2]) + + with pytest.raises(ValidationError): + imod.mf6.Drainage(**ds) + + +def test_validate_false(drainage): + drainage["elevation"] = drainage["elevation"].astype(np.int32) + + imod.mf6.Drainage(validate=False, **drainage) + + +def test_check_conductance_zero(drainage): + drainage["conductance"] = drainage["conductance"] * 0.0 + + idomain = 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(**drainage) + errors = drn._validate(drn._write_schemata, **dis.dataset) + assert len(errors) == 1 + for var, error in errors.items(): + assert var == "conductance" + + +@pytest.mark.parametrize("nodata_idomain", [0, -1]) +def test_validate_inside_nodata(drainage, nodata_idomain): + idomain = drainage["elevation"].astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + + idomain[:, 2, 2] = nodata_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 == "elevation" + + +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 == "concentration" + + +def test_discontinuous_layer(drainage): + drn = imod.mf6.Drainage(**drainage) + drn["layer"] = [1, 3, 5] + bin_ds = drn[list(drn._period_data)] + layer = bin_ds["layer"].values + arrdict = drn._ds_to_arrdict(bin_ds) + struct_array = drn._to_struct_array(arrdict, layer) + assert np.array_equal(np.unique(struct_array["layer"]), [1, 3, 5]) + + +def test_3d_singelayer(): + # Introduced because of Issue #224 + layer = [1] + y = np.arange(4.5, 0.0, -1.0) + x = np.arange(0.5, 5.0, 1.0) + elevation = xr.DataArray( + np.full((1, 5, 5), 1.0), + coords={"layer": layer, "y": y, "x": x, "dx": 1.0, "dy": -1.0}, + dims=("layer", "y", "x"), + ) + conductance = elevation.copy() + drn = imod.mf6.Drainage(elevation=elevation, conductance=conductance) + + bin_ds = drn[list(drn._period_data)] + layer = bin_ds["layer"].values + arrdict = drn._ds_to_arrdict(bin_ds) + struct_array = drn._to_struct_array(arrdict, layer) + assert isinstance(struct_array, np.ndarray) + + +def test_aggregate_layers(drainage): + river = imod.mf6.Drainage(**drainage) + + planar_dict = river.aggregate_layers(river.dataset) + assert isinstance(planar_dict, dict) + for value in planar_dict.values(): + assert isinstance(value, xr.DataArray) + assert "layer" not in value.dims + assert "layer" not in value.coords + + # Conductance should be summed, stage averaged + assert not (planar_dict["elevation"] > planar_dict["conductance"]).any() + + +def test_render_concentration( + concentration_fc, + elevation_fc, + conductance_fc, +): + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64[ns]", + ) + + drn = imod.mf6.Drainage( + elevation=elevation_fc, + conductance=conductance_fc, + concentration=concentration_fc, + concentration_boundary_type="AUX", + ) + + actual = drn._render(directory, "drn", globaltimes, False) + + expected = textwrap.dedent( + """\ + begin options + auxiliary salinity temperature + end options + + begin dimensions + maxbound 2 + end dimensions + + begin period 1 + open/close mymodel/drn/drn-0.dat + end period + begin period 2 + open/close mymodel/drn/drn-1.dat + end period + begin period 3 + open/close mymodel/drn/drn-2.dat + end period + """ + ) + assert actual == expected + + +def test_repeat_stress( + elevation_fc, + conductance_fc, +): + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + "2000-01-04", + "2000-01-05", + ], + dtype="datetime64[ns]", + ) + + repeat_stress = xr.DataArray( + [ + [globaltimes[3], globaltimes[0]], + [globaltimes[4], globaltimes[1]], + ], + dims=("repeat", "repeat_items"), + ) + + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 2 + end dimensions + + begin period 1 + open/close mymodel/drn/drn-0.dat + end period + begin period 2 + open/close mymodel/drn/drn-1.dat + end period + begin period 3 + open/close mymodel/drn/drn-2.dat + end period + begin period 4 + open/close mymodel/drn/drn-0.dat + end period + begin period 5 + open/close mymodel/drn/drn-1.dat + end period + """ + ) + + drn = imod.mf6.Drainage( + elevation=elevation_fc, + conductance=conductance_fc, + repeat_stress=repeat_stress, + ) + actual = drn._render(directory, "drn", globaltimes, False) + assert actual == expected + + drn = imod.mf6.Drainage( + elevation=elevation_fc, + conductance=conductance_fc, + ) + drn.dataset["repeat_stress"] = get_repeat_stress( + times={ + globaltimes[3]: globaltimes[0], + globaltimes[4]: globaltimes[1], + }, + ) + actual = drn._render(directory, "drn", globaltimes, False) + assert actual == expected + + drn = imod.mf6.Drainage( + elevation=elevation_fc, + conductance=conductance_fc, + repeat_stress={ + globaltimes[3]: globaltimes[0], + globaltimes[4]: globaltimes[1], + }, + ) + actual = drn._render(directory, "drn", globaltimes, False) + assert actual == expected + + +def test_clip_box(drainage): + drn = imod.mf6.Drainage(**drainage) + + selection = drn.clip_box() + assert isinstance(selection, imod.mf6.Drainage) + assert selection.dataset.identical(drn.dataset) + + selection = drn.clip_box(x_min=None, x_max=None) + assert isinstance(selection, imod.mf6.Drainage) + assert selection.dataset.identical(drn.dataset) + + selection = drn.clip_box( + layer_min=1, + layer_max=2, + y_min=1.0, + y_max=4.0, + x_min=1.0, + x_max=4.0, + ) + assert isinstance(selection, imod.mf6.Drainage) + assert selection["conductance"].dims == ("layer", "y", "x") + assert selection["conductance"].shape == (2, 3, 3) + + +def test_clip_box_transient(transient_drainage): + drn = imod.mf6.Drainage(**transient_drainage) + + # First test the standard case: clip into existing times. + selection = drn.clip_box(time_min="2001-01-01", time_max="2004-01-01") + expected = np.array( + [ + "2001-01-01T00:00:00.000000", + "2002-01-01T00:00:00.000000", + "2003-01-01T00:00:00.000000", + "2004-01-01T00:00:00.000000", + ], + dtype="datetime64[ns]", + ) + assert isinstance(selection, imod.mf6.Drainage) + assert selection["elevation"].dims == ("layer", "y", "x") + assert selection["conductance"].dims == ("time", "layer", "y", "x") + assert np.array_equal(selection.dataset["time"], expected) + + # Now test a succesfull forward fill. + selection = drn.clip_box(time_min="2000-06-01", time_max="2002-06-01") + expected = np.array( + [ + "2000-06-01T00:00:00.000000", + "2001-01-01T00:00:00.000000", + "2002-01-01T00:00:00.000000", + ], + dtype="datetime64[ns]", + ) + assert np.array_equal(selection.dataset["time"], expected) + assert (selection["conductance"].sel(time="2000-06-01") == 1.0).all() + + # And a backfill. + selection = drn.clip_box(time_min="1990-06-01", time_max="2002-06-01") + expected = np.array( + [ + "1990-06-01T00:00:00.000000", + "2000-01-01T00:00:00.000000", + "2001-01-01T00:00:00.000000", + "2002-01-01T00:00:00.000000", + ], + dtype="datetime64[ns]", + ) + assert np.array_equal(selection.dataset["time"].values, expected) + assert (selection["conductance"].sel(time="1990-06-01") == 1.0).all() + assert (selection["conductance"].sel(time="2000-01-01") == 1.0).all() + + +def test_reallocate(drainage): + drn = imod.mf6.Drainage(**drainage) + idomain = drainage["elevation"].astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ALLOCATION_OPTION.first_active_to_elevation + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + # Act + drn_reallocated = drn.reallocate(dis, npf, allocation_option, distributing_option) + # Assert + assert isinstance(drn_reallocated, imod.mf6.Drainage) + assert not drn_reallocated.dataset.equals(drn.dataset) + assert ( + drn_reallocated["conductance"] + .sum("layer") + .equals(drn["conductance"].sum("layer")) + ) + assert ( + drn_reallocated["elevation"] + .mean("layer") + .equals(drn["elevation"].mean("layer")) + ) + + +def test_reallocate_drop_empty_layers(drainage): + """ + drop_empty_layers=True should trim layers off the final package without + changing the values of the layers that remain (Option A: allocation and + conductance distribution always run over the full layer range first). + """ + drn = imod.mf6.Drainage(**drainage) + idomain = drainage["elevation"].astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ALLOCATION_OPTION.first_active_to_elevation + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + + full = drn.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=False + ) + trimmed = drn.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=True + ) + + full_layers = full.dataset["layer"].values + trimmed_layers = trimmed.dataset["layer"].values + assert set(trimmed_layers) <= set(full_layers) + assert len(trimmed_layers) < len(full_layers) + np.testing.assert_allclose( + trimmed["conductance"].sel(layer=trimmed_layers).values, + full["conductance"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + + +def test_repr(drainage): + repr_string = imod.mf6.Drainage(**drainage).__repr__() + assert isinstance(repr_string, str) + assert repr_string.split("\n")[0] == "Drainage" + + +def test_html_repr(drainage): + html_string = imod.mf6.Drainage(**drainage)._repr_html_() + assert isinstance(html_string, str) + assert html_string.split("
")[0] == "
Drainage" + + +class AllocationSettings: + def case_default(self): + return SimulationAllocationOptions.drn, SimulationDistributingOptions.drn + + def case_custom(self): + return ALLOCATION_OPTION.at_elevation, DISTRIBUTING_OPTION.by_crosscut_thickness + + +@pytest.mark.unittest_jit +@parametrize_with_cases( + ["allocation_setting", "distribution_setting"], cases=AllocationSettings +) +def test_from_imod5( + imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting +): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + drn_2 = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=allocation_setting, + distributing_option=distribution_setting, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + assert isinstance(drn_2, imod.mf6.Drainage) + + drn_time = drn_2.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(drn_time, expected_times) + drn_repeat_stress = drn_2.dataset["repeat_stress"].data + assert np.all(drn_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(drn_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + pkg_errors = drn_2._validate( + schemata=drn_2._write_schemata, + idomain=target_dis["idomain"], + bottom=target_dis["bottom"], + ) + assert len(pkg_errors) == 0 + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + drn_2._write("mydrn", [1], write_context) + + +@pytest.mark.unittest_jit +@parametrize_with_cases( + ["allocation_setting", "distribution_setting"], cases=AllocationSettings +) +def test_from_imod5_and_cleanup( + imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting +): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + drn_2 = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=allocation_setting, + distributing_option=distribution_setting, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + drn_2.cleanup(target_dis) + + +@pytest.mark.unittest_jit +@parametrize_with_cases( + ["allocation_setting", "distribution_setting"], cases=AllocationSettings +) +def test_from_imod5__with_constant( + imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting +): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + original_drn_2 = imod5_dataset["drn-2"].copy() + imod5_dataset["drn-2"]["elevation"] = xr.DataArray( + [0.0], dims=("layer",), coords={"layer": [0]} + ) + + drn_2 = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=allocation_setting, + distributing_option=distribution_setting, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + assert isinstance(drn_2, imod.mf6.Drainage) + + pkg_errors = drn_2._validate( + schemata=drn_2._write_schemata, + idomain=target_dis["idomain"], + bottom=target_dis["bottom"], + ) + assert len(pkg_errors) == 0 + + # Tear down + imod5_dataset["drn-2"] = original_drn_2 + + +@pytest.mark.unittest_jit +@parametrize_with_cases( + ["allocation_setting", "distribution_setting"], cases=AllocationSettings +) +def test_from_imod5_and_cleanup__with_constant( + imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting +): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + original_drn_2 = imod5_dataset["drn-2"].copy() + imod5_dataset["drn-2"]["elevation"] = xr.DataArray( + [0.0], dims=("layer",), coords={"layer": [0]} + ) + + drn_2 = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=allocation_setting, + distributing_option=distribution_setting, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + drn_2.cleanup(target_dis) + # Teardown + imod5_dataset["drn-2"] = original_drn_2 + + +@pytest.mark.unittest_jit +def test_from_imod5__negative_layer(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + drn_reference = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=ALLOCATION_OPTION.at_first_active, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + original_drn_2 = imod5_dataset["drn-2"].copy() + imod5_dataset["drn-2"] = { + key: da.assign_coords(layer=[-1]) for key, da in imod5_dataset["drn-2"].items() + } + + drn_negative_layer = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + assert isinstance(drn_negative_layer, imod.mf6.Drainage) + + pkg_errors = drn_negative_layer._validate( + schemata=drn_negative_layer._write_schemata, + idomain=target_dis["idomain"], + bottom=target_dis["bottom"], + ) + assert len(pkg_errors) == 0 + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + drn_negative_layer._write("mydrn", [1], write_context) + + assert drn_negative_layer.dataset.identical(drn_reference.dataset) + + # Tear down + imod5_dataset["drn-2"] = original_drn_2 + + +@pytest.mark.unittest_jit +def test_from_imod5_and_cleanup__negative_layer(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + drn_reference = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=ALLOCATION_OPTION.at_first_active, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + original_drn_2 = imod5_dataset["drn-2"].copy() + imod5_dataset["drn-2"] = { + key: da.assign_coords(layer=[-1]) for key, da in imod5_dataset["drn-2"].items() + } + + drn_negative_layer = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + drn_negative_layer.cleanup(target_dis) + + assert drn_negative_layer.dataset.identical(drn_reference.dataset) + + # Teardown + imod5_dataset["drn-2"] = original_drn_2 diff --git a/imod/tests/test_mf6/test_mf6_ghb.py b/imod/tests/test_mf6/test_mf6_ghb.py index 1005113f6..14f785748 100644 --- a/imod/tests/test_mf6/test_mf6_ghb.py +++ b/imod/tests/test_mf6/test_mf6_ghb.py @@ -1,239 +1,283 @@ -from copy import deepcopy -from datetime import datetime - -import numpy as np -import pytest -import xarray as xr - -import imod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.write_context import WriteContext -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION -from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION - - -@pytest.mark.unittest_jit -def test_from_imod5_non_planar(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - ) - - assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) - - ghb_time = ghb.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(ghb_time, expected_times) - ghb_repeat_stress = ghb.dataset["repeat_stress"].data - assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - ghb._write("ghb", [1], write_context) - - -@pytest.mark.unittest_jit -def test_from_imod5_and_cleanup_non_planar(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - ) - - ghb.cleanup(target_dis) - - -@pytest.mark.unittest_jit -def test_from_imod5_constant(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - original_ghb = deepcopy(imod5_dataset["ghb"]) - layer = imod5_dataset["ghb"]["conductance"].coords["layer"].data - imod5_dataset["ghb"]["conductance"] = xr.DataArray( - [1.0], coords={"layer": layer}, dims=("layer",) - ) - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - ) - - assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) - - ghb_time = ghb.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(ghb_time, expected_times) - ghb_repeat_stress = ghb.dataset["repeat_stress"].data - assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - ghb._write("ghb", [1], write_context) - - # teardown - imod5_dataset["ghb"] = original_ghb - - -@pytest.mark.unittest_jit -def test_from_imod5_and_cleanup_constant(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - original_ghb = deepcopy(imod5_dataset["ghb"]) - layer = imod5_dataset["ghb"]["conductance"].coords["layer"].data - imod5_dataset["ghb"]["conductance"] = xr.DataArray( - [1.0], coords={"layer": layer}, dims=("layer",) - ) - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - ) - - ghb.cleanup(target_dis) - # teardown - imod5_dataset["ghb"] = original_ghb - - -@pytest.mark.unittest_jit -def test_from_imod5_planar(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - original_ghb = deepcopy(imod5_dataset["ghb"]) - imod5_dataset["ghb"]["conductance"] = imod5_dataset["ghb"][ - "conductance" - ].assign_coords({"layer": [0]}) - imod5_dataset["ghb"]["head"] = imod5_dataset["ghb"]["head"].isel({"layer": 0}) - - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_layer_thickness, - ) - - assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) - - ghb_time = ghb.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(ghb_time, expected_times) - ghb_repeat_stress = ghb.dataset["repeat_stress"].data - assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - ghb._write("ghb", [1], write_context) - - # teardown - imod5_dataset["ghb"] = original_ghb - - -@pytest.mark.unittest_jit -def test_from_imod5_and_cleanup_planar(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - original_ghb = deepcopy(imod5_dataset["ghb"]) - imod5_dataset["ghb"]["conductance"] = imod5_dataset["ghb"][ - "conductance" - ].assign_coords({"layer": [0]}) - imod5_dataset["ghb"]["head"] = imod5_dataset["ghb"]["head"].isel({"layer": 0}) - - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_layer_thickness, - ) - - ghb.cleanup(target_dis) - - # teardown - imod5_dataset["ghb"] = original_ghb +from copy import deepcopy +from datetime import datetime + +import numpy as np +import pytest +import xarray as xr + +import imod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.write_context import WriteContext +from imod.prepare.topsystem.allocation import ALLOCATION_OPTION +from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION + + +def test_reallocate_drop_empty_layers(): + """ + drop_empty_layers=True should trim layers off the final package without + changing the values of the layers that remain (Option A: allocation and + conductance distribution always run over the full layer range first). + """ + layer = [1, 2, 3] + y = [25.0, 15.0, 5.0] + x = [5.0, 15.0, 25.0] + dx, dy = 10.0, -10.0 + head = xr.DataArray( + np.full((3, 3, 3), 1.0), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + dims=("layer", "y", "x"), + ) + conductance = head.copy() + ghb = imod.mf6.GeneralHeadBoundary(head=head, conductance=conductance) + + idomain = head.astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ALLOCATION_OPTION.at_first_active + distributing_option = DISTRIBUTING_OPTION.by_layer_thickness + + full = ghb.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=False + ) + trimmed = ghb.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=True + ) + + full_layers = full.dataset["layer"].values + trimmed_layers = trimmed.dataset["layer"].values + assert set(trimmed_layers) <= set(full_layers) + assert len(trimmed_layers) < len(full_layers) + np.testing.assert_allclose( + trimmed["conductance"].sel(layer=trimmed_layers).values, + full["conductance"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + + +@pytest.mark.unittest_jit +def test_from_imod5_non_planar(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + ) + + assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) + + ghb_time = ghb.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(ghb_time, expected_times) + ghb_repeat_stress = ghb.dataset["repeat_stress"].data + assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + ghb._write("ghb", [1], write_context) + + +@pytest.mark.unittest_jit +def test_from_imod5_and_cleanup_non_planar(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + ) + + ghb.cleanup(target_dis) + + +@pytest.mark.unittest_jit +def test_from_imod5_constant(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + original_ghb = deepcopy(imod5_dataset["ghb"]) + layer = imod5_dataset["ghb"]["conductance"].coords["layer"].data + imod5_dataset["ghb"]["conductance"] = xr.DataArray( + [1.0], coords={"layer": layer}, dims=("layer",) + ) + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + ) + + assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) + + ghb_time = ghb.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(ghb_time, expected_times) + ghb_repeat_stress = ghb.dataset["repeat_stress"].data + assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + ghb._write("ghb", [1], write_context) + + # teardown + imod5_dataset["ghb"] = original_ghb + + +@pytest.mark.unittest_jit +def test_from_imod5_and_cleanup_constant(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + original_ghb = deepcopy(imod5_dataset["ghb"]) + layer = imod5_dataset["ghb"]["conductance"].coords["layer"].data + imod5_dataset["ghb"]["conductance"] = xr.DataArray( + [1.0], coords={"layer": layer}, dims=("layer",) + ) + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + ) + + ghb.cleanup(target_dis) + # teardown + imod5_dataset["ghb"] = original_ghb + + +@pytest.mark.unittest_jit +def test_from_imod5_planar(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + original_ghb = deepcopy(imod5_dataset["ghb"]) + imod5_dataset["ghb"]["conductance"] = imod5_dataset["ghb"][ + "conductance" + ].assign_coords({"layer": [0]}) + imod5_dataset["ghb"]["head"] = imod5_dataset["ghb"]["head"].isel({"layer": 0}) + + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_layer_thickness, + ) + + assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) + + ghb_time = ghb.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(ghb_time, expected_times) + ghb_repeat_stress = ghb.dataset["repeat_stress"].data + assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + ghb._write("ghb", [1], write_context) + + # teardown + imod5_dataset["ghb"] = original_ghb + + +@pytest.mark.unittest_jit +def test_from_imod5_and_cleanup_planar(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + original_ghb = deepcopy(imod5_dataset["ghb"]) + imod5_dataset["ghb"]["conductance"] = imod5_dataset["ghb"][ + "conductance" + ].assign_coords({"layer": [0]}) + imod5_dataset["ghb"]["head"] = imod5_dataset["ghb"]["head"].isel({"layer": 0}) + + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_layer_thickness, + ) + + ghb.cleanup(target_dis) + + # teardown + imod5_dataset["ghb"] = original_ghb diff --git a/imod/tests/test_mf6/test_mf6_rch.py b/imod/tests/test_mf6/test_mf6_rch.py index 5867234f0..4525970eb 100644 --- a/imod/tests/test_mf6/test_mf6_rch.py +++ b/imod/tests/test_mf6/test_mf6_rch.py @@ -1,657 +1,706 @@ -import pathlib -import re -import tempfile -import textwrap -from copy import deepcopy -from datetime import datetime - -import dask -import numpy as np -import pytest -import xarray as xr - -import imod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.validation_settings import ValidationSettings -from imod.mf6.write_context import WriteContext -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION -from imod.schemata import ValidationError -from imod.typing.grid import is_planar_grid, is_transient_data_grid, nan_like -from imod.util.regrid import RegridderWeightsCache - - -@pytest.fixture(scope="function") -def rch_dict(): - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - layer = [1] - dx = 10.0 - dy = -10.0 - - da = xr.DataArray( - data=np.ones((1, 3, 3), dtype=float), - dims=("layer", "y", "x"), - coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - ) - - da[:, 1, 1] = np.nan - - return {"rate": da} - - -@pytest.fixture(scope="function") -def rch_dict_transient(): - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - layer = [1] - time = np.array(["2000-01-01", "2000-01-02"], dtype="datetime64[ns]") - dx = 10.0 - dy = -10.0 - - da = xr.DataArray( - data=np.ones((2, 1, 3, 3), dtype=float), - dims=("time", "layer", "y", "x"), - coords={"time": time, "layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - ) - - da[..., 1, 1] = np.nan - - return {"rate": da} - - -def test_render(rch_dict): - rch = imod.mf6.Recharge(**rch_dict) - directory = pathlib.Path("mymodel") - globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch.bin (binary) - end period - """ - ) - assert actual == expected - - -def test_render_fixed_cell(rch_dict): - rch_dict["fixed_cell"] = True - rch = imod.mf6.Recharge(**rch_dict) - directory = pathlib.Path("mymodel") - globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - fixed_cell - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch.bin (binary) - end period - """ - ) - assert actual == expected - - -def test_render_transient(rch_dict_transient): - rch = imod.mf6.Recharge(**rch_dict_transient) - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64[ns]", - ) - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch-0.bin (binary) - end period - begin period 2 - open/close mymodel/recharge/rch-1.bin (binary) - end period - """ - ) - assert actual == expected - - -def test_wrong_dtype(rch_dict): - rch_dict["rate"] = rch_dict["rate"].astype(np.int32) - with pytest.raises(ValidationError): - imod.mf6.Recharge(**rch_dict) - - -def test_no_layer_dim(rch_dict): - rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=False) - rch = imod.mf6.Recharge(**rch_dict) - directory = pathlib.Path("mymodel") - globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch.bin (binary) - end period - """ - ) - assert actual == expected - - -def test_transient_no_layer_dim(rch_dict_transient): - rch_dict_transient["rate"] = rch_dict_transient["rate"].sel(layer=1, drop=False) - rch = imod.mf6.Recharge(**rch_dict_transient) - - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64[ns]", - ) - - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch-0.bin (binary) - end period - begin period 2 - open/close mymodel/recharge/rch-1.bin (binary) - end period - """ - ) - - assert actual == expected - - -def test_transient_aggregate(rch_dict_transient): - rch = imod.mf6.Recharge(**rch_dict_transient) - planar_dict = rch.aggregate_layers(rch.dataset) - - assert isinstance(planar_dict, dict) - for value in planar_dict.values(): - assert isinstance(value, xr.DataArray) - assert "layer" not in value.dims - assert "layer" not in value.coords - assert value.dims == ("time", "y", "x") - - -def test_render_concentration(concentration_fc, rate_fc): - rch = imod.mf6.Recharge( - rate=rate_fc, - concentration=concentration_fc, - concentration_boundary_type="AUX", - ) - - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64[ns]", - ) - - actual = rch._render(directory, "rch", globaltimes, False) - - expected = textwrap.dedent( - """\ - begin options - auxiliary salinity temperature - end options - - begin dimensions - maxbound 2 - end dimensions - - begin period 1 - open/close mymodel/rch/rch-0.dat - end period - begin period 2 - open/close mymodel/rch/rch-1.dat - end period - begin period 3 - open/close mymodel/rch/rch-2.dat - end period - """ - ) - assert actual == expected - - -def test_no_layer_coord(rch_dict): - message = textwrap.dedent( - """ - - rate - - coords has missing keys: {'layer'}""" - ) - - rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=True) - with pytest.raises( - ValidationError, - match=re.escape(message), - ): - imod.mf6.Recharge(**rch_dict) - - -def test_scalar(): - message = textwrap.dedent( - """ - - rate - - coords has missing keys: {'layer'} - - No option succeeded: - dim mismatch: expected ('time', 'layer', 'y', 'x'), got () - dim mismatch: expected ('layer', 'y', 'x'), got () - dim mismatch: expected ('time', 'layer', '{face_dim}'), got () - dim mismatch: expected ('layer', '{face_dim}'), got () - dim mismatch: expected ('time', 'y', 'x'), got () - dim mismatch: expected ('y', 'x'), got () - dim mismatch: expected ('time', '{face_dim}'), got () - dim mismatch: expected ('{face_dim}',), got ()""" - ) - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.Recharge(rate=0.001) - - -def test_validate_false(): - imod.mf6.Recharge(rate=0.001, validate=False) - - -@pytest.mark.timeout(10, method="thread") -def test_ignore_time_validation(): - """ - Create a large recharge dataset with a time dimension. This is to test the - performance of the validation when ignore_time_no_data is True. - NOTE: There currently is no easy way to test the opposite (i.e., - ignore_time_no_data is False, then catch timeout with an pytest xfail - marker), because the timeout will terminate the test run with an error when - using dask instead of a fail. Somewhat relevant issue: - https://github.com/pytest-dev/pytest-timeout/issues/181 - """ - # Arrange - rng = dask.array.random.default_rng() - layer = [1, 2, 3] - template = imod.util.empty_3d(1.0, 0.0, 1000.0, 1.0, 0.0, 1000.0, layer) - idomain = xr.ones_like(template, dtype=np.int32) - layer_bottom = xr.DataArray( - [0.0, -10.0, -20.0], coords={"layer": layer}, dims=["layer"] - ) - bottom = layer_bottom * idomain - x = rng.random((10000, 3, 1000, 1000), chunks=(1, -1, -1, -1)) - rate = xr.DataArray(x, coords=idomain.coords, dims=("time", "layer", "y", "x")) - rch = imod.mf6.Recharge(rate=rate, validate=False) - validation_context = ValidationSettings(ignore_time=True) - # Act - rch._validate( - schemata=rch._write_schemata, - idomain=idomain, - bottom=bottom, - validation_context=validation_context, - ) - - -def test_write_concentration_period_data(rate_fc, concentration_fc): - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64", - ) - rate_fc[:] = 1 - concentration_fc[:] = 2 - - rch = imod.mf6.Recharge( - rate=rate_fc, - concentration=concentration_fc, - concentration_boundary_type="AUX", - ) - with tempfile.TemporaryDirectory() as output_dir: - write_context = WriteContext( - simulation_directory=output_dir, write_directory=output_dir - ) - rch._write(pkgname="rch", globaltimes=globaltimes, write_context=write_context) - - with open(output_dir + "/rch/rch-0.dat", "r") as f: - data = f.read() - assert ( - data.count("2") == 1755 - ) # the number 2 is in the concentration data, and in the cell indices. - - -def test_clip_box(rch_dict): - rch = imod.mf6.Recharge(**rch_dict) - - selection = rch.clip_box() - assert isinstance(selection, imod.mf6.Recharge) - assert selection.dataset.identical(rch.dataset) - - selection = rch.clip_box(x_min=10.0, x_max=20.0, y_min=10.0, y_max=20.0) - assert selection["rate"].dims == ("layer", "y", "x") - assert selection["rate"].shape == (1, 1, 1) - - # No layer dim - rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=False) - rch = imod.mf6.Recharge(**rch_dict) - selection = rch.clip_box(x_min=10.0, x_max=20.0, y_min=10.0, y_max=20.0) - assert selection["rate"].dims == ("y", "x") - assert selection["rate"].shape == (1, 1) - - -@pytest.mark.parametrize( - "allocation_option", - [None, ALLOCATION_OPTION.at_first_active, ALLOCATION_OPTION.stage_to_riv_bot], -) -def test_reallocate(rch_dict, allocation_option): - # Arrange - rch = imod.mf6.Recharge(**rch_dict) - idomain = rch_dict["rate"].fillna(0.0).astype(np.int16) - top = 1.0 - bottom = top - idomain.coords["layer"] - dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) - if allocation_option is ALLOCATION_OPTION.stage_to_riv_bot: - # Act - with pytest.raises( - ValueError, match="Received incompatible setting for recharge" - ): - rch.reallocate(dis, allocation_option=allocation_option) - else: - # Act - rch_reallocated = rch.reallocate(dis, allocation_option=allocation_option) - # Assert - assert isinstance(rch_reallocated, imod.mf6.Recharge) - assert rch_reallocated.dataset.equals(rch.dataset) - - -@pytest.mark.unittest_jit -def test_planar_rch_from_imod5_constant(imod5_dataset, tmp_path): - data = deepcopy(imod5_dataset[0]) - period_data = imod5_dataset[1] - target_discretization = StructuredDiscretization.from_imod5_data(data) - - # create a planar grid with time-independent recharge - data["rch"]["rate"] = data["rch"]["rate"].assign_coords(layer=[-1]) - - assert not is_transient_data_grid(data["rch"]["rate"]) - assert is_planar_grid(data["rch"]["rate"]) - - # Act - rch = imod.mf6.Recharge.from_imod5_data( - data, - period_data, - target_discretization, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - ) - rendered_rch = rch._render(tmp_path, "rch", None, None) - - # Assert - np.testing.assert_allclose( - data["rch"]["rate"].mean().values / 1e3, - rch.dataset["rate"].mean().values, - atol=1e-5, - ) - assert "maxbound 33856" in rendered_rch - assert rendered_rch.count("begin period") == 1 - # teardown - data["rch"]["rate"] = data["rch"]["rate"].assign_coords(layer=[1]) - - -@pytest.mark.unittest_jit -def test_planar_rch_from_imod5_transient(imod5_dataset, tmp_path): - data = deepcopy(imod5_dataset[0]) - period_data = imod5_dataset[1] - target_discretization = StructuredDiscretization.from_imod5_data(data) - - # create a grid with recharge for 3 timesteps - input_recharge = data["rch"]["rate"].copy(deep=True) - times = [ - np.datetime64("2001-01-01"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - input_recharge = input_recharge.expand_dims({"time": times}) - - # make it planar by setting the layer coordinate to -1 - input_recharge = input_recharge.assign_coords({"layer": [-1]}) - - # update the data set - data["rch"]["rate"] = input_recharge - assert is_transient_data_grid(data["rch"]["rate"]) - assert is_planar_grid(data["rch"]["rate"]) - - # act - rch = imod.mf6.Recharge.from_imod5_data( - data, - period_data, - target_discretization, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - ) - globaltimes = times + [np.datetime64("2022-02-02")] - globaltimes[0] = np.datetime64("2002-02-02") - rendered_rch = rch._render(tmp_path, "rch", globaltimes, None) - - # assert - np.testing.assert_allclose( - data["rch"]["rate"].mean().values / 1e3, - rch.dataset["rate"].mean().values, - atol=1e-5, - ) - assert rendered_rch.count("begin period") == 3 - assert "maxbound 33856" in rendered_rch - - -@pytest.mark.unittest_jit -def test_non_planar_rch_from_imod5_constant(imod5_dataset, tmp_path): - data = deepcopy(imod5_dataset[0]) - period_data = imod5_dataset[1] - target_discretization = StructuredDiscretization.from_imod5_data(data) - - # make the first layer of the target grid inactive - target_grid = target_discretization.dataset["idomain"] - target_grid.loc[{"layer": 1}] = 0 - - # the input for recharge is on the second layer of the targetgrid - original_rch = data["rch"]["rate"].copy(deep=True) - data["rch"]["rate"] = data["rch"]["rate"].assign_coords({"layer": [-1]}) - input_recharge = nan_like(data["khv"]["kh"]) - input_recharge.loc[{"layer": 2}] = data["rch"]["rate"].isel(layer=0) - - # update the data set - - data["rch"]["rate"] = input_recharge - assert not is_planar_grid(data["rch"]["rate"]) - assert not is_transient_data_grid(data["rch"]["rate"]) - - # act - rch = imod.mf6.Recharge.from_imod5_data( - data, - period_data, - target_discretization, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - ) - rendered_rch = rch._render(tmp_path, "rch", None, None) - - # assert - np.testing.assert_allclose( - data["rch"]["rate"].mean().values / 1e3, - rch.dataset["rate"].mean().values, - atol=1e-5, - ) - assert rendered_rch.count("begin period") == 1 - assert "maxbound 33856" in rendered_rch - - # teardown - data["rch"]["rate"] = original_rch - - -@pytest.mark.unittest_jit -def test_non_planar_rch_from_imod5_transient(imod5_dataset, tmp_path): - data = deepcopy(imod5_dataset[0]) - period_data = imod5_dataset[1] - target_discretization = StructuredDiscretization.from_imod5_data(data) - # make the first layer of the target grid inactive - target_grid = target_discretization.dataset["idomain"] - target_grid.loc[{"layer": 1}] = 0 - - # the input for recharge is on the second layer of the targetgrid - input_recharge = nan_like(data["rch"]["rate"]) - input_recharge = input_recharge.assign_coords({"layer": [2]}) - input_recharge.loc[{"layer": 2}] = data["rch"]["rate"].sel(layer=1) - times = [ - np.datetime64("2001-01-01"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - input_recharge = input_recharge.expand_dims({"time": times}) - - # update the data set - data["rch"]["rate"] = input_recharge - assert not is_planar_grid(data["rch"]["rate"]) - assert is_transient_data_grid(data["rch"]["rate"]) - - # act - rch = imod.mf6.Recharge.from_imod5_data( - data, - period_data, - target_discretization, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - ) - globaltimes = times + [np.datetime64("2022-02-02")] - globaltimes[0] = np.datetime64("2002-02-02") - rendered_rch = rch._render(tmp_path, "rch", globaltimes, None) - - # assert - np.testing.assert_allclose( - data["rch"]["rate"].mean().values / 1e3, - rch.dataset["rate"].mean().values, - atol=1e-5, - ) - assert rendered_rch.count("begin period") == 3 - assert "maxbound 33856" in rendered_rch - - -@pytest.mark.unittest_jit -def test_from_imod5_cap_data(imod5_dataset): - # Arrange - data = deepcopy(imod5_dataset[0]) - target_discretization = StructuredDiscretization.from_imod5_data(data) - data["extra"] = {"paths": ["path1", "path2"]} - data["cap"] = {} - msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) - data["cap"]["boundary"] = msw_bound - data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 - data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 - # Compute midpoint of grid and set areas such, that cells need to be - # deactivated. - midpoint = tuple((int(x / 2) for x in msw_bound.shape)) - # Set to total cellsize, cell needs to be deactivated. - data["cap"]["wetted_area"][midpoint] = 625.0 - # Set to zero, cell needs to be deactivated. - data["cap"]["urban_area"][midpoint] = 0.0 - # Act - rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) - rate = rch.dataset["rate"] - # Assert - # Shape - assert rate.dims == ("y", "x") - assert "layer" in rate.coords - assert rate.coords["layer"] == 1 - # Values - np.testing.assert_array_equal(np.unique(rate), np.array([0.0, np.nan])) - # Boundaries inactive in MetaSWAP - assert np.isnan(rate[:, 0]).all() - assert np.isnan(rate[:, -1]).all() - assert np.isnan(rate[0, :]).all() - assert np.isnan(rate[-1, :]).all() - assert np.isnan(rate[midpoint]).all() - - -@pytest.mark.unittest_jit -def test_from_imod5_cap_data__regrid(imod5_dataset): - # Arrange - data = deepcopy(imod5_dataset[0]) - target_discretization = StructuredDiscretization.from_imod5_data(data) - data["extra"] = {"paths": ["path1", "path2"]} - data["cap"] = {} - msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) - data["cap"]["boundary"] = msw_bound - data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 - data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 - # Setup template grid - dx_small, xmin, xmax, dy_small, ymin, ymax = imod.util.spatial_reference(msw_bound) - dx = dx_small * 2 - dy = dy_small * 2 - expected_spatial_ref = dx, xmin, xmax, dy, ymin, ymax - like = imod.util.empty_2d(*expected_spatial_ref) - # Act - rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) - rch_coarse = rch.regrid_like(like, regrid_cache=RegridderWeightsCache()) - # Assert - actual_spatial_ref = imod.util.spatial_reference(rch_coarse.dataset["rate"]) - assert actual_spatial_ref == expected_spatial_ref - - -@pytest.mark.unittest_jit -def test_from_imod5_cap_data__clip_box(imod5_dataset): - # Arrange - data = deepcopy(imod5_dataset[0]) - target_discretization = StructuredDiscretization.from_imod5_data(data) - data["extra"] = {"paths": ["path1", "path2"]} - data["cap"] = {} - msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) - data["cap"]["boundary"] = msw_bound - data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 - data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 - # Setup template grid - dx, xmin, xmax, dy, ymin, ymax = imod.util.spatial_reference(msw_bound) - xmin_to_clip = xmin + 10 * dx - expected_spatial_ref = dx, xmin_to_clip, xmax, dy, ymin, ymax - # Act - rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) - rch_clipped = rch.clip_box(x_min=xmin_to_clip) - # Assert - actual_spatial_ref = imod.util.spatial_reference(rch_clipped.dataset["rate"]) - assert actual_spatial_ref == expected_spatial_ref +import pathlib +import re +import tempfile +import textwrap +from copy import deepcopy +from datetime import datetime + +import dask +import numpy as np +import pytest +import xarray as xr + +import imod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.validation_settings import ValidationSettings +from imod.mf6.write_context import WriteContext +from imod.prepare.topsystem.allocation import ALLOCATION_OPTION +from imod.schemata import ValidationError +from imod.typing.grid import is_planar_grid, is_transient_data_grid, nan_like +from imod.util.regrid import RegridderWeightsCache + + +@pytest.fixture(scope="function") +def rch_dict(): + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + layer = [1] + dx = 10.0 + dy = -10.0 + + da = xr.DataArray( + data=np.ones((1, 3, 3), dtype=float), + dims=("layer", "y", "x"), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + ) + + da[:, 1, 1] = np.nan + + return {"rate": da} + + +@pytest.fixture(scope="function") +def rch_dict_transient(): + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + layer = [1] + time = np.array(["2000-01-01", "2000-01-02"], dtype="datetime64[ns]") + dx = 10.0 + dy = -10.0 + + da = xr.DataArray( + data=np.ones((2, 1, 3, 3), dtype=float), + dims=("time", "layer", "y", "x"), + coords={"time": time, "layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + ) + + da[..., 1, 1] = np.nan + + return {"rate": da} + + +def test_render(rch_dict): + rch = imod.mf6.Recharge(**rch_dict) + directory = pathlib.Path("mymodel") + globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch.bin (binary) + end period + """ + ) + assert actual == expected + + +def test_render_fixed_cell(rch_dict): + rch_dict["fixed_cell"] = True + rch = imod.mf6.Recharge(**rch_dict) + directory = pathlib.Path("mymodel") + globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + fixed_cell + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch.bin (binary) + end period + """ + ) + assert actual == expected + + +def test_render_transient(rch_dict_transient): + rch = imod.mf6.Recharge(**rch_dict_transient) + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64[ns]", + ) + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch-0.bin (binary) + end period + begin period 2 + open/close mymodel/recharge/rch-1.bin (binary) + end period + """ + ) + assert actual == expected + + +def test_wrong_dtype(rch_dict): + rch_dict["rate"] = rch_dict["rate"].astype(np.int32) + with pytest.raises(ValidationError): + imod.mf6.Recharge(**rch_dict) + + +def test_no_layer_dim(rch_dict): + rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=False) + rch = imod.mf6.Recharge(**rch_dict) + directory = pathlib.Path("mymodel") + globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch.bin (binary) + end period + """ + ) + assert actual == expected + + +def test_transient_no_layer_dim(rch_dict_transient): + rch_dict_transient["rate"] = rch_dict_transient["rate"].sel(layer=1, drop=False) + rch = imod.mf6.Recharge(**rch_dict_transient) + + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64[ns]", + ) + + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch-0.bin (binary) + end period + begin period 2 + open/close mymodel/recharge/rch-1.bin (binary) + end period + """ + ) + + assert actual == expected + + +def test_transient_aggregate(rch_dict_transient): + rch = imod.mf6.Recharge(**rch_dict_transient) + planar_dict = rch.aggregate_layers(rch.dataset) + + assert isinstance(planar_dict, dict) + for value in planar_dict.values(): + assert isinstance(value, xr.DataArray) + assert "layer" not in value.dims + assert "layer" not in value.coords + assert value.dims == ("time", "y", "x") + + +def test_render_concentration(concentration_fc, rate_fc): + rch = imod.mf6.Recharge( + rate=rate_fc, + concentration=concentration_fc, + concentration_boundary_type="AUX", + ) + + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64[ns]", + ) + + actual = rch._render(directory, "rch", globaltimes, False) + + expected = textwrap.dedent( + """\ + begin options + auxiliary salinity temperature + end options + + begin dimensions + maxbound 2 + end dimensions + + begin period 1 + open/close mymodel/rch/rch-0.dat + end period + begin period 2 + open/close mymodel/rch/rch-1.dat + end period + begin period 3 + open/close mymodel/rch/rch-2.dat + end period + """ + ) + assert actual == expected + + +def test_no_layer_coord(rch_dict): + message = textwrap.dedent( + """ + - rate + - coords has missing keys: {'layer'}""" + ) + + rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=True) + with pytest.raises( + ValidationError, + match=re.escape(message), + ): + imod.mf6.Recharge(**rch_dict) + + +def test_scalar(): + message = textwrap.dedent( + """ + - rate + - coords has missing keys: {'layer'} + - No option succeeded: + dim mismatch: expected ('time', 'layer', 'y', 'x'), got () + dim mismatch: expected ('layer', 'y', 'x'), got () + dim mismatch: expected ('time', 'layer', '{face_dim}'), got () + dim mismatch: expected ('layer', '{face_dim}'), got () + dim mismatch: expected ('time', 'y', 'x'), got () + dim mismatch: expected ('y', 'x'), got () + dim mismatch: expected ('time', '{face_dim}'), got () + dim mismatch: expected ('{face_dim}',), got ()""" + ) + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.Recharge(rate=0.001) + + +def test_validate_false(): + imod.mf6.Recharge(rate=0.001, validate=False) + + +@pytest.mark.timeout(10, method="thread") +def test_ignore_time_validation(): + """ + Create a large recharge dataset with a time dimension. This is to test the + performance of the validation when ignore_time_no_data is True. + NOTE: There currently is no easy way to test the opposite (i.e., + ignore_time_no_data is False, then catch timeout with an pytest xfail + marker), because the timeout will terminate the test run with an error when + using dask instead of a fail. Somewhat relevant issue: + https://github.com/pytest-dev/pytest-timeout/issues/181 + """ + # Arrange + rng = dask.array.random.default_rng() + layer = [1, 2, 3] + template = imod.util.empty_3d(1.0, 0.0, 1000.0, 1.0, 0.0, 1000.0, layer) + idomain = xr.ones_like(template, dtype=np.int32) + layer_bottom = xr.DataArray( + [0.0, -10.0, -20.0], coords={"layer": layer}, dims=["layer"] + ) + bottom = layer_bottom * idomain + x = rng.random((10000, 3, 1000, 1000), chunks=(1, -1, -1, -1)) + rate = xr.DataArray(x, coords=idomain.coords, dims=("time", "layer", "y", "x")) + rch = imod.mf6.Recharge(rate=rate, validate=False) + validation_context = ValidationSettings(ignore_time=True) + # Act + rch._validate( + schemata=rch._write_schemata, + idomain=idomain, + bottom=bottom, + validation_context=validation_context, + ) + + +def test_write_concentration_period_data(rate_fc, concentration_fc): + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64", + ) + rate_fc[:] = 1 + concentration_fc[:] = 2 + + rch = imod.mf6.Recharge( + rate=rate_fc, + concentration=concentration_fc, + concentration_boundary_type="AUX", + ) + with tempfile.TemporaryDirectory() as output_dir: + write_context = WriteContext( + simulation_directory=output_dir, write_directory=output_dir + ) + rch._write(pkgname="rch", globaltimes=globaltimes, write_context=write_context) + + with open(output_dir + "/rch/rch-0.dat", "r") as f: + data = f.read() + assert ( + data.count("2") == 1755 + ) # the number 2 is in the concentration data, and in the cell indices. + + +def test_clip_box(rch_dict): + rch = imod.mf6.Recharge(**rch_dict) + + selection = rch.clip_box() + assert isinstance(selection, imod.mf6.Recharge) + assert selection.dataset.identical(rch.dataset) + + selection = rch.clip_box(x_min=10.0, x_max=20.0, y_min=10.0, y_max=20.0) + assert selection["rate"].dims == ("layer", "y", "x") + assert selection["rate"].shape == (1, 1, 1) + + # No layer dim + rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=False) + rch = imod.mf6.Recharge(**rch_dict) + selection = rch.clip_box(x_min=10.0, x_max=20.0, y_min=10.0, y_max=20.0) + assert selection["rate"].dims == ("y", "x") + assert selection["rate"].shape == (1, 1) + + +@pytest.mark.parametrize( + "allocation_option", + [None, ALLOCATION_OPTION.at_first_active, ALLOCATION_OPTION.stage_to_riv_bot], +) +def test_reallocate(rch_dict, allocation_option): + # Arrange + rch = imod.mf6.Recharge(**rch_dict) + idomain = rch_dict["rate"].fillna(0.0).astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + if allocation_option is ALLOCATION_OPTION.stage_to_riv_bot: + # Act + with pytest.raises( + ValueError, match="Received incompatible setting for recharge" + ): + rch.reallocate(dis, allocation_option=allocation_option) + else: + # Act + rch_reallocated = rch.reallocate(dis, allocation_option=allocation_option) + # Assert + assert isinstance(rch_reallocated, imod.mf6.Recharge) + assert rch_reallocated.dataset.equals(rch.dataset) + + +def test_reallocate_drop_empty_layers(): + """ + drop_empty_layers=True should trim layers off the final package without + changing the values of the layers that remain (Option A: allocation is + always computed over the full layer range first). + """ + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + layer = [1, 2, 3] + dx, dy = 10.0, -10.0 + + idomain = xr.DataArray( + np.ones((3, 3, 3), dtype=np.int16), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + dims=("layer", "y", "x"), + ) + top = 1.0 + bottom = top - idomain.coords["layer"] + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + + rate = xr.DataArray( + 1.0, + coords={"y": y, "x": x, "dx": dx, "dy": dy}, + dims=("y", "x"), + ).expand_dims(layer=[1]) + rch = imod.mf6.Recharge(rate=rate) + + full = rch.reallocate( + dis, + allocation_option=ALLOCATION_OPTION.at_first_active, + drop_empty_layers=False, + ) + trimmed = rch.reallocate( + dis, + allocation_option=ALLOCATION_OPTION.at_first_active, + drop_empty_layers=True, + ) + + full_layers = full.dataset["layer"].values + trimmed_layers = trimmed.dataset["layer"].values + assert set(trimmed_layers) <= set(full_layers) + assert len(trimmed_layers) < len(full_layers) + np.testing.assert_allclose( + trimmed["rate"].sel(layer=trimmed_layers).values, + full["rate"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + + +@pytest.mark.unittest_jit +def test_planar_rch_from_imod5_constant(imod5_dataset, tmp_path): + data = deepcopy(imod5_dataset[0]) + period_data = imod5_dataset[1] + target_discretization = StructuredDiscretization.from_imod5_data(data) + + # create a planar grid with time-independent recharge + data["rch"]["rate"] = data["rch"]["rate"].assign_coords(layer=[-1]) + + assert not is_transient_data_grid(data["rch"]["rate"]) + assert is_planar_grid(data["rch"]["rate"]) + + # Act + rch = imod.mf6.Recharge.from_imod5_data( + data, + period_data, + target_discretization, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + ) + rendered_rch = rch._render(tmp_path, "rch", None, None) + + # Assert + np.testing.assert_allclose( + data["rch"]["rate"].mean().values / 1e3, + rch.dataset["rate"].mean().values, + atol=1e-5, + ) + assert "maxbound 33856" in rendered_rch + assert rendered_rch.count("begin period") == 1 + # teardown + data["rch"]["rate"] = data["rch"]["rate"].assign_coords(layer=[1]) + + +@pytest.mark.unittest_jit +def test_planar_rch_from_imod5_transient(imod5_dataset, tmp_path): + data = deepcopy(imod5_dataset[0]) + period_data = imod5_dataset[1] + target_discretization = StructuredDiscretization.from_imod5_data(data) + + # create a grid with recharge for 3 timesteps + input_recharge = data["rch"]["rate"].copy(deep=True) + times = [ + np.datetime64("2001-01-01"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + input_recharge = input_recharge.expand_dims({"time": times}) + + # make it planar by setting the layer coordinate to -1 + input_recharge = input_recharge.assign_coords({"layer": [-1]}) + + # update the data set + data["rch"]["rate"] = input_recharge + assert is_transient_data_grid(data["rch"]["rate"]) + assert is_planar_grid(data["rch"]["rate"]) + + # act + rch = imod.mf6.Recharge.from_imod5_data( + data, + period_data, + target_discretization, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + ) + globaltimes = times + [np.datetime64("2022-02-02")] + globaltimes[0] = np.datetime64("2002-02-02") + rendered_rch = rch._render(tmp_path, "rch", globaltimes, None) + + # assert + np.testing.assert_allclose( + data["rch"]["rate"].mean().values / 1e3, + rch.dataset["rate"].mean().values, + atol=1e-5, + ) + assert rendered_rch.count("begin period") == 3 + assert "maxbound 33856" in rendered_rch + + +@pytest.mark.unittest_jit +def test_non_planar_rch_from_imod5_constant(imod5_dataset, tmp_path): + data = deepcopy(imod5_dataset[0]) + period_data = imod5_dataset[1] + target_discretization = StructuredDiscretization.from_imod5_data(data) + + # make the first layer of the target grid inactive + target_grid = target_discretization.dataset["idomain"] + target_grid.loc[{"layer": 1}] = 0 + + # the input for recharge is on the second layer of the targetgrid + original_rch = data["rch"]["rate"].copy(deep=True) + data["rch"]["rate"] = data["rch"]["rate"].assign_coords({"layer": [-1]}) + input_recharge = nan_like(data["khv"]["kh"]) + input_recharge.loc[{"layer": 2}] = data["rch"]["rate"].isel(layer=0) + + # update the data set + + data["rch"]["rate"] = input_recharge + assert not is_planar_grid(data["rch"]["rate"]) + assert not is_transient_data_grid(data["rch"]["rate"]) + + # act + rch = imod.mf6.Recharge.from_imod5_data( + data, + period_data, + target_discretization, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + ) + rendered_rch = rch._render(tmp_path, "rch", None, None) + + # assert + np.testing.assert_allclose( + data["rch"]["rate"].mean().values / 1e3, + rch.dataset["rate"].mean().values, + atol=1e-5, + ) + assert rendered_rch.count("begin period") == 1 + assert "maxbound 33856" in rendered_rch + + # teardown + data["rch"]["rate"] = original_rch + + +@pytest.mark.unittest_jit +def test_non_planar_rch_from_imod5_transient(imod5_dataset, tmp_path): + data = deepcopy(imod5_dataset[0]) + period_data = imod5_dataset[1] + target_discretization = StructuredDiscretization.from_imod5_data(data) + # make the first layer of the target grid inactive + target_grid = target_discretization.dataset["idomain"] + target_grid.loc[{"layer": 1}] = 0 + + # the input for recharge is on the second layer of the targetgrid + input_recharge = nan_like(data["rch"]["rate"]) + input_recharge = input_recharge.assign_coords({"layer": [2]}) + input_recharge.loc[{"layer": 2}] = data["rch"]["rate"].sel(layer=1) + times = [ + np.datetime64("2001-01-01"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + input_recharge = input_recharge.expand_dims({"time": times}) + + # update the data set + data["rch"]["rate"] = input_recharge + assert not is_planar_grid(data["rch"]["rate"]) + assert is_transient_data_grid(data["rch"]["rate"]) + + # act + rch = imod.mf6.Recharge.from_imod5_data( + data, + period_data, + target_discretization, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + ) + globaltimes = times + [np.datetime64("2022-02-02")] + globaltimes[0] = np.datetime64("2002-02-02") + rendered_rch = rch._render(tmp_path, "rch", globaltimes, None) + + # assert + np.testing.assert_allclose( + data["rch"]["rate"].mean().values / 1e3, + rch.dataset["rate"].mean().values, + atol=1e-5, + ) + assert rendered_rch.count("begin period") == 3 + assert "maxbound 33856" in rendered_rch + + +@pytest.mark.unittest_jit +def test_from_imod5_cap_data(imod5_dataset): + # Arrange + data = deepcopy(imod5_dataset[0]) + target_discretization = StructuredDiscretization.from_imod5_data(data) + data["extra"] = {"paths": ["path1", "path2"]} + data["cap"] = {} + msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) + data["cap"]["boundary"] = msw_bound + data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 + data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 + # Compute midpoint of grid and set areas such, that cells need to be + # deactivated. + midpoint = tuple((int(x / 2) for x in msw_bound.shape)) + # Set to total cellsize, cell needs to be deactivated. + data["cap"]["wetted_area"][midpoint] = 625.0 + # Set to zero, cell needs to be deactivated. + data["cap"]["urban_area"][midpoint] = 0.0 + # Act + rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) + rate = rch.dataset["rate"] + # Assert + # Shape + assert rate.dims == ("y", "x") + assert "layer" in rate.coords + assert rate.coords["layer"] == 1 + # Values + np.testing.assert_array_equal(np.unique(rate), np.array([0.0, np.nan])) + # Boundaries inactive in MetaSWAP + assert np.isnan(rate[:, 0]).all() + assert np.isnan(rate[:, -1]).all() + assert np.isnan(rate[0, :]).all() + assert np.isnan(rate[-1, :]).all() + assert np.isnan(rate[midpoint]).all() + + +@pytest.mark.unittest_jit +def test_from_imod5_cap_data__regrid(imod5_dataset): + # Arrange + data = deepcopy(imod5_dataset[0]) + target_discretization = StructuredDiscretization.from_imod5_data(data) + data["extra"] = {"paths": ["path1", "path2"]} + data["cap"] = {} + msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) + data["cap"]["boundary"] = msw_bound + data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 + data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 + # Setup template grid + dx_small, xmin, xmax, dy_small, ymin, ymax = imod.util.spatial_reference(msw_bound) + dx = dx_small * 2 + dy = dy_small * 2 + expected_spatial_ref = dx, xmin, xmax, dy, ymin, ymax + like = imod.util.empty_2d(*expected_spatial_ref) + # Act + rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) + rch_coarse = rch.regrid_like(like, regrid_cache=RegridderWeightsCache()) + # Assert + actual_spatial_ref = imod.util.spatial_reference(rch_coarse.dataset["rate"]) + assert actual_spatial_ref == expected_spatial_ref + + +@pytest.mark.unittest_jit +def test_from_imod5_cap_data__clip_box(imod5_dataset): + # Arrange + data = deepcopy(imod5_dataset[0]) + target_discretization = StructuredDiscretization.from_imod5_data(data) + data["extra"] = {"paths": ["path1", "path2"]} + data["cap"] = {} + msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) + data["cap"]["boundary"] = msw_bound + data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 + data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 + # Setup template grid + dx, xmin, xmax, dy, ymin, ymax = imod.util.spatial_reference(msw_bound) + xmin_to_clip = xmin + 10 * dx + expected_spatial_ref = dx, xmin_to_clip, xmax, dy, ymin, ymax + # Act + rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) + rch_clipped = rch.clip_box(x_min=xmin_to_clip) + # Assert + actual_spatial_ref = imod.util.spatial_reference(rch_clipped.dataset["rate"]) + assert actual_spatial_ref == expected_spatial_ref diff --git a/imod/tests/test_mf6/test_mf6_riv.py b/imod/tests/test_mf6/test_mf6_riv.py index 828d76e02..5ff5387f4 100644 --- a/imod/tests/test_mf6/test_mf6_riv.py +++ b/imod/tests/test_mf6/test_mf6_riv.py @@ -1,931 +1,996 @@ -import pathlib -import re -import tempfile -import textwrap -from copy import deepcopy -from datetime import datetime - -import numpy as np -import pytest -import xarray as xr -import xugrid as xu -from pytest_cases import parametrize_with_cases - -import imod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.write_context import WriteContext -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION -from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION -from imod.prepare.topsystem.default_allocation_methods import ( - SimulationAllocationOptions, - SimulationDistributingOptions, -) -from imod.schemata import ValidationError -from imod.typing.grid import ( - enforce_dim_order, - has_negative_layer, - is_planar_grid, - ones_like, - zeros_like, -) - -TYPE_DIS_PKG = { - xu.UgridDataArray: VerticesDiscretization, - xr.DataArray: StructuredDiscretization, -} - - -def make_da(): - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - layer = [2, 3] - dx = 10.0 - dy = -10.0 - - return xr.DataArray( - data=np.ones((2, 3, 3), dtype=float), - dims=("layer", "y", "x"), - coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - ) - - -def dis_dict(): - da = make_da() - bottom = da - xr.DataArray( - data=[1.5, 2.5], dims=("layer",), coords={"layer": [2, 3]} - ) - - return {"idomain": da.astype(int), "top": da.sel(layer=2), "bottom": bottom} - - -def riv_dict(): - da = make_da() - da[:, 1, 1] = np.nan - - bottom = da - xr.DataArray( - data=[1.0, 2.0], dims=("layer",), coords={"layer": [2, 3]} - ) - - return {"stage": da, "conductance": da.copy(), "bottom_elevation": bottom} - - -def make_dict_unstructured(d): - return {key: xu.UgridDataArray.from_structured2d(value) for key, value in d.items()} - - -class RivCases: - def case_structured(self): - return riv_dict() - - def case_unstructured(self): - return make_dict_unstructured(riv_dict()) - - -class DisCases: - def case_structured(self): - return dis_dict() - - def case_unstructured(self): - return make_dict_unstructured(dis_dict()) - - -class RivDisCases: - def case_structured(self): - return riv_dict(), dis_dict() - - def case_unstructured(self): - return make_dict_unstructured(riv_dict()), make_dict_unstructured(dis_dict()) - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_render(riv_data): - river = imod.mf6.River(**riv_data) - directory = pathlib.Path("mymodel") - globaltimes = [np.datetime64("2000-01-01")] - actual = river._render(directory, "river", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 16 - end dimensions - - begin period 1 - open/close mymodel/river/riv.bin (binary) - end period - """ - ) - assert actual == expected - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_render_repeat_stress(riv_data): - """ - Test that rendering a river with a repeated stress period does not raise an error. - """ - globaltimes = [ - np.datetime64("2000-04-01"), - np.datetime64("2000-10-01"), - np.datetime64("2001-04-01"), - np.datetime64("2001-10-01"), - ] - - seasonal_factors = [0.8, 1.2] - seasonal_da = xr.DataArray( - seasonal_factors, dims=["time"], coords={"time": globaltimes[:2]} - ) - - riv_data["stage"] = enforce_dim_order(riv_data["stage"] * seasonal_da) - riv_data["conductance"] = enforce_dim_order(riv_data["conductance"] * seasonal_da) - riv_data["bottom_elevation"] = enforce_dim_order( - riv_data["bottom_elevation"] * seasonal_da - ) - repeat_stress = { - globaltimes[2]: globaltimes[0], - globaltimes[3]: globaltimes[1], - } - river = imod.mf6.River(repeat_stress=repeat_stress, **riv_data) - directory = pathlib.Path("mymodel") - actual = river._render(directory, "river", globaltimes, True) - - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 16 - end dimensions - - begin period 1 - open/close mymodel/river/riv-0.bin (binary) - end period - begin period 2 - open/close mymodel/river/riv-1.bin (binary) - end period - begin period 3 - open/close mymodel/river/riv-0.bin (binary) - end period - begin period 4 - open/close mymodel/river/riv-1.bin (binary) - end period - """ - ) - assert actual == expected - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_wrong_dtype(riv_data): - riv_data["stage"] = riv_data["stage"].astype(int) - - with pytest.raises(ValidationError): - imod.mf6.River(**riv_data) - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_all_nan(riv_data, dis_data): - # Use where to set everything to np.nan - for var in ["stage", "conductance", "bottom_elevation"]: - riv_data[var] = riv_data[var].where(False) - - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 1 - assert "stage" in errors.keys() - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_validate_inconsistent_nan(riv_data, dis_data): - riv_data["stage"][..., 2] = np.nan - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 2 - assert "bottom_elevation" in errors.keys() - assert "conductance" in errors.keys() - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_cleanup_inconsistent_nan(riv_data, dis_data): - riv_data["stage"][..., 2] = np.nan - river = imod.mf6.River(**riv_data) - type_grid = type(riv_data["stage"]) - dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) - - river.cleanup(dis_pkg) - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 0 - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_layer_as_coord_in_active_cells(riv_data, dis_data): - # Test if no bugs like https://github.com/Deltares/imod-python/issues/830 - river = imod.mf6.River(**riv_data) - river.dataset = river.dataset.sel(layer=2, drop=False) - - dis_data["idomain"][1, ...] = 0 - - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 0 - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_layer_as_coord_in_inactive_cells(riv_data, dis_data): - river = imod.mf6.River(**riv_data) - river.dataset = river.dataset.sel(layer=2, drop=False) - - dis_data["idomain"][0, ...] = 0 - - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 1 - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_check_layer(riv_data): - """ - Test for error thrown if variable has no layer coord - """ - riv_data["stage"] = riv_data["stage"].sel(layer=2, drop=True) - - message = textwrap.dedent( - """ - - stage - - coords has missing keys: {'layer'}""" - ) - - with pytest.raises( - ValidationError, - match=re.escape(message), - ): - imod.mf6.River(**riv_data) - - -def test_check_dimsize_zero(): - """ - Test that error is thrown for layer dim size 0. - """ - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - dx = 10.0 - dy = -10.0 - - da = xr.DataArray( - data=np.ones((0, 3, 3), dtype=float), - dims=("layer", "y", "x"), - coords={"layer": [], "y": y, "x": x, "dx": dx, "dy": dy}, - ) - - da[:, 1, 1] = np.nan - - message = textwrap.dedent( - """ - - stage - - provided dimension layer with size 0 - - conductance - - provided dimension layer with size 0 - - bottom_elevation - - provided dimension layer with size 0""" - ) - - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.River(stage=da, conductance=da, bottom_elevation=da - 1.0) - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_validate_zero_conductance(riv_data, dis_data): - """ - Test for validation zero conductance - """ - riv_data["conductance"][..., 2] = 0.0 - - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 1 - for var, var_errors in errors.items(): - assert var == "conductance" - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_cleanup_zero_conductance(riv_data, dis_data): - """ - Cleanup zero conductance - """ - riv_data["conductance"][..., 2] = 0.0 - type_grid = type(riv_data["stage"]) - dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) - - river = imod.mf6.River(**riv_data) - river.cleanup(dis_pkg) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - assert len(errors) == 0 - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_validate_bottom_above_stage(riv_data, dis_data): - """ - Validate that river bottom is not above stage. - """ - - riv_data["bottom_elevation"] = riv_data["bottom_elevation"] + 10.0 - - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 1 - assert "stage" in errors.keys() - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_cleanup_bottom_above_stage(riv_data, dis_data): - """ - Cleanup river bottom above stage. - """ - - riv_data["bottom_elevation"] = riv_data["bottom_elevation"] + 10.0 - type_grid = type(riv_data["stage"]) - dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) - - river = imod.mf6.River(**riv_data) - river.cleanup(dis_pkg) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 0 - assert river.dataset["bottom_elevation"].equals(river.dataset["stage"]) - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_check_riv_bottom_above_dis_bottom(riv_data, dis_data): - """ - Check that river bottom not above dis bottom. - """ - - river = imod.mf6.River(**riv_data) - # Verify no errors initially - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - assert len(errors) == 0 - - # Adapt dis bottom to be above river bottom - dis_data["bottom"] += 2.0 - - # Should not error if icelltype <= 0 - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - assert len(errors) == 0 - # Error if icelltype > 0 - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 1 - for var, var_errors in errors.items(): - assert var == "bottom_elevation" - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_check_boundary_outside_active_domain(riv_data, dis_data): - """ - Check that river not outside idomain - """ - - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 0 - - dis_data["idomain"][..., 0] = 0 - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 1 - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_aggregate_layers(riv_data): - river = imod.mf6.River(**riv_data) - - expected_type = ( - xu.UgridDataArray - if isinstance(river.dataset, xu.UgridDataset) - else xr.DataArray - ) - - planar_dict = river.aggregate_layers(river.dataset) - assert isinstance(planar_dict, dict) - for value in planar_dict.values(): - assert isinstance(value, expected_type) - assert "layer" not in value.dims - assert "layer" not in value.coords - - # Conductance should be summed, stage averaged - assert not (planar_dict["stage"] > planar_dict["conductance"]).any() - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_reallocate(riv_data, dis_data): - # Arrange - river = imod.mf6.River(**riv_data) - is_unstructured = isinstance(riv_data["stage"], xu.UgridDataArray) - dis_pkg_type = ( - imod.mf6.VerticesDiscretization - if is_unstructured - else imod.mf6.StructuredDiscretization - ) - dis = dis_pkg_type(**dis_data) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - allocation_option = ALLOCATION_OPTION.stage_to_riv_bot - distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity - # Act - river_reallocated = river.reallocate( - dis, npf, allocation_option, distributing_option - ) - # Assert - assert isinstance(river_reallocated, imod.mf6.River) - assert not river_reallocated.dataset.equals(river.dataset) - assert ( - river_reallocated["conductance"] - .sum("layer") - .equals(river["conductance"].sum("layer")) - ) - assert river_reallocated["stage"].mean("layer").equals(river["stage"].mean("layer")) - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_reallocate__wrong_allocation_option(riv_data, dis_data): - # Arrange - river = imod.mf6.River(**riv_data) - is_unstructured = isinstance(riv_data["stage"], xu.UgridDataArray) - dis_pkg_type = ( - imod.mf6.VerticesDiscretization - if is_unstructured - else imod.mf6.StructuredDiscretization - ) - dis = dis_pkg_type(**dis_data) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - allocation_option = ( - ALLOCATION_OPTION.stage_to_riv_bot_drn_above - ) # unsupported option - distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity - # Act - with pytest.raises( - ValueError, - match="Allocation option ALLOCATION_OPTION.stage_to_riv_bot_drn_above", - ): - river.reallocate(dis, npf, allocation_option, distributing_option) - - -def test_check_dim_monotonicity(): - """ - Test if dimensions are monotonically increasing or, in case of the y coord, - decreasing - """ - riv_ds = xr.merge([riv_dict()]) - - message = textwrap.dedent( - """ - - stage - - coord y which is not monotonically decreasing - - conductance - - coord y which is not monotonically decreasing - - bottom_elevation - - coord y which is not monotonically decreasing""" - ) - - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.River(**riv_ds.sel(y=slice(None, None, -1))) - - message = textwrap.dedent( - """ - - stage - - coord x which is not monotonically increasing - - conductance - - coord x which is not monotonically increasing - - bottom_elevation - - coord x which is not monotonically increasing""" - ) - - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.River(**riv_ds.sel(x=slice(None, None, -1))) - - message = textwrap.dedent( - """ - - stage - - coord layer which is not monotonically increasing - - conductance - - coord layer which is not monotonically increasing - - bottom_elevation - - coord layer which is not monotonically increasing""" - ) - - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.River(**riv_ds.sel(layer=slice(None, None, -1))) - - -def test_validate_false(): - """ - Test turning off validation - """ - - riv_ds = xr.merge([riv_dict()]) - - imod.mf6.River(validate=False, **riv_ds.sel(layer=slice(None, None, -1))) - - -def test_render_concentration(concentration_fc): - riv_ds = xr.merge([riv_dict()]) - - concentration = concentration_fc.sel( - layer=[2, 3], time=np.datetime64("2000-01-01"), drop=True - ) - riv_ds["concentration"] = concentration.where(~np.isnan(riv_ds["stage"])) - - directory = pathlib.Path("mymodel") - globaltimes = [np.datetime64("2000-01-01")] - - riv = imod.mf6.River(concentration_boundary_type="AUX", **riv_ds) - actual = riv._render(directory, "riv", globaltimes, False) - - expected = textwrap.dedent( - """\ - begin options - auxiliary salinity temperature - end options - - begin dimensions - maxbound 16 - end dimensions - - begin period 1 - open/close mymodel/riv/riv.dat - end period - """ - ) - assert actual == expected - - -def test_write_concentration_period_data(concentration_fc): - globaltimes = [np.datetime64("2000-01-01")] - concentration_fc[:] = 2 - stage = xr.full_like(concentration_fc.sel({"species": "salinity"}), 13) - conductance = xr.full_like(stage, 13) - bottom_elevation = xr.full_like(stage, 13) - riv = imod.mf6.River( - stage=stage, - conductance=conductance, - bottom_elevation=bottom_elevation, - concentration=concentration_fc, - concentration_boundary_type="AUX", - ) - with tempfile.TemporaryDirectory() as output_dir: - write_context = WriteContext(simulation_directory=output_dir) - riv._write("riv", globaltimes, write_context) - with open(output_dir + "/riv/riv-0.dat", "r") as f: - data = f.read() - assert ( - data.count("2") == 1755 - ) # the number 2 is in the concentration data, and in the cell indices. - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5(imod5_dataset, tmp_path): - imod5_data = imod5_dataset[0] - period_data = imod5_dataset[1] - globaltimes = [np.datetime64("2000-01-01")] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=SimulationAllocationOptions.riv, - distributing_option=SimulationDistributingOptions.riv, - regridder_types=None, - ) - - write_context = WriteContext(simulation_directory=tmp_path) - riv._write("riv", globaltimes, write_context) - drn._write("drn", globaltimes, write_context) - - # set icelltype=1.0 to enforce checking river bottom above dis bottom - errors = riv._validate( - imod.mf6.River._write_schemata, - icelltype=1.0, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - ) - assert len(errors) == 0 - - errors = drn._validate( - imod.mf6.Drainage._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - ) - assert len(errors) == 0 - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__negative_layer(imod5_dataset, tmp_path): - # Arrange - imod5_data = imod5_dataset[0] - period_data = imod5_dataset[1] - globaltimes = [np.datetime64("2000-01-01")] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - # Gather reference packages (for negative layers, allocation option - # "at_first_active" should be taken) - (riv_reference, drn_reference) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_first_active, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - # Set layer to -1 - original_riv_1 = deepcopy(imod5_data["riv-1"]) - imod5_data["riv-1"] = { - key: da.assign_coords(layer=[-1]) for key, da in imod5_data["riv-1"].items() - } - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - write_context = WriteContext(simulation_directory=tmp_path) - riv._write("riv", globaltimes, write_context) - drn._write("drn", globaltimes, write_context) - - # Assert - # Test if arrangement is correctly set up - assert is_planar_grid(imod5_data["riv-1"]["conductance"]) - assert has_negative_layer(imod5_data["riv-1"]["conductance"]) - - errors = riv._validate( - imod.mf6.River._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - icelltype=1.0, - ) - assert len(errors) == 0 - errors = drn._validate( - imod.mf6.Drainage._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - ) - assert len(errors) == 0 - - assert riv.dataset.identical(riv_reference.dataset) - assert drn.dataset.identical(drn_reference.dataset) - - # teardown - imod5_data["riv-1"] = original_riv_1 - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__infiltration_factors(imod5_dataset): - imod5_data = imod5_dataset[0] - period_data = imod5_dataset[1] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] - imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - assert riv is not None - assert drn is None - - imod5_data["riv-1"]["infiltration_factor"] = zeros_like( - original_infiltration_factor - ) - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - assert riv is None - assert drn is not None - - # teardown - imod5_data["riv-1"]["infiltration_factor"] = original_infiltration_factor - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__constant(imod5_dataset): - """Test importing river with a constant infiltration factor.""" - imod5_data = imod5_dataset[0] - period_data = imod5_dataset[1] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] - layer = original_infiltration_factor.coords["layer"] - imod5_data["riv-1"]["infiltration_factor"] = xr.DataArray( - [1.0], coords={"layer": layer}, dims=("layer",) - ) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - assert riv is not None - assert drn is None - - # teardown - imod5_data["riv-1"]["infiltration_factor"] = original_infiltration_factor - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__period_data(imod5_dataset_periods, tmp_path): - imod5_data = imod5_dataset_periods[0] - imod5_periods = imod5_dataset_periods[1] - globaltimes = [np.datetime64("2000-01-01"), np.datetime64("2001-01-01")] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] - imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - imod5_periods, - target_dis, - target_npf, - datetime(2002, 2, 2), - datetime(2022, 2, 2), - ALLOCATION_OPTION.stage_to_riv_bot_drn_above, - SimulationDistributingOptions.riv, - regridder_types=None, - ) - - assert riv is not None - assert drn is not None - - errors = riv._validate( - imod.mf6.River._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - icelltype=1.0, - ) - assert len(errors) == 0 - - errors = drn._validate( - imod.mf6.Drainage._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - ) - assert len(errors) == 0 - - riv_time = riv.dataset.coords["time"].data - drn_time = drn.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(riv_time, expected_times) - np.testing.assert_array_equal(drn_time, expected_times) - - riv_repeat_stress = riv.dataset["repeat_stress"].data - drn_repeat_stress = drn.dataset["repeat_stress"].data - assert np.all(riv_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(riv_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - assert np.all(drn_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(drn_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - write_context = WriteContext(simulation_directory=tmp_path) - riv._write("riv", globaltimes, write_context) - drn._write("drn", globaltimes, write_context) - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5_and_cleanup__period_data(imod5_dataset_periods): - imod5_data = imod5_dataset_periods[0] - imod5_periods = imod5_dataset_periods[1] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - imod5_periods, - target_dis, - target_npf, - datetime(2002, 2, 2), - datetime(2022, 2, 2), - ALLOCATION_OPTION.stage_to_riv_bot_drn_above, - SimulationDistributingOptions.riv, - regridder_types=None, - ) - - riv.cleanup(target_dis) - drn.cleanup(target_dis) - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__transient_data(imod5_dataset_transient): - """ - Test if importing a river from an IMOD5 dataset with transient data works - correctly and that the time data is clipped to the specified time range. - """ - imod5_data = imod5_dataset_transient[0] - imod5_periods = imod5_dataset_transient[1] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] - imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - imod5_periods, - target_dis, - target_npf, - datetime(2000, 4, 1), - datetime(2010, 1, 1), - ALLOCATION_OPTION.stage_to_riv_bot_drn_above, - SimulationDistributingOptions.riv, - regridder_types=None, - ) - - assert riv is not None - assert drn is not None - - riv_time = riv.dataset.coords["time"].data - drn_time = drn.dataset.coords["time"].data - assert riv_time[0] == np.datetime64("2000-04-01") - assert riv_time[-1] == np.datetime64("2003-01-01") - assert drn_time[0] == np.datetime64("2000-04-01") - assert drn_time[-1] == np.datetime64("2003-01-01") +import pathlib +import re +import tempfile +import textwrap +from copy import deepcopy +from datetime import datetime + +import numpy as np +import pytest +import xarray as xr +import xugrid as xu +from pytest_cases import parametrize_with_cases + +import imod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.write_context import WriteContext +from imod.prepare.topsystem.allocation import ALLOCATION_OPTION +from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION +from imod.prepare.topsystem.default_allocation_methods import ( + SimulationAllocationOptions, + SimulationDistributingOptions, +) +from imod.schemata import ValidationError +from imod.typing.grid import ( + enforce_dim_order, + has_negative_layer, + is_planar_grid, + ones_like, + zeros_like, +) + +TYPE_DIS_PKG = { + xu.UgridDataArray: VerticesDiscretization, + xr.DataArray: StructuredDiscretization, +} + + +def make_da(): + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + layer = [2, 3] + dx = 10.0 + dy = -10.0 + + return xr.DataArray( + data=np.ones((2, 3, 3), dtype=float), + dims=("layer", "y", "x"), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + ) + + +def dis_dict(): + da = make_da() + bottom = da - xr.DataArray( + data=[1.5, 2.5], dims=("layer",), coords={"layer": [2, 3]} + ) + + return {"idomain": da.astype(int), "top": da.sel(layer=2), "bottom": bottom} + + +def riv_dict(): + da = make_da() + da[:, 1, 1] = np.nan + + bottom = da - xr.DataArray( + data=[1.0, 2.0], dims=("layer",), coords={"layer": [2, 3]} + ) + + return {"stage": da, "conductance": da.copy(), "bottom_elevation": bottom} + + +def make_dict_unstructured(d): + return {key: xu.UgridDataArray.from_structured2d(value) for key, value in d.items()} + + +class RivCases: + def case_structured(self): + return riv_dict() + + def case_unstructured(self): + return make_dict_unstructured(riv_dict()) + + +class DisCases: + def case_structured(self): + return dis_dict() + + def case_unstructured(self): + return make_dict_unstructured(dis_dict()) + + +class RivDisCases: + def case_structured(self): + return riv_dict(), dis_dict() + + def case_unstructured(self): + return make_dict_unstructured(riv_dict()), make_dict_unstructured(dis_dict()) + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_render(riv_data): + river = imod.mf6.River(**riv_data) + directory = pathlib.Path("mymodel") + globaltimes = [np.datetime64("2000-01-01")] + actual = river._render(directory, "river", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 16 + end dimensions + + begin period 1 + open/close mymodel/river/riv.bin (binary) + end period + """ + ) + assert actual == expected + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_render_repeat_stress(riv_data): + """ + Test that rendering a river with a repeated stress period does not raise an error. + """ + globaltimes = [ + np.datetime64("2000-04-01"), + np.datetime64("2000-10-01"), + np.datetime64("2001-04-01"), + np.datetime64("2001-10-01"), + ] + + seasonal_factors = [0.8, 1.2] + seasonal_da = xr.DataArray( + seasonal_factors, dims=["time"], coords={"time": globaltimes[:2]} + ) + + riv_data["stage"] = enforce_dim_order(riv_data["stage"] * seasonal_da) + riv_data["conductance"] = enforce_dim_order(riv_data["conductance"] * seasonal_da) + riv_data["bottom_elevation"] = enforce_dim_order( + riv_data["bottom_elevation"] * seasonal_da + ) + repeat_stress = { + globaltimes[2]: globaltimes[0], + globaltimes[3]: globaltimes[1], + } + river = imod.mf6.River(repeat_stress=repeat_stress, **riv_data) + directory = pathlib.Path("mymodel") + actual = river._render(directory, "river", globaltimes, True) + + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 16 + end dimensions + + begin period 1 + open/close mymodel/river/riv-0.bin (binary) + end period + begin period 2 + open/close mymodel/river/riv-1.bin (binary) + end period + begin period 3 + open/close mymodel/river/riv-0.bin (binary) + end period + begin period 4 + open/close mymodel/river/riv-1.bin (binary) + end period + """ + ) + assert actual == expected + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_wrong_dtype(riv_data): + riv_data["stage"] = riv_data["stage"].astype(int) + + with pytest.raises(ValidationError): + imod.mf6.River(**riv_data) + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_all_nan(riv_data, dis_data): + # Use where to set everything to np.nan + for var in ["stage", "conductance", "bottom_elevation"]: + riv_data[var] = riv_data[var].where(False) + + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 1 + assert "stage" in errors.keys() + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_validate_inconsistent_nan(riv_data, dis_data): + riv_data["stage"][..., 2] = np.nan + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 2 + assert "bottom_elevation" in errors.keys() + assert "conductance" in errors.keys() + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_cleanup_inconsistent_nan(riv_data, dis_data): + riv_data["stage"][..., 2] = np.nan + river = imod.mf6.River(**riv_data) + type_grid = type(riv_data["stage"]) + dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) + + river.cleanup(dis_pkg) + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 0 + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_layer_as_coord_in_active_cells(riv_data, dis_data): + # Test if no bugs like https://github.com/Deltares/imod-python/issues/830 + river = imod.mf6.River(**riv_data) + river.dataset = river.dataset.sel(layer=2, drop=False) + + dis_data["idomain"][1, ...] = 0 + + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 0 + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_layer_as_coord_in_inactive_cells(riv_data, dis_data): + river = imod.mf6.River(**riv_data) + river.dataset = river.dataset.sel(layer=2, drop=False) + + dis_data["idomain"][0, ...] = 0 + + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 1 + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_check_layer(riv_data): + """ + Test for error thrown if variable has no layer coord + """ + riv_data["stage"] = riv_data["stage"].sel(layer=2, drop=True) + + message = textwrap.dedent( + """ + - stage + - coords has missing keys: {'layer'}""" + ) + + with pytest.raises( + ValidationError, + match=re.escape(message), + ): + imod.mf6.River(**riv_data) + + +def test_check_dimsize_zero(): + """ + Test that error is thrown for layer dim size 0. + """ + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + dx = 10.0 + dy = -10.0 + + da = xr.DataArray( + data=np.ones((0, 3, 3), dtype=float), + dims=("layer", "y", "x"), + coords={"layer": [], "y": y, "x": x, "dx": dx, "dy": dy}, + ) + + da[:, 1, 1] = np.nan + + message = textwrap.dedent( + """ + - stage + - provided dimension layer with size 0 + - conductance + - provided dimension layer with size 0 + - bottom_elevation + - provided dimension layer with size 0""" + ) + + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.River(stage=da, conductance=da, bottom_elevation=da - 1.0) + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_validate_zero_conductance(riv_data, dis_data): + """ + Test for validation zero conductance + """ + riv_data["conductance"][..., 2] = 0.0 + + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 1 + for var, var_errors in errors.items(): + assert var == "conductance" + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_cleanup_zero_conductance(riv_data, dis_data): + """ + Cleanup zero conductance + """ + riv_data["conductance"][..., 2] = 0.0 + type_grid = type(riv_data["stage"]) + dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) + + river = imod.mf6.River(**riv_data) + river.cleanup(dis_pkg) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + assert len(errors) == 0 + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_validate_bottom_above_stage(riv_data, dis_data): + """ + Validate that river bottom is not above stage. + """ + + riv_data["bottom_elevation"] = riv_data["bottom_elevation"] + 10.0 + + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 1 + assert "stage" in errors.keys() + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_cleanup_bottom_above_stage(riv_data, dis_data): + """ + Cleanup river bottom above stage. + """ + + riv_data["bottom_elevation"] = riv_data["bottom_elevation"] + 10.0 + type_grid = type(riv_data["stage"]) + dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) + + river = imod.mf6.River(**riv_data) + river.cleanup(dis_pkg) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 0 + assert river.dataset["bottom_elevation"].equals(river.dataset["stage"]) + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_check_riv_bottom_above_dis_bottom(riv_data, dis_data): + """ + Check that river bottom not above dis bottom. + """ + + river = imod.mf6.River(**riv_data) + # Verify no errors initially + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + assert len(errors) == 0 + + # Adapt dis bottom to be above river bottom + dis_data["bottom"] += 2.0 + + # Should not error if icelltype <= 0 + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + assert len(errors) == 0 + # Error if icelltype > 0 + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 1 + for var, var_errors in errors.items(): + assert var == "bottom_elevation" + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_check_boundary_outside_active_domain(riv_data, dis_data): + """ + Check that river not outside idomain + """ + + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 0 + + dis_data["idomain"][..., 0] = 0 + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 1 + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_aggregate_layers(riv_data): + river = imod.mf6.River(**riv_data) + + expected_type = ( + xu.UgridDataArray + if isinstance(river.dataset, xu.UgridDataset) + else xr.DataArray + ) + + planar_dict = river.aggregate_layers(river.dataset) + assert isinstance(planar_dict, dict) + for value in planar_dict.values(): + assert isinstance(value, expected_type) + assert "layer" not in value.dims + assert "layer" not in value.coords + + # Conductance should be summed, stage averaged + assert not (planar_dict["stage"] > planar_dict["conductance"]).any() + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_reallocate(riv_data, dis_data): + # Arrange + river = imod.mf6.River(**riv_data) + is_unstructured = isinstance(riv_data["stage"], xu.UgridDataArray) + dis_pkg_type = ( + imod.mf6.VerticesDiscretization + if is_unstructured + else imod.mf6.StructuredDiscretization + ) + dis = dis_pkg_type(**dis_data) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ALLOCATION_OPTION.stage_to_riv_bot + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + # Act + river_reallocated = river.reallocate( + dis, npf, allocation_option, distributing_option + ) + # Assert + assert isinstance(river_reallocated, imod.mf6.River) + assert not river_reallocated.dataset.equals(river.dataset) + assert ( + river_reallocated["conductance"] + .sum("layer") + .equals(river["conductance"].sum("layer")) + ) + assert river_reallocated["stage"].mean("layer").equals(river["stage"].mean("layer")) + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_reallocate__wrong_allocation_option(riv_data, dis_data): + # Arrange + river = imod.mf6.River(**riv_data) + is_unstructured = isinstance(riv_data["stage"], xu.UgridDataArray) + dis_pkg_type = ( + imod.mf6.VerticesDiscretization + if is_unstructured + else imod.mf6.StructuredDiscretization + ) + dis = dis_pkg_type(**dis_data) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ( + ALLOCATION_OPTION.stage_to_riv_bot_drn_above + ) # unsupported option + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + # Act + with pytest.raises( + ValueError, + match="Allocation option ALLOCATION_OPTION.stage_to_riv_bot_drn_above", + ): + river.reallocate(dis, npf, allocation_option, distributing_option) + + +def test_reallocate_drop_empty_layers(): + """ + drop_empty_layers=True should trim layers off the final package without + changing the values of the layers that remain (Option A: allocation and + conductance distribution always run over the full layer range first). + """ + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + dx, dy = 10.0, -10.0 + layer = [1, 2, 3, 4] + + top = xr.DataArray(0.0, coords={"y": y, "x": x, "dx": dx, "dy": dy}, dims=("y", "x")) + bottom = xr.DataArray( + np.array([-1.0, -2.0, -3.0, -4.0])[:, None, None] * np.ones((4, 3, 3)), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + dims=("layer", "y", "x"), + ) + idomain = xr.DataArray( + np.ones((4, 3, 3), dtype=int), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + dims=("layer", "y", "x"), + ) + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + + # Stage and bottom entirely confined to layer 2 (-1.0 to -2.0). + planar_coords = {"y": y, "x": x, "dx": dx, "dy": dy} + river = imod.mf6.River( + stage=xr.DataArray(-1.1, coords=planar_coords, dims=("y", "x")).expand_dims( + layer=[1] + ), + conductance=xr.DataArray( + 10.0, coords=planar_coords, dims=("y", "x") + ).expand_dims(layer=[1]), + bottom_elevation=xr.DataArray( + -1.9, coords=planar_coords, dims=("y", "x") + ).expand_dims(layer=[1]), + ) + + allocation_option = ALLOCATION_OPTION.stage_to_riv_bot + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + + full = river.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=False + ) + trimmed = river.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=True + ) + + full_layers = full.dataset["layer"].values + trimmed_layers = trimmed.dataset["layer"].values + assert set(trimmed_layers) <= set(full_layers) + assert len(trimmed_layers) < len(full_layers) + np.testing.assert_allclose( + trimmed["conductance"].sel(layer=trimmed_layers).values, + full["conductance"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + np.testing.assert_allclose( + trimmed["stage"].sel(layer=trimmed_layers).values, + full["stage"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + + +def test_check_dim_monotonicity(): + """ + Test if dimensions are monotonically increasing or, in case of the y coord, + decreasing + """ + riv_ds = xr.merge([riv_dict()]) + + message = textwrap.dedent( + """ + - stage + - coord y which is not monotonically decreasing + - conductance + - coord y which is not monotonically decreasing + - bottom_elevation + - coord y which is not monotonically decreasing""" + ) + + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.River(**riv_ds.sel(y=slice(None, None, -1))) + + message = textwrap.dedent( + """ + - stage + - coord x which is not monotonically increasing + - conductance + - coord x which is not monotonically increasing + - bottom_elevation + - coord x which is not monotonically increasing""" + ) + + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.River(**riv_ds.sel(x=slice(None, None, -1))) + + message = textwrap.dedent( + """ + - stage + - coord layer which is not monotonically increasing + - conductance + - coord layer which is not monotonically increasing + - bottom_elevation + - coord layer which is not monotonically increasing""" + ) + + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.River(**riv_ds.sel(layer=slice(None, None, -1))) + + +def test_validate_false(): + """ + Test turning off validation + """ + + riv_ds = xr.merge([riv_dict()]) + + imod.mf6.River(validate=False, **riv_ds.sel(layer=slice(None, None, -1))) + + +def test_render_concentration(concentration_fc): + riv_ds = xr.merge([riv_dict()]) + + concentration = concentration_fc.sel( + layer=[2, 3], time=np.datetime64("2000-01-01"), drop=True + ) + riv_ds["concentration"] = concentration.where(~np.isnan(riv_ds["stage"])) + + directory = pathlib.Path("mymodel") + globaltimes = [np.datetime64("2000-01-01")] + + riv = imod.mf6.River(concentration_boundary_type="AUX", **riv_ds) + actual = riv._render(directory, "riv", globaltimes, False) + + expected = textwrap.dedent( + """\ + begin options + auxiliary salinity temperature + end options + + begin dimensions + maxbound 16 + end dimensions + + begin period 1 + open/close mymodel/riv/riv.dat + end period + """ + ) + assert actual == expected + + +def test_write_concentration_period_data(concentration_fc): + globaltimes = [np.datetime64("2000-01-01")] + concentration_fc[:] = 2 + stage = xr.full_like(concentration_fc.sel({"species": "salinity"}), 13) + conductance = xr.full_like(stage, 13) + bottom_elevation = xr.full_like(stage, 13) + riv = imod.mf6.River( + stage=stage, + conductance=conductance, + bottom_elevation=bottom_elevation, + concentration=concentration_fc, + concentration_boundary_type="AUX", + ) + with tempfile.TemporaryDirectory() as output_dir: + write_context = WriteContext(simulation_directory=output_dir) + riv._write("riv", globaltimes, write_context) + with open(output_dir + "/riv/riv-0.dat", "r") as f: + data = f.read() + assert ( + data.count("2") == 1755 + ) # the number 2 is in the concentration data, and in the cell indices. + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5(imod5_dataset, tmp_path): + imod5_data = imod5_dataset[0] + period_data = imod5_dataset[1] + globaltimes = [np.datetime64("2000-01-01")] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=SimulationAllocationOptions.riv, + distributing_option=SimulationDistributingOptions.riv, + regridder_types=None, + ) + + write_context = WriteContext(simulation_directory=tmp_path) + riv._write("riv", globaltimes, write_context) + drn._write("drn", globaltimes, write_context) + + # set icelltype=1.0 to enforce checking river bottom above dis bottom + errors = riv._validate( + imod.mf6.River._write_schemata, + icelltype=1.0, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + ) + assert len(errors) == 0 + + errors = drn._validate( + imod.mf6.Drainage._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + ) + assert len(errors) == 0 + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__negative_layer(imod5_dataset, tmp_path): + # Arrange + imod5_data = imod5_dataset[0] + period_data = imod5_dataset[1] + globaltimes = [np.datetime64("2000-01-01")] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + # Gather reference packages (for negative layers, allocation option + # "at_first_active" should be taken) + (riv_reference, drn_reference) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_first_active, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + # Set layer to -1 + original_riv_1 = deepcopy(imod5_data["riv-1"]) + imod5_data["riv-1"] = { + key: da.assign_coords(layer=[-1]) for key, da in imod5_data["riv-1"].items() + } + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + write_context = WriteContext(simulation_directory=tmp_path) + riv._write("riv", globaltimes, write_context) + drn._write("drn", globaltimes, write_context) + + # Assert + # Test if arrangement is correctly set up + assert is_planar_grid(imod5_data["riv-1"]["conductance"]) + assert has_negative_layer(imod5_data["riv-1"]["conductance"]) + + errors = riv._validate( + imod.mf6.River._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + icelltype=1.0, + ) + assert len(errors) == 0 + errors = drn._validate( + imod.mf6.Drainage._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + ) + assert len(errors) == 0 + + assert riv.dataset.identical(riv_reference.dataset) + assert drn.dataset.identical(drn_reference.dataset) + + # teardown + imod5_data["riv-1"] = original_riv_1 + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__infiltration_factors(imod5_dataset): + imod5_data = imod5_dataset[0] + period_data = imod5_dataset[1] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] + imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + assert riv is not None + assert drn is None + + imod5_data["riv-1"]["infiltration_factor"] = zeros_like( + original_infiltration_factor + ) + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + assert riv is None + assert drn is not None + + # teardown + imod5_data["riv-1"]["infiltration_factor"] = original_infiltration_factor + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__constant(imod5_dataset): + """Test importing river with a constant infiltration factor.""" + imod5_data = imod5_dataset[0] + period_data = imod5_dataset[1] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] + layer = original_infiltration_factor.coords["layer"] + imod5_data["riv-1"]["infiltration_factor"] = xr.DataArray( + [1.0], coords={"layer": layer}, dims=("layer",) + ) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + assert riv is not None + assert drn is None + + # teardown + imod5_data["riv-1"]["infiltration_factor"] = original_infiltration_factor + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__period_data(imod5_dataset_periods, tmp_path): + imod5_data = imod5_dataset_periods[0] + imod5_periods = imod5_dataset_periods[1] + globaltimes = [np.datetime64("2000-01-01"), np.datetime64("2001-01-01")] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] + imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + imod5_periods, + target_dis, + target_npf, + datetime(2002, 2, 2), + datetime(2022, 2, 2), + ALLOCATION_OPTION.stage_to_riv_bot_drn_above, + SimulationDistributingOptions.riv, + regridder_types=None, + ) + + assert riv is not None + assert drn is not None + + errors = riv._validate( + imod.mf6.River._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + icelltype=1.0, + ) + assert len(errors) == 0 + + errors = drn._validate( + imod.mf6.Drainage._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + ) + assert len(errors) == 0 + + riv_time = riv.dataset.coords["time"].data + drn_time = drn.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(riv_time, expected_times) + np.testing.assert_array_equal(drn_time, expected_times) + + riv_repeat_stress = riv.dataset["repeat_stress"].data + drn_repeat_stress = drn.dataset["repeat_stress"].data + assert np.all(riv_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(riv_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + assert np.all(drn_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(drn_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + write_context = WriteContext(simulation_directory=tmp_path) + riv._write("riv", globaltimes, write_context) + drn._write("drn", globaltimes, write_context) + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5_and_cleanup__period_data(imod5_dataset_periods): + imod5_data = imod5_dataset_periods[0] + imod5_periods = imod5_dataset_periods[1] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + imod5_periods, + target_dis, + target_npf, + datetime(2002, 2, 2), + datetime(2022, 2, 2), + ALLOCATION_OPTION.stage_to_riv_bot_drn_above, + SimulationDistributingOptions.riv, + regridder_types=None, + ) + + riv.cleanup(target_dis) + drn.cleanup(target_dis) + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__transient_data(imod5_dataset_transient): + """ + Test if importing a river from an IMOD5 dataset with transient data works + correctly and that the time data is clipped to the specified time range. + """ + imod5_data = imod5_dataset_transient[0] + imod5_periods = imod5_dataset_transient[1] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] + imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + imod5_periods, + target_dis, + target_npf, + datetime(2000, 4, 1), + datetime(2010, 1, 1), + ALLOCATION_OPTION.stage_to_riv_bot_drn_above, + SimulationDistributingOptions.riv, + regridder_types=None, + ) + + assert riv is not None + assert drn is not None + + riv_time = riv.dataset.coords["time"].data + drn_time = drn.dataset.coords["time"].data + assert riv_time[0] == np.datetime64("2000-04-01") + assert riv_time[-1] == np.datetime64("2003-01-01") + assert drn_time[0] == np.datetime64("2000-04-01") + assert drn_time[-1] == np.datetime64("2003-01-01") diff --git a/imod/tests/test_prepare/test_cleanup.py b/imod/tests/test_prepare/test_cleanup.py index 7e2de1929..d7755e872 100644 --- a/imod/tests/test_prepare/test_cleanup.py +++ b/imod/tests/test_prepare/test_cleanup.py @@ -1,341 +1,373 @@ -from typing import Callable - -import geopandas as gpd -import numpy as np -import pandas as pd -import pytest -import xugrid as xu -from pytest_cases import parametrize, parametrize_with_cases -from shapely import linestrings - -from imod.prepare.cleanup import ( - cleanup_drn, - cleanup_ghb, - cleanup_hfb, - cleanup_riv, - cleanup_wel, -) -from imod.tests.test_mf6.test_mf6_riv import DisCases, RivDisCases -from imod.typing import GridDataArray - - -def _first(grid: GridDataArray): - """ - helper function to get first value, regardless of unstructured or - structured grid.""" - return grid.values.ravel()[0] - - -def _first_index(grid: GridDataArray) -> tuple: - if isinstance(grid, xu.UgridDataArray): - return (0, 0) - else: - return (0, 0, 0) - - -def _rename_data_dict(data: dict, func: Callable): - renamed = data.copy() - to_rename = _RENAME_DICT[func] - for src, dst in to_rename.items(): - mv_data = renamed.pop(src) - if dst is not None: - renamed[dst] = mv_data - return renamed - - -def _prepare_dis_dict(dis_dict: dict, func: Callable): - """Keep required dis args for specific cleanup functions""" - keep_vars = _KEEP_FROM_DIS_DICT[func] - return {var: dis_dict[var] for var in keep_vars} - - -_RENAME_DICT = { - cleanup_riv: {}, - cleanup_drn: {"stage": "elevation", "bottom_elevation": None}, - cleanup_ghb: {"stage": "head", "bottom_elevation": None}, -} - -_KEEP_FROM_DIS_DICT = { - cleanup_riv: ["idomain", "bottom"], - cleanup_drn: ["idomain"], - cleanup_ghb: ["idomain"], - cleanup_wel: ["top", "bottom"], -} - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) -def test_cleanup__align_nodata(riv_data: dict, dis_data: dict, cleanup_func: Callable): - dis_dict = _prepare_dis_dict(dis_data, cleanup_func) - data_dict = _rename_data_dict(riv_data, cleanup_func) - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) - idx = _first_index(data_dict["conductance"]) - # Arrange: Deactivate one cell - first_key = next(iter(data_dict.keys())) - data_dict[first_key][idx] = np.nan - # Act - data_cleaned = cleanup_func(**dis_dict, **data_dict) - # Assert - for key in data_cleaned.keys(): - # Test if xu.UgridDataArray not demoted to xr.DataArray - assert type(data_cleaned[key]) is type(data_dict[key]) - np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) -def test_cleanup__zero_conductance( - riv_data: dict, dis_data: dict, cleanup_func: Callable -): - dis_dict = _prepare_dis_dict(dis_data, cleanup_func) - data_dict = _rename_data_dict(riv_data, cleanup_func) - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) - idx = _first_index(data_dict["conductance"]) - # Arrange: Deactivate one cell - data_dict["conductance"][idx] = 0.0 - # Act - data_cleaned = cleanup_func(**dis_dict, **data_dict) - # Assert - for key in data_cleaned.keys(): - np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) -def test_cleanup__negative_concentration( - riv_data: dict, dis_data: dict, cleanup_func: Callable -): - dis_dict = _prepare_dis_dict(dis_data, cleanup_func) - data_dict = _rename_data_dict(riv_data, cleanup_func) - first_key = next(iter(data_dict.keys())) - # Create concentration data - data_dict["concentration"] = data_dict[first_key].copy() - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) - idx = _first_index(data_dict["conductance"]) - # Arrange: Deactivate one cell - data_dict["concentration"][idx] = -10.0 - # Act - data_cleaned = cleanup_func(**dis_dict, **data_dict) - # Assert - np.testing.assert_equal(_first(data_cleaned["concentration"]), 0.0) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) -def test_cleanup__outside_active_domain( - riv_data: dict, dis_data: dict, cleanup_func: Callable -): - dis_dict = _prepare_dis_dict(dis_data, cleanup_func) - data_dict = _rename_data_dict(riv_data, cleanup_func) - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) - idx = _first_index(data_dict["conductance"]) - # Arrange: Deactivate one cell - dis_dict["idomain"][idx] = 0.0 - # Act - data_cleaned = cleanup_func(**dis_dict, **data_dict) - # Assert - for key in data_cleaned.keys(): - np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__fix_bottom_elevation_to_bottom(riv_data: dict, dis_data: dict): - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Arrange: Set bottom elevation model layer bottom - riv_data["bottom_elevation"] -= 3.0 - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) - # Act - riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) - # Assert - # Account for cells inactive river cells. - riv_active = riv_data_cleaned["stage"].notnull() - expected = dis_dict["bottom"].where(riv_active) - - np.testing.assert_equal( - riv_data_cleaned["bottom_elevation"].values, expected.values - ) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__fix_bottom_elevation_to_stage(riv_data: dict, dis_data: dict): - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Arrange: Set bottom elevation above stage - riv_data["bottom_elevation"] += 3.0 - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) - # Act - riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) - # Assert - np.testing.assert_equal( - riv_data_cleaned["bottom_elevation"].values, riv_data_cleaned["stage"].values - ) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__stage_equals_bottom_elevation(riv_data: dict, dis_data: dict): - """Assure no cleanup accidentily takes place when stage equals bottom_elevation""" - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Arrange: Set bottom elevation equal to stage - riv_data["bottom_elevation"] = riv_data["stage"].copy() - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) - # Act - riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) - # Assert - np.testing.assert_equal( - riv_data_cleaned["bottom_elevation"].values, riv_data_cleaned["stage"].values - ) - np.testing.assert_equal(riv_data["stage"].values, riv_data_cleaned["stage"].values) - np.testing.assert_equal( - riv_data["bottom_elevation"].values, riv_data_cleaned["bottom_elevation"].values - ) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__raise_error(riv_data: dict, dis_data: dict): - """ - Test if error raised when stage below model layer bottom and see if user is - guided to the right prepare function. - """ - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Arrange: Set bottom elevation above stage - riv_data["stage"] -= 10.0 - # Act - with pytest.raises(ValueError, match="imod.prepare.topsystem.allocate_riv_cells"): - cleanup_riv(**dis_dict, **riv_data) - - -@parametrize_with_cases("dis_data", cases=DisCases) -def test_cleanup_wel(dis_data: dict): - """ - Cleanup wells. - - Cases by id (on purpose not in order, to see if pandas' - sorting results in any issues): - - a: filter completely above surface level -> point filter in top layer - c: filter partly above surface level -> filter top set to surface level - b: filter completely below model base -> well should be removed - d: filter partly below model base -> filter bottom set to model base - f: well outside grid bounds -> well should be removed - e: utrathin filter -> filter should be forced to point filter - g: filter screen_bottom above screen_top -> filter should be forced to point filter - """ - # Arrange - dis_dict = _prepare_dis_dict(dis_data, cleanup_wel) - wel_dict = { - "id": ["a", "c", "b", "d", "f", "e", "g"], - "x": [17.0, 17.0, 17.0, 17.0, 40.0, 17.0, 17.0], - "y": [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0], - "screen_top": [ - 2.0, - 2.0, - -7.0, - -1.0, - -1.0, - 1e-3, - 0.0, - ], - "screen_bottom": [ - 1.5, - 0.0, - -8.0, - -8.0, - -1.0, - 0.0, - 0.5, - ], - } - well_df = pd.DataFrame(wel_dict) - wel_expected = { - "id": ["a", "c", "d", "e", "g"], - "x": [17.0, 17.0, 17.0, 17.0, 17.0], - "y": [15.0, 15.0, 15.0, 15.0, 15.0], - "screen_top": [ - 1.0, - 1.0, - -1.0, - 1e-3, - 0.0, - ], - "screen_bottom": [ - 1.0, - 0.0, - -1.5, - 1e-3, - 0.0, - ], - } - well_expected_df = pd.DataFrame(wel_expected).set_index("id") - # Act - well_cleaned = cleanup_wel(well_df, **dis_dict) - # Assert - pd.testing.assert_frame_equal(well_cleaned, well_expected_df) - - -@parametrize_with_cases("dis_data", cases=DisCases) -def test_cleanup_hfb__ymax_clipped(dis_data: dict): - # Arrange - barrier_y = [25.0, 15.0, -1.0] - barrier_x = [16.0, 16.0, 16.0] - - geometry = gpd.GeoDataFrame( - geometry=[linestrings(barrier_x, barrier_y)], - data={ - "resistance": [1200.0], - "layer": [1], - }, - ) - y_max = 20.0 - idomain = dis_data["idomain"] - if isinstance(idomain, xu.UgridDataArray): - above_y_max = idomain.ugrid.grid.face_y > y_max - idomain.loc[:, above_y_max] = 0 - else: - above_y_max = idomain.coords["y"] > y_max - idomain.loc[:, above_y_max, :] = 0 - - # Act - with pytest.raises(ValueError): - cleanup_hfb(geometry, idomain) - - clipped_geometry = cleanup_hfb(geometry, idomain.isel(layer=0)) - - # Assert - np.testing.assert_allclose(clipped_geometry.bounds.maxy, y_max) - - -@parametrize_with_cases("dis_data", cases=DisCases) -def test_cleanup_hfb__split_in_two(dis_data: dict): - """Deactivate middle cell, barrier should be split in two.""" - # Arrange - barrier_y = [25.0, 15.0, -1.0] - barrier_x = [16.0, 16.0, 16.0] - - geometry = gpd.GeoDataFrame( - geometry=[linestrings(barrier_x, barrier_y)], - data={ - "resistance": [1200.0], - "layer": [1], - }, - ) - idomain = dis_data["idomain"] - if isinstance(idomain, xu.UgridDataArray): - idomain.loc[:, 4] = 0 - else: - idomain.loc[:, 15.0, :] = 0 - - # Act - with pytest.raises(ValueError): - cleanup_hfb(geometry, idomain) - - clipped_geometry = cleanup_hfb(geometry, idomain.isel(layer=0)) - bounds = clipped_geometry.geometry.bounds - assert len(clipped_geometry) == 2 - np.testing.assert_allclose(bounds.miny.values, np.array([20.0, 0.0])) - np.testing.assert_allclose(bounds.maxy.values, np.array([25.0, 10.0])) +from typing import Callable + +import geopandas as gpd +import numpy as np +import pandas as pd +import pytest +import xugrid as xu +from pytest_cases import parametrize, parametrize_with_cases +from shapely import linestrings + +from imod.prepare.cleanup import ( + cleanup_drn, + cleanup_ghb, + cleanup_hfb, + cleanup_riv, + cleanup_wel, +) +from imod.tests.test_mf6.test_mf6_riv import DisCases, RivDisCases +from imod.typing import GridDataArray + + +def _first(grid: GridDataArray): + """ + helper function to get first value, regardless of unstructured or + structured grid.""" + return grid.values.ravel()[0] + + +def _first_index(grid: GridDataArray) -> tuple: + if isinstance(grid, xu.UgridDataArray): + return (0, 0) + else: + return (0, 0, 0) + + +def _rename_data_dict(data: dict, func: Callable): + renamed = data.copy() + to_rename = _RENAME_DICT[func] + for src, dst in to_rename.items(): + mv_data = renamed.pop(src) + if dst is not None: + renamed[dst] = mv_data + return renamed + + +def _prepare_dis_dict(dis_dict: dict, func: Callable): + """Keep required dis args for specific cleanup functions""" + keep_vars = _KEEP_FROM_DIS_DICT[func] + return {var: dis_dict[var] for var in keep_vars} + + +_RENAME_DICT = { + cleanup_riv: {}, + cleanup_drn: {"stage": "elevation", "bottom_elevation": None}, + cleanup_ghb: {"stage": "head", "bottom_elevation": None}, +} + +_KEEP_FROM_DIS_DICT = { + cleanup_riv: ["idomain", "bottom"], + cleanup_drn: ["idomain"], + cleanup_ghb: ["idomain"], + cleanup_wel: ["top", "bottom"], +} + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) +def test_cleanup__align_nodata(riv_data: dict, dis_data: dict, cleanup_func: Callable): + dis_dict = _prepare_dis_dict(dis_data, cleanup_func) + data_dict = _rename_data_dict(riv_data, cleanup_func) + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) + idx = _first_index(data_dict["conductance"]) + # Arrange: Deactivate one cell + first_key = next(iter(data_dict.keys())) + data_dict[first_key][idx] = np.nan + # Act + data_cleaned = cleanup_func(**dis_dict, **data_dict) + # Assert + for key in data_cleaned.keys(): + # Test if xu.UgridDataArray not demoted to xr.DataArray + assert type(data_cleaned[key]) is type(data_dict[key]) + np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) +def test_cleanup__zero_conductance( + riv_data: dict, dis_data: dict, cleanup_func: Callable +): + dis_dict = _prepare_dis_dict(dis_data, cleanup_func) + data_dict = _rename_data_dict(riv_data, cleanup_func) + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) + idx = _first_index(data_dict["conductance"]) + # Arrange: Deactivate one cell + data_dict["conductance"][idx] = 0.0 + # Act + data_cleaned = cleanup_func(**dis_dict, **data_dict) + # Assert + for key in data_cleaned.keys(): + np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) +def test_cleanup__negative_concentration( + riv_data: dict, dis_data: dict, cleanup_func: Callable +): + dis_dict = _prepare_dis_dict(dis_data, cleanup_func) + data_dict = _rename_data_dict(riv_data, cleanup_func) + first_key = next(iter(data_dict.keys())) + # Create concentration data + data_dict["concentration"] = data_dict[first_key].copy() + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) + idx = _first_index(data_dict["conductance"]) + # Arrange: Deactivate one cell + data_dict["concentration"][idx] = -10.0 + # Act + data_cleaned = cleanup_func(**dis_dict, **data_dict) + # Assert + np.testing.assert_equal(_first(data_cleaned["concentration"]), 0.0) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) +def test_cleanup__outside_active_domain( + riv_data: dict, dis_data: dict, cleanup_func: Callable +): + dis_dict = _prepare_dis_dict(dis_data, cleanup_func) + data_dict = _rename_data_dict(riv_data, cleanup_func) + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) + idx = _first_index(data_dict["conductance"]) + # Arrange: Deactivate one cell + dis_dict["idomain"][idx] = 0.0 + # Act + data_cleaned = cleanup_func(**dis_dict, **data_dict) + # Assert + for key in data_cleaned.keys(): + np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__fix_bottom_elevation_to_bottom(riv_data: dict, dis_data: dict): + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Arrange: Set bottom elevation model layer bottom + riv_data["bottom_elevation"] -= 3.0 + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) + # Act + riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) + # Assert + # Account for cells inactive river cells. + riv_active = riv_data_cleaned["stage"].notnull() + expected = dis_dict["bottom"].where(riv_active) + + np.testing.assert_equal( + riv_data_cleaned["bottom_elevation"].values, expected.values + ) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__fix_bottom_elevation_to_stage(riv_data: dict, dis_data: dict): + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Arrange: Set bottom elevation above stage + riv_data["bottom_elevation"] += 3.0 + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) + # Act + riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) + # Assert + np.testing.assert_equal( + riv_data_cleaned["bottom_elevation"].values, riv_data_cleaned["stage"].values + ) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__stage_equals_bottom_elevation(riv_data: dict, dis_data: dict): + """Assure no cleanup accidentily takes place when stage equals bottom_elevation""" + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Arrange: Set bottom elevation equal to stage + riv_data["bottom_elevation"] = riv_data["stage"].copy() + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) + # Act + riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) + # Assert + np.testing.assert_equal( + riv_data_cleaned["bottom_elevation"].values, riv_data_cleaned["stage"].values + ) + np.testing.assert_equal(riv_data["stage"].values, riv_data_cleaned["stage"].values) + np.testing.assert_equal( + riv_data["bottom_elevation"].values, riv_data_cleaned["bottom_elevation"].values + ) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__trimmed_layers(riv_data: dict, dis_data: dict): + """ + A river package's own grids may have fewer layers than the model's full + ``bottom`` (e.g. produced via ``drop_empty_layers=True``, see + ``imod.prepare.topsystem``). ``cleanup_riv`` should still work in that + case, keeping the package's own (trimmed) layer coordinate rather than + raising an alignment error. + """ + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Trim the river package down to a single layer, model bottom stays full range. + trimmed_layer = riv_data["stage"]["layer"].isel(layer=[0]) + riv_data = { + key: (value.sel(layer=trimmed_layer) if "layer" in value.dims else value) + for key, value in riv_data.items() + } + # Force a bottom_elevation/bottom mismatch, to also exercise align_interface_levels. + riv_data["bottom_elevation"] -= 3.0 + + riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) + + # Returned grids keep the package's own (trimmed) layers. + for value in riv_data_cleaned.values(): + if value is not None and "layer" in value.dims: + np.testing.assert_equal(value["layer"].values, trimmed_layer.values) + riv_active = riv_data_cleaned["stage"].notnull() + expected = dis_dict["bottom"].sel(layer=trimmed_layer).where(riv_active) + np.testing.assert_equal( + riv_data_cleaned["bottom_elevation"].values, expected.values + ) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__raise_error(riv_data: dict, dis_data: dict): + """ + Test if error raised when stage below model layer bottom and see if user is + guided to the right prepare function. + """ + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Arrange: Set bottom elevation above stage + riv_data["stage"] -= 10.0 + # Act + with pytest.raises(ValueError, match="imod.prepare.topsystem.allocate_riv_cells"): + cleanup_riv(**dis_dict, **riv_data) + + +@parametrize_with_cases("dis_data", cases=DisCases) +def test_cleanup_wel(dis_data: dict): + """ + Cleanup wells. + + Cases by id (on purpose not in order, to see if pandas' + sorting results in any issues): + + a: filter completely above surface level -> point filter in top layer + c: filter partly above surface level -> filter top set to surface level + b: filter completely below model base -> well should be removed + d: filter partly below model base -> filter bottom set to model base + f: well outside grid bounds -> well should be removed + e: utrathin filter -> filter should be forced to point filter + g: filter screen_bottom above screen_top -> filter should be forced to point filter + """ + # Arrange + dis_dict = _prepare_dis_dict(dis_data, cleanup_wel) + wel_dict = { + "id": ["a", "c", "b", "d", "f", "e", "g"], + "x": [17.0, 17.0, 17.0, 17.0, 40.0, 17.0, 17.0], + "y": [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0], + "screen_top": [ + 2.0, + 2.0, + -7.0, + -1.0, + -1.0, + 1e-3, + 0.0, + ], + "screen_bottom": [ + 1.5, + 0.0, + -8.0, + -8.0, + -1.0, + 0.0, + 0.5, + ], + } + well_df = pd.DataFrame(wel_dict) + wel_expected = { + "id": ["a", "c", "d", "e", "g"], + "x": [17.0, 17.0, 17.0, 17.0, 17.0], + "y": [15.0, 15.0, 15.0, 15.0, 15.0], + "screen_top": [ + 1.0, + 1.0, + -1.0, + 1e-3, + 0.0, + ], + "screen_bottom": [ + 1.0, + 0.0, + -1.5, + 1e-3, + 0.0, + ], + } + well_expected_df = pd.DataFrame(wel_expected).set_index("id") + # Act + well_cleaned = cleanup_wel(well_df, **dis_dict) + # Assert + pd.testing.assert_frame_equal(well_cleaned, well_expected_df) + + +@parametrize_with_cases("dis_data", cases=DisCases) +def test_cleanup_hfb__ymax_clipped(dis_data: dict): + # Arrange + barrier_y = [25.0, 15.0, -1.0] + barrier_x = [16.0, 16.0, 16.0] + + geometry = gpd.GeoDataFrame( + geometry=[linestrings(barrier_x, barrier_y)], + data={ + "resistance": [1200.0], + "layer": [1], + }, + ) + y_max = 20.0 + idomain = dis_data["idomain"] + if isinstance(idomain, xu.UgridDataArray): + above_y_max = idomain.ugrid.grid.face_y > y_max + idomain.loc[:, above_y_max] = 0 + else: + above_y_max = idomain.coords["y"] > y_max + idomain.loc[:, above_y_max, :] = 0 + + # Act + with pytest.raises(ValueError): + cleanup_hfb(geometry, idomain) + + clipped_geometry = cleanup_hfb(geometry, idomain.isel(layer=0)) + + # Assert + np.testing.assert_allclose(clipped_geometry.bounds.maxy, y_max) + + +@parametrize_with_cases("dis_data", cases=DisCases) +def test_cleanup_hfb__split_in_two(dis_data: dict): + """Deactivate middle cell, barrier should be split in two.""" + # Arrange + barrier_y = [25.0, 15.0, -1.0] + barrier_x = [16.0, 16.0, 16.0] + + geometry = gpd.GeoDataFrame( + geometry=[linestrings(barrier_x, barrier_y)], + data={ + "resistance": [1200.0], + "layer": [1], + }, + ) + idomain = dis_data["idomain"] + if isinstance(idomain, xu.UgridDataArray): + idomain.loc[:, 4] = 0 + else: + idomain.loc[:, 15.0, :] = 0 + + # Act + with pytest.raises(ValueError): + cleanup_hfb(geometry, idomain) + + clipped_geometry = cleanup_hfb(geometry, idomain.isel(layer=0)) + bounds = clipped_geometry.geometry.bounds + assert len(clipped_geometry) == 2 + np.testing.assert_allclose(bounds.miny.values, np.array([20.0, 0.0])) + np.testing.assert_allclose(bounds.maxy.values, np.array([25.0, 10.0])) diff --git a/imod/tests/test_prepare/test_topsystem.py b/imod/tests/test_prepare/test_topsystem.py index 65295c7ff..da06d9763 100644 --- a/imod/tests/test_prepare/test_topsystem.py +++ b/imod/tests/test_prepare/test_topsystem.py @@ -1,628 +1,754 @@ -import numpy as np -import xarray as xr -from pytest_cases import parametrize_with_cases - -from imod.prepare.topsystem import ( - ALLOCATION_OPTION, - allocate_drn_cells, - allocate_ghb_cells, - allocate_rch_cells, - allocate_riv_cells, - distribute_drn_conductance, - distribute_ghb_conductance, - distribute_riv_conductance, -) -from imod.typing import GridDataArray -from imod.typing.grid import is_unstructured, zeros_like -from imod.util.dims import enforce_dim_order - - -def take_nth_layer_column(grid: GridDataArray, n: int) -> GridDataArray: - """ - Parameters - ---------- - grid: DataArray | UgridDataArray - grid to take values from. Must have dimensions (layer,y,x) for - structured and (layer,{face_dim}) for unstructured grids. - n: int - index number in the xy plane where layer column is taken. - - Returns - ------- - DataArray | UgridDataArray - Column along the layer dimension at the nth cell in the xy plane. - """ - if "time" in grid.dims: - grid = grid.isel(time=-1) - - if is_unstructured(grid): - return grid.values[:, n] - else: - return grid.values[:, n, n] - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" -) -def test_riv_allocation( - active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn -): - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, bottom_elevation - ) - - actual_riv = take_nth_layer_column(actual_riv_da, 0) - empty_riv = take_nth_layer_column(actual_riv_da, 1) - - if actual_drn_da is None: - actual_drn = None - empty_drn = None - else: - actual_drn = take_nth_layer_column(actual_drn_da, 0) - empty_drn = take_nth_layer_column(actual_drn_da, 1) - - np.testing.assert_equal(actual_riv, expected_riv) - np.testing.assert_equal(actual_drn, expected_drn) - assert np.all(~empty_riv) - if empty_drn is not None: - assert np.all(~empty_drn) - - -@parametrize_with_cases( - argnames="active,top,bottom,drn_elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="drn" -) -def test_drn_allocation(active, top, bottom, drn_elevation, option, expected, _): - actual_da = allocate_drn_cells(option, active, top, bottom, drn_elevation) - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - - -@parametrize_with_cases( - argnames="active,top,bottom,head", - prefix="ghb_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="ghb" -) -def test_ghb_allocation(active, top, bottom, head, option, expected, _): - actual_da = allocate_ghb_cells(option, active, top, bottom, head) - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - - -@parametrize_with_cases( - argnames="active,rate", - prefix="rch_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="rch" -) -def test_rch_allocation(active, rate, option, expected, _): - actual_da = allocate_rch_cells(option, active, rate) - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, expected -): - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - - actual_da = distribute_riv_conductance( - option, allocated, conductance, top, bottom, k, stage, bottom_elevation - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="drn" -) -def test_distribute_drn_conductance( - active, top, bottom, elevation, option, allocated_layer, expected -): - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - - actual_da = distribute_drn_conductance( - option, allocated, conductance, top, bottom, k, elevation - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="ghb_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="ghb" -) -def test_distribute_ghb_conductance( - active, top, bottom, elevation, option, allocated_layer, expected -): - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - - actual_da = distribute_ghb_conductance( - option, allocated, conductance, top, bottom, k - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" -) -def test_riv_allocation__elevation_above_surface_level( - active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn -): - # Put elevations a lot above surface level. Need to be allocated to first - # layer. - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage + 100.0, bottom_elevation + 100.0 - ) - - # Override expected values - expected_riv = [True, False, False, False] - if expected_drn: - expected_drn = [False, False, False, False] - - actual_riv = take_nth_layer_column(actual_riv_da, 0) - empty_riv = take_nth_layer_column(actual_riv_da, 1) - - if actual_drn_da is None: - actual_drn = None - empty_drn = None - else: - actual_drn = take_nth_layer_column(actual_drn_da, 0) - empty_drn = take_nth_layer_column(actual_drn_da, 1) - - np.testing.assert_equal(actual_riv, expected_riv) - np.testing.assert_equal(actual_drn, expected_drn) - assert np.all(~empty_riv) - if empty_drn is not None: - assert np.all(~empty_drn) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" -) -def test_riv_allocation__stage_equals_bottom_elevation( - active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn -): - # Bottom elevation equals stage here. - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, stage - ) - - # Override expected values - if option is ALLOCATION_OPTION.first_active_to_elevation: - expected_riv = [True, True, False, False] - elif option is not ALLOCATION_OPTION.at_first_active: - expected_riv = [False, True, False, False] - if expected_drn: - expected_drn = [True, False, False, False] - - actual_riv = take_nth_layer_column(actual_riv_da, 0) - empty_riv = take_nth_layer_column(actual_riv_da, 1) - - if actual_drn_da is None: - actual_drn = None - empty_drn = None - else: - actual_drn = take_nth_layer_column(actual_drn_da, 0) - empty_drn = take_nth_layer_column(actual_drn_da, 1) - - np.testing.assert_equal(actual_riv, expected_riv) - np.testing.assert_equal(actual_drn, expected_drn) - assert np.all(~empty_riv) - if empty_drn is not None: - assert np.all(~empty_drn) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" -) -def test_riv_allocation__stage_equals_bottom_elevation_equals_bottom( - active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn -): - # Set bottom in layer 2 to stage, take first value (stage is equal everywhere.) - bottom.loc[bottom.coords["layer"] == 2] = stage.values.ravel()[0] - - # Bottom elevation equals stage here. - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, stage - ) - - # Override expected values - if option is ALLOCATION_OPTION.first_active_to_elevation: - expected_riv = [True, True, False, False] - elif option is not ALLOCATION_OPTION.at_first_active: - expected_riv = [False, True, False, False] - if expected_drn: - expected_drn = [True, False, False, False] - - actual_riv = take_nth_layer_column(actual_riv_da, 0) - empty_riv = take_nth_layer_column(actual_riv_da, 1) - - if actual_drn_da is None: - actual_drn = None - empty_drn = None - else: - actual_drn = take_nth_layer_column(actual_drn_da, 0) - empty_drn = take_nth_layer_column(actual_drn_da, 1) - - np.testing.assert_equal(actual_riv, expected_riv) - np.testing.assert_equal(actual_drn, expected_drn) - assert np.all(~empty_riv) - if empty_drn is not None: - assert np.all(~empty_drn) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="drn" -) -def test_drn_allocation__elevation_above_surface_level( - active, top, bottom, elevation, option, expected, _ -): - # Put elevations a lot above surface level. Need to be allocated to first - # layer. - actual_da = allocate_drn_cells( - option, - active, - top, - bottom, - elevation + 100.0, - ) - - # Override expected - expected = [True, False, False, False] - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - if empty is not None: - assert np.all(~empty) - - -@parametrize_with_cases( - argnames="active,top,bottom,drn_elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="drn" -) -def test_drn_allocation__elevation_equal_to_bottom( - active, top, bottom, drn_elevation, option, expected, _ -): - # Set bottom in layer 3 to drain elevation, take first value (drain - # elevation is equal everywhere.) - bottom.loc[bottom.coords["layer"] == 3] = drn_elevation.values.ravel()[0] - - actual_da = allocate_drn_cells(option, active, top, bottom, drn_elevation) - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - - -@parametrize_with_cases( - argnames="active,top,bottom,head", - prefix="ghb_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="ghb" -) -def test_ghb_allocation__elevation_above_surface_level( - active, top, bottom, head, option, expected, _ -): - # Put elevations a lot above surface level. Need to be allocated to first - # layer. - actual_da = allocate_ghb_cells( - option, - active, - top, - bottom, - head + 100.0, - ) - - # Override expected - expected = [True, False, False, False] - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - if empty is not None: - assert np.all(~empty) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="drn" -) -def test_distribute_drn_conductance__above_surface_level( - active, top, bottom, elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - - actual_da = distribute_drn_conductance( - option, allocated, conductance, top, bottom, k, elevation + 100.0 - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="drn" -) -def test_distribute_drn_conductance__equal_to_surface_level( - active, top, bottom, elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - elevation = zeros_like(elevation) + top - - actual_da = distribute_drn_conductance( - option, allocated, conductance, top, bottom, k, elevation - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance__above_surface_level( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - - actual_da = distribute_riv_conductance( - option, - allocated, - conductance, - top, - bottom, - k, - stage + 100.0, - bottom_elevation + 100.0, - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance__equal_to_surface_level( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - elevation = zeros_like(bottom_elevation) + top - - actual_da = distribute_riv_conductance( - option, - allocated, - conductance, - top, - bottom, - k, - elevation, - elevation, - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance__stage_equal_to_bottom_elevation( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([False, True, False, False]) - expected = [np.nan, 1.0, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - - actual_da = distribute_riv_conductance( - option, - allocated, - conductance, - top, - bottom, - k, - stage, - stage, - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance__stage_equal_to_bottom_elevation_equal_to_bottom( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ -): - # Set bottom in layer 2 to stage, take first value (stage is equal everywhere.) - bottom.loc[bottom.coords["layer"] == 2] = stage.values.ravel()[0] - - allocated_layer.data = np.array([False, True, False, False]) - expected = [np.nan, 1.0, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - - actual_da = distribute_riv_conductance( - option, - allocated, - conductance, - top, - bottom, - k, - stage, - stage, - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="ghb_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="ghb" -) -def test_distribute_ghb_conductance__above_surface_level( - active, top, bottom, elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - - actual_da = distribute_ghb_conductance( - option, allocated, conductance, top, bottom, k - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) +import numpy as np +import xarray as xr +from pytest_cases import parametrize_with_cases + +from imod.prepare.topsystem import ( + ALLOCATION_OPTION, + allocate_drn_cells, + allocate_ghb_cells, + allocate_rch_cells, + allocate_riv_cells, + distribute_drn_conductance, + distribute_ghb_conductance, + distribute_riv_conductance, +) +from imod.typing import GridDataArray +from imod.typing.grid import is_unstructured, zeros_like +from imod.util.dims import enforce_dim_order + + +def take_nth_layer_column(grid: GridDataArray, n: int) -> GridDataArray: + """ + Parameters + ---------- + grid: DataArray | UgridDataArray + grid to take values from. Must have dimensions (layer,y,x) for + structured and (layer,{face_dim}) for unstructured grids. + n: int + index number in the xy plane where layer column is taken. + + Returns + ------- + DataArray | UgridDataArray + Column along the layer dimension at the nth cell in the xy plane. + """ + if "time" in grid.dims: + grid = grid.isel(time=-1) + + if is_unstructured(grid): + return grid.values[:, n] + else: + return grid.values[:, n, n] + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" +) +def test_riv_allocation( + active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn +): + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, bottom_elevation, drop_empty_layers=False + ) + + actual_riv = take_nth_layer_column(actual_riv_da, 0) + empty_riv = take_nth_layer_column(actual_riv_da, 1) + + if actual_drn_da is None: + actual_drn = None + empty_drn = None + else: + actual_drn = take_nth_layer_column(actual_drn_da, 0) + empty_drn = take_nth_layer_column(actual_drn_da, 1) + + np.testing.assert_equal(actual_riv, expected_riv) + np.testing.assert_equal(actual_drn, expected_drn) + assert np.all(~empty_riv) + if empty_drn is not None: + assert np.all(~empty_drn) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, bottom_elevation, drop_empty_layers=True + ) + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + np.testing.assert_array_equal( + actual_riv_da.coords["layer"].values, expected_riv_layers + ) + if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + np.testing.assert_array_equal( + actual_drn_da.coords["layer"].values, expected_drn_layers + ) + + +@parametrize_with_cases( + argnames="active,top,bottom,drn_elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="drn" +) +def test_drn_allocation(active, top, bottom, drn_elevation, option, expected, _): + actual_da = allocate_drn_cells( + option, active, top, bottom, drn_elevation, drop_empty_layers=False + ) + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_drn_cells( + option, active, top, bottom, drn_elevation, drop_empty_layers=True + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,head", + prefix="ghb_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="ghb" +) +def test_ghb_allocation(active, top, bottom, head, option, expected, _): + actual_da = allocate_ghb_cells( + option, active, top, bottom, head, drop_empty_layers=False + ) + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_ghb_cells( + option, active, top, bottom, head, drop_empty_layers=True + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,rate", + prefix="rch_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="rch" +) +def test_rch_allocation(active, rate, option, expected, _): + actual_da = allocate_rch_cells(option, active, rate, drop_empty_layers=False) + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_rch_cells(option, active, rate, drop_empty_layers=True) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, expected +): + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + + actual_da = distribute_riv_conductance( + option, allocated, conductance, top, bottom, k, stage, bottom_elevation + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="drn" +) +def test_distribute_drn_conductance( + active, top, bottom, elevation, option, allocated_layer, expected +): + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + + actual_da = distribute_drn_conductance( + option, allocated, conductance, top, bottom, k, elevation + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="ghb_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="ghb" +) +def test_distribute_ghb_conductance( + active, top, bottom, elevation, option, allocated_layer, expected +): + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + + actual_da = distribute_ghb_conductance( + option, allocated, conductance, top, bottom, k + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" +) +def test_riv_allocation__elevation_above_surface_level( + active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn +): + # Put elevations a lot above surface level. Need to be allocated to first + # layer. + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, + active, + top, + bottom, + stage + 100.0, + bottom_elevation + 100.0, + drop_empty_layers=False, + ) + + # Override expected values + expected_riv = [True, False, False, False] + if expected_drn: + expected_drn = [False, False, False, False] + + actual_riv = take_nth_layer_column(actual_riv_da, 0) + empty_riv = take_nth_layer_column(actual_riv_da, 1) + + if actual_drn_da is None: + actual_drn = None + empty_drn = None + else: + actual_drn = take_nth_layer_column(actual_drn_da, 0) + empty_drn = take_nth_layer_column(actual_drn_da, 1) + + np.testing.assert_equal(actual_riv, expected_riv) + np.testing.assert_equal(actual_drn, expected_drn) + assert np.all(~empty_riv) + if empty_drn is not None: + assert np.all(~empty_drn) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, + active, + top, + bottom, + stage + 100.0, + bottom_elevation + 100.0, + drop_empty_layers=True, + ) + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + np.testing.assert_array_equal( + actual_riv_da.coords["layer"].values, expected_riv_layers + ) + if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + np.testing.assert_array_equal( + actual_drn_da.coords["layer"].values, expected_drn_layers + ) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" +) +def test_riv_allocation__stage_equals_bottom_elevation( + active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn +): + # Bottom elevation equals stage here. + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, stage, drop_empty_layers=False + ) + + # Override expected values + if option is ALLOCATION_OPTION.first_active_to_elevation: + expected_riv = [True, True, False, False] + elif option is not ALLOCATION_OPTION.at_first_active: + expected_riv = [False, True, False, False] + if expected_drn: + expected_drn = [True, False, False, False] + + actual_riv = take_nth_layer_column(actual_riv_da, 0) + empty_riv = take_nth_layer_column(actual_riv_da, 1) + + if actual_drn_da is None: + actual_drn = None + empty_drn = None + else: + actual_drn = take_nth_layer_column(actual_drn_da, 0) + empty_drn = take_nth_layer_column(actual_drn_da, 1) + + np.testing.assert_equal(actual_riv, expected_riv) + np.testing.assert_equal(actual_drn, expected_drn) + assert np.all(~empty_riv) + if empty_drn is not None: + assert np.all(~empty_drn) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, stage, drop_empty_layers=True + ) + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + np.testing.assert_array_equal( + actual_riv_da.coords["layer"].values, expected_riv_layers + ) + if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + np.testing.assert_array_equal( + actual_drn_da.coords["layer"].values, expected_drn_layers + ) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" +) +def test_riv_allocation__stage_equals_bottom_elevation_equals_bottom( + active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn +): + # Set bottom in layer 2 to stage, take first value (stage is equal everywhere.) + bottom.loc[bottom.coords["layer"] == 2] = stage.values.ravel()[0] + + # Bottom elevation equals stage here. + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, stage, drop_empty_layers=False + ) + + # Override expected values + if option is ALLOCATION_OPTION.first_active_to_elevation: + expected_riv = [True, True, False, False] + elif option is not ALLOCATION_OPTION.at_first_active: + expected_riv = [False, True, False, False] + if expected_drn: + expected_drn = [True, False, False, False] + + actual_riv = take_nth_layer_column(actual_riv_da, 0) + empty_riv = take_nth_layer_column(actual_riv_da, 1) + + if actual_drn_da is None: + actual_drn = None + empty_drn = None + else: + actual_drn = take_nth_layer_column(actual_drn_da, 0) + empty_drn = take_nth_layer_column(actual_drn_da, 1) + + np.testing.assert_equal(actual_riv, expected_riv) + np.testing.assert_equal(actual_drn, expected_drn) + assert np.all(~empty_riv) + if empty_drn is not None: + assert np.all(~empty_drn) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, stage, drop_empty_layers=True + ) + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + np.testing.assert_array_equal( + actual_riv_da.coords["layer"].values, expected_riv_layers + ) + if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + np.testing.assert_array_equal( + actual_drn_da.coords["layer"].values, expected_drn_layers + ) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="drn" +) +def test_drn_allocation__elevation_above_surface_level( + active, top, bottom, elevation, option, expected, _ +): + # Put elevations a lot above surface level. Need to be allocated to first + # layer. + actual_da = allocate_drn_cells( + option, + active, + top, + bottom, + elevation + 100.0, + drop_empty_layers=False, + ) + + # Override expected + expected = [True, False, False, False] + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + if empty is not None: + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_drn_cells( + option, + active, + top, + bottom, + elevation + 100.0, + drop_empty_layers=True, + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,drn_elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="drn" +) +def test_drn_allocation__elevation_equal_to_bottom( + active, top, bottom, drn_elevation, option, expected, _ +): + # Set bottom in layer 3 to drain elevation, take first value (drain + # elevation is equal everywhere.) + bottom.loc[bottom.coords["layer"] == 3] = drn_elevation.values.ravel()[0] + + actual_da = allocate_drn_cells( + option, active, top, bottom, drn_elevation, drop_empty_layers=False + ) + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_drn_cells( + option, active, top, bottom, drn_elevation, drop_empty_layers=True + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,head", + prefix="ghb_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="ghb" +) +def test_ghb_allocation__elevation_above_surface_level( + active, top, bottom, head, option, expected, _ +): + # Put elevations a lot above surface level. Need to be allocated to first + # layer. + actual_da = allocate_ghb_cells( + option, + active, + top, + bottom, + head + 100.0, + drop_empty_layers=False, + ) + + # Override expected + expected = [True, False, False, False] + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + if empty is not None: + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_ghb_cells( + option, + active, + top, + bottom, + head + 100.0, + drop_empty_layers=True, + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="drn" +) +def test_distribute_drn_conductance__above_surface_level( + active, top, bottom, elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + + actual_da = distribute_drn_conductance( + option, allocated, conductance, top, bottom, k, elevation + 100.0 + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="drn" +) +def test_distribute_drn_conductance__equal_to_surface_level( + active, top, bottom, elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + elevation = zeros_like(elevation) + top + + actual_da = distribute_drn_conductance( + option, allocated, conductance, top, bottom, k, elevation + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance__above_surface_level( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + + actual_da = distribute_riv_conductance( + option, + allocated, + conductance, + top, + bottom, + k, + stage + 100.0, + bottom_elevation + 100.0, + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance__equal_to_surface_level( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + elevation = zeros_like(bottom_elevation) + top + + actual_da = distribute_riv_conductance( + option, + allocated, + conductance, + top, + bottom, + k, + elevation, + elevation, + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance__stage_equal_to_bottom_elevation( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([False, True, False, False]) + expected = [np.nan, 1.0, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + + actual_da = distribute_riv_conductance( + option, + allocated, + conductance, + top, + bottom, + k, + stage, + stage, + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance__stage_equal_to_bottom_elevation_equal_to_bottom( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ +): + # Set bottom in layer 2 to stage, take first value (stage is equal everywhere.) + bottom.loc[bottom.coords["layer"] == 2] = stage.values.ravel()[0] + + allocated_layer.data = np.array([False, True, False, False]) + expected = [np.nan, 1.0, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + + actual_da = distribute_riv_conductance( + option, + allocated, + conductance, + top, + bottom, + k, + stage, + stage, + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="ghb_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="ghb" +) +def test_distribute_ghb_conductance__above_surface_level( + active, top, bottom, elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + + actual_da = distribute_ghb_conductance( + option, allocated, conductance, top, bottom, k + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) From abaecc6f8bf7304d828be2b59f657ce1ec818563 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 28 Sep 2026 12:11:03 +0200 Subject: [PATCH 05/23] lint --- imod/mf6/rch.py | 2 +- imod/mf6/riv.py | 2 +- imod/tests/test_mf6/test_mf6_riv.py | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/imod/mf6/rch.py b/imod/mf6/rch.py index 1e92122c6..d51c1691c 100644 --- a/imod/mf6/rch.py +++ b/imod/mf6/rch.py @@ -219,7 +219,7 @@ def _allocate_planar_data( allocation_option, idomain > 0, planar_data["rate"], - drop_empty_layers=False, # Keep full here, drop empty layers below + drop_empty_layers=False, # Keep full here, drop empty layers below ) # remove rch from cells where it is not allocated and broadcast over layers. layered_data = {} diff --git a/imod/mf6/riv.py b/imod/mf6/riv.py index 2dd0249fd..1325e52a1 100644 --- a/imod/mf6/riv.py +++ b/imod/mf6/riv.py @@ -374,7 +374,7 @@ def _allocate_and_distribute_planar_data( bottom, planar_data["stage"], planar_data["bottom_elevation"], - drop_empty_layers=False, # Keep full layer range, drop empty layers below + drop_empty_layers=False, # Keep full layer range, drop empty layers below ) drn_is_allocated = drn_allocated is not None # Distribution of conductances diff --git a/imod/tests/test_mf6/test_mf6_riv.py b/imod/tests/test_mf6/test_mf6_riv.py index 5ff5387f4..3c7ed137d 100644 --- a/imod/tests/test_mf6/test_mf6_riv.py +++ b/imod/tests/test_mf6/test_mf6_riv.py @@ -497,7 +497,9 @@ def test_reallocate_drop_empty_layers(): dx, dy = 10.0, -10.0 layer = [1, 2, 3, 4] - top = xr.DataArray(0.0, coords={"y": y, "x": x, "dx": dx, "dy": dy}, dims=("y", "x")) + top = xr.DataArray( + 0.0, coords={"y": y, "x": x, "dx": dx, "dy": dy}, dims=("y", "x") + ) bottom = xr.DataArray( np.array([-1.0, -2.0, -3.0, -4.0])[:, None, None] * np.ones((4, 3, 3)), coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, From 761fb4a883bb841f97161da2ccd2c624234668a1 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 28 Sep 2026 12:24:05 +0200 Subject: [PATCH 06/23] revert lineendings change. --- docs/api/changelog.rst | 3972 ++++++++++----------- imod/mf6/drn.py | 722 ++-- imod/mf6/ghb.py | 722 ++-- imod/mf6/rch.py | 650 ++-- imod/mf6/riv.py | 1114 +++--- imod/mf6/topsystem.py | 384 +- imod/prepare/cleanup.py | 818 ++--- imod/tests/test_mf6/test_mf6_drn.py | 1628 ++++----- imod/tests/test_mf6/test_mf6_ghb.py | 566 +-- imod/tests/test_mf6/test_mf6_rch.py | 1412 ++++---- imod/tests/test_mf6/test_mf6_riv.py | 1996 +++++------ imod/tests/test_prepare/test_cleanup.py | 746 ++-- imod/tests/test_prepare/test_topsystem.py | 1508 ++++---- 13 files changed, 8119 insertions(+), 8119 deletions(-) diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index dfc659cf5..2023a2a51 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -1,1986 +1,1986 @@ -Changelog -========= - -All notable changes to this project will be documented in this file. - -The format is based on `Keep a Changelog`_, and this project adheres to -`Semantic Versioning`_. - -[Unreleased] ------------- - -Added -~~~~~ - -- Experimental class :class:`imod.msw.SprinklingPoints` to specify sprinkling - from points for MetaSWAP models, instead of from grid. You can use this to - specify sprinkling wells from IPF files in an iMOD5 CAP dataset with - :meth:`imod.msw.SprinklingPoints.from_imod5_data`. -- :class:`imod.mf6.LayeredWell.from_imod5_cap_data` now also supports loading - wells from IPF files in an iMOD5 CAP dataset. -- Added ``drop_empty_layers: bool = True`` to various cell allocation functions - in :mod:`imod.prepare.topsystem.allocation` to remove fully empty layers from the grids. - Strips the empty layers before they are passed along to - reprojection/regridding operations, which can save considerable time for models with - many empty layers. Set to False to keep the previous full-layer-coordinate - behaviour. :meth:`imod.prepare.topsystem.allocation.allocate_riv_cells`, - :meth:`imod.prepare.topsystem.allocation.allocate_drn_cells`, - :meth:`imod.prepare.topsystem.allocation.allocate_ghb_cells`, - :meth:`imod.prepare.topsystem.allocation.allocate_rch_cells` -- Added ``drop_empty_layers: bool = True`` to - :meth:`imod.mf6.River.reallocate`, :meth:`imod.mf6.Drainage.reallocate`, - :meth:`imod.mf6.GeneralHeadBoundary.reallocate`, and - :meth:`imod.mf6.Recharge.reallocate`. Allocation and conductance - distribution are always computed over the full layer range first; only - the final package has fully empty layers trimmed off afterwards, so this - does not affect computed values. Set to False to keep the previous - full-layer-coordinate behaviour. - -Fixed -~~~~~ - -- Fixed resampling in :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` when simulation timesteps precede - the first well timestep. -- Fixed :func:`imod.prepare.cleanup.align_interface_levels` (used by - ``cleanup_riv``, and therefore :meth:`imod.mf6.River.cleanup`) raising an - alignment error when a package's own layer coordinate is a subset of the - model's full layer range, e.g. after :meth:`imod.mf6.River.reallocate` with - ``drop_empty_layers=True``. - -Changed -~~~~~~~ - -- Deprecated :class:`imod.msw.Sprinkling` in favor of - :class:`imod.msw.SprinklingGrid`. Call :class:`imod.msw.SprinklingGrid` to get - the same behavior as you were used to. - -[1.1.0] - 2026-08-03 --------------------- - -Added -~~~~~ - -- Added ``ignore_time_purge_empty`` argument to - :meth:`imod.mf6.Modflow6Simulation.mask_all_models` and - :meth:`imod.mf6.Modflow6Simulation.clip_box` to consider a package empty if - its first times step is all nodata. This can save a lot of clipping or masking - transient models with many timesteps. -- Added :meth:`imod.msw.MetaSwapModel.split` to split MetaSWAP models. -- Added :meth:`imod.mf6.HorizontalFlowBarrierResistance.from_imod5_data` to load - barriers from 3D GEN files. -- Added ``name`` argument to :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` - to provide custom name to imported simulation and model. -- Added :meth:`imod.msw.MetaSwapModel.mask_all_packages` to mask all packages of - a MetaSWAP model. -- Added optional ``target_grid`` argument to - :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, - :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data`, - :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` to specify a target - grid for regridding the iMOD5 data to. If not provided, the first IBOUND layer - is used as target grid, like in iMOD5. - -Fixed -~~~~~ - -- Fixed bug in :class:`imod.mf6.GroundwaterFlowModel` and :class:`imod.formats.prf.IpfResult` - where names of wels were duplicated by increasing the character limit to 40 - and enumerating wel names. -- Fixed bug where :class:`imod.mf6.Evapotranspiration` package would write files - to binary, which could not be parsed by MODFLOW 6 when ``proportion_depth`` - and ``proportion_rate`` were provided without segments. -- Fixed bug where :class:`imod.mf6.ConstantConcentration` package could not be written - for multiple timesteps. -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` where a ValidationError - was thrown when clipping a model with a :class:`imod.mf6.ConstantHead` or - :class:`imod.mf6.ConstantConcentration` package with a ``time`` dimension and - providing ``states_for_boundary``. -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` would drop layers if - ``states_for_boundary`` were provided and the model already contained a - :class:`imod.mf6.ConstantHead` or :class:`imod.mf6.ConstantConcentration` with - less layers. -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` would not properly - align timesteps and forward fill data if ``states_for_boundary`` were provided - and the model already contained a :class:`imod.mf6.ConstantHead` or - :class:`imod.mf6.ConstantConcentration`, both with timesteps, which were - unaligned. -- Fixed bug where :class:`imod.mf6.Lake` package did not pass ``budgetfile``, - ``budgetcsvfile``, ``stagefile`` options to the written MODFLOW 6 package. -- Fixed bug where :func:`imod.evaluate.convert_pointwaterhead_freshwaterhead` - produced incorrect results when point water heads were below elevation levels - for unstructured grids. -- Support pandas 3.0. -- :class:`imod.msw.IdfMapping` when model clipping is applied, the global - row/column indices are converted to local indices, as written in - ``idf_svat.inp``. -- Fixed edge case where allocation of :class:`imod.mf6.River` package with the - ``stage_to_riv_bot`` or ``stage_to_riv_bot_drn_above`` option of - :func:`imod.prepare.ALLOCATION_OPTION` would assign river cells to the wrong - layer, when the stage and bottom_elevation were exactly equal to the bottom of - a layer in the model discretization, which would cause these cells to be - dropped when distributing conductances later. -- Fixed :func:`imod.prepare.spatial.polygonize` for polygons with holes. -- :func:`imod.formats.prj.open_projectfile_data` now drops empty wells from the - dataset, and logs a warning about it. -- :meth:`imod.mf6.NodePropertyFlow.regrid_like` now regrids ``k33`` using the - correct method, namely ``mean`` instead of ``harmonic_mean``. As this is the - appropriate method for horizontal regridding of ``k33``. -- :meth:`imod.msw.MetaSwapModel.from_imod5_data`, - :meth:`imod.mf6.Recharge.from_imod5_cap_data`, - :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` now regrids the iMOD5 CAP - data to the MODFLOW6 target discretization. -- Fixed confusing warning about inconsistent IPF columns when loading GEN files. -- Fix bug where iMOD Python would error on writing a model where package - settings were specified as dask array, which could happen when loading a model - lazily with :meth:`imod.mf6.Modflow6Simulation.from_file` and not - computing the data before writing. -- Fixed bug where ``concentration`` variables were needlessly loaded into - memory. Affected :class:`imod.mf6.River`, :class:`imod.mf6.Drain`, - :class:`imod.mf6.ConstantHeadBoundary`, :class:`imod.mf6.Recharge`, - :class:`imod.mf6.Well` and :class:`imod.mf6.GeneralHeadBoundary`. -- Fix bug where ``maxbound`` of the ``.wel`` file computed by - :class:`imod.mf6.Mf6Wel` was twice or thrice too large. -- Fixed bug where :func:`imod.evaluate.facebudget` raised an error when the - ``front`` budget was left out, even though you only need to provide one of - ``front``, ``lower`` or ``right``. Leaving out ``front`` now works as - described in the documentation. -- Fixed big performance degradation with :func:`imod.idf.open_subdomains` where - it would take a long time to open lots of idf files. Performance is now - significantly improved up to the same speed as before the change that caused - the performance degradation. - -Changed -~~~~~~~ - -- Increased the character limit to 40 in :class:`imod.mf6.Modflow6Model` for all - keys assigned to a Modflow6 model. -- ``proportion_depth`` and ``proportion_rate`` in - :class:`imod.mf6.Evapotranspiration` are now optional variables. If provided, - now require ``"segment"`` dimension when ``proportion_depth`` and - ``proportion_rate``. -- :meth:`imod.msw.GridData.generate_index_array` is now deprecated, use - :meth:`imod.msw.GridData.generate_isactive_svat_arrays` instead. -- If no ``target_grid`` is provided, - :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` chooses a grid the - same as iMOD5 did: the first IBOUND layer. This is different from previous - versions of iMOD Python, which defaulted to the smallest possible extent - and finest resolution, based on the iMOD5 IBOUND, TOP and BOTTOM data. - - -[1.0.0] - 2025-11-11 --------------------- - -Fixed -~~~~~ - -- Improved performance of :meth:`imod.mf6.Modflow6Simulation.split` for large - models loaded lazily into memory. Reduced a splitting operation of 2 hours to - a few minutes for a test case. -- Issue where :meth:`imod.mf6.LayeredWell.from_imod5_data` would result in wells - with a mismatch between coordinates and rates. - -Added -~~~~~ - -- Added :class:`imod.mf6.Viscosity` package to specify the viscosity of the - groundwater flow model. -- Functionality to dump and load MODFLOW 6 simulations to/from zarr and zipstore - formats. See :meth:`imod.mf6.Modflow6Simulation.dump` and - :meth:`imod.mf6.Modflow6Simulation.from_file` for more information. -- Functionality to dump and load MetaSwap models to/from netcdf - format. See :meth:`imod.msw.MetaSwapModel.dump` and - :meth:`imod.msw.MetaSwapModel.from_file` for more information. - -Changed -~~~~~~~ - -- :class:`imod.mf6.Well` and :func:`imod.prepare.assign_wells` now distribute - the well rates over the screened cells using a correction factor based on the - mismatch between the well screen center and the cell center, equal to iMOD5's - correction factor. - - -[1.0.0rc7] - 2025-10-28 ------------------------ - -Added -~~~~~ - -- :meth:`imod.mf6.Modflow6Simulation.set_validation_settings` to set validation - settings for a MODFLOW 6 simulation. See :class:`imod.mf6.ValidationSettings` - for more information. - -Changed -~~~~~~~ - -- No automatic validation upon calling :meth:`imod.mf6.Modflow6Simulation.regrid_like` anymore. - Use the ``validate`` argument of :meth:`imod.mf6.Modflow6Simulation.write` to - validate the regridded model upon writing instead. -- :class:`imod.mf6.River` now ignore confined cells (``icelltype == 0``) when - validating whether the river bottom elevation is below the model bottom - elevation. -- Moved :func:`imod.select.get_upper_active_layer_number`, - :func:`imod.select.get_upper_active_cells`, - :func:`imod.select.get_lower_active_cells`, and - :func:`imod.select.get_lower_active_layer_number` from :mod:`imod.prepare`. to - :mod:`imod.select`. -- :class:`imod.mf6.Dispersion` now is not a required package for - :class:`imod.mf6.GroundwaterTransportModel` anymore. -- No validation anymore for ``icelltype`` upon writing - :class:`imod.mf6.SpecificStorage` and :class:`imod.mf6.StorageCoefficient`. - - -Removed -~~~~~~~ - -- Removed ``imod.select.upper_active_layer`` function, use - :func:`imod.select.get_upper_active_layer_number` instead. - -Fixed -~~~~~ - -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.split` could result in - empty exchanges being present in the ``split_exchanges`` package list, when - two models were isolated by inactive cells from each other. These empty - exchanges are now removed. -- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.split`, - :meth:`imod.mf6.Modflow6Simulation.regrid_like`, and - :meth:`imod.mf6.Modflow6Simulation.clip_box` would not copy - :class:`imod.mf6.ValidationSettings`. -- ``landuse``, ``soil_physical_unit``, ``active`` for :class:`imod.msw.GridData` - are now properly regridded with the ``mode`` statistic when using - :meth:`imod.msw.GridData.regrid_like`. - -[1.0.0rc6] - 2025-08-28 ------------------------ - -Small post-release to fix rendering of documentation online. - -[1.0.0rc5] - 2025-08-27 ------------------------ - -Added -~~~~~ - -- :meth:`imod.mf6.River.reallocate`, :meth:`imod.mf6.Drainage.reallocate`, - :meth:`imod.mf6.GeneralHeadBoundary.reallocate`, - :meth:`imod.mf6.Recharge.reallocate` to reallocate the package data to a new - discretization or :class:`imod.mf6.NodePropertyFlow` package, or to use a - different :class:`imod.prepare.ALLOCATION_OPTION` or - :class:`imod.prepare.DISTRIBUTING_OPTION`. -- Added :meth:`imod.mf6.HorizontalFlowBarrierResistance.snap_to_grid` and - :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.snap_to_grid` to - debug how horizontal flow barriers are snapped to a grid. -- Added :meth:`imod.mf6.Modflow6Simulation.create_partition_labels` to create - partition labels for a MODFLOW 6 simulation from its idomain. This is useful - for splitting a simulation into multiple submodels. -- :class:`imod.mf6.AdaptiveTimeStepping` to specify adaptive time stepping - settings for MODFLOW 6 simulations. -- The ``ats_percel`` argument to :class:`imod.mf6.AdvectionTVD`, - :class:`imod.mf6.AdvectionUpstream`, :class:`imod.mf6.AdvectionCentral` to - adapt the time step based on the maximum fraction of a cell that a solute - parcel is allowed to travel. - -Fixed -~~~~~ - -- Reduce noisy warnings in models loaded with - :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` which have layers with - cells with zero thicknesses. -- Issue where regridding lead to excessively large inactive areas. -- Issue where regridding would lead to very large negative integer values (like - IDOMAIN) for inactive areas. -- Issue where :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` would throw a KeyError 0 upon - trying to resample timeseries with a non-zero index. -- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB - packages would have resistances that were double the expected value with - xugrid >= 0.14.2 -- The ``states_for_boundary`` argument now also works for tranport models in - :meth:`imod.mf6.Modflow6Simulation.clip_box`. -- Fix bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` and the - ``states_for_boundary`` argument would place these bc at the - incorrect places with unstructured grids. -- Fixed bug where :meth:`imod.mf6.SourceSinkMixing.from_flow_model` would return - an error upon adding a package which cannot have a ``concentration``, such as - :class:`imod.mf6.HorizontalFlowBarrierResistance`. -- Broken names for ``outer_csvfile`` and ``inner_csvfile`` in the - :class:`imod.mf6.Solution` MODFLOW 6 template file. - -Changed -~~~~~~~ - -- :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` and - :meth:`imod.mf6.NodePropertyFlow.from_imod5_data` now automatically load the - dataset into memory. This improves performance when loading models with - multiple topsystem packages. -- No upper limit anymore for ``mod_id`` in ``mod2svat.inp`` for - :class:`imod.msw.CouplerMapping`. -- :func:`imod.prepare.create_partition_labels` now takes an ``idomain`` grid - instead of :class:`imod.mf6.Modflow6Simulation` as first argument. To generate - partition labels from a :class:`imod.mf6.Modflow6Simulation` straightaway, use - the newly added :meth:`imod.mf6.Modflow6Simulation.create_partition_labels` - method instead. - -Removed -~~~~~~~ - -- Removed ``imod.mf6.WellDisStructured`` and ``imod.mf6.WellDisVertices``. Use - :class:`imod.mf6.Well` and :class:`imod.mf6.LayeredWell` instead. The - :class:`imod.mf6.Well` package can be used to specify wells with filters, - :class:`imod.mf6.LayeredWell` directly to layers. -- Removed ``imod.mf6.multimodel.partition_generator.get_label_array``, use - :func:`imod.prepare.create_partition_labels` instead. -- Removed ``imod.formats.idf.read`` use :func:`imod.formats.idf.open` instead. -- Removed ``imod.formats.rasterio.read`` use :func:`imod.formats.rasterio.open` instead. -- Removed ``head`` argument for :class:`imod.mf6.InitialConditions`, use - ``start`` instead. -- Removed ``cell_averaging`` argument for :class:`imod.mf6.NodePropertyFlow`, - use ``alternative_cell_averaging`` instead. -- Removed ``set_repeat_stress`` method from boundary condition packages like - :class:`imod.mf6.River`. Use ``repeat_stress`` argument instead. -- Removed ``time_discretization`` method from - :class:`imod.mf6.Modflow6Simulation` and :class:`imod.wq.SeawatModel`. Use - :meth:`imod.mf6.Modflow6Simulation.create_time_discretization` and - :meth:`imod.wq.SeawatModel.create_time_discretization` instead. -- Removed ``imod.util.round_extent``, use :func:`imod.prepare.round_extent` - instead. -- Removed :class:`imod.prepare.Regridder`. Use the `xugrid regridder - `_ instead. - - -[1.0.0rc4] - 2025-06-20 ------------------------ - -Added -~~~~~ - -- Added ``weights`` argument to :func:`imod.prepare.create_partition_labels` to - weigh how the simulation should be partioned. Areas with higher weights will - result in smaller partions. -- iMOD Python version is now written in a comment line to MODFLOW6 and - MetaSWAP's ``para_sim.inp`` files. This is useful for debugging purposes. -- Added option ``ignore_time_purge_empty`` to - :meth:`imod.mf6.Modflow6Simulation.split` to consider a package empty if its - first times step is all nodata. This can save a lot of time splitting - transient models. -- Add :class:`imod.mf6.ValidationSettings` to specify validation settings for - MODFLOW 6 simulations. You can provide it to the - :class:`imod.mf6.Modflow6Simulation` constructor. - -Fixed -~~~~~ - -- Upon providing an unexpected coordinate in the mask or regridding grid, - :meth:`imod.mf6.Modflow6Simulation.regrid_like` and - :meth:`imod.mf6.Modflow6Simulation.mask_all_models` now present the unexpected - coordinates in the error message. -- :class:`imod.mf6.VerticesDiscretization` now correctly sets the ``xorigins`` - and ``yorigins`` options in the ``.disv`` file. Incorrect origins cause issues - when splitting models and computing with XT3D on the exchanges. -- :func:`imod.mf6.open_cbc` and :func:`imod.mf6.open_hds` now account for - xorigins and yorigins for models ran with - :class:`imod.mf6.VerticesDiscretization`. **WARNING**: Given that these were - set incorrectly in previous versions of iMOD Python (see previous item in this - list), this means that reading MODFLOW6 DISV output of models generated with a - previous version of iMOD Python will result in a grid with an erroneous - offset. You can work around this by creating the model again with this - version of iMOD Python or newer. -- :meth:`imod.mf6.Modflow6Simulation.split` supports label array with a - different name than ``"idomain"``. -- :func:`imod.msw.MetaSwapModel.from_imod5_data` now supports the usage of - relative paths for the extra files block. -- Bug in :meth:`imod.msw.Sprinkling.write` where MetaSWAP svats with surface - water sprinkling and no groundwater sprinkling activated were not written to - ``scap_svat.inp``. -- :class:`imod.msw.IdfMapping` swapped order of y_grid and x_grid in dictionary - for writing the correct order of coordinates in idf_svat.inp. -- Improved performance of :meth:`imod.mf6.Modflow6Simulation.split` and - :meth:`imod.mf6.Modflow6Simulation.mask_all_models` when using dask. -- Fixed bug in :meth:`imod.mf6.Modflow6Simulation.mask_all_models` for unstructured grids - with a spatial dimension that differs from the default ``"mesh2d_nFaces"``. -- Fixed bug in :meth:`imod.mf6.Well.cleanup` and - :meth:`imod.mf6.LayeredWell.cleanup` which caused an error when called with an - unstructured discretization. -- Fixed bug in :func:`imod.formats.prj.open_projectfile_data` which caused an - error when a periods keyword was used having an upper case. -- Poor performance of :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` when the ``imod5_data`` contained - a well system with a large number of wells (>10k). -- :meth:`imod.mf6.River.from_imod5_data`, - :meth:`imod.mf6.Drainage.from_imod5_data`, - :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data` can now deal with - constant values for variables. One variable per package still needs to be a - grid. -- Fix bug where an error was thrown in ``get_non_grid_data`` when calling the - ``.cleanup`` and ``regrid_like`` methods on a boundary condition package with - a repeated stress. For example, :meth:`imod.mf6.River.cleanup` or - :meth:`imod.mf6.River.regrid_like`. -- Fix bug where an error was thrown in :class:`imod.mf6.Well` when an entry had - to be filtered and its ``id`` didn't match the index. -- Improved performance of :class:`imod.mf6.Modflow6Simulation.split` for - structured models, as unnecessary masking is avoided. -- Fixed warning thrown by type dispatcher about ``~GeoDataFrameType`` -- Fixed bug where variables in a package with only a ``"layer"`` coordinate - could not be regridded or masked. - -Changed -~~~~~~~ - -- :meth:`imod.wq.SeawatModel.write` now throws an error if trying to write in a - directory with a space in the path. (iMOD-WQ does not support this.) -- `imod.mf6.multimodel.partition_generator.get_label_array` moved to - :func:`imod.prepare.create_partition_labels`. -- :func:`imod.prepare.create_partition_labels` structured grids are now - partioned by METIS instead (just like already was the case for unstructured - grids). This results in more balanced partitions for grids with non-square - domains or lots of inactive cells. Downside is that the partitions are more - often than not perfectly rectangular in shape. -- :func:`imod.prepare.create_partition_labels` now returns a griddata with the - name ``"label"`` instead of ``"idomain"``. -- Upon providing the wrong type to one of the options of - :class:`imod.mf6.GroundwaterFlowModel`, - :class:`imod.mf6.GroundwaterTransportModel`, this will throw a - ``ValidationError`` upon initialization and writing. -- You can now also provide ``repeat_stress`` as dictionary to imod.mf6 - boundary conditions, such as :class:`imod.mf6.River`, :class:`imod.mf6.Drainage`, and - :class:`imod.mf6.GeneralHeadBoundary`. -- :meth:`imod.mf6.ConstantHead.from_imod5_data`, - :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data`, - :meth:`imod.mf6.River.from_imod5_data`, - :meth:`imod.mf6.Recharge.from_imod5_data`, and - :meth:`imod.mf6.Drainage.from_imod5_data` now forward fill data over time, - instead of clipping, when selecting a start time that is inbetween two data - records. -- :meth:`imod.mf6.ConstantHead.from_imod5_data` and - :meth:`imod.mf6.Recharge.from_imod5_data` got extra arguments for - ``period_data``, ``time_min`` and ``time_max``. -- :func:`imod.visualize.read_imod_legend` now also returns the labels as an extra - argument. Update your code by changing - ``colors, levels = read_imod_legend(...)`` to - ``colors, levels, labels = read_imod_legend(...)``. - - -[1.0.0rc3] - 2025-04-17 ------------------------ - -Added -~~~~~ - -- :meth:`imod.msw.MetaSwapModel.clip_box` to clip MetaSWAP models. -- Methods of class :class:`imod.mf6.Modflow6Simulation` can now be logged. -- :func:`imod.prepare.cleanup.cleanup_wel_layered` to clean up wells assigned - to layers. - - -Fixed -~~~~~ - -- Fixed bug where :meth:`imod.mf6.River.clip_box`, - :meth:`imod.mf6.Drainage.clip_box`, and - :meth:`imod.mf6.GeneralHeadBoundary.clip_box` threw an error when - ``time_start`` or ``time_end`` were set to ``None`` and a ``"repeat_stress"`` - was included in the dataset. -- Fixed bug where :meth:`imod.mf6.package.copy` threw an error. -- Sorting issue in :func:`imod.prepare.assign_wells`. This could cause - :class:`imod.mf6.Well` to assign wells to the wrong cells. -- Fixed crash upon calling :meth:`imod.mf6.Well.clip_box` when the top/bottom - arguments are specified. This could cause :class:`imod.mf6.Well` to crash - when wells are located outside the extent of the layer model. - - -[1.0.0rc2] - 2025-03-05 ------------------------ - -From this release on, we recommend using `xugrid's regridding utilities -`_ for -regridding individual grids instead of :class:`imod.prepare.Regridder`. Xugrid's -regridders are tested to be about 10 times faster than -:class:`imod.prepare.Regridder`. There is one small difference: xugrid's -``xugrid.BaryCentricInterpolator`` considers sample points of the destination -grid that lie on the source grid's cell edges to be inside, whereas -:class:`imod.prepare.Regridder` considers them to be outside. This difference is -negligible for most applications, but might create slightly fewer ``np.nan`` -values than before. - -Removed -~~~~~~~ -- ``imod.flow`` module has been removed for generating iMODFLOW models. Use - ``imod.mf6`` instead to generate MODFLOW 6 models. - -Added -~~~~~ - -- Support for Python 3.13. -- :meth:`imod.mf6.Recharge.from_imod5_data`, - :meth:`imod.mf6.River.from_imod5_data`, - :meth:`imod.mf6.Drainage.from_imod5_data`, and - :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data` now assign negative layer - numbers to the first active layer. -- :func:`imod.prepare.DISTRIBUTING_OPTION` got a new setting - ``by_corrected_thickness``. This matches DISTRCOND=-1 in iMOD5. -- :func:`imod.prepare.cleanup_hfb` to clean up HFB geometries. -- :meth:`imod.mf6.HorizontalFlowBarrierResistance.cleanup`, - :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.cleanup`, - to clean up HFB geometries crossing inactive model cells. -- :class:`imod.util.RegridderWeightsCache` to store regridder weights for - regridding multiple times. -- :class:`imod.util.RegridderType` to specify regridder types. - -Changed -~~~~~~~ - -- :func:`imod.formats.prj.open_projectfile_data` now also assigns negative and - zero layer numbers to grid coordinates. -- In :class:`imod.mf6.StructuredDiscretization`, IDOMAIN can now respectively be - > 0 to indicate an active cell and <0 to indicate a vertical passthrough cell, - consistent with MODFLOW 6. Previously this could only be indicated with 1 and - -1. -- :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` now also accept the argument - ``times = "steady-state"``, for the simulation is assumed to be "steady-state" - and well timeseries are averaged. -- The ``drn`` attribute of :class:`imod.prepare.SimulationAllocationOptions` has - the ``at_elevation`` of :func:`imod.prepare.ALLOCATION_OPTION` option now set - as default. This means by default drainage cells are placed differently in - :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`. -- :class:`imod.mf6.Well`, :class:`imod.mf6.LayeredWell`, - :func:`imod.prepare.assign_wells`, :meth:`imod.mf6.Well.from_imod5_data` - and :meth:`imod.mf6.LayeredWell.from_imod5_data` now have default values for - ``minimum_thickness`` and ``minimum_k`` set to 0.0. -- When intitating a MODFLOW 6 package with a ``layer`` coordinate with - values <= 0, iMOD Python will throw an error. -- :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB now - validate whether proper type of geometry is provided, respectively Polygon for - :class:`imod.mf6.HorizontalFlowBarrierResistance`, and LineString for - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance`. -- Relaxed validation for :class:`imod.msw.MetaSwapModel` if ``FileCopier`` - package is present. -- Change aterisk to dash and tabs to four spaces in ``ValidationError`` messages. -- :func:`imod.prepare.laplace_interpolate` has been simplified, using - ``scipy.sparse.linalg.cg`` as the backend. We've remove the support for the - ``ibound`` argument, the ``iter1`` argument has been dropped, ``mxiter`` has - been renamed to ``maxiter``, ``close`` has been renamed to ``rtol``. -- Moved ``imod.mf6.utilities.regrid.RegridderWeightsCache`` to the - :class:`imod.util.regrid.RegridderWeightsCache`. - -Fixed -~~~~~ - -- :meth:`imod.mf6.GroundwaterFlowModel.mask_all_packages` now preserves the ``dx`` and - ``dy`` coordinates -- :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` ignore well rates preceding first - element of ``times``. -- :meth:`imod.mf6.Well.from_imod5_data` and - :meth:`imod.mf6.LayeredWell.from_imod5_data` now sum the rates of well entries - that are on the exact same location (same x, y, and depth) instead of taking - the values of the first entry. -- :meth:`imod.mf6.River.from_imod5_data` now preserves the drainage cells - created with the ``stage_to_riv_bot_drn_above`` option of - :func:`imod.prepare.ALLOCATION_OPTION`. -- Bug in :func:`imod.prepare.distribute_riv_conductance` where conductances were - set to ``np.nan`` for cells where ``stage`` equals ``bottom_elevation`` when - :func:`imod.prepare.DISTRIBUTING_OPTION` was set to ``by_crosscut_thickness``, - ``by_crosscut_transmissivity``, ``by_corrected_transmissivity``. -- :meth:`imod.mf6.NodePropertyFlow.from_imod5_data` now defaults to 90 degrees - for missing layers ``imod5_data`` instead of 0 degrees. -- Bug in :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` where an error was - raised in case the ``"cap"`` package was present in the ``imod5_data``. -- Bug where :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` and - :meth:`imod.mf6.Recharge.from_imod5_cap_data` threw an error if the ``"cap"`` - in the ``imod5_data`` had a ``"layer"`` dimension and coordinate. -- :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` will convert the - ``max_abstraction_groundwater`` and ``max_abstraction_surfacewater`` capacity - from mm/d to m3/d. -- :class:`imod.msw.TimeOutputControl` now starts counting at 0.0 instead of 1.0, - like MetaSWAP expects. -- Models imported with :meth:`imod.msw.MetaSwapModel.from_imod5_data` can be - written with ``validate`` set to True. -- :meth:`imod.mf6.Recharge.from_imod5_cap_data` now returns a 2D array with a - ``"layer"`` coordinate of ``1`` as otherwise ``primod`` throws an error when - trying to derive recharge-svat mappings. -- Fixed part of the code that made Pandas, Geopandas, and xarray throw a lot of - ``FutureWarning`` and ``DeprecationWarning``. -- Fixed performance issue when converting very large wells (>10k) with - :meth:`imod.mf6.Well.to_mf6_pkg` and :meth:`imod.mf6.LayeredWell.to_mf6_pkg`, - such as those created with :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` - for a large grid. -- Fixed issue where an error was thrown when deriving couplings for - :class:`imod.msw.CouplerMapping` and computing svats in - :class:`imod.msw.GridData` with ``dask>=2025.2.0``. -- Fixed a bug where :func:`imod.mf6.out.open_cbc` did not properly sum fluxes - for a single boundary condition package when multiple entries were present in - the same cell. This never happened with models generated by iMOD Python, as it - cannot generate these boundary conditions, but could be a problem with models - generated by iMOD5 and Flopy. -- Removed duplicate entries in ``mod2svat.inp`` generated by - :class:`imod.msw.CouplerMapping` as MetaSWAP cannot handle this. - - -[1.0.0rc1] - 2024-12-20 ------------------------ - -Small post-release fix for installation instructions in documentation. - -[1.0.0rc0] - 2024-12-20 ------------------------ - -Added -~~~~~ - -- :class:`imod.msw.MeteoGridCopy` to copy existing `mete_grid.inp` files, so - ASCII grids in large existing meteo databases do not have to be read. -- :class:`imod.msw.FileCopier` to copy settings and lookup tables in existing - ``.inp`` files. -- :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` to construct a - :class:`imod.mf6.LayeredWell` package from iMOD5 data in the CAP package (for - MetaSWAP). Currently only griddata (IDF) is supported. -- :meth:`imod.mf6.Recharge.from_imod5_cap_data` to construct a recharge package - for coupling a MODFLOW 6 model to MetaSWAP. -- :meth:`imod.msw.MetaSwapModel.from_imod5_data` to construct a MetaSWAP model - from data in an iMOD5 projectfile. -- :meth:`imod.msw.MetaSwapModel.write` has a ``validate`` argument, which can be - used to turn off validation upon writing, use at your own risk! -- :class:`imod.msw.MetaSwapModel` got ``settings`` argument to set simulation - settings. -- :func:`imod.data.tutorial_03` to load data for the iMOD Documentation - tutorial. -- :meth:`imod.mf6.Modflow6Simulation.dump` now saves iMOD Python version number. - -Fixed -~~~~~ - -- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB - packages could not be allocated to cell edges when idomain in layer 1 was - largely inactive. -- Fixed bug where :meth:`imod.mf6.HorizontalFlowBarrierResistance.clip_box`, - :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.clip_box` methods - only returned deepcopy instead of actually clipping the line geometries. -- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB - packages could not be clipped or copied with xarray >= 2024.10.0. -- Fixed crash upon calling :meth:`imod.mf6.GroundwaterFlowModel.dump`, when a - :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` or other HFB - package was assigned to the model. -- :meth:`imod.mf6.Modflow6Simulation.regrid_like` can now regrid a structured - model to an unstructured grid. -- :meth:`imod.mf6.Modflow6Simulation.regrid_like` throws a - ``NotImplementedError`` when attempting to regrid an unstructured model to a - structured grid. -- :class:`imod.msw.Sprinkling` now correctly writes source svats to - scap_svat.inp file. -- :func:`imod.evaluate.calculate_gxg`, upon providing a head dataarray chunked - over time, will no longer error with ``ValueError: Object has inconsistent - chunks along dimension bimonth. This can be fixed by calling unify_chunks().`` -- Improved performance of regridding package data. - - -Changed -~~~~~~~ - -- :class:`imod.msw.Infiltration`'s variables ``upward_resistance`` and - ``downward_resistance`` now require a ``subunit`` coordinate. -- Variables ``max_abstraction_groundwater`` and ``max_abstraction_surfacewater`` - in :class:`imod.msw.Sprinkling` now needs to have a subunit coordinate. -- If ``"cap"`` package present in ``imod5_data``, - :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data` now automatically adds a - well for metaswap sprinkling named ``"msw-sprinkling"`` -- Less strict validation for :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB packages for - simulations which are imported with - :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` -- DeprecationWarning thrown upon initializing :class:`imod.prepare.Regridder`. - We plan to remove this object in the final 1.0 release. `Use the xugrid - regridder to regrid individual grids instead. - `_ To - regrid entire MODFLOW 6 packages or simulations, `see the user guide here. - `_. - -[0.18.1] - 2024-11-20 ---------------------- - -Added -~~~~~ - -- :class:`imod.prepare.SimulationAllocationOptions`, - :class:`imod.prepare.SimulationDistributingOptions`, which are used to store - default allocation and distributing options respectively. - -Fixed -~~~~~ - -- Relaxed validation for `imod.mf6.StructuredDiscretization` to also support - cells with zero thickness where IDOMAIN = 0. Before, only cells with a zero - thickness and IDOMAIN = -1 were supported, else the software threw a ``not all - values comply with criterion: > bottom``. -- Fix bug where no ``ValidationError`` was thrown if there is an active RCH, DRN, - GHB, or RIV cell where idomain = -1. - -Changed -~~~~~~~ - -- In :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, and - :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data` the arguments - ``allocation_options``, ``distributing_options`` are now optional. -- The order of arguments of :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, - and :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data`. It now is - ``imod5_data, period_data, times, allocation_options, distributing_options, regridder_types`` - instead of: - ``imod5_data, period_data, allocation_options, distributing_options, times, regridder_types`` - - -[0.18.0] - 2024-11-11 ---------------------- - -Fixed -~~~~~ - -- Multiple ``HorizontalFlowBarrier`` objects attached to - :class:`imod.mf6.GroundwaterFlowModel` are merged into a single horizontal - flow barrier for MODFLOW 6. -- Bug where error would be thrown when barriers in a ``HorizontalFlowBarrier`` - would be snapped to the same cell edge. These are now summed. -- Improve performance validation upon Package initialization -- Improve performance writing ``HorizontalFlowBarrier`` objects -- :func:`imod.mf6.open_cbc` failing with ``flowja=False`` on budget output for - DISV models if the model contained inactive cells. -- :func:`imod.mf6.open_cbc` now works for 2D and 1D models. -- :func:`imod.prepare.fill` previously assigned to the result of an xarray - ``.sel`` operation. This might not work for dask backed data and has been - addressed. -- Added :func:`imod.mf6.open_dvs` to read dependent variable output files like - the water content file of :class:`imod.mf6.UnsaturatedZoneFlow`. -- `imod.prj.open_projectfile_data` is now able to also read IPF data for - sprinkling wells in the CAP package. -- Fix that caused iMOD Python to break upon import with numpy >=1.23, <2.0 . -- ValidationError message now contains a suggestion to use the cleanup method, - if available in the erroneous package. -- Bug where error was thrown when :class:`imod.mf6.NodePropertyFlow` was - assigned to :class:`imod.mf6.GroundwaterFlowModel` with key different from - ``"npf"`` upon writing, along with well or horizontal flow barrier packages. - - -Changed -~~~~~~~ - -- :class:`imod.mf6.Well` now also validates that well filter top is above well - filter bottom -- :func:`imod.formats.prj.open_projectfile_data` now also imports well filter - top and bottom. -- :class:`imod.mf6.Well` now logs a warning if any wells are removed during writing. -- :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.HorizontalFlowBarrierMultiplier`, - :class:`imod.mf6.HorizontalFlowBarrierHydraulicCharacteristic` now uses - vertical Polygons instead of Linestrings as geometry, and ``"ztop"`` and - ``"zbottom"`` variables are not used anymore. See - :func:`imod.prepare.linestring_to_square_zpolygons` and - :func:`imod.prepare.linestring_to_trapezoid_zpolygons` to generate these - polygons. -- :func:`imod.formats.prj.open_projectfile_data` now returns well data grouped - by ipf name, instead of generic, separate number per entry. -- :class:`imod.mf6.Well` now supports wells which have a filter with zero - length, where ``"screen_top"`` equals ``"screen_bottom"``. -- :class:`imod.mf6.Well` shares the same default ``minimum_thickness`` as - :func:`imod.prepare.assign_wells`, which is 0.05, before this was 1.0. -- :func:`imod.prepare.allocate_drn_cells`, - :func:`imod.prepare.allocate_ghb_cells`, - :func:`imod.prepare.allocate_riv_cells`, now allocate to the first model layer - when elevations are above or equal to model top for all methods in - :func:`imod.prepare.ALLOCATION_OPTION`. -- :meth:`imod.mf6.Well.to_mf6_pkg` got a new argument: - ``strict_well_validation``, which controls the behavior for when wells are - removed entirely during their assignment to layers. This replaces the - ``is_partitioned`` argument. -- :func:`imod.prepare.fill` now takes a ``dims`` argument instead of ``by``, - and will fill over N dimensions. Secondly, the function no longer takes - an ``invalid`` argument, but instead always treats NaNs as missing. -- Reverted the need for providing WriteContext objects to MODFLOW 6 Model and - Package objects' ``write`` method. These now use similar arguments to the - :meth:`imod.mf6.Modflow6Simulation.write` method. -- :class:`imod.msw.CouplingMapping`, :class:`imod.msw.Sprinkling`, - `imod.msw.Sprinkling.MetaSwapModel`, now take the - :class:`imod.mf6.mf6_wel_adapter.Mf6Wel` and the - :class:`imod.mf6.StructuredDiscretization` packages as arguments at their - respective ``write`` method, instead of upon initializing these MetaSWAP - objects. -- :class:`imod.msw.CouplingMapping` and :class:`imod.msw.Sprinkling` now take - the :class:`imod.mf6.mf6_wel_adapter.Mf6Wel` as well argument instead of the - deprecated ``imod.mf6.WellDisStructured``. - - -Added -~~~~~ - -- :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` to import imod5 data - loaded with :func:`imod.formats.prj.open_projectfile_data` as a MODFLOW 6 - simulation. -- :func:`imod.prepare.linestring_to_square_zpolygons` and - :func:`imod.prepare.linestring_to_trapezoid_zpolygons` to generate vertical - polygons that can be used to specify horizontal flow barriers, specifically: - :class:`imod.mf6.HorizontalFlowBarrierResistance`, - :class:`imod.mf6.HorizontalFlowBarrierMultiplier`, - :class:`imod.mf6.HorizontalFlowBarrierHydraulicCharacteristic`. -- :class:`imod.mf6.LayeredWell` to specify wells directly to layers instead - assigning them with filter depths. -- :func:`imod.prepare.cleanup_drn`, :func:`imod.prepare.cleanup_ghb`, - :func:`imod.prepare.cleanup_riv`, :func:`imod.prepare.cleanup_wel`. These are - utility functions to clean up drainage, general head boundaries, and rivers, - respectively. -- :meth:`imod.mf6.Drainage.cleanup`, - :meth:`imod.mf6.GeneralHeadboundary.cleanup`, :meth:`imod.mf6.River.cleanup`, - :meth:`imod.mf6.Well.cleanup` convenience methods to call the corresponding - cleanup utility functions with the appropriate arguments. -- :meth:`imod.msw.MetaSwapModel.regrid_like` to regrid MetaSWAP models. This is - still experimental functionality, regridding the :class:`imod.msw.Sprinkling` - is not yet supported. -- The context :func:`imod.util.context.print_if_error` to print an error instead - of raising it in a ``with`` statement. This is useful for code snippets which - definitely will fail. -- :meth:`imod.msw.MetaSwapModel.regrid_like` to regrid MetaSWAP models. -- :meth:`imod.mf6.GroundwaterFlowModel.prepare_wel_for_mf6` to prepare wells for - MODFLOW 6, for debugging purposes. - -Removed -~~~~~~~ - -- :func:`imod.formats.prj.convert_to_disv` has been removed. This functionality - has been replaced by :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`. To - convert a structured simulation to an unstructured simulation, call: - :meth:`imod.mf6.Modflow6Simulation.regrid_like` - - -[0.17.2] - 2024-09-17 ---------------------- - -Fixed -~~~~~ -- :func:`imod.formats.prj.open_projectfile_data` now reports the path to a - faulty IPF or IDF file in the error message. -- Support for Numpy 2.0 - -Added -~~~~~ -- Added objects with regrid settings. These can be used to provide custom - settings: :class:`imod.mf6.regrid.ConstantHeadRegridMethod`, - :class:`imod.mf6.regrid.DiscretizationRegridMethod`, - :class:`imod.mf6.regrid.DispersionRegridMethod`, - :class:`imod.mf6.regrid.DrainageRegridMethod`, - :class:`imod.mf6.regrid.EmptyRegridMethod`, - :class:`imod.mf6.regrid.EvapotranspirationRegridMethod`, - :class:`imod.mf6.regrid.GeneralHeadBoundaryRegridMethod`, - :class:`imod.mf6.regrid.InitialConditionsRegridMethod`, - :class:`imod.mf6.regrid.MobileStorageTransferRegridMethod`, - :class:`imod.mf6.regrid.NodePropertyFlowRegridMethod`, - :class:`imod.mf6.regrid.RechargeRegridMethod`, - :class:`imod.mf6.regrid.RiverRegridMethod`, - :class:`imod.mf6.regrid.SpecificStorageRegridMethod`, - :class:`imod.mf6.regrid.StorageCoefficientRegridMethod`. - -Changed -~~~~~~~ -- Instead of providing a dictionary with settings to ``Package.regrid_like``, - provide one of the following ``RegridMethod`` objects: - :class:`imod.mf6.regrid.ConstantHeadRegridMethod`, - :class:`imod.mf6.regrid.DiscretizationRegridMethod`, - :class:`imod.mf6.regrid.DispersionRegridMethod`, - :class:`imod.mf6.regrid.DrainageRegridMethod`, - :class:`imod.mf6.regrid.EmptyRegridMethod`, - :class:`imod.mf6.regrid.EvapotranspirationRegridMethod`, - :class:`imod.mf6.regrid.GeneralHeadBoundaryRegridMethod`, - :class:`imod.mf6.regrid.InitialConditionsRegridMethod`, - :class:`imod.mf6.regrid.MobileStorageTransferRegridMethod`, - :class:`imod.mf6.regrid.NodePropertyFlowRegridMethod`, - :class:`imod.mf6.regrid.RechargeRegridMethod`, - :class:`imod.mf6.regrid.RiverRegridMethod`, - :class:`imod.mf6.regrid.SpecificStorageRegridMethod`, - :class:`imod.mf6.regrid.StorageCoefficientRegridMethod`. -- Renamed ``imod.mf6.LayeredHorizontalFlowBarrier`` classes to - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierHydraulicCharacteristic`, - :class:`imod.mf6.SingleLayerHorizontalFlowBarrierMultiplier`, - -Fixed -~~~~~ -- :func:`imod.formats.prj.open_projectfile_data` now reports the path to a - faulty IPF or IDF file in the error message. - - - - -[0.17.1] - 2024-05-16 ---------------------- - -Added -~~~~~ -- Added function :func:`imod.util.spatial.gdal_compliant_grid` to make spatial - coordinates of a NetCDF interpretable for GDAL (and so QGIS). -- Added ``crs`` argument to :func:`imod.util.spatial.mdal_compliant_ugrid2d`, - :meth:`imod.mf6.Simulation.dump`, :meth:`imod.mf6.GroundwaterFlowModel.dump`, - :meth:`imod.mf6.GroundwaterTransportModel.dump`, to add a coordinate reference - system to dumped files, to ease loading them in QGIS. - -Changed -~~~~~~~ -- :meth:`imod.mf6.Simulation.dump`, :meth:`imod.mf6.GroundwaterFlowModel.dump`, - :meth:`imod.mf6.GroundwaterTransportModel.dump` write with necessary - attributes to NetCDF to make these files interpretable for GDAL (and so QGIS). - -Fixed -~~~~~ -- Fix missing API docs for ``dump`` and ``write`` methods. - - -[0.17.0] - 2024-05-13 ---------------------- - -Added -~~~~~ -- Added functions to allocate planar grids over layers for the topsystem in - :func:`imod.prepare.allocate_drn_cells`, - :func:`imod.prepare.allocate_ghb_cells`, - :func:`imod.prepare.allocate_rch_cells`, - :func:`imod.prepare.allocate_riv_cells`, for this multiple options can be - selected, available in :func:`imod.prepare.ALLOCATION_OPTION`. -- Added functions to distribute conductances of planar grids over layers for the - topsystem in :func:`imod.prepare.distribute_riv_conductance`, - :func:`imod.prepare.distribute_drn_conductance`, - :func:`imod.prepare.distribute_ghb_conductance`, for this multiple options can - be selected, available in :func:`imod.prepare.DISTRIBUTING_OPTION`. -- :func:`imod.prepare.celltable` supports an optional ``dtype`` argument. This - can be used, for example, to create celltables of float values. -- Added ``fixed_cell`` option to :class:`imod.mf6.Recharge`. This option is - relevant for phreatic models, not using the Newton formulation and model cells - can become inactive. The prefered method for phreatic models is to use the - Newton formulation, where cells remain active, and this option irrelevant. -- Added support for ``ats_outer_maximum_fraction`` in :class:`imod.mf6.Solution`. -- Added validation for ``linear_acceleration``, ``rclose_option``, - ``scaling_method``, ``reordering_method``, ``print_option`` and ``no_ptc`` - entries in :class:`imod.mf6.Solution`. - -Fixed -~~~~~ -- No ``ValidationError`` thrown anymore in :class:`imod.mf6.River` when - ``bottom_elevation`` equals ``bottom`` in the model discretization. -- When wells outside of the domain are added, an exception is raised with an - error message stating a well is outside of the domain. -- When importing data from a .prj file, the multipliers and additions specified for - ipf and idf files are now applied -- Fix bug where y-coords were flipped in :class:`imod.msw.MeteoMapping` - -Changed -~~~~~~~ -- Replaced csv_output by outer_csvfile and inner_csvfile in - :class:`imod.mf6.Solution` to match newer MODFLOW 6 releases. -- Changed no_ptc from a bool to an option string in :class:`imod.mf6.Solution`. -- Removed constructor arguments `source` and `target` from - ``imod.mf6.utilities.regrid.RegridderWeightsCache``, as they were not - used. -- :func:`imod.mf6.open_cbc` now returns arrays which contain np.nan for cells where - budget variables are not defined. Based on new budget output a disquisition between - active cells but zero flow and inactive cells can be made. -- :func:`imod.mf6.open_cbc` now returns package type in return budget names. New format - is "package type"-"optional package variable"_"package name". E.g. a River package - named ``primary-sys`` will get a budget name ``riv_primary-sys``. An UZF package - with name ``uzf-sys1`` will get a budget name ``uzf-gwrch_uzf-sys1`` for the - groundwater recharge budget from the UZF-CBC. - - -[0.16.0] - 2024-03-29 ---------------------- - -Added -~~~~~ -- The :func:`imod.mf6.model.mask_all_packages` now also masks the idomain array - of the model discretization, and can be used with a mask array without a layer - dimension, to mask all layers the same way -- Validation for incompatible settings in the :class:`imod.mf6.NodePropertyFlow` - and :class:`imod.mf6.Dispersion` packages. -- Checks that only one flow model is present in a simulation when calling - :func:`imod.mf6.Modflow6Simulation.regrid_like`, - :func:`imod.mf6.Modflow6Simulation.clip_box` or - :func:`imod.mf6.Modflow6Simulation.split` -- Added support for coupling a GroundwaterFlowModel and Transport Model i.c.w. - the 6.4.3 release of MODFLOW. Using an older version of iMOD Python with this - version of MODFLOW will result in an error. -- :meth:`imod.mf6.Modflow6Simulation.split` supports splitting transport models, - including multi-species simulations. -- :meth:`imod.mf6.Modflow6Simulation.open_concentration` and - :meth:`imod.mf6.Modflow6Simulation.open_transport_budget` support opening - split multi-species simulations. - :meth:`imod.mf6.Modflow6Simulation.regrid_like` can now regrid simulations - that have 1 or more transport models. -- added logging to various initialization methods, write methods and dump - methods. `See the documentation - `_ - how to activate logging. -- added :func:`imod.data.hondsrug_simulation` and - :func:`imod.data.hondsrug_crosssection` data. -- simulations and models that include a lake package now raise an exception on - clipping, partitioning or regridding. - -Changed -~~~~~~~ -- :meth:`imod.mf6.Modflow6Simulation.open_concentration` and - :meth:`imod.mf6.Modflow6Simulation.open_transport_budget` raise a - ``ValueError`` if ``species_ls`` is provided with incorrect length. - -Fixed -~~~~~ -- Incorrect validation error ``data values found at nodata values of idomain`` - for boundary condition packages with a scalar coordinate not set as dimension. -- Fix issue where :func:`imod.formats.idf.open_subdomains` and - :func:`imod.mf6.Modflow6Simulation.open_head` (for split simulations) would - return arrays with incorrect ``dx`` and ``dy`` coordinates for equidistant - data. -- Fix issue where :func:`imod.formats.idf.open_subdomains` returned a flipped ``dy`` - coordinate for nonequidistant data. -- Made :func:`imod.util.round_extent` available again, as it was moved without - notice. Function now throws a DeprecationWarning to use - :func:`imod.prepare.spatial.round_extent` instead. -- :meth'`imod.mf6.Modflow6Simulation.write` failed after splitting the - simulation. This has been fixed. -- modflow options like "print flow", "save flow", and "print input" can now be - set on :class:`imod.mf6.Well` -- when regridding a :class:`imod.mf6.Modflow6Simulation`, - :class:`imod.mf6.GroundwaterFlowModel`, - :class:`imod.mf6.GroundwaterTransportModel` or a :class:`imod.mf6.package`, - regridding weights are now cached and can be re-used over the different - objects that are regridded. This improves performance considerably in most use - cases: when regridding is applied over the same grid cells with the same - regridder type, but with different values/methods, multiple times. - -[0.15.3] - 2024-02-22 ---------------------- - -Fixed -~~~~~ -- Add missing required dependencies for installing with ``pip``: loguru and tomli. -- Ensure geopandas and shapely are optional dependencies again when - installing with ``pip``, and no import errors are thrown. -- Fixed bug where calling ``copy.deepcopy`` on - :class:`imod.mf6.Modflow6Simulation`, :class:`imod.mf6.GroundwaterFlowModel` - and :class:`imod.mf6.GroundwaterTransportModel` objects threw an error. - - -Added -~~~~~ -- Developer environment: Added pixi environment ``interactive`` to interactively - run code. Can be useful to plot data. -- :class:`imod.mf6.ApiPackage` was added. It can be added to both flow and - transport models, and its presence allows users to interact with libMF6.dll - through its API. -- Developer environment: Empty python 3.10, 3.11, 3.12 environments where pip - install and import imod can be tested. - - - -[0.15.2] - 2024-02-16 ---------------------- - -Fixed -~~~~~ -- iMOD Python now supports versions of pandas >= 2 -- Fixed bugs with clipping :class:`imod.mf6.HorizontalFlowBarrier` for - structured grids -- Packages and boundary conditions in the ``imod.mf6`` module will now throw an - error upon initialization if coordinate labels are inconsistent amongst - variables -- 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 -- Fix support for coupling a GroundwaterFlowModel and Transport Model i.c.w. - the 6.4.3 release of MODFLOW. Using an older version of iMOD Python - with this version of MODFLOW will result in an error. - - -Changed -~~~~~~~ -- We moved to using `pixi `_ to create development - environments. This replaces the ``imod-environment.yml`` conda environment. We - advice doing development installations with pixi from now on. `See the - documentation. `_ - This does not affect users who installed with ``pip install imod``, ``mamba - install imod`` or ``conda install imod``. -- Changed build system from ``setuptools`` to ``hatchling``. Users who did a - development install are adviced to run ``pip uninstall imod`` and ``pip - install -e .`` again. This does not affect users who installed with ``pip - install imod``, ``mamba install imod`` or ``conda install imod``. -- Decreased lower limit of MetaSWAP validation for x and y limits in the - ``IdfMapping`` from 0 to -9999999.0. - - -[0.15.1] - 2023-12-22 ---------------------- - -Fixed -~~~~~ -- Made ``specific_yield`` optional argument in - :class:`imod.mf6.SpecificStorage`, :class:`imod.mf6.StorageCoefficient`. -- Fixed bug where simulations with :class:`imod.mf6.Well` were not partitioned - into multiple models. -- Fixed erroneous default value for the ``out_of_bounds`` in - :func:`imod.select.points.point_values` -- Fixed bug where :class:`imod.mf6.Well` could not be assigned to the first cell - of an unstructured grid. -- HorizontalFlowBarrier package now dropped if completely outside partition in a - split model. -- HorizontalFlowBarrier package clipped with ``clip_by_grid`` based on active - cells, consistent with how other packages are treated by this function. This - affects the :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` and - :meth:`imod.mf6.Modflow6Simulation.split` methods. - - -Changed -~~~~~~~ -- All the references to GitLab have been replaced by GitHub references as - part of the GitHub migration. - -Added -~~~~~ -- Added comment in Modflow6 exchanges file (GWFGWF) denoting column header. -- Added Python 3.11 support. -- The GWF-GWF exchange options are derived from user created packages (NPF, OC) and - set automatically. -- Added the ``simulation_start_time`` and ``time_unit`` arguments. To the - ``Modflow6Simulation.open_`` methods, and ``imod.mf6.out.open_`` functions. - This converts the ``"time"`` coordinate to datetimes. -- added :meth:`imod.mf6.Modflow6Simulation.mask_all_models` to apply a mask to - all models under a simulation, provided the simulation is not split and the - models use the same discretization. - - -Changed -~~~~~~~ -- :meth:`imod.mf6.Well.mask` masks with a 2D grid instead of returning a - deepcopy of the package. - - -[0.15.0] - 2023-11-25 ---------------------- - -Fixed -~~~~~ -- The Newton option for a :class:`imod.mf6.GroundwaterFlowModel` was being ignored. This has been - corrected. -- The Contextily packages started throwing errors. This was caused because the - default tile provider being used was Stamen. However Stamen is no longer free - which caused Contextily to fail. The default tile provider has been changed to - OpenStreetMap to resolve this issue. -- :func:`imod.mf6.open_cbc` now reads saved cell saturations and specific discharges. -- :func:`imod.mf6.open_cbc` failed to read unstructured budgets stored - following IMETH1, most importantly the storage fluxes. -- Fixed support of Python 3.11 by dropping the obsolete ``qgs`` module. -- Bug in :class:`imod.mf6.SourceSinkMixing` where, in case of multiple active - boundary conditions with assigned concentrations, it would write a ``.ssm`` - file with all sources/sinks on one single row. -- Fixed bug where TypeError was thrown upond calling - :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` and - :meth:`imod.mf6.HorizontalFlowBarrier.mask`. -- Fixed bug where calling :meth:`imod.mf6.Well.clip_box` over only the time - dimension would remove the index coordinate. -- Validation errors are rendered properly when writing a simulation object or - regridding a model object. - -Changed -~~~~~~~ -- The imod-environment.yml file has been split in an imod-environment.yml - (containing all packages required to run imod-python) and a - imod-environment-dev.yml file (containing additional packages for developers). -- Changed the way :class:`imod.mf6.Modflow6Simulation`, - :class:`imod.mf6.GroundwaterFlowModel`, - :class:`imod.mf6.GroundwaterTransportModel`, and MODFLOW 6 packages are - represented while printing. -- The grid-agnostic packages :meth:`imod.mf6.Well.regrid_like` and - :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` now return a clip with the - grid exterior of the target grid - -Added -~~~~~ -- The unit tests results are now published on GitLab -- A ``save_saturation`` option to :class:`imod.mf6.NodePropertyFlow` which saves - cell saturations for unconfined flow. -- Functions :func:`imod.prepare.layer.get_upper_active_layer_number` and - :func:`imod.prepare.layer.get_lower_active_layer_number` to return planar - grids with numbers of the highest and lowest active cells respectively. -- Functions :func:`imod.prepare.layer.get_upper_active_grid_cells` and - :func:`imod.prepare.layer.get_lower_active_grid_cells` to return boolean - grids designating respectively the highest and lowest active cells in a grid. -- validation of ``transient`` argument in :class:`imod.mf6.StorageCoefficient` - and :class:`imod.mf6.SpecificStorage`. -- :meth:`imod.mf6.Modflow6Simulation.open_concentration`, - :meth:`imod.mf6.Modflow6Simulation.open_head`, - :meth:`imod.mf6.Modflow6Simulation.open_transport_budget`, and - :meth:`imod.mf6.Modflow6Simulation.open_flow_budget`, were added as convenience - methods to open simulation output easier (without having to specify paths). -- The :meth:`imod.mf6.Modflow6Simulation.split` method has been added. This method makes - it possible for a user to create a Multi-Model simulation. A user needs to - provide a submodel label array in which they specify to which submodel a cell - belongs. The method will then create the submodels and split the nested - packages. The split method will create the gwfgwf exchanges required to - connect the submodels. At the moment auxiliary variables ``cdist`` and - ``angldegx`` are only computed for structured grids. -- The label array can be generated through a convenience function - :func:`imod.mf6.partition_generator.get_label_array` -- Once a split simulation has been executed by MF6, we find head and balance - results in each of the partition models. These can now be merged into head and - balance datasets for the original domain using - :meth:`imod.mf6.Modflow6Simulation.open_concentration`, - :meth:`imod.mf6.Modflow6Simulation.open_head`, - :meth:`imod.mf6.Modflow6Simulation.open_transport_budget`, - :meth:`imod.mf6.Modflow6Simulation.open_flow_budget`. - In the case of balances, the exchanges through the partition boundary are not - yet added to this merged balance. -- Settings such as ``save_flows`` can be passed through - :meth:`imod.mf6.SourceSinkMixing.from_flow_model` -- Added :class:`imod.mf6.LayeredHorizontalFlowBarrierHydraulicCharacteristic`, - :class:`imod.mf6.LayeredHorizontalFlowBarrierMultiplier`, - :class:`imod.mf6.LayeredHorizontalFlowBarrierResistance`, for horizontal flow - barriers with a specified layer number. - - -Removed -~~~~~~~ -- Tox has been removed from the project. -- Dropped support for writing .qgs files directly for QGIS, as this was hard to - maintain and rarely used. To export your model to QGIS readable files, call - the ``dump`` method :class:`imod.mf6.Modflow6Simulation` with ``mdal_compliant=True``. - This writes UGRID NetCDFs which can read as meshes in QGIS. -- Removed ``declxml`` from repository. - -[0.14.1] - 2023-09-07 ---------------------- - -Changed -~~~~~~~ - -- TWRI MODFLOW 6 example uses the grid-agnostic :class:`imod.mf6.Well` - package instead of the ``imod.mf6.WellDisStructured`` package. - -Fixed -~~~~~ - -- :class:`imod.mf6.HorizontalFlowBarrier` would write to a binary file by - default. However, the current version of MODFLOW 6 does not support this. - Therefore, this class now always writes to text file. - - -[0.14.0] - 2023-09-06 ---------------------- - -Changed -~~~~~~~ - -- :class:`imod.mf6.HorizontalFlowBarrier` is specified by providing a geopandas - `GeoDataFrame - `_ - - -Added -~~~~~ - -- :meth:`imod.mf6.Modflow6Simulation.regrid_like` to regrid a Modflow6 simulation to a - new grid (structured or unstructured), using `xugrid's regridding - functionality. - `_ - Variables are regridded with pre-selected methods. The regridding - functionality is useful for a variety of applications, for example to test the - effect of different grid sizes, to add detail to a simulation (by refining the - grid) or to speed up a simulation (by coarsening the grid) to name a few -- :meth:`imod.mf6.Package.regrid_like` to regrid packages. The user can - specify their own custom regridder types and methods for variables. -- :meth:`imod.mf6.Modflow6Simulation.clip_box` got an extra argument - ``states_for_boundary``, which takes a dictionary with modelname as key and - griddata as value. This data is specified as fixed state on the model - boundary. At present only `imod.mf6.GroundwaterFlowModel` is supported, grid - data is specified as a :class:`imod.mf6.ConstantHead` at the model boundary. -- :class:`imod.mf6.Well`, a grid-agnostic well package, where wells can be - specified based on their x,y coordinates and filter top and bottom. - - -[0.13.2] - 2023-07-26 ---------------------- - -Changed -~~~~~~~ - -- :func:`imod.formats.rasterio.save` will now write ESRII ASCII rasters, even if - rasterio is not installed. A fallback function has been added specifically - for ASCII rasters. - -Fixed -~~~~~ - -- Geopandas and rasterio were imported at the top of a module in some places. - This has been fixed so that both are not optional dependencies when - installing via pip (installing via conda or mamba will always pull all - dependencies and supports full functionality). -- :meth:`imod.mf6.Modflow6Simulation._validate` now print all validation errors for all - models and packages in one message. -- The gen file reader can now handle feature id's that contain commas and spaces -- :class:`imod.mf6.EvapoTranspiration` now supports segments, by adding a - ``segment`` dimension to the ``proportion_depth`` and ``proportion_rate`` - variables. -- :class:`imod.mf6.EvapoTranspiration` template for ``.evt`` file now properly - formats ``nseg`` option. -- Fixed bug in :class:`imod.wq.Well` preventing saving wells without a time - dimension, but with a layer dimension. -- :class:`imod.mf6.DiscretizationVertices._validate` threw ``KeyError`` for - ``"bottom"`` when validating the package separately. - -Added -~~~~~ - -- :func:`imod.select.grid.active_grid_boundary_xy` & - :func:`imod.select.grid.grid_boundary_xy` are added to find grid boundaries. - -[0.13.1] - 2023-05-05 ---------------------- - -Added -~~~~~ - -- :class:`imod.mf6.SpecificStorage` and :class:`imod.mf6.StorageCoefficient` - now have a ``save_flow`` argument. - -Fixed -~~~~~ - -- :func:`imod.mf6.open_cbc` can now read storage fluxes without error. - - -[0.13.0] - 2023-05-02 ---------------------- - -Added -~~~~~ - -- :class:`imod.mf6.OutputControl` now takes parameters ``head_file``, - ``concentration_file``, and ``budget_file`` to specify where to store - MODFLOW 6 output files. -- :func:`imod.util.spatial.from_mdal_compliant_ugrid2d` to "restack" the variables that - have have been "unstacked" in :func:`imod.util.spatial.mdal_compliant_ugrid2d`. -- Added support for the Modflow6 Lake package -- :func:`imod.select.points_in_bounds`, :func:`imod.select.points_indices`, - :func:`imod.select.points_values` now support unstructured grids. -- Added support for the MODFLOW 6 Lake package: :class:`imod.mf6.Lake`, - :class:`imod.mf6.LakeData`, :class:`imod.mf6.OutletManning`, :class:`OutletSpecified`, - :class:`OutletWeir`. See the examples for an application of the Lake package. -- :meth:`imod.mf6.simulation.Modflow6Simulation.dump` now supports dumping to MDAL compliant - ugrids. These can be used to view and explore Modlfow 6 simulations in QGIS. - -Fixed -~~~~~ - -- :meth:`imod.wq.bas.BasicFlow.thickness` returns a DataArray with the correct - dimension order again. This confusingly resulted in an error when writing the - :class:`imod.wq.btn.BasicTransport` package. -- Fixed bug in :class:`imod.mf6.dis.StructuredDiscretization` and - :class:`imod.mf6.dis.VerticesDiscretization` where - ``inactive bottom above active cell`` was incorrectly raised. - -[0.12.0] - 2023-03-17 ---------------------- - -Added -~~~~~ - -- :func:`imod.prj.read_projectfile` to read the contents of a project file into - a Python dictionary. -- :func:`imod.prj.open_projectfile_data` to read/open the data that is pointed - to in a project file. -- :func:`imod.gen.read_ascii` to read the geometry stored in ASCII text .gen files. -- :class:`imod.mf6.hfb.HorizontalFlowBarrier` to support Modflow6's HFB - package, works well with `xugrid.snap_to_grid` function. -- :meth:`imod.mf6.simulation.Modflow6Simulation.dump` to dump a simulation to a toml file - which acts as a definition file, pointing to packages written as netcdf files. This - can be used to intermediately store Modflow6 simulations. - -Fixed -~~~~~ - -- :func:`imod.evaluate.budget.flow_velocity` now properly computes velocity by - dividing by the porosity. Before, this function computed the Darcian velocity. - -Changed -~~~~~~~ - -- :func:`imod.formats.ipf.save` will error on duplicate IDs for associated files if a - ``"layer"`` column is present. As a dataframe is automatically broken down - into a single IPF per layer, associated files for the first layer would be - overwritten by the second, and so forth. -- :meth:`imod.wq.Well.save` will now write time varying data to associated - files for extration rate and concentration. -- Choosing ``method="geometric_mean"`` in the Regridder will now result in NaN - values in the regridded result if a geometric mean is computed over negative - values; in general, a geometric mean should only be computed over physical - quantities with a "true zero" (e.g. conductivity, but not elevation). - -[0.11.6] - 2023-02-01 ---------------------- - -Added -~~~~~ - -- Added an extra optional argument in - :meth:`imod.couplers.metamod.MetaMod.write` named ``modflow6_write_kwargs``, - which can be used to provide keyword arguments to the writing of the MODFLOW 6 - Simulation. - -Fixed -~~~~~ - -- :func:`imod.mf6.out.disv.read_grb` Remove repeated construction of - ``UgridDataArray`` for ``top`` - -[0.11.5] - 2022-12-15 ---------------------- - -Fixed -~~~~~ - -- :meth:`imod.mf6.Modflow6Simulation.write` with ``binary=False`` no longer - results in invalid MODFLOW 6 input for 2D grid data, such as DIS top. -- ``imod.flow.ImodflowModel.write`` no longer writes incorrect project - files for non-grid values with a time and layer dimension. -- :func:`imod.evaluate.interpolate_value_boundaries`: Fix edge case when - successive values in z direction are exactly equal to the boundary value. - -Changed -~~~~~~~ - -- Removed ``meshzoo`` dependency. -- Minor changes to :mod:`imod.gen.gen` backend, to support `Shapely 2.0 - `_ , Shapely - version above equal v1.8 is now required. - -Added -~~~~~ - -- ``imod.flow.ImodflowModel.write`` now supports writing a - ``config_run.ini`` to convert the projectfile to a runfile or modflow 6 - namfile with iMOD5. -- Added validation of Modflow6 Flow and Transport models. Incorrect model input - will now throw a ``ValidationError``. To turn off the validation, set - ``validate=False`` upon package initialization and/or when calling - :meth:`imod.mf6.Modflow6Simulation.write`. - -[0.11.4] - 2022-09-05 ---------------------- - -Fixed -~~~~~ - -- :meth:`imod.mf6.GroundwaterFlowModel.write` will no longer error when a 3D - DataArray with a single layer is written. It will now accept both 2D and 3D - arrays with a single layer coordinate. -- Hotfixes for :meth:`imod.wq.model.SeawatModel.clip`, until `this merge request - `_ is - fulfilled. -- ``imod.flow.ImodflowModel.write`` will set the timestring in the - projectfile to ``steady-state`` for ``BoundaryConditions`` without a time - dimension. -- Added ``imod.flow.OutputControl`` as this was still missing. -- :func:`imod.formats.ipf.read` will no longer error when an associated files with 0 - rows is read. -- :func:`imod.evaluate.calculate_gxg` now correctly uses (March 14, March - 28, April 14) to calculate GVG rather than (March 28, April 14, April 28). -- :func:`imod.mf6.out.open_cbc` now correctly loads boundary fluxes. -- :meth:`imod.prepare.LayerRegridder.regrid` will now correctly skip values - if ``top_source`` or ``bottom_source`` are NaN. -- :func:`imod.gen.write` no longer errors on dataframes with empty columns. -- ``imod.mf6.BoundaryCondition.set_repeat_stress`` reinstated. This is - a temporary measure, it gives a deprecation warning. - -Changed -~~~~~~~ - -- Deprecate the current documentation URL: https://imod.xyz. For the coming - months, redirection is automatic to: - https://deltares.gitlab.io/imod/imod-python/. -- :func:`imod.formats.ipf.save` will now store associated files in separate directories - named ``layer1``, ``layer2``, etc. The ID in the main IPF file is updated - accordingly. Previously, if IDs were shared between different layers, the - associated files would be overwritten as the IDs would result in the same - file name being used over and over. -- ``imod.flow.ImodflowModel.time_discretization``, - :meth:`imod.wq.SeawatModel.time_discretization`, - :meth:`imod.mf6.Modflow6Simulation.time_discretization`, - are renamed to: - ``imod.flow.ImodflowModel.create_time_discretization``, - :meth:`imod.wq.SeawatModel.create_time_discretization`, - :meth:`imod.mf6.Modflow6Simulation.create_time_discretization`, -- Moved tests inside `imod` directory, added an entry point for pytest fixtures. - Running the tests now requires an editable install, and also existing - installations have to be reinstalled to run the tests. -- The ``imod.mf6`` model packages now all run type checks on input. This is a - breaking change for scripts which provide input with an incorrect dtype. -- :class:`imod.mf6.Solution` now requires a `model_names` argument to specify - which models should be solved in a single numerical solution. This is - required to simulate groundwater flow and transport as they should be - in separate solutions. -- When writing MODFLOW 6 input option blocks, a NaN value is now recognized as - an alternative to None (and the entry will not be included in the options - block). - -Added -~~~~~ - -- Added support to write MetaSWAP models, :class:`imod.msw.MetaSwapModel`. -- Addes support to write coupled MetaSWAP and Modflow6 simulations, - :class:`imod.couplers.MetaMod` -- :func:`imod.util.replace` has been added to find and replace different values - in a DataArray. -- :func:`imod.evaluate.calculate_gxg_points` has been added to compute GXG - values for time varying point data (i.e. loaded from IPF and presented as a - Pandas dataframe). -- :func:`imod.evaluate.calculate_gxg` will return the number of years used - in the GxG calculation as separate variables in the output dataset. -- :func:`imod.visualize.spatial.plot_map` now accepts a `fix` and `ax` argument, - to enable adding maps to existing axes. -- ``imod.flow.ImodflowModel.create_time_discretization``, - :meth:`imod.wq.SeawatModel.create_time_discretization`, - :meth:`imod.mf6.Modflow6Simulation.create_time_discretization`, now have a - documentation section. -- :class:`imod.mf6.GroundwaterTransportModel` has been added with associated - simple classes to allow creation of solute transport models. Advanced - boundary conditions such as LAK or UZF are not yet supported. -- :class:`imod.mf6.Buoyancy` has been added to simulate density dependent - groundwater flow. - -[0.11.1] - 2021-12-23 ---------------------- - -Fixed -~~~~~ - -- ``contextily``, ``geopandas``, ``pyvista``, ``rasterio``, and ``shapely`` - are now fully optional dependencies. Import errors are only raised when - accessing functionality that requires their use. -- Include declxml as ``imod.declxml`` (should be internal use only!): declxml - is no longer maintained on the official repository: - https://github.com/gatkin/declxml. Furthermore, it has no conda feedstock, - which makes distribution via conda difficult. - -[0.11.0] - 2021-12-21 ---------------------- - -Fixed -~~~~~ - -- :func:`imod.formats.ipf.read` accepts list of file names. -- :func:`imod.mf6.open_hds` did not read the appropriate bytes from the - heads file, apart for the first timestep. It will now read the right records. -- Use the appropriate array for modflow6 timestep duration: the - :meth:`imod.mf6.GroundwaterFlowModel.write` would write the timesteps - multiplier in place of the duration array. -- :meth:`imod.mf6.GroundwaterFlowModel.write` will now respect the layer - coordinate of DataArrays that had multiple coordinates, but were - discontinuous from 1; e.g. layers [1, 3, 5] would've been transformed to [1, - 2, 3] incorrectly. -- :meth:`imod.mf6.Modflow6Simulation.write` will no longer change working directory - while writing model input -- this could lead to errors when multiple - processes are writing models in parallel. -- :func:`imod.prepare.laplace_interpolate` will no longer ZeroDivisionError - when given a value for ``ibound``. - -Added -~~~~~ - -- :func:`imod.formats.idf.open_subdomains` will now also accept iMOD-WQ output of - multiple species runs. -- :meth:`imod.wq.SeawatModel.to_netcdf` has been added to write all model - packages to netCDF files. -- :func:`imod.mf6.open_cbc` has been added to read the budget data of - structured (DIS) MODFLOW 6 models. The data is read lazily into xarray - DataArrays per timestep. -- :func:`imod.visualize.streamfunction` and :func:`imod.visualize.quiver` - were added to plot a 2D representation of the groundwater flow field using - either streamlines or quivers over a cross section plot - (:func:`imod.visualize.cross_section`). -- :func:`imod.evaluate.streamfunction_line` and - :func:`imod.evaluate.streamfunction_linestring` were added to extract the - 2D projected streamfunction of the 3D flow field for a given cross section. -- :func:`imod.evaluate.quiver_line` and :func:`imod.evaluate.quiver_linestring` - were added to extract the u and v components of the 3D flow field for a given - cross section. -- Added :meth:`imod.mf6.GroundwaterFlowModel.write_qgis_project` to write a - QGIS project for easier inspection of model input in QGIS. -- Added :meth:`imod.wq.SeawatModel.clip` to clip a model to a provided extent. - Boundary conditions of clipped model can be automatically derived from parent - model calculation results and are applied along the edges of the extent. -- Added :py:func:`imod.gen.read` and :py:func:`imod.gen.write` for reading - and writing binary iMOD GEN files to and from geopandas GeoDataFrames. -- Added :py:func:`imod.prepare.zonal_aggregate_raster` and - :py:func:`imod.prepare.zonal_aggregate_polygons` to efficiently compute zonal - aggregates for many polygons (e.g. the properties every individual ditch in - the Netherlands). -- Added ``imod.flow.ImodflowModel`` to write to model iMODFLOW project - file. -- :meth:`imod.mf6.Modflow6Simulation.write` now has a ``binary`` keyword. When set - to ``False``, all MODFLOW 6 input is written to text rather than binary files. -- Added :class:`imod.mf6.DiscretizationVertices` to write MODFLOW 6 DISV model - input. -- Packages for :class:`imod.mf6.GroundwaterFlowModel` will now accept - :class:`xugrid.UgridDataArray` objects for (DISV) unstructured grids, next to - :class:`xarray.DataArray` objects for structured (DIS) grids. -- Transient wells are now supported in ``imod.mf6.WellDisStructured`` and - ``imod.mf6.WellDisVertices``. -- :func:`imod.util.to_ugrid2d` has been added to convert a (structured) xarray - DataArray or Dataset to a quadrilateral UGRID dataset. -- Functions created to create empty DataArrays with greater ease: - :func:`imod.util.empty_2d`, :func:`imod.util.empty_2d_transient`, - :func:`imod.util.empty_3d`, and :func:`imod.util.empty_3d_transient`. -- :func:`imod.util.where` has been added for easier if-then-else operations, - especially for preserving NaN nodata values. -- :meth:`imod.mf6.Modflow6Simulation.run` has been added to more easily run a model, - especially in examples and tests. -- :func:`imod.mf6.open_cbc` and :func:`imod.mf6.open_hds` will automatically - return a ``xugrid.UgridDataArray`` for MODFLOW 6 DISV model output. - -Changed -~~~~~~~ - -- Documentation overhaul: different theme, add sample data for examples, add - Frequently Asked Questions (FAQ) section, restructure API Reference. Examples - now ru -- Datetime columns in IPF associated files (via - :func:`imod.formats.ipf.write_assoc`) will not be placed within quotes, as this can - break certain iMOD batch functions. -- :class:`imod.mf6.Well` has been renamed into ``imod.mf6.WellDisStructured``. -- :meth:`imod.mf6.GroundwaterFlowModel.write` will now write package names - into the simulation namefile. -- :func:`imod.mf6.open_cbc` will now return a dictionary with keys - ``flow-front-face, flow-lower-face, flow-right-face`` for the face flows, - rather than ``front-face-flow`` for better consistency. -- Switched to composition from inheritance for all model packages: all model - packages now contain an internal (xarray) Dataset, rather than inheriting - from the xarray Dataset. -- :class:`imod.mf6.SpecificStorage` or :class:`imod.mf6.StorageCoefficient` is - now mandatory for every MODFLOW 6 model to avoid accidental steady-state - configuration. - -Removed -~~~~~~~ - -- Module ``imod.tec`` for reading Tecplot files has been removed. - -[0.10.1] - 2020-10-19 ---------------------- - -Changed -~~~~~~~ - -- :meth:`imod.wq.SeawatModel.write` now generates iMOD-WQ runfiles with - more intelligent use of the "macro tokens". ``:`` is used exclusively for - ranges; ``$`` is used to signify all layers. (This makes runfiles shorter, - speeding up parsing, which takes a significant amount of time in the runfile - to namefile conversion of iMOD-WQ.) -- Datetime formats are inferred based on length of the time string according to - ``%Y%m%d%H%M%S``; supported lengths 4 (year only) to 14 (full format string). - -Added -~~~~~ - -- :class:`imod.wq.MassLoading` and - :class:`imod.wq.TimeVaryingConstantConcentration` have been added to allow - additional concentration boundary conditions. -- IPF writing methods support an ``assoc_columns`` keyword to allow greater - flexibility in including and renaming columns of the associated files. -- Optional basemap plotting has been added to :meth:`imod.visualize.plot_map`. - -Fixed -~~~~~ - -- IO methods for IDF files will now correctly identify double precision IDFs. - The correct record length identifier is 2295 rather than 2296 (2296 was a - typo in the iMOD manual). -- :meth:`imod.wq.SeawatModel.write` will now write the correct path for - recharge package concentration given in IDF files. It did not prepend the - name of the package correctly (resulting in paths like - ``concentration_l1.idf`` instead of ``rch/concentration_l1.idf``). -- :meth:`imod.formats.idf.save` will simplify constant cellsize arrays to a scalar - value -- this greatly speeds up drawing in the iMOD-GUI. - -[0.10.0] - 2020-05-23 ---------------------- - -Changed -~~~~~~~ - -- :meth:`imod.wq.SeawatModel.write` no longer automatically appends the model - name to the directory where the input is written. Instead, it simply writes - to the directory as specified. -- :func:`imod.select.points_set_values` returns a new DataArray rather than - mutating the input ``da``. -- :func:`imod.select.points_values` returns a DataArray with an index taken - from the data of the first provided dimensions if it is a ``pandas.Series``. -- :meth:`imod.wq.SeawatModel.write` now writes a runfile with ``start_hour`` - and ``start_minute`` (this results in output IDFs with datetime format - ``"%Y%m%d%H%M"``). - -Added -~~~~~ - -- :meth:`from_file` constructors have been added to all `imod.wq.Package`. - This allows loading directly package from a netCDF file (or any file supported by - ``xarray.open_dataset``), or a path to a Zarr directory with suffix ".zarr" or ".zip". -- This can be combined with the `cache` argument in :meth:`from_file` to - enable caching of answers to avoid repeated computation during - :meth:`imod.wq.SeawatModel.write`; it works by checking whether input and - output files have changed. -- The ``resultdir_is_workspace`` argument has been added to :meth:`imod.wq.SeawatModel.write`. - iMOD-wq writes a number of files (e.g. list file) in the directory where the - runfile is located. This results in mixing of input and output. By setting it - ``True``, **all** model output is written in the results directory. -- :func:`imod.visualize.imshow_topview` has been added to visualize a complete - DataArray with atleast dimensions ``x`` and ``y``; it dumps PNGs into a - specified directory. -- Some support for 3D visualization has been added. - :func:`imod.visualize.grid_3d` and :func:`imod.visualize.line_3d` have been - added to produce ``pyvista`` meshes from ``xarray.DataArray``'s and - ``shapely`` polygons, respectively. - :class:`imod.visualize.GridAnimation3D` and :class:`imod.visualize.StaticGridAnimation3D` - have been added to setup 3D animations of DataArrays with transient data. -- Support for out of core computation by ``imod.prepare.Regridder`` if ``source`` - is chunked. -- :func:`imod.formats.ipf.read` now reports the problematic file if reading errors occur. -- :func:`imod.prepare.polygonize` added to polygonize DataArrays to GeoDataFrames. -- Added more support for multiple species imod-wq models, specifically: scalar concentration - for boundary condition packages and well IPFs. - -Fixed -~~~~~ - -- :meth:`imod.prepare.Regridder` detects if the ``like`` DataArray is a subset - along a dimension, in which case the dimension is not regridded. -- :meth:`imod.prepare.Regridder` now slices the ``source`` array accurately - before regridding, taking cell boundaries into account rather than only - cell midpoints. -- ``density`` is no longer an optional argument in :class:`imod.wq.GeneralHeadboundary` and - :class:`imod.wq.River`. The reason is that iMOD-WQ fully removes (!) these packages if density - is not present. -- :func:`imod.formats.idf.save` and :func:`imod.formats.rasterio.save` will now also save DataArrays in - which a coordinate other than ``x`` or ``y`` is descending. -- :func:`imod.visualize.plot_map` enforces decreasing ``y``, which ensures maps are not plotted - upside down. -- :func:`imod.util.spatial.coord_reference` now returns a scalar cellsize if coordinate is equidistant. -- :meth:`imod.prepare.Regridder.regrid` returns cellsizes as scalar when coordinates are - equidistant. -- Raise proper ValueError in :meth:`imod.prepare.Regridder.regrid` consistenly when the number - of dimensions to regrid does not match the regridder dimensions. -- When writing DataArrays that have size 1 in dimension ``x`` or ``y``: raise error if cellsize - (``dx`` or ``dy``) is not specified; and actually use ``dy`` or ``dx`` when size is 1. - -[0.9.0] - 2020-01-19 --------------------- - -Added -~~~~~ - -- IDF files representing data of arbitrary dimensionality can be opened and - saved. This enables reading and writing files with more dimensions than just x, - y, layer, and time. -- Added multi-species support for (:mod:`imod.wq`) -- GDAL rasters representing N-dimensional data can be opened and saved similar to (:mod:`imod.idf`) in (:mod:`imod.rasterio`) -- Writing GDAL rasters using :meth:`imod.formats.rasterio.save` and (:meth:`imod.formats.rasterio.write`) auto-detects GDAL driver based on file extension -- 64-bit IDF files can be opened :meth:`imod.formats.idf.open` -- 64-bit IDF files can be written using :meth:`imod.formats.idf.save` and (:meth:`imod.formats.idf.write`) using keyword ``dtype=np.float64`` -- ``sel`` and ``isel`` methods to ``SeawatModel`` to support taking out a subdomain -- Docstrings for the MODFLOW 6 classes in :mod:`imod.mf6` -- :meth:`imod.select.upper_active_layer` function to get the upper active layer from ibound ``xr.DataArray`` - -Changed -~~~~~~~ - -- ``imod.formats.idf.read`` is deprecated, use :func:`imod.formats.idf.open` instead -- ``imod.formats.rasterio.read`` is deprecated, use :func:`imod.formats.rasterio.open` instead - -Fixed -~~~~~ - -- :meth:`imod.prepare.reproject` working instead of silently failing when given a ``"+init=ESPG:XXXX`` CRS string - -[0.8.0] - 2019-10-14 --------------------- - -Added -~~~~~ -- Laplace grid interpolation :meth:`imod.prepare.laplace_interpolate` -- Experimental MODFLOW 6 structured model write support :mod:`imod.mf6` -- More supported visualizations :mod:`imod.visualize` -- More extensive reading and writing of GDAL raster in :mod:`imod.rasterio` - -Changed -~~~~~~~ - -- The documentation moved to a custom domain name: https://imod.xyz/ - -[0.7.1] - 2019-08-07 --------------------- - -Added -~~~~~ -- ``"multilinear"`` has been added as a regridding option to ``imod.prepare.Regridder`` to do linear interpolation up to three dimensions. -- Boundary condition packages in ``imod.wq`` support a method called ``add_timemap`` to do cyclical boundary conditions, such as summer and winter stages. - -Fixed -~~~~~ - -- ``imod.idf.save`` no longer fails on a single IDF when it is a voxel IDF (when it has top and bottom data). -- ``imod.prepare.celltable`` now succesfully does parallel chunkwise operations, rather than raising an error. -- ``imod.Regridder``'s ``regrid`` method now succesfully returns ``source`` if all dimensions already have the right cell sizes, rather than raising an error. -- ``imod.idf.open_subdomains`` is much faster now at merging different subdomain IDFs of a parallel modflow simulation. -- ``imod.idf.save`` no longer suffers from extremely slow execution when the DataArray to save is chunked (it got extremely slow in some cases). -- Package checks in ``imod.wq.SeawatModel`` succesfully reduces over dimensions. -- Fix last case in ``imod.prepare.reproject`` where it did not allocate a new array yet, but returned ``like`` instead of the reprojected result. - -[0.7.0] - 2019-07-23 --------------------- - -Added -~~~~~ - -- :mod:`imod.wq` module to create iMODFLOW Water Quality models -- conda-forge recipe to install imod (https://github.com/conda-forge/imod-feedstock/) -- significantly extended documentation and examples -- :mod:`imod.prepare` module with many data mangling functions -- :mod:`imod.select` module for extracting data along cross sections or at points -- :mod:`imod.visualize` module added to visualize results -- :func:`imod.idf.open_subdomains` function to open and merge the IDF results of a parallelized run -- :func:`imod.formats.ipf.read` now infers delimeters for the headers and the body -- :func:`imod.formats.ipf.read` can now deal with heterogeneous delimiters between multiple IPF files, and between the headers and body in a single file - -Changed -~~~~~~~ - -- Namespaces: lift many functions one level, such that you can use e.g. the function ``imod.prepare.reproject`` instead of ``imod.prepare.reproject.reproject`` - -Removed -~~~~~~~ - -- All that was deprecated in v0.6.0 - -Deprecated -~~~~~~~~~~ - -- :func:`imod.seawat_write` is deprecated, use the write method of :class:`imod.wq.SeawatModel` instead -- :func:`imod.run.seawat_get_runfile` is deprecated, use :mod:`imod.wq` instead -- :func:`imod.run.seawat_write_runfile` is deprecated, use :mod:`imod.wq` instead - -[0.6.1] - 2019-04-17 --------------------- - -Added -~~~~~ - -- Support nonequidistant models in runfile - -Fixed -~~~~~ - -- Time conversion in runfile now also accepts cftime objects - -[0.6.0] - 2019-03-15 --------------------- - -The primary change is that a number of functions have been renamed to -better communicate what they do. - -The ``load`` function name was not appropriate for IDFs, since the IDFs -are not loaded into memory. Rather, they are opened and the headers are -read; the data is only loaded when needed, in accordance with -``xarray``'s design; compare for example ``xarray.open_dataset``. The -function has been renamed to ``open``. - -Similarly, ``load`` for IPFs has been deprecated. ``imod.ipf.read`` now -reads both single and multiple IPF files into a single -``pandas.DataFrame``. - -Removed -~~~~~~~ - -- ``imod.idf.setnodataheader`` - -Deprecated -~~~~~~~~~~ - -- Opening IDFs with ``imod.idf.load``, use ``imod.idf.open`` instead -- Opening a set of IDFs with ``imod.idf.loadset``, use - ``imod.idf.open_dataset`` instead -- Reading IPFs with ``imod.ipf.load``, use ``imod.ipf.read`` -- Reading IDF data into a dask array with ``imod.idf.dask``, use - ``imod.idf._dask`` instead -- Reading an iMOD-seawat .tec file, use ``imod.tec.read`` instead. - -Changed -~~~~~~~ - -- Use ``np.datetime64`` when dates are within time bounds, use - ``cftime.DatetimeProlepticGregorian`` when they are not (matches - ``xarray`` defaults) -- ``assert`` is no longer used to catch faulty input arguments, - appropriate exceptions are raised instead - -Fixed -~~~~~ - -- ``idf.open``: sorts both paths and headers consistently so data does - not end up mixed up in the DataArray -- ``idf.open``: Return an ``xarray.CFTimeIndex`` rather than an array - of ``cftime.DatimeProlepticGregorian`` objects -- ``idf.save`` properly forwards ``nodata`` argument to ``write`` -- ``idf.write`` coerces coordinates to floats before writing -- ``ipf.read``: Significant performance increase for reading IPF - timeseries by specifying the datetime format -- ``ipf.write`` no longer writes ``,,`` for missing data (which iMOD - does not accept) - -[0.5.0] - 2019-02-26 --------------------- - -Removed -~~~~~~~ - -- Reading IDFs with the ``chunks`` option - -Deprecated -~~~~~~~~~~ - -- Reading IDFs with the ``memmap`` option -- ``imod.idf.dataarray``, use ``imod.idf.load`` instead - -Changed -~~~~~~~ - -- Reading IDFs gives delayed objects, which are only read on demand by - dask -- IDF: instead of ``res`` and ``transform`` attributes, use ``dx`` and - ``dy`` coordinates (0D or 1D) -- Use ``cftime.DatetimeProlepticGregorian`` to support time instead of - ``np.datetime64``, allowing longer timespans -- Repository moved from ``https://gitlab.com/deltares/`` to - ``https://gitlab.com/deltares/imod/`` - -Added -~~~~~ - -- Notebook in ``examples`` folder for synthetic model example -- Support for nonequidistant IDF files, by adding ``dx`` and ``dy`` - coordinates - -Fixed -~~~~~ - -- IPF support implicit ``itype`` - -.. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/ -.. _Semantic Versioning: https://semver.org/spec/v2.0.0.html +Changelog +========= + +All notable changes to this project will be documented in this file. + +The format is based on `Keep a Changelog`_, and this project adheres to +`Semantic Versioning`_. + +[Unreleased] +------------ + +Added +~~~~~ + +- Experimental class :class:`imod.msw.SprinklingPoints` to specify sprinkling + from points for MetaSWAP models, instead of from grid. You can use this to + specify sprinkling wells from IPF files in an iMOD5 CAP dataset with + :meth:`imod.msw.SprinklingPoints.from_imod5_data`. +- :class:`imod.mf6.LayeredWell.from_imod5_cap_data` now also supports loading + wells from IPF files in an iMOD5 CAP dataset. +- Added ``drop_empty_layers: bool = True`` to various cell allocation functions + in :mod:`imod.prepare.topsystem.allocation` to remove fully empty layers from the grids. + Strips the empty layers before they are passed along to + reprojection/regridding operations, which can save considerable time for models with + many empty layers. Set to False to keep the previous full-layer-coordinate + behaviour. :meth:`imod.prepare.topsystem.allocation.allocate_riv_cells`, + :meth:`imod.prepare.topsystem.allocation.allocate_drn_cells`, + :meth:`imod.prepare.topsystem.allocation.allocate_ghb_cells`, + :meth:`imod.prepare.topsystem.allocation.allocate_rch_cells` +- Added ``drop_empty_layers: bool = True`` to + :meth:`imod.mf6.River.reallocate`, :meth:`imod.mf6.Drainage.reallocate`, + :meth:`imod.mf6.GeneralHeadBoundary.reallocate`, and + :meth:`imod.mf6.Recharge.reallocate`. Allocation and conductance + distribution are always computed over the full layer range first; only + the final package has fully empty layers trimmed off afterwards, so this + does not affect computed values. Set to False to keep the previous + full-layer-coordinate behaviour. + +Fixed +~~~~~ + +- Fixed resampling in :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` when simulation timesteps precede + the first well timestep. +- Fixed :func:`imod.prepare.cleanup.align_interface_levels` (used by + ``cleanup_riv``, and therefore :meth:`imod.mf6.River.cleanup`) raising an + alignment error when a package's own layer coordinate is a subset of the + model's full layer range, e.g. after :meth:`imod.mf6.River.reallocate` with + ``drop_empty_layers=True``. + +Changed +~~~~~~~ + +- Deprecated :class:`imod.msw.Sprinkling` in favor of + :class:`imod.msw.SprinklingGrid`. Call :class:`imod.msw.SprinklingGrid` to get + the same behavior as you were used to. + +[1.1.0] - 2026-08-03 +-------------------- + +Added +~~~~~ + +- Added ``ignore_time_purge_empty`` argument to + :meth:`imod.mf6.Modflow6Simulation.mask_all_models` and + :meth:`imod.mf6.Modflow6Simulation.clip_box` to consider a package empty if + its first times step is all nodata. This can save a lot of clipping or masking + transient models with many timesteps. +- Added :meth:`imod.msw.MetaSwapModel.split` to split MetaSWAP models. +- Added :meth:`imod.mf6.HorizontalFlowBarrierResistance.from_imod5_data` to load + barriers from 3D GEN files. +- Added ``name`` argument to :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` + to provide custom name to imported simulation and model. +- Added :meth:`imod.msw.MetaSwapModel.mask_all_packages` to mask all packages of + a MetaSWAP model. +- Added optional ``target_grid`` argument to + :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, + :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data`, + :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` to specify a target + grid for regridding the iMOD5 data to. If not provided, the first IBOUND layer + is used as target grid, like in iMOD5. + +Fixed +~~~~~ + +- Fixed bug in :class:`imod.mf6.GroundwaterFlowModel` and :class:`imod.formats.prf.IpfResult` + where names of wels were duplicated by increasing the character limit to 40 + and enumerating wel names. +- Fixed bug where :class:`imod.mf6.Evapotranspiration` package would write files + to binary, which could not be parsed by MODFLOW 6 when ``proportion_depth`` + and ``proportion_rate`` were provided without segments. +- Fixed bug where :class:`imod.mf6.ConstantConcentration` package could not be written + for multiple timesteps. +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` where a ValidationError + was thrown when clipping a model with a :class:`imod.mf6.ConstantHead` or + :class:`imod.mf6.ConstantConcentration` package with a ``time`` dimension and + providing ``states_for_boundary``. +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` would drop layers if + ``states_for_boundary`` were provided and the model already contained a + :class:`imod.mf6.ConstantHead` or :class:`imod.mf6.ConstantConcentration` with + less layers. +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` would not properly + align timesteps and forward fill data if ``states_for_boundary`` were provided + and the model already contained a :class:`imod.mf6.ConstantHead` or + :class:`imod.mf6.ConstantConcentration`, both with timesteps, which were + unaligned. +- Fixed bug where :class:`imod.mf6.Lake` package did not pass ``budgetfile``, + ``budgetcsvfile``, ``stagefile`` options to the written MODFLOW 6 package. +- Fixed bug where :func:`imod.evaluate.convert_pointwaterhead_freshwaterhead` + produced incorrect results when point water heads were below elevation levels + for unstructured grids. +- Support pandas 3.0. +- :class:`imod.msw.IdfMapping` when model clipping is applied, the global + row/column indices are converted to local indices, as written in + ``idf_svat.inp``. +- Fixed edge case where allocation of :class:`imod.mf6.River` package with the + ``stage_to_riv_bot`` or ``stage_to_riv_bot_drn_above`` option of + :func:`imod.prepare.ALLOCATION_OPTION` would assign river cells to the wrong + layer, when the stage and bottom_elevation were exactly equal to the bottom of + a layer in the model discretization, which would cause these cells to be + dropped when distributing conductances later. +- Fixed :func:`imod.prepare.spatial.polygonize` for polygons with holes. +- :func:`imod.formats.prj.open_projectfile_data` now drops empty wells from the + dataset, and logs a warning about it. +- :meth:`imod.mf6.NodePropertyFlow.regrid_like` now regrids ``k33`` using the + correct method, namely ``mean`` instead of ``harmonic_mean``. As this is the + appropriate method for horizontal regridding of ``k33``. +- :meth:`imod.msw.MetaSwapModel.from_imod5_data`, + :meth:`imod.mf6.Recharge.from_imod5_cap_data`, + :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` now regrids the iMOD5 CAP + data to the MODFLOW6 target discretization. +- Fixed confusing warning about inconsistent IPF columns when loading GEN files. +- Fix bug where iMOD Python would error on writing a model where package + settings were specified as dask array, which could happen when loading a model + lazily with :meth:`imod.mf6.Modflow6Simulation.from_file` and not + computing the data before writing. +- Fixed bug where ``concentration`` variables were needlessly loaded into + memory. Affected :class:`imod.mf6.River`, :class:`imod.mf6.Drain`, + :class:`imod.mf6.ConstantHeadBoundary`, :class:`imod.mf6.Recharge`, + :class:`imod.mf6.Well` and :class:`imod.mf6.GeneralHeadBoundary`. +- Fix bug where ``maxbound`` of the ``.wel`` file computed by + :class:`imod.mf6.Mf6Wel` was twice or thrice too large. +- Fixed bug where :func:`imod.evaluate.facebudget` raised an error when the + ``front`` budget was left out, even though you only need to provide one of + ``front``, ``lower`` or ``right``. Leaving out ``front`` now works as + described in the documentation. +- Fixed big performance degradation with :func:`imod.idf.open_subdomains` where + it would take a long time to open lots of idf files. Performance is now + significantly improved up to the same speed as before the change that caused + the performance degradation. + +Changed +~~~~~~~ + +- Increased the character limit to 40 in :class:`imod.mf6.Modflow6Model` for all + keys assigned to a Modflow6 model. +- ``proportion_depth`` and ``proportion_rate`` in + :class:`imod.mf6.Evapotranspiration` are now optional variables. If provided, + now require ``"segment"`` dimension when ``proportion_depth`` and + ``proportion_rate``. +- :meth:`imod.msw.GridData.generate_index_array` is now deprecated, use + :meth:`imod.msw.GridData.generate_isactive_svat_arrays` instead. +- If no ``target_grid`` is provided, + :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` chooses a grid the + same as iMOD5 did: the first IBOUND layer. This is different from previous + versions of iMOD Python, which defaulted to the smallest possible extent + and finest resolution, based on the iMOD5 IBOUND, TOP and BOTTOM data. + + +[1.0.0] - 2025-11-11 +-------------------- + +Fixed +~~~~~ + +- Improved performance of :meth:`imod.mf6.Modflow6Simulation.split` for large + models loaded lazily into memory. Reduced a splitting operation of 2 hours to + a few minutes for a test case. +- Issue where :meth:`imod.mf6.LayeredWell.from_imod5_data` would result in wells + with a mismatch between coordinates and rates. + +Added +~~~~~ + +- Added :class:`imod.mf6.Viscosity` package to specify the viscosity of the + groundwater flow model. +- Functionality to dump and load MODFLOW 6 simulations to/from zarr and zipstore + formats. See :meth:`imod.mf6.Modflow6Simulation.dump` and + :meth:`imod.mf6.Modflow6Simulation.from_file` for more information. +- Functionality to dump and load MetaSwap models to/from netcdf + format. See :meth:`imod.msw.MetaSwapModel.dump` and + :meth:`imod.msw.MetaSwapModel.from_file` for more information. + +Changed +~~~~~~~ + +- :class:`imod.mf6.Well` and :func:`imod.prepare.assign_wells` now distribute + the well rates over the screened cells using a correction factor based on the + mismatch between the well screen center and the cell center, equal to iMOD5's + correction factor. + + +[1.0.0rc7] - 2025-10-28 +----------------------- + +Added +~~~~~ + +- :meth:`imod.mf6.Modflow6Simulation.set_validation_settings` to set validation + settings for a MODFLOW 6 simulation. See :class:`imod.mf6.ValidationSettings` + for more information. + +Changed +~~~~~~~ + +- No automatic validation upon calling :meth:`imod.mf6.Modflow6Simulation.regrid_like` anymore. + Use the ``validate`` argument of :meth:`imod.mf6.Modflow6Simulation.write` to + validate the regridded model upon writing instead. +- :class:`imod.mf6.River` now ignore confined cells (``icelltype == 0``) when + validating whether the river bottom elevation is below the model bottom + elevation. +- Moved :func:`imod.select.get_upper_active_layer_number`, + :func:`imod.select.get_upper_active_cells`, + :func:`imod.select.get_lower_active_cells`, and + :func:`imod.select.get_lower_active_layer_number` from :mod:`imod.prepare`. to + :mod:`imod.select`. +- :class:`imod.mf6.Dispersion` now is not a required package for + :class:`imod.mf6.GroundwaterTransportModel` anymore. +- No validation anymore for ``icelltype`` upon writing + :class:`imod.mf6.SpecificStorage` and :class:`imod.mf6.StorageCoefficient`. + + +Removed +~~~~~~~ + +- Removed ``imod.select.upper_active_layer`` function, use + :func:`imod.select.get_upper_active_layer_number` instead. + +Fixed +~~~~~ + +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.split` could result in + empty exchanges being present in the ``split_exchanges`` package list, when + two models were isolated by inactive cells from each other. These empty + exchanges are now removed. +- Fixed bug where :meth:`imod.mf6.Modflow6Simulation.split`, + :meth:`imod.mf6.Modflow6Simulation.regrid_like`, and + :meth:`imod.mf6.Modflow6Simulation.clip_box` would not copy + :class:`imod.mf6.ValidationSettings`. +- ``landuse``, ``soil_physical_unit``, ``active`` for :class:`imod.msw.GridData` + are now properly regridded with the ``mode`` statistic when using + :meth:`imod.msw.GridData.regrid_like`. + +[1.0.0rc6] - 2025-08-28 +----------------------- + +Small post-release to fix rendering of documentation online. + +[1.0.0rc5] - 2025-08-27 +----------------------- + +Added +~~~~~ + +- :meth:`imod.mf6.River.reallocate`, :meth:`imod.mf6.Drainage.reallocate`, + :meth:`imod.mf6.GeneralHeadBoundary.reallocate`, + :meth:`imod.mf6.Recharge.reallocate` to reallocate the package data to a new + discretization or :class:`imod.mf6.NodePropertyFlow` package, or to use a + different :class:`imod.prepare.ALLOCATION_OPTION` or + :class:`imod.prepare.DISTRIBUTING_OPTION`. +- Added :meth:`imod.mf6.HorizontalFlowBarrierResistance.snap_to_grid` and + :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.snap_to_grid` to + debug how horizontal flow barriers are snapped to a grid. +- Added :meth:`imod.mf6.Modflow6Simulation.create_partition_labels` to create + partition labels for a MODFLOW 6 simulation from its idomain. This is useful + for splitting a simulation into multiple submodels. +- :class:`imod.mf6.AdaptiveTimeStepping` to specify adaptive time stepping + settings for MODFLOW 6 simulations. +- The ``ats_percel`` argument to :class:`imod.mf6.AdvectionTVD`, + :class:`imod.mf6.AdvectionUpstream`, :class:`imod.mf6.AdvectionCentral` to + adapt the time step based on the maximum fraction of a cell that a solute + parcel is allowed to travel. + +Fixed +~~~~~ + +- Reduce noisy warnings in models loaded with + :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` which have layers with + cells with zero thicknesses. +- Issue where regridding lead to excessively large inactive areas. +- Issue where regridding would lead to very large negative integer values (like + IDOMAIN) for inactive areas. +- Issue where :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` would throw a KeyError 0 upon + trying to resample timeseries with a non-zero index. +- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB + packages would have resistances that were double the expected value with + xugrid >= 0.14.2 +- The ``states_for_boundary`` argument now also works for tranport models in + :meth:`imod.mf6.Modflow6Simulation.clip_box`. +- Fix bug where :meth:`imod.mf6.Modflow6Simulation.clip_box` and the + ``states_for_boundary`` argument would place these bc at the + incorrect places with unstructured grids. +- Fixed bug where :meth:`imod.mf6.SourceSinkMixing.from_flow_model` would return + an error upon adding a package which cannot have a ``concentration``, such as + :class:`imod.mf6.HorizontalFlowBarrierResistance`. +- Broken names for ``outer_csvfile`` and ``inner_csvfile`` in the + :class:`imod.mf6.Solution` MODFLOW 6 template file. + +Changed +~~~~~~~ + +- :meth:`imod.mf6.StructuredDiscretization.from_imod5_data` and + :meth:`imod.mf6.NodePropertyFlow.from_imod5_data` now automatically load the + dataset into memory. This improves performance when loading models with + multiple topsystem packages. +- No upper limit anymore for ``mod_id`` in ``mod2svat.inp`` for + :class:`imod.msw.CouplerMapping`. +- :func:`imod.prepare.create_partition_labels` now takes an ``idomain`` grid + instead of :class:`imod.mf6.Modflow6Simulation` as first argument. To generate + partition labels from a :class:`imod.mf6.Modflow6Simulation` straightaway, use + the newly added :meth:`imod.mf6.Modflow6Simulation.create_partition_labels` + method instead. + +Removed +~~~~~~~ + +- Removed ``imod.mf6.WellDisStructured`` and ``imod.mf6.WellDisVertices``. Use + :class:`imod.mf6.Well` and :class:`imod.mf6.LayeredWell` instead. The + :class:`imod.mf6.Well` package can be used to specify wells with filters, + :class:`imod.mf6.LayeredWell` directly to layers. +- Removed ``imod.mf6.multimodel.partition_generator.get_label_array``, use + :func:`imod.prepare.create_partition_labels` instead. +- Removed ``imod.formats.idf.read`` use :func:`imod.formats.idf.open` instead. +- Removed ``imod.formats.rasterio.read`` use :func:`imod.formats.rasterio.open` instead. +- Removed ``head`` argument for :class:`imod.mf6.InitialConditions`, use + ``start`` instead. +- Removed ``cell_averaging`` argument for :class:`imod.mf6.NodePropertyFlow`, + use ``alternative_cell_averaging`` instead. +- Removed ``set_repeat_stress`` method from boundary condition packages like + :class:`imod.mf6.River`. Use ``repeat_stress`` argument instead. +- Removed ``time_discretization`` method from + :class:`imod.mf6.Modflow6Simulation` and :class:`imod.wq.SeawatModel`. Use + :meth:`imod.mf6.Modflow6Simulation.create_time_discretization` and + :meth:`imod.wq.SeawatModel.create_time_discretization` instead. +- Removed ``imod.util.round_extent``, use :func:`imod.prepare.round_extent` + instead. +- Removed :class:`imod.prepare.Regridder`. Use the `xugrid regridder + `_ instead. + + +[1.0.0rc4] - 2025-06-20 +----------------------- + +Added +~~~~~ + +- Added ``weights`` argument to :func:`imod.prepare.create_partition_labels` to + weigh how the simulation should be partioned. Areas with higher weights will + result in smaller partions. +- iMOD Python version is now written in a comment line to MODFLOW6 and + MetaSWAP's ``para_sim.inp`` files. This is useful for debugging purposes. +- Added option ``ignore_time_purge_empty`` to + :meth:`imod.mf6.Modflow6Simulation.split` to consider a package empty if its + first times step is all nodata. This can save a lot of time splitting + transient models. +- Add :class:`imod.mf6.ValidationSettings` to specify validation settings for + MODFLOW 6 simulations. You can provide it to the + :class:`imod.mf6.Modflow6Simulation` constructor. + +Fixed +~~~~~ + +- Upon providing an unexpected coordinate in the mask or regridding grid, + :meth:`imod.mf6.Modflow6Simulation.regrid_like` and + :meth:`imod.mf6.Modflow6Simulation.mask_all_models` now present the unexpected + coordinates in the error message. +- :class:`imod.mf6.VerticesDiscretization` now correctly sets the ``xorigins`` + and ``yorigins`` options in the ``.disv`` file. Incorrect origins cause issues + when splitting models and computing with XT3D on the exchanges. +- :func:`imod.mf6.open_cbc` and :func:`imod.mf6.open_hds` now account for + xorigins and yorigins for models ran with + :class:`imod.mf6.VerticesDiscretization`. **WARNING**: Given that these were + set incorrectly in previous versions of iMOD Python (see previous item in this + list), this means that reading MODFLOW6 DISV output of models generated with a + previous version of iMOD Python will result in a grid with an erroneous + offset. You can work around this by creating the model again with this + version of iMOD Python or newer. +- :meth:`imod.mf6.Modflow6Simulation.split` supports label array with a + different name than ``"idomain"``. +- :func:`imod.msw.MetaSwapModel.from_imod5_data` now supports the usage of + relative paths for the extra files block. +- Bug in :meth:`imod.msw.Sprinkling.write` where MetaSWAP svats with surface + water sprinkling and no groundwater sprinkling activated were not written to + ``scap_svat.inp``. +- :class:`imod.msw.IdfMapping` swapped order of y_grid and x_grid in dictionary + for writing the correct order of coordinates in idf_svat.inp. +- Improved performance of :meth:`imod.mf6.Modflow6Simulation.split` and + :meth:`imod.mf6.Modflow6Simulation.mask_all_models` when using dask. +- Fixed bug in :meth:`imod.mf6.Modflow6Simulation.mask_all_models` for unstructured grids + with a spatial dimension that differs from the default ``"mesh2d_nFaces"``. +- Fixed bug in :meth:`imod.mf6.Well.cleanup` and + :meth:`imod.mf6.LayeredWell.cleanup` which caused an error when called with an + unstructured discretization. +- Fixed bug in :func:`imod.formats.prj.open_projectfile_data` which caused an + error when a periods keyword was used having an upper case. +- Poor performance of :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` when the ``imod5_data`` contained + a well system with a large number of wells (>10k). +- :meth:`imod.mf6.River.from_imod5_data`, + :meth:`imod.mf6.Drainage.from_imod5_data`, + :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data` can now deal with + constant values for variables. One variable per package still needs to be a + grid. +- Fix bug where an error was thrown in ``get_non_grid_data`` when calling the + ``.cleanup`` and ``regrid_like`` methods on a boundary condition package with + a repeated stress. For example, :meth:`imod.mf6.River.cleanup` or + :meth:`imod.mf6.River.regrid_like`. +- Fix bug where an error was thrown in :class:`imod.mf6.Well` when an entry had + to be filtered and its ``id`` didn't match the index. +- Improved performance of :class:`imod.mf6.Modflow6Simulation.split` for + structured models, as unnecessary masking is avoided. +- Fixed warning thrown by type dispatcher about ``~GeoDataFrameType`` +- Fixed bug where variables in a package with only a ``"layer"`` coordinate + could not be regridded or masked. + +Changed +~~~~~~~ + +- :meth:`imod.wq.SeawatModel.write` now throws an error if trying to write in a + directory with a space in the path. (iMOD-WQ does not support this.) +- `imod.mf6.multimodel.partition_generator.get_label_array` moved to + :func:`imod.prepare.create_partition_labels`. +- :func:`imod.prepare.create_partition_labels` structured grids are now + partioned by METIS instead (just like already was the case for unstructured + grids). This results in more balanced partitions for grids with non-square + domains or lots of inactive cells. Downside is that the partitions are more + often than not perfectly rectangular in shape. +- :func:`imod.prepare.create_partition_labels` now returns a griddata with the + name ``"label"`` instead of ``"idomain"``. +- Upon providing the wrong type to one of the options of + :class:`imod.mf6.GroundwaterFlowModel`, + :class:`imod.mf6.GroundwaterTransportModel`, this will throw a + ``ValidationError`` upon initialization and writing. +- You can now also provide ``repeat_stress`` as dictionary to imod.mf6 + boundary conditions, such as :class:`imod.mf6.River`, :class:`imod.mf6.Drainage`, and + :class:`imod.mf6.GeneralHeadBoundary`. +- :meth:`imod.mf6.ConstantHead.from_imod5_data`, + :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data`, + :meth:`imod.mf6.River.from_imod5_data`, + :meth:`imod.mf6.Recharge.from_imod5_data`, and + :meth:`imod.mf6.Drainage.from_imod5_data` now forward fill data over time, + instead of clipping, when selecting a start time that is inbetween two data + records. +- :meth:`imod.mf6.ConstantHead.from_imod5_data` and + :meth:`imod.mf6.Recharge.from_imod5_data` got extra arguments for + ``period_data``, ``time_min`` and ``time_max``. +- :func:`imod.visualize.read_imod_legend` now also returns the labels as an extra + argument. Update your code by changing + ``colors, levels = read_imod_legend(...)`` to + ``colors, levels, labels = read_imod_legend(...)``. + + +[1.0.0rc3] - 2025-04-17 +----------------------- + +Added +~~~~~ + +- :meth:`imod.msw.MetaSwapModel.clip_box` to clip MetaSWAP models. +- Methods of class :class:`imod.mf6.Modflow6Simulation` can now be logged. +- :func:`imod.prepare.cleanup.cleanup_wel_layered` to clean up wells assigned + to layers. + + +Fixed +~~~~~ + +- Fixed bug where :meth:`imod.mf6.River.clip_box`, + :meth:`imod.mf6.Drainage.clip_box`, and + :meth:`imod.mf6.GeneralHeadBoundary.clip_box` threw an error when + ``time_start`` or ``time_end`` were set to ``None`` and a ``"repeat_stress"`` + was included in the dataset. +- Fixed bug where :meth:`imod.mf6.package.copy` threw an error. +- Sorting issue in :func:`imod.prepare.assign_wells`. This could cause + :class:`imod.mf6.Well` to assign wells to the wrong cells. +- Fixed crash upon calling :meth:`imod.mf6.Well.clip_box` when the top/bottom + arguments are specified. This could cause :class:`imod.mf6.Well` to crash + when wells are located outside the extent of the layer model. + + +[1.0.0rc2] - 2025-03-05 +----------------------- + +From this release on, we recommend using `xugrid's regridding utilities +`_ for +regridding individual grids instead of :class:`imod.prepare.Regridder`. Xugrid's +regridders are tested to be about 10 times faster than +:class:`imod.prepare.Regridder`. There is one small difference: xugrid's +``xugrid.BaryCentricInterpolator`` considers sample points of the destination +grid that lie on the source grid's cell edges to be inside, whereas +:class:`imod.prepare.Regridder` considers them to be outside. This difference is +negligible for most applications, but might create slightly fewer ``np.nan`` +values than before. + +Removed +~~~~~~~ +- ``imod.flow`` module has been removed for generating iMODFLOW models. Use + ``imod.mf6`` instead to generate MODFLOW 6 models. + +Added +~~~~~ + +- Support for Python 3.13. +- :meth:`imod.mf6.Recharge.from_imod5_data`, + :meth:`imod.mf6.River.from_imod5_data`, + :meth:`imod.mf6.Drainage.from_imod5_data`, and + :meth:`imod.mf6.GeneralHeadBoundary.from_imod5_data` now assign negative layer + numbers to the first active layer. +- :func:`imod.prepare.DISTRIBUTING_OPTION` got a new setting + ``by_corrected_thickness``. This matches DISTRCOND=-1 in iMOD5. +- :func:`imod.prepare.cleanup_hfb` to clean up HFB geometries. +- :meth:`imod.mf6.HorizontalFlowBarrierResistance.cleanup`, + :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.cleanup`, + to clean up HFB geometries crossing inactive model cells. +- :class:`imod.util.RegridderWeightsCache` to store regridder weights for + regridding multiple times. +- :class:`imod.util.RegridderType` to specify regridder types. + +Changed +~~~~~~~ + +- :func:`imod.formats.prj.open_projectfile_data` now also assigns negative and + zero layer numbers to grid coordinates. +- In :class:`imod.mf6.StructuredDiscretization`, IDOMAIN can now respectively be + > 0 to indicate an active cell and <0 to indicate a vertical passthrough cell, + consistent with MODFLOW 6. Previously this could only be indicated with 1 and + -1. +- :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` now also accept the argument + ``times = "steady-state"``, for the simulation is assumed to be "steady-state" + and well timeseries are averaged. +- The ``drn`` attribute of :class:`imod.prepare.SimulationAllocationOptions` has + the ``at_elevation`` of :func:`imod.prepare.ALLOCATION_OPTION` option now set + as default. This means by default drainage cells are placed differently in + :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`. +- :class:`imod.mf6.Well`, :class:`imod.mf6.LayeredWell`, + :func:`imod.prepare.assign_wells`, :meth:`imod.mf6.Well.from_imod5_data` + and :meth:`imod.mf6.LayeredWell.from_imod5_data` now have default values for + ``minimum_thickness`` and ``minimum_k`` set to 0.0. +- When intitating a MODFLOW 6 package with a ``layer`` coordinate with + values <= 0, iMOD Python will throw an error. +- :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB now + validate whether proper type of geometry is provided, respectively Polygon for + :class:`imod.mf6.HorizontalFlowBarrierResistance`, and LineString for + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance`. +- Relaxed validation for :class:`imod.msw.MetaSwapModel` if ``FileCopier`` + package is present. +- Change aterisk to dash and tabs to four spaces in ``ValidationError`` messages. +- :func:`imod.prepare.laplace_interpolate` has been simplified, using + ``scipy.sparse.linalg.cg`` as the backend. We've remove the support for the + ``ibound`` argument, the ``iter1`` argument has been dropped, ``mxiter`` has + been renamed to ``maxiter``, ``close`` has been renamed to ``rtol``. +- Moved ``imod.mf6.utilities.regrid.RegridderWeightsCache`` to the + :class:`imod.util.regrid.RegridderWeightsCache`. + +Fixed +~~~~~ + +- :meth:`imod.mf6.GroundwaterFlowModel.mask_all_packages` now preserves the ``dx`` and + ``dy`` coordinates +- :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` ignore well rates preceding first + element of ``times``. +- :meth:`imod.mf6.Well.from_imod5_data` and + :meth:`imod.mf6.LayeredWell.from_imod5_data` now sum the rates of well entries + that are on the exact same location (same x, y, and depth) instead of taking + the values of the first entry. +- :meth:`imod.mf6.River.from_imod5_data` now preserves the drainage cells + created with the ``stage_to_riv_bot_drn_above`` option of + :func:`imod.prepare.ALLOCATION_OPTION`. +- Bug in :func:`imod.prepare.distribute_riv_conductance` where conductances were + set to ``np.nan`` for cells where ``stage`` equals ``bottom_elevation`` when + :func:`imod.prepare.DISTRIBUTING_OPTION` was set to ``by_crosscut_thickness``, + ``by_crosscut_transmissivity``, ``by_corrected_transmissivity``. +- :meth:`imod.mf6.NodePropertyFlow.from_imod5_data` now defaults to 90 degrees + for missing layers ``imod5_data`` instead of 0 degrees. +- Bug in :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` where an error was + raised in case the ``"cap"`` package was present in the ``imod5_data``. +- Bug where :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` and + :meth:`imod.mf6.Recharge.from_imod5_cap_data` threw an error if the ``"cap"`` + in the ``imod5_data`` had a ``"layer"`` dimension and coordinate. +- :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` will convert the + ``max_abstraction_groundwater`` and ``max_abstraction_surfacewater`` capacity + from mm/d to m3/d. +- :class:`imod.msw.TimeOutputControl` now starts counting at 0.0 instead of 1.0, + like MetaSWAP expects. +- Models imported with :meth:`imod.msw.MetaSwapModel.from_imod5_data` can be + written with ``validate`` set to True. +- :meth:`imod.mf6.Recharge.from_imod5_cap_data` now returns a 2D array with a + ``"layer"`` coordinate of ``1`` as otherwise ``primod`` throws an error when + trying to derive recharge-svat mappings. +- Fixed part of the code that made Pandas, Geopandas, and xarray throw a lot of + ``FutureWarning`` and ``DeprecationWarning``. +- Fixed performance issue when converting very large wells (>10k) with + :meth:`imod.mf6.Well.to_mf6_pkg` and :meth:`imod.mf6.LayeredWell.to_mf6_pkg`, + such as those created with :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` + for a large grid. +- Fixed issue where an error was thrown when deriving couplings for + :class:`imod.msw.CouplerMapping` and computing svats in + :class:`imod.msw.GridData` with ``dask>=2025.2.0``. +- Fixed a bug where :func:`imod.mf6.out.open_cbc` did not properly sum fluxes + for a single boundary condition package when multiple entries were present in + the same cell. This never happened with models generated by iMOD Python, as it + cannot generate these boundary conditions, but could be a problem with models + generated by iMOD5 and Flopy. +- Removed duplicate entries in ``mod2svat.inp`` generated by + :class:`imod.msw.CouplerMapping` as MetaSWAP cannot handle this. + + +[1.0.0rc1] - 2024-12-20 +----------------------- + +Small post-release fix for installation instructions in documentation. + +[1.0.0rc0] - 2024-12-20 +----------------------- + +Added +~~~~~ + +- :class:`imod.msw.MeteoGridCopy` to copy existing `mete_grid.inp` files, so + ASCII grids in large existing meteo databases do not have to be read. +- :class:`imod.msw.FileCopier` to copy settings and lookup tables in existing + ``.inp`` files. +- :meth:`imod.mf6.LayeredWell.from_imod5_cap_data` to construct a + :class:`imod.mf6.LayeredWell` package from iMOD5 data in the CAP package (for + MetaSWAP). Currently only griddata (IDF) is supported. +- :meth:`imod.mf6.Recharge.from_imod5_cap_data` to construct a recharge package + for coupling a MODFLOW 6 model to MetaSWAP. +- :meth:`imod.msw.MetaSwapModel.from_imod5_data` to construct a MetaSWAP model + from data in an iMOD5 projectfile. +- :meth:`imod.msw.MetaSwapModel.write` has a ``validate`` argument, which can be + used to turn off validation upon writing, use at your own risk! +- :class:`imod.msw.MetaSwapModel` got ``settings`` argument to set simulation + settings. +- :func:`imod.data.tutorial_03` to load data for the iMOD Documentation + tutorial. +- :meth:`imod.mf6.Modflow6Simulation.dump` now saves iMOD Python version number. + +Fixed +~~~~~ + +- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB + packages could not be allocated to cell edges when idomain in layer 1 was + largely inactive. +- Fixed bug where :meth:`imod.mf6.HorizontalFlowBarrierResistance.clip_box`, + :meth:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance.clip_box` methods + only returned deepcopy instead of actually clipping the line geometries. +- Fixed bug where :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB + packages could not be clipped or copied with xarray >= 2024.10.0. +- Fixed crash upon calling :meth:`imod.mf6.GroundwaterFlowModel.dump`, when a + :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` or other HFB + package was assigned to the model. +- :meth:`imod.mf6.Modflow6Simulation.regrid_like` can now regrid a structured + model to an unstructured grid. +- :meth:`imod.mf6.Modflow6Simulation.regrid_like` throws a + ``NotImplementedError`` when attempting to regrid an unstructured model to a + structured grid. +- :class:`imod.msw.Sprinkling` now correctly writes source svats to + scap_svat.inp file. +- :func:`imod.evaluate.calculate_gxg`, upon providing a head dataarray chunked + over time, will no longer error with ``ValueError: Object has inconsistent + chunks along dimension bimonth. This can be fixed by calling unify_chunks().`` +- Improved performance of regridding package data. + + +Changed +~~~~~~~ + +- :class:`imod.msw.Infiltration`'s variables ``upward_resistance`` and + ``downward_resistance`` now require a ``subunit`` coordinate. +- Variables ``max_abstraction_groundwater`` and ``max_abstraction_surfacewater`` + in :class:`imod.msw.Sprinkling` now needs to have a subunit coordinate. +- If ``"cap"`` package present in ``imod5_data``, + :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data` now automatically adds a + well for metaswap sprinkling named ``"msw-sprinkling"`` +- Less strict validation for :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance` and other HFB packages for + simulations which are imported with + :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` +- DeprecationWarning thrown upon initializing :class:`imod.prepare.Regridder`. + We plan to remove this object in the final 1.0 release. `Use the xugrid + regridder to regrid individual grids instead. + `_ To + regrid entire MODFLOW 6 packages or simulations, `see the user guide here. + `_. + +[0.18.1] - 2024-11-20 +--------------------- + +Added +~~~~~ + +- :class:`imod.prepare.SimulationAllocationOptions`, + :class:`imod.prepare.SimulationDistributingOptions`, which are used to store + default allocation and distributing options respectively. + +Fixed +~~~~~ + +- Relaxed validation for `imod.mf6.StructuredDiscretization` to also support + cells with zero thickness where IDOMAIN = 0. Before, only cells with a zero + thickness and IDOMAIN = -1 were supported, else the software threw a ``not all + values comply with criterion: > bottom``. +- Fix bug where no ``ValidationError`` was thrown if there is an active RCH, DRN, + GHB, or RIV cell where idomain = -1. + +Changed +~~~~~~~ + +- In :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, and + :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data` the arguments + ``allocation_options``, ``distributing_options`` are now optional. +- The order of arguments of :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`, + and :meth:`imod.mf6.GroundwaterFlowModel.from_imod5_data`. It now is + ``imod5_data, period_data, times, allocation_options, distributing_options, regridder_types`` + instead of: + ``imod5_data, period_data, allocation_options, distributing_options, times, regridder_types`` + + +[0.18.0] - 2024-11-11 +--------------------- + +Fixed +~~~~~ + +- Multiple ``HorizontalFlowBarrier`` objects attached to + :class:`imod.mf6.GroundwaterFlowModel` are merged into a single horizontal + flow barrier for MODFLOW 6. +- Bug where error would be thrown when barriers in a ``HorizontalFlowBarrier`` + would be snapped to the same cell edge. These are now summed. +- Improve performance validation upon Package initialization +- Improve performance writing ``HorizontalFlowBarrier`` objects +- :func:`imod.mf6.open_cbc` failing with ``flowja=False`` on budget output for + DISV models if the model contained inactive cells. +- :func:`imod.mf6.open_cbc` now works for 2D and 1D models. +- :func:`imod.prepare.fill` previously assigned to the result of an xarray + ``.sel`` operation. This might not work for dask backed data and has been + addressed. +- Added :func:`imod.mf6.open_dvs` to read dependent variable output files like + the water content file of :class:`imod.mf6.UnsaturatedZoneFlow`. +- `imod.prj.open_projectfile_data` is now able to also read IPF data for + sprinkling wells in the CAP package. +- Fix that caused iMOD Python to break upon import with numpy >=1.23, <2.0 . +- ValidationError message now contains a suggestion to use the cleanup method, + if available in the erroneous package. +- Bug where error was thrown when :class:`imod.mf6.NodePropertyFlow` was + assigned to :class:`imod.mf6.GroundwaterFlowModel` with key different from + ``"npf"`` upon writing, along with well or horizontal flow barrier packages. + + +Changed +~~~~~~~ + +- :class:`imod.mf6.Well` now also validates that well filter top is above well + filter bottom +- :func:`imod.formats.prj.open_projectfile_data` now also imports well filter + top and bottom. +- :class:`imod.mf6.Well` now logs a warning if any wells are removed during writing. +- :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.HorizontalFlowBarrierMultiplier`, + :class:`imod.mf6.HorizontalFlowBarrierHydraulicCharacteristic` now uses + vertical Polygons instead of Linestrings as geometry, and ``"ztop"`` and + ``"zbottom"`` variables are not used anymore. See + :func:`imod.prepare.linestring_to_square_zpolygons` and + :func:`imod.prepare.linestring_to_trapezoid_zpolygons` to generate these + polygons. +- :func:`imod.formats.prj.open_projectfile_data` now returns well data grouped + by ipf name, instead of generic, separate number per entry. +- :class:`imod.mf6.Well` now supports wells which have a filter with zero + length, where ``"screen_top"`` equals ``"screen_bottom"``. +- :class:`imod.mf6.Well` shares the same default ``minimum_thickness`` as + :func:`imod.prepare.assign_wells`, which is 0.05, before this was 1.0. +- :func:`imod.prepare.allocate_drn_cells`, + :func:`imod.prepare.allocate_ghb_cells`, + :func:`imod.prepare.allocate_riv_cells`, now allocate to the first model layer + when elevations are above or equal to model top for all methods in + :func:`imod.prepare.ALLOCATION_OPTION`. +- :meth:`imod.mf6.Well.to_mf6_pkg` got a new argument: + ``strict_well_validation``, which controls the behavior for when wells are + removed entirely during their assignment to layers. This replaces the + ``is_partitioned`` argument. +- :func:`imod.prepare.fill` now takes a ``dims`` argument instead of ``by``, + and will fill over N dimensions. Secondly, the function no longer takes + an ``invalid`` argument, but instead always treats NaNs as missing. +- Reverted the need for providing WriteContext objects to MODFLOW 6 Model and + Package objects' ``write`` method. These now use similar arguments to the + :meth:`imod.mf6.Modflow6Simulation.write` method. +- :class:`imod.msw.CouplingMapping`, :class:`imod.msw.Sprinkling`, + `imod.msw.Sprinkling.MetaSwapModel`, now take the + :class:`imod.mf6.mf6_wel_adapter.Mf6Wel` and the + :class:`imod.mf6.StructuredDiscretization` packages as arguments at their + respective ``write`` method, instead of upon initializing these MetaSWAP + objects. +- :class:`imod.msw.CouplingMapping` and :class:`imod.msw.Sprinkling` now take + the :class:`imod.mf6.mf6_wel_adapter.Mf6Wel` as well argument instead of the + deprecated ``imod.mf6.WellDisStructured``. + + +Added +~~~~~ + +- :meth:`imod.mf6.Modflow6Simulation.from_imod5_data` to import imod5 data + loaded with :func:`imod.formats.prj.open_projectfile_data` as a MODFLOW 6 + simulation. +- :func:`imod.prepare.linestring_to_square_zpolygons` and + :func:`imod.prepare.linestring_to_trapezoid_zpolygons` to generate vertical + polygons that can be used to specify horizontal flow barriers, specifically: + :class:`imod.mf6.HorizontalFlowBarrierResistance`, + :class:`imod.mf6.HorizontalFlowBarrierMultiplier`, + :class:`imod.mf6.HorizontalFlowBarrierHydraulicCharacteristic`. +- :class:`imod.mf6.LayeredWell` to specify wells directly to layers instead + assigning them with filter depths. +- :func:`imod.prepare.cleanup_drn`, :func:`imod.prepare.cleanup_ghb`, + :func:`imod.prepare.cleanup_riv`, :func:`imod.prepare.cleanup_wel`. These are + utility functions to clean up drainage, general head boundaries, and rivers, + respectively. +- :meth:`imod.mf6.Drainage.cleanup`, + :meth:`imod.mf6.GeneralHeadboundary.cleanup`, :meth:`imod.mf6.River.cleanup`, + :meth:`imod.mf6.Well.cleanup` convenience methods to call the corresponding + cleanup utility functions with the appropriate arguments. +- :meth:`imod.msw.MetaSwapModel.regrid_like` to regrid MetaSWAP models. This is + still experimental functionality, regridding the :class:`imod.msw.Sprinkling` + is not yet supported. +- The context :func:`imod.util.context.print_if_error` to print an error instead + of raising it in a ``with`` statement. This is useful for code snippets which + definitely will fail. +- :meth:`imod.msw.MetaSwapModel.regrid_like` to regrid MetaSWAP models. +- :meth:`imod.mf6.GroundwaterFlowModel.prepare_wel_for_mf6` to prepare wells for + MODFLOW 6, for debugging purposes. + +Removed +~~~~~~~ + +- :func:`imod.formats.prj.convert_to_disv` has been removed. This functionality + has been replaced by :meth:`imod.mf6.Modflow6Simulation.from_imod5_data`. To + convert a structured simulation to an unstructured simulation, call: + :meth:`imod.mf6.Modflow6Simulation.regrid_like` + + +[0.17.2] - 2024-09-17 +--------------------- + +Fixed +~~~~~ +- :func:`imod.formats.prj.open_projectfile_data` now reports the path to a + faulty IPF or IDF file in the error message. +- Support for Numpy 2.0 + +Added +~~~~~ +- Added objects with regrid settings. These can be used to provide custom + settings: :class:`imod.mf6.regrid.ConstantHeadRegridMethod`, + :class:`imod.mf6.regrid.DiscretizationRegridMethod`, + :class:`imod.mf6.regrid.DispersionRegridMethod`, + :class:`imod.mf6.regrid.DrainageRegridMethod`, + :class:`imod.mf6.regrid.EmptyRegridMethod`, + :class:`imod.mf6.regrid.EvapotranspirationRegridMethod`, + :class:`imod.mf6.regrid.GeneralHeadBoundaryRegridMethod`, + :class:`imod.mf6.regrid.InitialConditionsRegridMethod`, + :class:`imod.mf6.regrid.MobileStorageTransferRegridMethod`, + :class:`imod.mf6.regrid.NodePropertyFlowRegridMethod`, + :class:`imod.mf6.regrid.RechargeRegridMethod`, + :class:`imod.mf6.regrid.RiverRegridMethod`, + :class:`imod.mf6.regrid.SpecificStorageRegridMethod`, + :class:`imod.mf6.regrid.StorageCoefficientRegridMethod`. + +Changed +~~~~~~~ +- Instead of providing a dictionary with settings to ``Package.regrid_like``, + provide one of the following ``RegridMethod`` objects: + :class:`imod.mf6.regrid.ConstantHeadRegridMethod`, + :class:`imod.mf6.regrid.DiscretizationRegridMethod`, + :class:`imod.mf6.regrid.DispersionRegridMethod`, + :class:`imod.mf6.regrid.DrainageRegridMethod`, + :class:`imod.mf6.regrid.EmptyRegridMethod`, + :class:`imod.mf6.regrid.EvapotranspirationRegridMethod`, + :class:`imod.mf6.regrid.GeneralHeadBoundaryRegridMethod`, + :class:`imod.mf6.regrid.InitialConditionsRegridMethod`, + :class:`imod.mf6.regrid.MobileStorageTransferRegridMethod`, + :class:`imod.mf6.regrid.NodePropertyFlowRegridMethod`, + :class:`imod.mf6.regrid.RechargeRegridMethod`, + :class:`imod.mf6.regrid.RiverRegridMethod`, + :class:`imod.mf6.regrid.SpecificStorageRegridMethod`, + :class:`imod.mf6.regrid.StorageCoefficientRegridMethod`. +- Renamed ``imod.mf6.LayeredHorizontalFlowBarrier`` classes to + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierResistance`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierHydraulicCharacteristic`, + :class:`imod.mf6.SingleLayerHorizontalFlowBarrierMultiplier`, + +Fixed +~~~~~ +- :func:`imod.formats.prj.open_projectfile_data` now reports the path to a + faulty IPF or IDF file in the error message. + + + + +[0.17.1] - 2024-05-16 +--------------------- + +Added +~~~~~ +- Added function :func:`imod.util.spatial.gdal_compliant_grid` to make spatial + coordinates of a NetCDF interpretable for GDAL (and so QGIS). +- Added ``crs`` argument to :func:`imod.util.spatial.mdal_compliant_ugrid2d`, + :meth:`imod.mf6.Simulation.dump`, :meth:`imod.mf6.GroundwaterFlowModel.dump`, + :meth:`imod.mf6.GroundwaterTransportModel.dump`, to add a coordinate reference + system to dumped files, to ease loading them in QGIS. + +Changed +~~~~~~~ +- :meth:`imod.mf6.Simulation.dump`, :meth:`imod.mf6.GroundwaterFlowModel.dump`, + :meth:`imod.mf6.GroundwaterTransportModel.dump` write with necessary + attributes to NetCDF to make these files interpretable for GDAL (and so QGIS). + +Fixed +~~~~~ +- Fix missing API docs for ``dump`` and ``write`` methods. + + +[0.17.0] - 2024-05-13 +--------------------- + +Added +~~~~~ +- Added functions to allocate planar grids over layers for the topsystem in + :func:`imod.prepare.allocate_drn_cells`, + :func:`imod.prepare.allocate_ghb_cells`, + :func:`imod.prepare.allocate_rch_cells`, + :func:`imod.prepare.allocate_riv_cells`, for this multiple options can be + selected, available in :func:`imod.prepare.ALLOCATION_OPTION`. +- Added functions to distribute conductances of planar grids over layers for the + topsystem in :func:`imod.prepare.distribute_riv_conductance`, + :func:`imod.prepare.distribute_drn_conductance`, + :func:`imod.prepare.distribute_ghb_conductance`, for this multiple options can + be selected, available in :func:`imod.prepare.DISTRIBUTING_OPTION`. +- :func:`imod.prepare.celltable` supports an optional ``dtype`` argument. This + can be used, for example, to create celltables of float values. +- Added ``fixed_cell`` option to :class:`imod.mf6.Recharge`. This option is + relevant for phreatic models, not using the Newton formulation and model cells + can become inactive. The prefered method for phreatic models is to use the + Newton formulation, where cells remain active, and this option irrelevant. +- Added support for ``ats_outer_maximum_fraction`` in :class:`imod.mf6.Solution`. +- Added validation for ``linear_acceleration``, ``rclose_option``, + ``scaling_method``, ``reordering_method``, ``print_option`` and ``no_ptc`` + entries in :class:`imod.mf6.Solution`. + +Fixed +~~~~~ +- No ``ValidationError`` thrown anymore in :class:`imod.mf6.River` when + ``bottom_elevation`` equals ``bottom`` in the model discretization. +- When wells outside of the domain are added, an exception is raised with an + error message stating a well is outside of the domain. +- When importing data from a .prj file, the multipliers and additions specified for + ipf and idf files are now applied +- Fix bug where y-coords were flipped in :class:`imod.msw.MeteoMapping` + +Changed +~~~~~~~ +- Replaced csv_output by outer_csvfile and inner_csvfile in + :class:`imod.mf6.Solution` to match newer MODFLOW 6 releases. +- Changed no_ptc from a bool to an option string in :class:`imod.mf6.Solution`. +- Removed constructor arguments `source` and `target` from + ``imod.mf6.utilities.regrid.RegridderWeightsCache``, as they were not + used. +- :func:`imod.mf6.open_cbc` now returns arrays which contain np.nan for cells where + budget variables are not defined. Based on new budget output a disquisition between + active cells but zero flow and inactive cells can be made. +- :func:`imod.mf6.open_cbc` now returns package type in return budget names. New format + is "package type"-"optional package variable"_"package name". E.g. a River package + named ``primary-sys`` will get a budget name ``riv_primary-sys``. An UZF package + with name ``uzf-sys1`` will get a budget name ``uzf-gwrch_uzf-sys1`` for the + groundwater recharge budget from the UZF-CBC. + + +[0.16.0] - 2024-03-29 +--------------------- + +Added +~~~~~ +- The :func:`imod.mf6.model.mask_all_packages` now also masks the idomain array + of the model discretization, and can be used with a mask array without a layer + dimension, to mask all layers the same way +- Validation for incompatible settings in the :class:`imod.mf6.NodePropertyFlow` + and :class:`imod.mf6.Dispersion` packages. +- Checks that only one flow model is present in a simulation when calling + :func:`imod.mf6.Modflow6Simulation.regrid_like`, + :func:`imod.mf6.Modflow6Simulation.clip_box` or + :func:`imod.mf6.Modflow6Simulation.split` +- Added support for coupling a GroundwaterFlowModel and Transport Model i.c.w. + the 6.4.3 release of MODFLOW. Using an older version of iMOD Python with this + version of MODFLOW will result in an error. +- :meth:`imod.mf6.Modflow6Simulation.split` supports splitting transport models, + including multi-species simulations. +- :meth:`imod.mf6.Modflow6Simulation.open_concentration` and + :meth:`imod.mf6.Modflow6Simulation.open_transport_budget` support opening + split multi-species simulations. + :meth:`imod.mf6.Modflow6Simulation.regrid_like` can now regrid simulations + that have 1 or more transport models. +- added logging to various initialization methods, write methods and dump + methods. `See the documentation + `_ + how to activate logging. +- added :func:`imod.data.hondsrug_simulation` and + :func:`imod.data.hondsrug_crosssection` data. +- simulations and models that include a lake package now raise an exception on + clipping, partitioning or regridding. + +Changed +~~~~~~~ +- :meth:`imod.mf6.Modflow6Simulation.open_concentration` and + :meth:`imod.mf6.Modflow6Simulation.open_transport_budget` raise a + ``ValueError`` if ``species_ls`` is provided with incorrect length. + +Fixed +~~~~~ +- Incorrect validation error ``data values found at nodata values of idomain`` + for boundary condition packages with a scalar coordinate not set as dimension. +- Fix issue where :func:`imod.formats.idf.open_subdomains` and + :func:`imod.mf6.Modflow6Simulation.open_head` (for split simulations) would + return arrays with incorrect ``dx`` and ``dy`` coordinates for equidistant + data. +- Fix issue where :func:`imod.formats.idf.open_subdomains` returned a flipped ``dy`` + coordinate for nonequidistant data. +- Made :func:`imod.util.round_extent` available again, as it was moved without + notice. Function now throws a DeprecationWarning to use + :func:`imod.prepare.spatial.round_extent` instead. +- :meth'`imod.mf6.Modflow6Simulation.write` failed after splitting the + simulation. This has been fixed. +- modflow options like "print flow", "save flow", and "print input" can now be + set on :class:`imod.mf6.Well` +- when regridding a :class:`imod.mf6.Modflow6Simulation`, + :class:`imod.mf6.GroundwaterFlowModel`, + :class:`imod.mf6.GroundwaterTransportModel` or a :class:`imod.mf6.package`, + regridding weights are now cached and can be re-used over the different + objects that are regridded. This improves performance considerably in most use + cases: when regridding is applied over the same grid cells with the same + regridder type, but with different values/methods, multiple times. + +[0.15.3] - 2024-02-22 +--------------------- + +Fixed +~~~~~ +- Add missing required dependencies for installing with ``pip``: loguru and tomli. +- Ensure geopandas and shapely are optional dependencies again when + installing with ``pip``, and no import errors are thrown. +- Fixed bug where calling ``copy.deepcopy`` on + :class:`imod.mf6.Modflow6Simulation`, :class:`imod.mf6.GroundwaterFlowModel` + and :class:`imod.mf6.GroundwaterTransportModel` objects threw an error. + + +Added +~~~~~ +- Developer environment: Added pixi environment ``interactive`` to interactively + run code. Can be useful to plot data. +- :class:`imod.mf6.ApiPackage` was added. It can be added to both flow and + transport models, and its presence allows users to interact with libMF6.dll + through its API. +- Developer environment: Empty python 3.10, 3.11, 3.12 environments where pip + install and import imod can be tested. + + + +[0.15.2] - 2024-02-16 +--------------------- + +Fixed +~~~~~ +- iMOD Python now supports versions of pandas >= 2 +- Fixed bugs with clipping :class:`imod.mf6.HorizontalFlowBarrier` for + structured grids +- Packages and boundary conditions in the ``imod.mf6`` module will now throw an + error upon initialization if coordinate labels are inconsistent amongst + variables +- 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 +- Fix support for coupling a GroundwaterFlowModel and Transport Model i.c.w. + the 6.4.3 release of MODFLOW. Using an older version of iMOD Python + with this version of MODFLOW will result in an error. + + +Changed +~~~~~~~ +- We moved to using `pixi `_ to create development + environments. This replaces the ``imod-environment.yml`` conda environment. We + advice doing development installations with pixi from now on. `See the + documentation. `_ + This does not affect users who installed with ``pip install imod``, ``mamba + install imod`` or ``conda install imod``. +- Changed build system from ``setuptools`` to ``hatchling``. Users who did a + development install are adviced to run ``pip uninstall imod`` and ``pip + install -e .`` again. This does not affect users who installed with ``pip + install imod``, ``mamba install imod`` or ``conda install imod``. +- Decreased lower limit of MetaSWAP validation for x and y limits in the + ``IdfMapping`` from 0 to -9999999.0. + + +[0.15.1] - 2023-12-22 +--------------------- + +Fixed +~~~~~ +- Made ``specific_yield`` optional argument in + :class:`imod.mf6.SpecificStorage`, :class:`imod.mf6.StorageCoefficient`. +- Fixed bug where simulations with :class:`imod.mf6.Well` were not partitioned + into multiple models. +- Fixed erroneous default value for the ``out_of_bounds`` in + :func:`imod.select.points.point_values` +- Fixed bug where :class:`imod.mf6.Well` could not be assigned to the first cell + of an unstructured grid. +- HorizontalFlowBarrier package now dropped if completely outside partition in a + split model. +- HorizontalFlowBarrier package clipped with ``clip_by_grid`` based on active + cells, consistent with how other packages are treated by this function. This + affects the :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` and + :meth:`imod.mf6.Modflow6Simulation.split` methods. + + +Changed +~~~~~~~ +- All the references to GitLab have been replaced by GitHub references as + part of the GitHub migration. + +Added +~~~~~ +- Added comment in Modflow6 exchanges file (GWFGWF) denoting column header. +- Added Python 3.11 support. +- The GWF-GWF exchange options are derived from user created packages (NPF, OC) and + set automatically. +- Added the ``simulation_start_time`` and ``time_unit`` arguments. To the + ``Modflow6Simulation.open_`` methods, and ``imod.mf6.out.open_`` functions. + This converts the ``"time"`` coordinate to datetimes. +- added :meth:`imod.mf6.Modflow6Simulation.mask_all_models` to apply a mask to + all models under a simulation, provided the simulation is not split and the + models use the same discretization. + + +Changed +~~~~~~~ +- :meth:`imod.mf6.Well.mask` masks with a 2D grid instead of returning a + deepcopy of the package. + + +[0.15.0] - 2023-11-25 +--------------------- + +Fixed +~~~~~ +- The Newton option for a :class:`imod.mf6.GroundwaterFlowModel` was being ignored. This has been + corrected. +- The Contextily packages started throwing errors. This was caused because the + default tile provider being used was Stamen. However Stamen is no longer free + which caused Contextily to fail. The default tile provider has been changed to + OpenStreetMap to resolve this issue. +- :func:`imod.mf6.open_cbc` now reads saved cell saturations and specific discharges. +- :func:`imod.mf6.open_cbc` failed to read unstructured budgets stored + following IMETH1, most importantly the storage fluxes. +- Fixed support of Python 3.11 by dropping the obsolete ``qgs`` module. +- Bug in :class:`imod.mf6.SourceSinkMixing` where, in case of multiple active + boundary conditions with assigned concentrations, it would write a ``.ssm`` + file with all sources/sinks on one single row. +- Fixed bug where TypeError was thrown upond calling + :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` and + :meth:`imod.mf6.HorizontalFlowBarrier.mask`. +- Fixed bug where calling :meth:`imod.mf6.Well.clip_box` over only the time + dimension would remove the index coordinate. +- Validation errors are rendered properly when writing a simulation object or + regridding a model object. + +Changed +~~~~~~~ +- The imod-environment.yml file has been split in an imod-environment.yml + (containing all packages required to run imod-python) and a + imod-environment-dev.yml file (containing additional packages for developers). +- Changed the way :class:`imod.mf6.Modflow6Simulation`, + :class:`imod.mf6.GroundwaterFlowModel`, + :class:`imod.mf6.GroundwaterTransportModel`, and MODFLOW 6 packages are + represented while printing. +- The grid-agnostic packages :meth:`imod.mf6.Well.regrid_like` and + :meth:`imod.mf6.HorizontalFlowBarrier.regrid_like` now return a clip with the + grid exterior of the target grid + +Added +~~~~~ +- The unit tests results are now published on GitLab +- A ``save_saturation`` option to :class:`imod.mf6.NodePropertyFlow` which saves + cell saturations for unconfined flow. +- Functions :func:`imod.prepare.layer.get_upper_active_layer_number` and + :func:`imod.prepare.layer.get_lower_active_layer_number` to return planar + grids with numbers of the highest and lowest active cells respectively. +- Functions :func:`imod.prepare.layer.get_upper_active_grid_cells` and + :func:`imod.prepare.layer.get_lower_active_grid_cells` to return boolean + grids designating respectively the highest and lowest active cells in a grid. +- validation of ``transient`` argument in :class:`imod.mf6.StorageCoefficient` + and :class:`imod.mf6.SpecificStorage`. +- :meth:`imod.mf6.Modflow6Simulation.open_concentration`, + :meth:`imod.mf6.Modflow6Simulation.open_head`, + :meth:`imod.mf6.Modflow6Simulation.open_transport_budget`, and + :meth:`imod.mf6.Modflow6Simulation.open_flow_budget`, were added as convenience + methods to open simulation output easier (without having to specify paths). +- The :meth:`imod.mf6.Modflow6Simulation.split` method has been added. This method makes + it possible for a user to create a Multi-Model simulation. A user needs to + provide a submodel label array in which they specify to which submodel a cell + belongs. The method will then create the submodels and split the nested + packages. The split method will create the gwfgwf exchanges required to + connect the submodels. At the moment auxiliary variables ``cdist`` and + ``angldegx`` are only computed for structured grids. +- The label array can be generated through a convenience function + :func:`imod.mf6.partition_generator.get_label_array` +- Once a split simulation has been executed by MF6, we find head and balance + results in each of the partition models. These can now be merged into head and + balance datasets for the original domain using + :meth:`imod.mf6.Modflow6Simulation.open_concentration`, + :meth:`imod.mf6.Modflow6Simulation.open_head`, + :meth:`imod.mf6.Modflow6Simulation.open_transport_budget`, + :meth:`imod.mf6.Modflow6Simulation.open_flow_budget`. + In the case of balances, the exchanges through the partition boundary are not + yet added to this merged balance. +- Settings such as ``save_flows`` can be passed through + :meth:`imod.mf6.SourceSinkMixing.from_flow_model` +- Added :class:`imod.mf6.LayeredHorizontalFlowBarrierHydraulicCharacteristic`, + :class:`imod.mf6.LayeredHorizontalFlowBarrierMultiplier`, + :class:`imod.mf6.LayeredHorizontalFlowBarrierResistance`, for horizontal flow + barriers with a specified layer number. + + +Removed +~~~~~~~ +- Tox has been removed from the project. +- Dropped support for writing .qgs files directly for QGIS, as this was hard to + maintain and rarely used. To export your model to QGIS readable files, call + the ``dump`` method :class:`imod.mf6.Modflow6Simulation` with ``mdal_compliant=True``. + This writes UGRID NetCDFs which can read as meshes in QGIS. +- Removed ``declxml`` from repository. + +[0.14.1] - 2023-09-07 +--------------------- + +Changed +~~~~~~~ + +- TWRI MODFLOW 6 example uses the grid-agnostic :class:`imod.mf6.Well` + package instead of the ``imod.mf6.WellDisStructured`` package. + +Fixed +~~~~~ + +- :class:`imod.mf6.HorizontalFlowBarrier` would write to a binary file by + default. However, the current version of MODFLOW 6 does not support this. + Therefore, this class now always writes to text file. + + +[0.14.0] - 2023-09-06 +--------------------- + +Changed +~~~~~~~ + +- :class:`imod.mf6.HorizontalFlowBarrier` is specified by providing a geopandas + `GeoDataFrame + `_ + + +Added +~~~~~ + +- :meth:`imod.mf6.Modflow6Simulation.regrid_like` to regrid a Modflow6 simulation to a + new grid (structured or unstructured), using `xugrid's regridding + functionality. + `_ + Variables are regridded with pre-selected methods. The regridding + functionality is useful for a variety of applications, for example to test the + effect of different grid sizes, to add detail to a simulation (by refining the + grid) or to speed up a simulation (by coarsening the grid) to name a few +- :meth:`imod.mf6.Package.regrid_like` to regrid packages. The user can + specify their own custom regridder types and methods for variables. +- :meth:`imod.mf6.Modflow6Simulation.clip_box` got an extra argument + ``states_for_boundary``, which takes a dictionary with modelname as key and + griddata as value. This data is specified as fixed state on the model + boundary. At present only `imod.mf6.GroundwaterFlowModel` is supported, grid + data is specified as a :class:`imod.mf6.ConstantHead` at the model boundary. +- :class:`imod.mf6.Well`, a grid-agnostic well package, where wells can be + specified based on their x,y coordinates and filter top and bottom. + + +[0.13.2] - 2023-07-26 +--------------------- + +Changed +~~~~~~~ + +- :func:`imod.formats.rasterio.save` will now write ESRII ASCII rasters, even if + rasterio is not installed. A fallback function has been added specifically + for ASCII rasters. + +Fixed +~~~~~ + +- Geopandas and rasterio were imported at the top of a module in some places. + This has been fixed so that both are not optional dependencies when + installing via pip (installing via conda or mamba will always pull all + dependencies and supports full functionality). +- :meth:`imod.mf6.Modflow6Simulation._validate` now print all validation errors for all + models and packages in one message. +- The gen file reader can now handle feature id's that contain commas and spaces +- :class:`imod.mf6.EvapoTranspiration` now supports segments, by adding a + ``segment`` dimension to the ``proportion_depth`` and ``proportion_rate`` + variables. +- :class:`imod.mf6.EvapoTranspiration` template for ``.evt`` file now properly + formats ``nseg`` option. +- Fixed bug in :class:`imod.wq.Well` preventing saving wells without a time + dimension, but with a layer dimension. +- :class:`imod.mf6.DiscretizationVertices._validate` threw ``KeyError`` for + ``"bottom"`` when validating the package separately. + +Added +~~~~~ + +- :func:`imod.select.grid.active_grid_boundary_xy` & + :func:`imod.select.grid.grid_boundary_xy` are added to find grid boundaries. + +[0.13.1] - 2023-05-05 +--------------------- + +Added +~~~~~ + +- :class:`imod.mf6.SpecificStorage` and :class:`imod.mf6.StorageCoefficient` + now have a ``save_flow`` argument. + +Fixed +~~~~~ + +- :func:`imod.mf6.open_cbc` can now read storage fluxes without error. + + +[0.13.0] - 2023-05-02 +--------------------- + +Added +~~~~~ + +- :class:`imod.mf6.OutputControl` now takes parameters ``head_file``, + ``concentration_file``, and ``budget_file`` to specify where to store + MODFLOW 6 output files. +- :func:`imod.util.spatial.from_mdal_compliant_ugrid2d` to "restack" the variables that + have have been "unstacked" in :func:`imod.util.spatial.mdal_compliant_ugrid2d`. +- Added support for the Modflow6 Lake package +- :func:`imod.select.points_in_bounds`, :func:`imod.select.points_indices`, + :func:`imod.select.points_values` now support unstructured grids. +- Added support for the MODFLOW 6 Lake package: :class:`imod.mf6.Lake`, + :class:`imod.mf6.LakeData`, :class:`imod.mf6.OutletManning`, :class:`OutletSpecified`, + :class:`OutletWeir`. See the examples for an application of the Lake package. +- :meth:`imod.mf6.simulation.Modflow6Simulation.dump` now supports dumping to MDAL compliant + ugrids. These can be used to view and explore Modlfow 6 simulations in QGIS. + +Fixed +~~~~~ + +- :meth:`imod.wq.bas.BasicFlow.thickness` returns a DataArray with the correct + dimension order again. This confusingly resulted in an error when writing the + :class:`imod.wq.btn.BasicTransport` package. +- Fixed bug in :class:`imod.mf6.dis.StructuredDiscretization` and + :class:`imod.mf6.dis.VerticesDiscretization` where + ``inactive bottom above active cell`` was incorrectly raised. + +[0.12.0] - 2023-03-17 +--------------------- + +Added +~~~~~ + +- :func:`imod.prj.read_projectfile` to read the contents of a project file into + a Python dictionary. +- :func:`imod.prj.open_projectfile_data` to read/open the data that is pointed + to in a project file. +- :func:`imod.gen.read_ascii` to read the geometry stored in ASCII text .gen files. +- :class:`imod.mf6.hfb.HorizontalFlowBarrier` to support Modflow6's HFB + package, works well with `xugrid.snap_to_grid` function. +- :meth:`imod.mf6.simulation.Modflow6Simulation.dump` to dump a simulation to a toml file + which acts as a definition file, pointing to packages written as netcdf files. This + can be used to intermediately store Modflow6 simulations. + +Fixed +~~~~~ + +- :func:`imod.evaluate.budget.flow_velocity` now properly computes velocity by + dividing by the porosity. Before, this function computed the Darcian velocity. + +Changed +~~~~~~~ + +- :func:`imod.formats.ipf.save` will error on duplicate IDs for associated files if a + ``"layer"`` column is present. As a dataframe is automatically broken down + into a single IPF per layer, associated files for the first layer would be + overwritten by the second, and so forth. +- :meth:`imod.wq.Well.save` will now write time varying data to associated + files for extration rate and concentration. +- Choosing ``method="geometric_mean"`` in the Regridder will now result in NaN + values in the regridded result if a geometric mean is computed over negative + values; in general, a geometric mean should only be computed over physical + quantities with a "true zero" (e.g. conductivity, but not elevation). + +[0.11.6] - 2023-02-01 +--------------------- + +Added +~~~~~ + +- Added an extra optional argument in + :meth:`imod.couplers.metamod.MetaMod.write` named ``modflow6_write_kwargs``, + which can be used to provide keyword arguments to the writing of the MODFLOW 6 + Simulation. + +Fixed +~~~~~ + +- :func:`imod.mf6.out.disv.read_grb` Remove repeated construction of + ``UgridDataArray`` for ``top`` + +[0.11.5] - 2022-12-15 +--------------------- + +Fixed +~~~~~ + +- :meth:`imod.mf6.Modflow6Simulation.write` with ``binary=False`` no longer + results in invalid MODFLOW 6 input for 2D grid data, such as DIS top. +- ``imod.flow.ImodflowModel.write`` no longer writes incorrect project + files for non-grid values with a time and layer dimension. +- :func:`imod.evaluate.interpolate_value_boundaries`: Fix edge case when + successive values in z direction are exactly equal to the boundary value. + +Changed +~~~~~~~ + +- Removed ``meshzoo`` dependency. +- Minor changes to :mod:`imod.gen.gen` backend, to support `Shapely 2.0 + `_ , Shapely + version above equal v1.8 is now required. + +Added +~~~~~ + +- ``imod.flow.ImodflowModel.write`` now supports writing a + ``config_run.ini`` to convert the projectfile to a runfile or modflow 6 + namfile with iMOD5. +- Added validation of Modflow6 Flow and Transport models. Incorrect model input + will now throw a ``ValidationError``. To turn off the validation, set + ``validate=False`` upon package initialization and/or when calling + :meth:`imod.mf6.Modflow6Simulation.write`. + +[0.11.4] - 2022-09-05 +--------------------- + +Fixed +~~~~~ + +- :meth:`imod.mf6.GroundwaterFlowModel.write` will no longer error when a 3D + DataArray with a single layer is written. It will now accept both 2D and 3D + arrays with a single layer coordinate. +- Hotfixes for :meth:`imod.wq.model.SeawatModel.clip`, until `this merge request + `_ is + fulfilled. +- ``imod.flow.ImodflowModel.write`` will set the timestring in the + projectfile to ``steady-state`` for ``BoundaryConditions`` without a time + dimension. +- Added ``imod.flow.OutputControl`` as this was still missing. +- :func:`imod.formats.ipf.read` will no longer error when an associated files with 0 + rows is read. +- :func:`imod.evaluate.calculate_gxg` now correctly uses (March 14, March + 28, April 14) to calculate GVG rather than (March 28, April 14, April 28). +- :func:`imod.mf6.out.open_cbc` now correctly loads boundary fluxes. +- :meth:`imod.prepare.LayerRegridder.regrid` will now correctly skip values + if ``top_source`` or ``bottom_source`` are NaN. +- :func:`imod.gen.write` no longer errors on dataframes with empty columns. +- ``imod.mf6.BoundaryCondition.set_repeat_stress`` reinstated. This is + a temporary measure, it gives a deprecation warning. + +Changed +~~~~~~~ + +- Deprecate the current documentation URL: https://imod.xyz. For the coming + months, redirection is automatic to: + https://deltares.gitlab.io/imod/imod-python/. +- :func:`imod.formats.ipf.save` will now store associated files in separate directories + named ``layer1``, ``layer2``, etc. The ID in the main IPF file is updated + accordingly. Previously, if IDs were shared between different layers, the + associated files would be overwritten as the IDs would result in the same + file name being used over and over. +- ``imod.flow.ImodflowModel.time_discretization``, + :meth:`imod.wq.SeawatModel.time_discretization`, + :meth:`imod.mf6.Modflow6Simulation.time_discretization`, + are renamed to: + ``imod.flow.ImodflowModel.create_time_discretization``, + :meth:`imod.wq.SeawatModel.create_time_discretization`, + :meth:`imod.mf6.Modflow6Simulation.create_time_discretization`, +- Moved tests inside `imod` directory, added an entry point for pytest fixtures. + Running the tests now requires an editable install, and also existing + installations have to be reinstalled to run the tests. +- The ``imod.mf6`` model packages now all run type checks on input. This is a + breaking change for scripts which provide input with an incorrect dtype. +- :class:`imod.mf6.Solution` now requires a `model_names` argument to specify + which models should be solved in a single numerical solution. This is + required to simulate groundwater flow and transport as they should be + in separate solutions. +- When writing MODFLOW 6 input option blocks, a NaN value is now recognized as + an alternative to None (and the entry will not be included in the options + block). + +Added +~~~~~ + +- Added support to write MetaSWAP models, :class:`imod.msw.MetaSwapModel`. +- Addes support to write coupled MetaSWAP and Modflow6 simulations, + :class:`imod.couplers.MetaMod` +- :func:`imod.util.replace` has been added to find and replace different values + in a DataArray. +- :func:`imod.evaluate.calculate_gxg_points` has been added to compute GXG + values for time varying point data (i.e. loaded from IPF and presented as a + Pandas dataframe). +- :func:`imod.evaluate.calculate_gxg` will return the number of years used + in the GxG calculation as separate variables in the output dataset. +- :func:`imod.visualize.spatial.plot_map` now accepts a `fix` and `ax` argument, + to enable adding maps to existing axes. +- ``imod.flow.ImodflowModel.create_time_discretization``, + :meth:`imod.wq.SeawatModel.create_time_discretization`, + :meth:`imod.mf6.Modflow6Simulation.create_time_discretization`, now have a + documentation section. +- :class:`imod.mf6.GroundwaterTransportModel` has been added with associated + simple classes to allow creation of solute transport models. Advanced + boundary conditions such as LAK or UZF are not yet supported. +- :class:`imod.mf6.Buoyancy` has been added to simulate density dependent + groundwater flow. + +[0.11.1] - 2021-12-23 +--------------------- + +Fixed +~~~~~ + +- ``contextily``, ``geopandas``, ``pyvista``, ``rasterio``, and ``shapely`` + are now fully optional dependencies. Import errors are only raised when + accessing functionality that requires their use. +- Include declxml as ``imod.declxml`` (should be internal use only!): declxml + is no longer maintained on the official repository: + https://github.com/gatkin/declxml. Furthermore, it has no conda feedstock, + which makes distribution via conda difficult. + +[0.11.0] - 2021-12-21 +--------------------- + +Fixed +~~~~~ + +- :func:`imod.formats.ipf.read` accepts list of file names. +- :func:`imod.mf6.open_hds` did not read the appropriate bytes from the + heads file, apart for the first timestep. It will now read the right records. +- Use the appropriate array for modflow6 timestep duration: the + :meth:`imod.mf6.GroundwaterFlowModel.write` would write the timesteps + multiplier in place of the duration array. +- :meth:`imod.mf6.GroundwaterFlowModel.write` will now respect the layer + coordinate of DataArrays that had multiple coordinates, but were + discontinuous from 1; e.g. layers [1, 3, 5] would've been transformed to [1, + 2, 3] incorrectly. +- :meth:`imod.mf6.Modflow6Simulation.write` will no longer change working directory + while writing model input -- this could lead to errors when multiple + processes are writing models in parallel. +- :func:`imod.prepare.laplace_interpolate` will no longer ZeroDivisionError + when given a value for ``ibound``. + +Added +~~~~~ + +- :func:`imod.formats.idf.open_subdomains` will now also accept iMOD-WQ output of + multiple species runs. +- :meth:`imod.wq.SeawatModel.to_netcdf` has been added to write all model + packages to netCDF files. +- :func:`imod.mf6.open_cbc` has been added to read the budget data of + structured (DIS) MODFLOW 6 models. The data is read lazily into xarray + DataArrays per timestep. +- :func:`imod.visualize.streamfunction` and :func:`imod.visualize.quiver` + were added to plot a 2D representation of the groundwater flow field using + either streamlines or quivers over a cross section plot + (:func:`imod.visualize.cross_section`). +- :func:`imod.evaluate.streamfunction_line` and + :func:`imod.evaluate.streamfunction_linestring` were added to extract the + 2D projected streamfunction of the 3D flow field for a given cross section. +- :func:`imod.evaluate.quiver_line` and :func:`imod.evaluate.quiver_linestring` + were added to extract the u and v components of the 3D flow field for a given + cross section. +- Added :meth:`imod.mf6.GroundwaterFlowModel.write_qgis_project` to write a + QGIS project for easier inspection of model input in QGIS. +- Added :meth:`imod.wq.SeawatModel.clip` to clip a model to a provided extent. + Boundary conditions of clipped model can be automatically derived from parent + model calculation results and are applied along the edges of the extent. +- Added :py:func:`imod.gen.read` and :py:func:`imod.gen.write` for reading + and writing binary iMOD GEN files to and from geopandas GeoDataFrames. +- Added :py:func:`imod.prepare.zonal_aggregate_raster` and + :py:func:`imod.prepare.zonal_aggregate_polygons` to efficiently compute zonal + aggregates for many polygons (e.g. the properties every individual ditch in + the Netherlands). +- Added ``imod.flow.ImodflowModel`` to write to model iMODFLOW project + file. +- :meth:`imod.mf6.Modflow6Simulation.write` now has a ``binary`` keyword. When set + to ``False``, all MODFLOW 6 input is written to text rather than binary files. +- Added :class:`imod.mf6.DiscretizationVertices` to write MODFLOW 6 DISV model + input. +- Packages for :class:`imod.mf6.GroundwaterFlowModel` will now accept + :class:`xugrid.UgridDataArray` objects for (DISV) unstructured grids, next to + :class:`xarray.DataArray` objects for structured (DIS) grids. +- Transient wells are now supported in ``imod.mf6.WellDisStructured`` and + ``imod.mf6.WellDisVertices``. +- :func:`imod.util.to_ugrid2d` has been added to convert a (structured) xarray + DataArray or Dataset to a quadrilateral UGRID dataset. +- Functions created to create empty DataArrays with greater ease: + :func:`imod.util.empty_2d`, :func:`imod.util.empty_2d_transient`, + :func:`imod.util.empty_3d`, and :func:`imod.util.empty_3d_transient`. +- :func:`imod.util.where` has been added for easier if-then-else operations, + especially for preserving NaN nodata values. +- :meth:`imod.mf6.Modflow6Simulation.run` has been added to more easily run a model, + especially in examples and tests. +- :func:`imod.mf6.open_cbc` and :func:`imod.mf6.open_hds` will automatically + return a ``xugrid.UgridDataArray`` for MODFLOW 6 DISV model output. + +Changed +~~~~~~~ + +- Documentation overhaul: different theme, add sample data for examples, add + Frequently Asked Questions (FAQ) section, restructure API Reference. Examples + now ru +- Datetime columns in IPF associated files (via + :func:`imod.formats.ipf.write_assoc`) will not be placed within quotes, as this can + break certain iMOD batch functions. +- :class:`imod.mf6.Well` has been renamed into ``imod.mf6.WellDisStructured``. +- :meth:`imod.mf6.GroundwaterFlowModel.write` will now write package names + into the simulation namefile. +- :func:`imod.mf6.open_cbc` will now return a dictionary with keys + ``flow-front-face, flow-lower-face, flow-right-face`` for the face flows, + rather than ``front-face-flow`` for better consistency. +- Switched to composition from inheritance for all model packages: all model + packages now contain an internal (xarray) Dataset, rather than inheriting + from the xarray Dataset. +- :class:`imod.mf6.SpecificStorage` or :class:`imod.mf6.StorageCoefficient` is + now mandatory for every MODFLOW 6 model to avoid accidental steady-state + configuration. + +Removed +~~~~~~~ + +- Module ``imod.tec`` for reading Tecplot files has been removed. + +[0.10.1] - 2020-10-19 +--------------------- + +Changed +~~~~~~~ + +- :meth:`imod.wq.SeawatModel.write` now generates iMOD-WQ runfiles with + more intelligent use of the "macro tokens". ``:`` is used exclusively for + ranges; ``$`` is used to signify all layers. (This makes runfiles shorter, + speeding up parsing, which takes a significant amount of time in the runfile + to namefile conversion of iMOD-WQ.) +- Datetime formats are inferred based on length of the time string according to + ``%Y%m%d%H%M%S``; supported lengths 4 (year only) to 14 (full format string). + +Added +~~~~~ + +- :class:`imod.wq.MassLoading` and + :class:`imod.wq.TimeVaryingConstantConcentration` have been added to allow + additional concentration boundary conditions. +- IPF writing methods support an ``assoc_columns`` keyword to allow greater + flexibility in including and renaming columns of the associated files. +- Optional basemap plotting has been added to :meth:`imod.visualize.plot_map`. + +Fixed +~~~~~ + +- IO methods for IDF files will now correctly identify double precision IDFs. + The correct record length identifier is 2295 rather than 2296 (2296 was a + typo in the iMOD manual). +- :meth:`imod.wq.SeawatModel.write` will now write the correct path for + recharge package concentration given in IDF files. It did not prepend the + name of the package correctly (resulting in paths like + ``concentration_l1.idf`` instead of ``rch/concentration_l1.idf``). +- :meth:`imod.formats.idf.save` will simplify constant cellsize arrays to a scalar + value -- this greatly speeds up drawing in the iMOD-GUI. + +[0.10.0] - 2020-05-23 +--------------------- + +Changed +~~~~~~~ + +- :meth:`imod.wq.SeawatModel.write` no longer automatically appends the model + name to the directory where the input is written. Instead, it simply writes + to the directory as specified. +- :func:`imod.select.points_set_values` returns a new DataArray rather than + mutating the input ``da``. +- :func:`imod.select.points_values` returns a DataArray with an index taken + from the data of the first provided dimensions if it is a ``pandas.Series``. +- :meth:`imod.wq.SeawatModel.write` now writes a runfile with ``start_hour`` + and ``start_minute`` (this results in output IDFs with datetime format + ``"%Y%m%d%H%M"``). + +Added +~~~~~ + +- :meth:`from_file` constructors have been added to all `imod.wq.Package`. + This allows loading directly package from a netCDF file (or any file supported by + ``xarray.open_dataset``), or a path to a Zarr directory with suffix ".zarr" or ".zip". +- This can be combined with the `cache` argument in :meth:`from_file` to + enable caching of answers to avoid repeated computation during + :meth:`imod.wq.SeawatModel.write`; it works by checking whether input and + output files have changed. +- The ``resultdir_is_workspace`` argument has been added to :meth:`imod.wq.SeawatModel.write`. + iMOD-wq writes a number of files (e.g. list file) in the directory where the + runfile is located. This results in mixing of input and output. By setting it + ``True``, **all** model output is written in the results directory. +- :func:`imod.visualize.imshow_topview` has been added to visualize a complete + DataArray with atleast dimensions ``x`` and ``y``; it dumps PNGs into a + specified directory. +- Some support for 3D visualization has been added. + :func:`imod.visualize.grid_3d` and :func:`imod.visualize.line_3d` have been + added to produce ``pyvista`` meshes from ``xarray.DataArray``'s and + ``shapely`` polygons, respectively. + :class:`imod.visualize.GridAnimation3D` and :class:`imod.visualize.StaticGridAnimation3D` + have been added to setup 3D animations of DataArrays with transient data. +- Support for out of core computation by ``imod.prepare.Regridder`` if ``source`` + is chunked. +- :func:`imod.formats.ipf.read` now reports the problematic file if reading errors occur. +- :func:`imod.prepare.polygonize` added to polygonize DataArrays to GeoDataFrames. +- Added more support for multiple species imod-wq models, specifically: scalar concentration + for boundary condition packages and well IPFs. + +Fixed +~~~~~ + +- :meth:`imod.prepare.Regridder` detects if the ``like`` DataArray is a subset + along a dimension, in which case the dimension is not regridded. +- :meth:`imod.prepare.Regridder` now slices the ``source`` array accurately + before regridding, taking cell boundaries into account rather than only + cell midpoints. +- ``density`` is no longer an optional argument in :class:`imod.wq.GeneralHeadboundary` and + :class:`imod.wq.River`. The reason is that iMOD-WQ fully removes (!) these packages if density + is not present. +- :func:`imod.formats.idf.save` and :func:`imod.formats.rasterio.save` will now also save DataArrays in + which a coordinate other than ``x`` or ``y`` is descending. +- :func:`imod.visualize.plot_map` enforces decreasing ``y``, which ensures maps are not plotted + upside down. +- :func:`imod.util.spatial.coord_reference` now returns a scalar cellsize if coordinate is equidistant. +- :meth:`imod.prepare.Regridder.regrid` returns cellsizes as scalar when coordinates are + equidistant. +- Raise proper ValueError in :meth:`imod.prepare.Regridder.regrid` consistenly when the number + of dimensions to regrid does not match the regridder dimensions. +- When writing DataArrays that have size 1 in dimension ``x`` or ``y``: raise error if cellsize + (``dx`` or ``dy``) is not specified; and actually use ``dy`` or ``dx`` when size is 1. + +[0.9.0] - 2020-01-19 +-------------------- + +Added +~~~~~ + +- IDF files representing data of arbitrary dimensionality can be opened and + saved. This enables reading and writing files with more dimensions than just x, + y, layer, and time. +- Added multi-species support for (:mod:`imod.wq`) +- GDAL rasters representing N-dimensional data can be opened and saved similar to (:mod:`imod.idf`) in (:mod:`imod.rasterio`) +- Writing GDAL rasters using :meth:`imod.formats.rasterio.save` and (:meth:`imod.formats.rasterio.write`) auto-detects GDAL driver based on file extension +- 64-bit IDF files can be opened :meth:`imod.formats.idf.open` +- 64-bit IDF files can be written using :meth:`imod.formats.idf.save` and (:meth:`imod.formats.idf.write`) using keyword ``dtype=np.float64`` +- ``sel`` and ``isel`` methods to ``SeawatModel`` to support taking out a subdomain +- Docstrings for the MODFLOW 6 classes in :mod:`imod.mf6` +- :meth:`imod.select.upper_active_layer` function to get the upper active layer from ibound ``xr.DataArray`` + +Changed +~~~~~~~ + +- ``imod.formats.idf.read`` is deprecated, use :func:`imod.formats.idf.open` instead +- ``imod.formats.rasterio.read`` is deprecated, use :func:`imod.formats.rasterio.open` instead + +Fixed +~~~~~ + +- :meth:`imod.prepare.reproject` working instead of silently failing when given a ``"+init=ESPG:XXXX`` CRS string + +[0.8.0] - 2019-10-14 +-------------------- + +Added +~~~~~ +- Laplace grid interpolation :meth:`imod.prepare.laplace_interpolate` +- Experimental MODFLOW 6 structured model write support :mod:`imod.mf6` +- More supported visualizations :mod:`imod.visualize` +- More extensive reading and writing of GDAL raster in :mod:`imod.rasterio` + +Changed +~~~~~~~ + +- The documentation moved to a custom domain name: https://imod.xyz/ + +[0.7.1] - 2019-08-07 +-------------------- + +Added +~~~~~ +- ``"multilinear"`` has been added as a regridding option to ``imod.prepare.Regridder`` to do linear interpolation up to three dimensions. +- Boundary condition packages in ``imod.wq`` support a method called ``add_timemap`` to do cyclical boundary conditions, such as summer and winter stages. + +Fixed +~~~~~ + +- ``imod.idf.save`` no longer fails on a single IDF when it is a voxel IDF (when it has top and bottom data). +- ``imod.prepare.celltable`` now succesfully does parallel chunkwise operations, rather than raising an error. +- ``imod.Regridder``'s ``regrid`` method now succesfully returns ``source`` if all dimensions already have the right cell sizes, rather than raising an error. +- ``imod.idf.open_subdomains`` is much faster now at merging different subdomain IDFs of a parallel modflow simulation. +- ``imod.idf.save`` no longer suffers from extremely slow execution when the DataArray to save is chunked (it got extremely slow in some cases). +- Package checks in ``imod.wq.SeawatModel`` succesfully reduces over dimensions. +- Fix last case in ``imod.prepare.reproject`` where it did not allocate a new array yet, but returned ``like`` instead of the reprojected result. + +[0.7.0] - 2019-07-23 +-------------------- + +Added +~~~~~ + +- :mod:`imod.wq` module to create iMODFLOW Water Quality models +- conda-forge recipe to install imod (https://github.com/conda-forge/imod-feedstock/) +- significantly extended documentation and examples +- :mod:`imod.prepare` module with many data mangling functions +- :mod:`imod.select` module for extracting data along cross sections or at points +- :mod:`imod.visualize` module added to visualize results +- :func:`imod.idf.open_subdomains` function to open and merge the IDF results of a parallelized run +- :func:`imod.formats.ipf.read` now infers delimeters for the headers and the body +- :func:`imod.formats.ipf.read` can now deal with heterogeneous delimiters between multiple IPF files, and between the headers and body in a single file + +Changed +~~~~~~~ + +- Namespaces: lift many functions one level, such that you can use e.g. the function ``imod.prepare.reproject`` instead of ``imod.prepare.reproject.reproject`` + +Removed +~~~~~~~ + +- All that was deprecated in v0.6.0 + +Deprecated +~~~~~~~~~~ + +- :func:`imod.seawat_write` is deprecated, use the write method of :class:`imod.wq.SeawatModel` instead +- :func:`imod.run.seawat_get_runfile` is deprecated, use :mod:`imod.wq` instead +- :func:`imod.run.seawat_write_runfile` is deprecated, use :mod:`imod.wq` instead + +[0.6.1] - 2019-04-17 +-------------------- + +Added +~~~~~ + +- Support nonequidistant models in runfile + +Fixed +~~~~~ + +- Time conversion in runfile now also accepts cftime objects + +[0.6.0] - 2019-03-15 +-------------------- + +The primary change is that a number of functions have been renamed to +better communicate what they do. + +The ``load`` function name was not appropriate for IDFs, since the IDFs +are not loaded into memory. Rather, they are opened and the headers are +read; the data is only loaded when needed, in accordance with +``xarray``'s design; compare for example ``xarray.open_dataset``. The +function has been renamed to ``open``. + +Similarly, ``load`` for IPFs has been deprecated. ``imod.ipf.read`` now +reads both single and multiple IPF files into a single +``pandas.DataFrame``. + +Removed +~~~~~~~ + +- ``imod.idf.setnodataheader`` + +Deprecated +~~~~~~~~~~ + +- Opening IDFs with ``imod.idf.load``, use ``imod.idf.open`` instead +- Opening a set of IDFs with ``imod.idf.loadset``, use + ``imod.idf.open_dataset`` instead +- Reading IPFs with ``imod.ipf.load``, use ``imod.ipf.read`` +- Reading IDF data into a dask array with ``imod.idf.dask``, use + ``imod.idf._dask`` instead +- Reading an iMOD-seawat .tec file, use ``imod.tec.read`` instead. + +Changed +~~~~~~~ + +- Use ``np.datetime64`` when dates are within time bounds, use + ``cftime.DatetimeProlepticGregorian`` when they are not (matches + ``xarray`` defaults) +- ``assert`` is no longer used to catch faulty input arguments, + appropriate exceptions are raised instead + +Fixed +~~~~~ + +- ``idf.open``: sorts both paths and headers consistently so data does + not end up mixed up in the DataArray +- ``idf.open``: Return an ``xarray.CFTimeIndex`` rather than an array + of ``cftime.DatimeProlepticGregorian`` objects +- ``idf.save`` properly forwards ``nodata`` argument to ``write`` +- ``idf.write`` coerces coordinates to floats before writing +- ``ipf.read``: Significant performance increase for reading IPF + timeseries by specifying the datetime format +- ``ipf.write`` no longer writes ``,,`` for missing data (which iMOD + does not accept) + +[0.5.0] - 2019-02-26 +-------------------- + +Removed +~~~~~~~ + +- Reading IDFs with the ``chunks`` option + +Deprecated +~~~~~~~~~~ + +- Reading IDFs with the ``memmap`` option +- ``imod.idf.dataarray``, use ``imod.idf.load`` instead + +Changed +~~~~~~~ + +- Reading IDFs gives delayed objects, which are only read on demand by + dask +- IDF: instead of ``res`` and ``transform`` attributes, use ``dx`` and + ``dy`` coordinates (0D or 1D) +- Use ``cftime.DatetimeProlepticGregorian`` to support time instead of + ``np.datetime64``, allowing longer timespans +- Repository moved from ``https://gitlab.com/deltares/`` to + ``https://gitlab.com/deltares/imod/`` + +Added +~~~~~ + +- Notebook in ``examples`` folder for synthetic model example +- Support for nonequidistant IDF files, by adding ``dx`` and ``dy`` + coordinates + +Fixed +~~~~~ + +- IPF support implicit ``itype`` + +.. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/ +.. _Semantic Versioning: https://semver.org/spec/v2.0.0.html diff --git a/imod/mf6/drn.py b/imod/mf6/drn.py index 544e82a4c..392b506e8 100644 --- a/imod/mf6/drn.py +++ b/imod/mf6/drn.py @@ -1,361 +1,361 @@ -from datetime import datetime -from typing import Optional - -import numpy as np - -from imod.common.interfaces.iregridpackage import IRegridPackage -from imod.common.utilities.dataclass_type import DataclassType -from imod.common.utilities.mask import broadcast_and_mask_arrays -from imod.logging import init_log_decorator, standard_log_decorator -from imod.mf6.aggregate.aggregate_schemes import DrainageAggregationMethod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.regrid.regrid_schemes import DrainageRegridMethod -from imod.mf6.topsystem import TopSystemBoundaryCondition -from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data -from imod.mf6.utilities.package import set_repeat_stress_if_available -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA -from imod.prepare.cleanup import cleanup_drn -from imod.prepare.topsystem.allocation import ( - ALLOCATION_OPTION, - allocate_drn_cells, - drop_empty_layers_from_dict, -) -from imod.prepare.topsystem.conductance import ( - DISTRIBUTING_OPTION, - distribute_drn_conductance, -) -from imod.schemata import ( - AllCoordsValueSchema, - AllInsideNoDataSchema, - AllNoDataSchema, - AllValueSchema, - CoordsSchema, - DimsSchema, - DTypeSchema, - IdentityNoDataSchema, - IndexesSchema, - OtherCoordsSchema, -) -from imod.typing import GridDataArray -from imod.typing.grid import enforce_dim_order, has_negative_layer, is_planar_grid -from imod.util.regrid import RegridderWeightsCache - - -class Drainage(TopSystemBoundaryCondition, IRegridPackage): - """ - The Drain package is used to simulate head-dependent flux boundaries. - https://water.usgs.gov/ogw/modflow/mf6io.pdf#page=67 - - Parameters - ---------- - elevation: array of floats (xr.DataArray) - elevation of the drain. (elev) - conductance: array of floats (xr.DataArray) - is the conductance of the drain. (cond) - concentration: array of floats (xr.DataArray, optional) - if this flow package is used in simulations also involving transport, then this array is used - as the concentration for inflow over this boundary. - concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) - if this flow package is used in simulations also involving transport, then this keyword specifies - how outflow over this boundary is computed. - print_input: ({True, False}, optional) - keyword to indicate that the list of drain information will be written - to the listing file immediately after it is read. Default is False. - print_flows: ({True, False}, optional) - Indicates that the list of drain flow rates will be printed to the - listing file for every stress period time step in which "BUDGET PRINT" - is specified in Output Control. If there is no Output Control option and - PRINT FLOWS is specified, then flow rates are printed for the last time - step of each stress period. - Default is False. - save_flows: ({True, False}, optional) - Indicates that drain flow terms will be written to the file specified - with "BUDGET FILEOUT" in Output Control. Default is False. - observations: [Not yet supported.] - Default is None. - validate: {True, False} - Flag to indicate whether the package should be validated upon - initialization. This raises a ValidationError if package input is - provided in the wrong manner. Defaults to True. - repeat_stress: dict or xr.DataArray of datetimes, optional - Used to repeat data for e.g. repeating stress periods such as - seasonality without duplicating the values. If provided as dict, it - should map new dates to old dates present in the dataset. - ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided - as DataArray, it should have dimensions ``("repeat", "repeat_items")``. - The ``repeat_items`` dimension should have size 2: the first value is - the "key", the second value is the "value". For the "key" datetime, the - data of the "value" datetime will be used. - """ - - _pkg_id = "drn" - - # has to be ordered as in the list - _init_schemata = { - "elevation": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "conductance": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "concentration": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema( - ( - "species", - "layer", - ) - ), - CONC_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "print_flows": [DTypeSchema(np.bool_), DimsSchema()], - "save_flows": [DTypeSchema(np.bool_), DimsSchema()], - } - _write_schemata = { - "elevation": [ - OtherCoordsSchema("idomain"), - AllNoDataSchema(), # Check for all nan, can occur while clipping - AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), - ], - "conductance": [IdentityNoDataSchema("elevation"), AllValueSchema(">", 0.0)], - "concentration": [IdentityNoDataSchema("elevation"), AllValueSchema(">=", 0.0)], - } - - _period_data = ("elevation", "conductance") - _keyword_map = {} - _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) - _auxiliary_data = {"concentration": "species"} - _regrid_method = DrainageRegridMethod() - _aggregate_method: DataclassType = DrainageAggregationMethod() - - @init_log_decorator() - def __init__( - self, - elevation, - conductance, - concentration=None, - concentration_boundary_type="aux", - print_input=False, - print_flows=False, - save_flows=False, - observations=None, - validate: bool = True, - repeat_stress=None, - ): - dict_dataset = { - "elevation": elevation, - "conductance": conductance, - "concentration": concentration, - "concentration_boundary_type": concentration_boundary_type, - "print_input": print_input, - "print_flows": print_flows, - "save_flows": save_flows, - "observations": observations, - "repeat_stress": repeat_stress, - } - super().__init__(dict_dataset) - self._validate_init_schemata(validate) - - def _validate(self, schemata, **kwargs): - # Insert additional kwargs - kwargs["elevation"] = self["elevation"] - errors = super()._validate(schemata, **kwargs) - - return errors - - @standard_log_decorator() - def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: - """ - Clean up package inplace. This method calls - :func:`imod.prepare.cleanup_drn`, see documentation of that - function for details on cleanup. - - dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization - Model discretization package. - """ - dis_dict = {"idomain": dis.dataset["idomain"]} - cleaned_dict = self._call_func_on_grids(cleanup_drn, dis_dict) - super().__init__(cleaned_dict) - - @classmethod - def _allocate_and_distribute_planar_data( - cls, - planar_data: dict[str, GridDataArray], - dis: StructuredDiscretization | VerticesDiscretization, - npf: NodePropertyFlow, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - drop_empty_layers: bool = True, - ) -> dict[str, GridDataArray]: - """ - Allocate and distribute planar data for given discretization and npf - package. If layer number of ``planar_data`` is negative, - ``allocation_option`` is overrided and set to - ALLOCATION_OPTION.at_first_active. - - Parameters - ---------- - planar_data: dict[str, GridDataArray] - Dictionary with planar grid data. - dis: imod.mf6.StructuredDiscretization - Model discretization package. - npf: imod.mf6.NodePropertyFlow - Node property flow package. - allocation_option: ALLOCATION_OPTION - allocation option. If planar data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: DISTRIBUTING_OPTION - distributing option. - drop_empty_layers: bool - If True, drop layers without any allocated cells from the - returned grids. Allocation and distribution are always computed - over the full layer range first, so this does not affect the - computed values. - - Returns - ------- - dict[str, GridDataArray] - Dictionary with layered grid data. - """ - - top = dis.dataset["top"] - bottom = dis.dataset["bottom"] - idomain = dis.dataset["idomain"] - - if has_negative_layer(planar_data["elevation"]): - allocation_option = ALLOCATION_OPTION.at_first_active - - # Enforce planar data, remove all layer dimension information - planar_data = { - key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") - for key, grid in planar_data.items() - } - - drn_allocation = allocate_drn_cells( - allocation_option, - idomain > 0, - top, - bottom, - planar_data["elevation"], - drop_empty_layers=False, # Keep full layer range, drop empty layers below - ) - layered_data = {} - layered_data["conductance"] = distribute_drn_conductance( - distributing_option, - drn_allocation, - planar_data["conductance"], - top, - bottom, - npf.dataset["k"], - planar_data["elevation"], - ) - layered_data["elevation"] = planar_data["elevation"].where(drn_allocation) - layered_data["elevation"] = enforce_dim_order(layered_data["elevation"]) - - if drop_empty_layers: - layered_data = drop_empty_layers_from_dict(layered_data, drn_allocation) - - return layered_data - - @classmethod - def from_imod5_data( - cls, - key: str, - imod5_data: dict[str, dict[str, GridDataArray]], - period_data: dict[str, list[datetime]], - target_dis: StructuredDiscretization, - target_npf: NodePropertyFlow, - time_min: datetime, - time_max: datetime, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - regridder_types: Optional[DrainageRegridMethod] = None, - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> "Drainage": - """ - Construct a drainage-package from iMOD5 data, loaded with the - :func:`imod.formats.prj.open_projectfile_data` function. - - .. note:: - - The method expects the iMOD5 model to be fully 3D, not quasi-3D. - - Parameters - ---------- - key: str - Packagename of the iMOD5 data to use. - imod5_data: dict - Dictionary with iMOD5 data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - period_data: dict - Dictionary with iMOD5 period data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - target_dis: StructuredDiscretization package - The grid that should be used for the new package. Does not - need to be identical to one of the input grids. - target_npf: NodePropertyFlow package - The conductivity information, used to compute drainage flux - allocation_option: ALLOCATION_OPTION - allocation option. If package data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: dict[str, DISTRIBUTING_OPTION] - distributing option. - time_min: datetime - Begin-time of the simulation. Used for expanding period data. - time_max: datetime - End-time of the simulation. Used for expanding period data. - regridder_types: DrainageRegridMethod, optional - Optional dataclass with regridder types for a specific variable. - Use this to override default regridding methods. - regrid_cache: RegridderWeightsCache, optional - stores regridder weights for different regridders. Can be used to speed up regridding, - if the same regridders are used several times for regridding different arrays. - - Returns - ------- - A Modflow 6 Drainage package. - """ - data = { - "elevation": imod5_data[key]["elevation"], - "conductance": imod5_data[key]["conductance"], - } - mask = data["conductance"] > 0 - data["conductance"] = data["conductance"].where(mask) - # Regrid the input data - regridded_package_data = regrid_imod5_pkg_data( - cls, data, target_dis, regridder_types, regrid_cache - ) - regridded_package_data = broadcast_and_mask_arrays(regridded_package_data) - is_planar = is_planar_grid(regridded_package_data["elevation"]) - if is_planar: - layered_data = cls._allocate_and_distribute_planar_data( - regridded_package_data, - target_dis, - target_npf, - allocation_option, - distributing_option, - ) - regridded_package_data.update(layered_data) - - drn = cls(**regridded_package_data, validate=True) - repeat = period_data.get(key) - set_repeat_stress_if_available(repeat, time_min, time_max, drn) - # Clip the drain package to the time range of the simulation and ensure - # time is forward filled. - drn = drn.clip_box(time_min=time_min, time_max=time_max) - - return drn +from datetime import datetime +from typing import Optional + +import numpy as np + +from imod.common.interfaces.iregridpackage import IRegridPackage +from imod.common.utilities.dataclass_type import DataclassType +from imod.common.utilities.mask import broadcast_and_mask_arrays +from imod.logging import init_log_decorator, standard_log_decorator +from imod.mf6.aggregate.aggregate_schemes import DrainageAggregationMethod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.regrid.regrid_schemes import DrainageRegridMethod +from imod.mf6.topsystem import TopSystemBoundaryCondition +from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data +from imod.mf6.utilities.package import set_repeat_stress_if_available +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA +from imod.prepare.cleanup import cleanup_drn +from imod.prepare.topsystem.allocation import ( + ALLOCATION_OPTION, + allocate_drn_cells, + drop_empty_layers_from_dict, +) +from imod.prepare.topsystem.conductance import ( + DISTRIBUTING_OPTION, + distribute_drn_conductance, +) +from imod.schemata import ( + AllCoordsValueSchema, + AllInsideNoDataSchema, + AllNoDataSchema, + AllValueSchema, + CoordsSchema, + DimsSchema, + DTypeSchema, + IdentityNoDataSchema, + IndexesSchema, + OtherCoordsSchema, +) +from imod.typing import GridDataArray +from imod.typing.grid import enforce_dim_order, has_negative_layer, is_planar_grid +from imod.util.regrid import RegridderWeightsCache + + +class Drainage(TopSystemBoundaryCondition, IRegridPackage): + """ + The Drain package is used to simulate head-dependent flux boundaries. + https://water.usgs.gov/ogw/modflow/mf6io.pdf#page=67 + + Parameters + ---------- + elevation: array of floats (xr.DataArray) + elevation of the drain. (elev) + conductance: array of floats (xr.DataArray) + is the conductance of the drain. (cond) + concentration: array of floats (xr.DataArray, optional) + if this flow package is used in simulations also involving transport, then this array is used + as the concentration for inflow over this boundary. + concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) + if this flow package is used in simulations also involving transport, then this keyword specifies + how outflow over this boundary is computed. + print_input: ({True, False}, optional) + keyword to indicate that the list of drain information will be written + to the listing file immediately after it is read. Default is False. + print_flows: ({True, False}, optional) + Indicates that the list of drain flow rates will be printed to the + listing file for every stress period time step in which "BUDGET PRINT" + is specified in Output Control. If there is no Output Control option and + PRINT FLOWS is specified, then flow rates are printed for the last time + step of each stress period. + Default is False. + save_flows: ({True, False}, optional) + Indicates that drain flow terms will be written to the file specified + with "BUDGET FILEOUT" in Output Control. Default is False. + observations: [Not yet supported.] + Default is None. + validate: {True, False} + Flag to indicate whether the package should be validated upon + initialization. This raises a ValidationError if package input is + provided in the wrong manner. Defaults to True. + repeat_stress: dict or xr.DataArray of datetimes, optional + Used to repeat data for e.g. repeating stress periods such as + seasonality without duplicating the values. If provided as dict, it + should map new dates to old dates present in the dataset. + ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided + as DataArray, it should have dimensions ``("repeat", "repeat_items")``. + The ``repeat_items`` dimension should have size 2: the first value is + the "key", the second value is the "value". For the "key" datetime, the + data of the "value" datetime will be used. + """ + + _pkg_id = "drn" + + # has to be ordered as in the list + _init_schemata = { + "elevation": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "conductance": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "print_flows": [DTypeSchema(np.bool_), DimsSchema()], + "save_flows": [DTypeSchema(np.bool_), DimsSchema()], + } + _write_schemata = { + "elevation": [ + OtherCoordsSchema("idomain"), + AllNoDataSchema(), # Check for all nan, can occur while clipping + AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), + ], + "conductance": [IdentityNoDataSchema("elevation"), AllValueSchema(">", 0.0)], + "concentration": [IdentityNoDataSchema("elevation"), AllValueSchema(">=", 0.0)], + } + + _period_data = ("elevation", "conductance") + _keyword_map = {} + _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) + _auxiliary_data = {"concentration": "species"} + _regrid_method = DrainageRegridMethod() + _aggregate_method: DataclassType = DrainageAggregationMethod() + + @init_log_decorator() + def __init__( + self, + elevation, + conductance, + concentration=None, + concentration_boundary_type="aux", + print_input=False, + print_flows=False, + save_flows=False, + observations=None, + validate: bool = True, + repeat_stress=None, + ): + dict_dataset = { + "elevation": elevation, + "conductance": conductance, + "concentration": concentration, + "concentration_boundary_type": concentration_boundary_type, + "print_input": print_input, + "print_flows": print_flows, + "save_flows": save_flows, + "observations": observations, + "repeat_stress": repeat_stress, + } + super().__init__(dict_dataset) + self._validate_init_schemata(validate) + + def _validate(self, schemata, **kwargs): + # Insert additional kwargs + kwargs["elevation"] = self["elevation"] + errors = super()._validate(schemata, **kwargs) + + return errors + + @standard_log_decorator() + def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: + """ + Clean up package inplace. This method calls + :func:`imod.prepare.cleanup_drn`, see documentation of that + function for details on cleanup. + + dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization + Model discretization package. + """ + dis_dict = {"idomain": dis.dataset["idomain"]} + cleaned_dict = self._call_func_on_grids(cleanup_drn, dis_dict) + super().__init__(cleaned_dict) + + @classmethod + def _allocate_and_distribute_planar_data( + cls, + planar_data: dict[str, GridDataArray], + dis: StructuredDiscretization | VerticesDiscretization, + npf: NodePropertyFlow, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + drop_empty_layers: bool = True, + ) -> dict[str, GridDataArray]: + """ + Allocate and distribute planar data for given discretization and npf + package. If layer number of ``planar_data`` is negative, + ``allocation_option`` is overrided and set to + ALLOCATION_OPTION.at_first_active. + + Parameters + ---------- + planar_data: dict[str, GridDataArray] + Dictionary with planar grid data. + dis: imod.mf6.StructuredDiscretization + Model discretization package. + npf: imod.mf6.NodePropertyFlow + Node property flow package. + allocation_option: ALLOCATION_OPTION + allocation option. If planar data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: DISTRIBUTING_OPTION + distributing option. + drop_empty_layers: bool + If True, drop layers without any allocated cells from the + returned grids. Allocation and distribution are always computed + over the full layer range first, so this does not affect the + computed values. + + Returns + ------- + dict[str, GridDataArray] + Dictionary with layered grid data. + """ + + top = dis.dataset["top"] + bottom = dis.dataset["bottom"] + idomain = dis.dataset["idomain"] + + if has_negative_layer(planar_data["elevation"]): + allocation_option = ALLOCATION_OPTION.at_first_active + + # Enforce planar data, remove all layer dimension information + planar_data = { + key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") + for key, grid in planar_data.items() + } + + drn_allocation = allocate_drn_cells( + allocation_option, + idomain > 0, + top, + bottom, + planar_data["elevation"], + drop_empty_layers=False, # Keep full layer range, drop empty layers below + ) + layered_data = {} + layered_data["conductance"] = distribute_drn_conductance( + distributing_option, + drn_allocation, + planar_data["conductance"], + top, + bottom, + npf.dataset["k"], + planar_data["elevation"], + ) + layered_data["elevation"] = planar_data["elevation"].where(drn_allocation) + layered_data["elevation"] = enforce_dim_order(layered_data["elevation"]) + + if drop_empty_layers: + layered_data = drop_empty_layers_from_dict(layered_data, drn_allocation) + + return layered_data + + @classmethod + def from_imod5_data( + cls, + key: str, + imod5_data: dict[str, dict[str, GridDataArray]], + period_data: dict[str, list[datetime]], + target_dis: StructuredDiscretization, + target_npf: NodePropertyFlow, + time_min: datetime, + time_max: datetime, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + regridder_types: Optional[DrainageRegridMethod] = None, + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> "Drainage": + """ + Construct a drainage-package from iMOD5 data, loaded with the + :func:`imod.formats.prj.open_projectfile_data` function. + + .. note:: + + The method expects the iMOD5 model to be fully 3D, not quasi-3D. + + Parameters + ---------- + key: str + Packagename of the iMOD5 data to use. + imod5_data: dict + Dictionary with iMOD5 data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + period_data: dict + Dictionary with iMOD5 period data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + target_dis: StructuredDiscretization package + The grid that should be used for the new package. Does not + need to be identical to one of the input grids. + target_npf: NodePropertyFlow package + The conductivity information, used to compute drainage flux + allocation_option: ALLOCATION_OPTION + allocation option. If package data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: dict[str, DISTRIBUTING_OPTION] + distributing option. + time_min: datetime + Begin-time of the simulation. Used for expanding period data. + time_max: datetime + End-time of the simulation. Used for expanding period data. + regridder_types: DrainageRegridMethod, optional + Optional dataclass with regridder types for a specific variable. + Use this to override default regridding methods. + regrid_cache: RegridderWeightsCache, optional + stores regridder weights for different regridders. Can be used to speed up regridding, + if the same regridders are used several times for regridding different arrays. + + Returns + ------- + A Modflow 6 Drainage package. + """ + data = { + "elevation": imod5_data[key]["elevation"], + "conductance": imod5_data[key]["conductance"], + } + mask = data["conductance"] > 0 + data["conductance"] = data["conductance"].where(mask) + # Regrid the input data + regridded_package_data = regrid_imod5_pkg_data( + cls, data, target_dis, regridder_types, regrid_cache + ) + regridded_package_data = broadcast_and_mask_arrays(regridded_package_data) + is_planar = is_planar_grid(regridded_package_data["elevation"]) + if is_planar: + layered_data = cls._allocate_and_distribute_planar_data( + regridded_package_data, + target_dis, + target_npf, + allocation_option, + distributing_option, + ) + regridded_package_data.update(layered_data) + + drn = cls(**regridded_package_data, validate=True) + repeat = period_data.get(key) + set_repeat_stress_if_available(repeat, time_min, time_max, drn) + # Clip the drain package to the time range of the simulation and ensure + # time is forward filled. + drn = drn.clip_box(time_min=time_min, time_max=time_max) + + return drn diff --git a/imod/mf6/ghb.py b/imod/mf6/ghb.py index 93f0ae7ef..5db99d65a 100644 --- a/imod/mf6/ghb.py +++ b/imod/mf6/ghb.py @@ -1,361 +1,361 @@ -from datetime import datetime -from typing import Optional - -import numpy as np - -from imod.common.interfaces.iregridpackage import IRegridPackage -from imod.common.utilities.dataclass_type import DataclassType -from imod.common.utilities.mask import broadcast_and_mask_arrays -from imod.logging import init_log_decorator, standard_log_decorator -from imod.mf6.aggregate.aggregate_schemes import GeneralHeadBoundaryAggregationMethod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.regrid.regrid_schemes import ( - GeneralHeadBoundaryRegridMethod, -) -from imod.mf6.topsystem import TopSystemBoundaryCondition -from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data -from imod.mf6.utilities.package import set_repeat_stress_if_available -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA -from imod.prepare.cleanup import cleanup_ghb -from imod.prepare.topsystem.allocation import ( - ALLOCATION_OPTION, - allocate_ghb_cells, - drop_empty_layers_from_dict, -) -from imod.prepare.topsystem.conductance import ( - DISTRIBUTING_OPTION, - distribute_ghb_conductance, -) -from imod.schemata import ( - AllCoordsValueSchema, - AllInsideNoDataSchema, - AllNoDataSchema, - AllValueSchema, - CoordsSchema, - DimsSchema, - DTypeSchema, - IdentityNoDataSchema, - IndexesSchema, - OtherCoordsSchema, -) -from imod.typing import GridDataArray -from imod.typing.grid import enforce_dim_order, has_negative_layer, is_planar_grid -from imod.util.regrid import RegridderWeightsCache - - -class GeneralHeadBoundary(TopSystemBoundaryCondition, IRegridPackage): - """ - The General-Head Boundary package is used to simulate head-dependent flux - boundaries. - https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=75 - - Parameters - ---------- - head: array of floats (xr.DataArray) - is the boundary head. (bhead) - conductance: array of floats (xr.DataArray) - is the hydraulic conductance of the interface between the aquifer cell and - the boundary.(cond) - concentration: array of floats (xr.DataArray, optional) - if this flow package is used in simulations also involving transport, then this array is used - as the concentration for inflow over this boundary. - concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) - if this flow package is used in simulations also involving transport, then this keyword specifies - how outflow over this boundary is computed. - print_input: ({True, False}, optional) - keyword to indicate that the list of general head boundary information - will be written to the listing file immediately after it is read. - Default is False. - print_flows: ({True, False}, optional) - Indicates that the list of general head boundary flow rates will be - printed to the listing file for every stress period time step in which - "BUDGET PRINT" is specified in Output Control. If there is no Output - Control option and PRINT FLOWS is specified, then flow rates are printed - for the last time step of each stress period. - Default is False. - save_flows: ({True, False}, optional) - Indicates that general head boundary flow terms will be written to the - file specified with "BUDGET FILEOUT" in Output Control. - Default is False. - observations: [Not yet supported.] - Default is None. - validate: {True, False} - Flag to indicate whether the package should be validated upon - initialization. This raises a ValidationError if package input is - provided in the wrong manner. Defaults to True. - repeat_stress: dict or xr.DataArray of datetimes, optional - Used to repeat data for e.g. repeating stress periods such as - seasonality without duplicating the values. If provided as dict, it - should map new dates to old dates present in the dataset. - ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided - as DataArray, it should have dimensions ``("repeat", "repeat_items")``. - The ``repeat_items`` dimension should have size 2: the first value is - the "key", the second value is the "value". For the "key" datetime, the - data of the "value" datetime will be used. - """ - - _pkg_id = "ghb" - _period_data = ("head", "conductance") - - _init_schemata = { - "head": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "conductance": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "concentration": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema( - ( - "species", - "layer", - ) - ), - CONC_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "print_flows": [DTypeSchema(np.bool_), DimsSchema()], - "save_flows": [DTypeSchema(np.bool_), DimsSchema()], - } - _write_schemata = { - "head": [ - OtherCoordsSchema("idomain"), - AllNoDataSchema(), # Check for all nan, can occur while clipping - AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), - ], - "conductance": [IdentityNoDataSchema("head"), AllValueSchema(">", 0.0)], - "concentration": [IdentityNoDataSchema("head"), AllValueSchema(">=", 0.0)], - } - - _keyword_map = {} - _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) - _auxiliary_data = {"concentration": "species"} - _regrid_method = GeneralHeadBoundaryRegridMethod() - _aggregate_method: DataclassType = GeneralHeadBoundaryAggregationMethod() - - @init_log_decorator() - def __init__( - self, - head, - conductance, - concentration=None, - concentration_boundary_type="aux", - print_input=False, - print_flows=False, - save_flows=False, - observations=None, - validate: bool = True, - repeat_stress=None, - ): - dict_dataset = { - "head": head, - "conductance": conductance, - "concentration": concentration, - "concentration_boundary_type": concentration_boundary_type, - "print_input": print_input, - "print_flows": print_flows, - "save_flows": save_flows, - "observations": observations, - "repeat_stress": repeat_stress, - } - super().__init__(dict_dataset) - self._validate_init_schemata(validate) - - def _validate(self, schemata, **kwargs): - # Insert additional kwargs - kwargs["head"] = self["head"] - errors = super()._validate(schemata, **kwargs) - - return errors - - @standard_log_decorator() - def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: - """ - Clean up package inplace. This method calls - :func:`imod.prepare.cleanup_ghb`, see documentation of that - function for details on cleanup. - - dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization - Model discretization package. - """ - dis_dict = {"idomain": dis.dataset["idomain"]} - cleaned_dict = self._call_func_on_grids(cleanup_ghb, dis_dict) - super().__init__(cleaned_dict) - - @classmethod - def _allocate_and_distribute_planar_data( - cls, - planar_data: dict[str, GridDataArray], - dis: StructuredDiscretization | VerticesDiscretization, - npf: NodePropertyFlow, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - drop_empty_layers: bool = True, - ) -> dict[str, GridDataArray]: - """ - Allocate and distribute planar data for given discretization and npf - package. If layer number of ``planar_data`` is negative, - ``allocation_option`` is overrided and set to - ALLOCATION_OPTION.at_first_active. - - Parameters - ---------- - planar_data: dict[str, GridDataArray] - Dictionary with planar grid data. - dis: imod.mf6.StructuredDiscretization - Model discretization package. - npf: imod.mf6.NodePropertyFlow - Node property flow package. - allocation_option: ALLOCATION_OPTION - allocation option. If planar data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: DISTRIBUTING_OPTION - distributing option. - drop_empty_layers: bool - If True, drop layers without any allocated cells from the - returned grids. Allocation and distribution are always computed - over the full layer range first, so this does not affect the - computed values. - - Returns - ------- - dict[str, GridDataArray] - Dictionary with layered grid data. - """ - - top = dis.dataset["top"] - bottom = dis.dataset["bottom"] - idomain = dis.dataset["idomain"] - - if has_negative_layer(planar_data["head"]): - allocation_option = ALLOCATION_OPTION.at_first_active - - # Enforce planar data, remove all layer dimension information - planar_data = { - key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") - for key, grid in planar_data.items() - } - - ghb_allocation = allocate_ghb_cells( - allocation_option, - idomain > 0, - top, - bottom, - planar_data["head"], - drop_empty_layers=False, # Keep full layer range, drop empty layers below - ) - - layered_data = {} - layered_data["head"] = planar_data["head"].where(ghb_allocation) - layered_data["head"] = enforce_dim_order(layered_data["head"]) - - layered_data["conductance"] = distribute_ghb_conductance( - distributing_option, - ghb_allocation, - planar_data["conductance"], - top, - bottom, - npf.dataset["k"], - ) - if drop_empty_layers: - layered_data = drop_empty_layers_from_dict(layered_data, ghb_allocation) - return layered_data - - @classmethod - def from_imod5_data( - cls, - key: str, - imod5_data: dict[str, dict[str, GridDataArray]], - period_data: dict[str, list[datetime]], - target_dis: StructuredDiscretization, - target_npf: NodePropertyFlow, - time_min: datetime, - time_max: datetime, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - regridder_types: Optional[DataclassType] = None, - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> "GeneralHeadBoundary": - """ - Construct a GeneralHeadBoundary-package from iMOD5 data, loaded with the - :func:`imod.formats.prj.open_projectfile_data` function. - - .. note:: - - The method expects the iMOD5 model to be fully 3D, not quasi-3D. - - Parameters - ---------- - imod5_data: dict - Dictionary with iMOD5 data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - period_data: dict - Dictionary with iMOD5 period data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - target_dis: StructuredDiscretization package - The grid that should be used for the new package. Does not - need to be identical to one of the input grids. - target_npf: NodePropertyFlow package - The conductivity information, used to compute GHB flux - allocation_option: ALLOCATION_OPTION - allocation option. If package data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - time_min: datetime - Begin-time of the simulation. Used for expanding period data. - time_max: datetime - End-time of the simulation. Used for expanding period data. - distributing_option: dict[str, DISTRIBUTING_OPTION] - distributing option. - regrid_cache: RegridderWeightsCache, optional - stores regridder weights for different regridders. Can be used to speed up regridding, - if the same regridders are used several times for regridding different arrays. - regridder_types: RegridMethodType, optional - Optional dataclass with regridder types for a specific variable. - Use this to override default regridding methods. - - Returns - ------- - A Modflow 6 GeneralHeadBoundary packages. - """ - data = { - "head": imod5_data[key]["head"], - "conductance": imod5_data[key]["conductance"], - } - mask = data["conductance"] > 0 - data["conductance"] = data["conductance"].where(mask) - regridded_package_data = regrid_imod5_pkg_data( - cls, data, target_dis, regridder_types, regrid_cache - ) - regridded_package_data = broadcast_and_mask_arrays(regridded_package_data) - is_planar = is_planar_grid(regridded_package_data["conductance"]) - if is_planar: - layered_data = cls._allocate_and_distribute_planar_data( - regridded_package_data, - target_dis, - target_npf, - allocation_option, - distributing_option, - ) - regridded_package_data.update(layered_data) - - ghb = cls(**regridded_package_data, validate=True) - repeat = period_data.get(key) - set_repeat_stress_if_available(repeat, time_min, time_max, ghb) - # Clip the ghb package to the time range of the simulation and ensure - # time is forward filled. - ghb = ghb.clip_box(time_min=time_min, time_max=time_max) - return ghb +from datetime import datetime +from typing import Optional + +import numpy as np + +from imod.common.interfaces.iregridpackage import IRegridPackage +from imod.common.utilities.dataclass_type import DataclassType +from imod.common.utilities.mask import broadcast_and_mask_arrays +from imod.logging import init_log_decorator, standard_log_decorator +from imod.mf6.aggregate.aggregate_schemes import GeneralHeadBoundaryAggregationMethod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.regrid.regrid_schemes import ( + GeneralHeadBoundaryRegridMethod, +) +from imod.mf6.topsystem import TopSystemBoundaryCondition +from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data +from imod.mf6.utilities.package import set_repeat_stress_if_available +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA +from imod.prepare.cleanup import cleanup_ghb +from imod.prepare.topsystem.allocation import ( + ALLOCATION_OPTION, + allocate_ghb_cells, + drop_empty_layers_from_dict, +) +from imod.prepare.topsystem.conductance import ( + DISTRIBUTING_OPTION, + distribute_ghb_conductance, +) +from imod.schemata import ( + AllCoordsValueSchema, + AllInsideNoDataSchema, + AllNoDataSchema, + AllValueSchema, + CoordsSchema, + DimsSchema, + DTypeSchema, + IdentityNoDataSchema, + IndexesSchema, + OtherCoordsSchema, +) +from imod.typing import GridDataArray +from imod.typing.grid import enforce_dim_order, has_negative_layer, is_planar_grid +from imod.util.regrid import RegridderWeightsCache + + +class GeneralHeadBoundary(TopSystemBoundaryCondition, IRegridPackage): + """ + The General-Head Boundary package is used to simulate head-dependent flux + boundaries. + https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=75 + + Parameters + ---------- + head: array of floats (xr.DataArray) + is the boundary head. (bhead) + conductance: array of floats (xr.DataArray) + is the hydraulic conductance of the interface between the aquifer cell and + the boundary.(cond) + concentration: array of floats (xr.DataArray, optional) + if this flow package is used in simulations also involving transport, then this array is used + as the concentration for inflow over this boundary. + concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) + if this flow package is used in simulations also involving transport, then this keyword specifies + how outflow over this boundary is computed. + print_input: ({True, False}, optional) + keyword to indicate that the list of general head boundary information + will be written to the listing file immediately after it is read. + Default is False. + print_flows: ({True, False}, optional) + Indicates that the list of general head boundary flow rates will be + printed to the listing file for every stress period time step in which + "BUDGET PRINT" is specified in Output Control. If there is no Output + Control option and PRINT FLOWS is specified, then flow rates are printed + for the last time step of each stress period. + Default is False. + save_flows: ({True, False}, optional) + Indicates that general head boundary flow terms will be written to the + file specified with "BUDGET FILEOUT" in Output Control. + Default is False. + observations: [Not yet supported.] + Default is None. + validate: {True, False} + Flag to indicate whether the package should be validated upon + initialization. This raises a ValidationError if package input is + provided in the wrong manner. Defaults to True. + repeat_stress: dict or xr.DataArray of datetimes, optional + Used to repeat data for e.g. repeating stress periods such as + seasonality without duplicating the values. If provided as dict, it + should map new dates to old dates present in the dataset. + ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided + as DataArray, it should have dimensions ``("repeat", "repeat_items")``. + The ``repeat_items`` dimension should have size 2: the first value is + the "key", the second value is the "value". For the "key" datetime, the + data of the "value" datetime will be used. + """ + + _pkg_id = "ghb" + _period_data = ("head", "conductance") + + _init_schemata = { + "head": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "conductance": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "print_flows": [DTypeSchema(np.bool_), DimsSchema()], + "save_flows": [DTypeSchema(np.bool_), DimsSchema()], + } + _write_schemata = { + "head": [ + OtherCoordsSchema("idomain"), + AllNoDataSchema(), # Check for all nan, can occur while clipping + AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), + ], + "conductance": [IdentityNoDataSchema("head"), AllValueSchema(">", 0.0)], + "concentration": [IdentityNoDataSchema("head"), AllValueSchema(">=", 0.0)], + } + + _keyword_map = {} + _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) + _auxiliary_data = {"concentration": "species"} + _regrid_method = GeneralHeadBoundaryRegridMethod() + _aggregate_method: DataclassType = GeneralHeadBoundaryAggregationMethod() + + @init_log_decorator() + def __init__( + self, + head, + conductance, + concentration=None, + concentration_boundary_type="aux", + print_input=False, + print_flows=False, + save_flows=False, + observations=None, + validate: bool = True, + repeat_stress=None, + ): + dict_dataset = { + "head": head, + "conductance": conductance, + "concentration": concentration, + "concentration_boundary_type": concentration_boundary_type, + "print_input": print_input, + "print_flows": print_flows, + "save_flows": save_flows, + "observations": observations, + "repeat_stress": repeat_stress, + } + super().__init__(dict_dataset) + self._validate_init_schemata(validate) + + def _validate(self, schemata, **kwargs): + # Insert additional kwargs + kwargs["head"] = self["head"] + errors = super()._validate(schemata, **kwargs) + + return errors + + @standard_log_decorator() + def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: + """ + Clean up package inplace. This method calls + :func:`imod.prepare.cleanup_ghb`, see documentation of that + function for details on cleanup. + + dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization + Model discretization package. + """ + dis_dict = {"idomain": dis.dataset["idomain"]} + cleaned_dict = self._call_func_on_grids(cleanup_ghb, dis_dict) + super().__init__(cleaned_dict) + + @classmethod + def _allocate_and_distribute_planar_data( + cls, + planar_data: dict[str, GridDataArray], + dis: StructuredDiscretization | VerticesDiscretization, + npf: NodePropertyFlow, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + drop_empty_layers: bool = True, + ) -> dict[str, GridDataArray]: + """ + Allocate and distribute planar data for given discretization and npf + package. If layer number of ``planar_data`` is negative, + ``allocation_option`` is overrided and set to + ALLOCATION_OPTION.at_first_active. + + Parameters + ---------- + planar_data: dict[str, GridDataArray] + Dictionary with planar grid data. + dis: imod.mf6.StructuredDiscretization + Model discretization package. + npf: imod.mf6.NodePropertyFlow + Node property flow package. + allocation_option: ALLOCATION_OPTION + allocation option. If planar data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: DISTRIBUTING_OPTION + distributing option. + drop_empty_layers: bool + If True, drop layers without any allocated cells from the + returned grids. Allocation and distribution are always computed + over the full layer range first, so this does not affect the + computed values. + + Returns + ------- + dict[str, GridDataArray] + Dictionary with layered grid data. + """ + + top = dis.dataset["top"] + bottom = dis.dataset["bottom"] + idomain = dis.dataset["idomain"] + + if has_negative_layer(planar_data["head"]): + allocation_option = ALLOCATION_OPTION.at_first_active + + # Enforce planar data, remove all layer dimension information + planar_data = { + key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") + for key, grid in planar_data.items() + } + + ghb_allocation = allocate_ghb_cells( + allocation_option, + idomain > 0, + top, + bottom, + planar_data["head"], + drop_empty_layers=False, # Keep full layer range, drop empty layers below + ) + + layered_data = {} + layered_data["head"] = planar_data["head"].where(ghb_allocation) + layered_data["head"] = enforce_dim_order(layered_data["head"]) + + layered_data["conductance"] = distribute_ghb_conductance( + distributing_option, + ghb_allocation, + planar_data["conductance"], + top, + bottom, + npf.dataset["k"], + ) + if drop_empty_layers: + layered_data = drop_empty_layers_from_dict(layered_data, ghb_allocation) + return layered_data + + @classmethod + def from_imod5_data( + cls, + key: str, + imod5_data: dict[str, dict[str, GridDataArray]], + period_data: dict[str, list[datetime]], + target_dis: StructuredDiscretization, + target_npf: NodePropertyFlow, + time_min: datetime, + time_max: datetime, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + regridder_types: Optional[DataclassType] = None, + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> "GeneralHeadBoundary": + """ + Construct a GeneralHeadBoundary-package from iMOD5 data, loaded with the + :func:`imod.formats.prj.open_projectfile_data` function. + + .. note:: + + The method expects the iMOD5 model to be fully 3D, not quasi-3D. + + Parameters + ---------- + imod5_data: dict + Dictionary with iMOD5 data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + period_data: dict + Dictionary with iMOD5 period data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + target_dis: StructuredDiscretization package + The grid that should be used for the new package. Does not + need to be identical to one of the input grids. + target_npf: NodePropertyFlow package + The conductivity information, used to compute GHB flux + allocation_option: ALLOCATION_OPTION + allocation option. If package data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + time_min: datetime + Begin-time of the simulation. Used for expanding period data. + time_max: datetime + End-time of the simulation. Used for expanding period data. + distributing_option: dict[str, DISTRIBUTING_OPTION] + distributing option. + regrid_cache: RegridderWeightsCache, optional + stores regridder weights for different regridders. Can be used to speed up regridding, + if the same regridders are used several times for regridding different arrays. + regridder_types: RegridMethodType, optional + Optional dataclass with regridder types for a specific variable. + Use this to override default regridding methods. + + Returns + ------- + A Modflow 6 GeneralHeadBoundary packages. + """ + data = { + "head": imod5_data[key]["head"], + "conductance": imod5_data[key]["conductance"], + } + mask = data["conductance"] > 0 + data["conductance"] = data["conductance"].where(mask) + regridded_package_data = regrid_imod5_pkg_data( + cls, data, target_dis, regridder_types, regrid_cache + ) + regridded_package_data = broadcast_and_mask_arrays(regridded_package_data) + is_planar = is_planar_grid(regridded_package_data["conductance"]) + if is_planar: + layered_data = cls._allocate_and_distribute_planar_data( + regridded_package_data, + target_dis, + target_npf, + allocation_option, + distributing_option, + ) + regridded_package_data.update(layered_data) + + ghb = cls(**regridded_package_data, validate=True) + repeat = period_data.get(key) + set_repeat_stress_if_available(repeat, time_min, time_max, ghb) + # Clip the ghb package to the time range of the simulation and ensure + # time is forward filled. + ghb = ghb.clip_box(time_min=time_min, time_max=time_max) + return ghb diff --git a/imod/mf6/rch.py b/imod/mf6/rch.py index d51c1691c..3bc10003c 100644 --- a/imod/mf6/rch.py +++ b/imod/mf6/rch.py @@ -1,325 +1,325 @@ -from datetime import datetime -from typing import Optional - -import numpy as np -import xarray as xr - -from imod.common.interfaces.iregridpackage import IRegridPackage -from imod.common.utilities.dataclass_type import DataclassType -from imod.common.utilities.regrid import regrid_imod5_cap_data -from imod.logging import init_log_decorator -from imod.mf6.aggregate.aggregate_schemes import RechargeAggregationMethod -from imod.mf6.dis import StructuredDiscretization, VerticesDiscretization -from imod.mf6.regrid.regrid_schemes import ( - CapDataRechargeRegridMethod, - RechargeRegridMethod, -) -from imod.mf6.topsystem import TopSystemBoundaryCondition -from imod.mf6.utilities.imod5_converter import ( - convert_unit_rch_rate, - regrid_imod5_pkg_data, -) -from imod.mf6.utilities.package import set_repeat_stress_if_available -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA -from imod.msw.utilities.imod5_converter import ( - get_cell_area_from_imod5_data, - is_msw_active_cell, -) -from imod.prepare.topsystem.allocation import ( - ALLOCATION_OPTION, - allocate_rch_cells, - drop_empty_layers_from_dict, -) -from imod.schemata import ( - AllCoordsValueSchema, - AllInsideNoDataSchema, - AllNoDataSchema, - AllValueSchema, - CoordsSchema, - DimsSchema, - DTypeSchema, - IdentityNoDataSchema, - IndexesSchema, - OtherCoordsSchema, -) -from imod.typing import GridDataArray, Imod5DataDict -from imod.typing.grid import ( - enforce_dim_order, - is_planar_grid, -) -from imod.util.regrid import RegridderWeightsCache - - -class Recharge(TopSystemBoundaryCondition, IRegridPackage): - """ - Recharge Package. - Any number of RCH Packages can be specified for a single groundwater flow - model. - https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=79 - - Parameters - ---------- - rate: array of floats (xr.DataArray) - is the recharge flux rate (LT −1). This rate is multiplied inside the - program by the surface area of the cell to calculate the volumetric - recharge rate. A time-series name may be specified. - concentration: array of floats (xr.DataArray, optional) - if this flow package is used in simulations also involving transport, then this array is used - as the concentration for inflow over this boundary. - concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) - if this flow package is used in simulations also involving transport, then this keyword specifies - how outflow over this boundary is computed. - print_input: ({True, False}, optional) - keyword to indicate that the list of recharge information will be - written to the listing file immediately after it is read. - Default is False. - print_flows: ({True, False}, optional) - Indicates that the list of recharge flow rates will be printed to the - listing file for every stress period time step in which "BUDGET PRINT"is - specified in Output Control. If there is no Output Control option and - PRINT FLOWS is specified, then flow rates are printed for the last time - step of each stress period. - Default is False. - save_flows: ({True, False}, optional) - Indicates that recharge flow terms will be written to the file specified - with "BUDGET FILEOUT" in Output Control. - Default is False. - observations: [Not yet supported.] - Default is None. - validate: {True, False} - Flag to indicate whether the package should be validated upon - initialization. This raises a ValidationError if package input is - provided in the wrong manner. Defaults to True. - repeat_stress: dict or xr.DataArray of datetimes, optional - Used to repeat data for e.g. repeating stress periods such as - seasonality without duplicating the values. If provided as dict, it - should map new dates to old dates present in the dataset. - ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided - as DataArray, it should have dimensions ``("repeat", "repeat_items")``. - The ``repeat_items`` dimension should have size 2: the first value is - the "key", the second value is the "value". For the "key" datetime, the - data of the "value" datetime will be used. - fixed_cell: ({True, False}, optional) - indicates that recharge will not be reassigned to a cell underlying the - cell specified in the list if the specified cell is inactive. - """ - - _pkg_id = "rch" - _period_data = ("rate",) - _keyword_map = {} - - _init_schemata = { - "rate": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "concentration": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema( - ( - "species", - "layer", - ) - ), - CONC_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "print_flows": [DTypeSchema(np.bool_), DimsSchema()], - "save_flows": [DTypeSchema(np.bool_), DimsSchema()], - } - _write_schemata = { - "rate": [ - OtherCoordsSchema("idomain"), - AllNoDataSchema(), # Check for all nan, can occur while clipping - AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), - ], - "concentration": [IdentityNoDataSchema("rate"), AllValueSchema(">=", 0.0)], - } - - _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) - _auxiliary_data = {"concentration": "species"} - _regrid_method = RechargeRegridMethod() - _aggregate_method: DataclassType = RechargeAggregationMethod() - - @init_log_decorator() - def __init__( - self, - rate, - concentration=None, - concentration_boundary_type="auxmixed", - print_input=False, - print_flows=False, - save_flows=False, - observations=None, - validate: bool = True, - repeat_stress=None, - fixed_cell: bool = False, - ): - dict_dataset = { - "rate": rate, - "concentration": concentration, - "concentration_boundary_type": concentration_boundary_type, - "print_input": print_input, - "print_flows": print_flows, - "save_flows": save_flows, - "observations": observations, - "repeat_stress": repeat_stress, - "fixed_cell": fixed_cell, - } - super().__init__(dict_dataset) - self._validate_init_schemata(validate) - - def _validate(self, schemata, **kwargs): - # Insert additional kwargs - kwargs["rate"] = self["rate"] - errors = super()._validate(schemata, **kwargs) - - return errors - - @classmethod - def _allocate_planar_data( - cls, - planar_data: dict[str, GridDataArray], - dis: StructuredDiscretization | VerticesDiscretization, - allocation_option: ALLOCATION_OPTION, - drop_empty_layers: bool = True, - ) -> dict[str, GridDataArray]: - """ - Allocate and distribute planar data for given discretization and npf - package. To allocate cells, the allocation option - ALLOCATION_OPTION.at_first_active is set. - - Parameters - ---------- - planar_data: dict[str, GridDataArray] - Dictionary with planar grid data. - dis: imod.mf6.StructuredDiscretization - Model discretization package. - allocation_option: ALLOCATION_OPTION - The allocation option to use for the reallocation. - drop_empty_layers: bool - If True, drop layers without any allocated cells from the - returned grids. Allocation is always computed over the full - layer range first, so this does not affect the computed values. - - Returns - ------- - dict[str, GridDataArray] - Dictionary with layered grid data. - """ - idomain = dis.dataset["idomain"] - if "layer" in planar_data["rate"].dims: - planar_data["rate"] = planar_data["rate"].isel(layer=0, drop=True) - # create an array indicating in which cells rch is active - is_rch_cell = allocate_rch_cells( - allocation_option, - idomain > 0, - planar_data["rate"], - drop_empty_layers=False, # Keep full here, drop empty layers below - ) - # remove rch from cells where it is not allocated and broadcast over layers. - layered_data = {} - layered_data["rate"] = planar_data["rate"].where(is_rch_cell) - layered_data["rate"] = enforce_dim_order(layered_data["rate"]) - if drop_empty_layers: - layered_data = drop_empty_layers_from_dict(layered_data, is_rch_cell) - return layered_data - - @classmethod - def from_imod5_data( - cls, - imod5_data: dict[str, dict[str, GridDataArray]], - period_data: dict[str, list[datetime]], - target_dis: StructuredDiscretization, - time_min: datetime, - time_max: datetime, - regridder_types: Optional[RechargeRegridMethod] = None, - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> "Recharge": - """ - Construct an rch-package from iMOD5 data, loaded with the - :func:`imod.formats.prj.open_projectfile_data` function. - - .. note:: - - The method expects the iMOD5 model to be fully 3D, not quasi-3D. - - Parameters - ---------- - imod5_data: dict - Dictionary with iMOD5 data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - period_data: dict - Dictionary with iMOD5 period data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - target_dis: GridDataArray - The discretization package for the simulation. Its grid does not - need to be identical to one of the input grids. - time_min: datetime - Begin-time of the simulation. Used for expanding period data. - time_max: datetime - End-time of the simulation. Used for expanding period data. - regridder_types: RechargeRegridMethod, optional - Optional dataclass with regridder types for a specific variable. - Use this to override default regridding methods. - regrid_cache: RegridderWeightsCache, optional - stores regridder weights for different regridders. Can be used to speed up regridding, - if the same regridders are used several times for regridding different arrays. - - Returns - ------- - Modflow 6 rch package. - - """ - data = { - "rate": convert_unit_rch_rate(imod5_data["rch"]["rate"]), - } - regridded_package_data = regrid_imod5_pkg_data( - cls, data, target_dis, regridder_types, regrid_cache - ) - # if rate has only layer 0, then it is planar. - if is_planar_grid(regridded_package_data["rate"]): - allocation_option = ALLOCATION_OPTION.at_first_active - layered_data = cls._allocate_planar_data( - regridded_package_data, target_dis, allocation_option - ) - regridded_package_data.update(layered_data) - rch = cls(**regridded_package_data, validate=True, fixed_cell=False) - repeat = period_data.get("rch") - set_repeat_stress_if_available(repeat, time_min, time_max, rch) - # Clip the rch package to the time range of the simulation and ensure - # time is forward filled. - rch = rch.clip_box(time_min=time_min, time_max=time_max) - return rch - - @classmethod - def from_imod5_cap_data( - cls, - imod5_data: Imod5DataDict, - target_dis: StructuredDiscretization, - regridder_types: CapDataRechargeRegridMethod = CapDataRechargeRegridMethod(), - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> "Recharge": - """ - Construct an rch-package from iMOD5 data in the CAP package, loaded with - the :func:`imod.formats.prj.open_projectfile_data` function. Package is - used to couple MODFLOW6 to MetaSWAP models. Active cells will have a - recharge rate of 0.0. - """ - cap_data = regrid_imod5_cap_data( - imod5_data, target_dis, regridder_types, regrid_cache - )["cap"] - - msw_area = get_cell_area_from_imod5_data(cap_data) - msw_active = is_msw_active_cell(target_dis, cap_data, msw_area) - active = msw_active.all - - data = {} - zero_scalar = xr.DataArray(0.0, coords={"layer": 1}) - data["rate"] = zero_scalar.where(active) - - return cls(**data, validate=True, fixed_cell=False) +from datetime import datetime +from typing import Optional + +import numpy as np +import xarray as xr + +from imod.common.interfaces.iregridpackage import IRegridPackage +from imod.common.utilities.dataclass_type import DataclassType +from imod.common.utilities.regrid import regrid_imod5_cap_data +from imod.logging import init_log_decorator +from imod.mf6.aggregate.aggregate_schemes import RechargeAggregationMethod +from imod.mf6.dis import StructuredDiscretization, VerticesDiscretization +from imod.mf6.regrid.regrid_schemes import ( + CapDataRechargeRegridMethod, + RechargeRegridMethod, +) +from imod.mf6.topsystem import TopSystemBoundaryCondition +from imod.mf6.utilities.imod5_converter import ( + convert_unit_rch_rate, + regrid_imod5_pkg_data, +) +from imod.mf6.utilities.package import set_repeat_stress_if_available +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA +from imod.msw.utilities.imod5_converter import ( + get_cell_area_from_imod5_data, + is_msw_active_cell, +) +from imod.prepare.topsystem.allocation import ( + ALLOCATION_OPTION, + allocate_rch_cells, + drop_empty_layers_from_dict, +) +from imod.schemata import ( + AllCoordsValueSchema, + AllInsideNoDataSchema, + AllNoDataSchema, + AllValueSchema, + CoordsSchema, + DimsSchema, + DTypeSchema, + IdentityNoDataSchema, + IndexesSchema, + OtherCoordsSchema, +) +from imod.typing import GridDataArray, Imod5DataDict +from imod.typing.grid import ( + enforce_dim_order, + is_planar_grid, +) +from imod.util.regrid import RegridderWeightsCache + + +class Recharge(TopSystemBoundaryCondition, IRegridPackage): + """ + Recharge Package. + Any number of RCH Packages can be specified for a single groundwater flow + model. + https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=79 + + Parameters + ---------- + rate: array of floats (xr.DataArray) + is the recharge flux rate (LT −1). This rate is multiplied inside the + program by the surface area of the cell to calculate the volumetric + recharge rate. A time-series name may be specified. + concentration: array of floats (xr.DataArray, optional) + if this flow package is used in simulations also involving transport, then this array is used + as the concentration for inflow over this boundary. + concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) + if this flow package is used in simulations also involving transport, then this keyword specifies + how outflow over this boundary is computed. + print_input: ({True, False}, optional) + keyword to indicate that the list of recharge information will be + written to the listing file immediately after it is read. + Default is False. + print_flows: ({True, False}, optional) + Indicates that the list of recharge flow rates will be printed to the + listing file for every stress period time step in which "BUDGET PRINT"is + specified in Output Control. If there is no Output Control option and + PRINT FLOWS is specified, then flow rates are printed for the last time + step of each stress period. + Default is False. + save_flows: ({True, False}, optional) + Indicates that recharge flow terms will be written to the file specified + with "BUDGET FILEOUT" in Output Control. + Default is False. + observations: [Not yet supported.] + Default is None. + validate: {True, False} + Flag to indicate whether the package should be validated upon + initialization. This raises a ValidationError if package input is + provided in the wrong manner. Defaults to True. + repeat_stress: dict or xr.DataArray of datetimes, optional + Used to repeat data for e.g. repeating stress periods such as + seasonality without duplicating the values. If provided as dict, it + should map new dates to old dates present in the dataset. + ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided + as DataArray, it should have dimensions ``("repeat", "repeat_items")``. + The ``repeat_items`` dimension should have size 2: the first value is + the "key", the second value is the "value". For the "key" datetime, the + data of the "value" datetime will be used. + fixed_cell: ({True, False}, optional) + indicates that recharge will not be reassigned to a cell underlying the + cell specified in the list if the specified cell is inactive. + """ + + _pkg_id = "rch" + _period_data = ("rate",) + _keyword_map = {} + + _init_schemata = { + "rate": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "print_flows": [DTypeSchema(np.bool_), DimsSchema()], + "save_flows": [DTypeSchema(np.bool_), DimsSchema()], + } + _write_schemata = { + "rate": [ + OtherCoordsSchema("idomain"), + AllNoDataSchema(), # Check for all nan, can occur while clipping + AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), + ], + "concentration": [IdentityNoDataSchema("rate"), AllValueSchema(">=", 0.0)], + } + + _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) + _auxiliary_data = {"concentration": "species"} + _regrid_method = RechargeRegridMethod() + _aggregate_method: DataclassType = RechargeAggregationMethod() + + @init_log_decorator() + def __init__( + self, + rate, + concentration=None, + concentration_boundary_type="auxmixed", + print_input=False, + print_flows=False, + save_flows=False, + observations=None, + validate: bool = True, + repeat_stress=None, + fixed_cell: bool = False, + ): + dict_dataset = { + "rate": rate, + "concentration": concentration, + "concentration_boundary_type": concentration_boundary_type, + "print_input": print_input, + "print_flows": print_flows, + "save_flows": save_flows, + "observations": observations, + "repeat_stress": repeat_stress, + "fixed_cell": fixed_cell, + } + super().__init__(dict_dataset) + self._validate_init_schemata(validate) + + def _validate(self, schemata, **kwargs): + # Insert additional kwargs + kwargs["rate"] = self["rate"] + errors = super()._validate(schemata, **kwargs) + + return errors + + @classmethod + def _allocate_planar_data( + cls, + planar_data: dict[str, GridDataArray], + dis: StructuredDiscretization | VerticesDiscretization, + allocation_option: ALLOCATION_OPTION, + drop_empty_layers: bool = True, + ) -> dict[str, GridDataArray]: + """ + Allocate and distribute planar data for given discretization and npf + package. To allocate cells, the allocation option + ALLOCATION_OPTION.at_first_active is set. + + Parameters + ---------- + planar_data: dict[str, GridDataArray] + Dictionary with planar grid data. + dis: imod.mf6.StructuredDiscretization + Model discretization package. + allocation_option: ALLOCATION_OPTION + The allocation option to use for the reallocation. + drop_empty_layers: bool + If True, drop layers without any allocated cells from the + returned grids. Allocation is always computed over the full + layer range first, so this does not affect the computed values. + + Returns + ------- + dict[str, GridDataArray] + Dictionary with layered grid data. + """ + idomain = dis.dataset["idomain"] + if "layer" in planar_data["rate"].dims: + planar_data["rate"] = planar_data["rate"].isel(layer=0, drop=True) + # create an array indicating in which cells rch is active + is_rch_cell = allocate_rch_cells( + allocation_option, + idomain > 0, + planar_data["rate"], + drop_empty_layers=False, # Keep full here, drop empty layers below + ) + # remove rch from cells where it is not allocated and broadcast over layers. + layered_data = {} + layered_data["rate"] = planar_data["rate"].where(is_rch_cell) + layered_data["rate"] = enforce_dim_order(layered_data["rate"]) + if drop_empty_layers: + layered_data = drop_empty_layers_from_dict(layered_data, is_rch_cell) + return layered_data + + @classmethod + def from_imod5_data( + cls, + imod5_data: dict[str, dict[str, GridDataArray]], + period_data: dict[str, list[datetime]], + target_dis: StructuredDiscretization, + time_min: datetime, + time_max: datetime, + regridder_types: Optional[RechargeRegridMethod] = None, + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> "Recharge": + """ + Construct an rch-package from iMOD5 data, loaded with the + :func:`imod.formats.prj.open_projectfile_data` function. + + .. note:: + + The method expects the iMOD5 model to be fully 3D, not quasi-3D. + + Parameters + ---------- + imod5_data: dict + Dictionary with iMOD5 data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + period_data: dict + Dictionary with iMOD5 period data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + target_dis: GridDataArray + The discretization package for the simulation. Its grid does not + need to be identical to one of the input grids. + time_min: datetime + Begin-time of the simulation. Used for expanding period data. + time_max: datetime + End-time of the simulation. Used for expanding period data. + regridder_types: RechargeRegridMethod, optional + Optional dataclass with regridder types for a specific variable. + Use this to override default regridding methods. + regrid_cache: RegridderWeightsCache, optional + stores regridder weights for different regridders. Can be used to speed up regridding, + if the same regridders are used several times for regridding different arrays. + + Returns + ------- + Modflow 6 rch package. + + """ + data = { + "rate": convert_unit_rch_rate(imod5_data["rch"]["rate"]), + } + regridded_package_data = regrid_imod5_pkg_data( + cls, data, target_dis, regridder_types, regrid_cache + ) + # if rate has only layer 0, then it is planar. + if is_planar_grid(regridded_package_data["rate"]): + allocation_option = ALLOCATION_OPTION.at_first_active + layered_data = cls._allocate_planar_data( + regridded_package_data, target_dis, allocation_option + ) + regridded_package_data.update(layered_data) + rch = cls(**regridded_package_data, validate=True, fixed_cell=False) + repeat = period_data.get("rch") + set_repeat_stress_if_available(repeat, time_min, time_max, rch) + # Clip the rch package to the time range of the simulation and ensure + # time is forward filled. + rch = rch.clip_box(time_min=time_min, time_max=time_max) + return rch + + @classmethod + def from_imod5_cap_data( + cls, + imod5_data: Imod5DataDict, + target_dis: StructuredDiscretization, + regridder_types: CapDataRechargeRegridMethod = CapDataRechargeRegridMethod(), + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> "Recharge": + """ + Construct an rch-package from iMOD5 data in the CAP package, loaded with + the :func:`imod.formats.prj.open_projectfile_data` function. Package is + used to couple MODFLOW6 to MetaSWAP models. Active cells will have a + recharge rate of 0.0. + """ + cap_data = regrid_imod5_cap_data( + imod5_data, target_dis, regridder_types, regrid_cache + )["cap"] + + msw_area = get_cell_area_from_imod5_data(cap_data) + msw_active = is_msw_active_cell(target_dis, cap_data, msw_area) + active = msw_active.all + + data = {} + zero_scalar = xr.DataArray(0.0, coords={"layer": 1}) + data["rate"] = zero_scalar.where(active) + + return cls(**data, validate=True, fixed_cell=False) diff --git a/imod/mf6/riv.py b/imod/mf6/riv.py index 1325e52a1..5f59550d9 100644 --- a/imod/mf6/riv.py +++ b/imod/mf6/riv.py @@ -1,557 +1,557 @@ -from datetime import datetime -from typing import Optional, Tuple, cast - -import numpy as np - -from imod import logging -from imod.common.interfaces.iregridpackage import IRegridPackage -from imod.common.utilities.dataclass_type import DataclassType -from imod.common.utilities.mask import broadcast_and_mask_arrays -from imod.logging import init_log_decorator, standard_log_decorator -from imod.mf6.aggregate.aggregate_schemes import RiverAggregationMethod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.drn import Drainage -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.regrid.regrid_schemes import RiverRegridMethod -from imod.mf6.topsystem import TopSystemBoundaryCondition -from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data -from imod.mf6.utilities.package import set_repeat_stress_if_available -from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA -from imod.prepare.cleanup import AlignLevelsMode, align_interface_levels, cleanup_riv -from imod.prepare.topsystem.allocation import ( - ALLOCATION_OPTION, - allocate_riv_cells, - drop_empty_layers_from_dict, -) -from imod.prepare.topsystem.conductance import ( - DISTRIBUTING_OPTION, - distribute_drn_conductance, - distribute_riv_conductance, - split_conductance_with_infiltration_factor, -) -from imod.schemata import ( - AllCoordsValueSchema, - AllInsideNoDataSchema, - AllNoDataSchema, - AllValueSchema, - CoordsSchema, - DimsSchema, - DTypeSchema, - IdentityNoDataSchema, - IndexesSchema, - OtherCoordsSchema, -) -from imod.typing import GridDataArray, GridDataDict -from imod.typing.grid import ( - concat, - enforce_dim_order, - has_negative_layer, - is_planar_grid, -) -from imod.util.regrid import ( - RegridderWeightsCache, -) - - -def mask_package__drop_if_empty( - package: TopSystemBoundaryCondition, -) -> Optional[TopSystemBoundaryCondition]: - """ " - Create an optional package from a package if it has data. Return None if - package is inactive everywhere. - """ - # remove River package if its mask is False everywhere - mask = ~np.isnan(package["conductance"]) - return package.mask(mask) if np.any(mask) else None - - -def clip_time_if_package( - package: Optional[TopSystemBoundaryCondition], - time_min: datetime, - time_max: datetime, -) -> Optional[TopSystemBoundaryCondition]: - if package is not None: - package = package.clip_box(time_min=time_min, time_max=time_max) - return package - - -def rise_bottom_elevation_if_needed( - bottom_elevation: GridDataArray, bottom: GridDataArray -) -> GridDataArray: - """ - Due to regridding, the bottom_elevation could be less than the - layer bottom, so here we overwrite it with bottom if that's - the case. - """ - is_layer_bottom_above_bottom_elevation = (bottom > bottom_elevation).any() - - if is_layer_bottom_above_bottom_elevation: - logging.logger.warning( - "Note: riv bottom was detected below model bottom. Updated the riv's bottom." - ) - bottom_elevation, _ = align_interface_levels( - bottom_elevation, bottom, AlignLevelsMode.BOTTOMUP - ) - return bottom_elevation - - -def _separate_infiltration_data( - riv_pkg_data: GridDataDict, infiltration_factor: GridDataArray -) -> tuple[GridDataDict, GridDataDict]: - """ - Account for the infiltration factor in the river package data. This function - updates the riv_pkg_data with an infiltration conductance. The extra - exfiltration conductance is separated into a data dict for drainage - """ - # update the conductance of the river package to account for the - # infiltration factor - drain_conductance, river_conductance = split_conductance_with_infiltration_factor( - riv_pkg_data["conductance"], infiltration_factor - ) - riv_pkg_data["conductance"] = river_conductance - # create a drainage package with the conductance we computed from the - # infiltration factor - drn_pkg_data = { - "elevation": riv_pkg_data["stage"], - "conductance": drain_conductance, - } - return riv_pkg_data, drn_pkg_data - - -def _create_drain_from_leftover_riv_imod5_data( - allocation_drn_data: GridDataDict, - infiltration_drn_data: GridDataDict, -) -> Drainage: - """ - Create a drainage package from leftover imod5 river package data, - stemming from: - - * If ``ALLOCATION_OPTION.stage_to_riv_bottom_drn_above`` is chosen, - drain cells are allocated from the first active cell to river - stage. In this case ``allocation_drn_data`` is not empty. - * Infiltration factor. This factor is optional in imod5, but it - does not exist in MF6, so we mimic its effect with a Drainage - boundary. This data is stored in ``infiltration_drn_data``. - """ - - if allocation_drn_data: - drain_leftover_data: GridDataDict = {} - for key, allocation_grid in allocation_drn_data.items(): - concatenated = concat( - [allocation_grid, infiltration_drn_data[key]], dim="leftover" - ) - drain_leftover_data[key] = concatenated.mean(dim="leftover") - else: - drain_leftover_data = infiltration_drn_data - - return Drainage(**drain_leftover_data) # type: ignore - - -class River(TopSystemBoundaryCondition, IRegridPackage): - """ - River package. - Any number of RIV Packages can be specified for a single groundwater flow - model. - https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=71 - - Parameters - ---------- - stage: array of floats (xr.DataArray) - is the head in the river. - conductance: array of floats (xr.DataArray) - is the riverbed hydraulic conductance. - bottom_elevation: array of floats (xr.DataArray) - is the elevation of the bottom of the riverbed. - concentration: array of floats (xr.DataArray, optional) - if this flow package is used in simulations also involving transport, then this array is used - as the concentration for inflow over this boundary. - concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) - if this flow package is used in simulations also involving transport, then this keyword specifies - how outflow over this boundary is computed. - print_input: ({True, False}, optional) - keyword to indicate that the list of river information will be written - to the listing file immediately after it is read. Default is False. - print_flows: ({True, False}, optional) - Indicates that the list of river flow rates will be printed to the - listing file for every stress period time step in which "BUDGET PRINT" - is specified in Output Control. If there is no Output Control option and - PRINT FLOWS is specified, then flow rates are printed for the last time - step of each stress period. Default is False. - save_flows: ({True, False}, optional) - Indicates that river flow terms will be written to the file specified - with "BUDGET FILEOUT" in Output Control. Default is False. - observations: [Not yet supported.] - Default is None. - validate: {True, False} - Flag to indicate whether the package should be validated upon - initialization. This raises a ValidationError if package input is - provided in the wrong manner. Defaults to True. - repeat_stress: dict or xr.DataArray of datetimes, optional - Used to repeat data for e.g. repeating stress periods such as - seasonality without duplicating the values. If provided as dict, it - should map new dates to old dates present in the dataset. - ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided - as DataArray, it should have dimensions ``("repeat", "repeat_items")``. - The ``repeat_items`` dimension should have size 2: the first value is - the "key", the second value is the "value". For the "key" datetime, the - data of the "value" datetime will be used. - """ - - _pkg_id = "riv" - _period_data = ("stage", "conductance", "bottom_elevation") - _keyword_map = {} - - _init_schemata = { - "stage": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "conductance": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "bottom_elevation": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema(("layer",)), - BOUNDARY_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "concentration": [ - DTypeSchema(np.floating), - IndexesSchema(), - CoordsSchema( - ( - "species", - "layer", - ) - ), - CONC_DIMS_SCHEMA, - AllCoordsValueSchema("layer", ">", 0), - ], - "print_input": [DTypeSchema(np.bool_), DimsSchema()], - "print_flows": [DTypeSchema(np.bool_), DimsSchema()], - "save_flows": [DTypeSchema(np.bool_), DimsSchema()], - } - _write_schemata = { - "stage": [ - AllValueSchema(">=", "bottom_elevation"), - OtherCoordsSchema("idomain"), - AllNoDataSchema(), # Check for all nan, can occur while clipping - AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), - ], - "conductance": [IdentityNoDataSchema("stage"), AllValueSchema(">", 0.0)], - "bottom_elevation": [ - IdentityNoDataSchema("stage"), - # Check river bottom above layer bottom, else Modflow throws error. - AllValueSchema(">=", "bottom", ignore=("icelltype", "==", 0)), - ], - "concentration": [IdentityNoDataSchema("stage"), AllValueSchema(">=", 0.0)], - } - - _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) - _auxiliary_data = {"concentration": "species"} - _regrid_method = RiverRegridMethod() - _aggregate_method: DataclassType = RiverAggregationMethod() - - @init_log_decorator() - def __init__( - self, - stage, - conductance, - bottom_elevation, - concentration=None, - concentration_boundary_type="aux", - print_input=False, - print_flows=False, - save_flows=False, - observations=None, - validate: bool = True, - repeat_stress=None, - ): - dict_dataset = { - "stage": stage, - "conductance": conductance, - "bottom_elevation": bottom_elevation, - "concentration": concentration, - "concentration_boundary_type": concentration_boundary_type, - "print_input": print_input, - "print_flows": print_flows, - "save_flows": save_flows, - "observations": observations, - "repeat_stress": repeat_stress, - } - super().__init__(dict_dataset) - self._validate_init_schemata(validate) - - def _validate(self, schemata, **kwargs): - # Insert additional kwargs - kwargs["stage"] = self["stage"] - kwargs["bottom_elevation"] = self["bottom_elevation"] - errors = super()._validate(schemata, **kwargs) - - return errors - - @standard_log_decorator() - def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: - """ - Clean up package inplace. This method calls - :func:`imod.prepare.cleanup_riv`, see documentation of that - function for details on cleanup. - - dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization - Model discretization package. - """ - dis_dict = {"idomain": dis.dataset["idomain"], "bottom": dis.dataset["bottom"]} - cleaned_dict = self._call_func_on_grids(cleanup_riv, dis_dict) - super().__init__(cleaned_dict) - - @classmethod - def _allocate_and_distribute_planar_data( - cls, - planar_data: GridDataDict, - dis: StructuredDiscretization | VerticesDiscretization, - npf: NodePropertyFlow, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - drop_empty_layers: bool = True, - ) -> tuple[GridDataDict, GridDataDict]: - """ - Allocate and distribute planar data for given discretization and npf - package. If layer number of ``planar_data`` is negative, - ``allocation_option`` is overrided and set to - ALLOCATION_OPTION.at_first_active. - - Parameters - ---------- - planar_data: GridDataDict - Dictionary with planar grid data. - dis: imod.mf6.StructuredDiscretization - Model discretization package. - npf: imod.mf6.NodePropertyFlow - Node property flow package. - allocation_option: ALLOCATION_OPTION - allocation option. If planar data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: DISTRIBUTING_OPTION - distributing option. - drop_empty_layers: bool - If True, drop layers without any allocated cells from the - returned grids. Allocation and distribution are always computed - over the full layer range first, so this does not affect the - computed values. - - Returns - ------- - GridDataDict - Dictionary with layered grid data. - """ - top = dis.dataset["top"] - bottom = dis.dataset["bottom"] - idomain = dis.dataset["idomain"] - - if has_negative_layer(planar_data["stage"]): - allocation_option = ALLOCATION_OPTION.at_first_active - - # Enforce planar data, remove all layer dimension information - planar_data = { - key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") - for key, grid in planar_data.items() - } - # Allocation of cells - riv_allocated, drn_allocated = allocate_riv_cells( - allocation_option, - idomain > 0, - top, - bottom, - planar_data["stage"], - planar_data["bottom_elevation"], - drop_empty_layers=False, # Keep full layer range, drop empty layers below - ) - drn_is_allocated = drn_allocated is not None - # Distribution of conductances - allocated_for_distribution = ( - riv_allocated | drn_allocated if drn_is_allocated else riv_allocated # type: ignore - ) - distribute_func = ( - distribute_drn_conductance - if drn_is_allocated - else distribute_riv_conductance - ) - distribute_args = ( - distributing_option, - allocated_for_distribution, - planar_data["conductance"], - top, - bottom, - npf.dataset["k"], - ) - riv_distribute_grids = (planar_data["stage"], planar_data["bottom_elevation"]) - drn_distribute_grids = (planar_data["bottom_elevation"],) - bc_distribute_grids = ( - drn_distribute_grids if drn_is_allocated else riv_distribute_grids - ) - conductance = distribute_func(*distribute_args, *bc_distribute_grids) - # Create layered data dicts - layered_data_riv = {} - # create layered arrays of stage and bottom elevation - for key in ["stage", "bottom_elevation"]: - layered_data_riv[key] = enforce_dim_order( - planar_data[key].where(riv_allocated) - ) - layered_data_riv["conductance"] = conductance.where(riv_allocated) - - layered_data_drn = {} - if drn_allocated is not None: - layered_data_drn["elevation"] = enforce_dim_order( - planar_data["stage"].where(drn_allocated) - ) - layered_data_drn["conductance"] = conductance.where(drn_allocated) - - layered_data_riv["bottom_elevation"] = rise_bottom_elevation_if_needed( - layered_data_riv["bottom_elevation"], bottom - ) - - if drop_empty_layers: - layered_data_riv = drop_empty_layers_from_dict( - layered_data_riv, riv_allocated - ) - if drn_allocated is not None: - layered_data_drn = drop_empty_layers_from_dict( - layered_data_drn, drn_allocated - ) - - return layered_data_riv, layered_data_drn - - @classmethod - def from_imod5_data( - cls, - key: str, - imod5_data: dict[str, GridDataDict], - period_data: dict[str, list[datetime]], - target_dis: StructuredDiscretization, - target_npf: NodePropertyFlow, - time_min: datetime, - time_max: datetime, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - regridder_types: Optional[RiverRegridMethod] = None, - regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), - ) -> Tuple[Optional["River"], Optional[Drainage]]: - """ - Construct a river-package from iMOD5 data, loaded with the - :func:`imod.formats.prj.open_projectfile_data` function. - - .. note:: - - The method expects the iMOD5 model to be fully 3D, not quasi-3D. - - Parameters - ---------- - key: str - Packagename of the package that needs to be converted to river - package. - imod5_data: dict - Dictionary with iMOD5 data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - period_data: dict - Dictionary with iMOD5 period data. This can be constructed from the - :func:`imod.formats.prj.open_projectfile_data` method. - target_dis: StructuredDiscretization package - The grid that should be used for the new package. Does not - need to be identical to one of the input grids. - time_min: datetime - Begin-time of the simulation. Used for expanding period data. - time_max: datetime - End-time of the simulation. Used for expanding period data. - allocation_option: ALLOCATION_OPTION - allocation option. If package data is assigned to a negative layer - number, this option is overridden and set to - ALLOCATION_OPTION.at_first_active. - distributing_option: DISTRIBUTING_OPTION - distributing option. - regridder_types: RiverRegridMethod, optional - Optional dataclass with regridder types for a specific variable. - Use this to override default regridding methods. - regrid_cache: RegridderWeightsCache, optional - stores regridder weights for different regridders. Can be used to speed up regridding, - if the same regridders are used several times for regridding different arrays. - - Returns - ------- - A tuple containing a River package and a Drainage package. The Drainage - package accounts for the infiltration factor which exists in iMOD5 but - not in MF6. It furthermore potentially contains drainage cells above - river stage if ``ALLOCATION_OPTION.stage_to_riv_bot_drn_above`` is - chosen. Both the river package and the drainage package can be None, - this can happen if the infiltration factor is 0 or 1 everywhere. - """ - # gather input data - varnames = ["conductance", "stage", "bottom_elevation", "infiltration_factor"] - data = {varname: imod5_data[key][varname] for varname in varnames} - mask = data["conductance"] > 0 - data["conductance"] = data["conductance"].where(mask) - # Regrid the input data - regridded_riv_pkg_data = regrid_imod5_pkg_data( - cls, data, target_dis, regridder_types, regrid_cache - ) - regridded_riv_pkg_data = broadcast_and_mask_arrays(regridded_riv_pkg_data) - # Pop infiltration_factor to avoid unnecessarily allocating and - # distributing it. - infiltration_factor = regridded_riv_pkg_data.pop("infiltration_factor") - # Allocate and distribute planar data if the grid is planar - is_planar_xy = is_planar_grid(regridded_riv_pkg_data["conductance"]) - allocation_drn_data: GridDataDict = {} - if is_planar_xy: - # allocate and distribute planar data - allocation_riv_data, allocation_drn_data = ( - cls._allocate_and_distribute_planar_data( - regridded_riv_pkg_data, - target_dis, - target_npf, - allocation_option, - distributing_option, - ) - ) - regridded_riv_pkg_data.update(allocation_riv_data) - infiltration_factor = infiltration_factor.isel( - {"layer": 0}, drop=True, missing_dims="ignore" - ) - regridded_riv_pkg_data["bottom_elevation"] = enforce_dim_order( - regridded_riv_pkg_data["bottom_elevation"] - ) - # Create packages - regridded_riv_pkg_data, infiltration_drn_data = _separate_infiltration_data( - regridded_riv_pkg_data, infiltration_factor - ) - riv_pkg = cls(**regridded_riv_pkg_data, validate=True) - drn_pkg = _create_drain_from_leftover_riv_imod5_data( - allocation_drn_data, - infiltration_drn_data, - ) - # Mask the river and drainage packages to drop empty data. - optional_riv_pkg = mask_package__drop_if_empty(riv_pkg) - optional_drn_pkg = mask_package__drop_if_empty(drn_pkg) - - # Account for periods with repeat stresses. - repeat = period_data.get(key) - set_repeat_stress_if_available(repeat, time_min, time_max, optional_riv_pkg) - set_repeat_stress_if_available(repeat, time_min, time_max, optional_drn_pkg) - # Clip the river package to the time range of the simulation and ensure - # time is forward filled. - optional_riv_pkg = clip_time_if_package(optional_riv_pkg, time_min, time_max) - optional_drn_pkg = clip_time_if_package(optional_drn_pkg, time_min, time_max) - - # Cast for mypy checks - optional_riv_pkg = cast(Optional[River], optional_riv_pkg) - optional_drn_pkg = cast(Optional[Drainage], optional_drn_pkg) - - return (optional_riv_pkg, optional_drn_pkg) +from datetime import datetime +from typing import Optional, Tuple, cast + +import numpy as np + +from imod import logging +from imod.common.interfaces.iregridpackage import IRegridPackage +from imod.common.utilities.dataclass_type import DataclassType +from imod.common.utilities.mask import broadcast_and_mask_arrays +from imod.logging import init_log_decorator, standard_log_decorator +from imod.mf6.aggregate.aggregate_schemes import RiverAggregationMethod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.drn import Drainage +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.regrid.regrid_schemes import RiverRegridMethod +from imod.mf6.topsystem import TopSystemBoundaryCondition +from imod.mf6.utilities.imod5_converter import regrid_imod5_pkg_data +from imod.mf6.utilities.package import set_repeat_stress_if_available +from imod.mf6.validation import BOUNDARY_DIMS_SCHEMA, CONC_DIMS_SCHEMA +from imod.prepare.cleanup import AlignLevelsMode, align_interface_levels, cleanup_riv +from imod.prepare.topsystem.allocation import ( + ALLOCATION_OPTION, + allocate_riv_cells, + drop_empty_layers_from_dict, +) +from imod.prepare.topsystem.conductance import ( + DISTRIBUTING_OPTION, + distribute_drn_conductance, + distribute_riv_conductance, + split_conductance_with_infiltration_factor, +) +from imod.schemata import ( + AllCoordsValueSchema, + AllInsideNoDataSchema, + AllNoDataSchema, + AllValueSchema, + CoordsSchema, + DimsSchema, + DTypeSchema, + IdentityNoDataSchema, + IndexesSchema, + OtherCoordsSchema, +) +from imod.typing import GridDataArray, GridDataDict +from imod.typing.grid import ( + concat, + enforce_dim_order, + has_negative_layer, + is_planar_grid, +) +from imod.util.regrid import ( + RegridderWeightsCache, +) + + +def mask_package__drop_if_empty( + package: TopSystemBoundaryCondition, +) -> Optional[TopSystemBoundaryCondition]: + """ " + Create an optional package from a package if it has data. Return None if + package is inactive everywhere. + """ + # remove River package if its mask is False everywhere + mask = ~np.isnan(package["conductance"]) + return package.mask(mask) if np.any(mask) else None + + +def clip_time_if_package( + package: Optional[TopSystemBoundaryCondition], + time_min: datetime, + time_max: datetime, +) -> Optional[TopSystemBoundaryCondition]: + if package is not None: + package = package.clip_box(time_min=time_min, time_max=time_max) + return package + + +def rise_bottom_elevation_if_needed( + bottom_elevation: GridDataArray, bottom: GridDataArray +) -> GridDataArray: + """ + Due to regridding, the bottom_elevation could be less than the + layer bottom, so here we overwrite it with bottom if that's + the case. + """ + is_layer_bottom_above_bottom_elevation = (bottom > bottom_elevation).any() + + if is_layer_bottom_above_bottom_elevation: + logging.logger.warning( + "Note: riv bottom was detected below model bottom. Updated the riv's bottom." + ) + bottom_elevation, _ = align_interface_levels( + bottom_elevation, bottom, AlignLevelsMode.BOTTOMUP + ) + return bottom_elevation + + +def _separate_infiltration_data( + riv_pkg_data: GridDataDict, infiltration_factor: GridDataArray +) -> tuple[GridDataDict, GridDataDict]: + """ + Account for the infiltration factor in the river package data. This function + updates the riv_pkg_data with an infiltration conductance. The extra + exfiltration conductance is separated into a data dict for drainage + """ + # update the conductance of the river package to account for the + # infiltration factor + drain_conductance, river_conductance = split_conductance_with_infiltration_factor( + riv_pkg_data["conductance"], infiltration_factor + ) + riv_pkg_data["conductance"] = river_conductance + # create a drainage package with the conductance we computed from the + # infiltration factor + drn_pkg_data = { + "elevation": riv_pkg_data["stage"], + "conductance": drain_conductance, + } + return riv_pkg_data, drn_pkg_data + + +def _create_drain_from_leftover_riv_imod5_data( + allocation_drn_data: GridDataDict, + infiltration_drn_data: GridDataDict, +) -> Drainage: + """ + Create a drainage package from leftover imod5 river package data, + stemming from: + + * If ``ALLOCATION_OPTION.stage_to_riv_bottom_drn_above`` is chosen, + drain cells are allocated from the first active cell to river + stage. In this case ``allocation_drn_data`` is not empty. + * Infiltration factor. This factor is optional in imod5, but it + does not exist in MF6, so we mimic its effect with a Drainage + boundary. This data is stored in ``infiltration_drn_data``. + """ + + if allocation_drn_data: + drain_leftover_data: GridDataDict = {} + for key, allocation_grid in allocation_drn_data.items(): + concatenated = concat( + [allocation_grid, infiltration_drn_data[key]], dim="leftover" + ) + drain_leftover_data[key] = concatenated.mean(dim="leftover") + else: + drain_leftover_data = infiltration_drn_data + + return Drainage(**drain_leftover_data) # type: ignore + + +class River(TopSystemBoundaryCondition, IRegridPackage): + """ + River package. + Any number of RIV Packages can be specified for a single groundwater flow + model. + https://water.usgs.gov/water-resources/software/MODFLOW-6/mf6io_6.0.4.pdf#page=71 + + Parameters + ---------- + stage: array of floats (xr.DataArray) + is the head in the river. + conductance: array of floats (xr.DataArray) + is the riverbed hydraulic conductance. + bottom_elevation: array of floats (xr.DataArray) + is the elevation of the bottom of the riverbed. + concentration: array of floats (xr.DataArray, optional) + if this flow package is used in simulations also involving transport, then this array is used + as the concentration for inflow over this boundary. + concentration_boundary_type: ({"AUX", "AUXMIXED"}, optional) + if this flow package is used in simulations also involving transport, then this keyword specifies + how outflow over this boundary is computed. + print_input: ({True, False}, optional) + keyword to indicate that the list of river information will be written + to the listing file immediately after it is read. Default is False. + print_flows: ({True, False}, optional) + Indicates that the list of river flow rates will be printed to the + listing file for every stress period time step in which "BUDGET PRINT" + is specified in Output Control. If there is no Output Control option and + PRINT FLOWS is specified, then flow rates are printed for the last time + step of each stress period. Default is False. + save_flows: ({True, False}, optional) + Indicates that river flow terms will be written to the file specified + with "BUDGET FILEOUT" in Output Control. Default is False. + observations: [Not yet supported.] + Default is None. + validate: {True, False} + Flag to indicate whether the package should be validated upon + initialization. This raises a ValidationError if package input is + provided in the wrong manner. Defaults to True. + repeat_stress: dict or xr.DataArray of datetimes, optional + Used to repeat data for e.g. repeating stress periods such as + seasonality without duplicating the values. If provided as dict, it + should map new dates to old dates present in the dataset. + ``{"2001-04-01": "2000-04-01", "2001-10-01": "2000-10-01"}`` if provided + as DataArray, it should have dimensions ``("repeat", "repeat_items")``. + The ``repeat_items`` dimension should have size 2: the first value is + the "key", the second value is the "value". For the "key" datetime, the + data of the "value" datetime will be used. + """ + + _pkg_id = "riv" + _period_data = ("stage", "conductance", "bottom_elevation") + _keyword_map = {} + + _init_schemata = { + "stage": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "conductance": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "bottom_elevation": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema(("layer",)), + BOUNDARY_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "concentration": [ + DTypeSchema(np.floating), + IndexesSchema(), + CoordsSchema( + ( + "species", + "layer", + ) + ), + CONC_DIMS_SCHEMA, + AllCoordsValueSchema("layer", ">", 0), + ], + "print_input": [DTypeSchema(np.bool_), DimsSchema()], + "print_flows": [DTypeSchema(np.bool_), DimsSchema()], + "save_flows": [DTypeSchema(np.bool_), DimsSchema()], + } + _write_schemata = { + "stage": [ + AllValueSchema(">=", "bottom_elevation"), + OtherCoordsSchema("idomain"), + AllNoDataSchema(), # Check for all nan, can occur while clipping + AllInsideNoDataSchema(other="idomain", is_other_notnull=(">", 0)), + ], + "conductance": [IdentityNoDataSchema("stage"), AllValueSchema(">", 0.0)], + "bottom_elevation": [ + IdentityNoDataSchema("stage"), + # Check river bottom above layer bottom, else Modflow throws error. + AllValueSchema(">=", "bottom", ignore=("icelltype", "==", 0)), + ], + "concentration": [IdentityNoDataSchema("stage"), AllValueSchema(">=", 0.0)], + } + + _template = TopSystemBoundaryCondition._initialize_template(_pkg_id) + _auxiliary_data = {"concentration": "species"} + _regrid_method = RiverRegridMethod() + _aggregate_method: DataclassType = RiverAggregationMethod() + + @init_log_decorator() + def __init__( + self, + stage, + conductance, + bottom_elevation, + concentration=None, + concentration_boundary_type="aux", + print_input=False, + print_flows=False, + save_flows=False, + observations=None, + validate: bool = True, + repeat_stress=None, + ): + dict_dataset = { + "stage": stage, + "conductance": conductance, + "bottom_elevation": bottom_elevation, + "concentration": concentration, + "concentration_boundary_type": concentration_boundary_type, + "print_input": print_input, + "print_flows": print_flows, + "save_flows": save_flows, + "observations": observations, + "repeat_stress": repeat_stress, + } + super().__init__(dict_dataset) + self._validate_init_schemata(validate) + + def _validate(self, schemata, **kwargs): + # Insert additional kwargs + kwargs["stage"] = self["stage"] + kwargs["bottom_elevation"] = self["bottom_elevation"] + errors = super()._validate(schemata, **kwargs) + + return errors + + @standard_log_decorator() + def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization) -> None: + """ + Clean up package inplace. This method calls + :func:`imod.prepare.cleanup_riv`, see documentation of that + function for details on cleanup. + + dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization + Model discretization package. + """ + dis_dict = {"idomain": dis.dataset["idomain"], "bottom": dis.dataset["bottom"]} + cleaned_dict = self._call_func_on_grids(cleanup_riv, dis_dict) + super().__init__(cleaned_dict) + + @classmethod + def _allocate_and_distribute_planar_data( + cls, + planar_data: GridDataDict, + dis: StructuredDiscretization | VerticesDiscretization, + npf: NodePropertyFlow, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + drop_empty_layers: bool = True, + ) -> tuple[GridDataDict, GridDataDict]: + """ + Allocate and distribute planar data for given discretization and npf + package. If layer number of ``planar_data`` is negative, + ``allocation_option`` is overrided and set to + ALLOCATION_OPTION.at_first_active. + + Parameters + ---------- + planar_data: GridDataDict + Dictionary with planar grid data. + dis: imod.mf6.StructuredDiscretization + Model discretization package. + npf: imod.mf6.NodePropertyFlow + Node property flow package. + allocation_option: ALLOCATION_OPTION + allocation option. If planar data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: DISTRIBUTING_OPTION + distributing option. + drop_empty_layers: bool + If True, drop layers without any allocated cells from the + returned grids. Allocation and distribution are always computed + over the full layer range first, so this does not affect the + computed values. + + Returns + ------- + GridDataDict + Dictionary with layered grid data. + """ + top = dis.dataset["top"] + bottom = dis.dataset["bottom"] + idomain = dis.dataset["idomain"] + + if has_negative_layer(planar_data["stage"]): + allocation_option = ALLOCATION_OPTION.at_first_active + + # Enforce planar data, remove all layer dimension information + planar_data = { + key: grid.isel({"layer": 0}, drop=True, missing_dims="ignore") + for key, grid in planar_data.items() + } + # Allocation of cells + riv_allocated, drn_allocated = allocate_riv_cells( + allocation_option, + idomain > 0, + top, + bottom, + planar_data["stage"], + planar_data["bottom_elevation"], + drop_empty_layers=False, # Keep full layer range, drop empty layers below + ) + drn_is_allocated = drn_allocated is not None + # Distribution of conductances + allocated_for_distribution = ( + riv_allocated | drn_allocated if drn_is_allocated else riv_allocated # type: ignore + ) + distribute_func = ( + distribute_drn_conductance + if drn_is_allocated + else distribute_riv_conductance + ) + distribute_args = ( + distributing_option, + allocated_for_distribution, + planar_data["conductance"], + top, + bottom, + npf.dataset["k"], + ) + riv_distribute_grids = (planar_data["stage"], planar_data["bottom_elevation"]) + drn_distribute_grids = (planar_data["bottom_elevation"],) + bc_distribute_grids = ( + drn_distribute_grids if drn_is_allocated else riv_distribute_grids + ) + conductance = distribute_func(*distribute_args, *bc_distribute_grids) + # Create layered data dicts + layered_data_riv = {} + # create layered arrays of stage and bottom elevation + for key in ["stage", "bottom_elevation"]: + layered_data_riv[key] = enforce_dim_order( + planar_data[key].where(riv_allocated) + ) + layered_data_riv["conductance"] = conductance.where(riv_allocated) + + layered_data_drn = {} + if drn_allocated is not None: + layered_data_drn["elevation"] = enforce_dim_order( + planar_data["stage"].where(drn_allocated) + ) + layered_data_drn["conductance"] = conductance.where(drn_allocated) + + layered_data_riv["bottom_elevation"] = rise_bottom_elevation_if_needed( + layered_data_riv["bottom_elevation"], bottom + ) + + if drop_empty_layers: + layered_data_riv = drop_empty_layers_from_dict( + layered_data_riv, riv_allocated + ) + if drn_allocated is not None: + layered_data_drn = drop_empty_layers_from_dict( + layered_data_drn, drn_allocated + ) + + return layered_data_riv, layered_data_drn + + @classmethod + def from_imod5_data( + cls, + key: str, + imod5_data: dict[str, GridDataDict], + period_data: dict[str, list[datetime]], + target_dis: StructuredDiscretization, + target_npf: NodePropertyFlow, + time_min: datetime, + time_max: datetime, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + regridder_types: Optional[RiverRegridMethod] = None, + regrid_cache: RegridderWeightsCache = RegridderWeightsCache(), + ) -> Tuple[Optional["River"], Optional[Drainage]]: + """ + Construct a river-package from iMOD5 data, loaded with the + :func:`imod.formats.prj.open_projectfile_data` function. + + .. note:: + + The method expects the iMOD5 model to be fully 3D, not quasi-3D. + + Parameters + ---------- + key: str + Packagename of the package that needs to be converted to river + package. + imod5_data: dict + Dictionary with iMOD5 data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + period_data: dict + Dictionary with iMOD5 period data. This can be constructed from the + :func:`imod.formats.prj.open_projectfile_data` method. + target_dis: StructuredDiscretization package + The grid that should be used for the new package. Does not + need to be identical to one of the input grids. + time_min: datetime + Begin-time of the simulation. Used for expanding period data. + time_max: datetime + End-time of the simulation. Used for expanding period data. + allocation_option: ALLOCATION_OPTION + allocation option. If package data is assigned to a negative layer + number, this option is overridden and set to + ALLOCATION_OPTION.at_first_active. + distributing_option: DISTRIBUTING_OPTION + distributing option. + regridder_types: RiverRegridMethod, optional + Optional dataclass with regridder types for a specific variable. + Use this to override default regridding methods. + regrid_cache: RegridderWeightsCache, optional + stores regridder weights for different regridders. Can be used to speed up regridding, + if the same regridders are used several times for regridding different arrays. + + Returns + ------- + A tuple containing a River package and a Drainage package. The Drainage + package accounts for the infiltration factor which exists in iMOD5 but + not in MF6. It furthermore potentially contains drainage cells above + river stage if ``ALLOCATION_OPTION.stage_to_riv_bot_drn_above`` is + chosen. Both the river package and the drainage package can be None, + this can happen if the infiltration factor is 0 or 1 everywhere. + """ + # gather input data + varnames = ["conductance", "stage", "bottom_elevation", "infiltration_factor"] + data = {varname: imod5_data[key][varname] for varname in varnames} + mask = data["conductance"] > 0 + data["conductance"] = data["conductance"].where(mask) + # Regrid the input data + regridded_riv_pkg_data = regrid_imod5_pkg_data( + cls, data, target_dis, regridder_types, regrid_cache + ) + regridded_riv_pkg_data = broadcast_and_mask_arrays(regridded_riv_pkg_data) + # Pop infiltration_factor to avoid unnecessarily allocating and + # distributing it. + infiltration_factor = regridded_riv_pkg_data.pop("infiltration_factor") + # Allocate and distribute planar data if the grid is planar + is_planar_xy = is_planar_grid(regridded_riv_pkg_data["conductance"]) + allocation_drn_data: GridDataDict = {} + if is_planar_xy: + # allocate and distribute planar data + allocation_riv_data, allocation_drn_data = ( + cls._allocate_and_distribute_planar_data( + regridded_riv_pkg_data, + target_dis, + target_npf, + allocation_option, + distributing_option, + ) + ) + regridded_riv_pkg_data.update(allocation_riv_data) + infiltration_factor = infiltration_factor.isel( + {"layer": 0}, drop=True, missing_dims="ignore" + ) + regridded_riv_pkg_data["bottom_elevation"] = enforce_dim_order( + regridded_riv_pkg_data["bottom_elevation"] + ) + # Create packages + regridded_riv_pkg_data, infiltration_drn_data = _separate_infiltration_data( + regridded_riv_pkg_data, infiltration_factor + ) + riv_pkg = cls(**regridded_riv_pkg_data, validate=True) + drn_pkg = _create_drain_from_leftover_riv_imod5_data( + allocation_drn_data, + infiltration_drn_data, + ) + # Mask the river and drainage packages to drop empty data. + optional_riv_pkg = mask_package__drop_if_empty(riv_pkg) + optional_drn_pkg = mask_package__drop_if_empty(drn_pkg) + + # Account for periods with repeat stresses. + repeat = period_data.get(key) + set_repeat_stress_if_available(repeat, time_min, time_max, optional_riv_pkg) + set_repeat_stress_if_available(repeat, time_min, time_max, optional_drn_pkg) + # Clip the river package to the time range of the simulation and ensure + # time is forward filled. + optional_riv_pkg = clip_time_if_package(optional_riv_pkg, time_min, time_max) + optional_drn_pkg = clip_time_if_package(optional_drn_pkg, time_min, time_max) + + # Cast for mypy checks + optional_riv_pkg = cast(Optional[River], optional_riv_pkg) + optional_drn_pkg = cast(Optional[Drainage], optional_drn_pkg) + + return (optional_riv_pkg, optional_drn_pkg) diff --git a/imod/mf6/topsystem.py b/imod/mf6/topsystem.py index b7c1d1b6a..d08bf5083 100644 --- a/imod/mf6/topsystem.py +++ b/imod/mf6/topsystem.py @@ -1,192 +1,192 @@ -import abc -from copy import deepcopy -from dataclasses import asdict -from typing import Optional, Self, cast - -from imod.common.utilities.dataclass_type import DataclassType -from imod.mf6.aggregate.aggregate_schemes import EmptyAggregationMethod -from imod.mf6.boundary_condition import BoundaryCondition -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.prepare.topsystem import ( - ALLOCATION_OPTION, - DISTRIBUTING_OPTION, - SimulationAllocationOptions, - SimulationDistributingOptions, -) -from imod.typing import GridDataDict, GridDataset - - -def _handle_reallocate_arguments( - pkg_id: str, - has_conductance: bool, - npf: Optional[NodePropertyFlow], - allocation_option: Optional[ALLOCATION_OPTION], - distributing_option: Optional[DISTRIBUTING_OPTION], -) -> tuple[ALLOCATION_OPTION, Optional[DISTRIBUTING_OPTION]]: - if allocation_option is None: - allocation_option = asdict(SimulationAllocationOptions())[pkg_id] - elif allocation_option == ALLOCATION_OPTION.stage_to_riv_bot_drn_above: - raise ValueError( - f"Allocation option {allocation_option} is not supported for " - "reallocation of boundary conditions." - ) - if has_conductance and distributing_option is None: - distributing_option = asdict(SimulationDistributingOptions())[pkg_id] - if has_conductance and npf is None: - raise ValueError( - "NodePropertyFlow must be provided for packages with conductance variable." - ) - return allocation_option, distributing_option - - -class TopSystemBoundaryCondition(BoundaryCondition, abc.ABC): - """ - Base class to add some extra functionality for topsystem packages, such as - RCH, DRN, RIV, and GHB. - """ - - _aggregate_method: DataclassType = EmptyAggregationMethod() - - def reallocate( - self, - dis: StructuredDiscretization | VerticesDiscretization, - npf: Optional[NodePropertyFlow] = None, - allocation_option: Optional[ALLOCATION_OPTION] = None, - distributing_option: Optional[DISTRIBUTING_OPTION] = None, - drop_empty_layers: bool = True, - ) -> Self: - """ - Reallocates topsystem data across layers and create new package with it. - Aggregate data to planar data first, by taking either the mean for state - variables (e.g. river stage), or the sum for fluxes and the - conductance. Consequently allocate and distribute the planar data to the - provided model layer schematization. - - Parameters - ---------- - dis : StructuredDiscretization | VerticesDiscretization - The discretization of the model to which the data should be - reallocated. - npf : NodePropertyFlow, optional - The node property flow package of the model to which the conductance - should be distributed (if applicable). Required for packages with a - conductance variable. - allocation_option : ALLOCATION_OPTION, optional - The allocation option to use for the reallocation. If None, the - default allocation option is taken from - :class:`imod.prepare.SimulationAllocationOptions`. - distributing_option : DISTRIBUTING_OPTION, optional - The distributing option to use for the reallocation. Required for - packages with a conductance variable. If None, the default is taken - from :class:`imod.prepare.SimulationDistributingOptions`. - drop_empty_layers : bool, default True - If True, drop layers from the resulting package that contain no - allocated cells anywhere in the domain. Allocation and - distribution are always computed over the full layer range - first; layers are only trimmed off the final result, so this - does not affect the computed values, only the package's layer - coordinate. - - Returns - ------- - BoundaryCondition - A new instance of the boundary condition class with the reallocated - data. The original instance remains unchanged. - """ - # Handle input arguments - has_conductance = "conductance" in self.dataset.data_vars - allocation_option, distributing_option = _handle_reallocate_arguments( - self._pkg_id, has_conductance, npf, allocation_option, distributing_option - ) - # Aggregate data to planar data first - planar_data = self.aggregate_layers(self.dataset) - # Then allocate and distribute the planar data to the model layers - if has_conductance: - npf = cast(NodePropertyFlow, npf) - distributing_option = cast(DISTRIBUTING_OPTION, distributing_option) - grid_dict = self._allocate_and_distribute_planar_data( - planar_data, - dis, - npf, - allocation_option, - distributing_option, - drop_empty_layers, - ) - else: - grid_dict = self._allocate_planar_data( - planar_data, dis, allocation_option, drop_empty_layers - ) - # River package returns a tuple (second argument can also be Drainage - # package) - if isinstance(grid_dict, tuple): - grid_dict, _ = grid_dict - options = self._get_unfiltered_pkg_options({}) - data_dict = grid_dict | options - return self.__class__(**data_dict) - - @classmethod - def _allocate_and_distribute_planar_data( - cls, - planar_data: GridDataDict, - dis: StructuredDiscretization | VerticesDiscretization, - npf: NodePropertyFlow, - allocation_option: ALLOCATION_OPTION, - distributing_option: DISTRIBUTING_OPTION, - drop_empty_layers: bool = True, - ) -> tuple[GridDataDict, GridDataDict] | GridDataDict: - raise NotImplementedError( - "This method should be implemented in the specific boundary condition " - "class that inherits from BoundaryCondition." - ) - - @classmethod - def _allocate_planar_data( - cls, - planar_data: GridDataDict, - dis: StructuredDiscretization | VerticesDiscretization, - allocation_option: ALLOCATION_OPTION, - drop_empty_layers: bool = True, - ) -> tuple[GridDataDict, GridDataDict] | GridDataDict: - raise NotImplementedError( - "This method should be implemented in the specific boundary condition " - "class that inherits from BoundaryCondition." - ) - - @classmethod - def _get_aggregate_methods(cls) -> DataclassType: - """ - Returns the aggregation methods used for aggregating data over layers - into planar data. - - Returns - ------- - DataclassType - The aggregation methods used for the package. - """ - return deepcopy(cls._aggregate_method) - - @classmethod - def aggregate_layers(cls, dataset: GridDataset) -> GridDataDict: - """ - Aggregate data over layers into planar dataset. - - Returns - ------- - dict - Dict of aggregated data arrays, where the keys are the variable - names and the values are aggregated across the "layer" dimension. - """ - aggr_methods = cls._get_aggregate_methods() - if isinstance(aggr_methods, EmptyAggregationMethod): - raise TypeError( - f"Aggregation methods for {cls._pkg_id} package are not defined." - ) - aggr_methods_dict = asdict(aggr_methods) - planar_data = { - key: dataset[key].reduce(func, dim="layer") - for key, func in aggr_methods_dict.items() - if key in dataset.data_vars - } - return planar_data +import abc +from copy import deepcopy +from dataclasses import asdict +from typing import Optional, Self, cast + +from imod.common.utilities.dataclass_type import DataclassType +from imod.mf6.aggregate.aggregate_schemes import EmptyAggregationMethod +from imod.mf6.boundary_condition import BoundaryCondition +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.prepare.topsystem import ( + ALLOCATION_OPTION, + DISTRIBUTING_OPTION, + SimulationAllocationOptions, + SimulationDistributingOptions, +) +from imod.typing import GridDataDict, GridDataset + + +def _handle_reallocate_arguments( + pkg_id: str, + has_conductance: bool, + npf: Optional[NodePropertyFlow], + allocation_option: Optional[ALLOCATION_OPTION], + distributing_option: Optional[DISTRIBUTING_OPTION], +) -> tuple[ALLOCATION_OPTION, Optional[DISTRIBUTING_OPTION]]: + if allocation_option is None: + allocation_option = asdict(SimulationAllocationOptions())[pkg_id] + elif allocation_option == ALLOCATION_OPTION.stage_to_riv_bot_drn_above: + raise ValueError( + f"Allocation option {allocation_option} is not supported for " + "reallocation of boundary conditions." + ) + if has_conductance and distributing_option is None: + distributing_option = asdict(SimulationDistributingOptions())[pkg_id] + if has_conductance and npf is None: + raise ValueError( + "NodePropertyFlow must be provided for packages with conductance variable." + ) + return allocation_option, distributing_option + + +class TopSystemBoundaryCondition(BoundaryCondition, abc.ABC): + """ + Base class to add some extra functionality for topsystem packages, such as + RCH, DRN, RIV, and GHB. + """ + + _aggregate_method: DataclassType = EmptyAggregationMethod() + + def reallocate( + self, + dis: StructuredDiscretization | VerticesDiscretization, + npf: Optional[NodePropertyFlow] = None, + allocation_option: Optional[ALLOCATION_OPTION] = None, + distributing_option: Optional[DISTRIBUTING_OPTION] = None, + drop_empty_layers: bool = True, + ) -> Self: + """ + Reallocates topsystem data across layers and create new package with it. + Aggregate data to planar data first, by taking either the mean for state + variables (e.g. river stage), or the sum for fluxes and the + conductance. Consequently allocate and distribute the planar data to the + provided model layer schematization. + + Parameters + ---------- + dis : StructuredDiscretization | VerticesDiscretization + The discretization of the model to which the data should be + reallocated. + npf : NodePropertyFlow, optional + The node property flow package of the model to which the conductance + should be distributed (if applicable). Required for packages with a + conductance variable. + allocation_option : ALLOCATION_OPTION, optional + The allocation option to use for the reallocation. If None, the + default allocation option is taken from + :class:`imod.prepare.SimulationAllocationOptions`. + distributing_option : DISTRIBUTING_OPTION, optional + The distributing option to use for the reallocation. Required for + packages with a conductance variable. If None, the default is taken + from :class:`imod.prepare.SimulationDistributingOptions`. + drop_empty_layers : bool, default True + If True, drop layers from the resulting package that contain no + allocated cells anywhere in the domain. Allocation and + distribution are always computed over the full layer range + first; layers are only trimmed off the final result, so this + does not affect the computed values, only the package's layer + coordinate. + + Returns + ------- + BoundaryCondition + A new instance of the boundary condition class with the reallocated + data. The original instance remains unchanged. + """ + # Handle input arguments + has_conductance = "conductance" in self.dataset.data_vars + allocation_option, distributing_option = _handle_reallocate_arguments( + self._pkg_id, has_conductance, npf, allocation_option, distributing_option + ) + # Aggregate data to planar data first + planar_data = self.aggregate_layers(self.dataset) + # Then allocate and distribute the planar data to the model layers + if has_conductance: + npf = cast(NodePropertyFlow, npf) + distributing_option = cast(DISTRIBUTING_OPTION, distributing_option) + grid_dict = self._allocate_and_distribute_planar_data( + planar_data, + dis, + npf, + allocation_option, + distributing_option, + drop_empty_layers, + ) + else: + grid_dict = self._allocate_planar_data( + planar_data, dis, allocation_option, drop_empty_layers + ) + # River package returns a tuple (second argument can also be Drainage + # package) + if isinstance(grid_dict, tuple): + grid_dict, _ = grid_dict + options = self._get_unfiltered_pkg_options({}) + data_dict = grid_dict | options + return self.__class__(**data_dict) + + @classmethod + def _allocate_and_distribute_planar_data( + cls, + planar_data: GridDataDict, + dis: StructuredDiscretization | VerticesDiscretization, + npf: NodePropertyFlow, + allocation_option: ALLOCATION_OPTION, + distributing_option: DISTRIBUTING_OPTION, + drop_empty_layers: bool = True, + ) -> tuple[GridDataDict, GridDataDict] | GridDataDict: + raise NotImplementedError( + "This method should be implemented in the specific boundary condition " + "class that inherits from BoundaryCondition." + ) + + @classmethod + def _allocate_planar_data( + cls, + planar_data: GridDataDict, + dis: StructuredDiscretization | VerticesDiscretization, + allocation_option: ALLOCATION_OPTION, + drop_empty_layers: bool = True, + ) -> tuple[GridDataDict, GridDataDict] | GridDataDict: + raise NotImplementedError( + "This method should be implemented in the specific boundary condition " + "class that inherits from BoundaryCondition." + ) + + @classmethod + def _get_aggregate_methods(cls) -> DataclassType: + """ + Returns the aggregation methods used for aggregating data over layers + into planar data. + + Returns + ------- + DataclassType + The aggregation methods used for the package. + """ + return deepcopy(cls._aggregate_method) + + @classmethod + def aggregate_layers(cls, dataset: GridDataset) -> GridDataDict: + """ + Aggregate data over layers into planar dataset. + + Returns + ------- + dict + Dict of aggregated data arrays, where the keys are the variable + names and the values are aggregated across the "layer" dimension. + """ + aggr_methods = cls._get_aggregate_methods() + if isinstance(aggr_methods, EmptyAggregationMethod): + raise TypeError( + f"Aggregation methods for {cls._pkg_id} package are not defined." + ) + aggr_methods_dict = asdict(aggr_methods) + planar_data = { + key: dataset[key].reduce(func, dim="layer") + for key, func in aggr_methods_dict.items() + if key in dataset.data_vars + } + return planar_data diff --git a/imod/prepare/cleanup.py b/imod/prepare/cleanup.py index fcb78984a..f49a02c21 100644 --- a/imod/prepare/cleanup.py +++ b/imod/prepare/cleanup.py @@ -1,409 +1,409 @@ -"""Cleanup utilities""" - -from enum import Enum -from typing import Optional - -import pandas as pd -import xarray as xr - -from imod.common.utilities.clip import clip_line_gdf_by_grid -from imod.common.utilities.mask import mask_arrays -from imod.prepare.wells import locate_wells, validate_well_columnnames -from imod.schemata import scalar_None -from imod.typing import GeoDataFrameType, GridDataArray - - -class AlignLevelsMode(Enum): - TOPDOWN = 0 - BOTTOMUP = 1 - - -def align_nodata(grids: dict[str, xr.DataArray]) -> dict[str, xr.DataArray]: - return mask_arrays(grids) - - -def align_interface_levels( - top: GridDataArray, - bottom: GridDataArray, - method: AlignLevelsMode = AlignLevelsMode.TOPDOWN, -) -> tuple[GridDataArray, GridDataArray]: - # `bottom` (e.g. a model's full layer range) may have more layers than - # `top` (e.g. a package trimmed to only its allocated layers, see - # ``drop_empty_layers`` in ``imod.prepare.topsystem``). Reindex `bottom` - # down to `top`'s own layers first, so the comparison below doesn't fail - # with an alignment error; `top`'s layers are always the ones we want to - # keep, matching the ``join="left"`` pattern used in - # ``imod.common.utilities.mask.mask_da``. - if "layer" in top.dims and "layer" in bottom.dims: - bottom = bottom.sel(layer=top["layer"]) - - to_align = top < bottom - - match method: - case AlignLevelsMode.BOTTOMUP: - return top.where(~to_align, bottom), bottom - case AlignLevelsMode.TOPDOWN: - return top, bottom.where(~to_align, top) - case _: - raise TypeError(f"Unmatched case for method, got {method}") - - -def _cleanup_robin_boundary( - idomain: GridDataArray, grids: dict[str, GridDataArray] -) -> dict[str, GridDataArray]: - """Cleanup robin boundary condition (i.e. bc with conductance)""" - active = idomain > 0 - # Deactivate conductance cells outside active domain; this nodata - # inconsistency will be aligned in the final call to align_nodata - conductance = grids["conductance"].where(active) - concentration = grids["concentration"] - # Make conductance cells with erronous values inactive - grids["conductance"] = conductance.where(conductance > 0.0) - # Clip negative concentration cells to 0.0 - if (concentration is not None) and not scalar_None(concentration): - grids["concentration"] = concentration.clip(min=0.0) - else: - grids.pop("concentration") - - # Align nodata - return align_nodata(grids) - - -def cleanup_riv( - idomain: GridDataArray, - bottom: GridDataArray, - stage: GridDataArray, - conductance: GridDataArray, - bottom_elevation: GridDataArray, - concentration: Optional[GridDataArray] = None, -) -> dict[str, GridDataArray]: - """ - Clean up river data, fixes some common mistakes causing ValidationErrors by - doing the following: - - - Cells where conductance <= 0 are deactivated. - - Cells where concentration < 0 are set to 0.0. - - Cells outside active domain (idomain==1) are removed. - - Align NoData: If one variable has an inactive cell in one cell, ensure - this cell is deactivated for all variables. - - River bottom elevations below model bottom of a layer are set to model - bottom of that layer. - - River bottom elevations which exceed river stage are lowered to river - stage. - - Parameters - ---------- - idomain: xarray.DataArray | xugrid.UgridDataArray - MODFLOW 6 model domain. idomain==1 is considered active domain. - bottom: xarray.DataArray | xugrid.UgridDataArray - Grid with model bottoms - stage: xarray.DataArray | xugrid.UgridDataArray - Grid with river stages - conductance: xarray.DataArray | xugrid.UgridDataArray - Grid with conductances - bottom_elevation: xarray.DataArray | xugrid.UgridDataArray - Grid with river bottom elevations - concentration: xarray.DataArray | xugrid.UgridDataArray, optional - Optional grid with concentrations - - Returns - ------- - dict[str, xarray.DataArray | xugrid.UgridDataArray] - Dict of cleaned up grids. Has keys: "stage", "conductance", - "bottom_elevation", "concentration". - """ - # Output dict - output_dict = { - "stage": stage, - "conductance": conductance, - "bottom_elevation": bottom_elevation, - "concentration": concentration, - } - output_dict = _cleanup_robin_boundary(idomain, output_dict) - if (output_dict["stage"] < bottom).any(): - raise ValueError( - "River stage below bottom of model layer, cannot fix this. " - "Probably rivers are assigned to the wrong layer, you can reallocate " - "river data to model layers with: " - "``imod.prepare.topsystem.allocate_riv_cells``." - ) - # Ensure bottom elevation above model bottom - output_dict["bottom_elevation"], _ = align_interface_levels( - output_dict["bottom_elevation"], bottom, AlignLevelsMode.BOTTOMUP - ) - # Ensure stage above bottom_elevation - output_dict["stage"], output_dict["bottom_elevation"] = align_interface_levels( - output_dict["stage"], output_dict["bottom_elevation"], AlignLevelsMode.TOPDOWN - ) - return output_dict - - -def cleanup_drn( - idomain: GridDataArray, - elevation: GridDataArray, - conductance: GridDataArray, - concentration: Optional[GridDataArray] = None, -) -> dict[str, GridDataArray]: - """ - Clean up drain data, fixes some common mistakes causing ValidationErrors by - doing the following: - - - Cells where conductance <= 0 are deactivated. - - Cells where concentration < 0 are set to 0.0. - - Cells outside active domain (idomain==1) are removed. - - Align NoData: If one variable has an inactive cell in one cell, ensure - this cell is deactivated for all variables. - - Parameters - ---------- - idomain: xarray.DataArray | xugrid.UgridDataArray - MODFLOW 6 model domain. idomain==1 is considered active domain. - elevation: xarray.DataArray | xugrid.UgridDataArray - Grid with drain elevations - conductance: xarray.DataArray | xugrid.UgridDataArray - Grid with conductances - concentration: xarray.DataArray | xugrid.UgridDataArray, optional - Optional grid with concentrations - - Returns - ------- - dict[str, xarray.DataArray | xugrid.UgridDataArray] - Dict of cleaned up grids. Has keys: "elevation", "conductance", - "concentration". - """ - # Output dict - output_dict = { - "elevation": elevation, - "conductance": conductance, - "concentration": concentration, - } - return _cleanup_robin_boundary(idomain, output_dict) - - -def cleanup_ghb( - idomain: GridDataArray, - head: GridDataArray, - conductance: GridDataArray, - concentration: Optional[GridDataArray] = None, -) -> dict[str, GridDataArray]: - """ - Clean up general head boundary data, fixes some common mistakes causing - ValidationErrors by doing the following: - - - Cells where conductance <= 0 are deactivated. - - Cells where concentration < 0 are set to 0.0. - - Cells outside active domain (idomain==1) are removed. - - Align NoData: If one variable has an inactive cell in one cell, ensure - this cell is deactivated for all variables. - - Parameters - ---------- - idomain: xarray.DataArray | xugrid.UgridDataArray - MODFLOW 6 model domain. idomain==1 is considered active domain. - head: xarray.DataArray | xugrid.UgridDataArray - Grid with heads - conductance: xarray.DataArray | xugrid.UgridDataArray - Grid with conductances - concentration: xarray.DataArray | xugrid.UgridDataArray, optional - Optional grid with concentrations - - Returns - ------- - dict[str, xarray.DataArray | xugrid.UgridDataArray] - Dict of cleaned up grids. Has keys: "head", "conductance", - "concentration". - """ - # Output dict - output_dict = { - "head": head, - "conductance": conductance, - "concentration": concentration, - } - return _cleanup_robin_boundary(idomain, output_dict) - - -def _locate_wells_in_bounds( - wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray -) -> tuple[pd.DataFrame, pd.Series, pd.Series]: - """ - Locate wells in model bounds, wells outside bounds are dropped. Returned - dataframes and series have well "id" as index. - - Returns - ------- - wells_in_bounds: pd.DataFrame - wells in model boundaries. Has "id" as index. - xy_top_series: pd.Series - model top at well xy location. Has "id" as index. - xy_base_series: pd.Series - model base at well xy location. Has "id" as index. - """ - id_in_bounds, xy_top, xy_bottom, _ = locate_wells( - wells, top, bottom, validate=False - ) - xy_base_model = xy_bottom.isel(layer=-1, drop=True) - - # Assign id as coordinates - xy_top = xy_top.assign_coords(id=("index", id_in_bounds)) - xy_base_model = xy_base_model.assign_coords(id=("index", id_in_bounds)) - # Create pandas dataframes/series with "id" as index. - xy_top_series = xy_top.to_dataframe(name="top").set_index("id")["top"] - xy_base_series = xy_base_model.to_dataframe(name="bottom").set_index("id")["bottom"] - wells_in_bounds = wells.set_index("id").loc[id_in_bounds] - return wells_in_bounds, xy_top_series, xy_base_series - - -def _clip_filter_screen_to_surface_level( - cleaned_wells: pd.DataFrame, xy_top_series: pd.Series -) -> pd.DataFrame: - cleaned_wells["screen_top"] = cleaned_wells["screen_top"].clip(upper=xy_top_series) - return cleaned_wells - - -def _drop_wells_below_model_base( - cleaned_wells: pd.DataFrame, xy_base_series: pd.Series -) -> pd.DataFrame: - is_below_base = cleaned_wells["screen_top"] >= xy_base_series - return cleaned_wells.loc[is_below_base] - - -def _clip_filter_bottom_to_model_base( - cleaned_wells: pd.DataFrame, xy_base_series: pd.Series -) -> pd.DataFrame: - cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip( - lower=xy_base_series - ) - return cleaned_wells - - -def _set_inverted_filters_to_point_filters(cleaned_wells: pd.DataFrame) -> pd.DataFrame: - # Convert all filters where screen bottom exceeds screen top to - # point filters - cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip( - upper=cleaned_wells["screen_top"] - ) - return cleaned_wells - - -def _set_ultrathin_filters_to_point_filters( - cleaned_wells: pd.DataFrame, minimum_thickness: float -) -> pd.DataFrame: - not_ultrathin_layer = ( - cleaned_wells["screen_top"] - cleaned_wells["screen_bottom"] - ) > minimum_thickness - cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].where( - not_ultrathin_layer, cleaned_wells["screen_top"] - ) - return cleaned_wells - - -def cleanup_wel_layered( - wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray -) -> pd.DataFrame: - """ - Clean up dataframe with wells, fixes some common mistakes in the following - order: - - 1. Wells outside grid bounds are dropped - - Parameters - ---------- - wells: pandas.Dataframe - Dataframe with wells to be cleaned up. Requires columns ``"x", "y", - "id"`` - top: xarray.DataArray | xugrid.UgridDataArray - Grid with model top - bottom: xarray.DataArray | xugrid.UgridDataArray - Grid with model bottoms - - Returns - ------- - pandas.DataFrame - Cleaned well dataframe. - """ - validate_well_columnnames(wells, names={"x", "y", "id"}) - - cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds( - wells, top, bottom - ) - return cleaned_wells - - -def cleanup_wel( - wells: pd.DataFrame, - top: GridDataArray, - bottom: GridDataArray, - minimum_thickness: float = 0.05, -) -> pd.DataFrame: - """ - Clean up dataframe with wells, fixes some common mistakes in the following - order: - - 1. Wells outside grid bounds are dropped - 2. Filters above surface level are set to surface level - 3. Drop wells with filters entirely below base - 4. Clip filter screen_bottom to model base - 5. Clip filter screen_bottom to screen_top - 6. Well filters thinner than minimum thickness are made point filters - - Parameters - ---------- - wells: pandas.Dataframe - Dataframe with wells to be cleaned up. Requires columns ``"x", "y", - "id", "screen_top", "screen_bottom"`` - top: xarray.DataArray | xugrid.UgridDataArray - Grid with model top - bottom: xarray.DataArray | xugrid.UgridDataArray - Grid with model bottoms - minimum_thickness: float - Minimum thickness, filter thinner than this thickness are set to point - filters - - Returns - ------- - pandas.DataFrame - Cleaned well dataframe. - """ - validate_well_columnnames( - wells, names={"x", "y", "id", "screen_top", "screen_bottom"} - ) - - cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds( - wells, top, bottom - ) - cleaned_wells = _clip_filter_screen_to_surface_level(cleaned_wells, xy_top_series) - cleaned_wells = _drop_wells_below_model_base(cleaned_wells, xy_base_series) - cleaned_wells = _clip_filter_bottom_to_model_base(cleaned_wells, xy_base_series) - cleaned_wells = _set_inverted_filters_to_point_filters(cleaned_wells) - cleaned_wells = _set_ultrathin_filters_to_point_filters( - cleaned_wells, minimum_thickness - ) - return cleaned_wells - - -def cleanup_hfb( - barrier: GeoDataFrameType, idomain_2d: GridDataArray -) -> GeoDataFrameType: - """ - Clean up HFB data, fixes some common mistakes causing ValidationErrors by - doing the following: - - - Drop HFB segments outside active domain (idomain==1) - - Parameters - ---------- - barrier: geopandas.GeoDataFrame - GeoDataFrame with HFB data - idomain_2d: xarray.DataArray | xugrid.UgridDataArray - MODFLOW 6 model domain of a single layer. idomain==1 is considered active domain. - - Returns - ------- - geopandas.GeoDataFrame - Cleaned up GeoDataFrame with HFB data. - """ - - active = idomain_2d > 0 - # Drop HFB cells outside active domain - clipped_barrier = clip_line_gdf_by_grid(barrier, active) - return clipped_barrier +"""Cleanup utilities""" + +from enum import Enum +from typing import Optional + +import pandas as pd +import xarray as xr + +from imod.common.utilities.clip import clip_line_gdf_by_grid +from imod.common.utilities.mask import mask_arrays +from imod.prepare.wells import locate_wells, validate_well_columnnames +from imod.schemata import scalar_None +from imod.typing import GeoDataFrameType, GridDataArray + + +class AlignLevelsMode(Enum): + TOPDOWN = 0 + BOTTOMUP = 1 + + +def align_nodata(grids: dict[str, xr.DataArray]) -> dict[str, xr.DataArray]: + return mask_arrays(grids) + + +def align_interface_levels( + top: GridDataArray, + bottom: GridDataArray, + method: AlignLevelsMode = AlignLevelsMode.TOPDOWN, +) -> tuple[GridDataArray, GridDataArray]: + # `bottom` (e.g. a model's full layer range) may have more layers than + # `top` (e.g. a package trimmed to only its allocated layers, see + # ``drop_empty_layers`` in ``imod.prepare.topsystem``). Reindex `bottom` + # down to `top`'s own layers first, so the comparison below doesn't fail + # with an alignment error; `top`'s layers are always the ones we want to + # keep, matching the ``join="left"`` pattern used in + # ``imod.common.utilities.mask.mask_da``. + if "layer" in top.dims and "layer" in bottom.dims: + bottom = bottom.sel(layer=top["layer"]) + + to_align = top < bottom + + match method: + case AlignLevelsMode.BOTTOMUP: + return top.where(~to_align, bottom), bottom + case AlignLevelsMode.TOPDOWN: + return top, bottom.where(~to_align, top) + case _: + raise TypeError(f"Unmatched case for method, got {method}") + + +def _cleanup_robin_boundary( + idomain: GridDataArray, grids: dict[str, GridDataArray] +) -> dict[str, GridDataArray]: + """Cleanup robin boundary condition (i.e. bc with conductance)""" + active = idomain > 0 + # Deactivate conductance cells outside active domain; this nodata + # inconsistency will be aligned in the final call to align_nodata + conductance = grids["conductance"].where(active) + concentration = grids["concentration"] + # Make conductance cells with erronous values inactive + grids["conductance"] = conductance.where(conductance > 0.0) + # Clip negative concentration cells to 0.0 + if (concentration is not None) and not scalar_None(concentration): + grids["concentration"] = concentration.clip(min=0.0) + else: + grids.pop("concentration") + + # Align nodata + return align_nodata(grids) + + +def cleanup_riv( + idomain: GridDataArray, + bottom: GridDataArray, + stage: GridDataArray, + conductance: GridDataArray, + bottom_elevation: GridDataArray, + concentration: Optional[GridDataArray] = None, +) -> dict[str, GridDataArray]: + """ + Clean up river data, fixes some common mistakes causing ValidationErrors by + doing the following: + + - Cells where conductance <= 0 are deactivated. + - Cells where concentration < 0 are set to 0.0. + - Cells outside active domain (idomain==1) are removed. + - Align NoData: If one variable has an inactive cell in one cell, ensure + this cell is deactivated for all variables. + - River bottom elevations below model bottom of a layer are set to model + bottom of that layer. + - River bottom elevations which exceed river stage are lowered to river + stage. + + Parameters + ---------- + idomain: xarray.DataArray | xugrid.UgridDataArray + MODFLOW 6 model domain. idomain==1 is considered active domain. + bottom: xarray.DataArray | xugrid.UgridDataArray + Grid with model bottoms + stage: xarray.DataArray | xugrid.UgridDataArray + Grid with river stages + conductance: xarray.DataArray | xugrid.UgridDataArray + Grid with conductances + bottom_elevation: xarray.DataArray | xugrid.UgridDataArray + Grid with river bottom elevations + concentration: xarray.DataArray | xugrid.UgridDataArray, optional + Optional grid with concentrations + + Returns + ------- + dict[str, xarray.DataArray | xugrid.UgridDataArray] + Dict of cleaned up grids. Has keys: "stage", "conductance", + "bottom_elevation", "concentration". + """ + # Output dict + output_dict = { + "stage": stage, + "conductance": conductance, + "bottom_elevation": bottom_elevation, + "concentration": concentration, + } + output_dict = _cleanup_robin_boundary(idomain, output_dict) + if (output_dict["stage"] < bottom).any(): + raise ValueError( + "River stage below bottom of model layer, cannot fix this. " + "Probably rivers are assigned to the wrong layer, you can reallocate " + "river data to model layers with: " + "``imod.prepare.topsystem.allocate_riv_cells``." + ) + # Ensure bottom elevation above model bottom + output_dict["bottom_elevation"], _ = align_interface_levels( + output_dict["bottom_elevation"], bottom, AlignLevelsMode.BOTTOMUP + ) + # Ensure stage above bottom_elevation + output_dict["stage"], output_dict["bottom_elevation"] = align_interface_levels( + output_dict["stage"], output_dict["bottom_elevation"], AlignLevelsMode.TOPDOWN + ) + return output_dict + + +def cleanup_drn( + idomain: GridDataArray, + elevation: GridDataArray, + conductance: GridDataArray, + concentration: Optional[GridDataArray] = None, +) -> dict[str, GridDataArray]: + """ + Clean up drain data, fixes some common mistakes causing ValidationErrors by + doing the following: + + - Cells where conductance <= 0 are deactivated. + - Cells where concentration < 0 are set to 0.0. + - Cells outside active domain (idomain==1) are removed. + - Align NoData: If one variable has an inactive cell in one cell, ensure + this cell is deactivated for all variables. + + Parameters + ---------- + idomain: xarray.DataArray | xugrid.UgridDataArray + MODFLOW 6 model domain. idomain==1 is considered active domain. + elevation: xarray.DataArray | xugrid.UgridDataArray + Grid with drain elevations + conductance: xarray.DataArray | xugrid.UgridDataArray + Grid with conductances + concentration: xarray.DataArray | xugrid.UgridDataArray, optional + Optional grid with concentrations + + Returns + ------- + dict[str, xarray.DataArray | xugrid.UgridDataArray] + Dict of cleaned up grids. Has keys: "elevation", "conductance", + "concentration". + """ + # Output dict + output_dict = { + "elevation": elevation, + "conductance": conductance, + "concentration": concentration, + } + return _cleanup_robin_boundary(idomain, output_dict) + + +def cleanup_ghb( + idomain: GridDataArray, + head: GridDataArray, + conductance: GridDataArray, + concentration: Optional[GridDataArray] = None, +) -> dict[str, GridDataArray]: + """ + Clean up general head boundary data, fixes some common mistakes causing + ValidationErrors by doing the following: + + - Cells where conductance <= 0 are deactivated. + - Cells where concentration < 0 are set to 0.0. + - Cells outside active domain (idomain==1) are removed. + - Align NoData: If one variable has an inactive cell in one cell, ensure + this cell is deactivated for all variables. + + Parameters + ---------- + idomain: xarray.DataArray | xugrid.UgridDataArray + MODFLOW 6 model domain. idomain==1 is considered active domain. + head: xarray.DataArray | xugrid.UgridDataArray + Grid with heads + conductance: xarray.DataArray | xugrid.UgridDataArray + Grid with conductances + concentration: xarray.DataArray | xugrid.UgridDataArray, optional + Optional grid with concentrations + + Returns + ------- + dict[str, xarray.DataArray | xugrid.UgridDataArray] + Dict of cleaned up grids. Has keys: "head", "conductance", + "concentration". + """ + # Output dict + output_dict = { + "head": head, + "conductance": conductance, + "concentration": concentration, + } + return _cleanup_robin_boundary(idomain, output_dict) + + +def _locate_wells_in_bounds( + wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray +) -> tuple[pd.DataFrame, pd.Series, pd.Series]: + """ + Locate wells in model bounds, wells outside bounds are dropped. Returned + dataframes and series have well "id" as index. + + Returns + ------- + wells_in_bounds: pd.DataFrame + wells in model boundaries. Has "id" as index. + xy_top_series: pd.Series + model top at well xy location. Has "id" as index. + xy_base_series: pd.Series + model base at well xy location. Has "id" as index. + """ + id_in_bounds, xy_top, xy_bottom, _ = locate_wells( + wells, top, bottom, validate=False + ) + xy_base_model = xy_bottom.isel(layer=-1, drop=True) + + # Assign id as coordinates + xy_top = xy_top.assign_coords(id=("index", id_in_bounds)) + xy_base_model = xy_base_model.assign_coords(id=("index", id_in_bounds)) + # Create pandas dataframes/series with "id" as index. + xy_top_series = xy_top.to_dataframe(name="top").set_index("id")["top"] + xy_base_series = xy_base_model.to_dataframe(name="bottom").set_index("id")["bottom"] + wells_in_bounds = wells.set_index("id").loc[id_in_bounds] + return wells_in_bounds, xy_top_series, xy_base_series + + +def _clip_filter_screen_to_surface_level( + cleaned_wells: pd.DataFrame, xy_top_series: pd.Series +) -> pd.DataFrame: + cleaned_wells["screen_top"] = cleaned_wells["screen_top"].clip(upper=xy_top_series) + return cleaned_wells + + +def _drop_wells_below_model_base( + cleaned_wells: pd.DataFrame, xy_base_series: pd.Series +) -> pd.DataFrame: + is_below_base = cleaned_wells["screen_top"] >= xy_base_series + return cleaned_wells.loc[is_below_base] + + +def _clip_filter_bottom_to_model_base( + cleaned_wells: pd.DataFrame, xy_base_series: pd.Series +) -> pd.DataFrame: + cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip( + lower=xy_base_series + ) + return cleaned_wells + + +def _set_inverted_filters_to_point_filters(cleaned_wells: pd.DataFrame) -> pd.DataFrame: + # Convert all filters where screen bottom exceeds screen top to + # point filters + cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip( + upper=cleaned_wells["screen_top"] + ) + return cleaned_wells + + +def _set_ultrathin_filters_to_point_filters( + cleaned_wells: pd.DataFrame, minimum_thickness: float +) -> pd.DataFrame: + not_ultrathin_layer = ( + cleaned_wells["screen_top"] - cleaned_wells["screen_bottom"] + ) > minimum_thickness + cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].where( + not_ultrathin_layer, cleaned_wells["screen_top"] + ) + return cleaned_wells + + +def cleanup_wel_layered( + wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray +) -> pd.DataFrame: + """ + Clean up dataframe with wells, fixes some common mistakes in the following + order: + + 1. Wells outside grid bounds are dropped + + Parameters + ---------- + wells: pandas.Dataframe + Dataframe with wells to be cleaned up. Requires columns ``"x", "y", + "id"`` + top: xarray.DataArray | xugrid.UgridDataArray + Grid with model top + bottom: xarray.DataArray | xugrid.UgridDataArray + Grid with model bottoms + + Returns + ------- + pandas.DataFrame + Cleaned well dataframe. + """ + validate_well_columnnames(wells, names={"x", "y", "id"}) + + cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds( + wells, top, bottom + ) + return cleaned_wells + + +def cleanup_wel( + wells: pd.DataFrame, + top: GridDataArray, + bottom: GridDataArray, + minimum_thickness: float = 0.05, +) -> pd.DataFrame: + """ + Clean up dataframe with wells, fixes some common mistakes in the following + order: + + 1. Wells outside grid bounds are dropped + 2. Filters above surface level are set to surface level + 3. Drop wells with filters entirely below base + 4. Clip filter screen_bottom to model base + 5. Clip filter screen_bottom to screen_top + 6. Well filters thinner than minimum thickness are made point filters + + Parameters + ---------- + wells: pandas.Dataframe + Dataframe with wells to be cleaned up. Requires columns ``"x", "y", + "id", "screen_top", "screen_bottom"`` + top: xarray.DataArray | xugrid.UgridDataArray + Grid with model top + bottom: xarray.DataArray | xugrid.UgridDataArray + Grid with model bottoms + minimum_thickness: float + Minimum thickness, filter thinner than this thickness are set to point + filters + + Returns + ------- + pandas.DataFrame + Cleaned well dataframe. + """ + validate_well_columnnames( + wells, names={"x", "y", "id", "screen_top", "screen_bottom"} + ) + + cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds( + wells, top, bottom + ) + cleaned_wells = _clip_filter_screen_to_surface_level(cleaned_wells, xy_top_series) + cleaned_wells = _drop_wells_below_model_base(cleaned_wells, xy_base_series) + cleaned_wells = _clip_filter_bottom_to_model_base(cleaned_wells, xy_base_series) + cleaned_wells = _set_inverted_filters_to_point_filters(cleaned_wells) + cleaned_wells = _set_ultrathin_filters_to_point_filters( + cleaned_wells, minimum_thickness + ) + return cleaned_wells + + +def cleanup_hfb( + barrier: GeoDataFrameType, idomain_2d: GridDataArray +) -> GeoDataFrameType: + """ + Clean up HFB data, fixes some common mistakes causing ValidationErrors by + doing the following: + + - Drop HFB segments outside active domain (idomain==1) + + Parameters + ---------- + barrier: geopandas.GeoDataFrame + GeoDataFrame with HFB data + idomain_2d: xarray.DataArray | xugrid.UgridDataArray + MODFLOW 6 model domain of a single layer. idomain==1 is considered active domain. + + Returns + ------- + geopandas.GeoDataFrame + Cleaned up GeoDataFrame with HFB data. + """ + + active = idomain_2d > 0 + # Drop HFB cells outside active domain + clipped_barrier = clip_line_gdf_by_grid(barrier, active) + return clipped_barrier diff --git a/imod/tests/test_mf6/test_mf6_drn.py b/imod/tests/test_mf6/test_mf6_drn.py index 417450e10..092f49970 100644 --- a/imod/tests/test_mf6/test_mf6_drn.py +++ b/imod/tests/test_mf6/test_mf6_drn.py @@ -1,814 +1,814 @@ -import pathlib -import textwrap -from datetime import datetime - -import numpy as np -import pandas as pd -import pytest -import xarray as xr -from pytest_cases import parametrize_with_cases - -import imod -from imod.common.utilities.version import get_version -from imod.logging import LoggerType, LogLevel -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.utilities.package import get_repeat_stress -from imod.mf6.write_context import WriteContext -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION -from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION -from imod.prepare.topsystem.default_allocation_methods import ( - SimulationAllocationOptions, - SimulationDistributingOptions, -) -from imod.schemata import ValidationError - - -@pytest.fixture(scope="function") -def 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"), - ) - conductance = elevation.copy() - - drn = {"elevation": elevation, "conductance": conductance} - return drn - - -@pytest.fixture(scope="function") -def transient_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",), - ) - conductance = time_multiplier * elevation - - drn = {"elevation": elevation, "conductance": conductance} - 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 = { - "elevation": elevation, - "conductance": conductance, - "concentration": concentration, - } - return drn - - -def test_write(drainage, tmp_path): - imod.logging.configure( - LoggerType.PYTHON, - log_level=LogLevel.DEBUG, - add_default_file_handler=True, - add_default_stream_handler=False, - ) - - drn = imod.mf6.Drainage(**drainage) - write_context = WriteContext(simulation_directory=tmp_path, use_binary=True) - drn._write("mydrn", [1], write_context) - - version = get_version() - block_expected = textwrap.dedent( - f"""\ - # File written with iMOD Python version: {version} - - begin options - end options - - begin dimensions - maxbound 75 - end dimensions - - begin period 1 - open/close mydrn/drn.bin (binary) - end period - """ - ) - - with open(tmp_path / "mydrn.drn") as f: - block = f.read() - - assert block == block_expected - - -def test_wrong_dtype(drainage): - drainage["elevation"] = drainage["elevation"].astype(np.int32) - - with pytest.raises(ValidationError): - imod.mf6.Drainage(**drainage) - - -def test_wrong_layer_coord(drainage): - ds = xr.merge([drainage], join="exact") - ds = ds.assign_coords(layer=[0, 1, 2]) - - with pytest.raises(ValidationError): - imod.mf6.Drainage(**ds) - - -def test_validate_false(drainage): - drainage["elevation"] = drainage["elevation"].astype(np.int32) - - imod.mf6.Drainage(validate=False, **drainage) - - -def test_check_conductance_zero(drainage): - drainage["conductance"] = drainage["conductance"] * 0.0 - - idomain = 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(**drainage) - errors = drn._validate(drn._write_schemata, **dis.dataset) - assert len(errors) == 1 - for var, error in errors.items(): - assert var == "conductance" - - -@pytest.mark.parametrize("nodata_idomain", [0, -1]) -def test_validate_inside_nodata(drainage, nodata_idomain): - idomain = drainage["elevation"].astype(np.int16) - top = 1.0 - bottom = top - idomain.coords["layer"] - - idomain[:, 2, 2] = nodata_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 == "elevation" - - -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 == "concentration" - - -def test_discontinuous_layer(drainage): - drn = imod.mf6.Drainage(**drainage) - drn["layer"] = [1, 3, 5] - bin_ds = drn[list(drn._period_data)] - layer = bin_ds["layer"].values - arrdict = drn._ds_to_arrdict(bin_ds) - struct_array = drn._to_struct_array(arrdict, layer) - assert np.array_equal(np.unique(struct_array["layer"]), [1, 3, 5]) - - -def test_3d_singelayer(): - # Introduced because of Issue #224 - layer = [1] - y = np.arange(4.5, 0.0, -1.0) - x = np.arange(0.5, 5.0, 1.0) - elevation = xr.DataArray( - np.full((1, 5, 5), 1.0), - coords={"layer": layer, "y": y, "x": x, "dx": 1.0, "dy": -1.0}, - dims=("layer", "y", "x"), - ) - conductance = elevation.copy() - drn = imod.mf6.Drainage(elevation=elevation, conductance=conductance) - - bin_ds = drn[list(drn._period_data)] - layer = bin_ds["layer"].values - arrdict = drn._ds_to_arrdict(bin_ds) - struct_array = drn._to_struct_array(arrdict, layer) - assert isinstance(struct_array, np.ndarray) - - -def test_aggregate_layers(drainage): - river = imod.mf6.Drainage(**drainage) - - planar_dict = river.aggregate_layers(river.dataset) - assert isinstance(planar_dict, dict) - for value in planar_dict.values(): - assert isinstance(value, xr.DataArray) - assert "layer" not in value.dims - assert "layer" not in value.coords - - # Conductance should be summed, stage averaged - assert not (planar_dict["elevation"] > planar_dict["conductance"]).any() - - -def test_render_concentration( - concentration_fc, - elevation_fc, - conductance_fc, -): - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64[ns]", - ) - - drn = imod.mf6.Drainage( - elevation=elevation_fc, - conductance=conductance_fc, - concentration=concentration_fc, - concentration_boundary_type="AUX", - ) - - actual = drn._render(directory, "drn", globaltimes, False) - - expected = textwrap.dedent( - """\ - begin options - auxiliary salinity temperature - end options - - begin dimensions - maxbound 2 - end dimensions - - begin period 1 - open/close mymodel/drn/drn-0.dat - end period - begin period 2 - open/close mymodel/drn/drn-1.dat - end period - begin period 3 - open/close mymodel/drn/drn-2.dat - end period - """ - ) - assert actual == expected - - -def test_repeat_stress( - elevation_fc, - conductance_fc, -): - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - "2000-01-04", - "2000-01-05", - ], - dtype="datetime64[ns]", - ) - - repeat_stress = xr.DataArray( - [ - [globaltimes[3], globaltimes[0]], - [globaltimes[4], globaltimes[1]], - ], - dims=("repeat", "repeat_items"), - ) - - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 2 - end dimensions - - begin period 1 - open/close mymodel/drn/drn-0.dat - end period - begin period 2 - open/close mymodel/drn/drn-1.dat - end period - begin period 3 - open/close mymodel/drn/drn-2.dat - end period - begin period 4 - open/close mymodel/drn/drn-0.dat - end period - begin period 5 - open/close mymodel/drn/drn-1.dat - end period - """ - ) - - drn = imod.mf6.Drainage( - elevation=elevation_fc, - conductance=conductance_fc, - repeat_stress=repeat_stress, - ) - actual = drn._render(directory, "drn", globaltimes, False) - assert actual == expected - - drn = imod.mf6.Drainage( - elevation=elevation_fc, - conductance=conductance_fc, - ) - drn.dataset["repeat_stress"] = get_repeat_stress( - times={ - globaltimes[3]: globaltimes[0], - globaltimes[4]: globaltimes[1], - }, - ) - actual = drn._render(directory, "drn", globaltimes, False) - assert actual == expected - - drn = imod.mf6.Drainage( - elevation=elevation_fc, - conductance=conductance_fc, - repeat_stress={ - globaltimes[3]: globaltimes[0], - globaltimes[4]: globaltimes[1], - }, - ) - actual = drn._render(directory, "drn", globaltimes, False) - assert actual == expected - - -def test_clip_box(drainage): - drn = imod.mf6.Drainage(**drainage) - - selection = drn.clip_box() - assert isinstance(selection, imod.mf6.Drainage) - assert selection.dataset.identical(drn.dataset) - - selection = drn.clip_box(x_min=None, x_max=None) - assert isinstance(selection, imod.mf6.Drainage) - assert selection.dataset.identical(drn.dataset) - - selection = drn.clip_box( - layer_min=1, - layer_max=2, - y_min=1.0, - y_max=4.0, - x_min=1.0, - x_max=4.0, - ) - assert isinstance(selection, imod.mf6.Drainage) - assert selection["conductance"].dims == ("layer", "y", "x") - assert selection["conductance"].shape == (2, 3, 3) - - -def test_clip_box_transient(transient_drainage): - drn = imod.mf6.Drainage(**transient_drainage) - - # First test the standard case: clip into existing times. - selection = drn.clip_box(time_min="2001-01-01", time_max="2004-01-01") - expected = np.array( - [ - "2001-01-01T00:00:00.000000", - "2002-01-01T00:00:00.000000", - "2003-01-01T00:00:00.000000", - "2004-01-01T00:00:00.000000", - ], - dtype="datetime64[ns]", - ) - assert isinstance(selection, imod.mf6.Drainage) - assert selection["elevation"].dims == ("layer", "y", "x") - assert selection["conductance"].dims == ("time", "layer", "y", "x") - assert np.array_equal(selection.dataset["time"], expected) - - # Now test a succesfull forward fill. - selection = drn.clip_box(time_min="2000-06-01", time_max="2002-06-01") - expected = np.array( - [ - "2000-06-01T00:00:00.000000", - "2001-01-01T00:00:00.000000", - "2002-01-01T00:00:00.000000", - ], - dtype="datetime64[ns]", - ) - assert np.array_equal(selection.dataset["time"], expected) - assert (selection["conductance"].sel(time="2000-06-01") == 1.0).all() - - # And a backfill. - selection = drn.clip_box(time_min="1990-06-01", time_max="2002-06-01") - expected = np.array( - [ - "1990-06-01T00:00:00.000000", - "2000-01-01T00:00:00.000000", - "2001-01-01T00:00:00.000000", - "2002-01-01T00:00:00.000000", - ], - dtype="datetime64[ns]", - ) - assert np.array_equal(selection.dataset["time"].values, expected) - assert (selection["conductance"].sel(time="1990-06-01") == 1.0).all() - assert (selection["conductance"].sel(time="2000-01-01") == 1.0).all() - - -def test_reallocate(drainage): - drn = imod.mf6.Drainage(**drainage) - idomain = drainage["elevation"].astype(np.int16) - top = 1.0 - bottom = top - idomain.coords["layer"] - - dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - allocation_option = ALLOCATION_OPTION.first_active_to_elevation - distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity - # Act - drn_reallocated = drn.reallocate(dis, npf, allocation_option, distributing_option) - # Assert - assert isinstance(drn_reallocated, imod.mf6.Drainage) - assert not drn_reallocated.dataset.equals(drn.dataset) - assert ( - drn_reallocated["conductance"] - .sum("layer") - .equals(drn["conductance"].sum("layer")) - ) - assert ( - drn_reallocated["elevation"] - .mean("layer") - .equals(drn["elevation"].mean("layer")) - ) - - -def test_reallocate_drop_empty_layers(drainage): - """ - drop_empty_layers=True should trim layers off the final package without - changing the values of the layers that remain (Option A: allocation and - conductance distribution always run over the full layer range first). - """ - drn = imod.mf6.Drainage(**drainage) - idomain = drainage["elevation"].astype(np.int16) - top = 1.0 - bottom = top - idomain.coords["layer"] - - dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - allocation_option = ALLOCATION_OPTION.first_active_to_elevation - distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity - - full = drn.reallocate( - dis, npf, allocation_option, distributing_option, drop_empty_layers=False - ) - trimmed = drn.reallocate( - dis, npf, allocation_option, distributing_option, drop_empty_layers=True - ) - - full_layers = full.dataset["layer"].values - trimmed_layers = trimmed.dataset["layer"].values - assert set(trimmed_layers) <= set(full_layers) - assert len(trimmed_layers) < len(full_layers) - np.testing.assert_allclose( - trimmed["conductance"].sel(layer=trimmed_layers).values, - full["conductance"].sel(layer=trimmed_layers).values, - equal_nan=True, - ) - - -def test_repr(drainage): - repr_string = imod.mf6.Drainage(**drainage).__repr__() - assert isinstance(repr_string, str) - assert repr_string.split("\n")[0] == "Drainage" - - -def test_html_repr(drainage): - html_string = imod.mf6.Drainage(**drainage)._repr_html_() - assert isinstance(html_string, str) - assert html_string.split("
")[0] == "
Drainage" - - -class AllocationSettings: - def case_default(self): - return SimulationAllocationOptions.drn, SimulationDistributingOptions.drn - - def case_custom(self): - return ALLOCATION_OPTION.at_elevation, DISTRIBUTING_OPTION.by_crosscut_thickness - - -@pytest.mark.unittest_jit -@parametrize_with_cases( - ["allocation_setting", "distribution_setting"], cases=AllocationSettings -) -def test_from_imod5( - imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting -): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - drn_2 = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=allocation_setting, - distributing_option=distribution_setting, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - assert isinstance(drn_2, imod.mf6.Drainage) - - drn_time = drn_2.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(drn_time, expected_times) - drn_repeat_stress = drn_2.dataset["repeat_stress"].data - assert np.all(drn_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(drn_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - pkg_errors = drn_2._validate( - schemata=drn_2._write_schemata, - idomain=target_dis["idomain"], - bottom=target_dis["bottom"], - ) - assert len(pkg_errors) == 0 - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - drn_2._write("mydrn", [1], write_context) - - -@pytest.mark.unittest_jit -@parametrize_with_cases( - ["allocation_setting", "distribution_setting"], cases=AllocationSettings -) -def test_from_imod5_and_cleanup( - imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting -): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - drn_2 = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=allocation_setting, - distributing_option=distribution_setting, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - drn_2.cleanup(target_dis) - - -@pytest.mark.unittest_jit -@parametrize_with_cases( - ["allocation_setting", "distribution_setting"], cases=AllocationSettings -) -def test_from_imod5__with_constant( - imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting -): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - original_drn_2 = imod5_dataset["drn-2"].copy() - imod5_dataset["drn-2"]["elevation"] = xr.DataArray( - [0.0], dims=("layer",), coords={"layer": [0]} - ) - - drn_2 = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=allocation_setting, - distributing_option=distribution_setting, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - assert isinstance(drn_2, imod.mf6.Drainage) - - pkg_errors = drn_2._validate( - schemata=drn_2._write_schemata, - idomain=target_dis["idomain"], - bottom=target_dis["bottom"], - ) - assert len(pkg_errors) == 0 - - # Tear down - imod5_dataset["drn-2"] = original_drn_2 - - -@pytest.mark.unittest_jit -@parametrize_with_cases( - ["allocation_setting", "distribution_setting"], cases=AllocationSettings -) -def test_from_imod5_and_cleanup__with_constant( - imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting -): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - original_drn_2 = imod5_dataset["drn-2"].copy() - imod5_dataset["drn-2"]["elevation"] = xr.DataArray( - [0.0], dims=("layer",), coords={"layer": [0]} - ) - - drn_2 = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=allocation_setting, - distributing_option=distribution_setting, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - drn_2.cleanup(target_dis) - # Teardown - imod5_dataset["drn-2"] = original_drn_2 - - -@pytest.mark.unittest_jit -def test_from_imod5__negative_layer(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - drn_reference = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=ALLOCATION_OPTION.at_first_active, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - original_drn_2 = imod5_dataset["drn-2"].copy() - imod5_dataset["drn-2"] = { - key: da.assign_coords(layer=[-1]) for key, da in imod5_dataset["drn-2"].items() - } - - drn_negative_layer = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - assert isinstance(drn_negative_layer, imod.mf6.Drainage) - - pkg_errors = drn_negative_layer._validate( - schemata=drn_negative_layer._write_schemata, - idomain=target_dis["idomain"], - bottom=target_dis["bottom"], - ) - assert len(pkg_errors) == 0 - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - drn_negative_layer._write("mydrn", [1], write_context) - - assert drn_negative_layer.dataset.identical(drn_reference.dataset) - - # Tear down - imod5_dataset["drn-2"] = original_drn_2 - - -@pytest.mark.unittest_jit -def test_from_imod5_and_cleanup__negative_layer(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - drn_reference = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=ALLOCATION_OPTION.at_first_active, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - original_drn_2 = imod5_dataset["drn-2"].copy() - imod5_dataset["drn-2"] = { - key: da.assign_coords(layer=[-1]) for key, da in imod5_dataset["drn-2"].items() - } - - drn_negative_layer = imod.mf6.Drainage.from_imod5_data( - "drn-2", - imod5_dataset, - period_data, - target_dis, - target_npf, - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - regridder_types=None, - ) - - drn_negative_layer.cleanup(target_dis) - - assert drn_negative_layer.dataset.identical(drn_reference.dataset) - - # Teardown - imod5_dataset["drn-2"] = original_drn_2 +import pathlib +import textwrap +from datetime import datetime + +import numpy as np +import pandas as pd +import pytest +import xarray as xr +from pytest_cases import parametrize_with_cases + +import imod +from imod.common.utilities.version import get_version +from imod.logging import LoggerType, LogLevel +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.utilities.package import get_repeat_stress +from imod.mf6.write_context import WriteContext +from imod.prepare.topsystem.allocation import ALLOCATION_OPTION +from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION +from imod.prepare.topsystem.default_allocation_methods import ( + SimulationAllocationOptions, + SimulationDistributingOptions, +) +from imod.schemata import ValidationError + + +@pytest.fixture(scope="function") +def 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"), + ) + conductance = elevation.copy() + + drn = {"elevation": elevation, "conductance": conductance} + return drn + + +@pytest.fixture(scope="function") +def transient_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",), + ) + conductance = time_multiplier * elevation + + drn = {"elevation": elevation, "conductance": conductance} + 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 = { + "elevation": elevation, + "conductance": conductance, + "concentration": concentration, + } + return drn + + +def test_write(drainage, tmp_path): + imod.logging.configure( + LoggerType.PYTHON, + log_level=LogLevel.DEBUG, + add_default_file_handler=True, + add_default_stream_handler=False, + ) + + drn = imod.mf6.Drainage(**drainage) + write_context = WriteContext(simulation_directory=tmp_path, use_binary=True) + drn._write("mydrn", [1], write_context) + + version = get_version() + block_expected = textwrap.dedent( + f"""\ + # File written with iMOD Python version: {version} + + begin options + end options + + begin dimensions + maxbound 75 + end dimensions + + begin period 1 + open/close mydrn/drn.bin (binary) + end period + """ + ) + + with open(tmp_path / "mydrn.drn") as f: + block = f.read() + + assert block == block_expected + + +def test_wrong_dtype(drainage): + drainage["elevation"] = drainage["elevation"].astype(np.int32) + + with pytest.raises(ValidationError): + imod.mf6.Drainage(**drainage) + + +def test_wrong_layer_coord(drainage): + ds = xr.merge([drainage], join="exact") + ds = ds.assign_coords(layer=[0, 1, 2]) + + with pytest.raises(ValidationError): + imod.mf6.Drainage(**ds) + + +def test_validate_false(drainage): + drainage["elevation"] = drainage["elevation"].astype(np.int32) + + imod.mf6.Drainage(validate=False, **drainage) + + +def test_check_conductance_zero(drainage): + drainage["conductance"] = drainage["conductance"] * 0.0 + + idomain = 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(**drainage) + errors = drn._validate(drn._write_schemata, **dis.dataset) + assert len(errors) == 1 + for var, error in errors.items(): + assert var == "conductance" + + +@pytest.mark.parametrize("nodata_idomain", [0, -1]) +def test_validate_inside_nodata(drainage, nodata_idomain): + idomain = drainage["elevation"].astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + + idomain[:, 2, 2] = nodata_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 == "elevation" + + +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 == "concentration" + + +def test_discontinuous_layer(drainage): + drn = imod.mf6.Drainage(**drainage) + drn["layer"] = [1, 3, 5] + bin_ds = drn[list(drn._period_data)] + layer = bin_ds["layer"].values + arrdict = drn._ds_to_arrdict(bin_ds) + struct_array = drn._to_struct_array(arrdict, layer) + assert np.array_equal(np.unique(struct_array["layer"]), [1, 3, 5]) + + +def test_3d_singelayer(): + # Introduced because of Issue #224 + layer = [1] + y = np.arange(4.5, 0.0, -1.0) + x = np.arange(0.5, 5.0, 1.0) + elevation = xr.DataArray( + np.full((1, 5, 5), 1.0), + coords={"layer": layer, "y": y, "x": x, "dx": 1.0, "dy": -1.0}, + dims=("layer", "y", "x"), + ) + conductance = elevation.copy() + drn = imod.mf6.Drainage(elevation=elevation, conductance=conductance) + + bin_ds = drn[list(drn._period_data)] + layer = bin_ds["layer"].values + arrdict = drn._ds_to_arrdict(bin_ds) + struct_array = drn._to_struct_array(arrdict, layer) + assert isinstance(struct_array, np.ndarray) + + +def test_aggregate_layers(drainage): + river = imod.mf6.Drainage(**drainage) + + planar_dict = river.aggregate_layers(river.dataset) + assert isinstance(planar_dict, dict) + for value in planar_dict.values(): + assert isinstance(value, xr.DataArray) + assert "layer" not in value.dims + assert "layer" not in value.coords + + # Conductance should be summed, stage averaged + assert not (planar_dict["elevation"] > planar_dict["conductance"]).any() + + +def test_render_concentration( + concentration_fc, + elevation_fc, + conductance_fc, +): + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64[ns]", + ) + + drn = imod.mf6.Drainage( + elevation=elevation_fc, + conductance=conductance_fc, + concentration=concentration_fc, + concentration_boundary_type="AUX", + ) + + actual = drn._render(directory, "drn", globaltimes, False) + + expected = textwrap.dedent( + """\ + begin options + auxiliary salinity temperature + end options + + begin dimensions + maxbound 2 + end dimensions + + begin period 1 + open/close mymodel/drn/drn-0.dat + end period + begin period 2 + open/close mymodel/drn/drn-1.dat + end period + begin period 3 + open/close mymodel/drn/drn-2.dat + end period + """ + ) + assert actual == expected + + +def test_repeat_stress( + elevation_fc, + conductance_fc, +): + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + "2000-01-04", + "2000-01-05", + ], + dtype="datetime64[ns]", + ) + + repeat_stress = xr.DataArray( + [ + [globaltimes[3], globaltimes[0]], + [globaltimes[4], globaltimes[1]], + ], + dims=("repeat", "repeat_items"), + ) + + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 2 + end dimensions + + begin period 1 + open/close mymodel/drn/drn-0.dat + end period + begin period 2 + open/close mymodel/drn/drn-1.dat + end period + begin period 3 + open/close mymodel/drn/drn-2.dat + end period + begin period 4 + open/close mymodel/drn/drn-0.dat + end period + begin period 5 + open/close mymodel/drn/drn-1.dat + end period + """ + ) + + drn = imod.mf6.Drainage( + elevation=elevation_fc, + conductance=conductance_fc, + repeat_stress=repeat_stress, + ) + actual = drn._render(directory, "drn", globaltimes, False) + assert actual == expected + + drn = imod.mf6.Drainage( + elevation=elevation_fc, + conductance=conductance_fc, + ) + drn.dataset["repeat_stress"] = get_repeat_stress( + times={ + globaltimes[3]: globaltimes[0], + globaltimes[4]: globaltimes[1], + }, + ) + actual = drn._render(directory, "drn", globaltimes, False) + assert actual == expected + + drn = imod.mf6.Drainage( + elevation=elevation_fc, + conductance=conductance_fc, + repeat_stress={ + globaltimes[3]: globaltimes[0], + globaltimes[4]: globaltimes[1], + }, + ) + actual = drn._render(directory, "drn", globaltimes, False) + assert actual == expected + + +def test_clip_box(drainage): + drn = imod.mf6.Drainage(**drainage) + + selection = drn.clip_box() + assert isinstance(selection, imod.mf6.Drainage) + assert selection.dataset.identical(drn.dataset) + + selection = drn.clip_box(x_min=None, x_max=None) + assert isinstance(selection, imod.mf6.Drainage) + assert selection.dataset.identical(drn.dataset) + + selection = drn.clip_box( + layer_min=1, + layer_max=2, + y_min=1.0, + y_max=4.0, + x_min=1.0, + x_max=4.0, + ) + assert isinstance(selection, imod.mf6.Drainage) + assert selection["conductance"].dims == ("layer", "y", "x") + assert selection["conductance"].shape == (2, 3, 3) + + +def test_clip_box_transient(transient_drainage): + drn = imod.mf6.Drainage(**transient_drainage) + + # First test the standard case: clip into existing times. + selection = drn.clip_box(time_min="2001-01-01", time_max="2004-01-01") + expected = np.array( + [ + "2001-01-01T00:00:00.000000", + "2002-01-01T00:00:00.000000", + "2003-01-01T00:00:00.000000", + "2004-01-01T00:00:00.000000", + ], + dtype="datetime64[ns]", + ) + assert isinstance(selection, imod.mf6.Drainage) + assert selection["elevation"].dims == ("layer", "y", "x") + assert selection["conductance"].dims == ("time", "layer", "y", "x") + assert np.array_equal(selection.dataset["time"], expected) + + # Now test a succesfull forward fill. + selection = drn.clip_box(time_min="2000-06-01", time_max="2002-06-01") + expected = np.array( + [ + "2000-06-01T00:00:00.000000", + "2001-01-01T00:00:00.000000", + "2002-01-01T00:00:00.000000", + ], + dtype="datetime64[ns]", + ) + assert np.array_equal(selection.dataset["time"], expected) + assert (selection["conductance"].sel(time="2000-06-01") == 1.0).all() + + # And a backfill. + selection = drn.clip_box(time_min="1990-06-01", time_max="2002-06-01") + expected = np.array( + [ + "1990-06-01T00:00:00.000000", + "2000-01-01T00:00:00.000000", + "2001-01-01T00:00:00.000000", + "2002-01-01T00:00:00.000000", + ], + dtype="datetime64[ns]", + ) + assert np.array_equal(selection.dataset["time"].values, expected) + assert (selection["conductance"].sel(time="1990-06-01") == 1.0).all() + assert (selection["conductance"].sel(time="2000-01-01") == 1.0).all() + + +def test_reallocate(drainage): + drn = imod.mf6.Drainage(**drainage) + idomain = drainage["elevation"].astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ALLOCATION_OPTION.first_active_to_elevation + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + # Act + drn_reallocated = drn.reallocate(dis, npf, allocation_option, distributing_option) + # Assert + assert isinstance(drn_reallocated, imod.mf6.Drainage) + assert not drn_reallocated.dataset.equals(drn.dataset) + assert ( + drn_reallocated["conductance"] + .sum("layer") + .equals(drn["conductance"].sum("layer")) + ) + assert ( + drn_reallocated["elevation"] + .mean("layer") + .equals(drn["elevation"].mean("layer")) + ) + + +def test_reallocate_drop_empty_layers(drainage): + """ + drop_empty_layers=True should trim layers off the final package without + changing the values of the layers that remain (Option A: allocation and + conductance distribution always run over the full layer range first). + """ + drn = imod.mf6.Drainage(**drainage) + idomain = drainage["elevation"].astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ALLOCATION_OPTION.first_active_to_elevation + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + + full = drn.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=False + ) + trimmed = drn.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=True + ) + + full_layers = full.dataset["layer"].values + trimmed_layers = trimmed.dataset["layer"].values + assert set(trimmed_layers) <= set(full_layers) + assert len(trimmed_layers) < len(full_layers) + np.testing.assert_allclose( + trimmed["conductance"].sel(layer=trimmed_layers).values, + full["conductance"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + + +def test_repr(drainage): + repr_string = imod.mf6.Drainage(**drainage).__repr__() + assert isinstance(repr_string, str) + assert repr_string.split("\n")[0] == "Drainage" + + +def test_html_repr(drainage): + html_string = imod.mf6.Drainage(**drainage)._repr_html_() + assert isinstance(html_string, str) + assert html_string.split("
")[0] == "
Drainage" + + +class AllocationSettings: + def case_default(self): + return SimulationAllocationOptions.drn, SimulationDistributingOptions.drn + + def case_custom(self): + return ALLOCATION_OPTION.at_elevation, DISTRIBUTING_OPTION.by_crosscut_thickness + + +@pytest.mark.unittest_jit +@parametrize_with_cases( + ["allocation_setting", "distribution_setting"], cases=AllocationSettings +) +def test_from_imod5( + imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting +): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + drn_2 = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=allocation_setting, + distributing_option=distribution_setting, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + assert isinstance(drn_2, imod.mf6.Drainage) + + drn_time = drn_2.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(drn_time, expected_times) + drn_repeat_stress = drn_2.dataset["repeat_stress"].data + assert np.all(drn_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(drn_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + pkg_errors = drn_2._validate( + schemata=drn_2._write_schemata, + idomain=target_dis["idomain"], + bottom=target_dis["bottom"], + ) + assert len(pkg_errors) == 0 + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + drn_2._write("mydrn", [1], write_context) + + +@pytest.mark.unittest_jit +@parametrize_with_cases( + ["allocation_setting", "distribution_setting"], cases=AllocationSettings +) +def test_from_imod5_and_cleanup( + imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting +): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + drn_2 = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=allocation_setting, + distributing_option=distribution_setting, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + drn_2.cleanup(target_dis) + + +@pytest.mark.unittest_jit +@parametrize_with_cases( + ["allocation_setting", "distribution_setting"], cases=AllocationSettings +) +def test_from_imod5__with_constant( + imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting +): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + original_drn_2 = imod5_dataset["drn-2"].copy() + imod5_dataset["drn-2"]["elevation"] = xr.DataArray( + [0.0], dims=("layer",), coords={"layer": [0]} + ) + + drn_2 = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=allocation_setting, + distributing_option=distribution_setting, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + assert isinstance(drn_2, imod.mf6.Drainage) + + pkg_errors = drn_2._validate( + schemata=drn_2._write_schemata, + idomain=target_dis["idomain"], + bottom=target_dis["bottom"], + ) + assert len(pkg_errors) == 0 + + # Tear down + imod5_dataset["drn-2"] = original_drn_2 + + +@pytest.mark.unittest_jit +@parametrize_with_cases( + ["allocation_setting", "distribution_setting"], cases=AllocationSettings +) +def test_from_imod5_and_cleanup__with_constant( + imod5_dataset_periods, tmp_path, allocation_setting, distribution_setting +): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + original_drn_2 = imod5_dataset["drn-2"].copy() + imod5_dataset["drn-2"]["elevation"] = xr.DataArray( + [0.0], dims=("layer",), coords={"layer": [0]} + ) + + drn_2 = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=allocation_setting, + distributing_option=distribution_setting, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + drn_2.cleanup(target_dis) + # Teardown + imod5_dataset["drn-2"] = original_drn_2 + + +@pytest.mark.unittest_jit +def test_from_imod5__negative_layer(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + drn_reference = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=ALLOCATION_OPTION.at_first_active, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + original_drn_2 = imod5_dataset["drn-2"].copy() + imod5_dataset["drn-2"] = { + key: da.assign_coords(layer=[-1]) for key, da in imod5_dataset["drn-2"].items() + } + + drn_negative_layer = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + assert isinstance(drn_negative_layer, imod.mf6.Drainage) + + pkg_errors = drn_negative_layer._validate( + schemata=drn_negative_layer._write_schemata, + idomain=target_dis["idomain"], + bottom=target_dis["bottom"], + ) + assert len(pkg_errors) == 0 + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + drn_negative_layer._write("mydrn", [1], write_context) + + assert drn_negative_layer.dataset.identical(drn_reference.dataset) + + # Tear down + imod5_dataset["drn-2"] = original_drn_2 + + +@pytest.mark.unittest_jit +def test_from_imod5_and_cleanup__negative_layer(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + drn_reference = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=ALLOCATION_OPTION.at_first_active, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + original_drn_2 = imod5_dataset["drn-2"].copy() + imod5_dataset["drn-2"] = { + key: da.assign_coords(layer=[-1]) for key, da in imod5_dataset["drn-2"].items() + } + + drn_negative_layer = imod.mf6.Drainage.from_imod5_data( + "drn-2", + imod5_dataset, + period_data, + target_dis, + target_npf, + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + regridder_types=None, + ) + + drn_negative_layer.cleanup(target_dis) + + assert drn_negative_layer.dataset.identical(drn_reference.dataset) + + # Teardown + imod5_dataset["drn-2"] = original_drn_2 diff --git a/imod/tests/test_mf6/test_mf6_ghb.py b/imod/tests/test_mf6/test_mf6_ghb.py index 14f785748..086bd75e6 100644 --- a/imod/tests/test_mf6/test_mf6_ghb.py +++ b/imod/tests/test_mf6/test_mf6_ghb.py @@ -1,283 +1,283 @@ -from copy import deepcopy -from datetime import datetime - -import numpy as np -import pytest -import xarray as xr - -import imod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.write_context import WriteContext -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION -from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION - - -def test_reallocate_drop_empty_layers(): - """ - drop_empty_layers=True should trim layers off the final package without - changing the values of the layers that remain (Option A: allocation and - conductance distribution always run over the full layer range first). - """ - layer = [1, 2, 3] - y = [25.0, 15.0, 5.0] - x = [5.0, 15.0, 25.0] - dx, dy = 10.0, -10.0 - head = xr.DataArray( - np.full((3, 3, 3), 1.0), - coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - dims=("layer", "y", "x"), - ) - conductance = head.copy() - ghb = imod.mf6.GeneralHeadBoundary(head=head, conductance=conductance) - - idomain = head.astype(np.int16) - top = 1.0 - bottom = top - idomain.coords["layer"] - dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - allocation_option = ALLOCATION_OPTION.at_first_active - distributing_option = DISTRIBUTING_OPTION.by_layer_thickness - - full = ghb.reallocate( - dis, npf, allocation_option, distributing_option, drop_empty_layers=False - ) - trimmed = ghb.reallocate( - dis, npf, allocation_option, distributing_option, drop_empty_layers=True - ) - - full_layers = full.dataset["layer"].values - trimmed_layers = trimmed.dataset["layer"].values - assert set(trimmed_layers) <= set(full_layers) - assert len(trimmed_layers) < len(full_layers) - np.testing.assert_allclose( - trimmed["conductance"].sel(layer=trimmed_layers).values, - full["conductance"].sel(layer=trimmed_layers).values, - equal_nan=True, - ) - - -@pytest.mark.unittest_jit -def test_from_imod5_non_planar(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - ) - - assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) - - ghb_time = ghb.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(ghb_time, expected_times) - ghb_repeat_stress = ghb.dataset["repeat_stress"].data - assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - ghb._write("ghb", [1], write_context) - - -@pytest.mark.unittest_jit -def test_from_imod5_and_cleanup_non_planar(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - ) - - ghb.cleanup(target_dis) - - -@pytest.mark.unittest_jit -def test_from_imod5_constant(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - original_ghb = deepcopy(imod5_dataset["ghb"]) - layer = imod5_dataset["ghb"]["conductance"].coords["layer"].data - imod5_dataset["ghb"]["conductance"] = xr.DataArray( - [1.0], coords={"layer": layer}, dims=("layer",) - ) - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - ) - - assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) - - ghb_time = ghb.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(ghb_time, expected_times) - ghb_repeat_stress = ghb.dataset["repeat_stress"].data - assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - ghb._write("ghb", [1], write_context) - - # teardown - imod5_dataset["ghb"] = original_ghb - - -@pytest.mark.unittest_jit -def test_from_imod5_and_cleanup_constant(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - original_ghb = deepcopy(imod5_dataset["ghb"]) - layer = imod5_dataset["ghb"]["conductance"].coords["layer"].data - imod5_dataset["ghb"]["conductance"] = xr.DataArray( - [1.0], coords={"layer": layer}, dims=("layer",) - ) - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - ) - - ghb.cleanup(target_dis) - # teardown - imod5_dataset["ghb"] = original_ghb - - -@pytest.mark.unittest_jit -def test_from_imod5_planar(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - original_ghb = deepcopy(imod5_dataset["ghb"]) - imod5_dataset["ghb"]["conductance"] = imod5_dataset["ghb"][ - "conductance" - ].assign_coords({"layer": [0]}) - imod5_dataset["ghb"]["head"] = imod5_dataset["ghb"]["head"].isel({"layer": 0}) - - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_layer_thickness, - ) - - assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) - - ghb_time = ghb.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(ghb_time, expected_times) - ghb_repeat_stress = ghb.dataset["repeat_stress"].data - assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - # write the packages for write validation - write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) - ghb._write("ghb", [1], write_context) - - # teardown - imod5_dataset["ghb"] = original_ghb - - -@pytest.mark.unittest_jit -def test_from_imod5_and_cleanup_planar(imod5_dataset_periods, tmp_path): - period_data = imod5_dataset_periods[1] - imod5_dataset = imod5_dataset_periods[0] - target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) - target_npf = NodePropertyFlow.from_imod5_data( - imod5_dataset, target_dis.dataset["idomain"] - ) - - original_ghb = deepcopy(imod5_dataset["ghb"]) - imod5_dataset["ghb"]["conductance"] = imod5_dataset["ghb"][ - "conductance" - ].assign_coords({"layer": [0]}) - imod5_dataset["ghb"]["head"] = imod5_dataset["ghb"]["head"].isel({"layer": 0}) - - ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( - "ghb", - imod5_dataset, - period_data, - target_dis, - target_npf, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_layer_thickness, - ) - - ghb.cleanup(target_dis) - - # teardown - imod5_dataset["ghb"] = original_ghb +from copy import deepcopy +from datetime import datetime + +import numpy as np +import pytest +import xarray as xr + +import imod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.write_context import WriteContext +from imod.prepare.topsystem.allocation import ALLOCATION_OPTION +from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION + + +def test_reallocate_drop_empty_layers(): + """ + drop_empty_layers=True should trim layers off the final package without + changing the values of the layers that remain (Option A: allocation and + conductance distribution always run over the full layer range first). + """ + layer = [1, 2, 3] + y = [25.0, 15.0, 5.0] + x = [5.0, 15.0, 25.0] + dx, dy = 10.0, -10.0 + head = xr.DataArray( + np.full((3, 3, 3), 1.0), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + dims=("layer", "y", "x"), + ) + conductance = head.copy() + ghb = imod.mf6.GeneralHeadBoundary(head=head, conductance=conductance) + + idomain = head.astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ALLOCATION_OPTION.at_first_active + distributing_option = DISTRIBUTING_OPTION.by_layer_thickness + + full = ghb.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=False + ) + trimmed = ghb.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=True + ) + + full_layers = full.dataset["layer"].values + trimmed_layers = trimmed.dataset["layer"].values + assert set(trimmed_layers) <= set(full_layers) + assert len(trimmed_layers) < len(full_layers) + np.testing.assert_allclose( + trimmed["conductance"].sel(layer=trimmed_layers).values, + full["conductance"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + + +@pytest.mark.unittest_jit +def test_from_imod5_non_planar(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + ) + + assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) + + ghb_time = ghb.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(ghb_time, expected_times) + ghb_repeat_stress = ghb.dataset["repeat_stress"].data + assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + ghb._write("ghb", [1], write_context) + + +@pytest.mark.unittest_jit +def test_from_imod5_and_cleanup_non_planar(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + ) + + ghb.cleanup(target_dis) + + +@pytest.mark.unittest_jit +def test_from_imod5_constant(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + original_ghb = deepcopy(imod5_dataset["ghb"]) + layer = imod5_dataset["ghb"]["conductance"].coords["layer"].data + imod5_dataset["ghb"]["conductance"] = xr.DataArray( + [1.0], coords={"layer": layer}, dims=("layer",) + ) + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + ) + + assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) + + ghb_time = ghb.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(ghb_time, expected_times) + ghb_repeat_stress = ghb.dataset["repeat_stress"].data + assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + ghb._write("ghb", [1], write_context) + + # teardown + imod5_dataset["ghb"] = original_ghb + + +@pytest.mark.unittest_jit +def test_from_imod5_and_cleanup_constant(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + original_ghb = deepcopy(imod5_dataset["ghb"]) + layer = imod5_dataset["ghb"]["conductance"].coords["layer"].data + imod5_dataset["ghb"]["conductance"] = xr.DataArray( + [1.0], coords={"layer": layer}, dims=("layer",) + ) + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + ) + + ghb.cleanup(target_dis) + # teardown + imod5_dataset["ghb"] = original_ghb + + +@pytest.mark.unittest_jit +def test_from_imod5_planar(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + original_ghb = deepcopy(imod5_dataset["ghb"]) + imod5_dataset["ghb"]["conductance"] = imod5_dataset["ghb"][ + "conductance" + ].assign_coords({"layer": [0]}) + imod5_dataset["ghb"]["head"] = imod5_dataset["ghb"]["head"].isel({"layer": 0}) + + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_layer_thickness, + ) + + assert isinstance(ghb, imod.mf6.GeneralHeadBoundary) + + ghb_time = ghb.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(ghb_time, expected_times) + ghb_repeat_stress = ghb.dataset["repeat_stress"].data + assert np.all(ghb_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(ghb_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + # write the packages for write validation + write_context = WriteContext(simulation_directory=tmp_path, use_binary=False) + ghb._write("ghb", [1], write_context) + + # teardown + imod5_dataset["ghb"] = original_ghb + + +@pytest.mark.unittest_jit +def test_from_imod5_and_cleanup_planar(imod5_dataset_periods, tmp_path): + period_data = imod5_dataset_periods[1] + imod5_dataset = imod5_dataset_periods[0] + target_dis = StructuredDiscretization.from_imod5_data(imod5_dataset, validate=False) + target_npf = NodePropertyFlow.from_imod5_data( + imod5_dataset, target_dis.dataset["idomain"] + ) + + original_ghb = deepcopy(imod5_dataset["ghb"]) + imod5_dataset["ghb"]["conductance"] = imod5_dataset["ghb"][ + "conductance" + ].assign_coords({"layer": [0]}) + imod5_dataset["ghb"]["head"] = imod5_dataset["ghb"]["head"].isel({"layer": 0}) + + ghb = imod.mf6.GeneralHeadBoundary.from_imod5_data( + "ghb", + imod5_dataset, + period_data, + target_dis, + target_npf, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_layer_thickness, + ) + + ghb.cleanup(target_dis) + + # teardown + imod5_dataset["ghb"] = original_ghb diff --git a/imod/tests/test_mf6/test_mf6_rch.py b/imod/tests/test_mf6/test_mf6_rch.py index 4525970eb..3038bbbfd 100644 --- a/imod/tests/test_mf6/test_mf6_rch.py +++ b/imod/tests/test_mf6/test_mf6_rch.py @@ -1,706 +1,706 @@ -import pathlib -import re -import tempfile -import textwrap -from copy import deepcopy -from datetime import datetime - -import dask -import numpy as np -import pytest -import xarray as xr - -import imod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.validation_settings import ValidationSettings -from imod.mf6.write_context import WriteContext -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION -from imod.schemata import ValidationError -from imod.typing.grid import is_planar_grid, is_transient_data_grid, nan_like -from imod.util.regrid import RegridderWeightsCache - - -@pytest.fixture(scope="function") -def rch_dict(): - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - layer = [1] - dx = 10.0 - dy = -10.0 - - da = xr.DataArray( - data=np.ones((1, 3, 3), dtype=float), - dims=("layer", "y", "x"), - coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - ) - - da[:, 1, 1] = np.nan - - return {"rate": da} - - -@pytest.fixture(scope="function") -def rch_dict_transient(): - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - layer = [1] - time = np.array(["2000-01-01", "2000-01-02"], dtype="datetime64[ns]") - dx = 10.0 - dy = -10.0 - - da = xr.DataArray( - data=np.ones((2, 1, 3, 3), dtype=float), - dims=("time", "layer", "y", "x"), - coords={"time": time, "layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - ) - - da[..., 1, 1] = np.nan - - return {"rate": da} - - -def test_render(rch_dict): - rch = imod.mf6.Recharge(**rch_dict) - directory = pathlib.Path("mymodel") - globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch.bin (binary) - end period - """ - ) - assert actual == expected - - -def test_render_fixed_cell(rch_dict): - rch_dict["fixed_cell"] = True - rch = imod.mf6.Recharge(**rch_dict) - directory = pathlib.Path("mymodel") - globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - fixed_cell - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch.bin (binary) - end period - """ - ) - assert actual == expected - - -def test_render_transient(rch_dict_transient): - rch = imod.mf6.Recharge(**rch_dict_transient) - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64[ns]", - ) - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch-0.bin (binary) - end period - begin period 2 - open/close mymodel/recharge/rch-1.bin (binary) - end period - """ - ) - assert actual == expected - - -def test_wrong_dtype(rch_dict): - rch_dict["rate"] = rch_dict["rate"].astype(np.int32) - with pytest.raises(ValidationError): - imod.mf6.Recharge(**rch_dict) - - -def test_no_layer_dim(rch_dict): - rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=False) - rch = imod.mf6.Recharge(**rch_dict) - directory = pathlib.Path("mymodel") - globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch.bin (binary) - end period - """ - ) - assert actual == expected - - -def test_transient_no_layer_dim(rch_dict_transient): - rch_dict_transient["rate"] = rch_dict_transient["rate"].sel(layer=1, drop=False) - rch = imod.mf6.Recharge(**rch_dict_transient) - - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64[ns]", - ) - - actual = rch._render(directory, "recharge", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 8 - end dimensions - - begin period 1 - open/close mymodel/recharge/rch-0.bin (binary) - end period - begin period 2 - open/close mymodel/recharge/rch-1.bin (binary) - end period - """ - ) - - assert actual == expected - - -def test_transient_aggregate(rch_dict_transient): - rch = imod.mf6.Recharge(**rch_dict_transient) - planar_dict = rch.aggregate_layers(rch.dataset) - - assert isinstance(planar_dict, dict) - for value in planar_dict.values(): - assert isinstance(value, xr.DataArray) - assert "layer" not in value.dims - assert "layer" not in value.coords - assert value.dims == ("time", "y", "x") - - -def test_render_concentration(concentration_fc, rate_fc): - rch = imod.mf6.Recharge( - rate=rate_fc, - concentration=concentration_fc, - concentration_boundary_type="AUX", - ) - - directory = pathlib.Path("mymodel") - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64[ns]", - ) - - actual = rch._render(directory, "rch", globaltimes, False) - - expected = textwrap.dedent( - """\ - begin options - auxiliary salinity temperature - end options - - begin dimensions - maxbound 2 - end dimensions - - begin period 1 - open/close mymodel/rch/rch-0.dat - end period - begin period 2 - open/close mymodel/rch/rch-1.dat - end period - begin period 3 - open/close mymodel/rch/rch-2.dat - end period - """ - ) - assert actual == expected - - -def test_no_layer_coord(rch_dict): - message = textwrap.dedent( - """ - - rate - - coords has missing keys: {'layer'}""" - ) - - rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=True) - with pytest.raises( - ValidationError, - match=re.escape(message), - ): - imod.mf6.Recharge(**rch_dict) - - -def test_scalar(): - message = textwrap.dedent( - """ - - rate - - coords has missing keys: {'layer'} - - No option succeeded: - dim mismatch: expected ('time', 'layer', 'y', 'x'), got () - dim mismatch: expected ('layer', 'y', 'x'), got () - dim mismatch: expected ('time', 'layer', '{face_dim}'), got () - dim mismatch: expected ('layer', '{face_dim}'), got () - dim mismatch: expected ('time', 'y', 'x'), got () - dim mismatch: expected ('y', 'x'), got () - dim mismatch: expected ('time', '{face_dim}'), got () - dim mismatch: expected ('{face_dim}',), got ()""" - ) - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.Recharge(rate=0.001) - - -def test_validate_false(): - imod.mf6.Recharge(rate=0.001, validate=False) - - -@pytest.mark.timeout(10, method="thread") -def test_ignore_time_validation(): - """ - Create a large recharge dataset with a time dimension. This is to test the - performance of the validation when ignore_time_no_data is True. - NOTE: There currently is no easy way to test the opposite (i.e., - ignore_time_no_data is False, then catch timeout with an pytest xfail - marker), because the timeout will terminate the test run with an error when - using dask instead of a fail. Somewhat relevant issue: - https://github.com/pytest-dev/pytest-timeout/issues/181 - """ - # Arrange - rng = dask.array.random.default_rng() - layer = [1, 2, 3] - template = imod.util.empty_3d(1.0, 0.0, 1000.0, 1.0, 0.0, 1000.0, layer) - idomain = xr.ones_like(template, dtype=np.int32) - layer_bottom = xr.DataArray( - [0.0, -10.0, -20.0], coords={"layer": layer}, dims=["layer"] - ) - bottom = layer_bottom * idomain - x = rng.random((10000, 3, 1000, 1000), chunks=(1, -1, -1, -1)) - rate = xr.DataArray(x, coords=idomain.coords, dims=("time", "layer", "y", "x")) - rch = imod.mf6.Recharge(rate=rate, validate=False) - validation_context = ValidationSettings(ignore_time=True) - # Act - rch._validate( - schemata=rch._write_schemata, - idomain=idomain, - bottom=bottom, - validation_context=validation_context, - ) - - -def test_write_concentration_period_data(rate_fc, concentration_fc): - globaltimes = np.array( - [ - "2000-01-01", - "2000-01-02", - "2000-01-03", - ], - dtype="datetime64", - ) - rate_fc[:] = 1 - concentration_fc[:] = 2 - - rch = imod.mf6.Recharge( - rate=rate_fc, - concentration=concentration_fc, - concentration_boundary_type="AUX", - ) - with tempfile.TemporaryDirectory() as output_dir: - write_context = WriteContext( - simulation_directory=output_dir, write_directory=output_dir - ) - rch._write(pkgname="rch", globaltimes=globaltimes, write_context=write_context) - - with open(output_dir + "/rch/rch-0.dat", "r") as f: - data = f.read() - assert ( - data.count("2") == 1755 - ) # the number 2 is in the concentration data, and in the cell indices. - - -def test_clip_box(rch_dict): - rch = imod.mf6.Recharge(**rch_dict) - - selection = rch.clip_box() - assert isinstance(selection, imod.mf6.Recharge) - assert selection.dataset.identical(rch.dataset) - - selection = rch.clip_box(x_min=10.0, x_max=20.0, y_min=10.0, y_max=20.0) - assert selection["rate"].dims == ("layer", "y", "x") - assert selection["rate"].shape == (1, 1, 1) - - # No layer dim - rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=False) - rch = imod.mf6.Recharge(**rch_dict) - selection = rch.clip_box(x_min=10.0, x_max=20.0, y_min=10.0, y_max=20.0) - assert selection["rate"].dims == ("y", "x") - assert selection["rate"].shape == (1, 1) - - -@pytest.mark.parametrize( - "allocation_option", - [None, ALLOCATION_OPTION.at_first_active, ALLOCATION_OPTION.stage_to_riv_bot], -) -def test_reallocate(rch_dict, allocation_option): - # Arrange - rch = imod.mf6.Recharge(**rch_dict) - idomain = rch_dict["rate"].fillna(0.0).astype(np.int16) - top = 1.0 - bottom = top - idomain.coords["layer"] - dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) - if allocation_option is ALLOCATION_OPTION.stage_to_riv_bot: - # Act - with pytest.raises( - ValueError, match="Received incompatible setting for recharge" - ): - rch.reallocate(dis, allocation_option=allocation_option) - else: - # Act - rch_reallocated = rch.reallocate(dis, allocation_option=allocation_option) - # Assert - assert isinstance(rch_reallocated, imod.mf6.Recharge) - assert rch_reallocated.dataset.equals(rch.dataset) - - -def test_reallocate_drop_empty_layers(): - """ - drop_empty_layers=True should trim layers off the final package without - changing the values of the layers that remain (Option A: allocation is - always computed over the full layer range first). - """ - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - layer = [1, 2, 3] - dx, dy = 10.0, -10.0 - - idomain = xr.DataArray( - np.ones((3, 3, 3), dtype=np.int16), - coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - dims=("layer", "y", "x"), - ) - top = 1.0 - bottom = top - idomain.coords["layer"] - dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) - - rate = xr.DataArray( - 1.0, - coords={"y": y, "x": x, "dx": dx, "dy": dy}, - dims=("y", "x"), - ).expand_dims(layer=[1]) - rch = imod.mf6.Recharge(rate=rate) - - full = rch.reallocate( - dis, - allocation_option=ALLOCATION_OPTION.at_first_active, - drop_empty_layers=False, - ) - trimmed = rch.reallocate( - dis, - allocation_option=ALLOCATION_OPTION.at_first_active, - drop_empty_layers=True, - ) - - full_layers = full.dataset["layer"].values - trimmed_layers = trimmed.dataset["layer"].values - assert set(trimmed_layers) <= set(full_layers) - assert len(trimmed_layers) < len(full_layers) - np.testing.assert_allclose( - trimmed["rate"].sel(layer=trimmed_layers).values, - full["rate"].sel(layer=trimmed_layers).values, - equal_nan=True, - ) - - -@pytest.mark.unittest_jit -def test_planar_rch_from_imod5_constant(imod5_dataset, tmp_path): - data = deepcopy(imod5_dataset[0]) - period_data = imod5_dataset[1] - target_discretization = StructuredDiscretization.from_imod5_data(data) - - # create a planar grid with time-independent recharge - data["rch"]["rate"] = data["rch"]["rate"].assign_coords(layer=[-1]) - - assert not is_transient_data_grid(data["rch"]["rate"]) - assert is_planar_grid(data["rch"]["rate"]) - - # Act - rch = imod.mf6.Recharge.from_imod5_data( - data, - period_data, - target_discretization, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - ) - rendered_rch = rch._render(tmp_path, "rch", None, None) - - # Assert - np.testing.assert_allclose( - data["rch"]["rate"].mean().values / 1e3, - rch.dataset["rate"].mean().values, - atol=1e-5, - ) - assert "maxbound 33856" in rendered_rch - assert rendered_rch.count("begin period") == 1 - # teardown - data["rch"]["rate"] = data["rch"]["rate"].assign_coords(layer=[1]) - - -@pytest.mark.unittest_jit -def test_planar_rch_from_imod5_transient(imod5_dataset, tmp_path): - data = deepcopy(imod5_dataset[0]) - period_data = imod5_dataset[1] - target_discretization = StructuredDiscretization.from_imod5_data(data) - - # create a grid with recharge for 3 timesteps - input_recharge = data["rch"]["rate"].copy(deep=True) - times = [ - np.datetime64("2001-01-01"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - input_recharge = input_recharge.expand_dims({"time": times}) - - # make it planar by setting the layer coordinate to -1 - input_recharge = input_recharge.assign_coords({"layer": [-1]}) - - # update the data set - data["rch"]["rate"] = input_recharge - assert is_transient_data_grid(data["rch"]["rate"]) - assert is_planar_grid(data["rch"]["rate"]) - - # act - rch = imod.mf6.Recharge.from_imod5_data( - data, - period_data, - target_discretization, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - ) - globaltimes = times + [np.datetime64("2022-02-02")] - globaltimes[0] = np.datetime64("2002-02-02") - rendered_rch = rch._render(tmp_path, "rch", globaltimes, None) - - # assert - np.testing.assert_allclose( - data["rch"]["rate"].mean().values / 1e3, - rch.dataset["rate"].mean().values, - atol=1e-5, - ) - assert rendered_rch.count("begin period") == 3 - assert "maxbound 33856" in rendered_rch - - -@pytest.mark.unittest_jit -def test_non_planar_rch_from_imod5_constant(imod5_dataset, tmp_path): - data = deepcopy(imod5_dataset[0]) - period_data = imod5_dataset[1] - target_discretization = StructuredDiscretization.from_imod5_data(data) - - # make the first layer of the target grid inactive - target_grid = target_discretization.dataset["idomain"] - target_grid.loc[{"layer": 1}] = 0 - - # the input for recharge is on the second layer of the targetgrid - original_rch = data["rch"]["rate"].copy(deep=True) - data["rch"]["rate"] = data["rch"]["rate"].assign_coords({"layer": [-1]}) - input_recharge = nan_like(data["khv"]["kh"]) - input_recharge.loc[{"layer": 2}] = data["rch"]["rate"].isel(layer=0) - - # update the data set - - data["rch"]["rate"] = input_recharge - assert not is_planar_grid(data["rch"]["rate"]) - assert not is_transient_data_grid(data["rch"]["rate"]) - - # act - rch = imod.mf6.Recharge.from_imod5_data( - data, - period_data, - target_discretization, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - ) - rendered_rch = rch._render(tmp_path, "rch", None, None) - - # assert - np.testing.assert_allclose( - data["rch"]["rate"].mean().values / 1e3, - rch.dataset["rate"].mean().values, - atol=1e-5, - ) - assert rendered_rch.count("begin period") == 1 - assert "maxbound 33856" in rendered_rch - - # teardown - data["rch"]["rate"] = original_rch - - -@pytest.mark.unittest_jit -def test_non_planar_rch_from_imod5_transient(imod5_dataset, tmp_path): - data = deepcopy(imod5_dataset[0]) - period_data = imod5_dataset[1] - target_discretization = StructuredDiscretization.from_imod5_data(data) - # make the first layer of the target grid inactive - target_grid = target_discretization.dataset["idomain"] - target_grid.loc[{"layer": 1}] = 0 - - # the input for recharge is on the second layer of the targetgrid - input_recharge = nan_like(data["rch"]["rate"]) - input_recharge = input_recharge.assign_coords({"layer": [2]}) - input_recharge.loc[{"layer": 2}] = data["rch"]["rate"].sel(layer=1) - times = [ - np.datetime64("2001-01-01"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - input_recharge = input_recharge.expand_dims({"time": times}) - - # update the data set - data["rch"]["rate"] = input_recharge - assert not is_planar_grid(data["rch"]["rate"]) - assert is_transient_data_grid(data["rch"]["rate"]) - - # act - rch = imod.mf6.Recharge.from_imod5_data( - data, - period_data, - target_discretization, - time_min=datetime(2002, 2, 2), - time_max=datetime(2022, 2, 2), - ) - globaltimes = times + [np.datetime64("2022-02-02")] - globaltimes[0] = np.datetime64("2002-02-02") - rendered_rch = rch._render(tmp_path, "rch", globaltimes, None) - - # assert - np.testing.assert_allclose( - data["rch"]["rate"].mean().values / 1e3, - rch.dataset["rate"].mean().values, - atol=1e-5, - ) - assert rendered_rch.count("begin period") == 3 - assert "maxbound 33856" in rendered_rch - - -@pytest.mark.unittest_jit -def test_from_imod5_cap_data(imod5_dataset): - # Arrange - data = deepcopy(imod5_dataset[0]) - target_discretization = StructuredDiscretization.from_imod5_data(data) - data["extra"] = {"paths": ["path1", "path2"]} - data["cap"] = {} - msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) - data["cap"]["boundary"] = msw_bound - data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 - data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 - # Compute midpoint of grid and set areas such, that cells need to be - # deactivated. - midpoint = tuple((int(x / 2) for x in msw_bound.shape)) - # Set to total cellsize, cell needs to be deactivated. - data["cap"]["wetted_area"][midpoint] = 625.0 - # Set to zero, cell needs to be deactivated. - data["cap"]["urban_area"][midpoint] = 0.0 - # Act - rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) - rate = rch.dataset["rate"] - # Assert - # Shape - assert rate.dims == ("y", "x") - assert "layer" in rate.coords - assert rate.coords["layer"] == 1 - # Values - np.testing.assert_array_equal(np.unique(rate), np.array([0.0, np.nan])) - # Boundaries inactive in MetaSWAP - assert np.isnan(rate[:, 0]).all() - assert np.isnan(rate[:, -1]).all() - assert np.isnan(rate[0, :]).all() - assert np.isnan(rate[-1, :]).all() - assert np.isnan(rate[midpoint]).all() - - -@pytest.mark.unittest_jit -def test_from_imod5_cap_data__regrid(imod5_dataset): - # Arrange - data = deepcopy(imod5_dataset[0]) - target_discretization = StructuredDiscretization.from_imod5_data(data) - data["extra"] = {"paths": ["path1", "path2"]} - data["cap"] = {} - msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) - data["cap"]["boundary"] = msw_bound - data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 - data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 - # Setup template grid - dx_small, xmin, xmax, dy_small, ymin, ymax = imod.util.spatial_reference(msw_bound) - dx = dx_small * 2 - dy = dy_small * 2 - expected_spatial_ref = dx, xmin, xmax, dy, ymin, ymax - like = imod.util.empty_2d(*expected_spatial_ref) - # Act - rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) - rch_coarse = rch.regrid_like(like, regrid_cache=RegridderWeightsCache()) - # Assert - actual_spatial_ref = imod.util.spatial_reference(rch_coarse.dataset["rate"]) - assert actual_spatial_ref == expected_spatial_ref - - -@pytest.mark.unittest_jit -def test_from_imod5_cap_data__clip_box(imod5_dataset): - # Arrange - data = deepcopy(imod5_dataset[0]) - target_discretization = StructuredDiscretization.from_imod5_data(data) - data["extra"] = {"paths": ["path1", "path2"]} - data["cap"] = {} - msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) - data["cap"]["boundary"] = msw_bound - data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 - data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 - # Setup template grid - dx, xmin, xmax, dy, ymin, ymax = imod.util.spatial_reference(msw_bound) - xmin_to_clip = xmin + 10 * dx - expected_spatial_ref = dx, xmin_to_clip, xmax, dy, ymin, ymax - # Act - rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) - rch_clipped = rch.clip_box(x_min=xmin_to_clip) - # Assert - actual_spatial_ref = imod.util.spatial_reference(rch_clipped.dataset["rate"]) - assert actual_spatial_ref == expected_spatial_ref +import pathlib +import re +import tempfile +import textwrap +from copy import deepcopy +from datetime import datetime + +import dask +import numpy as np +import pytest +import xarray as xr + +import imod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.validation_settings import ValidationSettings +from imod.mf6.write_context import WriteContext +from imod.prepare.topsystem.allocation import ALLOCATION_OPTION +from imod.schemata import ValidationError +from imod.typing.grid import is_planar_grid, is_transient_data_grid, nan_like +from imod.util.regrid import RegridderWeightsCache + + +@pytest.fixture(scope="function") +def rch_dict(): + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + layer = [1] + dx = 10.0 + dy = -10.0 + + da = xr.DataArray( + data=np.ones((1, 3, 3), dtype=float), + dims=("layer", "y", "x"), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + ) + + da[:, 1, 1] = np.nan + + return {"rate": da} + + +@pytest.fixture(scope="function") +def rch_dict_transient(): + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + layer = [1] + time = np.array(["2000-01-01", "2000-01-02"], dtype="datetime64[ns]") + dx = 10.0 + dy = -10.0 + + da = xr.DataArray( + data=np.ones((2, 1, 3, 3), dtype=float), + dims=("time", "layer", "y", "x"), + coords={"time": time, "layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + ) + + da[..., 1, 1] = np.nan + + return {"rate": da} + + +def test_render(rch_dict): + rch = imod.mf6.Recharge(**rch_dict) + directory = pathlib.Path("mymodel") + globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch.bin (binary) + end period + """ + ) + assert actual == expected + + +def test_render_fixed_cell(rch_dict): + rch_dict["fixed_cell"] = True + rch = imod.mf6.Recharge(**rch_dict) + directory = pathlib.Path("mymodel") + globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + fixed_cell + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch.bin (binary) + end period + """ + ) + assert actual == expected + + +def test_render_transient(rch_dict_transient): + rch = imod.mf6.Recharge(**rch_dict_transient) + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64[ns]", + ) + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch-0.bin (binary) + end period + begin period 2 + open/close mymodel/recharge/rch-1.bin (binary) + end period + """ + ) + assert actual == expected + + +def test_wrong_dtype(rch_dict): + rch_dict["rate"] = rch_dict["rate"].astype(np.int32) + with pytest.raises(ValidationError): + imod.mf6.Recharge(**rch_dict) + + +def test_no_layer_dim(rch_dict): + rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=False) + rch = imod.mf6.Recharge(**rch_dict) + directory = pathlib.Path("mymodel") + globaltimes = np.array(["2000-01-01"], dtype="datetime64[ns]") + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch.bin (binary) + end period + """ + ) + assert actual == expected + + +def test_transient_no_layer_dim(rch_dict_transient): + rch_dict_transient["rate"] = rch_dict_transient["rate"].sel(layer=1, drop=False) + rch = imod.mf6.Recharge(**rch_dict_transient) + + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64[ns]", + ) + + actual = rch._render(directory, "recharge", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 8 + end dimensions + + begin period 1 + open/close mymodel/recharge/rch-0.bin (binary) + end period + begin period 2 + open/close mymodel/recharge/rch-1.bin (binary) + end period + """ + ) + + assert actual == expected + + +def test_transient_aggregate(rch_dict_transient): + rch = imod.mf6.Recharge(**rch_dict_transient) + planar_dict = rch.aggregate_layers(rch.dataset) + + assert isinstance(planar_dict, dict) + for value in planar_dict.values(): + assert isinstance(value, xr.DataArray) + assert "layer" not in value.dims + assert "layer" not in value.coords + assert value.dims == ("time", "y", "x") + + +def test_render_concentration(concentration_fc, rate_fc): + rch = imod.mf6.Recharge( + rate=rate_fc, + concentration=concentration_fc, + concentration_boundary_type="AUX", + ) + + directory = pathlib.Path("mymodel") + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64[ns]", + ) + + actual = rch._render(directory, "rch", globaltimes, False) + + expected = textwrap.dedent( + """\ + begin options + auxiliary salinity temperature + end options + + begin dimensions + maxbound 2 + end dimensions + + begin period 1 + open/close mymodel/rch/rch-0.dat + end period + begin period 2 + open/close mymodel/rch/rch-1.dat + end period + begin period 3 + open/close mymodel/rch/rch-2.dat + end period + """ + ) + assert actual == expected + + +def test_no_layer_coord(rch_dict): + message = textwrap.dedent( + """ + - rate + - coords has missing keys: {'layer'}""" + ) + + rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=True) + with pytest.raises( + ValidationError, + match=re.escape(message), + ): + imod.mf6.Recharge(**rch_dict) + + +def test_scalar(): + message = textwrap.dedent( + """ + - rate + - coords has missing keys: {'layer'} + - No option succeeded: + dim mismatch: expected ('time', 'layer', 'y', 'x'), got () + dim mismatch: expected ('layer', 'y', 'x'), got () + dim mismatch: expected ('time', 'layer', '{face_dim}'), got () + dim mismatch: expected ('layer', '{face_dim}'), got () + dim mismatch: expected ('time', 'y', 'x'), got () + dim mismatch: expected ('y', 'x'), got () + dim mismatch: expected ('time', '{face_dim}'), got () + dim mismatch: expected ('{face_dim}',), got ()""" + ) + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.Recharge(rate=0.001) + + +def test_validate_false(): + imod.mf6.Recharge(rate=0.001, validate=False) + + +@pytest.mark.timeout(10, method="thread") +def test_ignore_time_validation(): + """ + Create a large recharge dataset with a time dimension. This is to test the + performance of the validation when ignore_time_no_data is True. + NOTE: There currently is no easy way to test the opposite (i.e., + ignore_time_no_data is False, then catch timeout with an pytest xfail + marker), because the timeout will terminate the test run with an error when + using dask instead of a fail. Somewhat relevant issue: + https://github.com/pytest-dev/pytest-timeout/issues/181 + """ + # Arrange + rng = dask.array.random.default_rng() + layer = [1, 2, 3] + template = imod.util.empty_3d(1.0, 0.0, 1000.0, 1.0, 0.0, 1000.0, layer) + idomain = xr.ones_like(template, dtype=np.int32) + layer_bottom = xr.DataArray( + [0.0, -10.0, -20.0], coords={"layer": layer}, dims=["layer"] + ) + bottom = layer_bottom * idomain + x = rng.random((10000, 3, 1000, 1000), chunks=(1, -1, -1, -1)) + rate = xr.DataArray(x, coords=idomain.coords, dims=("time", "layer", "y", "x")) + rch = imod.mf6.Recharge(rate=rate, validate=False) + validation_context = ValidationSettings(ignore_time=True) + # Act + rch._validate( + schemata=rch._write_schemata, + idomain=idomain, + bottom=bottom, + validation_context=validation_context, + ) + + +def test_write_concentration_period_data(rate_fc, concentration_fc): + globaltimes = np.array( + [ + "2000-01-01", + "2000-01-02", + "2000-01-03", + ], + dtype="datetime64", + ) + rate_fc[:] = 1 + concentration_fc[:] = 2 + + rch = imod.mf6.Recharge( + rate=rate_fc, + concentration=concentration_fc, + concentration_boundary_type="AUX", + ) + with tempfile.TemporaryDirectory() as output_dir: + write_context = WriteContext( + simulation_directory=output_dir, write_directory=output_dir + ) + rch._write(pkgname="rch", globaltimes=globaltimes, write_context=write_context) + + with open(output_dir + "/rch/rch-0.dat", "r") as f: + data = f.read() + assert ( + data.count("2") == 1755 + ) # the number 2 is in the concentration data, and in the cell indices. + + +def test_clip_box(rch_dict): + rch = imod.mf6.Recharge(**rch_dict) + + selection = rch.clip_box() + assert isinstance(selection, imod.mf6.Recharge) + assert selection.dataset.identical(rch.dataset) + + selection = rch.clip_box(x_min=10.0, x_max=20.0, y_min=10.0, y_max=20.0) + assert selection["rate"].dims == ("layer", "y", "x") + assert selection["rate"].shape == (1, 1, 1) + + # No layer dim + rch_dict["rate"] = rch_dict["rate"].sel(layer=1, drop=False) + rch = imod.mf6.Recharge(**rch_dict) + selection = rch.clip_box(x_min=10.0, x_max=20.0, y_min=10.0, y_max=20.0) + assert selection["rate"].dims == ("y", "x") + assert selection["rate"].shape == (1, 1) + + +@pytest.mark.parametrize( + "allocation_option", + [None, ALLOCATION_OPTION.at_first_active, ALLOCATION_OPTION.stage_to_riv_bot], +) +def test_reallocate(rch_dict, allocation_option): + # Arrange + rch = imod.mf6.Recharge(**rch_dict) + idomain = rch_dict["rate"].fillna(0.0).astype(np.int16) + top = 1.0 + bottom = top - idomain.coords["layer"] + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + if allocation_option is ALLOCATION_OPTION.stage_to_riv_bot: + # Act + with pytest.raises( + ValueError, match="Received incompatible setting for recharge" + ): + rch.reallocate(dis, allocation_option=allocation_option) + else: + # Act + rch_reallocated = rch.reallocate(dis, allocation_option=allocation_option) + # Assert + assert isinstance(rch_reallocated, imod.mf6.Recharge) + assert rch_reallocated.dataset.equals(rch.dataset) + + +def test_reallocate_drop_empty_layers(): + """ + drop_empty_layers=True should trim layers off the final package without + changing the values of the layers that remain (Option A: allocation is + always computed over the full layer range first). + """ + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + layer = [1, 2, 3] + dx, dy = 10.0, -10.0 + + idomain = xr.DataArray( + np.ones((3, 3, 3), dtype=np.int16), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + dims=("layer", "y", "x"), + ) + top = 1.0 + bottom = top - idomain.coords["layer"] + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + + rate = xr.DataArray( + 1.0, + coords={"y": y, "x": x, "dx": dx, "dy": dy}, + dims=("y", "x"), + ).expand_dims(layer=[1]) + rch = imod.mf6.Recharge(rate=rate) + + full = rch.reallocate( + dis, + allocation_option=ALLOCATION_OPTION.at_first_active, + drop_empty_layers=False, + ) + trimmed = rch.reallocate( + dis, + allocation_option=ALLOCATION_OPTION.at_first_active, + drop_empty_layers=True, + ) + + full_layers = full.dataset["layer"].values + trimmed_layers = trimmed.dataset["layer"].values + assert set(trimmed_layers) <= set(full_layers) + assert len(trimmed_layers) < len(full_layers) + np.testing.assert_allclose( + trimmed["rate"].sel(layer=trimmed_layers).values, + full["rate"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + + +@pytest.mark.unittest_jit +def test_planar_rch_from_imod5_constant(imod5_dataset, tmp_path): + data = deepcopy(imod5_dataset[0]) + period_data = imod5_dataset[1] + target_discretization = StructuredDiscretization.from_imod5_data(data) + + # create a planar grid with time-independent recharge + data["rch"]["rate"] = data["rch"]["rate"].assign_coords(layer=[-1]) + + assert not is_transient_data_grid(data["rch"]["rate"]) + assert is_planar_grid(data["rch"]["rate"]) + + # Act + rch = imod.mf6.Recharge.from_imod5_data( + data, + period_data, + target_discretization, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + ) + rendered_rch = rch._render(tmp_path, "rch", None, None) + + # Assert + np.testing.assert_allclose( + data["rch"]["rate"].mean().values / 1e3, + rch.dataset["rate"].mean().values, + atol=1e-5, + ) + assert "maxbound 33856" in rendered_rch + assert rendered_rch.count("begin period") == 1 + # teardown + data["rch"]["rate"] = data["rch"]["rate"].assign_coords(layer=[1]) + + +@pytest.mark.unittest_jit +def test_planar_rch_from_imod5_transient(imod5_dataset, tmp_path): + data = deepcopy(imod5_dataset[0]) + period_data = imod5_dataset[1] + target_discretization = StructuredDiscretization.from_imod5_data(data) + + # create a grid with recharge for 3 timesteps + input_recharge = data["rch"]["rate"].copy(deep=True) + times = [ + np.datetime64("2001-01-01"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + input_recharge = input_recharge.expand_dims({"time": times}) + + # make it planar by setting the layer coordinate to -1 + input_recharge = input_recharge.assign_coords({"layer": [-1]}) + + # update the data set + data["rch"]["rate"] = input_recharge + assert is_transient_data_grid(data["rch"]["rate"]) + assert is_planar_grid(data["rch"]["rate"]) + + # act + rch = imod.mf6.Recharge.from_imod5_data( + data, + period_data, + target_discretization, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + ) + globaltimes = times + [np.datetime64("2022-02-02")] + globaltimes[0] = np.datetime64("2002-02-02") + rendered_rch = rch._render(tmp_path, "rch", globaltimes, None) + + # assert + np.testing.assert_allclose( + data["rch"]["rate"].mean().values / 1e3, + rch.dataset["rate"].mean().values, + atol=1e-5, + ) + assert rendered_rch.count("begin period") == 3 + assert "maxbound 33856" in rendered_rch + + +@pytest.mark.unittest_jit +def test_non_planar_rch_from_imod5_constant(imod5_dataset, tmp_path): + data = deepcopy(imod5_dataset[0]) + period_data = imod5_dataset[1] + target_discretization = StructuredDiscretization.from_imod5_data(data) + + # make the first layer of the target grid inactive + target_grid = target_discretization.dataset["idomain"] + target_grid.loc[{"layer": 1}] = 0 + + # the input for recharge is on the second layer of the targetgrid + original_rch = data["rch"]["rate"].copy(deep=True) + data["rch"]["rate"] = data["rch"]["rate"].assign_coords({"layer": [-1]}) + input_recharge = nan_like(data["khv"]["kh"]) + input_recharge.loc[{"layer": 2}] = data["rch"]["rate"].isel(layer=0) + + # update the data set + + data["rch"]["rate"] = input_recharge + assert not is_planar_grid(data["rch"]["rate"]) + assert not is_transient_data_grid(data["rch"]["rate"]) + + # act + rch = imod.mf6.Recharge.from_imod5_data( + data, + period_data, + target_discretization, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + ) + rendered_rch = rch._render(tmp_path, "rch", None, None) + + # assert + np.testing.assert_allclose( + data["rch"]["rate"].mean().values / 1e3, + rch.dataset["rate"].mean().values, + atol=1e-5, + ) + assert rendered_rch.count("begin period") == 1 + assert "maxbound 33856" in rendered_rch + + # teardown + data["rch"]["rate"] = original_rch + + +@pytest.mark.unittest_jit +def test_non_planar_rch_from_imod5_transient(imod5_dataset, tmp_path): + data = deepcopy(imod5_dataset[0]) + period_data = imod5_dataset[1] + target_discretization = StructuredDiscretization.from_imod5_data(data) + # make the first layer of the target grid inactive + target_grid = target_discretization.dataset["idomain"] + target_grid.loc[{"layer": 1}] = 0 + + # the input for recharge is on the second layer of the targetgrid + input_recharge = nan_like(data["rch"]["rate"]) + input_recharge = input_recharge.assign_coords({"layer": [2]}) + input_recharge.loc[{"layer": 2}] = data["rch"]["rate"].sel(layer=1) + times = [ + np.datetime64("2001-01-01"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + input_recharge = input_recharge.expand_dims({"time": times}) + + # update the data set + data["rch"]["rate"] = input_recharge + assert not is_planar_grid(data["rch"]["rate"]) + assert is_transient_data_grid(data["rch"]["rate"]) + + # act + rch = imod.mf6.Recharge.from_imod5_data( + data, + period_data, + target_discretization, + time_min=datetime(2002, 2, 2), + time_max=datetime(2022, 2, 2), + ) + globaltimes = times + [np.datetime64("2022-02-02")] + globaltimes[0] = np.datetime64("2002-02-02") + rendered_rch = rch._render(tmp_path, "rch", globaltimes, None) + + # assert + np.testing.assert_allclose( + data["rch"]["rate"].mean().values / 1e3, + rch.dataset["rate"].mean().values, + atol=1e-5, + ) + assert rendered_rch.count("begin period") == 3 + assert "maxbound 33856" in rendered_rch + + +@pytest.mark.unittest_jit +def test_from_imod5_cap_data(imod5_dataset): + # Arrange + data = deepcopy(imod5_dataset[0]) + target_discretization = StructuredDiscretization.from_imod5_data(data) + data["extra"] = {"paths": ["path1", "path2"]} + data["cap"] = {} + msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) + data["cap"]["boundary"] = msw_bound + data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 + data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 + # Compute midpoint of grid and set areas such, that cells need to be + # deactivated. + midpoint = tuple((int(x / 2) for x in msw_bound.shape)) + # Set to total cellsize, cell needs to be deactivated. + data["cap"]["wetted_area"][midpoint] = 625.0 + # Set to zero, cell needs to be deactivated. + data["cap"]["urban_area"][midpoint] = 0.0 + # Act + rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) + rate = rch.dataset["rate"] + # Assert + # Shape + assert rate.dims == ("y", "x") + assert "layer" in rate.coords + assert rate.coords["layer"] == 1 + # Values + np.testing.assert_array_equal(np.unique(rate), np.array([0.0, np.nan])) + # Boundaries inactive in MetaSWAP + assert np.isnan(rate[:, 0]).all() + assert np.isnan(rate[:, -1]).all() + assert np.isnan(rate[0, :]).all() + assert np.isnan(rate[-1, :]).all() + assert np.isnan(rate[midpoint]).all() + + +@pytest.mark.unittest_jit +def test_from_imod5_cap_data__regrid(imod5_dataset): + # Arrange + data = deepcopy(imod5_dataset[0]) + target_discretization = StructuredDiscretization.from_imod5_data(data) + data["extra"] = {"paths": ["path1", "path2"]} + data["cap"] = {} + msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) + data["cap"]["boundary"] = msw_bound + data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 + data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 + # Setup template grid + dx_small, xmin, xmax, dy_small, ymin, ymax = imod.util.spatial_reference(msw_bound) + dx = dx_small * 2 + dy = dy_small * 2 + expected_spatial_ref = dx, xmin, xmax, dy, ymin, ymax + like = imod.util.empty_2d(*expected_spatial_ref) + # Act + rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) + rch_coarse = rch.regrid_like(like, regrid_cache=RegridderWeightsCache()) + # Assert + actual_spatial_ref = imod.util.spatial_reference(rch_coarse.dataset["rate"]) + assert actual_spatial_ref == expected_spatial_ref + + +@pytest.mark.unittest_jit +def test_from_imod5_cap_data__clip_box(imod5_dataset): + # Arrange + data = deepcopy(imod5_dataset[0]) + target_discretization = StructuredDiscretization.from_imod5_data(data) + data["extra"] = {"paths": ["path1", "path2"]} + data["cap"] = {} + msw_bound = data["bnd"]["ibound"].isel(layer=0, drop=False) + data["cap"]["boundary"] = msw_bound + data["cap"]["wetted_area"] = xr.ones_like(msw_bound) * 100 + data["cap"]["urban_area"] = xr.ones_like(msw_bound) * 200 + # Setup template grid + dx, xmin, xmax, dy, ymin, ymax = imod.util.spatial_reference(msw_bound) + xmin_to_clip = xmin + 10 * dx + expected_spatial_ref = dx, xmin_to_clip, xmax, dy, ymin, ymax + # Act + rch = imod.mf6.Recharge.from_imod5_cap_data(data, target_discretization) + rch_clipped = rch.clip_box(x_min=xmin_to_clip) + # Assert + actual_spatial_ref = imod.util.spatial_reference(rch_clipped.dataset["rate"]) + assert actual_spatial_ref == expected_spatial_ref diff --git a/imod/tests/test_mf6/test_mf6_riv.py b/imod/tests/test_mf6/test_mf6_riv.py index 3c7ed137d..0edd6b5fa 100644 --- a/imod/tests/test_mf6/test_mf6_riv.py +++ b/imod/tests/test_mf6/test_mf6_riv.py @@ -1,998 +1,998 @@ -import pathlib -import re -import tempfile -import textwrap -from copy import deepcopy -from datetime import datetime - -import numpy as np -import pytest -import xarray as xr -import xugrid as xu -from pytest_cases import parametrize_with_cases - -import imod -from imod.mf6.dis import StructuredDiscretization -from imod.mf6.disv import VerticesDiscretization -from imod.mf6.npf import NodePropertyFlow -from imod.mf6.write_context import WriteContext -from imod.prepare.topsystem.allocation import ALLOCATION_OPTION -from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION -from imod.prepare.topsystem.default_allocation_methods import ( - SimulationAllocationOptions, - SimulationDistributingOptions, -) -from imod.schemata import ValidationError -from imod.typing.grid import ( - enforce_dim_order, - has_negative_layer, - is_planar_grid, - ones_like, - zeros_like, -) - -TYPE_DIS_PKG = { - xu.UgridDataArray: VerticesDiscretization, - xr.DataArray: StructuredDiscretization, -} - - -def make_da(): - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - layer = [2, 3] - dx = 10.0 - dy = -10.0 - - return xr.DataArray( - data=np.ones((2, 3, 3), dtype=float), - dims=("layer", "y", "x"), - coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - ) - - -def dis_dict(): - da = make_da() - bottom = da - xr.DataArray( - data=[1.5, 2.5], dims=("layer",), coords={"layer": [2, 3]} - ) - - return {"idomain": da.astype(int), "top": da.sel(layer=2), "bottom": bottom} - - -def riv_dict(): - da = make_da() - da[:, 1, 1] = np.nan - - bottom = da - xr.DataArray( - data=[1.0, 2.0], dims=("layer",), coords={"layer": [2, 3]} - ) - - return {"stage": da, "conductance": da.copy(), "bottom_elevation": bottom} - - -def make_dict_unstructured(d): - return {key: xu.UgridDataArray.from_structured2d(value) for key, value in d.items()} - - -class RivCases: - def case_structured(self): - return riv_dict() - - def case_unstructured(self): - return make_dict_unstructured(riv_dict()) - - -class DisCases: - def case_structured(self): - return dis_dict() - - def case_unstructured(self): - return make_dict_unstructured(dis_dict()) - - -class RivDisCases: - def case_structured(self): - return riv_dict(), dis_dict() - - def case_unstructured(self): - return make_dict_unstructured(riv_dict()), make_dict_unstructured(dis_dict()) - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_render(riv_data): - river = imod.mf6.River(**riv_data) - directory = pathlib.Path("mymodel") - globaltimes = [np.datetime64("2000-01-01")] - actual = river._render(directory, "river", globaltimes, True) - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 16 - end dimensions - - begin period 1 - open/close mymodel/river/riv.bin (binary) - end period - """ - ) - assert actual == expected - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_render_repeat_stress(riv_data): - """ - Test that rendering a river with a repeated stress period does not raise an error. - """ - globaltimes = [ - np.datetime64("2000-04-01"), - np.datetime64("2000-10-01"), - np.datetime64("2001-04-01"), - np.datetime64("2001-10-01"), - ] - - seasonal_factors = [0.8, 1.2] - seasonal_da = xr.DataArray( - seasonal_factors, dims=["time"], coords={"time": globaltimes[:2]} - ) - - riv_data["stage"] = enforce_dim_order(riv_data["stage"] * seasonal_da) - riv_data["conductance"] = enforce_dim_order(riv_data["conductance"] * seasonal_da) - riv_data["bottom_elevation"] = enforce_dim_order( - riv_data["bottom_elevation"] * seasonal_da - ) - repeat_stress = { - globaltimes[2]: globaltimes[0], - globaltimes[3]: globaltimes[1], - } - river = imod.mf6.River(repeat_stress=repeat_stress, **riv_data) - directory = pathlib.Path("mymodel") - actual = river._render(directory, "river", globaltimes, True) - - expected = textwrap.dedent( - """\ - begin options - end options - - begin dimensions - maxbound 16 - end dimensions - - begin period 1 - open/close mymodel/river/riv-0.bin (binary) - end period - begin period 2 - open/close mymodel/river/riv-1.bin (binary) - end period - begin period 3 - open/close mymodel/river/riv-0.bin (binary) - end period - begin period 4 - open/close mymodel/river/riv-1.bin (binary) - end period - """ - ) - assert actual == expected - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_wrong_dtype(riv_data): - riv_data["stage"] = riv_data["stage"].astype(int) - - with pytest.raises(ValidationError): - imod.mf6.River(**riv_data) - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_all_nan(riv_data, dis_data): - # Use where to set everything to np.nan - for var in ["stage", "conductance", "bottom_elevation"]: - riv_data[var] = riv_data[var].where(False) - - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 1 - assert "stage" in errors.keys() - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_validate_inconsistent_nan(riv_data, dis_data): - riv_data["stage"][..., 2] = np.nan - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 2 - assert "bottom_elevation" in errors.keys() - assert "conductance" in errors.keys() - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_cleanup_inconsistent_nan(riv_data, dis_data): - riv_data["stage"][..., 2] = np.nan - river = imod.mf6.River(**riv_data) - type_grid = type(riv_data["stage"]) - dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) - - river.cleanup(dis_pkg) - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 0 - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_layer_as_coord_in_active_cells(riv_data, dis_data): - # Test if no bugs like https://github.com/Deltares/imod-python/issues/830 - river = imod.mf6.River(**riv_data) - river.dataset = river.dataset.sel(layer=2, drop=False) - - dis_data["idomain"][1, ...] = 0 - - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 0 - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_layer_as_coord_in_inactive_cells(riv_data, dis_data): - river = imod.mf6.River(**riv_data) - river.dataset = river.dataset.sel(layer=2, drop=False) - - dis_data["idomain"][0, ...] = 0 - - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 1 - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_check_layer(riv_data): - """ - Test for error thrown if variable has no layer coord - """ - riv_data["stage"] = riv_data["stage"].sel(layer=2, drop=True) - - message = textwrap.dedent( - """ - - stage - - coords has missing keys: {'layer'}""" - ) - - with pytest.raises( - ValidationError, - match=re.escape(message), - ): - imod.mf6.River(**riv_data) - - -def test_check_dimsize_zero(): - """ - Test that error is thrown for layer dim size 0. - """ - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - dx = 10.0 - dy = -10.0 - - da = xr.DataArray( - data=np.ones((0, 3, 3), dtype=float), - dims=("layer", "y", "x"), - coords={"layer": [], "y": y, "x": x, "dx": dx, "dy": dy}, - ) - - da[:, 1, 1] = np.nan - - message = textwrap.dedent( - """ - - stage - - provided dimension layer with size 0 - - conductance - - provided dimension layer with size 0 - - bottom_elevation - - provided dimension layer with size 0""" - ) - - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.River(stage=da, conductance=da, bottom_elevation=da - 1.0) - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_validate_zero_conductance(riv_data, dis_data): - """ - Test for validation zero conductance - """ - riv_data["conductance"][..., 2] = 0.0 - - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 1 - for var, var_errors in errors.items(): - assert var == "conductance" - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_cleanup_zero_conductance(riv_data, dis_data): - """ - Cleanup zero conductance - """ - riv_data["conductance"][..., 2] = 0.0 - type_grid = type(riv_data["stage"]) - dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) - - river = imod.mf6.River(**riv_data) - river.cleanup(dis_pkg) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - assert len(errors) == 0 - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_validate_bottom_above_stage(riv_data, dis_data): - """ - Validate that river bottom is not above stage. - """ - - riv_data["bottom_elevation"] = riv_data["bottom_elevation"] + 10.0 - - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 1 - assert "stage" in errors.keys() - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_cleanup_bottom_above_stage(riv_data, dis_data): - """ - Cleanup river bottom above stage. - """ - - riv_data["bottom_elevation"] = riv_data["bottom_elevation"] + 10.0 - type_grid = type(riv_data["stage"]) - dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) - - river = imod.mf6.River(**riv_data) - river.cleanup(dis_pkg) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 0 - assert river.dataset["bottom_elevation"].equals(river.dataset["stage"]) - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_check_riv_bottom_above_dis_bottom(riv_data, dis_data): - """ - Check that river bottom not above dis bottom. - """ - - river = imod.mf6.River(**riv_data) - # Verify no errors initially - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - assert len(errors) == 0 - - # Adapt dis bottom to be above river bottom - dis_data["bottom"] += 2.0 - - # Should not error if icelltype <= 0 - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - assert len(errors) == 0 - # Error if icelltype > 0 - errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) - - assert len(errors) == 1 - for var, var_errors in errors.items(): - assert var == "bottom_elevation" - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_check_boundary_outside_active_domain(riv_data, dis_data): - """ - Check that river not outside idomain - """ - - river = imod.mf6.River(**riv_data) - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 0 - - dis_data["idomain"][..., 0] = 0 - - errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) - - assert len(errors) == 1 - - -@parametrize_with_cases("riv_data", cases=RivCases) -def test_aggregate_layers(riv_data): - river = imod.mf6.River(**riv_data) - - expected_type = ( - xu.UgridDataArray - if isinstance(river.dataset, xu.UgridDataset) - else xr.DataArray - ) - - planar_dict = river.aggregate_layers(river.dataset) - assert isinstance(planar_dict, dict) - for value in planar_dict.values(): - assert isinstance(value, expected_type) - assert "layer" not in value.dims - assert "layer" not in value.coords - - # Conductance should be summed, stage averaged - assert not (planar_dict["stage"] > planar_dict["conductance"]).any() - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_reallocate(riv_data, dis_data): - # Arrange - river = imod.mf6.River(**riv_data) - is_unstructured = isinstance(riv_data["stage"], xu.UgridDataArray) - dis_pkg_type = ( - imod.mf6.VerticesDiscretization - if is_unstructured - else imod.mf6.StructuredDiscretization - ) - dis = dis_pkg_type(**dis_data) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - allocation_option = ALLOCATION_OPTION.stage_to_riv_bot - distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity - # Act - river_reallocated = river.reallocate( - dis, npf, allocation_option, distributing_option - ) - # Assert - assert isinstance(river_reallocated, imod.mf6.River) - assert not river_reallocated.dataset.equals(river.dataset) - assert ( - river_reallocated["conductance"] - .sum("layer") - .equals(river["conductance"].sum("layer")) - ) - assert river_reallocated["stage"].mean("layer").equals(river["stage"].mean("layer")) - - -@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) -def test_reallocate__wrong_allocation_option(riv_data, dis_data): - # Arrange - river = imod.mf6.River(**riv_data) - is_unstructured = isinstance(riv_data["stage"], xu.UgridDataArray) - dis_pkg_type = ( - imod.mf6.VerticesDiscretization - if is_unstructured - else imod.mf6.StructuredDiscretization - ) - dis = dis_pkg_type(**dis_data) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - allocation_option = ( - ALLOCATION_OPTION.stage_to_riv_bot_drn_above - ) # unsupported option - distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity - # Act - with pytest.raises( - ValueError, - match="Allocation option ALLOCATION_OPTION.stage_to_riv_bot_drn_above", - ): - river.reallocate(dis, npf, allocation_option, distributing_option) - - -def test_reallocate_drop_empty_layers(): - """ - drop_empty_layers=True should trim layers off the final package without - changing the values of the layers that remain (Option A: allocation and - conductance distribution always run over the full layer range first). - """ - x = [5.0, 15.0, 25.0] - y = [25.0, 15.0, 5.0] - dx, dy = 10.0, -10.0 - layer = [1, 2, 3, 4] - - top = xr.DataArray( - 0.0, coords={"y": y, "x": x, "dx": dx, "dy": dy}, dims=("y", "x") - ) - bottom = xr.DataArray( - np.array([-1.0, -2.0, -3.0, -4.0])[:, None, None] * np.ones((4, 3, 3)), - coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - dims=("layer", "y", "x"), - ) - idomain = xr.DataArray( - np.ones((4, 3, 3), dtype=int), - coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, - dims=("layer", "y", "x"), - ) - dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) - npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) - - # Stage and bottom entirely confined to layer 2 (-1.0 to -2.0). - planar_coords = {"y": y, "x": x, "dx": dx, "dy": dy} - river = imod.mf6.River( - stage=xr.DataArray(-1.1, coords=planar_coords, dims=("y", "x")).expand_dims( - layer=[1] - ), - conductance=xr.DataArray( - 10.0, coords=planar_coords, dims=("y", "x") - ).expand_dims(layer=[1]), - bottom_elevation=xr.DataArray( - -1.9, coords=planar_coords, dims=("y", "x") - ).expand_dims(layer=[1]), - ) - - allocation_option = ALLOCATION_OPTION.stage_to_riv_bot - distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity - - full = river.reallocate( - dis, npf, allocation_option, distributing_option, drop_empty_layers=False - ) - trimmed = river.reallocate( - dis, npf, allocation_option, distributing_option, drop_empty_layers=True - ) - - full_layers = full.dataset["layer"].values - trimmed_layers = trimmed.dataset["layer"].values - assert set(trimmed_layers) <= set(full_layers) - assert len(trimmed_layers) < len(full_layers) - np.testing.assert_allclose( - trimmed["conductance"].sel(layer=trimmed_layers).values, - full["conductance"].sel(layer=trimmed_layers).values, - equal_nan=True, - ) - np.testing.assert_allclose( - trimmed["stage"].sel(layer=trimmed_layers).values, - full["stage"].sel(layer=trimmed_layers).values, - equal_nan=True, - ) - - -def test_check_dim_monotonicity(): - """ - Test if dimensions are monotonically increasing or, in case of the y coord, - decreasing - """ - riv_ds = xr.merge([riv_dict()]) - - message = textwrap.dedent( - """ - - stage - - coord y which is not monotonically decreasing - - conductance - - coord y which is not monotonically decreasing - - bottom_elevation - - coord y which is not monotonically decreasing""" - ) - - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.River(**riv_ds.sel(y=slice(None, None, -1))) - - message = textwrap.dedent( - """ - - stage - - coord x which is not monotonically increasing - - conductance - - coord x which is not monotonically increasing - - bottom_elevation - - coord x which is not monotonically increasing""" - ) - - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.River(**riv_ds.sel(x=slice(None, None, -1))) - - message = textwrap.dedent( - """ - - stage - - coord layer which is not monotonically increasing - - conductance - - coord layer which is not monotonically increasing - - bottom_elevation - - coord layer which is not monotonically increasing""" - ) - - with pytest.raises(ValidationError, match=re.escape(message)): - imod.mf6.River(**riv_ds.sel(layer=slice(None, None, -1))) - - -def test_validate_false(): - """ - Test turning off validation - """ - - riv_ds = xr.merge([riv_dict()]) - - imod.mf6.River(validate=False, **riv_ds.sel(layer=slice(None, None, -1))) - - -def test_render_concentration(concentration_fc): - riv_ds = xr.merge([riv_dict()]) - - concentration = concentration_fc.sel( - layer=[2, 3], time=np.datetime64("2000-01-01"), drop=True - ) - riv_ds["concentration"] = concentration.where(~np.isnan(riv_ds["stage"])) - - directory = pathlib.Path("mymodel") - globaltimes = [np.datetime64("2000-01-01")] - - riv = imod.mf6.River(concentration_boundary_type="AUX", **riv_ds) - actual = riv._render(directory, "riv", globaltimes, False) - - expected = textwrap.dedent( - """\ - begin options - auxiliary salinity temperature - end options - - begin dimensions - maxbound 16 - end dimensions - - begin period 1 - open/close mymodel/riv/riv.dat - end period - """ - ) - assert actual == expected - - -def test_write_concentration_period_data(concentration_fc): - globaltimes = [np.datetime64("2000-01-01")] - concentration_fc[:] = 2 - stage = xr.full_like(concentration_fc.sel({"species": "salinity"}), 13) - conductance = xr.full_like(stage, 13) - bottom_elevation = xr.full_like(stage, 13) - riv = imod.mf6.River( - stage=stage, - conductance=conductance, - bottom_elevation=bottom_elevation, - concentration=concentration_fc, - concentration_boundary_type="AUX", - ) - with tempfile.TemporaryDirectory() as output_dir: - write_context = WriteContext(simulation_directory=output_dir) - riv._write("riv", globaltimes, write_context) - with open(output_dir + "/riv/riv-0.dat", "r") as f: - data = f.read() - assert ( - data.count("2") == 1755 - ) # the number 2 is in the concentration data, and in the cell indices. - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5(imod5_dataset, tmp_path): - imod5_data = imod5_dataset[0] - period_data = imod5_dataset[1] - globaltimes = [np.datetime64("2000-01-01")] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=SimulationAllocationOptions.riv, - distributing_option=SimulationDistributingOptions.riv, - regridder_types=None, - ) - - write_context = WriteContext(simulation_directory=tmp_path) - riv._write("riv", globaltimes, write_context) - drn._write("drn", globaltimes, write_context) - - # set icelltype=1.0 to enforce checking river bottom above dis bottom - errors = riv._validate( - imod.mf6.River._write_schemata, - icelltype=1.0, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - ) - assert len(errors) == 0 - - errors = drn._validate( - imod.mf6.Drainage._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - ) - assert len(errors) == 0 - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__negative_layer(imod5_dataset, tmp_path): - # Arrange - imod5_data = imod5_dataset[0] - period_data = imod5_dataset[1] - globaltimes = [np.datetime64("2000-01-01")] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - # Gather reference packages (for negative layers, allocation option - # "at_first_active" should be taken) - (riv_reference, drn_reference) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_first_active, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - # Set layer to -1 - original_riv_1 = deepcopy(imod5_data["riv-1"]) - imod5_data["riv-1"] = { - key: da.assign_coords(layer=[-1]) for key, da in imod5_data["riv-1"].items() - } - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - write_context = WriteContext(simulation_directory=tmp_path) - riv._write("riv", globaltimes, write_context) - drn._write("drn", globaltimes, write_context) - - # Assert - # Test if arrangement is correctly set up - assert is_planar_grid(imod5_data["riv-1"]["conductance"]) - assert has_negative_layer(imod5_data["riv-1"]["conductance"]) - - errors = riv._validate( - imod.mf6.River._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - icelltype=1.0, - ) - assert len(errors) == 0 - errors = drn._validate( - imod.mf6.Drainage._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - ) - assert len(errors) == 0 - - assert riv.dataset.identical(riv_reference.dataset) - assert drn.dataset.identical(drn_reference.dataset) - - # teardown - imod5_data["riv-1"] = original_riv_1 - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__infiltration_factors(imod5_dataset): - imod5_data = imod5_dataset[0] - period_data = imod5_dataset[1] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] - imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - assert riv is not None - assert drn is None - - imod5_data["riv-1"]["infiltration_factor"] = zeros_like( - original_infiltration_factor - ) - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - assert riv is None - assert drn is not None - - # teardown - imod5_data["riv-1"]["infiltration_factor"] = original_infiltration_factor - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__constant(imod5_dataset): - """Test importing river with a constant infiltration factor.""" - imod5_data = imod5_dataset[0] - period_data = imod5_dataset[1] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] - layer = original_infiltration_factor.coords["layer"] - imod5_data["riv-1"]["infiltration_factor"] = xr.DataArray( - [1.0], coords={"layer": layer}, dims=("layer",) - ) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - period_data, - target_dis, - target_npf, - time_min=datetime(2000, 1, 1), - time_max=datetime(2002, 1, 1), - allocation_option=ALLOCATION_OPTION.at_elevation, - distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, - regridder_types=None, - ) - - assert riv is not None - assert drn is None - - # teardown - imod5_data["riv-1"]["infiltration_factor"] = original_infiltration_factor - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__period_data(imod5_dataset_periods, tmp_path): - imod5_data = imod5_dataset_periods[0] - imod5_periods = imod5_dataset_periods[1] - globaltimes = [np.datetime64("2000-01-01"), np.datetime64("2001-01-01")] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] - imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - imod5_periods, - target_dis, - target_npf, - datetime(2002, 2, 2), - datetime(2022, 2, 2), - ALLOCATION_OPTION.stage_to_riv_bot_drn_above, - SimulationDistributingOptions.riv, - regridder_types=None, - ) - - assert riv is not None - assert drn is not None - - errors = riv._validate( - imod.mf6.River._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - icelltype=1.0, - ) - assert len(errors) == 0 - - errors = drn._validate( - imod.mf6.Drainage._write_schemata, - idomain=target_dis.dataset["idomain"], - bottom=target_dis.dataset["bottom"], - ) - assert len(errors) == 0 - - riv_time = riv.dataset.coords["time"].data - drn_time = drn.dataset.coords["time"].data - expected_times = np.array( - [ - np.datetime64("2002-02-02"), - np.datetime64("2002-04-01"), - np.datetime64("2002-10-01"), - ] - ) - np.testing.assert_array_equal(riv_time, expected_times) - np.testing.assert_array_equal(drn_time, expected_times) - - riv_repeat_stress = riv.dataset["repeat_stress"].data - drn_repeat_stress = drn.dataset["repeat_stress"].data - assert np.all(riv_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(riv_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - assert np.all(drn_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) - assert np.all(drn_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) - - write_context = WriteContext(simulation_directory=tmp_path) - riv._write("riv", globaltimes, write_context) - drn._write("drn", globaltimes, write_context) - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5_and_cleanup__period_data(imod5_dataset_periods): - imod5_data = imod5_dataset_periods[0] - imod5_periods = imod5_dataset_periods[1] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - imod5_periods, - target_dis, - target_npf, - datetime(2002, 2, 2), - datetime(2022, 2, 2), - ALLOCATION_OPTION.stage_to_riv_bot_drn_above, - SimulationDistributingOptions.riv, - regridder_types=None, - ) - - riv.cleanup(target_dis) - drn.cleanup(target_dis) - - -@pytest.mark.unittest_jit -def test_import_river_from_imod5__transient_data(imod5_dataset_transient): - """ - Test if importing a river from an IMOD5 dataset with transient data works - correctly and that the time data is clipped to the specified time range. - """ - imod5_data = imod5_dataset_transient[0] - imod5_periods = imod5_dataset_transient[1] - target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) - grid = target_dis.dataset["idomain"] - target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) - - original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] - imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) - - (riv, drn) = imod.mf6.River.from_imod5_data( - "riv-1", - imod5_data, - imod5_periods, - target_dis, - target_npf, - datetime(2000, 4, 1), - datetime(2010, 1, 1), - ALLOCATION_OPTION.stage_to_riv_bot_drn_above, - SimulationDistributingOptions.riv, - regridder_types=None, - ) - - assert riv is not None - assert drn is not None - - riv_time = riv.dataset.coords["time"].data - drn_time = drn.dataset.coords["time"].data - assert riv_time[0] == np.datetime64("2000-04-01") - assert riv_time[-1] == np.datetime64("2003-01-01") - assert drn_time[0] == np.datetime64("2000-04-01") - assert drn_time[-1] == np.datetime64("2003-01-01") +import pathlib +import re +import tempfile +import textwrap +from copy import deepcopy +from datetime import datetime + +import numpy as np +import pytest +import xarray as xr +import xugrid as xu +from pytest_cases import parametrize_with_cases + +import imod +from imod.mf6.dis import StructuredDiscretization +from imod.mf6.disv import VerticesDiscretization +from imod.mf6.npf import NodePropertyFlow +from imod.mf6.write_context import WriteContext +from imod.prepare.topsystem.allocation import ALLOCATION_OPTION +from imod.prepare.topsystem.conductance import DISTRIBUTING_OPTION +from imod.prepare.topsystem.default_allocation_methods import ( + SimulationAllocationOptions, + SimulationDistributingOptions, +) +from imod.schemata import ValidationError +from imod.typing.grid import ( + enforce_dim_order, + has_negative_layer, + is_planar_grid, + ones_like, + zeros_like, +) + +TYPE_DIS_PKG = { + xu.UgridDataArray: VerticesDiscretization, + xr.DataArray: StructuredDiscretization, +} + + +def make_da(): + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + layer = [2, 3] + dx = 10.0 + dy = -10.0 + + return xr.DataArray( + data=np.ones((2, 3, 3), dtype=float), + dims=("layer", "y", "x"), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + ) + + +def dis_dict(): + da = make_da() + bottom = da - xr.DataArray( + data=[1.5, 2.5], dims=("layer",), coords={"layer": [2, 3]} + ) + + return {"idomain": da.astype(int), "top": da.sel(layer=2), "bottom": bottom} + + +def riv_dict(): + da = make_da() + da[:, 1, 1] = np.nan + + bottom = da - xr.DataArray( + data=[1.0, 2.0], dims=("layer",), coords={"layer": [2, 3]} + ) + + return {"stage": da, "conductance": da.copy(), "bottom_elevation": bottom} + + +def make_dict_unstructured(d): + return {key: xu.UgridDataArray.from_structured2d(value) for key, value in d.items()} + + +class RivCases: + def case_structured(self): + return riv_dict() + + def case_unstructured(self): + return make_dict_unstructured(riv_dict()) + + +class DisCases: + def case_structured(self): + return dis_dict() + + def case_unstructured(self): + return make_dict_unstructured(dis_dict()) + + +class RivDisCases: + def case_structured(self): + return riv_dict(), dis_dict() + + def case_unstructured(self): + return make_dict_unstructured(riv_dict()), make_dict_unstructured(dis_dict()) + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_render(riv_data): + river = imod.mf6.River(**riv_data) + directory = pathlib.Path("mymodel") + globaltimes = [np.datetime64("2000-01-01")] + actual = river._render(directory, "river", globaltimes, True) + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 16 + end dimensions + + begin period 1 + open/close mymodel/river/riv.bin (binary) + end period + """ + ) + assert actual == expected + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_render_repeat_stress(riv_data): + """ + Test that rendering a river with a repeated stress period does not raise an error. + """ + globaltimes = [ + np.datetime64("2000-04-01"), + np.datetime64("2000-10-01"), + np.datetime64("2001-04-01"), + np.datetime64("2001-10-01"), + ] + + seasonal_factors = [0.8, 1.2] + seasonal_da = xr.DataArray( + seasonal_factors, dims=["time"], coords={"time": globaltimes[:2]} + ) + + riv_data["stage"] = enforce_dim_order(riv_data["stage"] * seasonal_da) + riv_data["conductance"] = enforce_dim_order(riv_data["conductance"] * seasonal_da) + riv_data["bottom_elevation"] = enforce_dim_order( + riv_data["bottom_elevation"] * seasonal_da + ) + repeat_stress = { + globaltimes[2]: globaltimes[0], + globaltimes[3]: globaltimes[1], + } + river = imod.mf6.River(repeat_stress=repeat_stress, **riv_data) + directory = pathlib.Path("mymodel") + actual = river._render(directory, "river", globaltimes, True) + + expected = textwrap.dedent( + """\ + begin options + end options + + begin dimensions + maxbound 16 + end dimensions + + begin period 1 + open/close mymodel/river/riv-0.bin (binary) + end period + begin period 2 + open/close mymodel/river/riv-1.bin (binary) + end period + begin period 3 + open/close mymodel/river/riv-0.bin (binary) + end period + begin period 4 + open/close mymodel/river/riv-1.bin (binary) + end period + """ + ) + assert actual == expected + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_wrong_dtype(riv_data): + riv_data["stage"] = riv_data["stage"].astype(int) + + with pytest.raises(ValidationError): + imod.mf6.River(**riv_data) + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_all_nan(riv_data, dis_data): + # Use where to set everything to np.nan + for var in ["stage", "conductance", "bottom_elevation"]: + riv_data[var] = riv_data[var].where(False) + + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 1 + assert "stage" in errors.keys() + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_validate_inconsistent_nan(riv_data, dis_data): + riv_data["stage"][..., 2] = np.nan + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 2 + assert "bottom_elevation" in errors.keys() + assert "conductance" in errors.keys() + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_cleanup_inconsistent_nan(riv_data, dis_data): + riv_data["stage"][..., 2] = np.nan + river = imod.mf6.River(**riv_data) + type_grid = type(riv_data["stage"]) + dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) + + river.cleanup(dis_pkg) + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 0 + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_layer_as_coord_in_active_cells(riv_data, dis_data): + # Test if no bugs like https://github.com/Deltares/imod-python/issues/830 + river = imod.mf6.River(**riv_data) + river.dataset = river.dataset.sel(layer=2, drop=False) + + dis_data["idomain"][1, ...] = 0 + + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 0 + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_layer_as_coord_in_inactive_cells(riv_data, dis_data): + river = imod.mf6.River(**riv_data) + river.dataset = river.dataset.sel(layer=2, drop=False) + + dis_data["idomain"][0, ...] = 0 + + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 1 + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_check_layer(riv_data): + """ + Test for error thrown if variable has no layer coord + """ + riv_data["stage"] = riv_data["stage"].sel(layer=2, drop=True) + + message = textwrap.dedent( + """ + - stage + - coords has missing keys: {'layer'}""" + ) + + with pytest.raises( + ValidationError, + match=re.escape(message), + ): + imod.mf6.River(**riv_data) + + +def test_check_dimsize_zero(): + """ + Test that error is thrown for layer dim size 0. + """ + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + dx = 10.0 + dy = -10.0 + + da = xr.DataArray( + data=np.ones((0, 3, 3), dtype=float), + dims=("layer", "y", "x"), + coords={"layer": [], "y": y, "x": x, "dx": dx, "dy": dy}, + ) + + da[:, 1, 1] = np.nan + + message = textwrap.dedent( + """ + - stage + - provided dimension layer with size 0 + - conductance + - provided dimension layer with size 0 + - bottom_elevation + - provided dimension layer with size 0""" + ) + + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.River(stage=da, conductance=da, bottom_elevation=da - 1.0) + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_validate_zero_conductance(riv_data, dis_data): + """ + Test for validation zero conductance + """ + riv_data["conductance"][..., 2] = 0.0 + + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 1 + for var, var_errors in errors.items(): + assert var == "conductance" + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_cleanup_zero_conductance(riv_data, dis_data): + """ + Cleanup zero conductance + """ + riv_data["conductance"][..., 2] = 0.0 + type_grid = type(riv_data["stage"]) + dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) + + river = imod.mf6.River(**riv_data) + river.cleanup(dis_pkg) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + assert len(errors) == 0 + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_validate_bottom_above_stage(riv_data, dis_data): + """ + Validate that river bottom is not above stage. + """ + + riv_data["bottom_elevation"] = riv_data["bottom_elevation"] + 10.0 + + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 1 + assert "stage" in errors.keys() + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_cleanup_bottom_above_stage(riv_data, dis_data): + """ + Cleanup river bottom above stage. + """ + + riv_data["bottom_elevation"] = riv_data["bottom_elevation"] + 10.0 + type_grid = type(riv_data["stage"]) + dis_pkg = TYPE_DIS_PKG[type_grid](**dis_data) + + river = imod.mf6.River(**riv_data) + river.cleanup(dis_pkg) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 0 + assert river.dataset["bottom_elevation"].equals(river.dataset["stage"]) + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_check_riv_bottom_above_dis_bottom(riv_data, dis_data): + """ + Check that river bottom not above dis bottom. + """ + + river = imod.mf6.River(**riv_data) + # Verify no errors initially + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + assert len(errors) == 0 + + # Adapt dis bottom to be above river bottom + dis_data["bottom"] += 2.0 + + # Should not error if icelltype <= 0 + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + assert len(errors) == 0 + # Error if icelltype > 0 + errors = river._validate(river._write_schemata, icelltype=1.0, **dis_data) + + assert len(errors) == 1 + for var, var_errors in errors.items(): + assert var == "bottom_elevation" + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_check_boundary_outside_active_domain(riv_data, dis_data): + """ + Check that river not outside idomain + """ + + river = imod.mf6.River(**riv_data) + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 0 + + dis_data["idomain"][..., 0] = 0 + + errors = river._validate(river._write_schemata, icelltype=0.0, **dis_data) + + assert len(errors) == 1 + + +@parametrize_with_cases("riv_data", cases=RivCases) +def test_aggregate_layers(riv_data): + river = imod.mf6.River(**riv_data) + + expected_type = ( + xu.UgridDataArray + if isinstance(river.dataset, xu.UgridDataset) + else xr.DataArray + ) + + planar_dict = river.aggregate_layers(river.dataset) + assert isinstance(planar_dict, dict) + for value in planar_dict.values(): + assert isinstance(value, expected_type) + assert "layer" not in value.dims + assert "layer" not in value.coords + + # Conductance should be summed, stage averaged + assert not (planar_dict["stage"] > planar_dict["conductance"]).any() + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_reallocate(riv_data, dis_data): + # Arrange + river = imod.mf6.River(**riv_data) + is_unstructured = isinstance(riv_data["stage"], xu.UgridDataArray) + dis_pkg_type = ( + imod.mf6.VerticesDiscretization + if is_unstructured + else imod.mf6.StructuredDiscretization + ) + dis = dis_pkg_type(**dis_data) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ALLOCATION_OPTION.stage_to_riv_bot + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + # Act + river_reallocated = river.reallocate( + dis, npf, allocation_option, distributing_option + ) + # Assert + assert isinstance(river_reallocated, imod.mf6.River) + assert not river_reallocated.dataset.equals(river.dataset) + assert ( + river_reallocated["conductance"] + .sum("layer") + .equals(river["conductance"].sum("layer")) + ) + assert river_reallocated["stage"].mean("layer").equals(river["stage"].mean("layer")) + + +@parametrize_with_cases("riv_data,dis_data", cases=RivDisCases) +def test_reallocate__wrong_allocation_option(riv_data, dis_data): + # Arrange + river = imod.mf6.River(**riv_data) + is_unstructured = isinstance(riv_data["stage"], xu.UgridDataArray) + dis_pkg_type = ( + imod.mf6.VerticesDiscretization + if is_unstructured + else imod.mf6.StructuredDiscretization + ) + dis = dis_pkg_type(**dis_data) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + allocation_option = ( + ALLOCATION_OPTION.stage_to_riv_bot_drn_above + ) # unsupported option + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + # Act + with pytest.raises( + ValueError, + match="Allocation option ALLOCATION_OPTION.stage_to_riv_bot_drn_above", + ): + river.reallocate(dis, npf, allocation_option, distributing_option) + + +def test_reallocate_drop_empty_layers(): + """ + drop_empty_layers=True should trim layers off the final package without + changing the values of the layers that remain (Option A: allocation and + conductance distribution always run over the full layer range first). + """ + x = [5.0, 15.0, 25.0] + y = [25.0, 15.0, 5.0] + dx, dy = 10.0, -10.0 + layer = [1, 2, 3, 4] + + top = xr.DataArray( + 0.0, coords={"y": y, "x": x, "dx": dx, "dy": dy}, dims=("y", "x") + ) + bottom = xr.DataArray( + np.array([-1.0, -2.0, -3.0, -4.0])[:, None, None] * np.ones((4, 3, 3)), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + dims=("layer", "y", "x"), + ) + idomain = xr.DataArray( + np.ones((4, 3, 3), dtype=int), + coords={"layer": layer, "y": y, "x": x, "dx": dx, "dy": dy}, + dims=("layer", "y", "x"), + ) + dis = imod.mf6.StructuredDiscretization(top=top, bottom=bottom, idomain=idomain) + npf = imod.mf6.NodePropertyFlow(icelltype=0, k=1.0) + + # Stage and bottom entirely confined to layer 2 (-1.0 to -2.0). + planar_coords = {"y": y, "x": x, "dx": dx, "dy": dy} + river = imod.mf6.River( + stage=xr.DataArray(-1.1, coords=planar_coords, dims=("y", "x")).expand_dims( + layer=[1] + ), + conductance=xr.DataArray( + 10.0, coords=planar_coords, dims=("y", "x") + ).expand_dims(layer=[1]), + bottom_elevation=xr.DataArray( + -1.9, coords=planar_coords, dims=("y", "x") + ).expand_dims(layer=[1]), + ) + + allocation_option = ALLOCATION_OPTION.stage_to_riv_bot + distributing_option = DISTRIBUTING_OPTION.by_corrected_transmissivity + + full = river.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=False + ) + trimmed = river.reallocate( + dis, npf, allocation_option, distributing_option, drop_empty_layers=True + ) + + full_layers = full.dataset["layer"].values + trimmed_layers = trimmed.dataset["layer"].values + assert set(trimmed_layers) <= set(full_layers) + assert len(trimmed_layers) < len(full_layers) + np.testing.assert_allclose( + trimmed["conductance"].sel(layer=trimmed_layers).values, + full["conductance"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + np.testing.assert_allclose( + trimmed["stage"].sel(layer=trimmed_layers).values, + full["stage"].sel(layer=trimmed_layers).values, + equal_nan=True, + ) + + +def test_check_dim_monotonicity(): + """ + Test if dimensions are monotonically increasing or, in case of the y coord, + decreasing + """ + riv_ds = xr.merge([riv_dict()]) + + message = textwrap.dedent( + """ + - stage + - coord y which is not monotonically decreasing + - conductance + - coord y which is not monotonically decreasing + - bottom_elevation + - coord y which is not monotonically decreasing""" + ) + + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.River(**riv_ds.sel(y=slice(None, None, -1))) + + message = textwrap.dedent( + """ + - stage + - coord x which is not monotonically increasing + - conductance + - coord x which is not monotonically increasing + - bottom_elevation + - coord x which is not monotonically increasing""" + ) + + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.River(**riv_ds.sel(x=slice(None, None, -1))) + + message = textwrap.dedent( + """ + - stage + - coord layer which is not monotonically increasing + - conductance + - coord layer which is not monotonically increasing + - bottom_elevation + - coord layer which is not monotonically increasing""" + ) + + with pytest.raises(ValidationError, match=re.escape(message)): + imod.mf6.River(**riv_ds.sel(layer=slice(None, None, -1))) + + +def test_validate_false(): + """ + Test turning off validation + """ + + riv_ds = xr.merge([riv_dict()]) + + imod.mf6.River(validate=False, **riv_ds.sel(layer=slice(None, None, -1))) + + +def test_render_concentration(concentration_fc): + riv_ds = xr.merge([riv_dict()]) + + concentration = concentration_fc.sel( + layer=[2, 3], time=np.datetime64("2000-01-01"), drop=True + ) + riv_ds["concentration"] = concentration.where(~np.isnan(riv_ds["stage"])) + + directory = pathlib.Path("mymodel") + globaltimes = [np.datetime64("2000-01-01")] + + riv = imod.mf6.River(concentration_boundary_type="AUX", **riv_ds) + actual = riv._render(directory, "riv", globaltimes, False) + + expected = textwrap.dedent( + """\ + begin options + auxiliary salinity temperature + end options + + begin dimensions + maxbound 16 + end dimensions + + begin period 1 + open/close mymodel/riv/riv.dat + end period + """ + ) + assert actual == expected + + +def test_write_concentration_period_data(concentration_fc): + globaltimes = [np.datetime64("2000-01-01")] + concentration_fc[:] = 2 + stage = xr.full_like(concentration_fc.sel({"species": "salinity"}), 13) + conductance = xr.full_like(stage, 13) + bottom_elevation = xr.full_like(stage, 13) + riv = imod.mf6.River( + stage=stage, + conductance=conductance, + bottom_elevation=bottom_elevation, + concentration=concentration_fc, + concentration_boundary_type="AUX", + ) + with tempfile.TemporaryDirectory() as output_dir: + write_context = WriteContext(simulation_directory=output_dir) + riv._write("riv", globaltimes, write_context) + with open(output_dir + "/riv/riv-0.dat", "r") as f: + data = f.read() + assert ( + data.count("2") == 1755 + ) # the number 2 is in the concentration data, and in the cell indices. + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5(imod5_dataset, tmp_path): + imod5_data = imod5_dataset[0] + period_data = imod5_dataset[1] + globaltimes = [np.datetime64("2000-01-01")] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=SimulationAllocationOptions.riv, + distributing_option=SimulationDistributingOptions.riv, + regridder_types=None, + ) + + write_context = WriteContext(simulation_directory=tmp_path) + riv._write("riv", globaltimes, write_context) + drn._write("drn", globaltimes, write_context) + + # set icelltype=1.0 to enforce checking river bottom above dis bottom + errors = riv._validate( + imod.mf6.River._write_schemata, + icelltype=1.0, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + ) + assert len(errors) == 0 + + errors = drn._validate( + imod.mf6.Drainage._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + ) + assert len(errors) == 0 + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__negative_layer(imod5_dataset, tmp_path): + # Arrange + imod5_data = imod5_dataset[0] + period_data = imod5_dataset[1] + globaltimes = [np.datetime64("2000-01-01")] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + # Gather reference packages (for negative layers, allocation option + # "at_first_active" should be taken) + (riv_reference, drn_reference) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_first_active, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + # Set layer to -1 + original_riv_1 = deepcopy(imod5_data["riv-1"]) + imod5_data["riv-1"] = { + key: da.assign_coords(layer=[-1]) for key, da in imod5_data["riv-1"].items() + } + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + write_context = WriteContext(simulation_directory=tmp_path) + riv._write("riv", globaltimes, write_context) + drn._write("drn", globaltimes, write_context) + + # Assert + # Test if arrangement is correctly set up + assert is_planar_grid(imod5_data["riv-1"]["conductance"]) + assert has_negative_layer(imod5_data["riv-1"]["conductance"]) + + errors = riv._validate( + imod.mf6.River._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + icelltype=1.0, + ) + assert len(errors) == 0 + errors = drn._validate( + imod.mf6.Drainage._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + ) + assert len(errors) == 0 + + assert riv.dataset.identical(riv_reference.dataset) + assert drn.dataset.identical(drn_reference.dataset) + + # teardown + imod5_data["riv-1"] = original_riv_1 + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__infiltration_factors(imod5_dataset): + imod5_data = imod5_dataset[0] + period_data = imod5_dataset[1] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] + imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + assert riv is not None + assert drn is None + + imod5_data["riv-1"]["infiltration_factor"] = zeros_like( + original_infiltration_factor + ) + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + assert riv is None + assert drn is not None + + # teardown + imod5_data["riv-1"]["infiltration_factor"] = original_infiltration_factor + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__constant(imod5_dataset): + """Test importing river with a constant infiltration factor.""" + imod5_data = imod5_dataset[0] + period_data = imod5_dataset[1] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] + layer = original_infiltration_factor.coords["layer"] + imod5_data["riv-1"]["infiltration_factor"] = xr.DataArray( + [1.0], coords={"layer": layer}, dims=("layer",) + ) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + period_data, + target_dis, + target_npf, + time_min=datetime(2000, 1, 1), + time_max=datetime(2002, 1, 1), + allocation_option=ALLOCATION_OPTION.at_elevation, + distributing_option=DISTRIBUTING_OPTION.by_crosscut_thickness, + regridder_types=None, + ) + + assert riv is not None + assert drn is None + + # teardown + imod5_data["riv-1"]["infiltration_factor"] = original_infiltration_factor + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__period_data(imod5_dataset_periods, tmp_path): + imod5_data = imod5_dataset_periods[0] + imod5_periods = imod5_dataset_periods[1] + globaltimes = [np.datetime64("2000-01-01"), np.datetime64("2001-01-01")] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] + imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + imod5_periods, + target_dis, + target_npf, + datetime(2002, 2, 2), + datetime(2022, 2, 2), + ALLOCATION_OPTION.stage_to_riv_bot_drn_above, + SimulationDistributingOptions.riv, + regridder_types=None, + ) + + assert riv is not None + assert drn is not None + + errors = riv._validate( + imod.mf6.River._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + icelltype=1.0, + ) + assert len(errors) == 0 + + errors = drn._validate( + imod.mf6.Drainage._write_schemata, + idomain=target_dis.dataset["idomain"], + bottom=target_dis.dataset["bottom"], + ) + assert len(errors) == 0 + + riv_time = riv.dataset.coords["time"].data + drn_time = drn.dataset.coords["time"].data + expected_times = np.array( + [ + np.datetime64("2002-02-02"), + np.datetime64("2002-04-01"), + np.datetime64("2002-10-01"), + ] + ) + np.testing.assert_array_equal(riv_time, expected_times) + np.testing.assert_array_equal(drn_time, expected_times) + + riv_repeat_stress = riv.dataset["repeat_stress"].data + drn_repeat_stress = drn.dataset["repeat_stress"].data + assert np.all(riv_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(riv_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + assert np.all(drn_repeat_stress[:, 1][::2] == np.datetime64("2002-04-01")) + assert np.all(drn_repeat_stress[:, 1][1::2] == np.datetime64("2002-10-01")) + + write_context = WriteContext(simulation_directory=tmp_path) + riv._write("riv", globaltimes, write_context) + drn._write("drn", globaltimes, write_context) + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5_and_cleanup__period_data(imod5_dataset_periods): + imod5_data = imod5_dataset_periods[0] + imod5_periods = imod5_dataset_periods[1] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + imod5_periods, + target_dis, + target_npf, + datetime(2002, 2, 2), + datetime(2022, 2, 2), + ALLOCATION_OPTION.stage_to_riv_bot_drn_above, + SimulationDistributingOptions.riv, + regridder_types=None, + ) + + riv.cleanup(target_dis) + drn.cleanup(target_dis) + + +@pytest.mark.unittest_jit +def test_import_river_from_imod5__transient_data(imod5_dataset_transient): + """ + Test if importing a river from an IMOD5 dataset with transient data works + correctly and that the time data is clipped to the specified time range. + """ + imod5_data = imod5_dataset_transient[0] + imod5_periods = imod5_dataset_transient[1] + target_dis = StructuredDiscretization.from_imod5_data(imod5_data, validate=False) + grid = target_dis.dataset["idomain"] + target_npf = NodePropertyFlow.from_imod5_data(imod5_data, grid) + + original_infiltration_factor = imod5_data["riv-1"]["infiltration_factor"] + imod5_data["riv-1"]["infiltration_factor"] = ones_like(original_infiltration_factor) + + (riv, drn) = imod.mf6.River.from_imod5_data( + "riv-1", + imod5_data, + imod5_periods, + target_dis, + target_npf, + datetime(2000, 4, 1), + datetime(2010, 1, 1), + ALLOCATION_OPTION.stage_to_riv_bot_drn_above, + SimulationDistributingOptions.riv, + regridder_types=None, + ) + + assert riv is not None + assert drn is not None + + riv_time = riv.dataset.coords["time"].data + drn_time = drn.dataset.coords["time"].data + assert riv_time[0] == np.datetime64("2000-04-01") + assert riv_time[-1] == np.datetime64("2003-01-01") + assert drn_time[0] == np.datetime64("2000-04-01") + assert drn_time[-1] == np.datetime64("2003-01-01") diff --git a/imod/tests/test_prepare/test_cleanup.py b/imod/tests/test_prepare/test_cleanup.py index d7755e872..5346a9edd 100644 --- a/imod/tests/test_prepare/test_cleanup.py +++ b/imod/tests/test_prepare/test_cleanup.py @@ -1,373 +1,373 @@ -from typing import Callable - -import geopandas as gpd -import numpy as np -import pandas as pd -import pytest -import xugrid as xu -from pytest_cases import parametrize, parametrize_with_cases -from shapely import linestrings - -from imod.prepare.cleanup import ( - cleanup_drn, - cleanup_ghb, - cleanup_hfb, - cleanup_riv, - cleanup_wel, -) -from imod.tests.test_mf6.test_mf6_riv import DisCases, RivDisCases -from imod.typing import GridDataArray - - -def _first(grid: GridDataArray): - """ - helper function to get first value, regardless of unstructured or - structured grid.""" - return grid.values.ravel()[0] - - -def _first_index(grid: GridDataArray) -> tuple: - if isinstance(grid, xu.UgridDataArray): - return (0, 0) - else: - return (0, 0, 0) - - -def _rename_data_dict(data: dict, func: Callable): - renamed = data.copy() - to_rename = _RENAME_DICT[func] - for src, dst in to_rename.items(): - mv_data = renamed.pop(src) - if dst is not None: - renamed[dst] = mv_data - return renamed - - -def _prepare_dis_dict(dis_dict: dict, func: Callable): - """Keep required dis args for specific cleanup functions""" - keep_vars = _KEEP_FROM_DIS_DICT[func] - return {var: dis_dict[var] for var in keep_vars} - - -_RENAME_DICT = { - cleanup_riv: {}, - cleanup_drn: {"stage": "elevation", "bottom_elevation": None}, - cleanup_ghb: {"stage": "head", "bottom_elevation": None}, -} - -_KEEP_FROM_DIS_DICT = { - cleanup_riv: ["idomain", "bottom"], - cleanup_drn: ["idomain"], - cleanup_ghb: ["idomain"], - cleanup_wel: ["top", "bottom"], -} - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) -def test_cleanup__align_nodata(riv_data: dict, dis_data: dict, cleanup_func: Callable): - dis_dict = _prepare_dis_dict(dis_data, cleanup_func) - data_dict = _rename_data_dict(riv_data, cleanup_func) - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) - idx = _first_index(data_dict["conductance"]) - # Arrange: Deactivate one cell - first_key = next(iter(data_dict.keys())) - data_dict[first_key][idx] = np.nan - # Act - data_cleaned = cleanup_func(**dis_dict, **data_dict) - # Assert - for key in data_cleaned.keys(): - # Test if xu.UgridDataArray not demoted to xr.DataArray - assert type(data_cleaned[key]) is type(data_dict[key]) - np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) -def test_cleanup__zero_conductance( - riv_data: dict, dis_data: dict, cleanup_func: Callable -): - dis_dict = _prepare_dis_dict(dis_data, cleanup_func) - data_dict = _rename_data_dict(riv_data, cleanup_func) - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) - idx = _first_index(data_dict["conductance"]) - # Arrange: Deactivate one cell - data_dict["conductance"][idx] = 0.0 - # Act - data_cleaned = cleanup_func(**dis_dict, **data_dict) - # Assert - for key in data_cleaned.keys(): - np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) -def test_cleanup__negative_concentration( - riv_data: dict, dis_data: dict, cleanup_func: Callable -): - dis_dict = _prepare_dis_dict(dis_data, cleanup_func) - data_dict = _rename_data_dict(riv_data, cleanup_func) - first_key = next(iter(data_dict.keys())) - # Create concentration data - data_dict["concentration"] = data_dict[first_key].copy() - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) - idx = _first_index(data_dict["conductance"]) - # Arrange: Deactivate one cell - data_dict["concentration"][idx] = -10.0 - # Act - data_cleaned = cleanup_func(**dis_dict, **data_dict) - # Assert - np.testing.assert_equal(_first(data_cleaned["concentration"]), 0.0) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) -def test_cleanup__outside_active_domain( - riv_data: dict, dis_data: dict, cleanup_func: Callable -): - dis_dict = _prepare_dis_dict(dis_data, cleanup_func) - data_dict = _rename_data_dict(riv_data, cleanup_func) - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) - idx = _first_index(data_dict["conductance"]) - # Arrange: Deactivate one cell - dis_dict["idomain"][idx] = 0.0 - # Act - data_cleaned = cleanup_func(**dis_dict, **data_dict) - # Assert - for key in data_cleaned.keys(): - np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__fix_bottom_elevation_to_bottom(riv_data: dict, dis_data: dict): - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Arrange: Set bottom elevation model layer bottom - riv_data["bottom_elevation"] -= 3.0 - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) - # Act - riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) - # Assert - # Account for cells inactive river cells. - riv_active = riv_data_cleaned["stage"].notnull() - expected = dis_dict["bottom"].where(riv_active) - - np.testing.assert_equal( - riv_data_cleaned["bottom_elevation"].values, expected.values - ) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__fix_bottom_elevation_to_stage(riv_data: dict, dis_data: dict): - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Arrange: Set bottom elevation above stage - riv_data["bottom_elevation"] += 3.0 - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) - # Act - riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) - # Assert - np.testing.assert_equal( - riv_data_cleaned["bottom_elevation"].values, riv_data_cleaned["stage"].values - ) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__stage_equals_bottom_elevation(riv_data: dict, dis_data: dict): - """Assure no cleanup accidentily takes place when stage equals bottom_elevation""" - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Arrange: Set bottom elevation equal to stage - riv_data["bottom_elevation"] = riv_data["stage"].copy() - # Assure conductance not modified by previous tests. - np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) - # Act - riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) - # Assert - np.testing.assert_equal( - riv_data_cleaned["bottom_elevation"].values, riv_data_cleaned["stage"].values - ) - np.testing.assert_equal(riv_data["stage"].values, riv_data_cleaned["stage"].values) - np.testing.assert_equal( - riv_data["bottom_elevation"].values, riv_data_cleaned["bottom_elevation"].values - ) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__trimmed_layers(riv_data: dict, dis_data: dict): - """ - A river package's own grids may have fewer layers than the model's full - ``bottom`` (e.g. produced via ``drop_empty_layers=True``, see - ``imod.prepare.topsystem``). ``cleanup_riv`` should still work in that - case, keeping the package's own (trimmed) layer coordinate rather than - raising an alignment error. - """ - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Trim the river package down to a single layer, model bottom stays full range. - trimmed_layer = riv_data["stage"]["layer"].isel(layer=[0]) - riv_data = { - key: (value.sel(layer=trimmed_layer) if "layer" in value.dims else value) - for key, value in riv_data.items() - } - # Force a bottom_elevation/bottom mismatch, to also exercise align_interface_levels. - riv_data["bottom_elevation"] -= 3.0 - - riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) - - # Returned grids keep the package's own (trimmed) layers. - for value in riv_data_cleaned.values(): - if value is not None and "layer" in value.dims: - np.testing.assert_equal(value["layer"].values, trimmed_layer.values) - riv_active = riv_data_cleaned["stage"].notnull() - expected = dis_dict["bottom"].sel(layer=trimmed_layer).where(riv_active) - np.testing.assert_equal( - riv_data_cleaned["bottom_elevation"].values, expected.values - ) - - -@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) -def test_cleanup_riv__raise_error(riv_data: dict, dis_data: dict): - """ - Test if error raised when stage below model layer bottom and see if user is - guided to the right prepare function. - """ - dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) - # Arrange: Set bottom elevation above stage - riv_data["stage"] -= 10.0 - # Act - with pytest.raises(ValueError, match="imod.prepare.topsystem.allocate_riv_cells"): - cleanup_riv(**dis_dict, **riv_data) - - -@parametrize_with_cases("dis_data", cases=DisCases) -def test_cleanup_wel(dis_data: dict): - """ - Cleanup wells. - - Cases by id (on purpose not in order, to see if pandas' - sorting results in any issues): - - a: filter completely above surface level -> point filter in top layer - c: filter partly above surface level -> filter top set to surface level - b: filter completely below model base -> well should be removed - d: filter partly below model base -> filter bottom set to model base - f: well outside grid bounds -> well should be removed - e: utrathin filter -> filter should be forced to point filter - g: filter screen_bottom above screen_top -> filter should be forced to point filter - """ - # Arrange - dis_dict = _prepare_dis_dict(dis_data, cleanup_wel) - wel_dict = { - "id": ["a", "c", "b", "d", "f", "e", "g"], - "x": [17.0, 17.0, 17.0, 17.0, 40.0, 17.0, 17.0], - "y": [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0], - "screen_top": [ - 2.0, - 2.0, - -7.0, - -1.0, - -1.0, - 1e-3, - 0.0, - ], - "screen_bottom": [ - 1.5, - 0.0, - -8.0, - -8.0, - -1.0, - 0.0, - 0.5, - ], - } - well_df = pd.DataFrame(wel_dict) - wel_expected = { - "id": ["a", "c", "d", "e", "g"], - "x": [17.0, 17.0, 17.0, 17.0, 17.0], - "y": [15.0, 15.0, 15.0, 15.0, 15.0], - "screen_top": [ - 1.0, - 1.0, - -1.0, - 1e-3, - 0.0, - ], - "screen_bottom": [ - 1.0, - 0.0, - -1.5, - 1e-3, - 0.0, - ], - } - well_expected_df = pd.DataFrame(wel_expected).set_index("id") - # Act - well_cleaned = cleanup_wel(well_df, **dis_dict) - # Assert - pd.testing.assert_frame_equal(well_cleaned, well_expected_df) - - -@parametrize_with_cases("dis_data", cases=DisCases) -def test_cleanup_hfb__ymax_clipped(dis_data: dict): - # Arrange - barrier_y = [25.0, 15.0, -1.0] - barrier_x = [16.0, 16.0, 16.0] - - geometry = gpd.GeoDataFrame( - geometry=[linestrings(barrier_x, barrier_y)], - data={ - "resistance": [1200.0], - "layer": [1], - }, - ) - y_max = 20.0 - idomain = dis_data["idomain"] - if isinstance(idomain, xu.UgridDataArray): - above_y_max = idomain.ugrid.grid.face_y > y_max - idomain.loc[:, above_y_max] = 0 - else: - above_y_max = idomain.coords["y"] > y_max - idomain.loc[:, above_y_max, :] = 0 - - # Act - with pytest.raises(ValueError): - cleanup_hfb(geometry, idomain) - - clipped_geometry = cleanup_hfb(geometry, idomain.isel(layer=0)) - - # Assert - np.testing.assert_allclose(clipped_geometry.bounds.maxy, y_max) - - -@parametrize_with_cases("dis_data", cases=DisCases) -def test_cleanup_hfb__split_in_two(dis_data: dict): - """Deactivate middle cell, barrier should be split in two.""" - # Arrange - barrier_y = [25.0, 15.0, -1.0] - barrier_x = [16.0, 16.0, 16.0] - - geometry = gpd.GeoDataFrame( - geometry=[linestrings(barrier_x, barrier_y)], - data={ - "resistance": [1200.0], - "layer": [1], - }, - ) - idomain = dis_data["idomain"] - if isinstance(idomain, xu.UgridDataArray): - idomain.loc[:, 4] = 0 - else: - idomain.loc[:, 15.0, :] = 0 - - # Act - with pytest.raises(ValueError): - cleanup_hfb(geometry, idomain) - - clipped_geometry = cleanup_hfb(geometry, idomain.isel(layer=0)) - bounds = clipped_geometry.geometry.bounds - assert len(clipped_geometry) == 2 - np.testing.assert_allclose(bounds.miny.values, np.array([20.0, 0.0])) - np.testing.assert_allclose(bounds.maxy.values, np.array([25.0, 10.0])) +from typing import Callable + +import geopandas as gpd +import numpy as np +import pandas as pd +import pytest +import xugrid as xu +from pytest_cases import parametrize, parametrize_with_cases +from shapely import linestrings + +from imod.prepare.cleanup import ( + cleanup_drn, + cleanup_ghb, + cleanup_hfb, + cleanup_riv, + cleanup_wel, +) +from imod.tests.test_mf6.test_mf6_riv import DisCases, RivDisCases +from imod.typing import GridDataArray + + +def _first(grid: GridDataArray): + """ + helper function to get first value, regardless of unstructured or + structured grid.""" + return grid.values.ravel()[0] + + +def _first_index(grid: GridDataArray) -> tuple: + if isinstance(grid, xu.UgridDataArray): + return (0, 0) + else: + return (0, 0, 0) + + +def _rename_data_dict(data: dict, func: Callable): + renamed = data.copy() + to_rename = _RENAME_DICT[func] + for src, dst in to_rename.items(): + mv_data = renamed.pop(src) + if dst is not None: + renamed[dst] = mv_data + return renamed + + +def _prepare_dis_dict(dis_dict: dict, func: Callable): + """Keep required dis args for specific cleanup functions""" + keep_vars = _KEEP_FROM_DIS_DICT[func] + return {var: dis_dict[var] for var in keep_vars} + + +_RENAME_DICT = { + cleanup_riv: {}, + cleanup_drn: {"stage": "elevation", "bottom_elevation": None}, + cleanup_ghb: {"stage": "head", "bottom_elevation": None}, +} + +_KEEP_FROM_DIS_DICT = { + cleanup_riv: ["idomain", "bottom"], + cleanup_drn: ["idomain"], + cleanup_ghb: ["idomain"], + cleanup_wel: ["top", "bottom"], +} + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) +def test_cleanup__align_nodata(riv_data: dict, dis_data: dict, cleanup_func: Callable): + dis_dict = _prepare_dis_dict(dis_data, cleanup_func) + data_dict = _rename_data_dict(riv_data, cleanup_func) + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) + idx = _first_index(data_dict["conductance"]) + # Arrange: Deactivate one cell + first_key = next(iter(data_dict.keys())) + data_dict[first_key][idx] = np.nan + # Act + data_cleaned = cleanup_func(**dis_dict, **data_dict) + # Assert + for key in data_cleaned.keys(): + # Test if xu.UgridDataArray not demoted to xr.DataArray + assert type(data_cleaned[key]) is type(data_dict[key]) + np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) +def test_cleanup__zero_conductance( + riv_data: dict, dis_data: dict, cleanup_func: Callable +): + dis_dict = _prepare_dis_dict(dis_data, cleanup_func) + data_dict = _rename_data_dict(riv_data, cleanup_func) + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) + idx = _first_index(data_dict["conductance"]) + # Arrange: Deactivate one cell + data_dict["conductance"][idx] = 0.0 + # Act + data_cleaned = cleanup_func(**dis_dict, **data_dict) + # Assert + for key in data_cleaned.keys(): + np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) +def test_cleanup__negative_concentration( + riv_data: dict, dis_data: dict, cleanup_func: Callable +): + dis_dict = _prepare_dis_dict(dis_data, cleanup_func) + data_dict = _rename_data_dict(riv_data, cleanup_func) + first_key = next(iter(data_dict.keys())) + # Create concentration data + data_dict["concentration"] = data_dict[first_key].copy() + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) + idx = _first_index(data_dict["conductance"]) + # Arrange: Deactivate one cell + data_dict["concentration"][idx] = -10.0 + # Act + data_cleaned = cleanup_func(**dis_dict, **data_dict) + # Assert + np.testing.assert_equal(_first(data_cleaned["concentration"]), 0.0) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +@parametrize("cleanup_func", [cleanup_drn, cleanup_ghb, cleanup_riv]) +def test_cleanup__outside_active_domain( + riv_data: dict, dis_data: dict, cleanup_func: Callable +): + dis_dict = _prepare_dis_dict(dis_data, cleanup_func) + data_dict = _rename_data_dict(riv_data, cleanup_func) + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(data_dict["conductance"]), 1.0) + idx = _first_index(data_dict["conductance"]) + # Arrange: Deactivate one cell + dis_dict["idomain"][idx] = 0.0 + # Act + data_cleaned = cleanup_func(**dis_dict, **data_dict) + # Assert + for key in data_cleaned.keys(): + np.testing.assert_equal(_first(data_cleaned[key][idx]), np.nan) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__fix_bottom_elevation_to_bottom(riv_data: dict, dis_data: dict): + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Arrange: Set bottom elevation model layer bottom + riv_data["bottom_elevation"] -= 3.0 + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) + # Act + riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) + # Assert + # Account for cells inactive river cells. + riv_active = riv_data_cleaned["stage"].notnull() + expected = dis_dict["bottom"].where(riv_active) + + np.testing.assert_equal( + riv_data_cleaned["bottom_elevation"].values, expected.values + ) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__fix_bottom_elevation_to_stage(riv_data: dict, dis_data: dict): + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Arrange: Set bottom elevation above stage + riv_data["bottom_elevation"] += 3.0 + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) + # Act + riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) + # Assert + np.testing.assert_equal( + riv_data_cleaned["bottom_elevation"].values, riv_data_cleaned["stage"].values + ) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__stage_equals_bottom_elevation(riv_data: dict, dis_data: dict): + """Assure no cleanup accidentily takes place when stage equals bottom_elevation""" + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Arrange: Set bottom elevation equal to stage + riv_data["bottom_elevation"] = riv_data["stage"].copy() + # Assure conductance not modified by previous tests. + np.testing.assert_equal(_first(riv_data["conductance"]), 1.0) + # Act + riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) + # Assert + np.testing.assert_equal( + riv_data_cleaned["bottom_elevation"].values, riv_data_cleaned["stage"].values + ) + np.testing.assert_equal(riv_data["stage"].values, riv_data_cleaned["stage"].values) + np.testing.assert_equal( + riv_data["bottom_elevation"].values, riv_data_cleaned["bottom_elevation"].values + ) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__trimmed_layers(riv_data: dict, dis_data: dict): + """ + A river package's own grids may have fewer layers than the model's full + ``bottom`` (e.g. produced via ``drop_empty_layers=True``, see + ``imod.prepare.topsystem``). ``cleanup_riv`` should still work in that + case, keeping the package's own (trimmed) layer coordinate rather than + raising an alignment error. + """ + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Trim the river package down to a single layer, model bottom stays full range. + trimmed_layer = riv_data["stage"]["layer"].isel(layer=[0]) + riv_data = { + key: (value.sel(layer=trimmed_layer) if "layer" in value.dims else value) + for key, value in riv_data.items() + } + # Force a bottom_elevation/bottom mismatch, to also exercise align_interface_levels. + riv_data["bottom_elevation"] -= 3.0 + + riv_data_cleaned = cleanup_riv(**dis_dict, **riv_data) + + # Returned grids keep the package's own (trimmed) layers. + for value in riv_data_cleaned.values(): + if value is not None and "layer" in value.dims: + np.testing.assert_equal(value["layer"].values, trimmed_layer.values) + riv_active = riv_data_cleaned["stage"].notnull() + expected = dis_dict["bottom"].sel(layer=trimmed_layer).where(riv_active) + np.testing.assert_equal( + riv_data_cleaned["bottom_elevation"].values, expected.values + ) + + +@parametrize_with_cases("riv_data, dis_data", cases=RivDisCases) +def test_cleanup_riv__raise_error(riv_data: dict, dis_data: dict): + """ + Test if error raised when stage below model layer bottom and see if user is + guided to the right prepare function. + """ + dis_dict = _prepare_dis_dict(dis_data, cleanup_riv) + # Arrange: Set bottom elevation above stage + riv_data["stage"] -= 10.0 + # Act + with pytest.raises(ValueError, match="imod.prepare.topsystem.allocate_riv_cells"): + cleanup_riv(**dis_dict, **riv_data) + + +@parametrize_with_cases("dis_data", cases=DisCases) +def test_cleanup_wel(dis_data: dict): + """ + Cleanup wells. + + Cases by id (on purpose not in order, to see if pandas' + sorting results in any issues): + + a: filter completely above surface level -> point filter in top layer + c: filter partly above surface level -> filter top set to surface level + b: filter completely below model base -> well should be removed + d: filter partly below model base -> filter bottom set to model base + f: well outside grid bounds -> well should be removed + e: utrathin filter -> filter should be forced to point filter + g: filter screen_bottom above screen_top -> filter should be forced to point filter + """ + # Arrange + dis_dict = _prepare_dis_dict(dis_data, cleanup_wel) + wel_dict = { + "id": ["a", "c", "b", "d", "f", "e", "g"], + "x": [17.0, 17.0, 17.0, 17.0, 40.0, 17.0, 17.0], + "y": [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0], + "screen_top": [ + 2.0, + 2.0, + -7.0, + -1.0, + -1.0, + 1e-3, + 0.0, + ], + "screen_bottom": [ + 1.5, + 0.0, + -8.0, + -8.0, + -1.0, + 0.0, + 0.5, + ], + } + well_df = pd.DataFrame(wel_dict) + wel_expected = { + "id": ["a", "c", "d", "e", "g"], + "x": [17.0, 17.0, 17.0, 17.0, 17.0], + "y": [15.0, 15.0, 15.0, 15.0, 15.0], + "screen_top": [ + 1.0, + 1.0, + -1.0, + 1e-3, + 0.0, + ], + "screen_bottom": [ + 1.0, + 0.0, + -1.5, + 1e-3, + 0.0, + ], + } + well_expected_df = pd.DataFrame(wel_expected).set_index("id") + # Act + well_cleaned = cleanup_wel(well_df, **dis_dict) + # Assert + pd.testing.assert_frame_equal(well_cleaned, well_expected_df) + + +@parametrize_with_cases("dis_data", cases=DisCases) +def test_cleanup_hfb__ymax_clipped(dis_data: dict): + # Arrange + barrier_y = [25.0, 15.0, -1.0] + barrier_x = [16.0, 16.0, 16.0] + + geometry = gpd.GeoDataFrame( + geometry=[linestrings(barrier_x, barrier_y)], + data={ + "resistance": [1200.0], + "layer": [1], + }, + ) + y_max = 20.0 + idomain = dis_data["idomain"] + if isinstance(idomain, xu.UgridDataArray): + above_y_max = idomain.ugrid.grid.face_y > y_max + idomain.loc[:, above_y_max] = 0 + else: + above_y_max = idomain.coords["y"] > y_max + idomain.loc[:, above_y_max, :] = 0 + + # Act + with pytest.raises(ValueError): + cleanup_hfb(geometry, idomain) + + clipped_geometry = cleanup_hfb(geometry, idomain.isel(layer=0)) + + # Assert + np.testing.assert_allclose(clipped_geometry.bounds.maxy, y_max) + + +@parametrize_with_cases("dis_data", cases=DisCases) +def test_cleanup_hfb__split_in_two(dis_data: dict): + """Deactivate middle cell, barrier should be split in two.""" + # Arrange + barrier_y = [25.0, 15.0, -1.0] + barrier_x = [16.0, 16.0, 16.0] + + geometry = gpd.GeoDataFrame( + geometry=[linestrings(barrier_x, barrier_y)], + data={ + "resistance": [1200.0], + "layer": [1], + }, + ) + idomain = dis_data["idomain"] + if isinstance(idomain, xu.UgridDataArray): + idomain.loc[:, 4] = 0 + else: + idomain.loc[:, 15.0, :] = 0 + + # Act + with pytest.raises(ValueError): + cleanup_hfb(geometry, idomain) + + clipped_geometry = cleanup_hfb(geometry, idomain.isel(layer=0)) + bounds = clipped_geometry.geometry.bounds + assert len(clipped_geometry) == 2 + np.testing.assert_allclose(bounds.miny.values, np.array([20.0, 0.0])) + np.testing.assert_allclose(bounds.maxy.values, np.array([25.0, 10.0])) diff --git a/imod/tests/test_prepare/test_topsystem.py b/imod/tests/test_prepare/test_topsystem.py index da06d9763..8a12f6fa6 100644 --- a/imod/tests/test_prepare/test_topsystem.py +++ b/imod/tests/test_prepare/test_topsystem.py @@ -1,754 +1,754 @@ -import numpy as np -import xarray as xr -from pytest_cases import parametrize_with_cases - -from imod.prepare.topsystem import ( - ALLOCATION_OPTION, - allocate_drn_cells, - allocate_ghb_cells, - allocate_rch_cells, - allocate_riv_cells, - distribute_drn_conductance, - distribute_ghb_conductance, - distribute_riv_conductance, -) -from imod.typing import GridDataArray -from imod.typing.grid import is_unstructured, zeros_like -from imod.util.dims import enforce_dim_order - - -def take_nth_layer_column(grid: GridDataArray, n: int) -> GridDataArray: - """ - Parameters - ---------- - grid: DataArray | UgridDataArray - grid to take values from. Must have dimensions (layer,y,x) for - structured and (layer,{face_dim}) for unstructured grids. - n: int - index number in the xy plane where layer column is taken. - - Returns - ------- - DataArray | UgridDataArray - Column along the layer dimension at the nth cell in the xy plane. - """ - if "time" in grid.dims: - grid = grid.isel(time=-1) - - if is_unstructured(grid): - return grid.values[:, n] - else: - return grid.values[:, n, n] - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" -) -def test_riv_allocation( - active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn -): - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, bottom_elevation, drop_empty_layers=False - ) - - actual_riv = take_nth_layer_column(actual_riv_da, 0) - empty_riv = take_nth_layer_column(actual_riv_da, 1) - - if actual_drn_da is None: - actual_drn = None - empty_drn = None - else: - actual_drn = take_nth_layer_column(actual_drn_da, 0) - empty_drn = take_nth_layer_column(actual_drn_da, 1) - - np.testing.assert_equal(actual_riv, expected_riv) - np.testing.assert_equal(actual_drn, expected_drn) - assert np.all(~empty_riv) - if empty_drn is not None: - assert np.all(~empty_drn) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, bottom_elevation, drop_empty_layers=True - ) - expected_riv_layers = np.nonzero(expected_riv)[0] + 1 - np.testing.assert_array_equal( - actual_riv_da.coords["layer"].values, expected_riv_layers - ) - if actual_drn_da is not None: - expected_drn_layers = np.nonzero(expected_drn)[0] + 1 - np.testing.assert_array_equal( - actual_drn_da.coords["layer"].values, expected_drn_layers - ) - - -@parametrize_with_cases( - argnames="active,top,bottom,drn_elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="drn" -) -def test_drn_allocation(active, top, bottom, drn_elevation, option, expected, _): - actual_da = allocate_drn_cells( - option, active, top, bottom, drn_elevation, drop_empty_layers=False - ) - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_da = allocate_drn_cells( - option, active, top, bottom, drn_elevation, drop_empty_layers=True - ) - expected_layers = np.nonzero(expected)[0] + 1 - np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) - - -@parametrize_with_cases( - argnames="active,top,bottom,head", - prefix="ghb_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="ghb" -) -def test_ghb_allocation(active, top, bottom, head, option, expected, _): - actual_da = allocate_ghb_cells( - option, active, top, bottom, head, drop_empty_layers=False - ) - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_da = allocate_ghb_cells( - option, active, top, bottom, head, drop_empty_layers=True - ) - expected_layers = np.nonzero(expected)[0] + 1 - np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) - - -@parametrize_with_cases( - argnames="active,rate", - prefix="rch_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="rch" -) -def test_rch_allocation(active, rate, option, expected, _): - actual_da = allocate_rch_cells(option, active, rate, drop_empty_layers=False) - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_da = allocate_rch_cells(option, active, rate, drop_empty_layers=True) - expected_layers = np.nonzero(expected)[0] + 1 - np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, expected -): - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - - actual_da = distribute_riv_conductance( - option, allocated, conductance, top, bottom, k, stage, bottom_elevation - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="drn" -) -def test_distribute_drn_conductance( - active, top, bottom, elevation, option, allocated_layer, expected -): - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - - actual_da = distribute_drn_conductance( - option, allocated, conductance, top, bottom, k, elevation - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="ghb_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="ghb" -) -def test_distribute_ghb_conductance( - active, top, bottom, elevation, option, allocated_layer, expected -): - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - - actual_da = distribute_ghb_conductance( - option, allocated, conductance, top, bottom, k - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" -) -def test_riv_allocation__elevation_above_surface_level( - active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn -): - # Put elevations a lot above surface level. Need to be allocated to first - # layer. - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, - active, - top, - bottom, - stage + 100.0, - bottom_elevation + 100.0, - drop_empty_layers=False, - ) - - # Override expected values - expected_riv = [True, False, False, False] - if expected_drn: - expected_drn = [False, False, False, False] - - actual_riv = take_nth_layer_column(actual_riv_da, 0) - empty_riv = take_nth_layer_column(actual_riv_da, 1) - - if actual_drn_da is None: - actual_drn = None - empty_drn = None - else: - actual_drn = take_nth_layer_column(actual_drn_da, 0) - empty_drn = take_nth_layer_column(actual_drn_da, 1) - - np.testing.assert_equal(actual_riv, expected_riv) - np.testing.assert_equal(actual_drn, expected_drn) - assert np.all(~empty_riv) - if empty_drn is not None: - assert np.all(~empty_drn) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, - active, - top, - bottom, - stage + 100.0, - bottom_elevation + 100.0, - drop_empty_layers=True, - ) - expected_riv_layers = np.nonzero(expected_riv)[0] + 1 - np.testing.assert_array_equal( - actual_riv_da.coords["layer"].values, expected_riv_layers - ) - if actual_drn_da is not None: - expected_drn_layers = np.nonzero(expected_drn)[0] + 1 - np.testing.assert_array_equal( - actual_drn_da.coords["layer"].values, expected_drn_layers - ) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" -) -def test_riv_allocation__stage_equals_bottom_elevation( - active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn -): - # Bottom elevation equals stage here. - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, stage, drop_empty_layers=False - ) - - # Override expected values - if option is ALLOCATION_OPTION.first_active_to_elevation: - expected_riv = [True, True, False, False] - elif option is not ALLOCATION_OPTION.at_first_active: - expected_riv = [False, True, False, False] - if expected_drn: - expected_drn = [True, False, False, False] - - actual_riv = take_nth_layer_column(actual_riv_da, 0) - empty_riv = take_nth_layer_column(actual_riv_da, 1) - - if actual_drn_da is None: - actual_drn = None - empty_drn = None - else: - actual_drn = take_nth_layer_column(actual_drn_da, 0) - empty_drn = take_nth_layer_column(actual_drn_da, 1) - - np.testing.assert_equal(actual_riv, expected_riv) - np.testing.assert_equal(actual_drn, expected_drn) - assert np.all(~empty_riv) - if empty_drn is not None: - assert np.all(~empty_drn) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, stage, drop_empty_layers=True - ) - expected_riv_layers = np.nonzero(expected_riv)[0] + 1 - np.testing.assert_array_equal( - actual_riv_da.coords["layer"].values, expected_riv_layers - ) - if actual_drn_da is not None: - expected_drn_layers = np.nonzero(expected_drn)[0] + 1 - np.testing.assert_array_equal( - actual_drn_da.coords["layer"].values, expected_drn_layers - ) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" -) -def test_riv_allocation__stage_equals_bottom_elevation_equals_bottom( - active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn -): - # Set bottom in layer 2 to stage, take first value (stage is equal everywhere.) - bottom.loc[bottom.coords["layer"] == 2] = stage.values.ravel()[0] - - # Bottom elevation equals stage here. - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, stage, drop_empty_layers=False - ) - - # Override expected values - if option is ALLOCATION_OPTION.first_active_to_elevation: - expected_riv = [True, True, False, False] - elif option is not ALLOCATION_OPTION.at_first_active: - expected_riv = [False, True, False, False] - if expected_drn: - expected_drn = [True, False, False, False] - - actual_riv = take_nth_layer_column(actual_riv_da, 0) - empty_riv = take_nth_layer_column(actual_riv_da, 1) - - if actual_drn_da is None: - actual_drn = None - empty_drn = None - else: - actual_drn = take_nth_layer_column(actual_drn_da, 0) - empty_drn = take_nth_layer_column(actual_drn_da, 1) - - np.testing.assert_equal(actual_riv, expected_riv) - np.testing.assert_equal(actual_drn, expected_drn) - assert np.all(~empty_riv) - if empty_drn is not None: - assert np.all(~empty_drn) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_riv_da, actual_drn_da = allocate_riv_cells( - option, active, top, bottom, stage, stage, drop_empty_layers=True - ) - expected_riv_layers = np.nonzero(expected_riv)[0] + 1 - np.testing.assert_array_equal( - actual_riv_da.coords["layer"].values, expected_riv_layers - ) - if actual_drn_da is not None: - expected_drn_layers = np.nonzero(expected_drn)[0] + 1 - np.testing.assert_array_equal( - actual_drn_da.coords["layer"].values, expected_drn_layers - ) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="drn" -) -def test_drn_allocation__elevation_above_surface_level( - active, top, bottom, elevation, option, expected, _ -): - # Put elevations a lot above surface level. Need to be allocated to first - # layer. - actual_da = allocate_drn_cells( - option, - active, - top, - bottom, - elevation + 100.0, - drop_empty_layers=False, - ) - - # Override expected - expected = [True, False, False, False] - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - if empty is not None: - assert np.all(~empty) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_da = allocate_drn_cells( - option, - active, - top, - bottom, - elevation + 100.0, - drop_empty_layers=True, - ) - expected_layers = np.nonzero(expected)[0] + 1 - np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) - - -@parametrize_with_cases( - argnames="active,top,bottom,drn_elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="drn" -) -def test_drn_allocation__elevation_equal_to_bottom( - active, top, bottom, drn_elevation, option, expected, _ -): - # Set bottom in layer 3 to drain elevation, take first value (drain - # elevation is equal everywhere.) - bottom.loc[bottom.coords["layer"] == 3] = drn_elevation.values.ravel()[0] - - actual_da = allocate_drn_cells( - option, active, top, bottom, drn_elevation, drop_empty_layers=False - ) - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_da = allocate_drn_cells( - option, active, top, bottom, drn_elevation, drop_empty_layers=True - ) - expected_layers = np.nonzero(expected)[0] + 1 - np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) - - -@parametrize_with_cases( - argnames="active,top,bottom,head", - prefix="ghb_", -) -@parametrize_with_cases( - argnames="option,expected,_", prefix="allocation_", has_tag="ghb" -) -def test_ghb_allocation__elevation_above_surface_level( - active, top, bottom, head, option, expected, _ -): - # Put elevations a lot above surface level. Need to be allocated to first - # layer. - actual_da = allocate_ghb_cells( - option, - active, - top, - bottom, - head + 100.0, - drop_empty_layers=False, - ) - - # Override expected - expected = [True, False, False, False] - - actual = take_nth_layer_column(actual_da, 0) - empty = take_nth_layer_column(actual_da, 1) - - np.testing.assert_equal(actual, expected) - assert np.all(~empty) - if empty is not None: - assert np.all(~empty) - - # drop_empty_layers=True should keep only the layers with allocated cells - actual_da = allocate_ghb_cells( - option, - active, - top, - bottom, - head + 100.0, - drop_empty_layers=True, - ) - expected_layers = np.nonzero(expected)[0] + 1 - np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="drn" -) -def test_distribute_drn_conductance__above_surface_level( - active, top, bottom, elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - - actual_da = distribute_drn_conductance( - option, allocated, conductance, top, bottom, k, elevation + 100.0 - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="drn_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="drn" -) -def test_distribute_drn_conductance__equal_to_surface_level( - active, top, bottom, elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - elevation = zeros_like(elevation) + top - - actual_da = distribute_drn_conductance( - option, allocated, conductance, top, bottom, k, elevation - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance__above_surface_level( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - - actual_da = distribute_riv_conductance( - option, - allocated, - conductance, - top, - bottom, - k, - stage + 100.0, - bottom_elevation + 100.0, - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance__equal_to_surface_level( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - elevation = zeros_like(bottom_elevation) + top - - actual_da = distribute_riv_conductance( - option, - allocated, - conductance, - top, - bottom, - k, - elevation, - elevation, - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance__stage_equal_to_bottom_elevation( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([False, True, False, False]) - expected = [np.nan, 1.0, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - - actual_da = distribute_riv_conductance( - option, - allocated, - conductance, - top, - bottom, - k, - stage, - stage, - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,stage,bottom_elevation", - prefix="riv_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" -) -def test_distribute_riv_conductance__stage_equal_to_bottom_elevation_equal_to_bottom( - active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ -): - # Set bottom in layer 2 to stage, take first value (stage is equal everywhere.) - bottom.loc[bottom.coords["layer"] == 2] = stage.values.ravel()[0] - - allocated_layer.data = np.array([False, True, False, False]) - expected = [np.nan, 1.0, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(bottom_elevation) + 1.0 - - actual_da = distribute_riv_conductance( - option, - allocated, - conductance, - top, - bottom, - k, - stage, - stage, - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) - - -@parametrize_with_cases( - argnames="active,top,bottom,elevation", - prefix="ghb_", -) -@parametrize_with_cases( - argnames="option,allocated_layer,_", prefix="distribution_", has_tag="ghb" -) -def test_distribute_ghb_conductance__above_surface_level( - active, top, bottom, elevation, option, allocated_layer, _ -): - allocated_layer.data = np.array([True, False, False, False]) - expected = [1.0, np.nan, np.nan, np.nan] - allocated = enforce_dim_order(active & allocated_layer) - k = xr.DataArray( - [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) - ) - - conductance = zeros_like(elevation) + 1.0 - - actual_da = distribute_ghb_conductance( - option, allocated, conductance, top, bottom, k - ) - actual = take_nth_layer_column(actual_da, 0) - - np.testing.assert_equal(actual, expected) +import numpy as np +import xarray as xr +from pytest_cases import parametrize_with_cases + +from imod.prepare.topsystem import ( + ALLOCATION_OPTION, + allocate_drn_cells, + allocate_ghb_cells, + allocate_rch_cells, + allocate_riv_cells, + distribute_drn_conductance, + distribute_ghb_conductance, + distribute_riv_conductance, +) +from imod.typing import GridDataArray +from imod.typing.grid import is_unstructured, zeros_like +from imod.util.dims import enforce_dim_order + + +def take_nth_layer_column(grid: GridDataArray, n: int) -> GridDataArray: + """ + Parameters + ---------- + grid: DataArray | UgridDataArray + grid to take values from. Must have dimensions (layer,y,x) for + structured and (layer,{face_dim}) for unstructured grids. + n: int + index number in the xy plane where layer column is taken. + + Returns + ------- + DataArray | UgridDataArray + Column along the layer dimension at the nth cell in the xy plane. + """ + if "time" in grid.dims: + grid = grid.isel(time=-1) + + if is_unstructured(grid): + return grid.values[:, n] + else: + return grid.values[:, n, n] + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" +) +def test_riv_allocation( + active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn +): + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, bottom_elevation, drop_empty_layers=False + ) + + actual_riv = take_nth_layer_column(actual_riv_da, 0) + empty_riv = take_nth_layer_column(actual_riv_da, 1) + + if actual_drn_da is None: + actual_drn = None + empty_drn = None + else: + actual_drn = take_nth_layer_column(actual_drn_da, 0) + empty_drn = take_nth_layer_column(actual_drn_da, 1) + + np.testing.assert_equal(actual_riv, expected_riv) + np.testing.assert_equal(actual_drn, expected_drn) + assert np.all(~empty_riv) + if empty_drn is not None: + assert np.all(~empty_drn) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, bottom_elevation, drop_empty_layers=True + ) + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + np.testing.assert_array_equal( + actual_riv_da.coords["layer"].values, expected_riv_layers + ) + if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + np.testing.assert_array_equal( + actual_drn_da.coords["layer"].values, expected_drn_layers + ) + + +@parametrize_with_cases( + argnames="active,top,bottom,drn_elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="drn" +) +def test_drn_allocation(active, top, bottom, drn_elevation, option, expected, _): + actual_da = allocate_drn_cells( + option, active, top, bottom, drn_elevation, drop_empty_layers=False + ) + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_drn_cells( + option, active, top, bottom, drn_elevation, drop_empty_layers=True + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,head", + prefix="ghb_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="ghb" +) +def test_ghb_allocation(active, top, bottom, head, option, expected, _): + actual_da = allocate_ghb_cells( + option, active, top, bottom, head, drop_empty_layers=False + ) + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_ghb_cells( + option, active, top, bottom, head, drop_empty_layers=True + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,rate", + prefix="rch_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="rch" +) +def test_rch_allocation(active, rate, option, expected, _): + actual_da = allocate_rch_cells(option, active, rate, drop_empty_layers=False) + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_rch_cells(option, active, rate, drop_empty_layers=True) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, expected +): + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + + actual_da = distribute_riv_conductance( + option, allocated, conductance, top, bottom, k, stage, bottom_elevation + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="drn" +) +def test_distribute_drn_conductance( + active, top, bottom, elevation, option, allocated_layer, expected +): + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + + actual_da = distribute_drn_conductance( + option, allocated, conductance, top, bottom, k, elevation + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="ghb_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,expected", prefix="distribution_", has_tag="ghb" +) +def test_distribute_ghb_conductance( + active, top, bottom, elevation, option, allocated_layer, expected +): + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + + actual_da = distribute_ghb_conductance( + option, allocated, conductance, top, bottom, k + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" +) +def test_riv_allocation__elevation_above_surface_level( + active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn +): + # Put elevations a lot above surface level. Need to be allocated to first + # layer. + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, + active, + top, + bottom, + stage + 100.0, + bottom_elevation + 100.0, + drop_empty_layers=False, + ) + + # Override expected values + expected_riv = [True, False, False, False] + if expected_drn: + expected_drn = [False, False, False, False] + + actual_riv = take_nth_layer_column(actual_riv_da, 0) + empty_riv = take_nth_layer_column(actual_riv_da, 1) + + if actual_drn_da is None: + actual_drn = None + empty_drn = None + else: + actual_drn = take_nth_layer_column(actual_drn_da, 0) + empty_drn = take_nth_layer_column(actual_drn_da, 1) + + np.testing.assert_equal(actual_riv, expected_riv) + np.testing.assert_equal(actual_drn, expected_drn) + assert np.all(~empty_riv) + if empty_drn is not None: + assert np.all(~empty_drn) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, + active, + top, + bottom, + stage + 100.0, + bottom_elevation + 100.0, + drop_empty_layers=True, + ) + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + np.testing.assert_array_equal( + actual_riv_da.coords["layer"].values, expected_riv_layers + ) + if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + np.testing.assert_array_equal( + actual_drn_da.coords["layer"].values, expected_drn_layers + ) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" +) +def test_riv_allocation__stage_equals_bottom_elevation( + active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn +): + # Bottom elevation equals stage here. + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, stage, drop_empty_layers=False + ) + + # Override expected values + if option is ALLOCATION_OPTION.first_active_to_elevation: + expected_riv = [True, True, False, False] + elif option is not ALLOCATION_OPTION.at_first_active: + expected_riv = [False, True, False, False] + if expected_drn: + expected_drn = [True, False, False, False] + + actual_riv = take_nth_layer_column(actual_riv_da, 0) + empty_riv = take_nth_layer_column(actual_riv_da, 1) + + if actual_drn_da is None: + actual_drn = None + empty_drn = None + else: + actual_drn = take_nth_layer_column(actual_drn_da, 0) + empty_drn = take_nth_layer_column(actual_drn_da, 1) + + np.testing.assert_equal(actual_riv, expected_riv) + np.testing.assert_equal(actual_drn, expected_drn) + assert np.all(~empty_riv) + if empty_drn is not None: + assert np.all(~empty_drn) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, stage, drop_empty_layers=True + ) + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + np.testing.assert_array_equal( + actual_riv_da.coords["layer"].values, expected_riv_layers + ) + if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + np.testing.assert_array_equal( + actual_drn_da.coords["layer"].values, expected_drn_layers + ) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,expected_riv,expected_drn", prefix="allocation_", has_tag="riv" +) +def test_riv_allocation__stage_equals_bottom_elevation_equals_bottom( + active, top, bottom, stage, bottom_elevation, option, expected_riv, expected_drn +): + # Set bottom in layer 2 to stage, take first value (stage is equal everywhere.) + bottom.loc[bottom.coords["layer"] == 2] = stage.values.ravel()[0] + + # Bottom elevation equals stage here. + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, stage, drop_empty_layers=False + ) + + # Override expected values + if option is ALLOCATION_OPTION.first_active_to_elevation: + expected_riv = [True, True, False, False] + elif option is not ALLOCATION_OPTION.at_first_active: + expected_riv = [False, True, False, False] + if expected_drn: + expected_drn = [True, False, False, False] + + actual_riv = take_nth_layer_column(actual_riv_da, 0) + empty_riv = take_nth_layer_column(actual_riv_da, 1) + + if actual_drn_da is None: + actual_drn = None + empty_drn = None + else: + actual_drn = take_nth_layer_column(actual_drn_da, 0) + empty_drn = take_nth_layer_column(actual_drn_da, 1) + + np.testing.assert_equal(actual_riv, expected_riv) + np.testing.assert_equal(actual_drn, expected_drn) + assert np.all(~empty_riv) + if empty_drn is not None: + assert np.all(~empty_drn) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_riv_da, actual_drn_da = allocate_riv_cells( + option, active, top, bottom, stage, stage, drop_empty_layers=True + ) + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + np.testing.assert_array_equal( + actual_riv_da.coords["layer"].values, expected_riv_layers + ) + if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + np.testing.assert_array_equal( + actual_drn_da.coords["layer"].values, expected_drn_layers + ) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="drn" +) +def test_drn_allocation__elevation_above_surface_level( + active, top, bottom, elevation, option, expected, _ +): + # Put elevations a lot above surface level. Need to be allocated to first + # layer. + actual_da = allocate_drn_cells( + option, + active, + top, + bottom, + elevation + 100.0, + drop_empty_layers=False, + ) + + # Override expected + expected = [True, False, False, False] + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + if empty is not None: + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_drn_cells( + option, + active, + top, + bottom, + elevation + 100.0, + drop_empty_layers=True, + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,drn_elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="drn" +) +def test_drn_allocation__elevation_equal_to_bottom( + active, top, bottom, drn_elevation, option, expected, _ +): + # Set bottom in layer 3 to drain elevation, take first value (drain + # elevation is equal everywhere.) + bottom.loc[bottom.coords["layer"] == 3] = drn_elevation.values.ravel()[0] + + actual_da = allocate_drn_cells( + option, active, top, bottom, drn_elevation, drop_empty_layers=False + ) + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_drn_cells( + option, active, top, bottom, drn_elevation, drop_empty_layers=True + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,head", + prefix="ghb_", +) +@parametrize_with_cases( + argnames="option,expected,_", prefix="allocation_", has_tag="ghb" +) +def test_ghb_allocation__elevation_above_surface_level( + active, top, bottom, head, option, expected, _ +): + # Put elevations a lot above surface level. Need to be allocated to first + # layer. + actual_da = allocate_ghb_cells( + option, + active, + top, + bottom, + head + 100.0, + drop_empty_layers=False, + ) + + # Override expected + expected = [True, False, False, False] + + actual = take_nth_layer_column(actual_da, 0) + empty = take_nth_layer_column(actual_da, 1) + + np.testing.assert_equal(actual, expected) + assert np.all(~empty) + if empty is not None: + assert np.all(~empty) + + # drop_empty_layers=True should keep only the layers with allocated cells + actual_da = allocate_ghb_cells( + option, + active, + top, + bottom, + head + 100.0, + drop_empty_layers=True, + ) + expected_layers = np.nonzero(expected)[0] + 1 + np.testing.assert_array_equal(actual_da.coords["layer"].values, expected_layers) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="drn" +) +def test_distribute_drn_conductance__above_surface_level( + active, top, bottom, elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + + actual_da = distribute_drn_conductance( + option, allocated, conductance, top, bottom, k, elevation + 100.0 + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="drn_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="drn" +) +def test_distribute_drn_conductance__equal_to_surface_level( + active, top, bottom, elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + elevation = zeros_like(elevation) + top + + actual_da = distribute_drn_conductance( + option, allocated, conductance, top, bottom, k, elevation + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance__above_surface_level( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + + actual_da = distribute_riv_conductance( + option, + allocated, + conductance, + top, + bottom, + k, + stage + 100.0, + bottom_elevation + 100.0, + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance__equal_to_surface_level( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + elevation = zeros_like(bottom_elevation) + top + + actual_da = distribute_riv_conductance( + option, + allocated, + conductance, + top, + bottom, + k, + elevation, + elevation, + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance__stage_equal_to_bottom_elevation( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([False, True, False, False]) + expected = [np.nan, 1.0, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + + actual_da = distribute_riv_conductance( + option, + allocated, + conductance, + top, + bottom, + k, + stage, + stage, + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,stage,bottom_elevation", + prefix="riv_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="riv" +) +def test_distribute_riv_conductance__stage_equal_to_bottom_elevation_equal_to_bottom( + active, top, bottom, stage, bottom_elevation, option, allocated_layer, _ +): + # Set bottom in layer 2 to stage, take first value (stage is equal everywhere.) + bottom.loc[bottom.coords["layer"] == 2] = stage.values.ravel()[0] + + allocated_layer.data = np.array([False, True, False, False]) + expected = [np.nan, 1.0, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(bottom_elevation) + 1.0 + + actual_da = distribute_riv_conductance( + option, + allocated, + conductance, + top, + bottom, + k, + stage, + stage, + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) + + +@parametrize_with_cases( + argnames="active,top,bottom,elevation", + prefix="ghb_", +) +@parametrize_with_cases( + argnames="option,allocated_layer,_", prefix="distribution_", has_tag="ghb" +) +def test_distribute_ghb_conductance__above_surface_level( + active, top, bottom, elevation, option, allocated_layer, _ +): + allocated_layer.data = np.array([True, False, False, False]) + expected = [1.0, np.nan, np.nan, np.nan] + allocated = enforce_dim_order(active & allocated_layer) + k = xr.DataArray( + [2.0, 2.0, 1.0, 1.0], coords={"layer": [1, 2, 3, 4]}, dims=("layer",) + ) + + conductance = zeros_like(elevation) + 1.0 + + actual_da = distribute_ghb_conductance( + option, allocated, conductance, top, bottom, k + ) + actual = take_nth_layer_column(actual_da, 0) + + np.testing.assert_equal(actual, expected) From 714ce81a5d75c454d2f69a8e8e173bce4ad3460e Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 28 Sep 2026 15:40:35 +0200 Subject: [PATCH 07/23] fix `_used_layers` to also return None on empty masks --- imod/prepare/topsystem/allocation.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index ec24ed721..cbbd8f09a 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -609,7 +609,7 @@ def _used_layers(mask: GridDataArray) -> Optional[GridDataArray]: ------- GridDataArray | None Layer coordinate values with data, or None if nothing should be - trimmed. + trimmed or when the mask is entirely False. """ if "layer" not in mask.dims: return None @@ -626,8 +626,11 @@ def _used_layers(mask: GridDataArray) -> Optional[GridDataArray]: # inside indexing logic more than once. has_data_per_layer = has_data_per_layer.compute() - if bool(has_data_per_layer.all()): - return None # nothing to trim + if bool(has_data_per_layer.all()) or not bool(has_data_per_layer.any()): + # Nothing to trim, or nothing to keep. + # Never return an empty layer coordinate: keep the full range and + # let callers (e.g. # mask_package__drop_if_empty) remove empty packages. + return None return mask["layer"].where(has_data_per_layer, drop=True) From 4ee8e7e9ac35e91b455b496f1a19842062642947 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 28 Sep 2026 16:46:50 +0200 Subject: [PATCH 08/23] sync docstrings for drop_empty_layers --- imod/mf6/drn.py | 14 ++++-- imod/mf6/ghb.py | 14 ++++-- imod/mf6/rch.py | 13 +++-- imod/mf6/riv.py | 14 ++++-- imod/mf6/topsystem.py | 16 +++--- imod/prepare/topsystem/allocation.py | 73 ++++++++++++++++------------ 6 files changed, 87 insertions(+), 57 deletions(-) diff --git a/imod/mf6/drn.py b/imod/mf6/drn.py index 392b506e8..04e91d727 100644 --- a/imod/mf6/drn.py +++ b/imod/mf6/drn.py @@ -219,11 +219,15 @@ def _allocate_and_distribute_planar_data( ALLOCATION_OPTION.at_first_active. distributing_option: DISTRIBUTING_OPTION distributing option. - drop_empty_layers: bool - If True, drop layers without any allocated cells from the - returned grids. Allocation and distribution are always computed - over the full layer range first, so this does not affect the - computed values. + drop_empty_layers: bool, default True + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- diff --git a/imod/mf6/ghb.py b/imod/mf6/ghb.py index 5db99d65a..21506d390 100644 --- a/imod/mf6/ghb.py +++ b/imod/mf6/ghb.py @@ -224,11 +224,15 @@ def _allocate_and_distribute_planar_data( ALLOCATION_OPTION.at_first_active. distributing_option: DISTRIBUTING_OPTION distributing option. - drop_empty_layers: bool - If True, drop layers without any allocated cells from the - returned grids. Allocation and distribution are always computed - over the full layer range first, so this does not affect the - computed values. + drop_empty_layers: bool, default True + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- diff --git a/imod/mf6/rch.py b/imod/mf6/rch.py index 3bc10003c..50f55df25 100644 --- a/imod/mf6/rch.py +++ b/imod/mf6/rch.py @@ -201,10 +201,15 @@ def _allocate_planar_data( Model discretization package. allocation_option: ALLOCATION_OPTION The allocation option to use for the reallocation. - drop_empty_layers: bool - If True, drop layers without any allocated cells from the - returned grids. Allocation is always computed over the full - layer range first, so this does not affect the computed values. + drop_empty_layers: bool, default True + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- diff --git a/imod/mf6/riv.py b/imod/mf6/riv.py index 5f59550d9..165499221 100644 --- a/imod/mf6/riv.py +++ b/imod/mf6/riv.py @@ -343,11 +343,15 @@ def _allocate_and_distribute_planar_data( ALLOCATION_OPTION.at_first_active. distributing_option: DISTRIBUTING_OPTION distributing option. - drop_empty_layers: bool - If True, drop layers without any allocated cells from the - returned grids. Allocation and distribution are always computed - over the full layer range first, so this does not affect the - computed values. + drop_empty_layers: bool, default True + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- diff --git a/imod/mf6/topsystem.py b/imod/mf6/topsystem.py index d08bf5083..a2d55c560 100644 --- a/imod/mf6/topsystem.py +++ b/imod/mf6/topsystem.py @@ -81,13 +81,15 @@ def reallocate( The distributing option to use for the reallocation. Required for packages with a conductance variable. If None, the default is taken from :class:`imod.prepare.SimulationDistributingOptions`. - drop_empty_layers : bool, default True - If True, drop layers from the resulting package that contain no - allocated cells anywhere in the domain. Allocation and - distribution are always computed over the full layer range - first; layers are only trimmed off the final result, so this - does not affect the computed values, only the package's layer - coordinate. + drop_empty_layers: bool, default True + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index cbbd8f09a..142af89a5 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -97,14 +97,15 @@ def allocate_riv_cells( bottom_elevation: DataArray | UgridDatarray Planar grid containing river bottom elevations. Is not allowed to have a layer dimension. - drop_empty_layers: bool, default False - If True, drop layers from the result that contain no allocated - cells anywhere in the domain. This avoids carrying the package's - arrays at full model-layer size through downstream regridding, - clipping, masking, and splitting, which can otherwise become - expensive for models with many layers relative to how many - layers the topsystem package actually occupies. Set to False to - keep the previous full-layer-coordinate behaviour. + drop_empty_layers: bool, default True + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- @@ -188,13 +189,14 @@ def allocate_drn_cells( Planar grid containing drain elevation. Is not allowed to have a layer dimension. drop_empty_layers: bool, default True - If True, drop layers from the result that contain no allocated - cells anywhere in the domain. This avoids carrying the package's - arrays at full model-layer size through downstream regridding, - clipping, masking, and splitting, which can otherwise become - expensive for models with many layers relative to how many - layers the topsystem package actually occupies. Set to False to - keep the previous full-layer-coordinate behaviour. + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- @@ -260,13 +262,14 @@ def allocate_ghb_cells( Planar grid containing general head boundary's head. Is not allowed to have a layer dimension. drop_empty_layers: bool, default True - If True, drop layers from the result that contain no allocated - cells anywhere in the domain. This avoids carrying the package's - arrays at full model-layer size through downstream regridding, - clipping, masking, and splitting, which can otherwise become - expensive for models with many layers relative to how many - layers the topsystem package actually occupies. Set to False to - keep the previous full-layer-coordinate behaviour. + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- @@ -323,13 +326,14 @@ def allocate_rch_cells( Array with recharge rates. This will only be used to infer where recharge cells are defined. drop_empty_layers: bool, default True - If True, drop layers from the result that contain no allocated - cells anywhere in the domain. This avoids carrying the package's - arrays at full model-layer size through downstream regridding, - clipping, masking, and splitting, which can otherwise become - expensive for models with many layers relative to how many - layers the topsystem package actually occupies. Set to False to - keep the previous full-layer-coordinate behaviour. + If True, drop layers that contain no allocated cells anywhere in the + domain (or at any time), so the returned grids only span the layers that + are actually used. Reduces memory use and speeds up later operations such + as regridding, clipping and splitting. If no cells are allocated in any + layer, nothing is dropped and the full layer range is returned, as a layer + dimension of size 0 is not valid. Note that allocation and conductance + distribution are always computed over the full layer range first, so + dropping layers does not change the computed values. Returns ------- @@ -608,8 +612,13 @@ def _used_layers(mask: GridDataArray) -> Optional[GridDataArray]: Returns ------- GridDataArray | None - Layer coordinate values with data, or None if nothing should be - trimmed or when the mask is entirely False. + Layer coordinate values with data, or None. + Returning None means either nothing to trim (all layers used) + or nothing to keep (no layer has any allocated cell). In the + latter case, do not return an empty layer coordinate: a layer + dimension of size 0 fails package validation and breaks downstream + operations. Keep the full layer range instead, so empty packages + can be removed by callers, e.g. ``mask_package__drop_if_empty``. """ if "layer" not in mask.dims: return None @@ -683,6 +692,8 @@ def drop_empty_layers_from_dict( ------- GridDataDict Same dictionary, with every layered grid subset to layers with data. + If mask has no True values, data is returned unchanged. + To fully remove empty empty packages, additional logic outside this function is required. """ used_layers = _used_layers(mask) if used_layers is None: From f97cd84cc0689c79af5bc218a09825e5bfb09d7d Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Mon, 28 Sep 2026 16:47:31 +0200 Subject: [PATCH 09/23] update edge case test where all layers are empty. --- imod/tests/test_prepare/test_topsystem.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/imod/tests/test_prepare/test_topsystem.py b/imod/tests/test_prepare/test_topsystem.py index 8a12f6fa6..9d6739e97 100644 --- a/imod/tests/test_prepare/test_topsystem.py +++ b/imod/tests/test_prepare/test_topsystem.py @@ -288,14 +288,24 @@ def test_riv_allocation__elevation_above_surface_level( bottom_elevation + 100.0, drop_empty_layers=True, ) - expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + + def expected_layers_after_drop(expected: list[bool]) -> np.ndarray: + """ + Layer numbers expected after ``drop_empty_layers=True``: the (1-based) + layers with at least one allocated cell. If nothing is allocated at all, + no layers are dropped, so the full layer range is expected. We never + return a layer dimension of size 0. + """ + layers = np.nonzero(expected)[0] + 1 + return layers if layers.size > 0 else np.arange(1, len(expected) + 1) + np.testing.assert_array_equal( - actual_riv_da.coords["layer"].values, expected_riv_layers + actual_riv_da.coords["layer"].values, expected_layers_after_drop(expected_riv) ) if actual_drn_da is not None: - expected_drn_layers = np.nonzero(expected_drn)[0] + 1 np.testing.assert_array_equal( - actual_drn_da.coords["layer"].values, expected_drn_layers + actual_drn_da.coords["layer"].values, + expected_layers_after_drop(expected_drn), ) From a2f6fc6430d71236a1acd38526bb9c8f2f7fd5e7 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Tue, 29 Sep 2026 09:30:09 +0200 Subject: [PATCH 10/23] implement review comments --- imod/tests/test_mf6/test_mf6_drn.py | 3 +-- imod/tests/test_mf6/test_mf6_ghb.py | 3 +-- imod/tests/test_mf6/test_mf6_rch.py | 3 +-- imod/tests/test_mf6/test_mf6_riv.py | 3 +-- imod/tests/test_prepare/test_topsystem.py | 18 ++++++------------ 5 files changed, 10 insertions(+), 20 deletions(-) diff --git a/imod/tests/test_mf6/test_mf6_drn.py b/imod/tests/test_mf6/test_mf6_drn.py index 092f49970..355496824 100644 --- a/imod/tests/test_mf6/test_mf6_drn.py +++ b/imod/tests/test_mf6/test_mf6_drn.py @@ -488,8 +488,7 @@ def test_reallocate(drainage): def test_reallocate_drop_empty_layers(drainage): """ drop_empty_layers=True should trim layers off the final package without - changing the values of the layers that remain (Option A: allocation and - conductance distribution always run over the full layer range first). + changing the values of the layers that remain. """ drn = imod.mf6.Drainage(**drainage) idomain = drainage["elevation"].astype(np.int16) diff --git a/imod/tests/test_mf6/test_mf6_ghb.py b/imod/tests/test_mf6/test_mf6_ghb.py index 086bd75e6..3143b3b3f 100644 --- a/imod/tests/test_mf6/test_mf6_ghb.py +++ b/imod/tests/test_mf6/test_mf6_ghb.py @@ -16,8 +16,7 @@ def test_reallocate_drop_empty_layers(): """ drop_empty_layers=True should trim layers off the final package without - changing the values of the layers that remain (Option A: allocation and - conductance distribution always run over the full layer range first). + changing the values of the layers that remain. """ layer = [1, 2, 3] y = [25.0, 15.0, 5.0] diff --git a/imod/tests/test_mf6/test_mf6_rch.py b/imod/tests/test_mf6/test_mf6_rch.py index 3038bbbfd..393bba46a 100644 --- a/imod/tests/test_mf6/test_mf6_rch.py +++ b/imod/tests/test_mf6/test_mf6_rch.py @@ -405,8 +405,7 @@ def test_reallocate(rch_dict, allocation_option): def test_reallocate_drop_empty_layers(): """ drop_empty_layers=True should trim layers off the final package without - changing the values of the layers that remain (Option A: allocation is - always computed over the full layer range first). + changing the values of the layers that remain. """ x = [5.0, 15.0, 25.0] y = [25.0, 15.0, 5.0] diff --git a/imod/tests/test_mf6/test_mf6_riv.py b/imod/tests/test_mf6/test_mf6_riv.py index 0edd6b5fa..65352cdc8 100644 --- a/imod/tests/test_mf6/test_mf6_riv.py +++ b/imod/tests/test_mf6/test_mf6_riv.py @@ -489,8 +489,7 @@ def test_reallocate__wrong_allocation_option(riv_data, dis_data): def test_reallocate_drop_empty_layers(): """ drop_empty_layers=True should trim layers off the final package without - changing the values of the layers that remain (Option A: allocation and - conductance distribution always run over the full layer range first). + changing the values of the layers that remain. """ x = [5.0, 15.0, 25.0] y = [25.0, 15.0, 5.0] diff --git a/imod/tests/test_prepare/test_topsystem.py b/imod/tests/test_prepare/test_topsystem.py index 9d6739e97..a8ab7e42f 100644 --- a/imod/tests/test_prepare/test_topsystem.py +++ b/imod/tests/test_prepare/test_topsystem.py @@ -289,23 +289,17 @@ def test_riv_allocation__elevation_above_surface_level( drop_empty_layers=True, ) - def expected_layers_after_drop(expected: list[bool]) -> np.ndarray: - """ - Layer numbers expected after ``drop_empty_layers=True``: the (1-based) - layers with at least one allocated cell. If nothing is allocated at all, - no layers are dropped, so the full layer range is expected. We never - return a layer dimension of size 0. - """ - layers = np.nonzero(expected)[0] + 1 - return layers if layers.size > 0 else np.arange(1, len(expected) + 1) - + expected_riv_layers = np.nonzero(expected_riv)[0] + 1 + expected_riv_layers = expected_riv_layers if expected_riv_layers.size > 0 else np.arange(1, len(expected_riv) + 1) np.testing.assert_array_equal( - actual_riv_da.coords["layer"].values, expected_layers_after_drop(expected_riv) + actual_riv_da.coords["layer"].values, expected_riv_layers ) if actual_drn_da is not None: + expected_drn_layers = np.nonzero(expected_drn)[0] + 1 + expected_drn_layers = expected_drn_layers if expected_drn_layers.size > 0 else np.arange(1, len(expected_drn) + 1) np.testing.assert_array_equal( actual_drn_da.coords["layer"].values, - expected_layers_after_drop(expected_drn), + expected_drn_layers, ) From 98d6ddf8799abc342037b297f407a34eab5c91f1 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Tue, 29 Sep 2026 12:51:46 +0200 Subject: [PATCH 11/23] lint --- imod/tests/test_prepare/test_topsystem.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/imod/tests/test_prepare/test_topsystem.py b/imod/tests/test_prepare/test_topsystem.py index a8ab7e42f..9b0ab8a0e 100644 --- a/imod/tests/test_prepare/test_topsystem.py +++ b/imod/tests/test_prepare/test_topsystem.py @@ -290,13 +290,21 @@ def test_riv_allocation__elevation_above_surface_level( ) expected_riv_layers = np.nonzero(expected_riv)[0] + 1 - expected_riv_layers = expected_riv_layers if expected_riv_layers.size > 0 else np.arange(1, len(expected_riv) + 1) + expected_riv_layers = ( + expected_riv_layers + if expected_riv_layers.size > 0 + else np.arange(1, len(expected_riv) + 1) + ) np.testing.assert_array_equal( actual_riv_da.coords["layer"].values, expected_riv_layers ) if actual_drn_da is not None: expected_drn_layers = np.nonzero(expected_drn)[0] + 1 - expected_drn_layers = expected_drn_layers if expected_drn_layers.size > 0 else np.arange(1, len(expected_drn) + 1) + expected_drn_layers = ( + expected_drn_layers + if expected_drn_layers.size > 0 + else np.arange(1, len(expected_drn) + 1) + ) np.testing.assert_array_equal( actual_drn_da.coords["layer"].values, expected_drn_layers, From 1dd4332c085943d9d8e9d014633e52a30fb9e5e7 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Tue, 29 Sep 2026 13:56:15 +0200 Subject: [PATCH 12/23] update dvc config --- .dvc/config | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.dvc/config b/.dvc/config index 3fd415324..1d1b3075a 100644 --- a/.dvc/config +++ b/.dvc/config @@ -1,5 +1,6 @@ -[core] - remote = minio -['remote "minio"'] - url = s3://imod-python-test-data - endpointurl = https://s3.deltares.nl +[core] + remote = minio +['remote "minio"'] + url = s3://imod-python-test-data + endpointurl = https://s3.deltares.nl + allow_anonymous_login = true From 317adbcf381c3879a6e3d0d3d260bcea87482fa5 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Tue, 29 Sep 2026 15:49:27 +0200 Subject: [PATCH 13/23] add enum for LAYERS_USED. Created issue #1923 for letting callers handle it automatically. --- imod/prepare/topsystem/allocation.py | 52 +++++++++++++++++++--------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index 142af89a5..c3e701ae1 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -15,6 +15,7 @@ ) from imod.typing import GridDataArray, GridDataDict from imod.util.dims import enforced_dim_order +from imod.logging import logger class ALLOCATION_OPTION(Enum): @@ -55,6 +56,15 @@ class ALLOCATION_OPTION(Enum): at_elevation = 2 at_first_active = 9 # Not an iMOD 5.6 option +class LAYER_USED(Enum): + """ + What this result represents. It can be either: + - ALL: nothing to trim (all layers used) + - NONE: nothing to keep (no layer has any allocated cell) + """ + ALL = 0 + NONE = 1 + PLANAR_GRID = ( DimsSchema("time", "y", "x") @@ -596,7 +606,8 @@ def _allocate_cells__at_first_active( return topsystem_upper_active, None -def _used_layers(mask: GridDataArray) -> Optional[GridDataArray]: + +def _used_layers(mask: GridDataArray) -> GridDataArray | LAYER_USED: """ Return the layer coordinate values of ``mask`` that contain at least one True value anywhere in the domain (and, if present, at any timestep), or @@ -611,17 +622,17 @@ def _used_layers(mask: GridDataArray) -> Optional[GridDataArray]: Returns ------- - GridDataArray | None - Layer coordinate values with data, or None. - Returning None means either nothing to trim (all layers used) - or nothing to keep (no layer has any allocated cell). In the - latter case, do not return an empty layer coordinate: a layer - dimension of size 0 fails package validation and breaks downstream + GridDataArray | LAYER_USED + Layer coordinate values with data, or LAYER_USED. + Returning LAYER_USED means either nothing to trim (all layers used) + or nothing to keep (no layer has any allocated cell). In the latter cases, + do not return an empty layer coordinate: a layer dimension of + size 0 fails package validation and breaks downstream operations. Keep the full layer range instead, so empty packages can be removed by callers, e.g. ``mask_package__drop_if_empty``. """ if "layer" not in mask.dims: - return None + return LAYER_USED.ALL if mask.dtype != bool: raise ValueError( @@ -635,11 +646,12 @@ def _used_layers(mask: GridDataArray) -> Optional[GridDataArray]: # inside indexing logic more than once. has_data_per_layer = has_data_per_layer.compute() - if bool(has_data_per_layer.all()) or not bool(has_data_per_layer.any()): - # Nothing to trim, or nothing to keep. - # Never return an empty layer coordinate: keep the full range and - # let callers (e.g. # mask_package__drop_if_empty) remove empty packages. - return None + # Never return an empty layer coordinate: keep the full range and + # let callers (e.g. # mask_package__drop_if_empty) remove empty packages. + if bool(has_data_per_layer.all()): + return LAYER_USED.ALL + elif not bool(has_data_per_layer.any()): + return LAYER_USED.NONE return mask["layer"].where(has_data_per_layer, drop=True) @@ -665,7 +677,15 @@ def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: Same array, subset to layers with data. """ used_layers = _used_layers(grid) - return grid if used_layers is None else grid.sel(layer=used_layers) + if used_layers == LAYER_USED.NONE: + name = grid.name if hasattr(grid, "name") else "" + logger.warning(f"No layers have data in grid '{name}', the package should be removed by the caller.") + return grid + elif used_layers == LAYER_USED.ALL: + return grid + else: + logger.debug("Dropping empty layers, keeping only used layers.") + return grid.sel(layer=used_layers) def drop_empty_layers_from_dict( @@ -696,8 +716,8 @@ def drop_empty_layers_from_dict( To fully remove empty empty packages, additional logic outside this function is required. """ used_layers = _used_layers(mask) - if used_layers is None: - return data + if isinstance(used_layers, LAYER_USED): + return data # return as-is, let caller handle what to do with LAYER_USED.NONE or LAYER_USED.ALL return { key: grid.sel(layer=used_layers) if "layer" in grid.dims else grid From ddd880acee4da26912162b0f323814802128c9d9 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Tue, 29 Sep 2026 15:50:21 +0200 Subject: [PATCH 14/23] lint --- imod/prepare/topsystem/allocation.py | 37 +++++++++++++++------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index c3e701ae1..26c31d865 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -8,6 +8,7 @@ import numpy as np from imod.common.utilities.layer import create_layered_top +from imod.logging import logger from imod.schemata import DimsSchema from imod.select.layers import ( get_upper_active_grid_cells, @@ -15,7 +16,6 @@ ) from imod.typing import GridDataArray, GridDataDict from imod.util.dims import enforced_dim_order -from imod.logging import logger class ALLOCATION_OPTION(Enum): @@ -56,12 +56,14 @@ class ALLOCATION_OPTION(Enum): at_elevation = 2 at_first_active = 9 # Not an iMOD 5.6 option -class LAYER_USED(Enum): + +class LAYERS_USED(Enum): """ What this result represents. It can be either: - ALL: nothing to trim (all layers used) - NONE: nothing to keep (no layer has any allocated cell) """ + ALL = 0 NONE = 1 @@ -606,8 +608,7 @@ def _allocate_cells__at_first_active( return topsystem_upper_active, None - -def _used_layers(mask: GridDataArray) -> GridDataArray | LAYER_USED: +def _used_layers(mask: GridDataArray) -> GridDataArray | LAYERS_USED: """ Return the layer coordinate values of ``mask`` that contain at least one True value anywhere in the domain (and, if present, at any timestep), or @@ -622,17 +623,17 @@ def _used_layers(mask: GridDataArray) -> GridDataArray | LAYER_USED: Returns ------- - GridDataArray | LAYER_USED - Layer coordinate values with data, or LAYER_USED. - Returning LAYER_USED means either nothing to trim (all layers used) - or nothing to keep (no layer has any allocated cell). In the latter cases, - do not return an empty layer coordinate: a layer dimension of + GridDataArray | LAYERS_USED + Layer coordinate values with data, or LAYERS_USED. + Returning LAYERS_USED means either nothing to trim (all layers used) + or nothing to keep (no layer has any allocated cell). In the latter cases, + do not return an empty layer coordinate: a layer dimension of size 0 fails package validation and breaks downstream operations. Keep the full layer range instead, so empty packages can be removed by callers, e.g. ``mask_package__drop_if_empty``. """ if "layer" not in mask.dims: - return LAYER_USED.ALL + return LAYERS_USED.ALL if mask.dtype != bool: raise ValueError( @@ -649,9 +650,9 @@ def _used_layers(mask: GridDataArray) -> GridDataArray | LAYER_USED: # Never return an empty layer coordinate: keep the full range and # let callers (e.g. # mask_package__drop_if_empty) remove empty packages. if bool(has_data_per_layer.all()): - return LAYER_USED.ALL + return LAYERS_USED.ALL elif not bool(has_data_per_layer.any()): - return LAYER_USED.NONE + return LAYERS_USED.NONE return mask["layer"].where(has_data_per_layer, drop=True) @@ -677,11 +678,13 @@ def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: Same array, subset to layers with data. """ used_layers = _used_layers(grid) - if used_layers == LAYER_USED.NONE: + if used_layers == LAYERS_USED.NONE: name = grid.name if hasattr(grid, "name") else "" - logger.warning(f"No layers have data in grid '{name}', the package should be removed by the caller.") + logger.warning( + f"No layers have data in grid '{name}', the package should be removed by the caller." + ) return grid - elif used_layers == LAYER_USED.ALL: + elif used_layers == LAYERS_USED.ALL: return grid else: logger.debug("Dropping empty layers, keeping only used layers.") @@ -716,8 +719,8 @@ def drop_empty_layers_from_dict( To fully remove empty empty packages, additional logic outside this function is required. """ used_layers = _used_layers(mask) - if isinstance(used_layers, LAYER_USED): - return data # return as-is, let caller handle what to do with LAYER_USED.NONE or LAYER_USED.ALL + if isinstance(used_layers, LAYERS_USED): + return data # return as-is, let caller handle what to do with LAYERS_USED.NONE or LAYERS_USED.ALL return { key: grid.sel(layer=used_layers) if "layer" in grid.dims else grid From 9b187f24f6c23642683dba50807ca1e3e2fedc05 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Wed, 30 Sep 2026 09:23:20 +0200 Subject: [PATCH 15/23] Swap order for if-elif tree to not error when comparing a GridDataArray to LAYERS_USED --- imod/prepare/topsystem/allocation.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index 26c31d865..e147bdd21 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -678,7 +678,10 @@ def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: Same array, subset to layers with data. """ used_layers = _used_layers(grid) - if used_layers == LAYERS_USED.NONE: + if isinstance(used_layers, GridDataArray): + logger.debug("Dropping empty layers, keeping only used layers.") + return grid.sel(layer=used_layers) + elif used_layers == LAYERS_USED.NONE: name = grid.name if hasattr(grid, "name") else "" logger.warning( f"No layers have data in grid '{name}', the package should be removed by the caller." @@ -687,9 +690,8 @@ def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: elif used_layers == LAYERS_USED.ALL: return grid else: - logger.debug("Dropping empty layers, keeping only used layers.") - return grid.sel(layer=used_layers) - + raise ValueError(f"Unexpected value for used_layers: {used_layers}") + def drop_empty_layers_from_dict( data: GridDataDict, mask: GridDataArray From d082d039fc2e2fb5bfec576ef4c83791fc5f1a65 Mon Sep 17 00:00:00 2001 From: Luuk Blom Date: Wed, 30 Sep 2026 09:23:34 +0200 Subject: [PATCH 16/23] lint --- imod/prepare/topsystem/allocation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index e147bdd21..476bc1b67 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -691,7 +691,7 @@ def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: return grid else: raise ValueError(f"Unexpected value for used_layers: {used_layers}") - + def drop_empty_layers_from_dict( data: GridDataDict, mask: GridDataArray From 3361e12bc170327d0ae8a74a683c1e6c213c1786 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 10:18:54 +0200 Subject: [PATCH 17/23] Fix mypy errors and add some extra logger messages --- imod/prepare/topsystem/allocation.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index 476bc1b67..8edf6f5c9 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -678,19 +678,16 @@ def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: Same array, subset to layers with data. """ used_layers = _used_layers(grid) - if isinstance(used_layers, GridDataArray): - logger.debug("Dropping empty layers, keeping only used layers.") - return grid.sel(layer=used_layers) - elif used_layers == LAYERS_USED.NONE: - name = grid.name if hasattr(grid, "name") else "" + name = grid.name if hasattr(grid, "name") else "" + if used_layers is LAYERS_USED.NONE: logger.warning( f"No layers have data in grid '{name}', the package should be removed by the caller." ) - return grid - elif used_layers == LAYERS_USED.ALL: - return grid - else: - raise ValueError(f"Unexpected value for used_layers: {used_layers}") + if used_layers in (LAYERS_USED.NONE, LAYERS_USED.ALL): + return grid # return as-is, let caller handle what to do with LAYERS_USED.NONE or LAYERS_USED.ALL + + logger.debug(f"Dropping empty layers from grid '{name}', keeping only used layers.") + return grid.sel(layer=used_layers) def drop_empty_layers_from_dict( @@ -721,9 +718,17 @@ def drop_empty_layers_from_dict( To fully remove empty empty packages, additional logic outside this function is required. """ used_layers = _used_layers(mask) - if isinstance(used_layers, LAYERS_USED): + names = list(data.keys()) + if used_layers is LAYERS_USED.NONE: + logger.warning( + f"No layers have data in grids '{names}', the package should be removed by the caller." + ) + if used_layers in (LAYERS_USED.NONE, LAYERS_USED.ALL): return data # return as-is, let caller handle what to do with LAYERS_USED.NONE or LAYERS_USED.ALL + logger.debug( + f"Dropping empty layers from grids '{names}', keeping only used layers." + ) return { key: grid.sel(layer=used_layers) if "layer" in grid.dims else grid for key, grid in data.items() From a931f71c3cc937980ea05737d4c6fd49a0b0e064 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 14:27:49 +0200 Subject: [PATCH 18/23] Introduce match case system for layers_used_option --- imod/prepare/topsystem/allocation.py | 76 ++++++++++++++++------------ 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index 8edf6f5c9..89a31e938 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -61,11 +61,13 @@ class LAYERS_USED(Enum): """ What this result represents. It can be either: - ALL: nothing to trim (all layers used) + - SOME: some layers used, some empty - NONE: nothing to keep (no layer has any allocated cell) """ ALL = 0 - NONE = 1 + SOME = 1 + NONE = 2 PLANAR_GRID = ( @@ -608,7 +610,7 @@ def _allocate_cells__at_first_active( return topsystem_upper_active, None -def _used_layers(mask: GridDataArray) -> GridDataArray | LAYERS_USED: +def _used_layers(mask: GridDataArray) -> tuple[Optional[GridDataArray], LAYERS_USED]: """ Return the layer coordinate values of ``mask`` that contain at least one True value anywhere in the domain (and, if present, at any timestep), or @@ -633,7 +635,7 @@ def _used_layers(mask: GridDataArray) -> GridDataArray | LAYERS_USED: can be removed by callers, e.g. ``mask_package__drop_if_empty``. """ if "layer" not in mask.dims: - return LAYERS_USED.ALL + return None, LAYERS_USED.ALL if mask.dtype != bool: raise ValueError( @@ -650,11 +652,11 @@ def _used_layers(mask: GridDataArray) -> GridDataArray | LAYERS_USED: # Never return an empty layer coordinate: keep the full range and # let callers (e.g. # mask_package__drop_if_empty) remove empty packages. if bool(has_data_per_layer.all()): - return LAYERS_USED.ALL + return None, LAYERS_USED.ALL elif not bool(has_data_per_layer.any()): - return LAYERS_USED.NONE + return None, LAYERS_USED.NONE - return mask["layer"].where(has_data_per_layer, drop=True) + return mask["layer"].where(has_data_per_layer, drop=True), LAYERS_USED.SOME def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: @@ -677,17 +679,25 @@ def _drop_empty_layers(grid: GridDataArray) -> GridDataArray: GridDataArray Same array, subset to layers with data. """ - used_layers = _used_layers(grid) + used_layers, layers_used_option = _used_layers(grid) name = grid.name if hasattr(grid, "name") else "" - if used_layers is LAYERS_USED.NONE: - logger.warning( - f"No layers have data in grid '{name}', the package should be removed by the caller." - ) - if used_layers in (LAYERS_USED.NONE, LAYERS_USED.ALL): - return grid # return as-is, let caller handle what to do with LAYERS_USED.NONE or LAYERS_USED.ALL - - logger.debug(f"Dropping empty layers from grid '{name}', keeping only used layers.") - return grid.sel(layer=used_layers) + match layers_used_option: + case LAYERS_USED.NONE: + logger.warning( + f"No layers have data in grid '{name}', the package should be removed by the caller." + ) + return grid + case LAYERS_USED.ALL: + return grid + case LAYERS_USED.SOME: + logger.debug( + f"Dropping empty layers from grid '{name}', keeping only used layers." + ) + return grid.sel(layer=used_layers) + case _: + raise ValueError( + f"Unexpected value for layers_used_option: {layers_used_option}" + ) def drop_empty_layers_from_dict( @@ -717,19 +727,23 @@ def drop_empty_layers_from_dict( If mask has no True values, data is returned unchanged. To fully remove empty empty packages, additional logic outside this function is required. """ - used_layers = _used_layers(mask) + used_layers, layers_used = _used_layers(mask) names = list(data.keys()) - if used_layers is LAYERS_USED.NONE: - logger.warning( - f"No layers have data in grids '{names}', the package should be removed by the caller." - ) - if used_layers in (LAYERS_USED.NONE, LAYERS_USED.ALL): - return data # return as-is, let caller handle what to do with LAYERS_USED.NONE or LAYERS_USED.ALL - - logger.debug( - f"Dropping empty layers from grids '{names}', keeping only used layers." - ) - return { - key: grid.sel(layer=used_layers) if "layer" in grid.dims else grid - for key, grid in data.items() - } + match layers_used: + case LAYERS_USED.NONE: + logger.warning( + f"No layers have data in grid '{names}', the package should be removed by the caller." + ) + return data + case LAYERS_USED.ALL: + return data # return as-is, let caller handle what to do with LAYERS_USED.NONE or LAYERS_USED.ALL + case LAYERS_USED.SOME: + logger.debug( + f"Dropping empty layers from grid '{names}', keeping only used layers." + ) + return { + key: grid.sel(layer=used_layers) if "layer" in grid.dims else grid + for key, grid in data.items() + } + case _: + raise ValueError(f"Unexpected value for layers_used: {layers_used}") From e6139022cdbb416279fc350315b3c685317baf1b Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 14:34:28 +0200 Subject: [PATCH 19/23] Remove unnecessary sentence at the end of docstring explanation drop_empty_layers --- imod/mf6/drn.py | 4 +--- imod/mf6/ghb.py | 4 +--- imod/mf6/rch.py | 4 +--- imod/mf6/riv.py | 4 +--- imod/mf6/topsystem.py | 4 +--- imod/tests/_scratch.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 imod/tests/_scratch.py diff --git a/imod/mf6/drn.py b/imod/mf6/drn.py index 04e91d727..ca2a58ae8 100644 --- a/imod/mf6/drn.py +++ b/imod/mf6/drn.py @@ -225,9 +225,7 @@ def _allocate_and_distribute_planar_data( are actually used. Reduces memory use and speeds up later operations such as regridding, clipping and splitting. If no cells are allocated in any layer, nothing is dropped and the full layer range is returned, as a layer - dimension of size 0 is not valid. Note that allocation and conductance - distribution are always computed over the full layer range first, so - dropping layers does not change the computed values. + dimension of size 0 is not valid. Returns ------- diff --git a/imod/mf6/ghb.py b/imod/mf6/ghb.py index 21506d390..9b554e955 100644 --- a/imod/mf6/ghb.py +++ b/imod/mf6/ghb.py @@ -230,9 +230,7 @@ def _allocate_and_distribute_planar_data( are actually used. Reduces memory use and speeds up later operations such as regridding, clipping and splitting. If no cells are allocated in any layer, nothing is dropped and the full layer range is returned, as a layer - dimension of size 0 is not valid. Note that allocation and conductance - distribution are always computed over the full layer range first, so - dropping layers does not change the computed values. + dimension of size 0 is not valid. Returns ------- diff --git a/imod/mf6/rch.py b/imod/mf6/rch.py index a4246aa05..a32e6c28a 100644 --- a/imod/mf6/rch.py +++ b/imod/mf6/rch.py @@ -207,9 +207,7 @@ def _allocate_planar_data( are actually used. Reduces memory use and speeds up later operations such as regridding, clipping and splitting. If no cells are allocated in any layer, nothing is dropped and the full layer range is returned, as a layer - dimension of size 0 is not valid. Note that allocation and conductance - distribution are always computed over the full layer range first, so - dropping layers does not change the computed values. + dimension of size 0 is not valid. Returns ------- diff --git a/imod/mf6/riv.py b/imod/mf6/riv.py index 165499221..c80e89be2 100644 --- a/imod/mf6/riv.py +++ b/imod/mf6/riv.py @@ -349,9 +349,7 @@ def _allocate_and_distribute_planar_data( are actually used. Reduces memory use and speeds up later operations such as regridding, clipping and splitting. If no cells are allocated in any layer, nothing is dropped and the full layer range is returned, as a layer - dimension of size 0 is not valid. Note that allocation and conductance - distribution are always computed over the full layer range first, so - dropping layers does not change the computed values. + dimension of size 0 is not valid. Returns ------- diff --git a/imod/mf6/topsystem.py b/imod/mf6/topsystem.py index 91a248785..a6ff44c42 100644 --- a/imod/mf6/topsystem.py +++ b/imod/mf6/topsystem.py @@ -90,9 +90,7 @@ def reallocate( are actually used. Reduces memory use and speeds up later operations such as regridding, clipping and splitting. If no cells are allocated in any layer, nothing is dropped and the full layer range is returned, as a layer - dimension of size 0 is not valid. Note that allocation and conductance - distribution are always computed over the full layer range first, so - dropping layers does not change the computed values. + dimension of size 0 is not valid. Returns ------- diff --git a/imod/tests/_scratch.py b/imod/tests/_scratch.py new file mode 100644 index 000000000..b1ce11b52 --- /dev/null +++ b/imod/tests/_scratch.py @@ -0,0 +1,52 @@ +# %% +from pathlib import Path + +import xarray as xr + +import imod +from imod.util.dims import drop_layer_dim_cap_and_bnd_data + +# %% +# Path management +wdir = Path( + r"c:\Users\engelen\projects_wdir\imod-python\imod5_converter\NHI_sprint\Peelvenen" +) + +svat_dir = wdir / "CONVERSION_IMOD5_IPF" / "GWF_1" / "MSWAPINPUT" + +prj_path = wdir / "prjfiles" / "Peelvenen_absolute_paths_sprinkling_ipf.PRJ" + +svat_imod5 = imod.idf.open_dataset(svat_dir / "SVAT*.IDF", pattern="{name}") +svat_imod5 = xr.merge([svat_imod5]).to_dataarray(dim="subunit", name="svat").compute() +svat_imod5 = svat_imod5.assign_coords(subunit=[0, 1]) +# %% +# Setup to get example args for _render() of SprinklingPoints +prj_data, period_data = imod.formats.prj.open_projectfile_data(prj_path) + +dis_pkg = imod.mf6.StructuredDiscretization.from_imod5_data(prj_data, validate=False) +dis_pkg["idomain"] = dis_pkg["idomain"].clip(min=0) +npf_pkg = imod.mf6.NodePropertyFlow.from_imod5_data( + prj_data, dis_pkg.dataset["idomain"] +) + +prj_data = drop_layer_dim_cap_and_bnd_data(prj_data) +griddata, msw_active = imod.msw.GridData.from_imod5_data(prj_data, dis_pkg) + +well = imod.mf6.LayeredWell.from_imod5_cap_data( + prj_data, target_dis=None, regridder_types=None, regrid_cache=None +) +# Convert to args of Sprinkling._render() +mf6_well = well.to_mf6_pkg( + dis_pkg["idomain"], dis_pkg["top"], dis_pkg["bottom"], npf_pkg["k"] +) +isactive_1d, svat = griddata.generate_isactive_svat_arrays() + +sprinkling_points = imod.msw.SprinklingPoints.from_imod5_data(prj_data) + +# %% +directory = r"c:\Users\engelen\projects_wdir\imod-python\imod5_converter\NHI_sprint\Peelvenen\conversion_output_ipf" +sprinkling_points.write(directory, isactive_1d, svat, dis_pkg, mf6_well) + +# %% +# +# TODO: Verify if iMOD5 SVAT grid the same as the one derived in this script. From 7710b6b1990be445075bf3fa24ddef9682393586 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 15:11:12 +0200 Subject: [PATCH 20/23] Improve performance for cases where the mask has a lot of timesteps --- imod/prepare/topsystem/allocation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index 89a31e938..c6264e602 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -9,6 +9,7 @@ from imod.common.utilities.layer import create_layered_top from imod.logging import logger +from imod.msw.utilities import mask from imod.schemata import DimsSchema from imod.select.layers import ( get_upper_active_grid_cells, @@ -642,6 +643,9 @@ def _used_layers(mask: GridDataArray) -> tuple[Optional[GridDataArray], LAYERS_U f"Expected a boolean grid to drop empty layers from, got: {mask.dtype}" ) + # Reduce the mask to the first time step if a time dimension exists for + # performance reasons + mask = mask.isel(time=0, drop=True, missing_dims="ignore") reduce_dims = [d for d in mask.dims if d != "layer"] has_data_per_layer = mask.any(dim=reduce_dims) From 26f75d29dd44b5f5173d2b05c4bddbb06a73f732 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 15:11:29 +0200 Subject: [PATCH 21/23] Format --- imod/prepare/topsystem/allocation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index c6264e602..43ddda89a 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -9,7 +9,6 @@ from imod.common.utilities.layer import create_layered_top from imod.logging import logger -from imod.msw.utilities import mask from imod.schemata import DimsSchema from imod.select.layers import ( get_upper_active_grid_cells, From 1e0d14b458c8d3341501bd7fb8152a7aec93a193 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 15:20:13 +0200 Subject: [PATCH 22/23] Update return type docstring --- imod/prepare/topsystem/allocation.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/imod/prepare/topsystem/allocation.py b/imod/prepare/topsystem/allocation.py index 43ddda89a..ea40858a9 100644 --- a/imod/prepare/topsystem/allocation.py +++ b/imod/prepare/topsystem/allocation.py @@ -625,14 +625,15 @@ def _used_layers(mask: GridDataArray) -> tuple[Optional[GridDataArray], LAYERS_U Returns ------- - GridDataArray | LAYERS_USED - Layer coordinate values with data, or LAYERS_USED. - Returning LAYERS_USED means either nothing to trim (all layers used) - or nothing to keep (no layer has any allocated cell). In the latter cases, - do not return an empty layer coordinate: a layer dimension of - size 0 fails package validation and breaks downstream - operations. Keep the full layer range instead, so empty packages - can be removed by callers, e.g. ``mask_package__drop_if_empty``. + GridDataArray + Layer coordinate values with data. If all or none of the layers have + data, this will be None. In the latter case, do not return an empty + layer coordinate: a layer dimension of size 0 fails package validation + and breaks downstream operations. Keep the full layer range instead, so + empty packages can be removed by callers, e.g. + ``mask_package__drop_if_empty``. + LAYERS_USED + Enumerator indicating whether all, none, or some layers are used. """ if "layer" not in mask.dims: return None, LAYERS_USED.ALL From 0626ae70e715eec306a04f787e4c1236a7ee21d3 Mon Sep 17 00:00:00 2001 From: JoerivanEngelen Date: Wed, 30 Sep 2026 15:58:28 +0200 Subject: [PATCH 23/23] Do not add scratch file to PR --- imod/tests/_scratch.py | 52 ------------------------------------------ 1 file changed, 52 deletions(-) delete mode 100644 imod/tests/_scratch.py diff --git a/imod/tests/_scratch.py b/imod/tests/_scratch.py deleted file mode 100644 index b1ce11b52..000000000 --- a/imod/tests/_scratch.py +++ /dev/null @@ -1,52 +0,0 @@ -# %% -from pathlib import Path - -import xarray as xr - -import imod -from imod.util.dims import drop_layer_dim_cap_and_bnd_data - -# %% -# Path management -wdir = Path( - r"c:\Users\engelen\projects_wdir\imod-python\imod5_converter\NHI_sprint\Peelvenen" -) - -svat_dir = wdir / "CONVERSION_IMOD5_IPF" / "GWF_1" / "MSWAPINPUT" - -prj_path = wdir / "prjfiles" / "Peelvenen_absolute_paths_sprinkling_ipf.PRJ" - -svat_imod5 = imod.idf.open_dataset(svat_dir / "SVAT*.IDF", pattern="{name}") -svat_imod5 = xr.merge([svat_imod5]).to_dataarray(dim="subunit", name="svat").compute() -svat_imod5 = svat_imod5.assign_coords(subunit=[0, 1]) -# %% -# Setup to get example args for _render() of SprinklingPoints -prj_data, period_data = imod.formats.prj.open_projectfile_data(prj_path) - -dis_pkg = imod.mf6.StructuredDiscretization.from_imod5_data(prj_data, validate=False) -dis_pkg["idomain"] = dis_pkg["idomain"].clip(min=0) -npf_pkg = imod.mf6.NodePropertyFlow.from_imod5_data( - prj_data, dis_pkg.dataset["idomain"] -) - -prj_data = drop_layer_dim_cap_and_bnd_data(prj_data) -griddata, msw_active = imod.msw.GridData.from_imod5_data(prj_data, dis_pkg) - -well = imod.mf6.LayeredWell.from_imod5_cap_data( - prj_data, target_dis=None, regridder_types=None, regrid_cache=None -) -# Convert to args of Sprinkling._render() -mf6_well = well.to_mf6_pkg( - dis_pkg["idomain"], dis_pkg["top"], dis_pkg["bottom"], npf_pkg["k"] -) -isactive_1d, svat = griddata.generate_isactive_svat_arrays() - -sprinkling_points = imod.msw.SprinklingPoints.from_imod5_data(prj_data) - -# %% -directory = r"c:\Users\engelen\projects_wdir\imod-python\imod5_converter\NHI_sprint\Peelvenen\conversion_output_ipf" -sprinkling_points.write(directory, isactive_1d, svat, dis_pkg, mf6_well) - -# %% -# -# TODO: Verify if iMOD5 SVAT grid the same as the one derived in this script.