diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.json index b3687690..0279dbe4 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.json @@ -80,6 +80,7 @@ "tagged": false, "dtype": "integer", "index": true, + "node": true, "shape": [ "numalphaj" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.toml index a4923d82..c07f1fce 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.toml @@ -71,6 +71,7 @@ description = "is the array of CELLIDS for the contributing j cells, which contr tagged = false dtype = "integer" index = true +node = true shape = [ "numalphaj", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.yaml index aaee6b77..c9051a86 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.yaml @@ -91,6 +91,7 @@ blocks: tagged: false dtype: integer index: true + node: true shape: - numalphaj alphasj: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.json index baebee2c..828605b6 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.json @@ -80,6 +80,7 @@ "tagged": false, "dtype": "integer", "index": true, + "node": true, "shape": [ "numalphaj" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.toml index c39f9ba5..de84f064 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.toml @@ -71,6 +71,7 @@ description = "is the array of CELLIDS for the contributing j cells, which contr tagged = false dtype = "integer" index = true +node = true shape = [ "numalphaj", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.yaml index 75674869..59e98006 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.yaml @@ -91,6 +91,7 @@ blocks: tagged: false dtype: integer index: true + node: true shape: - numalphaj alphasj: diff --git a/autotest/dfns/test_migrate.py b/autotest/dfns/test_migrate.py index 9d8eae8c..30a688ef 100644 --- a/autotest/dfns/test_migrate.py +++ b/autotest/dfns/test_migrate.py @@ -281,3 +281,15 @@ def test_migrate_ts_shapes(dfn_dir): def test_migrate_auxiliary_stays_self_sizing(dfn_dir): component = _migrate_dev3(dfn_dir, "gwf-chd") assert component.blocks["options"].fields["auxiliary"].shape == [] + + +def test_migrate_gnc_node_refs(dfn_dir): + component = _migrate_dev3(dfn_dir, "gwf-gnc") + item = component.blocks["gncdata"].fields["gncdata"].item + assert item.fields["cellidn"].node + assert item.fields["cellidm"].node + cellidsj = item.fields["cellidsj"] + assert isinstance(cellidsj, v2.Array) + assert cellidsj.node + assert cellidsj.shape == ["numalphaj"] + assert not item.fields["alphasj"].node diff --git a/autotest/dfns/test_schema_relations.py b/autotest/dfns/test_schema_relations.py index 2ba6b64d..f3f53419 100644 --- a/autotest/dfns/test_schema_relations.py +++ b/autotest/dfns/test_schema_relations.py @@ -186,3 +186,45 @@ def test_dfns_validate_fk_in_union_item_scalar_arm(): arm = Integer(name="lakeno", fk="nosuchblock.lakeno") with pytest.raises(ValueError, match="is not a list block"): Dfns(components=_union_ctx(arm)) + + +def _node_ctx(field): + item = Record(name="item", fields={field.name: field}) + lst = List(name="gncdata", item=item) + block = Block(name="gncdata", fields={"gncdata": lst}) + pkg = Package(name="gwf-gnc", parent="gwf-nam", blocks={"gncdata": block}) + gwf = Model(name="gwf-nam", blocks=None) + return pkg, gwf + + +@pytest.mark.parametrize( + "field", + [ + Integer(name="cellidn", node=True), + Array(name="cellidsj", dtype="integer", node=True), + ], +) +def test_dfns_validate_node_in_list_item(field): + gnc, gwf = _node_ctx(field) + spec = Dfns(components={"gwf-nam": gwf, "gwf-gnc": gnc}) + assert "gwf-gnc" in spec.components + + +def test_array_node_requires_integer_dtype(): + with pytest.raises(ValueError, match="node=True requires dtype='integer'"): + Array(name="alphasj", dtype="double", node=True) + + +@pytest.mark.parametrize( + "field", + [ + Integer(name="cellid", node=True), + Array(name="cellids", dtype="integer", node=True), + ], +) +def test_dfns_validate_node_outside_list_item(field): + block = Block(name="options", fields={field.name: field}) + pkg = Package(name="gwf-gnc", parent="gwf-nam", blocks={"options": block}) + gwf = Model(name="gwf-nam", blocks=None) + with pytest.raises(ValueError, match="only valid on a column in a list item record"): + Dfns(components={"gwf-nam": gwf, "gwf-gnc": pkg}) diff --git a/docs/md/dfnspec.md b/docs/md/dfnspec.md index 51a5244f..baa9a652 100644 --- a/docs/md/dfnspec.md +++ b/docs/md/dfnspec.md @@ -72,6 +72,7 @@ This document describes the MODFLOW 6 component definition (DFN) system. This sy - [`time_series`](#time_series-3) - [`index`](#index-1) - [`fk`](#fk-2) + - [`node`](#node-1) - [Record](#record) - [Type-specific attributes](#type-specific-attributes-7) - [`fields`](#fields-2) @@ -406,7 +407,7 @@ Type `integer`. ###### `node` -`boolean (default: false)`. Marks this scalar as a grid cell reference, resolved from the parent model's grid (DIS/DISV/DISU) at runtime. Valid only on integer scalars that are columns in a list item record. +`boolean (default: false)`. Marks this scalar as a grid cell reference, resolved from the parent model's grid (DIS/DISV/DISU) at runtime. Valid only on integer scalars or integer arrays (see [Array `node`](#node-1)) that are columns in a list item record. #### Double @@ -468,6 +469,10 @@ A 1D array appearing as a subfield of a record is called an **inline array**. In `string | null (default: null)`. Marks the array's (nonzero) elements as a foreign key: a per-grid-cell reference to a row (by `pk`) in another list, 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`). 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". +###### `node` + +`boolean (default: false)`. Marks each of the array's elements as a grid cell reference, like the scalar [`node`](#node) (e.g. GNC's `cellidsj`, `numalphaj` cellids). Each element is one cellid, so the field spans `prod(shape) * ncelldim` values in the input file, where `ncelldim` is that of the grid referred to (3 for DIS, 2 for DISV, 1 for DISU) rather than part of `shape`. Only valid when `dtype` is `"integer"` and the array is a column in a list item record. + #### Record Type `record`. Product type. In MF6 input files, records appear on a single line, except for a record that consists of a dynamic keyword (see [List](#list)) followed by an array: it is read like a tagged array whose tag is the key's value, i.e. the key on its own line, then the array in READARRAY format on the following lines. Record subfields may or may not be `tagged`. While blocks can be considered product types also, in the DFN specification only records are considered fields; blocks are considered named collections of related fields. 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 c6fd25b8..ae0fb9df 100644 --- a/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py +++ b/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py @@ -489,7 +489,8 @@ def _mark_lonely_pk(blocks: dict[str, v2.Block]) -> dict[str, v2.Block]: return {**blocks, **updated} -# Integer columns in a list item record that reference a grid cell, resolved +# Integer columns (scalars, or arrays of cellids like GNC's `cellidsj`) in a +# list item record that reference a grid cell, resolved # from the parent model's grid (DIS/DISV/DISU) at runtime, rather than a pk/fk # relation to another list's row. v1 has no attribute that signals this # (`numeric_index` only means "needs 1-based/0-based conversion", not "is a @@ -502,7 +503,7 @@ def _mark_lonely_pk(blocks: dict[str, v2.Block]) -> dict[str, v2.Block]: "exg-gwfgwf": ("exchangedata", ("cellidm1", "cellidm2")), "exg-gwtgwt": ("exchangedata", ("cellidm1", "cellidm2")), "exg-olfgwf": ("exchangedata", ("cellidm1", "cellidm2")), - "gwf-gnc": ("gncdata", ("cellidm", "cellidn")), + "gwf-gnc": ("gncdata", ("cellidm", "cellidn", "cellidsj")), } @@ -526,7 +527,9 @@ def _mark_node_refs(name: str, blocks: dict[str, v2.Block]) -> dict[str, v2.Bloc updates = { fname: f.model_copy(update={"node": True}) for fname in field_names - if isinstance(f := item.fields.get(fname), v2.Integer) and not f.node + if isinstance(f := item.fields.get(fname), (v2.Integer, v2.Array)) + and (isinstance(f, v2.Integer) or f.dtype == "integer") + and not f.node } if not updates: return blocks diff --git a/modflow_devtools/dfns/schema.json b/modflow_devtools/dfns/schema.json index e65ba6ae..56b79693 100644 --- a/modflow_devtools/dfns/schema.json +++ b/modflow_devtools/dfns/schema.json @@ -138,6 +138,11 @@ ], "default": null, "title": "Fk" + }, + "node": { + "default": false, + "title": "Node", + "type": "boolean" } }, "required": [ diff --git a/modflow_devtools/dfns/schema.py b/modflow_devtools/dfns/schema.py index 8b80992f..2447c60e 100644 --- a/modflow_devtools/dfns/schema.py +++ b/modflow_devtools/dfns/schema.py @@ -125,6 +125,10 @@ class Array(InputFieldBase): layered: bool = False index: bool = False fk: str | None = None + # Each element is a grid-cell reference (see `Integer.node`), so the field + # spans `prod(shape) * ncelldim` tokens, the cell width coming from the + # grid referred to rather than from the shape. + node: bool = False @model_validator(mode="after") def _check_index_dtype(self) -> "Array": @@ -132,6 +136,10 @@ def _check_index_dtype(self) -> "Array": raise ValueError( f"Array {self.name!r}: index=True requires dtype='integer', got {self.dtype!r}" ) + if self.node and self.dtype != "integer": + raise ValueError( + f"Array {self.name!r}: node=True requires dtype='integer', got {self.dtype!r}" + ) 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}" @@ -1234,6 +1242,34 @@ def _check_fields(fields: dict, dynamic_key: "InputField | None" = None) -> None _check_fields(block.fields) +def _validate_node_fields(component: "ComponentBase") -> None: + """ + A grid-cell reference (`node=True`, see `Integer.node`/`Array.node`) is + only meaningful as a column in a list item record, where each row names + its cell(s). The dtype constraint on arrays is checked by `Array` itself. + """ + if not component.blocks: + return + + def _check(field: "InputField", in_item: bool) -> None: + if getattr(field, "node", False) and not in_item: + raise ValueError( + f"Field {field.name!r}: node=True is only valid on a column in a list item record" + ) + if isinstance(field, List): + _check(field.item, True) + elif isinstance(field, Record): + for subfield in field.fields.values(): + _check(subfield, in_item) + elif isinstance(field, Union): + for arm in field.arms.values(): + _check(arm, in_item) + + for block in component.blocks.values(): + for field in block.fields.values(): + _check(field, False) + + def _validate_array_shapes( component: "ComponentBase", component_name: str, @@ -1516,6 +1552,8 @@ def _validate_relations(self) -> "Dfns": _resolve_derived_dims(component, self.dims(name)) for name, component in self.components.items(): _validate_fk_fields(component, self) + for name, component in self.components.items(): + _validate_node_fields(component) for name, component in self.components.items(): _validate_array_shapes(component, name, self) for name, component in self.components.items():