From fe29fc1696ad1c16b234bee0c035ec886c80002c Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 14 Oct 2026 20:36:39 -0700 Subject: [PATCH 1/8] read a column an option turns on only when the option is set EVT's petm0 is read only with SURF_RATE_SPECIFIED, and then always. The row layout took it if a token was left for it, so with BOUNDNAMES set a row leaving out its boundname lost its petm0 to the boundname. Codegen now marks petm0 with its option, the row context carries the options a package has set, and the layout reads a gated column by its option. A Python row with petm0 sets the option, as one with a boundname sets BOUNDNAMES. Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/converter/ingress/structure.py | 13 +++- flopy4/mf6/gwf/evt.py | 2 +- flopy4/mf6/item.py | 55 +++++++++++++---- flopy4/mf6/package.py | 27 ++++++-- flopy4/mf6/spec.py | 3 +- flopy4/mf6/utils/codegen/filters.py | 6 +- flopy4/mf6/utils/codegen/make.py | 6 ++ test/mf6/test_mf6_codec.py | 75 ++++++++++++++++++++--- 8 files changed, 158 insertions(+), 29 deletions(-) diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index f3708c0e..6d8d8d06 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -22,6 +22,7 @@ item_list_type, item_list_words, ncelldim_from_dims, + option_columns, parse_union_items, sized_by, ) @@ -55,6 +56,7 @@ def _parse_rows( context: LoadContext, sizes: "dict[str, int] | None" = None, boundnames: bool = False, + options: frozenset[str] = frozenset(), union_arm: str = "cellid", union_forms: Callable[[Mapping[str, Any]], Any] | None = None, load_child: Callable[[Any, str], Any] | None = None, @@ -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, options=options + ) if isinstance(item_cls, tuple): return parse_union_items( rows, item_cls, words=words, context=row_context, load_child=load_child @@ -1060,6 +1064,10 @@ 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)) + # The options turning columns on (EVT's surf_rate_specified) that are set. + options = frozenset( + o for ic in item_types for o in option_columns(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 @@ -1091,6 +1099,7 @@ def load_child(f: Any, token: str) -> Any: item_cls, sizes=sizes, boundnames=boundnames, + options=options, context=rows_context, union_arm=union_arm, union_forms=union_forms, @@ -1116,6 +1125,7 @@ def load_child(f: Any, token: str) -> Any: item_cls, sizes=sizes, boundnames=boundnames, + options=options, context=rows_context, union_arm=union_arm, union_forms=union_forms, @@ -1152,6 +1162,7 @@ def load_child(f: Any, token: str) -> Any: period_item_cls, sizes=sizes, boundnames=boundnames, + options=options, context=rows_context, union_arm=union_arm, union_forms=union_forms, diff --git a/flopy4/mf6/gwf/evt.py b/flopy4/mf6/gwf/evt.py index e9b8c89f..0ea42719 100644 --- a/flopy4/mf6/gwf/evt.py +++ b/flopy4/mf6/gwf/evt.py @@ -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, option="surf_rate_specified", time_series=True, optional=True ) aux: tuple[Union[float, TimeSeriesRef], ...] = array( default=(), shape=("auxiliary",), time_series=True, optional=True diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py index 346667c2..0d650be3 100644 --- a/flopy4/mf6/item.py +++ b/flopy4/mf6/item.py @@ -127,6 +127,19 @@ def _has_boundname_field(cls: type) -> bool: return any(f.name == "boundname" for f in cast(type[Record], cls).fields()) +def option_columns(item_cls: "type[Item] | tuple[type[Item], ...]") -> dict[str, str]: + """An item class's columns a package option turns on, by name, to the + option (EVT's petm0, by surf_rate_specified). MF6 reads one only if its + option is set, and then always.""" + 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("option")) + } + + @lru_cache(maxsize=None) def _union_arms( cls: type, type_str: str @@ -374,11 +387,14 @@ 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. ``options`` are the options + that turn columns on (see `option_columns`) the package has set; None + if not known.""" ncelldim: int | Mapping[str, int] | None = None sizes: Mapping[str, int] = attrs.field(factory=dict) boundnames: bool = False + options: frozenset[str] | None = None def cellid_width(self, f: attrs.Attribute) -> int | None: if isinstance(self.ncelldim, Mapping): @@ -482,8 +498,10 @@ 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 column an option turns on (EVT's petm0) + is there if the package's ``options`` say, as MF6 reads it; for Python + values, or if they aren't known, if the values make room for it (the + package then sets the option). 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. @@ -491,6 +509,13 @@ def _layout( n = len(values) slots = _slots(cls, file) has_boundname = _has_boundname_field(cls) + gated = option_columns(cls) + if context.options 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.options) + 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: @@ -504,13 +529,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 options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict, by_option: bool): """Each way the slot can take values from ``pos``: the position - after it, what it took, and the unknown widths it assumed.""" + after it, what it took, and the unknown widths it assumed. A column + an option turns on is there just if it's on, ``by_option``.""" f = slot.field left = end - pos v = values[pos] if left else None optional = f is not None and f.metadata.get("optional") + if by_option and f is not None: + if f.name in off: + yield pos, None, unknown + return + optional = optional and f.name not in on if slot.kind == "keyword": if left and str(v).upper() == cast(type[Record], cls).keyword().upper(): yield pos + 1, None, unknown @@ -587,7 +618,7 @@ def options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict): if cast(type[Item], cls)._columns and s.kind == "value" and s.field.metadata.get("optional") ] - 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, by_option: bool): if k == len(slots): if len({name in out for name in together}) > 1: return @@ -595,16 +626,16 @@ def walk(k: int, pos: int, end: int, out: dict, unknown: dict, surplus: bool): yield out, unknown return slot = slots[k] - for pos2, took, unknown2 in options(slot, pos, end, out, unknown): + for pos2, took, unknown2 in options(slot, pos, end, out, unknown, by_option): 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, by_option) - def solve(boundname: list[bool], surplus: bool = False): + def solve(boundname: list[bool], surplus: bool = False, by_option: 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, by_option): yield ({**out, "boundname": ("bn", values[-1])} if bn else out), unknown fields = attrs.fields_dict(cls) # type: ignore[arg-type] @@ -636,8 +667,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, by_option=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], by_option=False)) if out is None and file: out = first(solve(bn, surplus=True)) if out is None: diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index 584e285a..aa498778 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -22,6 +22,7 @@ inline_dim, item_list_type, item_list_words, + option_columns, package_sized_fields, sized_by, union_value, @@ -365,7 +366,8 @@ 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 options that turn columns on (the + boundnames, EVT's surf_rate_specified). 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] @@ -388,8 +390,12 @@ def lookup(name: str) -> Any: ncelldim = self.resolve_dims("ncelldim").get("ncelldim") except ValueError: pass + options = frozenset(o for o in option_columns(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)), + options=options, ) def _coerce_item_list( @@ -416,7 +422,8 @@ def _coerce_item_list( single arm to build columns from). A row with a boundname sets the package's boundnames option, which - MF6 needs to read it. + MF6 needs to read it; one with a column an option turns on (EVT's + petm0) sets that option. """ context = self._row_context(item_cls) if isinstance(item_cls, tuple): @@ -439,9 +446,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 option_columns(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: diff --git a/flopy4/mf6/spec.py b/flopy4/mf6/spec.py index 37ba9aaa..5960510d 100644 --- a/flopy4/mf6/spec.py +++ b/flopy4/mf6/spec.py @@ -87,6 +87,7 @@ class FieldKwargs(TypedDict, total=False): optional: bool netcdf: bool tagged: bool + option: str | None def _pipe(*hooks): @@ -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", "option"): 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, ())}) diff --git a/flopy4/mf6/utils/codegen/filters.py b/flopy4/mf6/utils/codegen/filters.py index ed011ba4..8d42e2cd 100644 --- a/flopy4/mf6/utils/codegen/filters.py +++ b/flopy4/mf6/utils/codegen/filters.py @@ -920,14 +920,16 @@ def value_column( *, optional: bool = False, time_series: bool = False, + option: str | None = None, ) -> ItemColumn: - """A plain scalar. A time series value may also be a series reference.""" + """A plain scalar. A time series value may also be a series reference. + One a package ``option`` turns on is read only if it's set.""" py_type = "Union[float, TimeSeriesRef]" if time_series else _DFN_PY.get(dfn_type, "float") return attr_column( name, py_type, _DFN_FN.get(dfn_type, "double"), - {}, + {"option": option} if option else {}, optional=optional, time_series=time_series, ) diff --git a/flopy4/mf6/utils/codegen/make.py b/flopy4/mf6/utils/codegen/make.py index acdd5568..e0348b9d 100644 --- a/flopy4/mf6/utils/codegen/make.py +++ b/flopy4/mf6/utils/codegen/make.py @@ -202,6 +202,11 @@ class ComponentSpec: # vs fill_forward= metadata) differs between them. +# Columns a package option turns on: MF6 reads one only if the option is +# set. The DFNs say so only in the column's description. +_OPTION_COLUMNS = {"petm0": "surf_rate_specified"} # gwf-evt + + def _item_columns( columns: list[ColumnSpec], unions: "Mapping[str, tuple[list[str], list[str]]] | None" = None, @@ -308,6 +313,7 @@ def _item_columns( dfn_type, optional=optional, time_series=ts, + option=_OPTION_COLUMNS.get(name), ) items.append(item) return items diff --git a/test/mf6/test_mf6_codec.py b/test/mf6/test_mf6_codec.py index 99d44b72..f20f3d8e 100644 --- a/test/mf6/test_mf6_codec.py +++ b/test/mf6/test_mf6_codec.py @@ -1857,16 +1857,77 @@ def test_row_boundname_by_option(boundnames): assert row.boundname == ("5" if boundnames else None) -@pytest.mark.parametrize("boundnames", [True, False]) -def test_row_optional_column_or_boundname(boundnames): - """EVT's petm0 is there if the row makes room for it, after the - boundname, which the option places.""" +@pytest.mark.parametrize( + "boundnames, surf_rate, tokens, petm0, boundname", + [ + # a row may leave out its boundname, but not petm0 + (True, True, [0.5], 0.5, None), + (True, True, [0.5, "w"], 0.5, "w"), + (True, False, [0.5], None, "0.5"), + (False, True, [0.5, "w"], 0.5, None), # MF6 ignores the extra token + (False, False, ["w"], None, None), + ], +) +def test_row_option_column_or_boundname(boundnames, surf_rate, tokens, petm0, boundname): + """EVT's petm0 is there if, and only if, SURF_RATE_SPECIFIED is set, + and the boundname if BOUNDNAMES is, as MF6 reads them, whatever the + tokens look like.""" from flopy4.mf6.gwf import Evt from flopy4.mf6.item import RowContext - context = RowContext(ncelldim=3, sizes={"nseg-1": 0, "auxiliary": 0}, boundnames=boundnames) - row = Evt.StressPeriodData.from_tokens([1, 1, 1, 10.0, 0.001, 2.0, 0.5], context) - assert (row.petm0, row.boundname) == ((None, "0.5") if boundnames else (0.5, None)) + context = RowContext( + ncelldim=3, + sizes={"nseg-1": 0, "auxiliary": 0}, + boundnames=boundnames, + options=frozenset({"surf_rate_specified"} if surf_rate else ()), + ) + row = Evt.StressPeriodData.from_tokens([1, 1, 1, 10.0, 0.001, 2.0, *tokens], context) + assert (row.petm0, row.boundname) == (petm0, boundname) + + +def test_row_option_column_sets_option(): + """A Python row with EVT's petm0 sets SURF_RATE_SPECIFIED, which MF6 + needs to read it; then a row's last value is its boundname.""" + from flopy4.mf6.gwf import Evt + + evt = Evt(stress_period_data={0: [((0, 0, 0), 10.0, 0.001, 2.0, 0.5)]}) + assert evt.stress_period_data[0][0].petm0 == 0.5 + assert evt.surf_rate_specified + assert not Evt(stress_period_data={0: [((0, 0, 0), 10.0, 0.001, 2.0)]}).surf_rate_specified + + evt = Evt( + boundnames=True, + surf_rate_specified=True, + stress_period_data={0: [((0, 0, 0), 10.0, 0.001, 2.0, 0.5, 7)]}, + ) + row = evt.stress_period_data[0][0] + assert (row.petm0, row.boundname) == (0.5, "7") + + +def test_evt_load_petm0_without_boundname(): + """With both options set, a file row leaving out its boundname keeps + its petm0.""" + from flopy4.mf6.converter.ingress.structure import structure_component + from flopy4.mf6.gwf import Evt + + text = """BEGIN OPTIONS + BOUNDNAMES + SURF_RATE_SPECIFIED +END OPTIONS + +BEGIN DIMENSIONS + MAXBOUND 2 + NSEG 1 +END DIMENSIONS + +BEGIN PERIOD 1 + 1 1 1 10.0 0.001 2.0 0.5 + 1 1 2 10.0 0.001 2.0 0.25 well +END PERIOD 1 +""" + evt = structure_component(loads(text), Evt) + rows = evt.stress_period_data[0] + assert [(r.petm0, r.boundname) for r in rows] == [(0.5, None), (0.25, "well")] @pytest.mark.parametrize("boundname", [None, "b"]) From 74934dcb30043f436f3036fc16540123952c4269 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 14 Oct 2026 20:38:58 -0700 Subject: [PATCH 2/8] warn when an observation names a boundname that reads as a number MF6 tries an observation's id as a cell before a boundname, so an id naming boundname 5 is node 5 on a DISU grid, or an error on another. Writing a package now warns if one of its observations does. Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/package.py | 37 ++++++++++++++++++++++++++++ test/mf6/test_mf6_io_roundtrip.py | 41 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index aa498778..4e64f3ea 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -1,4 +1,6 @@ import operator +import re +import warnings from abc import ABC from pathlib import Path from typing import Any, Optional @@ -61,6 +63,14 @@ } +_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 (Fortran's 1.5d0 too).""" + 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( @@ -525,6 +535,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) @@ -564,6 +575,32 @@ 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 that reads as a number, + which MF6 may read as a cell instead: on a grid whose cellids are + one number (DISU), another cell; on another grid, an error.""" + 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.""" diff --git a/test/mf6/test_mf6_io_roundtrip.py b/test/mf6/test_mf6_io_roundtrip.py index ebb31621..bbe1d258 100644 --- a/test/mf6/test_mf6_io_roundtrip.py +++ b/test/mf6/test_mf6_io_roundtrip.py @@ -5,6 +5,7 @@ """ import math +import warnings from collections.abc import Mapping from pathlib import Path @@ -433,3 +434,43 @@ def test_quoted_multiword_boundname_and_obsname_round_trips(tmp_path): assert rghb.stress_period_data[0][0].boundname == "my bound" robs = rghb.obs.continuous[0].continuous[0] assert (robs.obsname, robs.id_) == ("my obs", "my bound") + + +@pytest.mark.parametrize( + "boundname, warns", + [("5", True), ("1.50", True), ("1d0", True), ("b5", False), ("inf", False)], +) +def test_obs_id_naming_numeric_boundname_warns(tmp_path, boundname, warns): + """MF6 may read an observation's id that looks like a number as a + cell, not a boundname, so writing one warns.""" + sim = Simulation( + tdis=Time(perlen=[1.0], nstp=[1], tsmult=[1.0], time_units="DAYS"), + workspace=tmp_path, + name="sim1", + solutiongroup=[Ims(filename="gwf.ims", models=["gwf1"])], + ) + dis = Dis(nlay=1, nrow=1, ncol=2, delr=1.0, delc=1.0, top=1.0, botm=0.0) + gwf = Gwf(parent=sim, dis=dis, name="gwf1", save_flows=True) + Ic(parent=gwf, strt=0.0) + Npf(parent=gwf, icelltype=0, k=1.0) + ghb = Ghb( + parent=gwf, + boundnames=True, + stress_period_data={0: [[(0, 0, 0), 1.0, 10.0, boundname]]}, + name="GHB-1", + ) + Obs( + parent=ghb, + continuous=[ + Obs.ContinuousBlock( + output=Obs.Output(obs_output_file_name="gwf1.ghb.obs.csv"), + continuous=[Obs.Continuous(obsname="o", obstype="GHB", id_=boundname)], + ) + ], + name="obs", + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + sim.write() + messages = [str(w.message) for w in caught if "MF6 may read as a cell" in str(w.message)] + assert len(messages) == warns From b17f74f26024567bec33c22258112afd0a47a07d Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 15 Oct 2026 06:45:56 -0700 Subject: [PATCH 3/8] bump modflow-devtools for the when attribute Pins MODFLOW-ORG/modflow-devtools#395 (9a1662ef), which adds the when attribute codegen will read in place of the _OPTION_COLUMNS table. Co-Authored-By: Claude Opus 5.5 --- pixi.lock | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/pixi.lock b/pixi.lock index 5a3e363d..b24acfdb 100644 --- a/pixi.lock +++ b/pixi.lock @@ -106,7 +106,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/70/746ab7140121dcf04bade9ab7d2e65b6d5eb5b23bb7dc04b7ad22742ca0f/xugrid-0.15.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/09/4e/23e0009b6d5d415c223ea38fcf1e38dc9b0fc6d3ff15b5c5090dcc1f6d88/sparse-0.19.2-py2.py3-none-any.whl @@ -255,7 +255,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/07/70/746ab7140121dcf04bade9ab7d2e65b6d5eb5b23bb7dc04b7ad22742ca0f/xugrid-0.15.3-py3-none-any.whl @@ -404,7 +404,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/70/746ab7140121dcf04bade9ab7d2e65b6d5eb5b23bb7dc04b7ad22742ca0f/xugrid-0.15.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/09/4e/23e0009b6d5d415c223ea38fcf1e38dc9b0fc6d3ff15b5c5090dcc1f6d88/sparse-0.19.2-py2.py3-none-any.whl @@ -547,7 +547,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/07/70/746ab7140121dcf04bade9ab7d2e65b6d5eb5b23bb7dc04b7ad22742ca0f/xugrid-0.15.3-py3-none-any.whl @@ -709,7 +709,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-9_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -987,7 +987,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -1263,7 +1263,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -1533,7 +1533,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -1822,7 +1822,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-9_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -2089,7 +2089,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -2357,7 +2357,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -2618,7 +2618,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -2898,7 +2898,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-9_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -3143,7 +3143,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -3389,7 +3389,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz @@ -3629,7 +3629,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -3887,7 +3887,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -4129,7 +4129,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -4372,7 +4372,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl @@ -4609,7 +4609,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl @@ -4863,7 +4863,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -5107,7 +5107,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl @@ -5352,7 +5352,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -5591,7 +5591,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f - pypi: https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -10852,7 +10852,7 @@ packages: - build ; extra == 'build' - twine ; extra == 'build' requires_python: '>=3.11,<3.14' -- pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#873c5cac2b964841b43def94da867ce36bb9cd1b +- pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f name: modflow-devtools version: 1.10.0.dev1 requires_dist: From 19bd858866e4f9593744d160e45c7c6b6cba19bd Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 15 Oct 2026 06:56:01 -0700 Subject: [PATCH 4/8] read option-gated columns from the DFN's when attribute Drops codegen's _OPTION_COLUMNS table for the when attribute modflow-devtools now migrates (EVT's petm0, by surf_rate_specified). Regenerating gives no diff. Boundname keeps its own handling for now. Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/utils/codegen/make.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/flopy4/mf6/utils/codegen/make.py b/flopy4/mf6/utils/codegen/make.py index e0348b9d..b394c4b9 100644 --- a/flopy4/mf6/utils/codegen/make.py +++ b/flopy4/mf6/utils/codegen/make.py @@ -202,11 +202,6 @@ class ComponentSpec: # vs fill_forward= metadata) differs between them. -# Columns a package option turns on: MF6 reads one only if the option is -# set. The DFNs say so only in the column's description. -_OPTION_COLUMNS = {"petm0": "surf_rate_specified"} # gwf-evt - - def _item_columns( columns: list[ColumnSpec], unions: "Mapping[str, tuple[list[str], list[str]]] | None" = None, @@ -248,7 +243,11 @@ def _item_columns( f = col.field name = col.name dfn_type = _dfn_type_str(f) - optional = bool(f.optional) or name in counts + # A column an option turns on (the DFN's `when`, e.g. EVT's petm0) may + # be absent, so it's optional here; the runtime requires it when the + # option is on (see item._layout). + when = getattr(f, "when", None) + optional = bool(f.optional) or bool(when) or name in counts ts = bool(getattr(f, "time_series", False)) if isinstance(f, File): if len(pending_prefix) > 1: @@ -313,8 +312,11 @@ def _item_columns( dfn_type, optional=optional, time_series=ts, - option=_OPTION_COLUMNS.get(name), + option=when, ) + when = None + if when and name != "boundname": + raise NotImplementedError(f"column {name!r}: when={when!r} on a non-scalar column") items.append(item) return items From aa39b765815e1246455f515ccb8380d783ad7525 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 15 Oct 2026 09:02:48 -0700 Subject: [PATCH 5/8] keep a field's optional and when as the DFN gives them Renames the option field metadata to when, after the DFN attribute, and stores optional unchanged: EVT's petm0 is now when=surf_rate_specified, not optional. Whether a column may be missing from a row is optional or when, worked out in one place (item._may_be_absent), and codegen gives it a None default on the same test. A column whose option is on is required unless it's optional, rather than always, so boundname can carry when=boundnames later and still be left out. Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/gwf/evt.py | 2 +- flopy4/mf6/item.py | 31 +++++++++++++++++------------ flopy4/mf6/spec.py | 4 ++-- flopy4/mf6/utils/codegen/filters.py | 22 ++++++++++---------- flopy4/mf6/utils/codegen/make.py | 7 ++----- 5 files changed, 34 insertions(+), 32 deletions(-) diff --git a/flopy4/mf6/gwf/evt.py b/flopy4/mf6/gwf/evt.py index 0ea42719..00dc53f9 100644 --- a/flopy4/mf6/gwf/evt.py +++ b/flopy4/mf6/gwf/evt.py @@ -36,7 +36,7 @@ class StressPeriodData(Item): default=(), shape=("nseg-1",), time_series=True, optional=True ) petm0: Optional[Union[float, TimeSeriesRef]] = double( - default=None, option="surf_rate_specified", 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 diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py index 0d650be3..10070cca 100644 --- a/flopy4/mf6/item.py +++ b/flopy4/mf6/item.py @@ -128,18 +128,24 @@ def _has_boundname_field(cls: type) -> bool: def option_columns(item_cls: "type[Item] | tuple[type[Item], ...]") -> dict[str, str]: - """An item class's columns a package option turns on, by name, to the - option (EVT's petm0, by surf_rate_specified). MF6 reads one only if its - option is set, and then always.""" + """An item class's columns a package option turns on (the DFN's + ``when``), by name, to the option (EVT's petm0, by surf_rate_specified). + MF6 reads one only if its option is set.""" 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("option")) + if (option := f.metadata.get("when")) } +def _may_be_absent(f: attrs.Attribute) -> bool: + """Whether a column may be missing from a row: it's optional, or an + option turns it on (and the option may be off).""" + return bool(f.metadata.get("optional") or f.metadata.get("when")) + + @lru_cache(maxsize=None) def _union_arms( cls: type, type_str: str @@ -329,7 +335,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],) @@ -536,12 +542,13 @@ def options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict, by_option 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 by_option and f is not None: if f.name in off: yield pos, None, unknown return - optional = optional and f.name not in on + 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 @@ -558,7 +565,7 @@ def options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict, by_option 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 @@ -615,7 +622,7 @@ def options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict, by_option 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, by_option: bool): @@ -643,9 +650,7 @@ def solve(boundname: list[bool], surplus: bool = False, by_option: bool = True): 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": @@ -817,7 +822,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): diff --git a/flopy4/mf6/spec.py b/flopy4/mf6/spec.py index 5960510d..10c74f98 100644 --- a/flopy4/mf6/spec.py +++ b/flopy4/mf6/spec.py @@ -87,7 +87,7 @@ class FieldKwargs(TypedDict, total=False): optional: bool netcdf: bool tagged: bool - option: str | None + when: str | None def _pipe(*hooks): @@ -100,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", "option"): + 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, ())}) diff --git a/flopy4/mf6/utils/codegen/filters.py b/flopy4/mf6/utils/codegen/filters.py index 8d42e2cd..81c2ed71 100644 --- a/flopy4/mf6/utils/codegen/filters.py +++ b/flopy4/mf6/utils/codegen/filters.py @@ -871,24 +871,23 @@ def attr_column( *, optional: bool, time_series: bool = False, + when: str | None = None, default: str = "None", args: tuple[str, ...] = (), **kwargs, ) -> ItemColumn: """The generic rendering: ``name: T = fn(*args, meta)``, ``fn`` the field - function for the DFN type (see `flopy4.mf6.spec`), or, when optional, - ``Optional[T]`` with a ``None`` default (or ``T`` with ``default``).""" + function for the DFN type (see `flopy4.mf6.spec`), or, when it may be + absent (optional, or turned on by the option ``when``), ``Optional[T]`` + with a ``None`` default (or ``T`` with ``default``).""" meta = dict(meta) if time_series: meta["time_series"] = True if optional: - # Needed even for time_series fields: _n_fixed_tokens() (item.py) - # uses this to tell "always present" fixed columns apart from - # trailing columns that may be entirely absent from a given row - # (e.g. EVT's pxdp/petm/petm0, only written when - # surf_rate_specified) when inferring a variable-width cellid's - # element count from raw token counts. meta["optional"] = True + if when: + meta["when"] = when + optional = optional or bool(when) positional = [_dq(a) for a in args] if optional: annotation = py_type if default != "None" else f"Optional[{py_type}]" @@ -920,18 +919,19 @@ def value_column( *, optional: bool = False, time_series: bool = False, - option: str | None = None, + when: str | None = None, ) -> ItemColumn: """A plain scalar. A time series value may also be a series reference. - One a package ``option`` turns on is read only if it's set.""" + One a package option turns on (``when``) is read only if it's set.""" py_type = "Union[float, TimeSeriesRef]" if time_series else _DFN_PY.get(dfn_type, "float") return attr_column( name, py_type, _DFN_FN.get(dfn_type, "double"), - {"option": option} if option else {}, + {}, optional=optional, time_series=time_series, + when=when, ) diff --git a/flopy4/mf6/utils/codegen/make.py b/flopy4/mf6/utils/codegen/make.py index b394c4b9..507a6268 100644 --- a/flopy4/mf6/utils/codegen/make.py +++ b/flopy4/mf6/utils/codegen/make.py @@ -243,11 +243,8 @@ def _item_columns( f = col.field name = col.name dfn_type = _dfn_type_str(f) - # A column an option turns on (the DFN's `when`, e.g. EVT's petm0) may - # be absent, so it's optional here; the runtime requires it when the - # option is on (see item._layout). when = getattr(f, "when", None) - optional = bool(f.optional) or bool(when) or name in counts + optional = bool(f.optional) or name in counts ts = bool(getattr(f, "time_series", False)) if isinstance(f, File): if len(pending_prefix) > 1: @@ -312,7 +309,7 @@ def _item_columns( dfn_type, optional=optional, time_series=ts, - option=when, + when=when, ) when = None if when and name != "boundname": From 24ae576ff3dc669909e6bb1d31f8322d3d33c48f Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 15 Oct 2026 09:25:09 -0700 Subject: [PATCH 6/8] rename option columns to conditional fields The DFN's when isn't only for list columns, and "options" meant three things: option_columns is now conditional_fields, RowContext.options (the options that are on) is RowContext.enabled, _layout's inner options() (the ways a slot can take values) is fits(), and by_option is conditional. Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/converter/ingress/structure.py | 18 ++++----- flopy4/mf6/item.py | 46 ++++++++++++----------- flopy4/mf6/package.py | 12 +++--- test/mf6/test_mf6_codec.py | 6 +-- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index 6d8d8d06..8983d62b 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -18,11 +18,11 @@ from flopy4.mf6.item import ( Item, RowContext, + conditional_fields, dim_lookup, item_list_type, item_list_words, ncelldim_from_dims, - option_columns, parse_union_items, sized_by, ) @@ -56,7 +56,7 @@ def _parse_rows( context: LoadContext, sizes: "dict[str, int] | None" = None, boundnames: bool = False, - options: frozenset[str] = frozenset(), + 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, @@ -72,7 +72,7 @@ def _parse_rows( if ncelldim is None: ncelldim = ncelldim_from_dims(dict(context.dims)) row_context = RowContext( - ncelldim=ncelldim, sizes=sizes or {}, boundnames=boundnames, options=options + ncelldim=ncelldim, sizes=sizes or {}, boundnames=boundnames, enabled=enabled ) if isinstance(item_cls, tuple): return parse_union_items( @@ -1064,9 +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)) - # The options turning columns on (EVT's surf_rate_specified) that are set. - options = frozenset( - o for ic in item_types for o in option_columns(ic).values() if kwargs.get(o) + # The options turning fields on (EVT's surf_rate_specified) that are set. + 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). @@ -1099,7 +1099,7 @@ def load_child(f: Any, token: str) -> Any: item_cls, sizes=sizes, boundnames=boundnames, - options=options, + enabled=enabled, context=rows_context, union_arm=union_arm, union_forms=union_forms, @@ -1125,7 +1125,7 @@ def load_child(f: Any, token: str) -> Any: item_cls, sizes=sizes, boundnames=boundnames, - options=options, + enabled=enabled, context=rows_context, union_arm=union_arm, union_forms=union_forms, @@ -1162,7 +1162,7 @@ def load_child(f: Any, token: str) -> Any: period_item_cls, sizes=sizes, boundnames=boundnames, - options=options, + enabled=enabled, context=rows_context, union_arm=union_arm, union_forms=union_forms, diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py index 10070cca..2e3a76b8 100644 --- a/flopy4/mf6/item.py +++ b/flopy4/mf6/item.py @@ -127,8 +127,8 @@ def _has_boundname_field(cls: type) -> bool: return any(f.name == "boundname" for f in cast(type[Record], cls).fields()) -def option_columns(item_cls: "type[Item] | tuple[type[Item], ...]") -> dict[str, str]: - """An item class's columns a package option turns on (the DFN's +def conditional_fields(item_cls: "type[Item] | tuple[type[Item], ...]") -> dict[str, str]: + """An item class's fields a package option turns on (the DFN's ``when``), by name, to the option (EVT's petm0, by surf_rate_specified). MF6 reads one only if its option is set.""" classes = item_cls if isinstance(item_cls, tuple) else (item_cls,) @@ -393,14 +393,14 @@ 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. ``options`` are the options - that turn columns on (see `option_columns`) the package has set; None - if not known.""" + ``boundnames`` is the package's option. ``enabled`` are the options + that turn fields on (see `conditional_fields`) the package has set; + None if not known.""" ncelldim: int | Mapping[str, int] | None = None sizes: Mapping[str, int] = attrs.field(factory=dict) boundnames: bool = False - options: frozenset[str] | None = None + enabled: frozenset[str] | None = None def cellid_width(self, f: attrs.Attribute) -> int | None: if isinstance(self.ncelldim, Mapping): @@ -504,8 +504,8 @@ 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. A column an option turns on (EVT's petm0) - is there if the package's ``options`` say, as MF6 reads it; for Python + package then sets the option. A field an option turns on (EVT's petm0) + is there if the package's ``enabled`` options say, as MF6 reads it; for Python values, or if they aren't known, if the values make room for it (the package then sets the option). A width that isn't known (no grid yet) is the one the values make room for, if only one does; @@ -515,12 +515,12 @@ def _layout( n = len(values) slots = _slots(cls, file) has_boundname = _has_boundname_field(cls) - gated = option_columns(cls) - if context.options is None: + 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.options) + 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]]: @@ -535,15 +535,15 @@ 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, by_option: bool): + 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. A column - an option turns on is there just if it's on, ``by_option``.""" + after it, what it took, and the unknown widths it assumed. A field + an option turns on is there just if it's on, if ``conditional``.""" f = slot.field left = end - pos v = values[pos] if left else None optional = f is not None and _may_be_absent(f) - if by_option and f is not None: + if conditional and f is not None: if f.name in off: yield pos, None, unknown return @@ -625,7 +625,9 @@ def options(slot: _Slot, pos: int, end: int, out: dict, unknown: dict, by_option 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, by_option: 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 @@ -633,16 +635,16 @@ def walk(k: int, pos: int, end: int, out: dict, unknown: dict, surplus: bool, by yield out, unknown return slot = slots[k] - for pos2, took, unknown2 in options(slot, pos, end, out, unknown, by_option): + 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, by_option) + yield from walk(k + 1, pos2, end, out2, unknown2, surplus, conditional) - def solve(boundname: list[bool], surplus: bool = False, by_option: bool = True): + 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, by_option): + 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] @@ -673,9 +675,9 @@ 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, by_option=False)) + out = first(solve(bn, conditional=False)) if out is None and not file and has_boundname and not context.boundnames: - out = first(solve([True])) or first(solve([True], by_option=False)) + 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: diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index 4e64f3ea..682ea1d0 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -20,11 +20,11 @@ from flopy4.mf6.item import ( Item, RowContext, + conditional_fields, dim_lookup, inline_dim, item_list_type, item_list_words, - option_columns, package_sized_fields, sized_by, union_value, @@ -376,7 +376,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 its options that turn columns on (the + the package sizes, and its options that turn fields on (the boundnames, EVT's surf_rate_specified). 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.""" @@ -400,12 +400,12 @@ def lookup(name: str) -> Any: ncelldim = self.resolve_dims("ncelldim").get("ncelldim") except ValueError: pass - options = frozenset(o for o in option_columns(item_cls).values() if values.get(o)) + 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)), - options=options, + enabled=enabled, ) def _coerce_item_list( @@ -432,7 +432,7 @@ def _coerce_item_list( single arm to build columns from). A row with a boundname sets the package's boundnames option, which - MF6 needs to read it; one with a column an option turns on (EVT's + MF6 needs to read it; one with a field an option turns on (EVT's petm0) sets that option. """ context = self._row_context(item_cls) @@ -458,7 +458,7 @@ def _coerce_item_list( items.append(item_cls.from_values(row, context)) on = { option - for name, option in option_columns(item_cls).items() + 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): diff --git a/test/mf6/test_mf6_codec.py b/test/mf6/test_mf6_codec.py index f20f3d8e..8611fdd2 100644 --- a/test/mf6/test_mf6_codec.py +++ b/test/mf6/test_mf6_codec.py @@ -1868,7 +1868,7 @@ def test_row_boundname_by_option(boundnames): (False, False, ["w"], None, None), ], ) -def test_row_option_column_or_boundname(boundnames, surf_rate, tokens, petm0, boundname): +def test_row_conditional_field_or_boundname(boundnames, surf_rate, tokens, petm0, boundname): """EVT's petm0 is there if, and only if, SURF_RATE_SPECIFIED is set, and the boundname if BOUNDNAMES is, as MF6 reads them, whatever the tokens look like.""" @@ -1879,13 +1879,13 @@ def test_row_option_column_or_boundname(boundnames, surf_rate, tokens, petm0, bo ncelldim=3, sizes={"nseg-1": 0, "auxiliary": 0}, boundnames=boundnames, - options=frozenset({"surf_rate_specified"} if surf_rate else ()), + enabled=frozenset({"surf_rate_specified"} if surf_rate else ()), ) row = Evt.StressPeriodData.from_tokens([1, 1, 1, 10.0, 0.001, 2.0, *tokens], context) assert (row.petm0, row.boundname) == (petm0, boundname) -def test_row_option_column_sets_option(): +def test_row_conditional_field_sets_option(): """A Python row with EVT's petm0 sets SURF_RATE_SPECIFIED, which MF6 needs to read it; then a row's last value is its boundname.""" from flopy4.mf6.gwf import Evt From cb632e4fd30521c11ecf697a484253955d40e0a1 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 15 Oct 2026 09:30:46 -0700 Subject: [PATCH 7/8] trim comments and docstrings Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/converter/ingress/structure.py | 1 - flopy4/mf6/item.py | 21 ++++++++------------- flopy4/mf6/package.py | 12 ++++-------- flopy4/mf6/utils/codegen/filters.py | 9 ++++----- test/mf6/test_mf6_codec.py | 5 ++--- 5 files changed, 18 insertions(+), 30 deletions(-) diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index 8983d62b..01cd70a8 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -1064,7 +1064,6 @@ 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)) - # The options turning fields on (EVT's surf_rate_specified) that are set. enabled = frozenset( o for ic in item_types for o in conditional_fields(ic).values() if kwargs.get(o) ) diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py index 2e3a76b8..62978165 100644 --- a/flopy4/mf6/item.py +++ b/flopy4/mf6/item.py @@ -128,9 +128,8 @@ def _has_boundname_field(cls: type) -> bool: def conditional_fields(item_cls: "type[Item] | tuple[type[Item], ...]") -> dict[str, str]: - """An item class's fields a package option turns on (the DFN's - ``when``), by name, to the option (EVT's petm0, by surf_rate_specified). - MF6 reads one only if its option is set.""" + """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 @@ -141,8 +140,7 @@ def conditional_fields(item_cls: "type[Item] | tuple[type[Item], ...]") -> dict[ def _may_be_absent(f: attrs.Attribute) -> bool: - """Whether a column may be missing from a row: it's optional, or an - option turns it on (and the option may be off).""" + """Whether a field may be missing from a row.""" return bool(f.metadata.get("optional") or f.metadata.get("when")) @@ -393,9 +391,8 @@ 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. ``enabled`` are the options - that turn fields on (see `conditional_fields`) the package has set; - None if not known.""" + ``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) @@ -505,9 +502,8 @@ def _layout( (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. A field an option turns on (EVT's petm0) - is there if the package's ``enabled`` options say, as MF6 reads it; for Python - values, or if they aren't known, if the values make room for it (the - package then sets the option). A width that isn't known (no + 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. @@ -537,8 +533,7 @@ def flat(span: list) -> bool: 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. A field - an option turns on is there just if it's on, if ``conditional``.""" + after it, what it took, and the unknown widths it assumed.""" f = slot.field left = end - pos v = values[pos] if left else None diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index 682ea1d0..f96d61e5 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -376,8 +376,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 its options that turn fields on (the - boundnames, EVT's surf_rate_specified). 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] @@ -431,9 +430,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; one with a field an option turns on (EVT's - petm0) sets that option. + 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): @@ -576,9 +574,7 @@ def _check_ts_refs(self) -> None: ) def _check_obs_ids(self) -> None: - """Warn if an observation names a boundname that reads as a number, - which MF6 may read as a cell instead: on a grid whose cellids are - one number (DISU), another cell; on another grid, an error.""" + """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: diff --git a/flopy4/mf6/utils/codegen/filters.py b/flopy4/mf6/utils/codegen/filters.py index 81c2ed71..55297994 100644 --- a/flopy4/mf6/utils/codegen/filters.py +++ b/flopy4/mf6/utils/codegen/filters.py @@ -877,9 +877,9 @@ def attr_column( **kwargs, ) -> ItemColumn: """The generic rendering: ``name: T = fn(*args, meta)``, ``fn`` the field - function for the DFN type (see `flopy4.mf6.spec`), or, when it may be - absent (optional, or turned on by the option ``when``), ``Optional[T]`` - with a ``None`` default (or ``T`` with ``default``).""" + function for the DFN type (see `flopy4.mf6.spec`), or, when optional + or ``when`` an option is set, ``Optional[T]`` with a ``None`` default + (or ``T`` with ``default``).""" meta = dict(meta) if time_series: meta["time_series"] = True @@ -921,8 +921,7 @@ def value_column( time_series: bool = False, when: str | None = None, ) -> ItemColumn: - """A plain scalar. A time series value may also be a series reference. - One a package option turns on (``when``) is read only if it's set.""" + """A plain scalar. A time series value may also be a series reference.""" py_type = "Union[float, TimeSeriesRef]" if time_series else _DFN_PY.get(dfn_type, "float") return attr_column( name, diff --git a/test/mf6/test_mf6_codec.py b/test/mf6/test_mf6_codec.py index 8611fdd2..cb486cce 100644 --- a/test/mf6/test_mf6_codec.py +++ b/test/mf6/test_mf6_codec.py @@ -1869,9 +1869,8 @@ def test_row_boundname_by_option(boundnames): ], ) def test_row_conditional_field_or_boundname(boundnames, surf_rate, tokens, petm0, boundname): - """EVT's petm0 is there if, and only if, SURF_RATE_SPECIFIED is set, - and the boundname if BOUNDNAMES is, as MF6 reads them, whatever the - tokens look like.""" + """EVT's petm0 is there iff SURF_RATE_SPECIFIED is set, and the + boundname if BOUNDNAMES is.""" from flopy4.mf6.gwf import Evt from flopy4.mf6.item import RowContext From bcaf0ce23a8bf8d056e5d5079e7cd46396bc4874 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 15 Oct 2026 09:36:03 -0700 Subject: [PATCH 8/8] clarify the D exponent in _reads_as_number Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/package.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index f96d61e5..f6031d52 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -67,7 +67,8 @@ def _reads_as_number(text: str) -> bool: - """Whether MF6 could read a word as a number (Fortran's 1.5d0 too).""" + """Whether MF6 could read a word as a number, with an E or a Fortran D + exponent.""" return _NUMBER.fullmatch(text) is not None