Skip to content

Commit 77b1e29

Browse files
deltamarnixwpbonelli
authored andcommitted
Copy imod code for reading heads lazily
1 parent e3a33b7 commit 77b1e29

File tree

4 files changed

+187
-6
lines changed

4 files changed

+187
-6
lines changed

docs/examples/quickstart.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from flopy4.mf6.simulation import Simulation
99
from flopy4.mf6.tdis import Tdis
1010

11-
ws = "./quickstart_data"
11+
ws = Path("./quickstart_data")
1212
name = "mymodel"
1313
tdis = Tdis()
1414
ims = Ims()

flopy4/mf6/gwf/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from flopy4.mf6.gwf.npf import Npf
1414
from flopy4.mf6.gwf.oc import Oc
1515
from flopy4.mf6.model import Model
16+
from flopy4.mf6.utils import open_hds
1617

1718
__all__ = ["Gwf", "Chd", "Dis", "Ic", "Npf", "Oc"]
1819

@@ -25,16 +26,16 @@ class Output:
2526

2627
@property
2728
def head(self) -> xr.DataArray:
28-
return imod.mf6.open_hds(
29-
Path("quickstart_data", f"{self.parent.name}.hds"),
30-
Path("quickstart_data", f"{self.parent.name}.dis.grb"),
29+
return open_hds(
30+
self.parent.parent.sim_ws / f"{self.parent.name}.hds",
31+
self.parent.parent.sim_ws / f"{self.parent.name}.dis.grb",
3132
)
3233

3334
@property
3435
def budget(self):
3536
return imod.mf6.open_cbc(
36-
Path("./quickstart_data/mymodel.bud"),
37-
Path("./quickstart_data/mymodel.dis.grb"),
37+
self.parent.parent.sim_ws / "mymodel.bud",
38+
self.parent.parent.sim_ws / "mymodel.dis.grb",
3839
merge_to_dataset=True,
3940
)
4041

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

0 commit comments

Comments
 (0)