Skip to content

Commit 13cc744

Browse files
committed
prep outbound conversion steps, improve array formatting
1 parent 282e6bd commit 13cc744

File tree

14 files changed

+297
-139
lines changed

14 files changed

+297
-139
lines changed

flopy4/mf6/codec.py

Lines changed: 0 additions & 35 deletions
This file was deleted.

flopy4/mf6/codec/__init__.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import sys
2+
from os import PathLike
3+
from typing import Any
4+
5+
import numpy as np
6+
from jinja2 import Environment, PackageLoader
7+
8+
from flopy4.mf6 import filters
9+
from flopy4.mf6.codec.converter import structure_array, unstructure_array
10+
11+
_JINJA_ENV = Environment(
12+
loader=PackageLoader("flopy4.mf6"),
13+
trim_blocks=True,
14+
lstrip_blocks=True,
15+
)
16+
_JINJA_ENV.filters["blocks"] = filters.blocks
17+
_JINJA_ENV.filters["field_type"] = filters.field_type
18+
_JINJA_ENV.filters["field_value"] = filters.field_value
19+
_JINJA_ENV.filters["is_list"] = filters.is_list
20+
_JINJA_ENV.filters["array_how"] = filters.array_how
21+
_JINJA_ENV.filters["array_chunks"] = filters.array_chunks
22+
_JINJA_ENV.filters["array2string"] = filters.array2string
23+
24+
_JINJA_TEMPLATE_NAME = "blocks.jinja"
25+
26+
_PRINT_OPTIONS = {
27+
"precision": 4,
28+
"linewidth": sys.maxsize,
29+
"threshold": sys.maxsize,
30+
}
31+
32+
33+
def unstructure(data):
34+
# TODO unstructure arrays into sparse dicts
35+
# TODO combine OC fields into list input as defined in the MF6 dfn
36+
# TODO return a dictionary instead of the component itself, then
37+
# update filters to use dictinoary access instead of getattr()
38+
return data
39+
40+
41+
def loads(data: str) -> Any:
42+
# TODO
43+
pass
44+
45+
46+
def load(path: str | PathLike) -> Any:
47+
# TODO
48+
pass
49+
50+
51+
def dumps(data) -> str:
52+
template = _JINJA_ENV.get_template(_JINJA_TEMPLATE_NAME)
53+
with np.printoptions(**_PRINT_OPTIONS): # type: ignore
54+
return template.render(dfn=type(data).dfn, data=unstructure(data))
55+
56+
57+
def dump(data, path: str | PathLike) -> None:
58+
template = _JINJA_ENV.get_template(_JINJA_TEMPLATE_NAME)
59+
iterator = template.generate(dfn=type(data).dfn, data=unstructure(data))
60+
with np.printoptions(**_PRINT_OPTIONS), open(path, "w") as f: # type: ignore
61+
f.writelines(iterator)
62+
63+
64+
__all__ = [
65+
"structure_array",
66+
"unstructure_array",
67+
"loads",
68+
"load",
69+
"dumps",
70+
"dump",
71+
]

flopy4/mf6/converters.py renamed to flopy4/mf6/codec/converter.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,21 @@
33
import numpy as np
44
import sparse
55
from numpy.typing import NDArray
6+
from xarray import DataArray
67
from xattree import get_xatspec
78

89
from flopy4.mf6.config import SPARSE_THRESHOLD
910
from flopy4.mf6.constants import FILL_DNODATA
1011

1112

12-
def convert_array(value, self_, field) -> NDArray:
13+
# TODO: convert to a cattrs structuring hook so we don't have to
14+
# apply separately to all array fields?
15+
def structure_array(value, self_, field) -> NDArray:
16+
"""
17+
Convert a sparse dictionary representation of an array to a
18+
dense numpy array or a sparse COO array.
19+
"""
20+
1321
if not isinstance(value, dict):
1422
# if not a dict, assume it's a numpy array
1523
# and let xarray deal with it if it isn't
@@ -91,3 +99,31 @@ def _get_nn(cellid):
9199
# a[(nn,)] = v
92100

93101
return final(a)
102+
103+
104+
def unstructure_array(value: DataArray) -> dict:
105+
"""
106+
Convert a dense numpy array or a sparse COO array to a sparse
107+
dictionary representation suitable for serialization into the
108+
MF6 list-based input format.
109+
"""
110+
# make sure dim 'kper' is present
111+
if "kper" not in value.dims:
112+
raise ValueError("array must have 'kper' dimension")
113+
114+
if isinstance(value.data, sparse.COO):
115+
coords = value.coords
116+
data = value.data
117+
else:
118+
coords = np.array(np.nonzero(value)).T # type: ignore
119+
data = value[tuple(coords.T)] # type: ignore
120+
if not coords.size: # type: ignore
121+
return {}
122+
match value.ndim:
123+
case 1:
124+
return {k: v for k, v in zip(coords[:, 0], data)} # type: ignore
125+
case 2:
126+
return {(k, j): v for (k, j), v in zip(coords, data)} # type: ignore
127+
case 3:
128+
return {(k, i, j): v for (k, i, j), v in zip(coords, data)} # type: ignore
129+
return {}

flopy4/mf6/filters.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from collections.abc import Hashable, Mapping
2+
from io import StringIO
13
from typing import Any
24

35
import numpy as np
@@ -36,28 +38,74 @@ def field_value(ctx, field: Field):
3638
return getattr(ctx["data"], field["name"])
3739

3840

39-
def array_delay(value: xr.DataArray, chunks=None):
41+
def array_how(value: xr.DataArray) -> str:
42+
return "internal"
43+
44+
45+
def array_chunks(value: xr.DataArray, chunks: Mapping[Hashable, int] | None = None):
4046
"""
41-
Yield chunks from an array. Each chunk becomes a line in the file.
42-
If the array is not already chunked, it is chunked using the given
43-
chunk size. If no chunk size is provided, the entire array becomes
44-
a single chunk.
47+
Yield chunks from an array of up to 3 dimensions. If the
48+
array is not already chunked, split it into chunks of the
49+
specified sizes, given as a dictionary mapping dimension
50+
names to chunk sizes.
51+
52+
If chunk sizes are not specified, chunk the array with at
53+
most 2 dimensions per chunk, where:
54+
55+
- If the array is 3D, assume the first dimension is the
56+
vertical (i.e. layers) and the others horizontal (rows and
57+
columns, in that order), and yield a chunk per layer, such
58+
that an array with indices (k, i, j) becomes k chunks, each
59+
of shape (i, j).
60+
61+
- If the array is 1D or 2D, yield it as a single chunk.
4562
"""
63+
4664
if value.chunks is None:
47-
chunk_shape = chunks or {dim: size for dim, size in zip(value.dims, value.shape)}
48-
value = value.chunk(chunk_shape)
65+
if chunks is None:
66+
match value.ndim:
67+
case 1:
68+
# 1D array, single chunk
69+
chunks = {value.dims[0]: value.shape[0]}
70+
case 2:
71+
# 2D array, single chunk
72+
chunks = {value.dims[0]: value.shape[0], value.dims[1]: value.shape[1]}
73+
case 3:
74+
# 3D array, chunk for each layer
75+
chunks = {
76+
value.dims[0]: 1,
77+
value.dims[1]: value.shape[1],
78+
value.dims[2]: value.shape[2],
79+
}
80+
value = value.chunk(chunks)
4981
for chunk in value.data.blocks:
5082
yield chunk.compute()
5183

5284

5385
def array2string(value: NDArray) -> str:
54-
"""Convert an array to a string."""
55-
s = np.array2string(value, separator=" ")
56-
if value.shape != ():
57-
s = s[1:-1] # remove brackets
58-
return s.replace("'", "").replace('"', "") # remove quotes
86+
"""
87+
Convert an array to a string. The array can be 1D or 2D.
88+
If the array is 1D, it is converted to a 1-line string,
89+
with elements separated by whitespace. If the array is
90+
2D, each row becomes a line in the string.
91+
"""
92+
buffer = StringIO()
93+
value = np.asarray(value)
94+
if value.ndim > 2:
95+
raise ValueError("Only 1D and 2D arrays are supported.")
96+
if value.ndim == 1:
97+
# add an axis to 1d arrays so np.savetxt writes elements on 1 line
98+
value = value[None]
99+
format = (
100+
"%d"
101+
if np.issubdtype(value.dtype, np.integer)
102+
else "%f"
103+
if np.issubdtype(value.dtype, np.floating)
104+
else "%s"
105+
)
106+
np.savetxt(buffer, value, fmt=format, delimiter=" ")
107+
return buffer.getvalue().strip()
59108

60109

61-
def is_dict(value: Any) -> bool:
62-
"""Check if the value is a dictionary."""
63-
return isinstance(value, dict)
110+
def is_list(value: Any) -> bool:
111+
return isinstance(value, list)

flopy4/mf6/gwf/chd.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from numpy.typing import NDArray
77
from xattree import xattree
88

9-
from flopy4.mf6.converters import convert_array
9+
from flopy4.mf6.codec import structure_array
1010
from flopy4.mf6.package import Package
1111
from flopy4.mf6.spec import array, field
1212

@@ -40,7 +40,7 @@ class Steps:
4040
"nnodes",
4141
),
4242
default=None,
43-
converter=Converter(convert_array, takes_self=True, takes_field=True),
43+
converter=Converter(structure_array, takes_self=True, takes_field=True),
4444
)
4545
aux: Optional[NDArray[np.floating]] = array(
4646
block="period",
@@ -49,7 +49,7 @@ class Steps:
4949
"nnodes",
5050
),
5151
default=None,
52-
converter=Converter(convert_array, takes_self=True, takes_field=True),
52+
converter=Converter(structure_array, takes_self=True, takes_field=True),
5353
)
5454
boundname: Optional[NDArray[np.str_]] = array(
5555
block="period",
@@ -58,12 +58,12 @@ class Steps:
5858
"nnodes",
5959
),
6060
default=None,
61-
converter=Converter(convert_array, takes_self=True, takes_field=True),
61+
converter=Converter(structure_array, takes_self=True, takes_field=True),
6262
)
6363
steps: Optional[NDArray[np.object_]] = array(
6464
Steps,
6565
block="period",
6666
dims=("nper", "nnodes"),
6767
default=None,
68-
converter=Converter(convert_array, takes_self=True, takes_field=True),
68+
converter=Converter(structure_array, takes_self=True, takes_field=True),
6969
)

flopy4/mf6/gwf/dis.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from numpy.typing import NDArray
55
from xattree import xattree
66

7-
from flopy4.mf6.converters import convert_array
7+
from flopy4.mf6.codec import structure_array
88
from flopy4.mf6.package import Package
99
from flopy4.mf6.spec import array, dim, field
1010

@@ -42,31 +42,31 @@ class Dis(Package):
4242
block="griddata",
4343
default=1.0,
4444
dims=("ncol",),
45-
converter=Converter(convert_array, takes_self=True, takes_field=True),
45+
converter=Converter(structure_array, takes_self=True, takes_field=True),
4646
)
4747
delc: NDArray[np.floating] = array(
4848
block="griddata",
4949
default=1.0,
5050
dims=("nrow",),
51-
converter=Converter(convert_array, takes_self=True, takes_field=True),
51+
converter=Converter(structure_array, takes_self=True, takes_field=True),
5252
)
5353
top: NDArray[np.floating] = array(
5454
block="griddata",
5555
default=1.0,
5656
dims=("nrow", "ncol"),
57-
converter=Converter(convert_array, takes_self=True, takes_field=True),
57+
converter=Converter(structure_array, takes_self=True, takes_field=True),
5858
)
5959
botm: NDArray[np.floating] = array(
6060
block="griddata",
6161
default=0.0,
6262
dims=("nlay", "nrow", "ncol"),
63-
converter=Converter(convert_array, takes_self=True, takes_field=True),
63+
converter=Converter(structure_array, takes_self=True, takes_field=True),
6464
)
6565
idomain: NDArray[np.integer] = array(
6666
block="griddata",
6767
default=1,
6868
dims=("nlay", "nrow", "ncol"),
69-
converter=Converter(convert_array, takes_self=True, takes_field=True),
69+
converter=Converter(structure_array, takes_self=True, takes_field=True),
7070
)
7171
nnodes: int = dim(
7272
coord="node",

flopy4/mf6/gwf/ic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from numpy.typing import NDArray
44
from xattree import xattree
55

6-
from flopy4.mf6.converters import convert_array
6+
from flopy4.mf6.codec import structure_array
77
from flopy4.mf6.package import Package
88
from flopy4.mf6.spec import array, field
99

@@ -14,7 +14,7 @@ class Ic(Package):
1414
block="packagedata",
1515
dims=("nnodes",),
1616
default=1.0,
17-
converter=Converter(convert_array, takes_self=True, takes_field=True),
17+
converter=Converter(structure_array, takes_self=True, takes_field=True),
1818
)
1919
export_array_ascii: bool = field(block="options", default=False)
2020
export_array_netcdf: bool = field(block="options", default=False)

0 commit comments

Comments
 (0)