diff --git a/docs/api/changelog.rst b/docs/api/changelog.rst index 3db8a0be0..7d2c51f5a 100644 --- a/docs/api/changelog.rst +++ b/docs/api/changelog.rst @@ -25,6 +25,11 @@ Changed advice doing development installations with pixi from now on. `See the documentation. `_ +Added +~~~~~ +- 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. [0.15.1] - 2023-12-22 --------------------- diff --git a/imod/mf6/__init__.py b/imod/mf6/__init__.py index e296bc197..2a3eee557 100644 --- a/imod/mf6/__init__.py +++ b/imod/mf6/__init__.py @@ -13,6 +13,7 @@ from imod.mf6.evt import Evapotranspiration from imod.mf6.ghb import GeneralHeadBoundary from imod.mf6.gwfgwf import GWFGWF +from imod.mf6.gwfgwt import GWFGWT from imod.mf6.hfb import ( HorizontalFlowBarrierBase, HorizontalFlowBarrierHydraulicCharacteristic, diff --git a/imod/mf6/exchangebase.py b/imod/mf6/exchangebase.py new file mode 100644 index 000000000..7a434df5b --- /dev/null +++ b/imod/mf6/exchangebase.py @@ -0,0 +1,42 @@ +from typing import Dict, Tuple + +from imod.mf6.package import Package + +_pkg_id_to_type = {"gwfgwf": "GWF6-GWF6", "gwfgwt": "GWF6-GWT6"} + + +class ExchangeBase(Package): + """ + Base class for all the exchanges. + This class enables writing the exchanges to file in a uniform way. + """ + + _keyword_map: Dict[str, str] = {} + + @property + def model_name1(self) -> str: + if "model_name_1" not in self.dataset: + raise ValueError("model_name_1 not present in dataset") + return self.dataset["model_name_1"].values[()].take(0) + + @property + def model_name2(self) -> str: + if "model_name_2" not in self.dataset: + raise ValueError("model_name_2 not present in dataset") + return self.dataset["model_name_2"].values[()].take(0) + + def package_name(self) -> str: + return f"{self.model_name1}_{self.model_name2}" + + def get_specification(self) -> Tuple[str, str, str, str]: + """ + Returns a tuple containing the exchange type, the exchange file name, and the model names. This can be used + to write the exchange information in the simulation .nam input file + """ + filename = f"{self.package_name()}.{self._pkg_id}" + return ( + _pkg_id_to_type[self._pkg_id], + filename, + self.model_name1, + self.model_name2, + ) diff --git a/imod/mf6/gwfgwf.py b/imod/mf6/gwfgwf.py index ef21fb0cc..8f1b8ae5a 100644 --- a/imod/mf6/gwfgwf.py +++ b/imod/mf6/gwfgwf.py @@ -1,22 +1,22 @@ -from typing import Dict, Optional, Tuple +from typing import Optional import cftime import numpy as np import xarray as xr from imod.mf6.auxiliary_variables import add_periodic_auxiliary_variable +from imod.mf6.exchangebase import ExchangeBase from imod.mf6.package import Package from imod.typing import GridDataArray -class GWFGWF(Package): +class GWFGWF(ExchangeBase): """ This package is for writing an exchange file, used for splitting up a model into different submodels (that can be solved in parallel). It (usually) is not instantiated by users, but created by the "split" method of the simulation class.""" - _keyword_map: Dict[str, str] = {} _auxiliary_data = {"auxiliary_data": "variable"} _pkg_id = "gwfgwf" _template = Package._initialize_template(_pkg_id) @@ -72,24 +72,6 @@ def set_options( self.dataset["xt3d"] = xt3d self.dataset["newton"] = newton - def filename(self) -> str: - return f"{self.packagename()}.{self._pkg_id}" - - def packagename(self) -> str: - return f"{self.dataset['model_name_1'].values[()]}_{self.dataset['model_name_2'].values[()]}" - - def get_specification(self) -> Tuple[str, str, str, str]: - """ - Returns a tuple containing the exchange type, the exchange file name, and the model names. This can be used - to write the exchange information in the simulation .nam input file - """ - return ( - "GWF6-GWF6", - self.filename(), - self.dataset["model_name_1"].values[()].take(0), - self.dataset["model_name_2"].values[()].take(0), - ) - def clip_box( self, time_min: Optional[cftime.datetime | np.datetime64 | str] = None, diff --git a/imod/mf6/gwfgwt.py b/imod/mf6/gwfgwt.py new file mode 100644 index 000000000..35f8a0e75 --- /dev/null +++ b/imod/mf6/gwfgwt.py @@ -0,0 +1,38 @@ +from copy import deepcopy +from typing import Optional + +import cftime +import numpy as np + +from imod.mf6.exchangebase import ExchangeBase +from imod.mf6.package import Package +from imod.typing import GridDataArray + + +class GWFGWT(ExchangeBase): + _pkg_id = "gwfgwt" + _template = Package._initialize_template(_pkg_id) + + def __init__(self, model_id1: str, model_id2: str): + super().__init__(locals()) + self.dataset["model_name_1"] = model_id1 + self.dataset["model_name_2"] = model_id2 + + def clip_box( + self, + time_min: Optional[cftime.datetime | np.datetime64 | str] = None, + time_max: Optional[cftime.datetime | np.datetime64 | str] = None, + layer_min: Optional[int] = None, + layer_max: Optional[int] = None, + x_min: Optional[float] = None, + x_max: Optional[float] = None, + y_min: Optional[float] = None, + y_max: Optional[float] = None, + top: Optional[GridDataArray] = None, + bottom: Optional[GridDataArray] = None, + state_for_boundary: Optional[GridDataArray] = None, + ) -> Package: + """ + The GWF-GWT exchange does not have any spatial coordinates that can be clipped. + """ + return deepcopy(self) diff --git a/imod/mf6/pkgbase.py b/imod/mf6/pkgbase.py index b5ab57b65..7c9c69a36 100644 --- a/imod/mf6/pkgbase.py +++ b/imod/mf6/pkgbase.py @@ -10,7 +10,7 @@ from imod.mf6.interfaces.ipackagebase import IPackageBase TRANSPORT_PACKAGES = ("adv", "dsp", "ssm", "mst", "ist", "src") -EXCHANGE_PACKAGES = "gwfgwf" +EXCHANGE_PACKAGES = ("gwfgwf", "gwfgwt") class PackageBase(IPackageBase, abc.ABC): diff --git a/imod/mf6/simulation.py b/imod/mf6/simulation.py index 596b731d0..4407f36fe 100644 --- a/imod/mf6/simulation.py +++ b/imod/mf6/simulation.py @@ -17,7 +17,10 @@ import xugrid as xu import imod +import imod.logging +import imod.mf6.exchangebase from imod.mf6.gwfgwf import GWFGWF +from imod.mf6.gwfgwt import GWFGWT from imod.mf6.model import Modflow6Model from imod.mf6.model_gwf import GroundwaterFlowModel from imod.mf6.model_gwt import GroundwaterTransportModel @@ -236,6 +239,10 @@ def write( if isinstance(model, Modflow6Model): model._model_checks(key) + # Generate GWF-GWT exchanges + if gwfgwt_exchanges := self._generate_gwfgwt_exchanges(): + self["gwtgwf_exchanges"] = gwfgwt_exchanges + directory = pathlib.Path(directory) directory.mkdir(exist_ok=True, parents=True) @@ -273,9 +280,9 @@ def write( value.write(key, globaltimes, ims_write_context) elif isinstance(value, list): for exchange in value: - if isinstance(exchange, imod.mf6.GWFGWF): + if isinstance(exchange, imod.mf6.exchangebase.ExchangeBase): exchange.write( - exchange.packagename(), globaltimes, write_context + exchange.package_name(), globaltimes, write_context ) if status_info.has_errors(): @@ -791,16 +798,10 @@ def from_file(toml_path): def get_exchange_relationships(self): result = [] - flowmodels = self.get_models_of_type("gwf6") - transportmodels = self.get_models_of_type("gwt6") - # exchange for flow and transport - if len(flowmodels) == 1 and len(transportmodels) > 0: - exchange_type = "GWF6-GWT6" - modelname_a = list(flowmodels.keys())[0] - for counter, key in enumerate(transportmodels.keys()): - filename = f"simulation{counter}.exg" - modelname_b = key - result.append((exchange_type, filename, modelname_a, modelname_b)) + + if "gwtgwf_exchanges" in self: + for exchange in self["gwtgwf_exchanges"]: + result.append(exchange.get_specification()) # exchange for splitting models if is_split(self): @@ -1044,3 +1045,16 @@ def __repr__(self) -> str: else: content = attrs + ["){}"] return "\n".join(content) + + def _generate_gwfgwt_exchanges(self): + flow_models = self.get_models_of_type("gwf6") + transport_models = self.get_models_of_type("gwt6") + + # exchange for flow and transport + exchanges = [] + if len(flow_models) == 1 and len(transport_models) > 0: + flow_model_name = list(flow_models.keys())[0] + for transport_model_name in transport_models.keys(): + exchanges.append(GWFGWT(flow_model_name, transport_model_name)) + + return exchanges diff --git a/imod/templates/mf6/exg-gwfgwt.j2 b/imod/templates/mf6/exg-gwfgwt.j2 new file mode 100644 index 000000000..e69de29bb diff --git a/imod/tests/fixtures/package_instance_creation.py b/imod/tests/fixtures/package_instance_creation.py index 4ec80bd7c..57b1c5264 100644 --- a/imod/tests/fixtures/package_instance_creation.py +++ b/imod/tests/fixtures/package_instance_creation.py @@ -299,6 +299,7 @@ def create_instance_boundary_condition_packages(is_unstructured): }, ), ), + imod.mf6.GWFGWT("model_name1", "model_name2"), ] diff --git a/imod/tests/test_mf6/test_exchangebase.py b/imod/tests/test_mf6/test_exchangebase.py new file mode 100644 index 000000000..a9410c78f --- /dev/null +++ b/imod/tests/test_mf6/test_exchangebase.py @@ -0,0 +1,71 @@ +from contextlib import nullcontext as does_not_raise + +import pytest + +from imod.mf6.exchangebase import ExchangeBase, _pkg_id_to_type + + +class DummyExchange(ExchangeBase): + _pkg_id = "gwfgwt" + + def __init__(self, model_id1: str = None, model_id2: str = None): + super().__init__() + if model_id1: + self.dataset["model_name_1"] = model_id1 + if model_id2: + self.dataset["model_name_2"] = model_id2 + + +def test_package_name_construct_name(): + # Arrange. + model_name1 = "testmodel1" + model_name2 = "testmodel2" + exchange = DummyExchange(model_name1, model_name2) + + # Act. + package_name = exchange.package_name() + + # Assert. + assert model_name1 in package_name + assert model_name2 in package_name + + +@pytest.mark.parametrize( + ("model_name1", "model_name2", "expectation"), + ( + [None, None, pytest.raises(ValueError)], + ["testmodel1", None, pytest.raises(ValueError)], + [None, "testmodel2", pytest.raises(ValueError)], + ["testmodel1", "testmodel2", does_not_raise()], + ), +) +def test_package_name_missing_name(model_name1, model_name2, expectation): + # Arrange + exchange = DummyExchange(model_name1, model_name2) + + # Act/Assert + with expectation: + exchange.package_name() + + +def test_get_specification(): + # Arrange. + model_name1 = "testmodel1" + model_name2 = "testmodel2" + exchange = DummyExchange(model_name1, model_name2) + + # Act. + ( + spec_exchange_type, + spec_filename, + spec_model_name1, + spec_model_name2, + ) = exchange.get_specification() + + # Assert + assert spec_exchange_type is _pkg_id_to_type[DummyExchange._pkg_id] + assert model_name1 in spec_filename + assert model_name2 in spec_filename + assert DummyExchange._pkg_id in spec_filename + assert spec_model_name1 == model_name1 + assert spec_model_name2 == model_name2 diff --git a/imod/tests/test_mf6/test_mf6_gwfgwf.py b/imod/tests/test_mf6/test_mf6_gwfgwf.py index e9acc12c5..d84c8b3f8 100644 --- a/imod/tests/test_mf6/test_mf6_gwfgwf.py +++ b/imod/tests/test_mf6/test_mf6_gwfgwf.py @@ -169,7 +169,7 @@ def test_option_xt3d_propagated(circle_model, xt3d_option, tmp_path): label_array = get_label_array(circle_model, 3) split_simulation = circle_model.split(label_array) - # check that the created exchagnes have the same newton option + # check that the created exchanges have the same newton option for exchange in split_simulation["split_exchanges"]: assert exchange.dataset["xt3d"].values[()] == xt3d_option textrep = exchange.render(tmp_path, "gwfgwf", [], False) @@ -185,7 +185,7 @@ def test_option_variablecv_propagated(circle_model, variablecv_option: bool, tmp label_array = get_label_array(circle_model, 3) split_simulation = circle_model.split(label_array) - # check that the created exchagnes have the same variablecv option + # check that the created exchanges have the same variablecv option for exchange in split_simulation["split_exchanges"]: assert exchange.dataset["variablecv"].values[()] == variablecv_option textrep = exchange.render(tmp_path, "gwfgwf", [], False) @@ -202,7 +202,7 @@ def test_option_dewatered_propagated(circle_model, dewatered_option: bool, tmp_p label_array = get_label_array(circle_model, 3) split_simulation = circle_model.split(label_array) - # check that the created exchagnes have the same dewatered option + # check that the created exchanges have the same dewatered option for exchange in split_simulation["split_exchanges"]: assert exchange.dataset["dewatered"].values[()] == dewatered_option textrep = exchange.render(tmp_path, "gwfgwf", [], False) @@ -219,7 +219,7 @@ def test_save_flows_propagated(circle_model, budget_option: bool, tmp_path): label_array = get_label_array(circle_model, 3) split_simulation = circle_model.split(label_array) - # check that the created exchagnes have the same dewatered option + # check that the created exchanges have the same dewatered option for exchange in split_simulation["split_exchanges"]: assert exchange.dataset["save_flows"].values[()] == budget_option textrep = exchange.render(tmp_path, "gwfgwf", [], False) diff --git a/imod/tests/test_mf6/test_mf6_simulation.py b/imod/tests/test_mf6/test_mf6_simulation.py index f621b2b02..24f9c96d8 100644 --- a/imod/tests/test_mf6/test_mf6_simulation.py +++ b/imod/tests/test_mf6/test_mf6_simulation.py @@ -448,7 +448,8 @@ def test_write_exchanges( transient_twri_model.write(tmp_path, True, True, True) # Assert - assert Path.exists(tmp_path / sample_gwfgwf_structured.filename()) + _, filename, _, _ = sample_gwfgwf_structured.get_specification() + assert Path.exists(tmp_path / filename) @pytest.mark.usefixtures("split_transient_twri_model") def test_prevent_split_after_split(