Skip to content

Commit 657a0a8

Browse files
Issue #1187 cleanup wells (#1206)
Fixes #1187 # Description Adds cleanup utility for wells, as well as a method on the ``Well`` object. ``LayeredWell`` doesn't have ``cleanup`` method yet, as cleaning up almost purely focuses on adjusting filter screen levels for now. The function as well as the method are a bit more involved than the other cleanup utilities, because we are dealing here with a comparison of point data and grids. # Checklist - [x] Links to correct issue - [x] Update changelog, if changes affect users - [x] PR title starts with ``Issue #nr``, e.g. ``Issue #737`` - [x] Unit tests were added - [ ] **If feature added**: Added/extended example
1 parent 4c947f8 commit 657a0a8

File tree

11 files changed

+322
-33
lines changed

11 files changed

+322
-33
lines changed

docs/api/changelog.rst

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,13 @@ Added
5656
- :class:`imod.mf6.LayeredWell` to specify wells directly to layers instead
5757
assigning them with filter depths.
5858
- :func:`imod.prepare.cleanup_drn`, :func:`imod.prepare.cleanup_ghb`,
59-
:func:`imod.prepare.cleanup_riv`. These are utility functions to clean up
60-
drainage, general head boundaries, and rivers, respectively.
59+
:func:`imod.prepare.cleanup_riv`, :func:`imod.prepare.cleanup_wel`. These are
60+
utility functions to clean up drainage, general head boundaries, and rivers,
61+
respectively.
6162
- :meth:`imod.mf6.Drainage.cleanup`,
62-
:meth:`imod.mf6.GeneralHeadboundary.cleanup`, :meth:`imod.mf6.River.cleanup`
63-
convenience methods to call the corresponding cleanup utility functions with
64-
the appropriate arguments.
63+
:meth:`imod.mf6.GeneralHeadboundary.cleanup`, :meth:`imod.mf6.River.cleanup`,
64+
:meth:`imod.mf6.Well.cleanup` convenience methods to call the corresponding
65+
cleanup utility functions with the appropriate arguments.
6566

6667

6768
Removed

docs/api/mf6.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Flow Packages
9797
StorageCoefficient
9898
UnsaturatedZoneFlow
9999
Well
100+
Well.cleanup
100101
Well.from_imod5_data
101102
Well.mask
102103
Well.regrid_like

docs/api/prepare.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ Prepare model input
5151
cleanup_drn
5252
cleanup_ghb
5353
cleanup_riv
54+
cleanup_wel

imod/mf6/wel.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import xugrid as xu
1616

1717
import imod
18+
import imod.mf6.utilities
1819
from imod.logging import init_log_decorator, logger
1920
from imod.logging.logging_decorators import standard_log_decorator
2021
from imod.logging.loglevel import LogLevel
@@ -28,6 +29,7 @@
2829
from imod.mf6.mf6_wel_adapter import Mf6Wel
2930
from imod.mf6.package import Package
3031
from imod.mf6.utilities.dataset import remove_inactive
32+
from imod.mf6.utilities.grid import broadcast_to_full_domain
3133
from imod.mf6.validation import validation_pkg_error_message
3234
from imod.mf6.write_context import WriteContext
3335
from imod.prepare import assign_wells
@@ -973,8 +975,43 @@ def _validate_imod5_depth_information(
973975
logger.log(loglevel=LogLevel.ERROR, message=log_msg, additional_depth=2)
974976
raise ValueError(log_msg)
975977

976-
def cleanup(self, _: StructuredDiscretization | VerticesDiscretization):
977-
self.dataset = cleanup_wel(self.dataset)
978+
@standard_log_decorator()
979+
def cleanup(self, dis: StructuredDiscretization | VerticesDiscretization):
980+
"""
981+
Clean up package inplace. This method calls
982+
:func:`imod.prepare.cleanup.cleanup_wel`, see documentation of that
983+
function for details on cleanup.
984+
985+
dis: imod.mf6.StructuredDiscretization | imod.mf6.VerticesDiscretization
986+
Model discretization package.
987+
"""
988+
# Top and bottom should be forced to grids with a x, y coordinates
989+
top, bottom = broadcast_to_full_domain(**dict(dis.dataset.data_vars))
990+
# Collect point variable datanames
991+
point_varnames = list(self._write_schemata.keys())
992+
if "concentration" not in self.dataset.keys():
993+
point_varnames.remove("concentration")
994+
point_varnames.append("id")
995+
# Create dataset with purely point locations
996+
point_ds = self.dataset[point_varnames]
997+
# Take first item of irrelevant dimensions
998+
point_ds = point_ds.isel(time=0, species=0, drop=True, missing_dims="ignore")
999+
# Cleanup well dataframe
1000+
wells = point_ds.to_dataframe()
1001+
minimum_thickness = float(self.dataset["minimum_thickness"])
1002+
cleaned_wells = cleanup_wel(wells, top.isel(layer=0), bottom, minimum_thickness)
1003+
# Select with ids in cleaned dataframe to drop points outside grid.
1004+
well_ids = cleaned_wells.index
1005+
dataset_cleaned = self.dataset.swap_dims({"index": "id"}).sel(id=well_ids)
1006+
# Assign adjusted screen top and bottom
1007+
dataset_cleaned["screen_top"] = cleaned_wells["screen_top"]
1008+
dataset_cleaned["screen_bottom"] = cleaned_wells["screen_bottom"]
1009+
# Ensure dtype of id is preserved
1010+
id_type = self.dataset["id"].dtype
1011+
dataset_cleaned = dataset_cleaned.swap_dims({"id": "index"}).reset_coords("id")
1012+
dataset_cleaned["id"] = dataset_cleaned["id"].astype(id_type)
1013+
# Override dataset
1014+
self.dataset = dataset_cleaned
9781015

9791016

9801017
class LayeredWell(GridAgnosticWell):

imod/prepare/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"""
1515

1616
from imod.prepare import hfb, spatial, subsoil, surface_water
17-
from imod.prepare.cleanup import cleanup_drn, cleanup_ghb, cleanup_riv
17+
from imod.prepare.cleanup import cleanup_drn, cleanup_ghb, cleanup_riv, cleanup_wel
1818
from imod.prepare.hfb import (
1919
linestring_to_square_zpolygons,
2020
linestring_to_trapezoid_zpolygons,

imod/prepare/cleanup.py

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from enum import Enum
44
from typing import Optional
55

6+
import pandas as pd
67
import xarray as xr
78

89
from imod.mf6.utilities.mask import mask_arrays
10+
from imod.prepare.wells import locate_wells, validate_well_columnnames
911
from imod.schemata import scalar_None
10-
from imod.typing import GridDataArray, GridDataset
12+
from imod.typing import GridDataArray
1113

1214

1315
class AlignLevelsMode(Enum):
@@ -83,7 +85,7 @@ def cleanup_riv(
8385
idomain: xarray.DataArray | xugrid.UgridDataArray
8486
MODFLOW 6 model domain. idomain==1 is considered active domain.
8587
bottom: xarray.DataArray | xugrid.UgridDataArray
86-
Grid with`model bottoms
88+
Grid with model bottoms
8789
stage: xarray.DataArray | xugrid.UgridDataArray
8890
Grid with river stages
8991
conductance: xarray.DataArray | xugrid.UgridDataArray
@@ -209,11 +211,128 @@ def cleanup_ghb(
209211
return _cleanup_robin_boundary(idomain, output_dict)
210212

211213

212-
def cleanup_wel(wel_ds: GridDataset):
214+
def _locate_wells_in_bounds(
215+
wells: pd.DataFrame, top: GridDataArray, bottom: GridDataArray
216+
) -> tuple[pd.DataFrame, pd.Series, pd.Series]:
213217
"""
214-
Clean up wells
218+
Locate wells in model bounds, wells outside bounds are dropped. Returned
219+
dataframes and series have well "id" as index.
215220
216-
- Removes wells where the screen bottom elevation exceeds screen top.
221+
Returns
222+
-------
223+
wells_in_bounds: pd.DataFrame
224+
wells in model boundaries. Has "id" as index.
225+
xy_top_series: pd.Series
226+
model top at well xy location. Has "id" as index.
227+
xy_base_series: pd.Series
228+
model base at well xy location. Has "id" as index.
217229
"""
218-
deactivate = wel_ds["screen_top"] < wel_ds["screen_bottom"]
219-
return wel_ds.where(~deactivate, drop=True)
230+
id_in_bounds, xy_top, xy_bottom, _ = locate_wells(
231+
wells, top, bottom, validate=False
232+
)
233+
xy_base_model = xy_bottom.isel(layer=-1, drop=True)
234+
235+
# Assign id as coordinates
236+
xy_top = xy_top.assign_coords(id=("index", id_in_bounds))
237+
xy_base_model = xy_base_model.assign_coords(id=("index", id_in_bounds))
238+
# Create pandas dataframes/series with "id" as index.
239+
xy_top_series = xy_top.to_dataframe(name="top").set_index("id")["top"]
240+
xy_base_series = xy_base_model.to_dataframe(name="bottom").set_index("id")["bottom"]
241+
wells_in_bounds = wells.set_index("id").loc[id_in_bounds]
242+
return wells_in_bounds, xy_top_series, xy_base_series
243+
244+
245+
def _clip_filter_screen_to_surface_level(
246+
cleaned_wells: pd.DataFrame, xy_top_series: pd.Series
247+
) -> pd.DataFrame:
248+
cleaned_wells["screen_top"] = cleaned_wells["screen_top"].clip(upper=xy_top_series)
249+
return cleaned_wells
250+
251+
252+
def _drop_wells_below_model_base(
253+
cleaned_wells: pd.DataFrame, xy_base_series: pd.Series
254+
) -> pd.DataFrame:
255+
is_below_base = cleaned_wells["screen_top"] >= xy_base_series
256+
return cleaned_wells.loc[is_below_base]
257+
258+
259+
def _clip_filter_bottom_to_model_base(
260+
cleaned_wells: pd.DataFrame, xy_base_series: pd.Series
261+
) -> pd.DataFrame:
262+
cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip(
263+
lower=xy_base_series
264+
)
265+
return cleaned_wells
266+
267+
268+
def _set_inverted_filters_to_point_filters(cleaned_wells: pd.DataFrame) -> pd.DataFrame:
269+
# Convert all filters where screen bottom exceeds screen top to
270+
# point filters
271+
cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].clip(
272+
upper=cleaned_wells["screen_top"]
273+
)
274+
return cleaned_wells
275+
276+
277+
def _set_ultrathin_filters_to_point_filters(
278+
cleaned_wells: pd.DataFrame, minimum_thickness: float
279+
) -> pd.DataFrame:
280+
not_ultrathin_layer = (
281+
cleaned_wells["screen_top"] - cleaned_wells["screen_bottom"]
282+
) > minimum_thickness
283+
cleaned_wells["screen_bottom"] = cleaned_wells["screen_bottom"].where(
284+
not_ultrathin_layer, cleaned_wells["screen_top"]
285+
)
286+
return cleaned_wells
287+
288+
289+
def cleanup_wel(
290+
wells: pd.DataFrame,
291+
top: GridDataArray,
292+
bottom: GridDataArray,
293+
minimum_thickness: float = 0.05,
294+
) -> pd.DataFrame:
295+
"""
296+
Clean up dataframe with wells, fixes some common mistakes in the following
297+
order:
298+
299+
1. Wells outside grid bounds are dropped
300+
2. Filters above surface level are set to surface level
301+
3. Drop wells with filters entirely below base
302+
4. Clip filter screen_bottom to model base
303+
5. Clip filter screen_bottom to screen_top
304+
6. Well filters thinner than minimum thickness are made point filters
305+
306+
Parameters
307+
----------
308+
wells: pandas.Dataframe
309+
Dataframe with wells to be cleaned up. Requires columns ``"x", "y",
310+
"id", "screen_top", "screen_bottom"``
311+
top: xarray.DataArray | xugrid.UgridDataArray
312+
Grid with model top
313+
bottom: xarray.DataArray | xugrid.UgridDataArray
314+
Grid with model bottoms
315+
minimum_thickness: float
316+
Minimum thickness, filter thinner than this thickness are set to point
317+
filters
318+
319+
Returns
320+
-------
321+
pandas.DataFrame
322+
Cleaned well dataframe.
323+
"""
324+
validate_well_columnnames(
325+
wells, names={"x", "y", "id", "screen_top", "screen_bottom"}
326+
)
327+
328+
cleaned_wells, xy_top_series, xy_base_series = _locate_wells_in_bounds(
329+
wells, top, bottom
330+
)
331+
cleaned_wells = _clip_filter_screen_to_surface_level(cleaned_wells, xy_top_series)
332+
cleaned_wells = _drop_wells_below_model_base(cleaned_wells, xy_base_series)
333+
cleaned_wells = _clip_filter_bottom_to_model_base(cleaned_wells, xy_base_series)
334+
cleaned_wells = _set_inverted_filters_to_point_filters(cleaned_wells)
335+
cleaned_wells = _set_ultrathin_filters_to_point_filters(
336+
cleaned_wells, minimum_thickness
337+
)
338+
return cleaned_wells

imod/prepare/wells.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def locate_wells(
8484
wells: pd.DataFrame,
8585
top: GridDataArray,
8686
bottom: GridDataArray,
87-
k: Optional[GridDataArray],
87+
k: Optional[GridDataArray] = None,
8888
validate: bool = True,
8989
) -> tuple[
9090
npt.NDArray[np.object_], GridDataArray, GridDataArray, float | GridDataArray
@@ -126,6 +126,22 @@ def locate_wells(
126126
return id_in_bounds, xy_top, xy_bottom, xy_k
127127

128128

129+
def validate_well_columnnames(
130+
wells: pd.DataFrame, names: set = {"x", "y", "id"}
131+
) -> None:
132+
missing = names.difference(wells.columns)
133+
if missing:
134+
raise ValueError(f"Columns are missing in wells dataframe: {missing}")
135+
136+
137+
def validate_arg_types_equal(**kwargs):
138+
types = [type(arg) for arg in (kwargs.values()) if arg is not None]
139+
if len(set(types)) != 1:
140+
members = ", ".join([t.__name__ for t in types])
141+
names = ", ".join(kwargs.keys())
142+
raise TypeError(f"{names} should be of the same type, received: {members}")
143+
144+
129145
def assign_wells(
130146
wells: pd.DataFrame,
131147
top: GridDataArray,
@@ -166,19 +182,9 @@ def assign_wells(
166182
Wells with rate subdivided per layer. Contains the original columns of
167183
``wells``, as well as layer, overlap, transmissivity.
168184
"""
169-
170-
names = {"x", "y", "id", "top", "bottom", "rate"}
171-
missing = names.difference(wells.columns)
172-
if missing:
173-
raise ValueError(f"Columns are missing in wells dataframe: {missing}")
174-
175-
types = [type(arg) for arg in (top, bottom, k) if arg is not None]
176-
if len(set(types)) != 1:
177-
members = ",".join([t.__name__ for t in types])
178-
raise TypeError(
179-
"top, bottom, and optionally k should be of the same type, "
180-
f"received: {members}"
181-
)
185+
columnnames = {"x", "y", "id", "top", "bottom", "rate"}
186+
validate_well_columnnames(wells, columnnames)
187+
validate_arg_types_equal(top=top, bottom=bottom, k=k)
182188

183189
id_in_bounds, xy_top, xy_bottom, xy_k = locate_wells(
184190
wells, top, bottom, k, validate

imod/tests/test_mf6/test_mf6_riv.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ def case_unstructured(self):
7171
return make_dict_unstructured(riv_dict())
7272

7373

74+
class DisCases:
75+
def case_structured(self):
76+
return dis_dict()
77+
78+
def case_unstructured(self):
79+
return make_dict_unstructured(dis_dict())
80+
81+
7482
class RivDisCases:
7583
def case_structured(self):
7684
return riv_dict(), dis_dict()

imod/tests/test_mf6/test_mf6_wel.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,32 @@ def test_is_empty(well_high_lvl_test_data_transient):
394394
assert wel_empty.is_empty()
395395

396396

397+
def test_cleanup(basic_dis, well_high_lvl_test_data_transient):
398+
# Arrange
399+
wel = imod.mf6.Well(*well_high_lvl_test_data_transient)
400+
ds_original = wel.dataset.copy()
401+
idomain, top, bottom = basic_dis
402+
top = top.isel(layer=0, drop=True)
403+
deep_offset = 100.0
404+
dis_normal = imod.mf6.StructuredDiscretization(top, bottom, idomain)
405+
dis_deep = imod.mf6.StructuredDiscretization(
406+
top - deep_offset, bottom - deep_offset, idomain
407+
)
408+
409+
# Nothing to be cleaned up with default discretization
410+
wel.cleanup(dis_normal)
411+
xr.testing.assert_identical(ds_original, wel.dataset)
412+
413+
# Cleanup
414+
wel.cleanup(dis_deep)
415+
assert not ds_original.identical(wel.dataset)
416+
# Wells filters should be placed downwards at surface level as point filters
417+
np.testing.assert_array_almost_equal(
418+
wel.dataset["screen_top"], wel.dataset["screen_bottom"]
419+
)
420+
np.testing.assert_array_almost_equal(wel.dataset["screen_top"], top - deep_offset)
421+
422+
397423
class ClipBoxCases:
398424
@staticmethod
399425
def case_clip_xy(parameterizable_basic_dis):
@@ -959,6 +985,26 @@ def test_import_and_convert_to_mf6(imod5_dataset, tmp_path, wel_class):
959985
mf6_well.write("wel", [], write_context)
960986

961987

988+
@parametrize("wel_class", [Well])
989+
@pytest.mark.usefixtures("imod5_dataset")
990+
def test_import_and_cleanup(imod5_dataset, wel_class: Well):
991+
data = imod5_dataset[0]
992+
target_dis = StructuredDiscretization.from_imod5_data(data)
993+
994+
ntimes = 8399
995+
times = list(pd.date_range(datetime(1989, 1, 1), datetime(2013, 1, 1), ntimes + 1))
996+
997+
# Import grid-agnostic well from imod5 data (it contains 1 well)
998+
wel = wel_class.from_imod5_data("wel-WELLS_L3", data, times)
999+
assert len(wel.dataset.coords["time"]) == ntimes
1000+
# Cleanup
1001+
wel.cleanup(target_dis)
1002+
# Nothing to be cleaned, single well point is located properly, test that
1003+
# time coordinate has not been dropped.
1004+
assert "time" in wel.dataset.coords
1005+
assert len(wel.dataset.coords["time"]) == ntimes
1006+
1007+
9621008
@parametrize("wel_class", [Well, LayeredWell])
9631009
@pytest.mark.usefixtures("well_regular_import_prj")
9641010
def test_import_multiple_wells(well_regular_import_prj, wel_class):

0 commit comments

Comments
 (0)