From 4716027a6b0401f16dffc38a24c0029e8f6355c5 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 21 Oct 2026 03:27:03 -0700 Subject: [PATCH 1/4] feat(dfns): mark model-name keys in sim-nam Mark sim-nam's models.mname as pk, and exchanges' exgmnamea/exgmnameb and solutiongroup's slnmnames as fk "models.mname". MF6 rejects duplicate model names and fails on an exchange or solution naming an unknown model. slnmnames is a string array, so allow fk on string arrays, each entry a key. An array's fk must now be a hierarchical path, as the spec already said. utl-hpc's partitions.mname is left unannotated for now: it leads its row, so a hierarchical fk would make it a dynamic keyword. Co-Authored-By: Claude Opus 5.5 --- .../__snapshots__/v2.0.0.dev2/sim-nam.json | 12 ++- .../__snapshots__/v2.0.0.dev2/sim-nam.toml | 4 + .../__snapshots__/v2.0.0.dev2/sim-nam.yaml | 4 + .../__snapshots__/v2.0.0.dev3/sim-nam.json | 12 ++- .../__snapshots__/v2.0.0.dev3/sim-nam.toml | 4 + .../__snapshots__/v2.0.0.dev3/sim-nam.yaml | 4 + autotest/dfns/test_migrate.py | 17 +++- autotest/dfns/test_schema_relations.py | 39 +++++++++ docs/md/dfnspec.md | 4 +- .../dfns/migrate_to_v2_0_0_dev2.py | 80 +++++++++++++++++++ modflow_devtools/dfns/schema.py | 35 +++++--- 11 files changed, 194 insertions(+), 21 deletions(-) diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.json index 2b5a496e..7daa016c 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.json @@ -107,7 +107,8 @@ "type": "string", "longname": "name of model", "description": "is the user-assigned name of the model. The model name cannot exceed 16 characters and must not have blanks within the name. The model name is case insensitive; any lowercase letters are converted and stored as upper case letters.", - "tagged": false + "tagged": false, + "pk": true } } } @@ -146,13 +147,15 @@ "type": "string", "longname": "name of model A", "description": "is the name of the first model that is part of this exchange.", - "tagged": false + "tagged": false, + "fk": "models.mname" }, "exgmnameb": { "type": "string", "longname": "name of model B", "description": "is the name of the second model that is part of this exchange.", - "tagged": false + "tagged": false, + "fk": "models.mname" } } } @@ -201,7 +204,8 @@ "longname": "array of model names in this solution", "description": "is the array of model names to add to this solution. The number of model names is determined by the number of model names the user provides on this line.", "tagged": false, - "dtype": "string" + "dtype": "string", + "fk": "models.mname" } } } diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.toml index dc29485b..b85b21c7 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.toml @@ -96,6 +96,7 @@ type = "string" longname = "name of model" description = "is the user-assigned name of the model. The model name cannot exceed 16 characters and must not have blanks within the name. The model name is case insensitive; any lowercase letters are converted and stored as upper case letters." tagged = false +pk = true [blocks.exchanges.fields.exchanges] type = "list" @@ -129,12 +130,14 @@ type = "string" longname = "name of model A" description = "is the name of the first model that is part of this exchange." tagged = false +fk = "models.mname" [blocks.exchanges.fields.exchanges.item.fields.exgmnameb] type = "string" longname = "name of model B" description = "is the name of the second model that is part of this exchange." tagged = false +fk = "models.mname" [blocks.solutiongroup.fields.mxiter] type = "integer" @@ -178,6 +181,7 @@ longname = "array of model names in this solution" description = "is the array of model names to add to this solution. The number of model names is determined by the number of model names the user provides on this line." tagged = false dtype = "string" +fk = "models.mname" [blocks.solutiongroup.header.field] name = "group_num" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.yaml index b1019f8e..256d0f6c 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/sim-nam.yaml @@ -104,6 +104,7 @@ blocks: and must not have blanks within the name. The model name is case insensitive; any lowercase letters are converted and stored as upper case letters. tagged: false + pk: true exchanges: fields: exchanges: @@ -135,11 +136,13 @@ blocks: longname: name of model A description: is the name of the first model that is part of this exchange. tagged: false + fk: models.mname exgmnameb: type: string longname: name of model B description: is the name of the second model that is part of this exchange. tagged: false + fk: models.mname solutiongroup: fields: mxiter: @@ -182,6 +185,7 @@ blocks: is determined by the number of model names the user provides on this line. tagged: false dtype: string + fk: models.mname header: field: name: group_num diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.json index 4b093599..b6a28aae 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.json @@ -107,7 +107,8 @@ "type": "string", "longname": "name of model", "description": "is the user-assigned name of the model. The model name cannot exceed 16 characters and must not have blanks within the name. The model name is case insensitive; any lowercase letters are converted and stored as upper case letters.", - "tagged": false + "tagged": false, + "pk": true } } } @@ -146,13 +147,15 @@ "type": "string", "longname": "name of model A", "description": "is the name of the first model that is part of this exchange.", - "tagged": false + "tagged": false, + "fk": "models.mname" }, "exgmnameb": { "type": "string", "longname": "name of model B", "description": "is the name of the second model that is part of this exchange.", - "tagged": false + "tagged": false, + "fk": "models.mname" } } } @@ -201,7 +204,8 @@ "longname": "array of model names in this solution", "description": "is the array of model names to add to this solution. The number of model names is determined by the number of model names the user provides on this line.", "tagged": false, - "dtype": "string" + "dtype": "string", + "fk": "models.mname" } } } diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.toml index 9e9c7b0d..6ca9b262 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.toml @@ -96,6 +96,7 @@ type = "string" longname = "name of model" description = "is the user-assigned name of the model. The model name cannot exceed 16 characters and must not have blanks within the name. The model name is case insensitive; any lowercase letters are converted and stored as upper case letters." tagged = false +pk = true [blocks.exchanges.fields.exchanges] type = "list" @@ -129,12 +130,14 @@ type = "string" longname = "name of model A" description = "is the name of the first model that is part of this exchange." tagged = false +fk = "models.mname" [blocks.exchanges.fields.exchanges.item.fields.exgmnameb] type = "string" longname = "name of model B" description = "is the name of the second model that is part of this exchange." tagged = false +fk = "models.mname" [blocks.solutiongroup.fields.mxiter] type = "integer" @@ -178,6 +181,7 @@ longname = "array of model names in this solution" description = "is the array of model names to add to this solution. The number of model names is determined by the number of model names the user provides on this line." tagged = false dtype = "string" +fk = "models.mname" [blocks.solutiongroup.header.field] name = "group_num" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.yaml index dc47c128..33d3aaef 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/sim-nam.yaml @@ -104,6 +104,7 @@ blocks: and must not have blanks within the name. The model name is case insensitive; any lowercase letters are converted and stored as upper case letters. tagged: false + pk: true exchanges: fields: exchanges: @@ -135,11 +136,13 @@ blocks: longname: name of model A description: is the name of the first model that is part of this exchange. tagged: false + fk: models.mname exgmnameb: type: string longname: name of model B description: is the name of the second model that is part of this exchange. tagged: false + fk: models.mname solutiongroup: fields: mxiter: @@ -182,6 +185,7 @@ blocks: is determined by the number of model names the user provides on this line. tagged: false dtype: string + fk: models.mname header: field: name: group_num diff --git a/autotest/dfns/test_migrate.py b/autotest/dfns/test_migrate.py index 437c407c..909b920f 100644 --- a/autotest/dfns/test_migrate.py +++ b/autotest/dfns/test_migrate.py @@ -9,7 +9,7 @@ from modflow_devtools.dfn.schema import Dfn from modflow_devtools.dfns import fetch_dfns, migrate from modflow_devtools.dfns import schema as v2 -from modflow_devtools.dfns.migrate_to_v2_0_0_dev2 import to_v2_0_0_dev2 +from modflow_devtools.dfns.migrate_to_v2_0_0_dev2 import _fix_sim_nam_relations, to_v2_0_0_dev2 from modflow_devtools.dfns.migrate_to_v2_0_0_dev3 import to_v2_0_0_dev3 FORMATS = ["yaml", "toml", "json"] @@ -333,6 +333,21 @@ def test_migrate_period_union_element(dfn_dir, name, setting): assert item.arms["rate"].fields["outletno"].fk == "outlets.outletno" +def test_migrate_sim_nam_model_keys(dfn_dir): + component = _migrate_dev3(dfn_dir, "sim-nam") + + def item(block): + return component.blocks[block].fields[block].item + + assert item("models").fields["mname"].pk + exchanges = item("exchanges") + assert exchanges.fields["exgmnamea"].fk == "models.mname" + assert exchanges.fields["exgmnameb"].fk == "models.mname" + assert item("solutiongroup").fields["slnmnames"].fk == "models.mname" + # unchanged by a second pass + assert _fix_sim_nam_relations("sim-nam", component.blocks) == component.blocks + + def test_migrate_auxiliary_stays_self_sizing(dfn_dir): component = _migrate_dev3(dfn_dir, "gwf-chd") assert component.blocks["options"].fields["auxiliary"].shape == [] diff --git a/autotest/dfns/test_schema_relations.py b/autotest/dfns/test_schema_relations.py index 1f8866f7..4474eee5 100644 --- a/autotest/dfns/test_schema_relations.py +++ b/autotest/dfns/test_schema_relations.py @@ -194,6 +194,45 @@ def test_dfns_validate_fk_in_union_item_scalar_arm(): Dfns(components=_union_ctx(arm)) +def _model_names_ctx(fk="models.mname", pk=True): + """A simulation whose solution rows each name several models, by key.""" + mname = String(name="mname", tagged=False, pk=pk) + models_item = Record(name="models", fields={"mtype": String(name="mtype"), "mname": mname}) + models = Block(name="models", fields={"models": List(name="models", item=models_item)}) + slnmnames = Array(name="slnmnames", dtype="string", tagged=False, fk=fk) + sln_item = Record( + name="sln", fields={"slntype": String(name="slntype"), "slnmnames": slnmnames} + ) + sln = Block(name="sln", fields={"sln": List(name="sln", item=sln_item)}) + return {"sim-nam": Simulation(name="sim-nam", blocks={"models": models, "sln": sln})} + + +def test_dfns_validate_string_array_fk(): + assert "sim-nam" in Dfns(components=_model_names_ctx()).components + + +def test_dfns_validate_string_array_fk_no_pk_rejected(): + with pytest.raises(ValueError, match="has no pk=True field"): + Dfns(components=_model_names_ctx(pk=False)) + + +def test_dfns_validate_string_array_fk_unknown_block_rejected(): + with pytest.raises(ValueError, match="is not a list block"): + Dfns(components=_model_names_ctx(fk="nosuchblock.mname")) + + +@pytest.mark.parametrize("dtype", ["integer", "string"]) +def test_array_fk_bare_block_rejected(dtype): + with pytest.raises(ValueError, match="must be a hierarchical path"): + Array(name="slnmnames", dtype=dtype, fk="models") + + +@pytest.mark.parametrize("dtype", ["double", "keyword"]) +def test_array_fk_requires_integer_or_string_dtype(dtype): + with pytest.raises(ValueError, match="fk requires dtype='integer' or 'string'"): + Array(name="slnmnames", dtype=dtype, fk="models.mname") + + def _cellid_ctx(field, name="gwf-gnc", parent="gwf-nam", dims=None): item = Record(name="item", fields={field.name: field}) lst = List(name="data", item=item) diff --git a/docs/md/dfnspec.md b/docs/md/dfnspec.md index 338c0b92..9cd8f62b 100644 --- a/docs/md/dfnspec.md +++ b/docs/md/dfnspec.md @@ -560,7 +560,7 @@ Dimensions are listed fastest-varying first, i.e. in the order elements are read ###### `fk` -`string | null (default: null)`. Marks the array's (nonzero) elements as a foreign key, each a reference to a row (by `pk`) in another list. On a grid array that's a per-grid-cell reference, rather than the per-list-row reference a scalar `fk` expresses (e.g. a grid-wide array giving each cell's cross-section id, referencing the cross-section package's `packagedata`). On an array column in a list item record, each row references several rows (e.g. SFR's `ic`, the reaches connected to a reach). Only valid when `dtype` is `"integer"`. Hierarchical path form only (`"[component.]block.field"`) — an array has no `fk_ref` counterpart, since it has no sibling record to carry a runtime component-selector field, and no `pk` counterpart, since it has no rows of its own to be a key of. See "Primary/foreign keys". +`string | null (default: null)`. Marks the array's (nonzero) elements as a foreign key, each a reference to a row (by `pk`) in another list. On a grid array that's a per-grid-cell reference, rather than the per-list-row reference a scalar `fk` expresses (e.g. a grid-wide array giving each cell's cross-section id, referencing the cross-section package's `packagedata`). On an array column in a list item record, each row references several rows (e.g. SFR's `ic`, the reaches connected to a reach). On a `"string"` array, each entry is a key: a row's `pk` value, or, as for a string scalar, an entry of a string array the path names. Only valid when `dtype` is `"integer"` or `"string"`. Hierarchical path form only (`"[component.]block.field"`) — an array has no `fk_ref` counterpart, since it has no sibling record to carry a runtime component-selector field, and no `pk` counterpart, since it has no rows of its own to be a key of. See "Primary/foreign keys". ###### `model`, `allow_zero` @@ -840,7 +840,7 @@ Only a dimension that every component in the model (or simulation) shares should Sometimes a column in one list identifies a row in another list. This can be conceptualized as a primary key (PK) / foreign key (FK) relation. Integers and strings may encode PK/FK semantics with attributes `pk`, `fk`, and `fk_ref`. A column referencing a grid cell instead of another list's row is a distinct concern, handled by the [`cellid`](#cellid) type rather than `pk`/`fk` — grid cells are resolved against the grid (DIS/DISV/DISU) the column refers to at runtime, not looked up via a `pk` column. -**Note**: `pk`/`fk_ref` are only valid on integer and string fields appearing as columns in a tabular (i.e. regular) list's record item type. `fk` is valid there too, and additionally on integer-`dtype` `Array` fields (see [Array](#array)) — there it references another list's row per grid cell rather than per list row, and only the hierarchical-path form applies (no `fk_ref` counterpart, since an array has no sibling record to carry a runtime component selector). +**Note**: `pk`/`fk_ref` are only valid on integer and string fields appearing as columns in a tabular (i.e. regular) list's record item type. `fk` is valid there too, and additionally on integer- and string-`dtype` `Array` fields (see [Array](#array)) — there each element references another list's row, per grid cell for a grid array, or several per row for an array column in a list item; a string array's entries are keys. Only the hierarchical-path form applies (no `fk_ref` counterpart, since an array has no sibling record to carry a runtime component selector). The `fk` attribute can take one of two forms: diff --git a/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py b/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py index 19bd380c..b76cd2cc 100644 --- a/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py +++ b/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py @@ -1579,6 +1579,85 @@ def _fix_mvr_relations(name: str, blocks: dict[str, v2.Block]) -> dict[str, v2.B return {**blocks, "packages": new_packages, "period": new_period} +def _list_item_field(blocks: dict[str, v2.Block], block_name: str, field_name: str): + """The field `field_name` of the item record of the list sharing + `block_name`'s name, or None if there's no such field.""" + block = blocks.get(block_name) + lst = block.fields.get(block_name) if block else None + item = lst.item if isinstance(lst, v2.List) else None + return item.fields.get(field_name) if isinstance(item, v2.Record) else None + + +def _set_list_item_attrs( + blocks: dict[str, v2.Block], block_name: str, attrs: dict[str, dict[str, Any]] +) -> dict[str, v2.Block]: + """ + Set attributes on fields of the item record of the list sharing + `block_name`'s name, e.g. `{"mname": {"pk": True}}`, leaving any that's + already set. The fields must exist (see `_list_item_field`). + """ + block = blocks[block_name] + lst = block.fields[block_name] + item_fields = dict(lst.item.fields) # type: ignore[union-attr] + for field_name, field_attrs in attrs.items(): + field = item_fields[field_name] + update = {k: v for k, v in field_attrs.items() if not getattr(field, k)} + if update: + item_fields[field_name] = field.model_copy(update=update) + new_item = lst.item.model_copy(update={"fields": item_fields}) # type: ignore[union-attr] + new_list = lst.model_copy(update={"item": new_item}) + new_block = block.model_copy(update={"fields": {**block.fields, block_name: new_list}}) + return {**blocks, block_name: new_block} + + +def _fix_sim_nam_relations(name: str, blocks: dict[str, v2.Block]) -> dict[str, v2.Block]: + """ + Patch `sim-nam`'s model-name pk/fk relations, which the general passes + can't reach on their own. + + - Marks `models.mname` as `pk`. MF6 rejects a duplicate model name + (`check_model_name` in `SimulationCreate.f90`), comparing names + upcased, i.e. case-insensitively like a `String` by default. It isn't + the leading column (`mtype` is), and nothing else repeats the name + `mname`, so neither `_resolve_relations` nor `_mark_lonely_pk` finds it. + - Marks `exchanges`' `exgmnamea`/`exgmnameb` and `solutiongroup`'s + `slnmnames` (a string array, each entry a model name) as + `fk: "models.mname"`, since MF6 looks each up among the model names + and fails on one not found. + + Does *not* annotate `utl-hpc`'s `partitions.mname`, though it names a + model too: it leads its row, and a leading untagged String with a + hierarchical-path `fk` is taken for a dynamic keyword (`_is_dynamic_key`), + which would make `partitions` a tagged list. Nor does it express which + model *types* are valid where (a GWF-GWT exchange's model A must be a GWF + model; IMS can't solve an explicit model): those are constraints on the + referenced row, not keys. + """ + if name != "sim-nam": + return blocks + + refs = { + "models": ("mname",), + "exchanges": ("exgmnamea", "exgmnameb"), + "solutiongroup": ("slnmnames",), + } + for block_name, field_names in refs.items(): + for field_name in field_names: + field = _list_item_field(blocks, block_name, field_name) + if not ( + isinstance(field, v2.String) + or (isinstance(field, v2.Array) and field.dtype == "string") + ): + return blocks + + blocks = _set_list_item_attrs(blocks, "models", {"mname": {"pk": True}}) + for block_name in ("exchanges", "solutiongroup"): + blocks = _set_list_item_attrs( + blocks, block_name, {n: {"fk": "models.mname"} for n in refs[block_name]} + ) + return blocks + + def _fix_ssm_sources_write_if_empty(name: str, blocks: dict[str, v2.Block]) -> dict[str, v2.Block]: """ Mark SSM's SOURCES block write_if_empty: MF6 requires the block's header @@ -2326,6 +2405,7 @@ def _lookup(rname: str) -> dict | None: blocks = _patch_oc_rtype(name, blocks) blocks = _fix_lak_relations(name, blocks) blocks = _fix_mvr_relations(name, blocks) + blocks = _fix_sim_nam_relations(name, blocks) blocks = _fix_ssm_sources_write_if_empty(name, blocks) blocks = _fix_optional_blocks(name, blocks) blocks = _wrap_grid_aux(blocks, fields) diff --git a/modflow_devtools/dfns/schema.py b/modflow_devtools/dfns/schema.py index 154fe276..995ddb9c 100644 --- a/modflow_devtools/dfns/schema.py +++ b/modflow_devtools/dfns/schema.py @@ -217,6 +217,9 @@ class Array(InputFieldBase): time_series: bool = False layered: bool = False index: Index = False + # Integer: each (nonzero) element references a row by `pk`, per grid cell + # or, in a list item, per entry. String: each entry is a key into the + # target (e.g. a list of model names). Hierarchical-path form only. fk: str | None = None # For dtype="cellid" only: each element is a `CellID` with these. model: CellModel = None @@ -241,10 +244,18 @@ def _check_index_dtype(self) -> "Array": raise ValueError( f"Array {self.name!r}: {attr} is not valid with dtype='cellid'" ) - if self.fk is not None and self.dtype != "integer": - raise ValueError( - f"Array {self.name!r}: fk requires dtype='integer', got {self.dtype!r}" - ) + if self.fk is not None: + if self.dtype not in ("integer", "string"): + raise ValueError( + f"Array {self.name!r}: fk requires dtype='integer' or 'string', " + f"got {self.dtype!r}" + ) + # no sibling `fk_ref` to resolve a bare block name against + if "." not in self.fk: + raise ValueError( + f"Array {self.name!r}: fk={self.fk!r} must be a hierarchical path " + f"('[component.]block.field')" + ) return self @@ -1460,17 +1471,18 @@ def _validate_list_shape_element( def _validate_fk_fields(component: "ComponentBase", spec: "Dfns") -> None: """ For every Integer/String/Array field with fk or fk_ref set, validate - structure. Array's `fk` (per-grid-cell rather than per-list-row) only - ever uses the hierarchical-path form below — it has no `pk`/`fk_ref` - counterpart (see `Array.fk`). + structure. Array's `fk` (an integer array's per grid cell or per entry, + a string array's per entry) only ever uses the hierarchical-path form + below — it has no `pk`/`fk_ref` counterpart (see `Array.fk`). Two forms (see docs/md/dfnspec.md, "Primary and foreign keys"); grid-cell references are a separate mechanism entirely (the `cellid` type, not an `fk` value — see `CellID`): - Hierarchical path fk ("[component.]block.field", no fk_ref): the named - block must be a list block whose item has a pk field, or, for a String, - the named field may instead be a string-dtype Array, whose entries are + block must be a list block whose item has a pk field, or, for a String + or string Array, the named field may instead be a string-dtype Array, + whose entries are then the key set (e.g. `options.auxiliary`). Unqualified ("block.field"), the block is looked up in this component; qualified ("component.block.field"), it's looked up in the named component via @@ -1531,7 +1543,10 @@ def _check_fields(fields: dict, dynamic_key: "InputField | None" = None) -> None f"'component.block.field'" ) is_dynamic_key = field is dynamic_key - if is_dynamic_key or (isinstance(field, String) and len(parts) > 1): + is_string = isinstance(field, String) or ( + isinstance(field, Array) and field.dtype == "string" + ) + if is_dynamic_key or (is_string and len(parts) > 1): target_block = (target.blocks or {}).get(block_name) target_field = target_block.fields.get(parts[-1]) if target_block else None if isinstance(target_field, Array) and target_field.dtype == "string": From 89779e0b1ace923cd8c46748a705232f6ca36abc Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 21 Oct 2026 05:23:54 -0700 Subject: [PATCH 2/4] feat(dfns): mark dynamic keywords explicitly A leading untagged String with a hierarchical fk was taken for a dynamic keyword, making its list tagged. But fk only says what a value references, not its role in the input grammar: utl-hpc's partitions rows begin with a model name, a key into sim-nam's models, yet are an untagged table. Add String.dynamic to declare a dynamic keyword. It requires tagged=False and a hierarchical-path fk without fk_ref, and only a record's first field may have it. A dynamic key's fk is now checked like any String's, so its keywords may come from a list's pk as well as a string array. Mark the array-based packages' auxname dynamic, and annotate utl-hpc's partitions.mname with fk "sim-nam.models.mname" (MF6 rejects an undefined model name there; not a pk, since a repeat isn't rejected). Co-Authored-By: Claude Opus 5.5 --- .../__snapshots__/v2.0.0.dev2/gwf-chdg.json | 3 +- .../__snapshots__/v2.0.0.dev2/gwf-chdg.toml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-chdg.yaml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-drng.json | 3 +- .../__snapshots__/v2.0.0.dev2/gwf-drng.toml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-drng.yaml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-evta.json | 3 +- .../__snapshots__/v2.0.0.dev2/gwf-evta.toml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-evta.yaml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-ghbg.json | 3 +- .../__snapshots__/v2.0.0.dev2/gwf-ghbg.toml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-ghbg.yaml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-rcha.json | 3 +- .../__snapshots__/v2.0.0.dev2/gwf-rcha.toml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-rcha.yaml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-rivg.json | 3 +- .../__snapshots__/v2.0.0.dev2/gwf-rivg.toml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-rivg.yaml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-welg.json | 3 +- .../__snapshots__/v2.0.0.dev2/gwf-welg.toml | 1 + .../__snapshots__/v2.0.0.dev2/gwf-welg.yaml | 1 + .../__snapshots__/v2.0.0.dev2/utl-hpc.json | 3 +- .../__snapshots__/v2.0.0.dev2/utl-hpc.toml | 1 + .../__snapshots__/v2.0.0.dev2/utl-hpc.yaml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-chdg.json | 3 +- .../__snapshots__/v2.0.0.dev3/gwf-chdg.toml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-chdg.yaml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-drng.json | 3 +- .../__snapshots__/v2.0.0.dev3/gwf-drng.toml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-drng.yaml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-evta.json | 3 +- .../__snapshots__/v2.0.0.dev3/gwf-evta.toml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-evta.yaml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-ghbg.json | 3 +- .../__snapshots__/v2.0.0.dev3/gwf-ghbg.toml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-ghbg.yaml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-rcha.json | 3 +- .../__snapshots__/v2.0.0.dev3/gwf-rcha.toml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-rcha.yaml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-rivg.json | 3 +- .../__snapshots__/v2.0.0.dev3/gwf-rivg.toml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-rivg.yaml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-welg.json | 3 +- .../__snapshots__/v2.0.0.dev3/gwf-welg.toml | 1 + .../__snapshots__/v2.0.0.dev3/gwf-welg.yaml | 1 + .../__snapshots__/v2.0.0.dev3/utl-hpc.json | 3 +- .../__snapshots__/v2.0.0.dev3/utl-hpc.toml | 1 + .../__snapshots__/v2.0.0.dev3/utl-hpc.yaml | 1 + autotest/dfns/test_migrate.py | 16 +++- autotest/dfns/test_schema.py | 35 +++++++-- autotest/dfns/test_schema_relations.py | 33 ++++++-- docs/md/dfnspec.md | 12 ++- .../dfns/migrate_to_v2_0_0_dev2.py | 36 +++++---- modflow_devtools/dfns/schema.py | 76 +++++++++++-------- 54 files changed, 212 insertions(+), 76 deletions(-) diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.json index 06b33f00..de56a838 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.json @@ -127,7 +127,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.toml index de08bb7d..cd746510 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.toml @@ -117,6 +117,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.yaml index 85c34808..e09219c6 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-chdg.yaml @@ -131,6 +131,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: constant head auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.json index aac74afd..6f88e5cb 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.json @@ -150,7 +150,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.toml index 6cae93ad..e59c5a6a 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.toml @@ -140,6 +140,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.yaml index 2079d741..847c2f37 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-drng.yaml @@ -160,6 +160,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: drain auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.json index aeb96e7b..f4b5fcde 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.json @@ -169,7 +169,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.toml index 32e5578d..5a499d8d 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.toml @@ -161,6 +161,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.yaml index 4b7029c5..9f35651a 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-evta.yaml @@ -172,6 +172,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: evapotranspiration auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.json index 5e86cb77..732098b9 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.json @@ -137,7 +137,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.toml index b2da8f02..b238dc48 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.toml @@ -127,6 +127,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.yaml index abd04c67..fa0f618a 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-ghbg.yaml @@ -142,6 +142,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: general-head boundary auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.json index 3b562664..90c02721 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.json @@ -147,7 +147,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.toml index e6f5355b..902e1755 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.toml @@ -139,6 +139,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.yaml index 7218308a..0b2c8988 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rcha.yaml @@ -156,6 +156,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: recharge auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.json index a4f98cf6..d2434383 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.json @@ -149,7 +149,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.toml index 7e148a41..2266e63d 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.toml @@ -139,6 +139,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.yaml index 19186a4c..107dab03 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-rivg.yaml @@ -151,6 +151,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: river auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.json index 12e0380b..11df6370 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.json @@ -155,7 +155,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.toml index c701f388..e805d77c 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.toml @@ -143,6 +143,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.yaml index 23ad18d0..43f693c3 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-welg.yaml @@ -172,6 +172,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: well auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.json index 489c1691..fd93a564 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.json @@ -39,7 +39,8 @@ "type": "string", "longname": "model name", "description": "is the unique model name.", - "tagged": false + "tagged": false, + "fk": "sim-nam.models.mname" }, "mrank": { "type": "integer", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.toml index 9563efdf..cc547318 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.toml @@ -34,6 +34,7 @@ type = "string" longname = "model name" description = "is the unique model name." tagged = false +fk = "sim-nam.models.mname" [blocks.partitions.fields.partitions.item.fields.mrank] type = "integer" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.yaml index ef773394..440af81d 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-hpc.yaml @@ -35,6 +35,7 @@ blocks: longname: model name description: is the unique model name. tagged: false + fk: sim-nam.models.mname mrank: type: integer longname: model rank diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.json index c4dfe5cd..f777677e 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.json @@ -138,7 +138,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.toml index cab92efe..4a318f13 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.toml @@ -126,6 +126,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.yaml index ba333452..748a19cf 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-chdg.yaml @@ -138,6 +138,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: constant head auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.json index 5e129fad..67abe55c 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.json @@ -161,7 +161,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.toml index e15bcb6c..f3f444da 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.toml @@ -149,6 +149,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.yaml index 077ffb5c..08a2e571 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-drng.yaml @@ -167,6 +167,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: drain auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.json index 68e06346..52fe1744 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.json @@ -180,7 +180,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.toml index 81464bc5..e4e3b8f3 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.toml @@ -170,6 +170,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.yaml index e1c14720..3d53de21 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-evta.yaml @@ -179,6 +179,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: evapotranspiration auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.json index edd5bde7..e3704f72 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.json @@ -148,7 +148,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.toml index 24a2fa4e..09fa02a9 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.toml @@ -136,6 +136,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.yaml index 6277a581..b7da1933 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-ghbg.yaml @@ -149,6 +149,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: general-head boundary auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.json index a11a9955..22c38bd9 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.json @@ -158,7 +158,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.toml index 27deab51..7947f52b 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.toml @@ -148,6 +148,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.yaml index ece09c52..40c6f59f 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rcha.yaml @@ -163,6 +163,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: recharge auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.json index 556e4ce8..45423a36 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.json @@ -160,7 +160,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.toml index 31883dbe..57a7d7ca 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.toml @@ -148,6 +148,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.yaml index b076f925..06abe03e 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-rivg.yaml @@ -158,6 +158,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: river auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.json index 70dcb875..b3d76be6 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.json @@ -166,7 +166,8 @@ "longname": "auxiliary variable name", "description": "name of the auxiliary variable the following array sets; must be one of the auxiliary variables.", "tagged": false, - "fk": "options.auxiliary" + "fk": "options.auxiliary", + "dynamic": true }, "aux": { "type": "array", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.toml index 6d31f066..ca8952e5 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.toml @@ -152,6 +152,7 @@ longname = "auxiliary variable name" description = "name of the auxiliary variable the following array sets; must be one of the auxiliary variables." tagged = false fk = "options.auxiliary" +dynamic = true [blocks.period.fields.aux.item.fields.aux] type = "array" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.yaml index b70d8c65..720a352c 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-welg.yaml @@ -179,6 +179,7 @@ blocks: auxiliary variables. tagged: false fk: options.auxiliary + dynamic: true aux: type: array longname: well auxiliary variable iaux diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.json index 4336302d..a92aa0a3 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.json @@ -39,7 +39,8 @@ "type": "string", "longname": "model name", "description": "is the unique model name.", - "tagged": false + "tagged": false, + "fk": "sim-nam.models.mname" }, "mrank": { "type": "integer", diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.toml index 8fa2ee6a..ccd6d7c8 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.toml @@ -34,6 +34,7 @@ type = "string" longname = "model name" description = "is the unique model name." tagged = false +fk = "sim-nam.models.mname" [blocks.partitions.fields.partitions.item.fields.mrank] type = "integer" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.yaml index fe7eddb9..45e4f7e7 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-hpc.yaml @@ -35,6 +35,7 @@ blocks: longname: model name description: is the unique model name. tagged: false + fk: sim-nam.models.mname mrank: type: integer longname: model rank diff --git a/autotest/dfns/test_migrate.py b/autotest/dfns/test_migrate.py index 909b920f..c819fa45 100644 --- a/autotest/dfns/test_migrate.py +++ b/autotest/dfns/test_migrate.py @@ -9,7 +9,7 @@ from modflow_devtools.dfn.schema import Dfn from modflow_devtools.dfns import fetch_dfns, migrate from modflow_devtools.dfns import schema as v2 -from modflow_devtools.dfns.migrate_to_v2_0_0_dev2 import _fix_sim_nam_relations, to_v2_0_0_dev2 +from modflow_devtools.dfns.migrate_to_v2_0_0_dev2 import _fix_model_name_relations, to_v2_0_0_dev2 from modflow_devtools.dfns.migrate_to_v2_0_0_dev3 import to_v2_0_0_dev3 FORMATS = ["yaml", "toml", "json"] @@ -345,7 +345,19 @@ def item(block): assert exchanges.fields["exgmnameb"].fk == "models.mname" assert item("solutiongroup").fields["slnmnames"].fk == "models.mname" # unchanged by a second pass - assert _fix_sim_nam_relations("sim-nam", component.blocks) == component.blocks + assert _fix_model_name_relations("sim-nam", component.blocks) == component.blocks + + +def test_migrate_hpc_model_names(dfn_dir): + """HPC's leading model name is a key into sim-nam's models, but data, not + a dynamic keyword, so its list stays untagged.""" + component = _migrate_dev3(dfn_dir, "utl-hpc") + partitions = component.blocks["partitions"].fields["partitions"] + mname = partitions.item.fields["mname"] + assert mname.fk == "sim-nam.models.mname" + assert not mname.pk and not mname.dynamic + assert not partitions.tagged + assert _fix_model_name_relations("utl-hpc", component.blocks) == component.blocks def test_migrate_auxiliary_stays_self_sizing(dfn_dir): diff --git a/autotest/dfns/test_schema.py b/autotest/dfns/test_schema.py index 703ff9fb..369392b0 100644 --- a/autotest/dfns/test_schema.py +++ b/autotest/dfns/test_schema.py @@ -546,14 +546,16 @@ def test_block_tagged_list_item_keyword_must_be_unique(): Block(name="options", fields={"a": _ts_list("a"), "b": _ts_list("b")}) -def _aux_list(fk="options.auxiliary", tagged=False): +def _aux_list(): return List( name="aux", optional=True, item=Record( name="aux", fields={ - "auxname": String(name="auxname", tagged=tagged, fk=fk), + "auxname": String( + name="auxname", tagged=False, fk="options.auxiliary", dynamic=True + ), "aux": Array(name="aux", dtype="double", tagged=False, shape=["nodes"]), }, ), @@ -561,8 +563,8 @@ def _aux_list(fk="options.auxiliary", tagged=False): def test_list_led_by_dynamic_key_is_tagged(): - """A record led by an untagged String with a hierarchical-path fk is - keyword-led: its keywords are the fk target's entries, known at runtime.""" + """A record led by a dynamic String is keyword-led: its keywords are the + fk target's keys, known at runtime.""" lst = _aux_list() assert lst.tagged is True assert lst.item_tags == [] @@ -575,6 +577,9 @@ def test_list_led_by_dynamic_key_is_tagged(): String(name="auxname", tagged=False), String(name="auxname", tagged=False, fk="packagedata"), String(name="auxname", tagged=False, fk_ref="pname"), + # a key, but data: leading isn't enough without `dynamic` + String(name="auxname", tagged=False, fk="options.auxiliary"), + String(name="mname", tagged=False, fk="sim-nam.models.mname"), Integer(name="auxname", tagged=False, fk="options.auxiliary"), ], ) @@ -583,6 +588,26 @@ def test_list_led_by_other_untagged_value_is_untagged(auxname): assert List(name="aux", item=item).tagged is False +@pytest.mark.parametrize( + "kwargs,match", + [ + ({"fk": "options.auxiliary"}, "requires tagged=False"), + ({"tagged": False}, "requires a hierarchical-path fk"), + ({"tagged": False, "fk": "packagedata"}, "requires a hierarchical-path fk"), + ({"tagged": False, "fk_ref": "pname"}, "requires a hierarchical-path fk"), + ], +) +def test_dynamic_string_requires_untagged_hierarchical_fk(kwargs, match): + with pytest.raises(ValueError, match=match): + String(name="auxname", dynamic=True, **kwargs) + + +def test_dynamic_string_must_lead_record(): + auxname = String(name="auxname", tagged=False, fk="options.auxiliary", dynamic=True) + with pytest.raises(ValueError, match="only the first field may be dynamic"): + Record(name="aux", fields={"v": Double(name="v", tagged=False), "auxname": auxname}) + + def test_block_dynamic_key_list_may_precede_fields(): block = Block( name="period", @@ -626,7 +651,7 @@ def test_array_package_aux_is_list_of_named_arrays(dev3_spec, name, shape): assert list(aux.item.fields) == ["auxname", "aux"] auxname, array = aux.item.fields.values() assert isinstance(auxname, String) - assert not auxname.tagged + assert not auxname.tagged and auxname.dynamic assert auxname.fk == "options.auxiliary" assert isinstance(array, Array) assert not array.tagged and not array.optional diff --git a/autotest/dfns/test_schema_relations.py b/autotest/dfns/test_schema_relations.py index 4474eee5..08e0ee6d 100644 --- a/autotest/dfns/test_schema_relations.py +++ b/autotest/dfns/test_schema_relations.py @@ -136,7 +136,7 @@ def _aux_ctx(fk="options.auxiliary", auxiliary_dtype="string", tagged=False): """A grid-array package whose period `aux` list is keyed by an auxiliary name.""" auxiliary = Array(name="auxiliary", dtype=auxiliary_dtype, optional=True) options = Block(name="options", fields={"auxiliary": auxiliary}) - auxname = String(name="auxname", tagged=tagged, fk=fk) + auxname = String(name="auxname", tagged=tagged, fk=fk, dynamic=not tagged) array = Array(name="aux", dtype="double", tagged=False) item = Record(name="aux", fields={"auxname": auxname, "aux": array}) period = Block(name="period", fields={"aux": List(name="aux", optional=True, item=item)}) @@ -156,12 +156,12 @@ def test_dfns_validate_fk_to_string_array_tagged_string(): def test_dfns_validate_fk_to_non_string_array_rejected(): - with pytest.raises(ValueError, match="must name a string array"): + with pytest.raises(ValueError, match="is not a list block"): Dfns(components=_aux_ctx(auxiliary_dtype="double")) def test_dfns_validate_fk_to_missing_field_rejected(): - with pytest.raises(ValueError, match="must name a string array"): + with pytest.raises(ValueError, match="is not a list block"): Dfns(components=_aux_ctx(fk="options.nosuchfield")) @@ -177,14 +177,14 @@ def _union_ctx(arm): def _keyed_arm(fk): - auxname = String(name="auxname", tagged=False, fk=fk) + auxname = String(name="auxname", tagged=False, fk=fk, dynamic=True) array = Array(name="aux", dtype="double", tagged=False) return Record(name="aux", fields={"auxname": auxname, "aux": array}) def test_dfns_validate_fk_in_union_item_record_arm(): assert "gwf-test" in Dfns(components=_union_ctx(_keyed_arm("options.auxiliary"))).components - with pytest.raises(ValueError, match="must name a string array"): + with pytest.raises(ValueError, match="is not a list block"): Dfns(components=_union_ctx(_keyed_arm("options.nosuchfield"))) @@ -221,6 +221,29 @@ def test_dfns_validate_string_array_fk_unknown_block_rejected(): Dfns(components=_model_names_ctx(fk="nosuchblock.mname")) +def _hpc_ctx(dynamic=False): + """A simulation plus an HPC-like package whose rows lead with a model name.""" + mname = String(name="mname", tagged=False, fk="sim-nam.models.mname", dynamic=dynamic) + mrank = Integer(name="mrank", tagged=False) + item = Record(name="partitions", fields={"mname": mname, "mrank": mrank}) + partitions = Block(name="partitions", fields={"partitions": List(name="partitions", item=item)}) + hpc = Package(name="utl-hpc", parent="sim-nam", blocks={"partitions": partitions}) + return {**_model_names_ctx(), "utl-hpc": hpc} + + +def test_dfns_validate_leading_fk_to_list_pk_is_data(): + """A leading untagged String keying another component's list is data + unless marked dynamic: its list stays untagged.""" + spec = Dfns(components=_hpc_ctx()) + assert spec.components["utl-hpc"].blocks["partitions"].fields["partitions"].tagged is False + + +def test_dfns_validate_dynamic_key_to_list_pk(): + """A dynamic key's keywords may come from a list's pk as well as a string array.""" + spec = Dfns(components=_hpc_ctx(dynamic=True)) + assert spec.components["utl-hpc"].blocks["partitions"].fields["partitions"].tagged is True + + @pytest.mark.parametrize("dtype", ["integer", "string"]) def test_array_fk_bare_block_rejected(dtype): with pytest.raises(ValueError, match="must be a hierarchical path"): diff --git a/docs/md/dfnspec.md b/docs/md/dfnspec.md index 9cd8f62b..1219a26f 100644 --- a/docs/md/dfnspec.md +++ b/docs/md/dfnspec.md @@ -51,6 +51,7 @@ This document describes the MODFLOW 6 component definition (DFN) system. This sy - [`pk`](#pk) - [`fk`](#fk) - [`fk_ref`](#fk_ref) + - [`dynamic`](#dynamic) - [Integer](#integer) - [Type-specific attributes](#type-specific-attributes-3) - [`valid`](#valid-1) @@ -386,7 +387,7 @@ A list's default value is interpreted as a single element. This allows consumers `boolean (default: true)`. Whether the field begins with a keyword in input files. On a scalar or an array, it says whether the field's own name precedes its value, e.g. `MAXBOUND `. Other types derive it, so it is never declared on them: - A keyword always begins with itself, so it is always tagged. -- A record is tagged if its first field is, or is a dynamic keyword (see [List](#list)). +- A record is tagged if its first field is, or is a dynamic keyword (see [`dynamic`](#dynamic)). - A union is tagged if every arm is, so that the keyword picks the arm. This is a tagged union in the type-theory sense. An untagged union has an arm that begins with a value instead. - A list is tagged if its item is (see [List](#list)). @@ -436,6 +437,10 @@ Type `string`. `string | null (default: null)`. For FKs whose target component is only known at runtime. Names a sibling string field whose value identifies the target component. May be set alone (block within target also unknown) or together with `fk` as a bare block name (block known, component not). See "Primary/foreign keys". +###### `dynamic` + +`boolean (default: false)`. Marks this string as a **dynamic keyword**: its value is itself a keyword, one of the keys its `fk` names, so a record it begins is keyword-led (see [List](#list)). Requires `tagged: false` and a hierarchical-path `fk`, without `fk_ref`, and is valid only on a record's first field. This is a fact about the input grammar, separate from `fk`, which only says what the value references. A leading untagged string with an `fk` is otherwise data: for example, `utl-hpc`'s `partitions` rows begin with a model name, an `fk` to `sim-nam.models.mname`, but are an untagged table. + #### Integer Type `integer`. @@ -598,7 +603,7 @@ A reader tries an untagged union's arms in order. A `keyword` arm is its own nam Type `list`. Collection type. Unlimited but for one rule: a list may not contain another list. Lists are distinct from arrays in two ways: a list element may be a composite type and a list admits sparse representations. -A list is **tagged** if and only if its `item` type is keyword-led, i.e. tagged. This is a property of the definition, not of any particular input file. A type is keyword-led if it is a `keyword`, a tagged scalar or array (which begins with its own name), or a record whose first field is keyword-led or is a **dynamic keyword**: an untagged `string` whose `fk` is a hierarchical path to a string array, so that its value is one of that array's entries. A dynamic keyword's possible values are known keywords once the array has been read, though not when the definition is written. A `union` item type is keyword-led if every arm is, since each arm is a distinct line form the item may take. For example, a record item `TS6 FILEIN `, or a union item whose arms are `ALL` and `FREQUENCY `. Otherwise it is **untagged**. This is derived from `item` rather than declared (like a block's optionality), and determines where the list may appear in its block (see [Field ordering](#field-ordering)). +A list is **tagged** if and only if its `item` type is keyword-led, i.e. tagged. This is a property of the definition, not of any particular input file. A type is keyword-led if it is a `keyword`, a tagged scalar or array (which begins with its own name), or a record whose first field is keyword-led or is a **dynamic keyword**: a `string` marked [`dynamic`](#dynamic), whose value is one of the keys its `fk` names, e.g. an entry of a string array. A dynamic keyword's possible values are known keywords once its `fk` target has been read, though not when the definition is written. A `union` item type is keyword-led if every arm is, since each arm is a distinct line form the item may take. For example, a record item `TS6 FILEIN `, or a union item whose arms are `ALL` and `FREQUENCY `. Otherwise it is **untagged**. This is derived from `item` rather than declared (like a block's optionality), and determines where the list may appear in its block (see [Field ordering](#field-ordering)). Untagged lists are tables: a block body of items, one per line, e.g. a stress package's period data. Tagged lists also express a line that may be repeated among a block's other fields, e.g. `TS6 FILEIN `, which MODFLOW 6 accepts any number of times in a package's options block (one per time-series file). A tagged list whose item begins with a dynamic keyword expresses a field whose name is chosen by the user. For example, an array-based stress package (e.g. `gwf-rcha`) reads one grid array per auxiliary variable, each introduced by that variable's name: @@ -614,6 +619,7 @@ aux: type: string tagged: false fk: options.auxiliary + dynamic: true aux: type: array dtype: double @@ -844,7 +850,7 @@ Sometimes a column in one list identifies a row in another list. This can be con The `fk` attribute can take one of two forms: -- **Hierarchical path**: A path of the form `"[component.]block.field"`, for use when the primary key field is statically known. The `component` segment is necessary for cross-component references; it may be omitted for within-component references. The hierarchical path form may not be used with `fk_ref`. On a `string` field, the path may instead name a string array (e.g. `"options.auxiliary"`), whose entries then act as the key set; no `pk` is involved. A dynamic keyword (see [List](#list)) must use this form. +- **Hierarchical path**: A path of the form `"[component.]block.field"`, for use when the primary key field is statically known. The `component` segment is necessary for cross-component references; it may be omitted for within-component references. The hierarchical path form may not be used with `fk_ref`. On a `string` field, the path may instead name a string array (e.g. `"options.auxiliary"`), whose entries then act as the key set: the array is in effect a one-column list whose entries are its keys. A dynamic keyword (see [`dynamic`](#dynamic)) must use this form. - **Bare block name**: The name of a block in which there is exactly one `pk` field. In this case, `fk_ref` is required to resolve the target component at runtime. The `fk_ref` attribute names a string field whose runtime value identifies the component containing the `pk` field. Two sub-cases exist: diff --git a/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py b/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py index b76cd2cc..6e306b3c 100644 --- a/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py +++ b/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py @@ -1610,10 +1610,10 @@ def _set_list_item_attrs( return {**blocks, block_name: new_block} -def _fix_sim_nam_relations(name: str, blocks: dict[str, v2.Block]) -> dict[str, v2.Block]: +def _fix_model_name_relations(name: str, blocks: dict[str, v2.Block]) -> dict[str, v2.Block]: """ - Patch `sim-nam`'s model-name pk/fk relations, which the general passes - can't reach on their own. + Patch the model-name pk/fk relations of `sim-nam` and `utl-hpc`, which + the general passes can't reach on their own. - Marks `models.mname` as `pk`. MF6 rejects a duplicate model name (`check_model_name` in `SimulationCreate.f90`), comparing names @@ -1624,15 +1624,20 @@ def _fix_sim_nam_relations(name: str, blocks: dict[str, v2.Block]) -> dict[str, `slnmnames` (a string array, each entry a model name) as `fk: "models.mname"`, since MF6 looks each up among the model names and fails on one not found. - - Does *not* annotate `utl-hpc`'s `partitions.mname`, though it names a - model too: it leads its row, and a leading untagged String with a - hierarchical-path `fk` is taken for a dynamic keyword (`_is_dynamic_key`), - which would make `partitions` a tagged list. Nor does it express which - model *types* are valid where (a GWF-GWT exchange's model A must be a GWF - model; IMS can't solve an explicit model): those are constraints on the - referenced row, not keys. - """ + - Marks `utl-hpc`'s `partitions.mname` as `fk: "sim-nam.models.mname"`: + MF6 rejects an undefined model name (`DistributedSim.f90`). Not a `pk`, + since MF6 doesn't reject a repeat (the last rank given wins). It leads + its row, but as data, not a dynamic keyword, so `partitions` stays + untagged. + + Doesn't express which model *types* are valid where (a GWF-GWT + exchange's model A must be a GWF model; IMS can't solve an explicit + model): those are constraints on the referenced row, not keys. + """ + if name == "utl-hpc": + if not isinstance(_list_item_field(blocks, "partitions", "mname"), v2.String): + return blocks + return _set_list_item_attrs(blocks, "partitions", {"mname": {"fk": "sim-nam.models.mname"}}) if name != "sim-nam": return blocks @@ -1784,8 +1789,8 @@ def _wrap_grid_aux(blocks: dict[str, v2.Block], fields: OMD) -> dict[str, v2.Blo ``AUX``, but the input file has zero or more grid arrays, each introduced by one of the ``auxiliary`` names (MF6 treats any period tag matching one as ``AUX``). The leading ``auxname`` is a dynamic keyword (an untagged - String with an ``fk`` to ``options.auxiliary``), which makes the list - tagged, so it mixes freely with the block's other arrays. Each auxiliary + String marked ``dynamic``, with an ``fk`` to ``options.auxiliary``), which + makes the list tagged, so it mixes freely with the block's other arrays. Each auxiliary variable is set at most once per period, so the list is bounded by the ``auxiliary`` count, and is always optional (v1's ``gwf-evta`` says otherwise). The outer name stays ``aux`` so memory variables sourced from @@ -1817,6 +1822,7 @@ def _wrap_grid_aux(blocks: dict[str, v2.Block], fields: OMD) -> dict[str, v2.Blo "one of the auxiliary variables.", tagged=False, fk="options.auxiliary", + dynamic=True, ) array = field.model_copy(update={"tagged": False, "optional": False, "description": None}) updates[fname] = v2.List( @@ -2405,7 +2411,7 @@ def _lookup(rname: str) -> dict | None: blocks = _patch_oc_rtype(name, blocks) blocks = _fix_lak_relations(name, blocks) blocks = _fix_mvr_relations(name, blocks) - blocks = _fix_sim_nam_relations(name, blocks) + blocks = _fix_model_name_relations(name, blocks) blocks = _fix_ssm_sources_write_if_empty(name, blocks) blocks = _fix_optional_blocks(name, blocks) blocks = _wrap_grid_aux(blocks, fields) diff --git a/modflow_devtools/dfns/schema.py b/modflow_devtools/dfns/schema.py index 995ddb9c..0d19301b 100644 --- a/modflow_devtools/dfns/schema.py +++ b/modflow_devtools/dfns/schema.py @@ -147,6 +147,26 @@ class String(InputFieldBase): pk: bool = False fk: str | None = None fk_ref: str | None = None + # A dynamic keyword: the value itself is a keyword, one of the `fk` + # target's keys, so a record it leads is keyword-led (see + # `_is_dynamic_key`). A role in the input grammar, independent of `fk`, + # which only says what the value references. + dynamic: bool = False + + @model_validator(mode="after") + def _check_dynamic(self) -> "String": + if not self.dynamic: + return self + if self.tagged: + raise ValueError( + f"String {self.name!r}: dynamic requires tagged=False (its value is the keyword)" + ) + if self.fk is None or "." not in self.fk or self.fk_ref is not None: + raise ValueError( + f"String {self.name!r}: dynamic requires a hierarchical-path fk " + f"('[component.]block.field') naming its keywords, without fk_ref" + ) + return self # `True`: a 1-based index in the file, 0-based in memory. `"signed"`: the @@ -267,6 +287,16 @@ def _derive_tagged(self) -> bool: # Its first field begins with a keyword, or is a dynamic keyword. return _item_tags(self) is not None + @model_validator(mode="after") + def _check_dynamic_leads(self) -> "Record": + # A dynamic keyword begins a line; anywhere else it'd be plain data. + later = [n for n, f in list(self.fields.items())[1:] if _is_dynamic_key(f)] + if later: + raise ValueError( + f"Record {self.name!r}: only the first field may be dynamic, got {later!r}" + ) + return self + @property def children(self) -> "dict[str, InputField]": return self.fields # type: ignore[return-value] @@ -286,19 +316,14 @@ def children(self) -> "dict[str, InputField]": def _is_dynamic_key(field: "InputField | None") -> bool: - """Whether `field` is a dynamic keyword: an untagged String whose value is - one of the entries of a string array named by a hierarchical-path `fk` - (e.g. an auxiliary variable's name). Its possible values are known keywords - once that array has been read, so a record it leads is keyword-led. That - the `fk` target really is a string array is checked by `_validate_fk_fields`, - since it needs the enclosing component.""" - return ( - isinstance(field, String) - and not field.tagged - and field.fk is not None - and "." in field.fk - and field.fk_ref is None - ) + """Whether `field` is a dynamic keyword: a String marked `dynamic`, whose + value is one of the keys its `fk` names (e.g. an auxiliary variable's + name, one of `options.auxiliary`'s entries). Its possible values are known + keywords once the target has been read, so a record it leads is + keyword-led. Declared rather than inferred from `fk`, since a leading + untagged String with an `fk` may just as well be data (e.g. `utl-hpc`'s + `partitions.mname`, a model name).""" + return isinstance(field, String) and field.dynamic def _leads_with_dynamic_key(form: "InputField") -> bool: @@ -1482,12 +1507,11 @@ def _validate_fk_fields(component: "ComponentBase", spec: "Dfns") -> None: - Hierarchical path fk ("[component.]block.field", no fk_ref): the named block must be a list block whose item has a pk field, or, for a String or string Array, the named field may instead be a string-dtype Array, - whose entries are - then the key set (e.g. `options.auxiliary`). Unqualified ("block.field"), - the block is looked up in this component; qualified - ("component.block.field"), it's looked up in the named component via - `spec.components` instead. A dynamic key (see `_is_dynamic_key`) must - take the string-array form. + whose entries are then the key set (e.g. `options.auxiliary`). + Unqualified ("block.field"), the block is looked up in this component; + qualified ("component.block.field"), it's looked up in the named + component via `spec.components` instead. A dynamic key (see + `_is_dynamic_key`) is checked like any other String. - Bare block name fk + fk_ref, or fk_ref alone: fk_ref must name a sibling String field in the same record, whose runtime value identifies the target component (and, with fk, the pk field is looked up in the block @@ -1499,7 +1523,7 @@ def _validate_fk_fields(component: "ComponentBase", spec: "Dfns") -> None: Observation fields (see `ObservationField`) are checked like a list item's. """ - def _check_fields(fields: dict, dynamic_key: "InputField | None" = None) -> None: + def _check_fields(fields: dict) -> None: for field in fields.values(): fk: str | None = getattr(field, "fk", None) fk_ref: str | None = getattr(field, "fk_ref", None) @@ -1542,21 +1566,14 @@ def _check_fields(fields: dict, dynamic_key: "InputField | None" = None) -> None f"must be a bare block name, 'block.field', or " f"'component.block.field'" ) - is_dynamic_key = field is dynamic_key is_string = isinstance(field, String) or ( isinstance(field, Array) and field.dtype == "string" ) - if is_dynamic_key or (is_string and len(parts) > 1): + if is_string and len(parts) > 1: target_block = (target.blocks or {}).get(block_name) target_field = target_block.fields.get(parts[-1]) if target_block else None if isinstance(target_field, Array) and target_field.dtype == "string": continue - if is_dynamic_key: - raise ValueError( - f"Field {field.name!r} fk={fk!r}: leads a tagged list's " - f"item as a dynamic keyword, so must name a string array " - f"in {where}" - ) list_field = _find_list_in_block(target, block_name) if list_field is None: raise ValueError( @@ -1582,8 +1599,7 @@ def _check_fields(fields: dict, dynamic_key: "InputField | None" = None) -> None # a Record item of its own (a scalar arm has no siblings). for form in [item] if isinstance(item, Record) else item.arms.values(): if isinstance(form, Record): - lead = next(iter(form.fields.values()), None) - _check_fields(form.fields, lead if _is_dynamic_key(lead) else None) + _check_fields(form.fields) else: _check_fields({form.name: form}) From 0853bd65d89378bde17b38ff16a1a0ae68050a58 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 21 Oct 2026 05:44:44 -0700 Subject: [PATCH 3/4] refactor(dfns): require only a static fk for dynamic keywords A dynamic keyword's keywords must be known before its line is read, so its fk can't use fk_ref, which resolves from a sibling later on that line. That is the real constraint; also requiring a hierarchical path was a leftover of inferring dynamic keys from the "." in fk. Drop it, so a bare block name without fk_ref is accepted like any static fk. Co-Authored-By: Claude Opus 5.5 --- autotest/dfns/test_schema.py | 13 +++++++++---- autotest/dfns/test_schema_relations.py | 18 ++++++++++++++++++ docs/md/dfnspec.md | 4 ++-- modflow_devtools/dfns/schema.py | 7 ++++--- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/autotest/dfns/test_schema.py b/autotest/dfns/test_schema.py index 369392b0..aa83f377 100644 --- a/autotest/dfns/test_schema.py +++ b/autotest/dfns/test_schema.py @@ -592,16 +592,21 @@ def test_list_led_by_other_untagged_value_is_untagged(auxname): "kwargs,match", [ ({"fk": "options.auxiliary"}, "requires tagged=False"), - ({"tagged": False}, "requires a hierarchical-path fk"), - ({"tagged": False, "fk": "packagedata"}, "requires a hierarchical-path fk"), - ({"tagged": False, "fk_ref": "pname"}, "requires a hierarchical-path fk"), + ({"tagged": False}, "requires an fk"), + ({"tagged": False, "fk_ref": "pname"}, "requires an fk"), + ({"tagged": False, "fk": "packagedata", "fk_ref": "pname"}, "requires an fk"), ], ) -def test_dynamic_string_requires_untagged_hierarchical_fk(kwargs, match): +def test_dynamic_string_requires_untagged_static_fk(kwargs, match): with pytest.raises(ValueError, match=match): String(name="auxname", dynamic=True, **kwargs) +@pytest.mark.parametrize("fk", ["options.auxiliary", "packagedata", "gwf-nam.packages.pname"]) +def test_dynamic_string_accepts_any_static_fk(fk): + assert String(name="auxname", tagged=False, fk=fk, dynamic=True).dynamic + + def test_dynamic_string_must_lead_record(): auxname = String(name="auxname", tagged=False, fk="options.auxiliary", dynamic=True) with pytest.raises(ValueError, match="only the first field may be dynamic"): diff --git a/autotest/dfns/test_schema_relations.py b/autotest/dfns/test_schema_relations.py index 08e0ee6d..2413624b 100644 --- a/autotest/dfns/test_schema_relations.py +++ b/autotest/dfns/test_schema_relations.py @@ -244,6 +244,24 @@ def test_dfns_validate_dynamic_key_to_list_pk(): assert spec.components["utl-hpc"].blocks["partitions"].fields["partitions"].tagged is True +def test_dfns_validate_dynamic_key_bare_block_fk(): + """A bare block name without fk_ref is as static as a path, so a dynamic + key may use it: the keywords are that block's pk values.""" + pname = String(name="pname", tagged=False, pk=True) + packages_item = Record(name="packages", fields={"pname": pname}) + packages = Block( + name="packages", fields={"packages": List(name="packages", item=packages_item)} + ) + key = String(name="pname", tagged=False, fk="packages", dynamic=True) + item = Record(name="rate", fields={"pname": key, "rate": Double(name="rate", tagged=False)}) + period = Block(name="period", fields={"rate": List(name="rate", item=item)}) + pkg = Package( + name="gwf-test", parent="gwf-nam", blocks={"packages": packages, "period": period} + ) + spec = Dfns(components={"gwf-nam": Model(name="gwf-nam", blocks=None), "gwf-test": pkg}) + assert spec.components["gwf-test"].blocks["period"].fields["rate"].tagged is True + + @pytest.mark.parametrize("dtype", ["integer", "string"]) def test_array_fk_bare_block_rejected(dtype): with pytest.raises(ValueError, match="must be a hierarchical path"): diff --git a/docs/md/dfnspec.md b/docs/md/dfnspec.md index 1219a26f..a9ee6b35 100644 --- a/docs/md/dfnspec.md +++ b/docs/md/dfnspec.md @@ -439,7 +439,7 @@ Type `string`. ###### `dynamic` -`boolean (default: false)`. Marks this string as a **dynamic keyword**: its value is itself a keyword, one of the keys its `fk` names, so a record it begins is keyword-led (see [List](#list)). Requires `tagged: false` and a hierarchical-path `fk`, without `fk_ref`, and is valid only on a record's first field. This is a fact about the input grammar, separate from `fk`, which only says what the value references. A leading untagged string with an `fk` is otherwise data: for example, `utl-hpc`'s `partitions` rows begin with a model name, an `fk` to `sim-nam.models.mname`, but are an untagged table. +`boolean (default: false)`. Marks this string as a **dynamic keyword**: its value is itself a keyword, one of the keys its `fk` names, so a record it begins is keyword-led (see [List](#list)). Requires `tagged: false` and an `fk` without `fk_ref`, and is valid only on a record's first field. The keywords must be known before the line is read, so they can't depend on a field later on the same line, as an `fk_ref` would make them. This is a fact about the input grammar, separate from `fk`, which only says what the value references. A leading untagged string with an `fk` is otherwise data: for example, `utl-hpc`'s `partitions` rows begin with a model name, an `fk` to `sim-nam.models.mname`, but are an untagged table. #### Integer @@ -850,7 +850,7 @@ Sometimes a column in one list identifies a row in another list. This can be con The `fk` attribute can take one of two forms: -- **Hierarchical path**: A path of the form `"[component.]block.field"`, for use when the primary key field is statically known. The `component` segment is necessary for cross-component references; it may be omitted for within-component references. The hierarchical path form may not be used with `fk_ref`. On a `string` field, the path may instead name a string array (e.g. `"options.auxiliary"`), whose entries then act as the key set: the array is in effect a one-column list whose entries are its keys. A dynamic keyword (see [`dynamic`](#dynamic)) must use this form. +- **Hierarchical path**: A path of the form `"[component.]block.field"`, for use when the primary key field is statically known. The `component` segment is necessary for cross-component references; it may be omitted for within-component references. The hierarchical path form may not be used with `fk_ref`. On a `string` field, the path may instead name a string array (e.g. `"options.auxiliary"`), whose entries then act as the key set: the array is in effect a one-column list whose entries are its keys. A dynamic keyword (see [`dynamic`](#dynamic)) needs this form when its keywords are a string array's entries. - **Bare block name**: The name of a block in which there is exactly one `pk` field. In this case, `fk_ref` is required to resolve the target component at runtime. The `fk_ref` attribute names a string field whose runtime value identifies the component containing the `pk` field. Two sub-cases exist: diff --git a/modflow_devtools/dfns/schema.py b/modflow_devtools/dfns/schema.py index 0d19301b..3efbc2ff 100644 --- a/modflow_devtools/dfns/schema.py +++ b/modflow_devtools/dfns/schema.py @@ -161,10 +161,11 @@ def _check_dynamic(self) -> "String": raise ValueError( f"String {self.name!r}: dynamic requires tagged=False (its value is the keyword)" ) - if self.fk is None or "." not in self.fk or self.fk_ref is not None: + # Its keywords must be known before its line is read, so they can't + # depend on a sibling later on that line, as `fk_ref` would make them. + if self.fk is None or self.fk_ref is not None: raise ValueError( - f"String {self.name!r}: dynamic requires a hierarchical-path fk " - f"('[component.]block.field') naming its keywords, without fk_ref" + f"String {self.name!r}: dynamic requires an fk naming its keywords, without fk_ref" ) return self From ef2ded75ebd382be205410a89c1090dc007c5908 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 21 Oct 2026 05:46:15 -0700 Subject: [PATCH 4/4] docs(dfns): allow a bare-block fk without fk_ref in the spec The validator already accepts one, looking up the block's pk in the same component, and dynamic keywords now rely on it. Say so in the spec instead of requiring fk_ref. Co-Authored-By: Claude Opus 5.5 --- docs/md/dfnspec.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/md/dfnspec.md b/docs/md/dfnspec.md index a9ee6b35..476f7f31 100644 --- a/docs/md/dfnspec.md +++ b/docs/md/dfnspec.md @@ -431,7 +431,7 @@ Type `string`. ###### `fk` -`string | null (default: null)`. Marks this scalar as a foreign key. Valid only on integer or string scalars that are columns in a list item record. Two forms: (1) hierarchical path `"block.field"` or `"component.block.field"` — fully static, used without `fk_ref`; (2) bare block name (e.g., `"packagedata"`) — used together with `fk_ref` to name the block within the runtime-resolved target component, leaving only the pk field to be discovered. A string's hierarchical path may also name a string array, whose entries are then the valid values (e.g. `"options.auxiliary"`). See "Primary/foreign keys". +`string | null (default: null)`. Marks this scalar as a foreign key. Valid only on integer or string scalars that are columns in a list item record. Two forms: (1) hierarchical path `"block.field"` or `"component.block.field"` — fully static, used without `fk_ref`; (2) bare block name (e.g., `"packagedata"`) — naming a block whose `pk` field is the target: in this component when used alone, or, together with `fk_ref`, within the runtime-resolved target component. A string's hierarchical path may also name a string array, whose entries are then the valid values (e.g. `"options.auxiliary"`). See "Primary/foreign keys". ###### `fk_ref` @@ -467,7 +467,7 @@ Type `integer`. ###### `fk` -`string | null (default: null)`. Marks this scalar as a foreign key. Valid only on integer or string scalars that are columns in a list item record. Two forms: (1) hierarchical path `"block.field"` or `"component.block.field"` — fully static, used without `fk_ref`; (2) bare block name (e.g., `"packagedata"`) — used together with `fk_ref` to name the block within the runtime-resolved target component, leaving only the pk field to be discovered. See "Primary/foreign keys". +`string | null (default: null)`. Marks this scalar as a foreign key. Valid only on integer or string scalars that are columns in a list item record. Two forms: (1) hierarchical path `"block.field"` or `"component.block.field"` — fully static, used without `fk_ref`; (2) bare block name (e.g., `"packagedata"`) — naming a block whose `pk` field is the target: in this component when used alone, or, together with `fk_ref`, within the runtime-resolved target component. See "Primary/foreign keys". ###### `fk_ref` @@ -851,7 +851,7 @@ Sometimes a column in one list identifies a row in another list. This can be con The `fk` attribute can take one of two forms: - **Hierarchical path**: A path of the form `"[component.]block.field"`, for use when the primary key field is statically known. The `component` segment is necessary for cross-component references; it may be omitted for within-component references. The hierarchical path form may not be used with `fk_ref`. On a `string` field, the path may instead name a string array (e.g. `"options.auxiliary"`), whose entries then act as the key set: the array is in effect a one-column list whose entries are its keys. A dynamic keyword (see [`dynamic`](#dynamic)) needs this form when its keywords are a string array's entries. -- **Bare block name**: The name of a block in which there is exactly one `pk` field. In this case, `fk_ref` is required to resolve the target component at runtime. +- **Bare block name**: The name of a block in which there is exactly one `pk` field. Alone, the block is looked up in this component, so the reference is as static as a hierarchical path, which may be preferred for clarity. With `fk_ref`, the block is looked up in the component `fk_ref` resolves to at runtime (see below). The `fk_ref` attribute names a string field whose runtime value identifies the component containing the `pk` field. Two sub-cases exist: