Skip to content

Commit fc09610

Browse files
authored
Adopt imod code (#125)
* Add output budget and head to Gwf model Still use the imod functions. * Copy imod code for reading heads lazily * Copy imod code for reading info. Make use of StructuredGrid instead of imod grid info. * update envs, default sim_ws to none * appease mypy
1 parent 9f385dc commit fc09610

File tree

7 files changed

+577
-325
lines changed

7 files changed

+577
-325
lines changed

docs/examples/quickstart.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from pathlib import Path
22

3-
import imod.mf6
43
import matplotlib.pyplot as plt
54
import numpy as np
65

@@ -9,11 +8,11 @@
98
from flopy4.mf6.simulation import Simulation
109
from flopy4.mf6.tdis import Tdis
1110

12-
ws = "./mymodel"
11+
ws = Path("./quickstart_data")
1312
name = "mymodel"
1413
tdis = Tdis()
1514
ims = Ims()
16-
sim = Simulation(name=name, tdis=tdis, solutions={"ims": ims})
15+
sim = Simulation(name=name, tdis=tdis, solutions={"ims": ims}, sim_ws=ws)
1716
dis = Dis(nrow=10, ncol=10)
1817
gwf = Gwf(parent=sim, name=name, save_flows=True, dis=dis)
1918
ic = Ic(parent=gwf)
@@ -43,17 +42,12 @@
4342
assert oc.data.save_head.sel(per=0) == "all"
4443

4544
# PLOT
46-
# create budget reader
47-
bpth = Path("./quickstart_data/mymodel.bud")
48-
grbpth = Path("./quickstart_data/mymodel.dis.grb")
4945

5046
# set specific discharge
51-
spdis = imod.mf6.open_cbc(bpth, grbpth, merge_to_dataset=True)
52-
53-
# create head reader
54-
hpth = Path("./quickstart_data/mymodel.hds")
55-
heads = imod.mf6.open_hds(hpth, grbpth)
47+
spdis = gwf.output.budget
48+
heads = gwf.output.head
5649
sq = heads.squeeze()
50+
5751
fig, ax = plt.subplots()
5852
ax.tick_params()
5953
ax.set_xticks(np.arange(0, 11, 2), minor=False)

flopy4/mf6/gwf/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from pathlib import Path
22
from typing import Optional
33

4+
import attrs
5+
import imod
6+
import xarray as xr
47
from attrs import define
58
from xattree import field, xattree
69

@@ -10,17 +13,40 @@
1013
from flopy4.mf6.gwf.npf import Npf
1114
from flopy4.mf6.gwf.oc import Oc
1215
from flopy4.mf6.model import Model
16+
from flopy4.mf6.utils import open_hds
1317

1418
__all__ = ["Gwf", "Chd", "Dis", "Ic", "Npf", "Oc"]
1519

1620

1721
@xattree
1822
class Gwf(Model):
23+
@define
24+
class Output:
25+
parent: "Gwf" = attrs.field(repr=False)
26+
27+
@property
28+
def head(self) -> xr.DataArray:
29+
return open_hds(
30+
self.parent.parent.sim_ws / f"{self.parent.name}.hds", # type: ignore
31+
self.parent.parent.sim_ws / f"{self.parent.name}.dis.grb", # type: ignore
32+
)
33+
34+
@property
35+
def budget(self):
36+
return imod.mf6.open_cbc(
37+
self.parent.parent.sim_ws / "mymodel.bud",
38+
self.parent.parent.sim_ws / "mymodel.dis.grb",
39+
merge_to_dataset=True,
40+
)
41+
1942
dis: Dis = field()
2043
ic: Ic = field()
2144
oc: Oc = field()
2245
npf: Npf = field()
2346
chd: list[Chd] = field()
47+
output: Output = attrs.field(
48+
default=attrs.Factory(lambda self: Gwf.Output(self), takes_self=True)
49+
)
2450

2551
@define
2652
class NewtonOptions:

flopy4/mf6/simulation.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from pathlib import Path
2+
13
from xattree import field, xattree
24

35
from flopy4.mf6.component import Component
@@ -13,3 +15,4 @@ class Simulation(Component):
1315
exchanges: dict[str, Exchange] = field()
1416
solutions: dict[str, Solution] = field()
1517
tdis: Tdis = field()
18+
sim_ws: Path = field(default=None)

flopy4/mf6/utils/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .heads_reader import open_hds
2+
3+
__all__ = ["open_hds"]

flopy4/mf6/utils/heads_reader.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import collections
2+
import os
3+
import struct
4+
from pathlib import Path
5+
from typing import Any
6+
7+
import dask
8+
import numpy as np
9+
import pandas as pd
10+
import xarray as xr
11+
from flopy.discretization import StructuredGrid
12+
13+
14+
def open_hds(
15+
hds_path: Path,
16+
grb_path: Path,
17+
dry_nan: bool = False,
18+
simulation_start_time: np.datetime64 | None = None,
19+
time_unit: str | None = "d",
20+
) -> xr.DataArray:
21+
"""
22+
Open modflow6 heads (.hds) file.
23+
24+
The data is lazily read per timestep and automatically converted into
25+
(dense) xr.DataArrays for DIS.
26+
The conversion is done via the information stored in the Binary Grid file
27+
(GRB).
28+
29+
30+
Parameters
31+
----------
32+
hds_path: pathlib.Path
33+
grb_path: pathlib.Path
34+
dry_nan: bool, default value: False.
35+
Whether to convert dry values to NaN.
36+
simulation_start_time : Optional datetime
37+
The time and date corresponding to the beginning of the simulation.
38+
Use this to convert the time coordinates of the output array to
39+
calendar time/dates.
40+
Time_unit must also be present if this argument is present.
41+
time_unit: Optional str
42+
The time unit MF6 is working in, in string representation.
43+
Only used if simulation_start_time was provided.
44+
Admissible values are:
45+
ns -> nanosecond
46+
ms -> microsecond
47+
s -> second
48+
m -> minute
49+
h -> hour
50+
d -> day
51+
w -> week
52+
Units "month" or "year" are not supported,
53+
as they do not represent unambiguous timedelta values durations.
54+
55+
Returns
56+
-------
57+
head: xr.DataArray
58+
"""
59+
grid = StructuredGrid.from_binary_grid_file(grb_path)
60+
return _open_hds_dis(
61+
hds_path, grid, dry_nan, simulation_start_time, time_unit
62+
)
63+
64+
65+
def _open_hds_dis(
66+
path: Path,
67+
grid: StructuredGrid,
68+
dry_nan: bool,
69+
simulation_start_time: np.datetime64 | None = None,
70+
time_unit: str | None = "d",
71+
) -> xr.DataArray:
72+
nlayer, nrow, ncol = (
73+
grid.nlay,
74+
grid.nrow,
75+
grid.ncol,
76+
)
77+
filesize = os.path.getsize(path)
78+
ntime = filesize // (nlayer * (52 + (nrow * ncol * 8)))
79+
coords = get_coords(grid)
80+
coords["time"] = read_times(path, ntime, nlayer, nrow, ncol)
81+
82+
dask_list = []
83+
# loop over times and add delayed arrays
84+
for i in range(ntime):
85+
# TODO verify dimension order
86+
pos = i * (nlayer * (52 + nrow * ncol * 8))
87+
a = dask.delayed(read_hds_timestep)(
88+
path, nlayer, nrow, ncol, dry_nan, pos
89+
)
90+
x = dask.array.from_delayed(
91+
a, shape=(nlayer, nrow, ncol), dtype=np.float64
92+
)
93+
dask_list.append(x)
94+
95+
daskarr = dask.array.stack(dask_list, axis=0)
96+
data_array = xr.DataArray(
97+
daskarr, coords, ("time", "layer", "y", "x"), name="head"
98+
)
99+
if simulation_start_time is not None:
100+
data_array = assign_datetime_coords(
101+
data_array, simulation_start_time, time_unit
102+
)
103+
return data_array
104+
105+
106+
def get_coords(grid: StructuredGrid) -> dict[str, Any]:
107+
# unpack tuples
108+
xmin, xmax, ymin, ymax = grid.extent
109+
dx, dy = (grid.delr, -grid.delc)
110+
coords: collections.OrderedDict[str, Any] = collections.OrderedDict()
111+
# from cell size to x and y coordinates
112+
if isinstance(dx, (int, float, np.int_)): # equidistant
113+
coords["x"] = np.arange(xmin + dx / 2.0, xmax, dx)
114+
coords["y"] = np.arange(ymax + dy / 2.0, ymin, dy)
115+
coords["dx"] = np.array(float(dx))
116+
coords["dy"] = np.array(float(dy))
117+
else: # nonequidistant
118+
# even though IDF may store them as float32,
119+
# we always convert them to float64
120+
dx = dx.astype(np.float64)
121+
dy = dy.astype(np.float64)
122+
coords["x"] = xmin + np.cumsum(dx) - 0.5 * dx
123+
coords["y"] = ymax + np.cumsum(dy) - 0.5 * dy
124+
if np.allclose(dx, dx[0]) and np.allclose(dy, dy[0]):
125+
coords["dx"] = np.array(float(dx[0]))
126+
coords["dy"] = np.array(float(dy[0]))
127+
else:
128+
coords["dx"] = ("x", dx)
129+
coords["dy"] = ("y", dy)
130+
coords["layer"] = np.arange(1, grid.nlay + 1)
131+
return coords
132+
133+
134+
def read_times(
135+
path: Path, ntime: int, nlayer: int, nrow: int, ncol: int
136+
) -> np.ndarray:
137+
"""
138+
Reads all total simulation times.
139+
"""
140+
times = np.empty(ntime, dtype=np.float64)
141+
142+
# Compute how much to skip to the next timestamp
143+
start_of_header = 16
144+
rest_of_header = 28
145+
data_single_layer = nrow * ncol * 8
146+
header = 52
147+
nskip = (
148+
rest_of_header
149+
+ data_single_layer
150+
+ (nlayer - 1) * (header + data_single_layer)
151+
+ start_of_header
152+
)
153+
154+
with open(path, "rb") as f:
155+
f.seek(start_of_header)
156+
for i in range(ntime):
157+
times[i] = struct.unpack("d", f.read(8))[
158+
0
159+
] # total simulation time
160+
f.seek(nskip, 1)
161+
return times
162+
163+
164+
def read_hds_timestep(
165+
path: Path, nlayer: int, nrow: int, ncol: int, dry_nan: bool, pos: int
166+
) -> np.ndarray:
167+
"""
168+
Reads all values of one timestep.
169+
"""
170+
ncell_per_layer = nrow * ncol
171+
with open(path, "rb") as f:
172+
f.seek(pos)
173+
a1d = np.empty(nlayer * nrow * ncol, dtype=np.float64)
174+
for k in range(nlayer):
175+
f.seek(52, 1) # skip kstp, kper, pertime
176+
a1d[k * ncell_per_layer : (k + 1) * ncell_per_layer] = np.fromfile(
177+
f, np.float64, nrow * ncol
178+
)
179+
180+
a3d = a1d.reshape((nlayer, nrow, ncol))
181+
return _to_nan(a3d, dry_nan)
182+
183+
184+
def assign_datetime_coords(
185+
da: xr.DataArray,
186+
simulation_start_time: np.datetime64,
187+
time_unit: str | None = "d",
188+
) -> xr.DataArray:
189+
if "time" not in da.coords:
190+
raise ValueError(
191+
"cannot convert time column,"
192+
" because a time column could not be found"
193+
)
194+
195+
time = pd.Timestamp(simulation_start_time) + pd.to_timedelta(
196+
da["time"], unit=time_unit
197+
)
198+
return da.assign_coords(time=time)
199+
200+
201+
def _to_nan(a: np.ndarray, dry_nan: bool) -> np.ndarray:
202+
# TODO: this could really use a docstring?
203+
a[a == 1e30] = np.nan
204+
if dry_nan:
205+
a[a == -1e30] = np.nan
206+
return a

0 commit comments

Comments
 (0)