Skip to content

Issue #965 npf from imod5 #1010

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
May 7, 2024
81 changes: 80 additions & 1 deletion imod/mf6/npf.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import warnings
from copy import deepcopy
from typing import Optional, Tuple

import numpy as np
import xarray as xr

from imod.logging import init_log_decorator
from imod.mf6.interfaces.iregridpackage import IRegridPackage
from imod.mf6.package import Package
from imod.mf6.utilities.regrid import RegridderType
from imod.mf6.utilities.imod5_converter import fill_missing_layers
from imod.mf6.utilities.regrid import (
RegridderType,
RegridderWeightsCache,
_regrid_package_data,
)
from imod.mf6.validation import PKG_DIMS_SCHEMA
from imod.schemata import (
AllValueSchema,
Expand All @@ -17,6 +24,7 @@
IndexesSchema,
)
from imod.typing import GridDataArray
from imod.typing.grid import zeros_like


def _dataarray_to_bool(griddataarray: GridDataArray) -> bool:
Expand Down Expand Up @@ -461,3 +469,74 @@ def _validate(self, schemata, **kwargs):

def get_regrid_methods(self) -> Optional[dict[str, Tuple[RegridderType, str]]]:
return self._regrid_method

@classmethod
def from_imod5_data(
cls,
imod5_data: dict[str, dict[str, GridDataArray]],
target_grid: GridDataArray,
regridder_types: Optional[dict[str, tuple[RegridderType, str]]] = None,
) -> "NodePropertyFlow":
"""
Construct an npf-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.
target_grid: GridDataArray
The grid that should be used for the new package. Does not
need to be identical to one of the input grids.
regridder_types: dict, optional
Optional dictionary with regridder types for a specific variable.
Use this to override default regridding methods.

Returns
-------
Modflow 6 npf package.

"""

data = {
"k": imod5_data["khv"]["kh"],
}
has_vertical_anisotropy = (
"kva" in imod5_data.keys()
and "vertical_anisotropy" in imod5_data["kva"].keys()
)
has_horizontal_anisotropy = "ani" in imod5_data.keys()

if has_vertical_anisotropy:
data["k33"] = data["k"] * imod5_data["kva"]["vertical_anisotropy"]
if has_horizontal_anisotropy:
if not np.all(np.isnan(imod5_data["ani"]["factor"].values)):
factor = imod5_data["ani"]["factor"]
factor = fill_missing_layers(factor, target_grid, 1)
data["k22"] = data["k"] * factor
if not np.all(np.isnan(imod5_data["ani"]["angle"].values)):
angle1 = imod5_data["ani"]["angle"]
angle1 = 90.0 - angle1
angle1 = xr.where(angle1 < 0, 360.0 + angle1, angle1)
angle1 = fill_missing_layers(angle1, target_grid, 0)
data["angle1"] = angle1

icelltype = zeros_like(target_grid, dtype=int)

regridder_settings = deepcopy(cls._regrid_method)
if regridder_types is not None:
regridder_settings.update(regridder_types)

regrid_context = RegridderWeightsCache(data["k"], target_grid)
Copy link
Contributor

Choose a reason for hiding this comment

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

Just a note: I think we should be able to optionally provide regrid contexts when we implement complete conversion of models.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we can just provide an optional parameter there which has a default value of an empty RegridderCache. Then it's up to the user.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I think that is a good resolution. You could implement immediately so we don't forget. If you decide to do so, please don't forget to update the method for the discretization package as well.


new_package_data = _regrid_package_data(
data, target_grid, regridder_settings, regrid_context, {}
)
new_package_data["icelltype"] = icelltype

return NodePropertyFlow(**new_package_data)
16 changes: 16 additions & 0 deletions imod/mf6/utilities/imod5_converter.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Union

import numpy as np
import xarray as xr

Expand Down Expand Up @@ -26,3 +28,17 @@ def convert_ibound_to_idomain(
)
# Fill the remaining nans at tops and bottoms with 0
return idomain_float.fillna(0).astype(int)


def fill_missing_layers(
source: xr.DataArray, full: xr.DataArray, fillvalue: Union[float | int]
) -> xr.DataArray:
"""
This function takes a source grid in which the layer dimension is
incomplete. It creates a result-grid which has the same layers as the "full"
grid, which is assumed to have all layers. The result has the values in the
source for the layers that are in the source. For the other layers, the
fillvalue is assigned.
"""
layer = full.coords["layer"]
return source.reindex(layer=layer, fill_value=fillvalue)
2 changes: 1 addition & 1 deletion imod/mf6/utilities/regrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ def _regrid_package_data(
target_grid: GridDataArray,
regridder_settings: dict[str, tuple[RegridderType, str]],
regrid_context: RegridderWeightsCache,
new_package_data: Optional[dict[str, GridDataArray]] = {},
new_package_data: dict[str, GridDataArray] = {},
) -> dict[str, GridDataArray]:
"""
Regrid package data. Loops over regridder settings to regrid variables one
Expand Down
1 change: 1 addition & 0 deletions imod/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest

from .fixtures.backward_compatibility_fixture import imod5_dataset
from .fixtures.flow_basic_fixture import (
basic_dis,
basic_dis__topsystem,
Expand Down
20 changes: 20 additions & 0 deletions imod/tests/fixtures/backward_compatibility_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import pytest
import xarray as xr

import imod


@pytest.fixture(scope="module")
def imod5_dataset():
tmp_path = imod.util.temporary_directory()
data = imod.data.imod5_projectfile_data(tmp_path)
_load_imod5_data_in_memory(data[0])
return data[0]


def _load_imod5_data_in_memory(imod5_data):
"""For debugging purposes, load everything in memory"""
for pkg in imod5_data.values():
for vardata in pkg.values():
if isinstance(vardata, xr.DataArray):
vardata.load()
11 changes: 3 additions & 8 deletions imod/tests/test_mf6/test_mf6_dis.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,9 @@
import imod
from imod.mf6.write_context import WriteContext
from imod.schemata import ValidationError


def _load_imod5_data_in_memory(imod5_data):
"""For debugging purposes, load everything in memory"""
for pkg in imod5_data.values():
for vardata in pkg.values():
if isinstance(vardata, xr.DataArray):
vardata.load()
from imod.tests.fixtures.backward_compatibility_fixture import (
_load_imod5_data_in_memory,
)


@pytest.fixture(scope="function")
Expand Down
85 changes: 85 additions & 0 deletions imod/tests/test_mf6/test_mf6_npf.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pathlib
import re
import textwrap
from copy import deepcopy

import numpy as np
import pytest
Expand Down Expand Up @@ -208,3 +209,87 @@ def test_configure_xt3d(tmp_path):
assert "xt3d" not in rendered
assert "rhs" not in rendered
assert not npf.get_xt3d_option()


@pytest.mark.usefixtures("imod5_dataset")
def test_npf_from_imod5_isotropic(imod5_dataset, tmp_path):
data = deepcopy(imod5_dataset)
# throw out kva (=vertical anisotropy array) and ani (=horizontal anisotropy array)
data.pop("kva")
data.pop("ani")

target_grid = data["khv"]["kh"]
npf = imod.mf6.NodePropertyFlow.from_imod5_data(data, target_grid)

# Test array values are the same for k ( disregarding the locations where k == np.nan)
k_nan_removed = xr.where(np.isnan(npf.dataset["k"]), 0, npf.dataset["k"])
np.testing.assert_allclose(k_nan_removed, data["khv"]["kh"].values)

rendered_npf = npf.render(tmp_path, "npf", None, None)
assert "k22" not in rendered_npf
assert "k33" not in rendered_npf
assert "angle1" not in rendered_npf
assert "angle2" not in rendered_npf
assert "angle3" not in rendered_npf


@pytest.mark.usefixtures("imod5_dataset")
def test_npf_from_imod5_horizontal_anisotropy(imod5_dataset, tmp_path):
data = deepcopy(imod5_dataset)
# throw out kva (=vertical anisotropy array)
data.pop("kva")

target_grid = data["khv"]["kh"]
data["ani"]["angle"].values[:, :, :] = 135.0
data["ani"]["factor"].values[:, :, :] = 0.1
npf = imod.mf6.NodePropertyFlow.from_imod5_data(data, target_grid)

# Test array values for k22 and angle1
for layer in npf.dataset["k"].coords["layer"].values:
ds_layer = npf.dataset.sel({"layer": layer})

ds_layer = ds_layer.fillna(0.0)

if layer in data["ani"]["factor"].coords["layer"].values:
np.testing.assert_allclose(
ds_layer["k"].values * 0.1, ds_layer["k22"].values, atol=1e-10
)
assert np.all(ds_layer["angle1"].values == 315.0)
else:
assert np.all(ds_layer["k"].values == ds_layer["k22"].values)
assert np.all(ds_layer["angle1"].values == 0.0)

rendered_npf = npf.render(tmp_path, "npf", None, None)
assert "k22" in rendered_npf
assert "k33" not in rendered_npf
assert "angle1" in rendered_npf
assert "angle2" not in rendered_npf
assert "angle3" not in rendered_npf


@pytest.mark.usefixtures("imod5_dataset")
def test_npf_from_imod5_vertical_anisotropy(imod5_dataset, tmp_path):
data = deepcopy(imod5_dataset)
# throw out ani (=horizontal anisotropy array)
data.pop("ani")

data["kva"]["vertical_anisotropy"].values[:] = 0.1
target_grid = data["khv"]["kh"]

npf = imod.mf6.NodePropertyFlow.from_imod5_data(data, target_grid)

# Test array values for k33
for layer in npf.dataset["k"].coords["layer"].values:
k_layer = npf.dataset["k"].sel({"layer": layer})
k33_layer = npf.dataset["k33"].sel({"layer": layer})

k_layer = xr.where(np.isnan(k_layer), 0.0, k_layer)
k33_layer = xr.where(np.isnan(k33_layer), 0.0, k33_layer)
np.testing.assert_allclose(k_layer.values * 0.1, k33_layer.values, atol=1e-10)

rendered_npf = npf.render(tmp_path, "npf", None, None)
assert "k22" not in rendered_npf
assert "k33" in rendered_npf
assert "angle1" not in rendered_npf
assert "angle2" not in rendered_npf
assert "angle3" not in rendered_npf