Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d8fc795
Improved performance for allocation & sparse/empty layer handling.
LuukBlom Aug 31, 2026
abec816
add changelog entry
LuukBlom Aug 31, 2026
34cf8df
remove unused `spatial_dims` arg
LuukBlom Aug 31, 2026
c39005a
set default for drop_empty_layers to True and update tests. Fixed a b…
LuukBlom Sep 28, 2026
abaecc6
lint
LuukBlom Sep 28, 2026
761fb4a
revert lineendings change.
LuukBlom Sep 28, 2026
714ce81
fix `_used_layers` to also return None on empty masks
LuukBlom Sep 28, 2026
4ee8e7e
sync docstrings for drop_empty_layers
LuukBlom Sep 28, 2026
f97cd84
update edge case test where all layers are empty.
LuukBlom Sep 28, 2026
a2f6fc6
implement review comments
LuukBlom Sep 29, 2026
3330bb7
Merge branch 'master' into feat/drop-unused-layers
LuukBlom Sep 29, 2026
98d6ddf
lint
LuukBlom Sep 29, 2026
1dd4332
update dvc config
LuukBlom Sep 29, 2026
317adbc
add enum for LAYERS_USED. Created issue #1923 for letting callers han…
LuukBlom Sep 29, 2026
ddd880a
lint
LuukBlom Sep 29, 2026
9b187f2
Swap order for if-elif tree to not error when comparing a GridDataArr…
LuukBlom Sep 30, 2026
d082d03
lint
LuukBlom Sep 30, 2026
3361e12
Fix mypy errors and add some extra logger messages
JoerivanEngelen Sep 30, 2026
a931f71
Introduce match case system for layers_used_option
JoerivanEngelen Sep 30, 2026
e613902
Remove unnecessary sentence at the end of docstring explanation drop_…
JoerivanEngelen Sep 30, 2026
7710b6b
Improve performance for cases where the mask has a lot of timesteps
JoerivanEngelen Sep 30, 2026
26f75d2
Format
JoerivanEngelen Sep 30, 2026
1e0d14b
Update return type docstring
JoerivanEngelen Sep 30, 2026
1908917
Merge branch 'master' into feat/drop-unused-layers
JoerivanEngelen Sep 30, 2026
0626ae7
Do not add scratch file to PR
JoerivanEngelen Sep 30, 2026
5966aec
erge branch 'feat/drop-unused-layers' of github.com:Deltares/imod-pyt…
JoerivanEngelen Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .dvc/config
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions docs/api/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ Added
:meth:`imod.mf6.GroundwaterTransportModel.mask_packages` to mask specific
packages of a groundwater flow model and a groundwater transport model
respectively.
- 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
~~~~~
Expand All @@ -35,6 +52,11 @@ Fixed
:class:`imod.mf6.Recharge`) where IBOUND is less than 0.
- :meth:`imod.msw.MetaSwapModel.from_imod5_data` now masks cells where IBOUND is
less than 0.
- 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``.
- :class:`imod.msw.FileCopier` and :class:`imod.msw.MeteoGridCopy` now force
paths to be stored as strings in the dataset. ``pathlib.Path`` objects could
cause errors when calling :meth:`imod.msw.MetaSwapModel.dump`.
Expand Down
19 changes: 18 additions & 1 deletion imod/mf6/drn.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
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.allocation import (
ALLOCATION_OPTION,
allocate_drn_cells,
drop_empty_layers_from_dict,
)
from imod.prepare.topsystem.conductance import (
DISTRIBUTING_OPTION,
distribute_drn_conductance,
Expand Down Expand Up @@ -193,6 +197,7 @@ def _allocate_and_distribute_planar_data(
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
Expand All @@ -214,6 +219,13 @@ def _allocate_and_distribute_planar_data(
ALLOCATION_OPTION.at_first_active.
distributing_option: DISTRIBUTING_OPTION
distributing option.
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.

Returns
-------
Expand All @@ -240,6 +252,7 @@ def _allocate_and_distribute_planar_data(
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(
Expand All @@ -253,6 +266,10 @@ def _allocate_and_distribute_planar_data(
)
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
Expand Down
17 changes: 16 additions & 1 deletion imod/mf6/ghb.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
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.allocation import (
ALLOCATION_OPTION,
allocate_ghb_cells,
drop_empty_layers_from_dict,
)
from imod.prepare.topsystem.conductance import (
DISTRIBUTING_OPTION,
distribute_ghb_conductance,
Expand Down Expand Up @@ -198,6 +202,7 @@ def _allocate_and_distribute_planar_data(
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
Expand All @@ -219,6 +224,13 @@ def _allocate_and_distribute_planar_data(
ALLOCATION_OPTION.at_first_active.
distributing_option: DISTRIBUTING_OPTION
distributing option.
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.

Returns
-------
Expand All @@ -245,6 +257,7 @@ def _allocate_and_distribute_planar_data(
top,
bottom,
planar_data["head"],
drop_empty_layers=False, # Keep full layer range, drop empty layers below
)

layered_data = {}
Expand All @@ -259,6 +272,8 @@ def _allocate_and_distribute_planar_data(
bottom,
npf.dataset["k"],
)
if drop_empty_layers:
layered_data = drop_empty_layers_from_dict(layered_data, ghb_allocation)
return layered_data

@classmethod
Expand Down
17 changes: 16 additions & 1 deletion imod/mf6/rch.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
get_cell_area_from_imod5_data,
is_msw_active_cell,
)
from imod.prepare.topsystem.allocation import ALLOCATION_OPTION, allocate_rch_cells
from imod.prepare.topsystem.allocation import (
ALLOCATION_OPTION,
allocate_rch_cells,
drop_empty_layers_from_dict,
)
from imod.schemata import (
AllCoordsValueSchema,
AllInsideNoDataSchema,
Expand Down Expand Up @@ -182,6 +186,7 @@ def _allocate_planar_data(
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
Expand All @@ -196,6 +201,13 @@ def _allocate_planar_data(
Model discretization package.
allocation_option: ALLOCATION_OPTION
The allocation option to use for the reallocation.
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.

Returns
-------
Expand All @@ -210,11 +222,14 @@ def _allocate_planar_data(
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
Expand Down
24 changes: 23 additions & 1 deletion imod/mf6/riv.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
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.allocation import (
ALLOCATION_OPTION,
allocate_riv_cells,
drop_empty_layers_from_dict,
)
from imod.prepare.topsystem.conductance import (
DISTRIBUTING_OPTION,
distribute_drn_conductance,
Expand Down Expand Up @@ -317,6 +321,7 @@ def _allocate_and_distribute_planar_data(
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
Expand All @@ -338,6 +343,13 @@ def _allocate_and_distribute_planar_data(
ALLOCATION_OPTION.at_first_active.
distributing_option: DISTRIBUTING_OPTION
distributing option.
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.

Returns
-------
Expand All @@ -364,6 +376,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
)
drn_is_allocated = drn_allocated is not None
# Distribution of conductances
Expand Down Expand Up @@ -409,6 +422,15 @@ def _allocate_and_distribute_planar_data(
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
Expand Down
21 changes: 19 additions & 2 deletions imod/mf6/topsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def reallocate(
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.
Expand All @@ -83,6 +84,13 @@ 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 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.

Returns
-------
Expand All @@ -102,10 +110,17 @@ def reallocate(
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
planar_data,
dis,
npf,
allocation_option,
distributing_option,
drop_empty_layers,
)
else:
grid_dict = self._allocate_planar_data(planar_data, dis, allocation_option)
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):
Expand All @@ -122,6 +137,7 @@ def _allocate_and_distribute_planar_data(
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 "
Expand All @@ -134,6 +150,7 @@ def _allocate_planar_data(
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 "
Expand Down
10 changes: 10 additions & 0 deletions imod/prepare/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ def align_interface_levels(
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`
Comment on lines +30 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't expect this to happen, which tests were failing because of this? Just wondering what the cause of this is as it might mean we overlooked something.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While developing I got errors, but I cannot remember exactly which ones to be honest.
I have run the tests again with this fix commented out, and got 2 errors:

test_prepare\test_cleanup.py::test_cleanup_riv__trimmed_layers[structured & unstructured]

This is however the test I added to produce this bug and show it does not error anymore. (Which is good news: all callers pass matching layers to this function.)

The bug (introduced by trimming layers):
bottom has the full model layers, while top/to_align can be a trimmed package with a subset of layers.
Calling xr.where(~to_align, bottom) will then raise an alignment error since xarray uses join="exact" by default.
This is why other places in the code use xr.align(obj1, obj2, join="left"), which fills missing layers with NaN (mask.py and schemata.py).
Since the inputs to xr.where need to be the same shape, and we are interested in the top layers, we can throw away the irrelevant layers from bottom before doing this.

# 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:
Expand Down
Loading
Loading