Skip to content

Commit cf757ad

Browse files
committed
passing minimal test
1 parent 3177ebf commit cf757ad

File tree

16 files changed

+1605
-654
lines changed

16 files changed

+1605
-654
lines changed

docs/dev/sdd.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,21 @@ Component types include:
4242
- **model**: a simulated hydrological process
4343
- **package**: a subcomponent of a model or simulation
4444

45-
Certain subsets of packages have distinguishing characteristics. A **stress package** represents a forcing. A **basic package** contains only input variables applying statically to the entire simulation. An **advanced package** contains time-variable (i.e. transient) input data. In some cases, only a single instance of a package is expected — in other cases, arbitrarily many. Packages for which the latter is true are called **multi-packages**.
45+
Certain subsets of packages have distinguishing characteristics. A **stress package** represents a forcing. A **basic package** contains only input variables applying statically to the entire simulation. An **advanced package** contains time-variable (i.e. transient) input data. Usually only a single instance of a package is expected — when arbitrarily many are permitted, the package is called a **multi-package**. A **subpackage** is a concept only recognized by the product, not by MODFLOW 6 — a package linked to its parent not by a separate input file, but directly (i.e., subpackage data provided to the parent's initializer method). Subpackages may be attached to packages, models, or simulations.
46+
47+
```mermaid
48+
classDiagram
49+
Simulation *-- "1+" Package
50+
Simulation *-- "1+" Model
51+
Simulation *-- "1+" Variable
52+
Simulation *-- "1+" Subpackage
53+
Model *-- "1+" Package
54+
Model *-- "1+" Subpackage
55+
Model *-- "1+" Variable
56+
Package *-- "1+" Subpackage
57+
Package *-- "1+" Variable
58+
Subpackage *-- "1+" Variable
59+
```
4660

4761
Components are specified by **definition files**. A **definition** specifies input variables for a single MF6 component. A **block** is a named collection of input variables. A definition file specifies exactly one component. A component may contain zero or more blocks. Each block must contain at least one variable.
4862

docs/examples/quickstart.py

Lines changed: 52 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,55 @@
1-
import flopy4
2-
ws = './mymodel'
3-
name = 'mymodel'
4-
sim = flopy4.mf6.MFSimulation(sim_name=name, sim_ws=ws, exe_name='mf6')
5-
tdis = flopy4.mf6.ModflowTdis(sim)
6-
ims = flopy4.mf6.ModflowIms(sim)
7-
gwf = flopy4.mf6.ModflowGwf(sim, modelname=name, save_flows=True)
8-
dis = flopy4.mf6.ModflowGwfdis(gwf, nrow=10, ncol=10)
9-
ic = flopy4.mf6.ModflowGwfic(gwf)
10-
npf = flopy4.mf6.ModflowGwfnpf(gwf, save_specific_discharge=True)
11-
chd = flopy4.mf6.ModflowGwfchd(gwf) # self.stress_period_data = sparse.DOK((ncol, nrow, nlay))
12-
chd.stress_period_data[0,0,0] = 1.
13-
chd.stress_period_data[0,9,9] = 0.
14-
15-
# chd = flopy4.mf6.ModflowGwfchd(gwf, stress_period_data=sparse.COO([[0,0], [0,9], [0,9]], [1., 0.]))
16-
# chd = flopy4.mf6.ModflowGwfchd(gwf, stress_period_data=[Chd.PeriodData((0, 0, 0), 1.),
17-
# Chd.PeriodData[(0, 9, 9), 0.]])
18-
budget_file = name + '.bud'
19-
head_file = name + '.hds'
20-
oc = flopy4.mf6.ModflowGwfoc(gwf,
21-
budget_filerecord=budget_file,
22-
head_filerecord=head_file,
23-
save={tis.get_period("10-20-2020"): {"head": "ALL", "budget": "ALL"}}
24-
print={"budget": np.ones()}
25-
# save_head="ALL",
26-
# save_budget=np.ones(nstp),
27-
# print_head="",
28-
# print_budget="",
29-
oc.saverecord["HEAD"]="ALL"
30-
oc.saverecord["BUDGET"]=np.ones((nstp))
31-
32-
# Normally you would write and run the simulation
1+
from flopy4.mf6 import Simulation, Tdis
2+
from flopy4.mf6.gwf import Chd, Dis, Gwf, Ic, Npf, Oc
3+
4+
ws = "./mymodel"
5+
name = "mymodel"
6+
sim = Simulation(name=name, path=ws, exe="mf6")
7+
tdis = Tdis(sim)
8+
gwf = Gwf(sim, name=name, save_flows=True)
9+
dis = Dis(gwf, nrow=10, ncol=10)
10+
ic = Ic(gwf)
11+
npf = Npf(gwf, save_specific_discharge=True)
12+
chd = Chd(
13+
gwf,
14+
stress_period_data=[
15+
# TODO? accept raw tuples for
16+
# parity with flopy3 example?
17+
Chd.StressPeriodData(cellid=(0, 0, 0), head=1.0),
18+
Chd.StressPeriodData(cellid=(0, 9, 9), head=1.0),
19+
],
20+
)
21+
22+
# TODO? xarray alternative
23+
# chd.data["stress_period_data"].loc(dict(i=0, j=0, k=0)) = 1.
24+
# chd.data["stress_period_data"].loc(dict(i=0, j=9, k=9)) = 0.
25+
26+
# TODO? sparse array alternative
27+
# spd = sparse.COO([[0,0], [0,9], [0,9]], [1., 0.])
28+
# chd = flopy4.mf6.ModflowGwfchd(gwf, stress_period_data=spd)
29+
30+
budget_file = name + ".bud"
31+
head_file = name + ".hds"
32+
oc = Oc(
33+
gwf,
34+
budget_filerecord=budget_file,
35+
head_filerecord=head_file,
36+
perioddata=[("HEAD", "ALL"), ("BUDGET", "ALL")],
37+
# TODO: format we want to support
38+
# save={"head": {0: "ALL"}, "budget": {0: "ALL"}},
39+
# print={"budget": {0: np.ones((tdis.nstp[0]))}, "budget": {0, "ALL"}},
40+
)
41+
42+
# TODO? mock some output
3343
# sim.write_simulation()
3444
# sim.run_simulation()
3545

36-
37-
head = gwf.output.head().get_data()
38-
bud = gwf.output.budget()
39-
40-
spdis = bud.get_data(text='DATA-SPDIS')[0]
41-
qx, qy, qz = flopy.utils.postprocessing.get_specific_discharge(spdis, gwf)
42-
pmv = flopy.plot.PlotMapView(gwf)
43-
pmv.plot_array(head)
44-
pmv.plot_grid(colors='white')
45-
pmv.contour_array(head, levels=[.2, .4, .6, .8], linewidths=3.)
46-
pmv.plot_vector(qx, qy, normalize=True, color="white")
47-
48-
49-
50-
51-
# from demo:
52-
53-
# # Create a simulation.
54-
#
55-
# sim = Simulation()
56-
# tdis = Tdis(sim=sim, nper=1, perioddata=[Tdis.PeriodData()])
57-
# gwf = Gwf(sim=sim)
58-
# dis = Dis(model=gwf)
59-
# ic = Ic(model=gwf, strt=1.0)
60-
# oc = Oc(model=gwf, perioddata=[Oc.Steps()])
61-
# npf = Npf(model=gwf, icelltype=0, k=1.0)
62-
#
63-
# # View the data tree.
64-
# sim.data
46+
# TODO? try to reproduce plots with xarray
47+
# head = gwf.output.head().get_data()
48+
# bud = gwf.output.budget()
49+
# spdis = bud.get_data(text='DATA-SPDIS')[0]
50+
# qx, qy, qz = flopy.utils.postprocessing.get_specific_discharge(spdis, gwf)
51+
# pmv = flopy.plot.PlotMapView(gwf)
52+
# pmv.plot_array(head)
53+
# pmv.plot_grid(colors='white')
54+
# pmv.contour_array(head, levels=[.2, .4, .6, .8], linewidths=3.)
55+
# pmv.plot_vector(qx, qy, normalize=True, color="white")

flopy4/__init__.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
from typing import Any, Optional, get_origin
2+
3+
import numpy as np
4+
from attr import Attribute, fields_dict
5+
from numpy.typing import ArrayLike, NDArray
6+
from xarray import Dataset, DataTree
7+
8+
9+
def _parse_dim_names(shape: str) -> tuple[str, ...]:
10+
return tuple(
11+
[
12+
dim.strip()
13+
for dim in shape.strip()
14+
.replace("(", "")
15+
.replace(")", "")
16+
.split(",")
17+
if any(dim)
18+
]
19+
)
20+
21+
22+
def _try_resolve_dim(data: Optional[DataTree], name: str) -> int | str:
23+
name = name.strip()
24+
if data is None:
25+
return name
26+
value = data.get(name, None)
27+
if value is not None:
28+
return value.item()
29+
root = data.root
30+
paths = [
31+
"tdis",
32+
"dis",
33+
"gwf/dis",
34+
]
35+
for path in paths:
36+
try:
37+
key = f"{path}/{name}"
38+
return root[key].item()
39+
except:
40+
try:
41+
return root[path].dims[name]
42+
except:
43+
pass
44+
return name
45+
46+
47+
def _try_resolve_shape(data: DataTree, attr: Attribute) -> tuple[int | str]:
48+
shape = attr.metadata.get("shape", None)
49+
if shape is None:
50+
raise ValueError(f"Array {attr.name} missing shape metadata")
51+
shape = [_try_resolve_dim(data, dim) for dim in _parse_dim_names(shape)]
52+
return shape
53+
54+
55+
def _reshape_array(value: ArrayLike, shape: tuple[int]) -> Optional[NDArray]:
56+
value = np.array(value)
57+
if value.shape == ():
58+
return np.full(shape, value.item())
59+
elif value.shape != shape:
60+
raise ValueError(
61+
f"Shape mismatch, got {value.shape}, expected {shape}"
62+
)
63+
return value
64+
65+
66+
def resolve_array(
67+
self, attr: Attribute, value: Optional[ArrayLike]
68+
) -> Optional[NDArray]:
69+
"""
70+
Resolve an array-like value to a numpy array with the correct shape.
71+
The shape is determined by the shape metadata of the attribute, and
72+
the dimensions are resolved by looking up the corresponding values
73+
in the data tree.
74+
"""
75+
if value is None:
76+
return None
77+
shape = _try_resolve_shape(self.data, attr)
78+
unresolved = [dim for dim in shape if not isinstance(dim, int)]
79+
if any(unresolved):
80+
raise ValueError(
81+
f"Class '{type(self).__name__}' "
82+
f"failed to resolve dims: {', '.join(unresolved)}"
83+
)
84+
return _reshape_array(value, shape)
85+
86+
87+
def _bind_tree(self, parent):
88+
parent.data = parent.data.assign({self.data.name: self.data})
89+
self.data = parent.data[self.data.name]
90+
grandparent = getattr(parent, "parent", None)
91+
if grandparent is not None:
92+
_bind_tree(parent, grandparent)
93+
94+
95+
def init_tree(self, parent=None, **kwargs):
96+
"""
97+
Initialize a data tree for a component instance.
98+
"""
99+
cls = type(self)
100+
cls_name = cls.__name__.lower()
101+
spec = fields_dict(cls)
102+
data = Dataset()
103+
dims = set()
104+
105+
# add arrays
106+
for name, attr in spec.items():
107+
value = kwargs.get(name, attr.default)
108+
shape = attr.metadata.get("shape", None)
109+
if shape is not None:
110+
dim_names = _parse_dim_names(shape)
111+
shape = [
112+
_try_resolve_dim(parent.data.root if parent else None, dim)
113+
for dim in dim_names
114+
]
115+
shape = tuple(
116+
[
117+
(dim if isinstance(dim, int) else kwargs.get(dim, dim))
118+
for dim in shape
119+
]
120+
)
121+
unresolved = [dim for dim in shape if not isinstance(dim, int)]
122+
if any(unresolved):
123+
raise ValueError(
124+
f"Class '{cls_name}' "
125+
f"failed to resolve dims: {', '.join(unresolved)}"
126+
)
127+
dims.update(dim_names)
128+
value = _reshape_array(value, shape)
129+
if value.shape == ():
130+
raise ValueError(
131+
f"Failed to resolve array '{name}', "
132+
f"make sure these dimensions exist: "
133+
f"{','.join(dims)}"
134+
)
135+
data[name] = (dim_names, value)
136+
137+
# add scalars
138+
for name, value in spec.items():
139+
if name in data or name in dims:
140+
continue
141+
value = kwargs.get(name, attr.default)
142+
data[name] = value
143+
144+
self.data = DataTree(data, name=cls_name)
145+
if parent is not None:
146+
self.parent = parent
147+
_bind_tree(self, parent)
148+
149+
150+
def getattribute(self, name: str) -> Any:
151+
"""Override `__getattribute__` to proxy attribute access."""
152+
cls = type(self)
153+
spec = fields_dict(cls)
154+
if name in spec:
155+
value = self.data.get(name, None)
156+
if value is not None:
157+
return value
158+
value = self.data.dims.get(name, None)
159+
if value is not None:
160+
return value
161+
value = self.data.attrs.get(name, None)
162+
if value is not None:
163+
return value
164+
return super(cls, self).__getattribute__(name)
165+
166+
167+
def setattribute(self, attr: Attribute, value: Any):
168+
"""Hook for setting attribute values."""
169+
cls = type(self)
170+
spec = fields_dict(cls)
171+
if attr.name not in spec:
172+
raise AttributeError(f"{cls.__name__} has no attribute {attr.name}")
173+
if value is None:
174+
return
175+
self.data[attr.name] = (
176+
(
177+
_parse_dim_names(attr.metadata["shape"]),
178+
resolve_array(self, attr, value),
179+
)
180+
if get_origin(attr.type) in [list, np.ndarray]
181+
else value
182+
)
183+
# TODO run validation?
184+
185+
186+
def component(cls):
187+
"""
188+
Decorator for component classes.
189+
190+
This decorator adds a data tree to the class instance, on
191+
top of the existing attributes. The data tree is used to
192+
store both scalars and arrays. Attributes proxy the tree.
193+
"""
194+
cls.__getattribute__ = getattribute
195+
return cls

0 commit comments

Comments
 (0)