Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion flopy4/mf6/converter/ingress/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from flopy4.mf6.item import (
Item,
RowContext,
conditional_fields,
dim_lookup,
item_list_type,
item_list_words,
Expand Down Expand Up @@ -55,6 +56,7 @@ def _parse_rows(
context: LoadContext,
sizes: "dict[str, int] | None" = None,
boundnames: bool = False,
enabled: frozenset[str] = frozenset(),
union_arm: str = "cellid",
union_forms: Callable[[Mapping[str, Any]], Any] | None = None,
load_child: Callable[[Any, str], Any] | None = None,
Expand All @@ -69,7 +71,9 @@ def _parse_rows(
ncelldim = context.ncelldim(classes[0]) if len(classes) == 1 else None
if ncelldim is None:
ncelldim = ncelldim_from_dims(dict(context.dims))
row_context = RowContext(ncelldim=ncelldim, sizes=sizes or {}, boundnames=boundnames)
row_context = RowContext(
ncelldim=ncelldim, sizes=sizes or {}, boundnames=boundnames, enabled=enabled
)
if isinstance(item_cls, tuple):
return parse_union_items(
rows, item_cls, words=words, context=row_context, load_child=load_child
Expand Down Expand Up @@ -1060,6 +1064,9 @@ def structure_component(
if (n := dim_value(shape, lookup=lambda name: dim_lookup(kwargs, name))) is not None:
sizes[shape] = n
boundnames = bool(kwargs.get("boundnames", False))
enabled = frozenset(
o for ic in item_types for o in conditional_fields(ic).values() if kwargs.get(o)
)

# The grid's dims give the cellids' width (see LoadContext.ncelldim).
# Same "self dims, since a DimensionProvider has none threaded to it
Expand Down Expand Up @@ -1091,6 +1098,7 @@ def load_child(f: Any, token: str) -> Any:
item_cls,
sizes=sizes,
boundnames=boundnames,
enabled=enabled,
context=rows_context,
union_arm=union_arm,
union_forms=union_forms,
Expand All @@ -1116,6 +1124,7 @@ def load_child(f: Any, token: str) -> Any:
item_cls,
sizes=sizes,
boundnames=boundnames,
enabled=enabled,
context=rows_context,
union_arm=union_arm,
union_forms=union_forms,
Expand Down Expand Up @@ -1152,6 +1161,7 @@ def load_child(f: Any, token: str) -> Any:
period_item_cls,
sizes=sizes,
boundnames=boundnames,
enabled=enabled,
context=rows_context,
union_arm=union_arm,
union_forms=union_forms,
Expand Down
2 changes: 1 addition & 1 deletion flopy4/mf6/gwf/evt.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class StressPeriodData(Item):
default=(), shape=("nseg-1",), time_series=True, optional=True
)
petm0: Optional[Union[float, TimeSeriesRef]] = double(
default=None, time_series=True, optional=True
default=None, time_series=True, when="surf_rate_specified"
)
aux: tuple[Union[float, TimeSeriesRef], ...] = array(
default=(), shape=("auxiliary",), time_series=True, optional=True
Expand Down
71 changes: 53 additions & 18 deletions flopy4/mf6/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ def _has_boundname_field(cls: type) -> bool:
return any(f.name == "boundname" for f in cast(type[Record], cls).fields())


def conditional_fields(item_cls: "type[Item] | tuple[type[Item], ...]") -> dict[str, str]:
"""An item class's fields an option turns on (the DFN's ``when``), by
name, to the option."""
classes = item_cls if isinstance(item_cls, tuple) else (item_cls,)
return {
f.name: option
for c in classes
for f in cast(type[Record], c).fields()
if (option := f.metadata.get("when"))
}


def _may_be_absent(f: attrs.Attribute) -> bool:
"""Whether a field may be missing from a row."""
return bool(f.metadata.get("optional") or f.metadata.get("when"))


@lru_cache(maxsize=None)
def _union_arms(
cls: type, type_str: str
Expand Down Expand Up @@ -316,7 +333,7 @@ def solve(i: int, pos: int, form: "tuple[str, ...] | None") -> "tuple[int, list]
if pos == len(tokens) or (form is not None and i == len(form)):
# trailing optional columns may be absent, but not a form's
ends = pos == len(tokens) and (i == len(form) if form else True)
absent = ends and fields[i].metadata.get("optional")
absent = ends and _may_be_absent(fields[i])
return (0, [None] * (len(fields) - i)) if absent else None
best = None
kinds = fields[i].metadata["union"] if form is None else (form[i],)
Expand Down Expand Up @@ -374,11 +391,13 @@ class RowContext:
exchange's models may be on different grids); None if not known.
``sizes`` gives the counts of the inline arrays the package sizes, by
shape expression (aux, by ``auxiliary``); one not given isn't known.
``boundnames`` is the package's option."""
``boundnames`` is the package's option. ``enabled`` are the set
options that turn fields on; None if not known."""

ncelldim: int | Mapping[str, int] | None = None
sizes: Mapping[str, int] = attrs.field(factory=dict)
boundnames: bool = False
enabled: frozenset[str] | None = None

def cellid_width(self, f: attrs.Attribute) -> int | None:
if isinstance(self.ncelldim, Mapping):
Expand Down Expand Up @@ -482,15 +501,23 @@ def _layout(
The boundname is there if the package's ``boundnames`` option is set
(and a value is left for it). For Python values with it unset, one
value more than the row's fields take is the boundname, and the
package then sets the option. An optional untagged field (EVT's petm0)
is there if the values make room for it. A width that isn't known (no
package then sets the option. A field an option turns on (EVT's petm0)
is there if the option is enabled, or, if that isn't known, if the
values make room for it. A width that isn't known (no
grid yet) is the one the values make room for, if only one does;
otherwise this raises, asking for the field as a tuple. Like MF6, a
file row's extra tokens are ignored, if no layout uses them all.
"""
n = len(values)
slots = _slots(cls, file)
has_boundname = _has_boundname_field(cls)
gated = conditional_fields(cls)
if context.enabled is None:
off: frozenset[str] = frozenset()
on: frozenset[str] = frozenset()
else:
off = frozenset(name for name, o in gated.items() if o not in context.enabled)
on = frozenset(gated) - off

def cellid_widths(f: attrs.Attribute, unknown: dict) -> list[tuple[int, dict]]:
if (w := context.cellid_width(f)) is not None:
Expand All @@ -504,13 +531,19 @@ def flat(span: list) -> bool:
"""Whether values are a field's values, not whole fields."""
return file or not any(_is_seq(x) or isinstance(x, Record) for x in span)

def options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict):
def fits(slot: _Slot, pos: int, end: int, out: dict, unknown: dict, conditional: bool):
"""Each way the slot can take values from ``pos``: the position
after it, what it took, and the unknown widths it assumed."""
f = slot.field
left = end - pos
v = values[pos] if left else None
optional = f is not None and f.metadata.get("optional")
optional = f is not None and _may_be_absent(f)
if conditional and f is not None:
if f.name in off:
yield pos, None, unknown
return
if f.name in on:
optional = bool(f.metadata.get("optional"))
if slot.kind == "keyword":
if left and str(v).upper() == cast(type[Record], cls).keyword().upper():
yield pos + 1, None, unknown
Expand All @@ -527,7 +560,7 @@ def options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict):
if file:
yield end, ("unions", values[pos:end], slot.group), unknown
return
required = sum(1 for g in slot.group if not g.metadata.get("optional"))
required = sum(1 for g in slot.group if not _may_be_absent(g))
for m in range(min(len(slot.group), left), required - 1, -1):
yield pos + m, ("unions", values[pos : pos + m], slot.group), unknown
return
Expand Down Expand Up @@ -584,37 +617,37 @@ def options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict):
together = [
s.field.name
for s in slots
if cast(type[Item], cls)._columns and s.kind == "value" and s.field.metadata.get("optional")
if cast(type[Item], cls)._columns and s.kind == "value" and _may_be_absent(s.field)
]

def walk(k: int, pos: int, end: int, out: dict, unknown: dict, surplus: bool):
def walk(
k: int, pos: int, end: int, out: dict, unknown: dict, surplus: bool, conditional: bool
):
if k == len(slots):
if len({name in out for name in together}) > 1:
return
if pos == end or (surplus and pos < end):
yield out, unknown
return
slot = slots[k]
for pos2, took, unknown2 in options(slot, pos, end, out, unknown):
for pos2, took, unknown2 in fits(slot, pos, end, out, unknown, conditional):
out2 = out if took is None else {**out, slot.field.name: took}
yield from walk(k + 1, pos2, end, out2, unknown2, surplus)
yield from walk(k + 1, pos2, end, out2, unknown2, surplus, conditional)

def solve(boundname: list[bool], surplus: bool = False):
def solve(boundname: list[bool], surplus: bool = False, conditional: bool = True):
for bn in boundname:
end = n - bn
if end < 0:
continue
for out, unknown in walk(0, 0, end, {}, {}, surplus):
for out, unknown in walk(0, 0, end, {}, {}, surplus, conditional):
yield ({**out, "boundname": ("bn", values[-1])} if bn else out), unknown

fields = attrs.fields_dict(cls) # type: ignore[arg-type]

def optionals(out: dict) -> int:
"""How many optional fields a layout has."""
return sum(
1
for k in out
if k == "boundname" or (k in fields and fields[k].metadata.get("optional"))
1 for k in out if k == "boundname" or (k in fields and _may_be_absent(fields[k]))
)

def first(solutions) -> "dict | None":
Expand All @@ -636,8 +669,10 @@ def first(solutions) -> "dict | None":

bn = [True, False] if has_boundname and context.boundnames else [False]
out = first(solve(bn))
if out is None and not file and (off or on):
out = first(solve(bn, conditional=False))
if out is None and not file and has_boundname and not context.boundnames:
out = first(solve([True]))
out = first(solve([True])) or first(solve([True], conditional=False))
if out is None and file:
out = first(solve(bn, surplus=True))
if out is None:
Expand Down Expand Up @@ -784,7 +819,7 @@ def to_tokens(self, *, ncelldim: int | Mapping[str, int] | None = None) -> tuple
)
val = n
if val is None and not (_is_one_cellid(f) and f.metadata.get("allow_zero")):
if _is_one_cellid(f) and not f.metadata.get("optional"):
if _is_one_cellid(f) and not _may_be_absent(f):
raise ValueError(f"{cls.__name__}.{f.name}: no cellid given")
continue
if not keyword_emitted and not _is_row_key(f):
Expand Down
63 changes: 56 additions & 7 deletions flopy4/mf6/package.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import operator
import re
import warnings
from abc import ABC
from pathlib import Path
from typing import Any, Optional
Expand All @@ -18,6 +20,7 @@
from flopy4.mf6.item import (
Item,
RowContext,
conditional_fields,
dim_lookup,
inline_dim,
item_list_type,
Expand Down Expand Up @@ -60,6 +63,15 @@
}


_NUMBER = re.compile(r"[+-]?(\d+\.?\d*|\.\d+)([ed][+-]?\d+)?", re.IGNORECASE)


def _reads_as_number(text: str) -> bool:
"""Whether MF6 could read a word as a number, with an E or a Fortran D
exponent."""
return _NUMBER.fullmatch(text) is not None


def _check_rows(dim: str, declared: int, nrows: int, bound: str | None) -> None:
if not _BOUND_OPS[bound](nrows, declared):
raise ValueError(
Expand Down Expand Up @@ -365,7 +377,7 @@ def _row_context(self, item_cls: "type[Item] | tuple[type[Item], ...]") -> RowCo
"""What the layout of a row of ``item_cls`` given in Python depends
on (see `Item.from_values`): the grid's ncelldim, if known (an
exchange's two grids aren't, here), the sizes of the inline arrays
the package sizes, and the boundnames option. A size the rows can
the package sizes, and its enabled options. A size the rows can
set (EVT's nseg, GNC's numalphaj) is solved from them if unset;
one they can't (aux's, by the names in ``auxiliary``) is 0."""
fields = attrs.fields_dict(type(self)) # type: ignore[arg-type]
Expand All @@ -388,8 +400,12 @@ def lookup(name: str) -> Any:
ncelldim = self.resolve_dims("ncelldim").get("ncelldim")
except ValueError:
pass
enabled = frozenset(o for o in conditional_fields(item_cls).values() if values.get(o))
return RowContext(
ncelldim=ncelldim, sizes=sizes, boundnames=bool(getattr(self, "boundnames", False))
ncelldim=ncelldim,
sizes=sizes,
boundnames=bool(getattr(self, "boundnames", False)),
enabled=enabled,
)

def _coerce_item_list(
Expand All @@ -415,8 +431,8 @@ def _coerce_item_list(
takes. Ambiguous columnar dict-of-lists input isn't supported (no
single arm to build columns from).

A row with a boundname sets the package's boundnames option, which
MF6 needs to read it.
A row with a boundname, or a field an option turns on, sets that
option, which MF6 needs to read it.
"""
context = self._row_context(item_cls)
if isinstance(item_cls, tuple):
Expand All @@ -439,9 +455,17 @@ def _coerce_item_list(
items.append(item_cls(**row))
else:
items.append(item_cls.from_values(row, context))
if not context.boundnames and "boundnames" in attrs.fields_dict(type(self)): # type: ignore[arg-type]
if any(getattr(item, "boundname", None) for item in items):
object.__setattr__(self, "boundnames", True)
on = {
option
for name, option in conditional_fields(item_cls).items()
if any(getattr(item, name, None) is not None for item in items)
}
if any(getattr(item, "boundname", None) for item in items):
on.add("boundnames")
fields = attrs.fields_dict(type(self)) # type: ignore[arg-type]
for option in on:
if option in fields and not getattr(self, option):
object.__setattr__(self, option, True)
return items

def _normalize_griddata(self, fields, dims: dict) -> None:
Expand Down Expand Up @@ -510,6 +534,7 @@ def write(self, format: str = MF6, context: Optional[WriteContext] = None) -> No
self._sync_dims()
self._check_tas_refs()
self._check_ts_refs()
self._check_obs_ids()
self._name_file_children()
super().write(format=format, context=context)

Expand Down Expand Up @@ -549,6 +574,30 @@ def _check_ts_refs(self) -> None:
f"defines (defined: {sorted(defined) or 'none'})"
)

def _check_obs_ids(self) -> None:
"""Warn if an observation names a boundname MF6 may read as a cell."""
boundnames = set()
for f in attrs.fields(type(self)): # type: ignore[arg-type]
if item_list_type(f.type) is None:
continue
value = getattr(self, f.name)
for rows in (value if isinstance(value, dict) else {None: value}).values():
boundnames |= {b for row in rows or [] if (b := getattr(row, "boundname", None))}
numeric = {b.lower() for b in boundnames if _reads_as_number(b)}
if not numeric:
return
for obs in self._children_of("utl-obs"):
for block in obs.continuous or []:
for row in block.continuous:
for id_ in (row.id_, row.id2):
if isinstance(id_, str) and id_.lower() in numeric:
warnings.warn(
f"{type(self).__name__}: observation {row.obsname!r} names "
f"boundname {id_!r}, which MF6 may read as a cell, not a "
f"boundname; give the boundaries a name that isn't a number",
stacklevel=3,
)

def _series_names(self, dfn_name: str) -> set[str]:
"""The series names, lowercase, the package's TS6 or TAS6 files
(``utl-ts``, ``utl-tas``) define."""
Expand Down
3 changes: 2 additions & 1 deletion flopy4/mf6/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ class FieldKwargs(TypedDict, total=False):
optional: bool
netcdf: bool
tagged: bool
when: str | None


def _pipe(*hooks):
Expand All @@ -99,7 +100,7 @@ def _field(type_: str, kwargs: FieldKwargs, **attributes) -> Any:
common ones in ``kwargs``) in its metadata where set."""
metadata = dict(kwargs.get("metadata") or {})
metadata["type"] = type_
for key in ("block", "longname", "optional", "netcdf", "tagged"):
for key in ("block", "longname", "optional", "netcdf", "tagged", "when"):
if kwargs.get(key):
metadata[key] = kwargs[key] # type: ignore[literal-required]
metadata.update({k: v for k, v in attributes.items() if v not in (None, False, ())})
Expand Down
Loading
Loading