Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions docs/api/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ Fixed
- 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.

Changed
~~~~~~~
Expand Down
29 changes: 25 additions & 4 deletions imod/mf6/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from imod.mf6.riv import River
from imod.mf6.utilities.clipped_bc_creator import (
StateClassType,
StateType,
create_clipped_boundary,
)
from imod.mf6.utilities.mf6hfb import merge_hfb_packages
Expand All @@ -64,8 +65,8 @@ def pkg_has_cleanup(pkg: Package):
def _create_boundary_condition_for_unassigned_boundary(
model: Modflow6Model,
state_for_boundary: Optional[GridDataArray],
additional_boundaries: Optional[list[StateClassType]] = None,
):
additional_boundaries: list[Optional[StateType]] = [None],
) -> Optional[StateType]:
if state_for_boundary is None:
return None

Expand All @@ -90,7 +91,7 @@ def _create_boundary_condition_clipped_boundary(
clipped_model: Modflow6Model,
state_for_boundary: Optional[GridDataArray],
clip_box_args: tuple,
):
) -> Optional[StateType]:
# Create temporary boundary condition for the original model boundary. This
# is used later to see which boundaries can be ignored as they were already
# present in the original model. We want to just end up with the boundary
Expand Down Expand Up @@ -120,10 +121,30 @@ def _create_boundary_condition_clipped_boundary(
else:
state_for_boundary_clipped = None

return _create_boundary_condition_for_unassigned_boundary(
bc_constant_pkg = _create_boundary_condition_for_unassigned_boundary(
clipped_model, state_for_boundary_clipped, [unassigned_boundary_clipped]
)

# Remove all indices before first timestep of state_for_clipped_boundary.
# This to prevent empty dataarrays unnecessarily being made for these
# indices, which can lead to them to be removed when purging empty packages
# with ignore_time=True. Unfortunately, this is needs to be handled here and
# not in _create_boundary_condition_for_unassigned_boundary, as otherwise
# this function is called twice which could result in broadcasting errors in
# the second call if the time domain of state_for_boundary and assigned
# packages have no overlap.
if (
(state_for_boundary is not None)
and (state_for_boundary.indexes.get("time") is not None)
and (bc_constant_pkg is not None)
):
start_time = state_for_boundary.indexes["time"][0]
bc_constant_pkg.dataset = bc_constant_pkg.dataset.sel(
time=slice(start_time, None)
)

return bc_constant_pkg


class Modflow6Model(collections.UserDict, IModel, abc.ABC):
_mandatory_packages: tuple[str, ...] = ()
Expand Down
105 changes: 99 additions & 6 deletions imod/mf6/utilities/clipped_bc_creator.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from typing import TypeAlias
from typing import Optional, Tuple, TypeAlias

import xarray as xr

from imod.mf6 import ConstantConcentration, ConstantHead
from imod.select.grid import active_grid_boundary_xy
from imod.typing import GridDataArray
from imod.util.dims import enforced_dim_order

StateType: TypeAlias = ConstantHead | ConstantConcentration
StateClassType: TypeAlias = type[ConstantHead] | type[ConstantConcentration]
Expand All @@ -12,7 +15,7 @@ def _find_unassigned_grid_boundaries(
active_grid_boundary: GridDataArray,
boundary_conditions: list[StateType],
) -> GridDataArray:
unassigned_grid_boundaries = active_grid_boundary
unassigned_grid_boundaries = active_grid_boundary.copy()
for boundary_condition in boundary_conditions:
# Fetch variable name from the first boundary condition, can be "head" or
# "concentration".
Expand All @@ -24,6 +27,96 @@ def _find_unassigned_grid_boundaries(
return unassigned_grid_boundaries


def _align_time_indexes_boundaries(
state_for_clipped_boundary: GridDataArray,
unassigned_grid_boundaries: GridDataArray,
) -> Optional[GridDataArray]:
"""
Create an outer time index for aligning boundaries. Furthermore deal with
cases where one or both boundaries don't have a time dimension. In a graphic
way, we want this:

State
a-----b-----c
Unassigned
d-----e

Needs to align to:
d---a--e--b-----c
"""
index_unassigned = unassigned_grid_boundaries.indexes
index_state = state_for_clipped_boundary.indexes
if "time" in index_unassigned and "time" in index_state:
return index_unassigned["time"].join(index_state["time"], how="outer")
elif "time" in index_unassigned:
return index_unassigned["time"]
elif "time" in index_state:
return index_state["time"]
else:
return None


def _align_boundaries(
state_for_clipped_boundary: GridDataArray,
unassigned_grid_boundaries: Optional[GridDataArray],
) -> Tuple[GridDataArray, Optional[GridDataArray]]:
"""
Customly align the state_for_clipped_boundary and unassigned grid boundaries.
- "layer" dimension requires outer alignment
- "time" requires reindexing with ffill
- planar coordinates are expected to be aligned already
"""
# Align dimensions
if unassigned_grid_boundaries is not None:
# Align along layer dimension with outer join, xarray API only supports
# excluding dims, not specifying dims to align along, so we have to do
# it this way.
dims_to_exclude = set(state_for_clipped_boundary.dims) | set(
unassigned_grid_boundaries.dims
)
dims_to_exclude.remove("layer")
state_for_clipped_boundary, unassigned_grid_boundaries = xr.align(
state_for_clipped_boundary,
unassigned_grid_boundaries,
join="outer",
exclude=dims_to_exclude,
)
# Align along time dimension by finding the outer time indexes and then
# reindexing with a ffill.
outer_time_index = _align_time_indexes_boundaries(
state_for_clipped_boundary, unassigned_grid_boundaries
)
if "time" in state_for_clipped_boundary.indexes:
state_for_clipped_boundary = state_for_clipped_boundary.reindex(
{"time": outer_time_index}, method="ffill"
)
if "time" in unassigned_grid_boundaries.indexes:
unassigned_grid_boundaries = unassigned_grid_boundaries.reindex(
{"time": outer_time_index}, method="ffill"
)

return state_for_clipped_boundary, unassigned_grid_boundaries


@enforced_dim_order
def _create_clipped_boundary_state(
idomain: GridDataArray,
state_for_clipped_boundary: GridDataArray,
original_constant_head_boundaries: list[StateType],
):
"""Helper function to make sure dimension order is enforced"""
active_grid_boundary = active_grid_boundary_xy(idomain > 0)
unassigned_grid_boundaries = _find_unassigned_grid_boundaries(
active_grid_boundary, original_constant_head_boundaries
)

state_for_clipped_boundary, unassigned_grid_boundaries = _align_boundaries(
state_for_clipped_boundary, unassigned_grid_boundaries
)

return state_for_clipped_boundary.where(unassigned_grid_boundaries)


def create_clipped_boundary(
idomain: GridDataArray,
state_for_clipped_boundary: GridDataArray,
Expand Down Expand Up @@ -52,10 +145,10 @@ def create_clipped_boundary(
packages

"""
active_grid_boundary = active_grid_boundary_xy(idomain > 0)
unassigned_grid_boundaries = _find_unassigned_grid_boundaries(
active_grid_boundary, original_constant_head_boundaries
constant_state = _create_clipped_boundary_state(
idomain,
state_for_clipped_boundary,
original_constant_head_boundaries,
)
constant_state = state_for_clipped_boundary.where(unassigned_grid_boundaries)

return pkg_type(constant_state, print_input=True, print_flows=True, save_flows=True)
3 changes: 2 additions & 1 deletion imod/templates/mf6/gwf-cnc.j2
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ end dimensions

{% for i, path in periods.items() %}begin period {{i}}
open/close {{path}}{% if binary %} (binary){% endif %}
end period{% endfor %}
end period
{% endfor %}
121 changes: 111 additions & 10 deletions imod/tests/test_mf6/test_circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,15 +205,30 @@ def test_simulation_write_and_run_transport_vsc(circle_model_transport_vsc, tmp_
assert concentration.shape == (52, 2, 216)


def run_simulation(simulation, modeldir):
idomain = simulation["GWF_1"]["disv"]["idomain"].compute()
time = simulation["time_discretization"].dataset.coords["time"].values
simulation.write(modeldir)
simulation.run()
head = (
simulation.open_head(simulation_start_time=time[0])
.compute()
.reindex_like(idomain)
)
concentration = (
simulation.open_concentration()
.compute(simulation_start_time=time[0])
.reindex_like(idomain)
)
return head, concentration


def test_simulation_clip_and_state_at_boundary(circle_model_transport, tmp_path):
# Arrange
simulation = circle_model_transport
idomain = simulation["GWF_1"]["disv"]["idomain"].compute()

simulation.write(tmp_path / "full")
simulation.run()
head = simulation.open_head().compute().reindex_like(idomain)
concentration = simulation.open_concentration().compute().reindex_like(idomain)
head, concentration = run_simulation(simulation, tmp_path / "full")

states_for_boundary = {
"GWF_1": head.isel(time=-1, drop=True),
Expand All @@ -233,13 +248,99 @@ def test_simulation_clip_and_state_at_boundary(circle_model_transport, tmp_path)
== 20
)
assert half_simulation["GWF_1"]["chd_clipped"]["head"].notnull().sum() == 20

# Test if model runs
head_half, concentration_half = run_simulation(half_simulation, tmp_path / "half")
assert head_half.shape == (52, 2, 108)
assert concentration_half.shape == (52, 2, 108)


def test_simulation_clip_and_constant_state_at_boundary__transient_chd(
circle_model_transport, tmp_path
):
# Arrange
simulation = circle_model_transport
idomain = simulation["GWF_1"]["disv"]["idomain"].compute()
time = simulation["time_discretization"].dataset.coords["time"].values

simulation["GWF_1"]["chd"].dataset["head"] = (
simulation["GWF_1"]["chd"].dataset["head"].expand_dims(time=time)
)

head, concentration = run_simulation(simulation, tmp_path / "full")

states_for_boundary = {
"GWF_1": head.isel(time=-1, drop=True),
"transport": concentration.isel(time=-1, drop=True),
}
# Act
half_simulation = simulation.clip_box(
x_max=0.1, states_for_boundary=states_for_boundary
)
# Assert
# Test if model dims halved
idomain_half = half_simulation["GWF_1"]["disv"]["idomain"]
dim = idomain_half.grid.face_dimension
np.testing.assert_array_equal(idomain_half.sizes[dim] / idomain.sizes[dim], 0.5)
# Test if the clipped boundary conditions have the correct dimension and values
conc_clipped = half_simulation["transport"]["cnc_clipped"]["concentration"]
assert conc_clipped.sizes["layer"] == 2
assert "time" not in conc_clipped.dims
assert conc_clipped.notnull().sum() == 20
head_clipped = half_simulation["GWF_1"]["chd_clipped"]["head"]
assert head_clipped.sizes["layer"] == 2
assert head_clipped.sizes["time"] == 52
assert head_clipped.notnull().sum() == 20 * 52
# Test if model runs
half_simulation.write(tmp_path / "half")
half_simulation.run()
# Test if the clipped model output has the correct dimension
head_half = half_simulation.open_head().compute().reindex_like(idomain_half)
concentration_half = (
half_simulation.open_concentration().compute().reindex_like(idomain_half)
head_half, concentration_half = run_simulation(half_simulation, tmp_path / "half")
assert head_half.shape == (52, 2, 108)
assert concentration_half.shape == (52, 2, 108)


def test_simulation_clip_and_transient_state_at_boundary__transient_chd(
circle_model_transport, tmp_path
):
"""
Test with a transient chd boundary condition and transient states for
boundary conditions. The time steps of the chd package are outside of the
time domain of the states_for_boundary.
"""
# Arrange
simulation = circle_model_transport
idomain = simulation["GWF_1"]["disv"]["idomain"].compute()
time = simulation["time_discretization"].dataset.coords["time"].values

simulation["GWF_1"]["chd"].dataset["head"] = (
simulation["GWF_1"]["chd"].dataset["head"].expand_dims(time=time[:5])
)

head, concentration = run_simulation(simulation, tmp_path / "full")

states_for_boundary = {
"GWF_1": head.isel(time=slice(11, -11)),
"transport": concentration.isel(time=slice(11, -11)),
}
# Act
half_simulation = simulation.clip_box(
x_max=0.1, states_for_boundary=states_for_boundary
)
# Assert
# Test if model dims halved
idomain_half = half_simulation["GWF_1"]["disv"]["idomain"]
dim = idomain_half.grid.face_dimension
np.testing.assert_array_equal(idomain_half.sizes[dim] / idomain.sizes[dim], 0.5)
# Test if the clipped boundary conditions have the correct dimension and values
# Transport data for just 30 time steps (from 12 up to 41)
conc_clipped = half_simulation["transport"]["cnc_clipped"]["concentration"]
assert conc_clipped.sizes["layer"] == 2
assert conc_clipped.sizes["time"] == 30
assert conc_clipped.notnull().sum() == 20 * 30
# Head data for chd for just 30 time steps (from 12 up to 41)
head_clipped = half_simulation["GWF_1"]["chd_clipped"]["head"]
assert head_clipped.sizes["layer"] == 2
assert head_clipped.sizes["time"] == 30
assert head_clipped.notnull().sum() == 20 * 30
# Test if model runs
head_half, concentration_half = run_simulation(half_simulation, tmp_path / "half")
assert head_half.shape == (52, 2, 108)
assert concentration_half.shape == (52, 2, 108)
Loading