From 002f58700d0a0d1db458af7da79864573c86a8d1 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 7 Oct 2026 12:59:42 -0700 Subject: [PATCH] fix(dfns): admit utl-obs under models and make it single-instance Model name files with observation types (chf, gwe, gwf, gwt, olf) read an OBS6 file from their packages block, so treat them as linkers of utl-obs in link_components. Its derived parent becomes ["model", "package"]. MF6 reads at most one OBS6 per parent (packages error on a second, models keep the last), so override flopy3's multi-package header for utl-obs: multi is now false. Co-Authored-By: Claude Opus 5.5 --- .../dfns/__snapshots__/v2.0.0.dev2/utl-obs.json | 6 ++++-- .../dfns/__snapshots__/v2.0.0.dev2/utl-obs.toml | 6 ++++-- .../dfns/__snapshots__/v2.0.0.dev2/utl-obs.yaml | 5 +++-- .../dfns/__snapshots__/v2.0.0.dev3/utl-obs.json | 6 ++++-- .../dfns/__snapshots__/v2.0.0.dev3/utl-obs.toml | 6 ++++-- .../dfns/__snapshots__/v2.0.0.dev3/utl-obs.yaml | 5 +++-- autotest/dfns/test_migrate.py | 17 +++++++++++++++-- autotest/dfns/test_schema_relations.py | 4 ++++ modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py | 14 +++++++++++++- 9 files changed, 54 insertions(+), 15 deletions(-) diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.json index 250b7389..c6dfeead 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.json @@ -2,10 +2,12 @@ "type": "package", "schema_version": "2.0.0.dev2", "name": "utl-obs", - "parent": "package", "ftype": "OBS6", - "multi": true, "subtype": "utility", + "parent": [ + "model", + "package" + ], "blocks": { "options": { "fields": { diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.toml index 9b414aef..2bc46eba 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.toml @@ -1,10 +1,12 @@ type = "package" schema_version = "2.0.0.dev2" name = "utl-obs" -parent = "package" ftype = "OBS6" -multi = true subtype = "utility" +parent = [ + "model", + "package", +] [blocks.options.fields.digits] type = "integer" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.yaml index e653c198..c9481726 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/utl-obs.yaml @@ -1,10 +1,11 @@ type: package schema_version: 2.0.0.dev2 name: utl-obs -parent: package ftype: OBS6 -multi: true subtype: utility +parent: + - model + - package blocks: options: fields: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.json index 7d9cd9e3..97acf05c 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.json @@ -2,10 +2,12 @@ "type": "package", "schema_version": "2.0.0.dev3", "name": "utl-obs", - "parent": "package", "ftype": "OBS6", - "multi": true, "subtype": "utility", + "parent": [ + "model", + "package" + ], "blocks": { "options": { "fields": { diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.toml index a4bf8174..498e7080 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.toml @@ -1,10 +1,12 @@ type = "package" schema_version = "2.0.0.dev3" name = "utl-obs" -parent = "package" ftype = "OBS6" -multi = true subtype = "utility" +parent = [ + "model", + "package", +] [blocks.options.fields.digits] type = "integer" diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.yaml index a0e515da..bd70feb4 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/utl-obs.yaml @@ -1,10 +1,11 @@ type: package schema_version: 2.0.0.dev3 name: utl-obs -parent: package ftype: OBS6 -multi: true subtype: utility +parent: + - model + - package blocks: options: fields: diff --git a/autotest/dfns/test_migrate.py b/autotest/dfns/test_migrate.py index 7ffaa542..da428211 100644 --- a/autotest/dfns/test_migrate.py +++ b/autotest/dfns/test_migrate.py @@ -640,7 +640,9 @@ def test_migrate_link_selectors_resolve(linked_spec): def test_migrate_model_packages_exclude_utilities(linked_spec): children = linked_spec.children("gwf-nam") assert {"gwf-dis", "gwf-gnc", "gwf-mvr"} <= set(children) - assert not any(n.startswith("utl-") for n in children) + # but a model with observations reads an OBS6 file + assert [n for n in children if n.startswith("utl-")] == ["utl-obs"] + assert linked_spec.ftype_family("OBS6", "gwf-nam") == ["utl-obs"] def test_migrate_unlinked_files(linked_spec): @@ -657,7 +659,7 @@ def test_migrate_unlinked_files(linked_spec): "name, parent", [ ("utl-ts", "package"), - ("utl-obs", "package"), + ("utl-obs", ["model", "package"]), ( "utl-ncf", [f"{m}-{d}" for m in ("gwe", "gwf", "gwt", "prt") for d in ("dis", "disv")], @@ -682,6 +684,17 @@ def test_migrate_derives_parents_from_links(linked_spec, name, parent): assert linked_spec.components[name].parent == parent +@pytest.mark.parametrize("name, multi", [("utl-obs", False), ("utl-ts", True)]) +def test_migrate_multi_overrides(linked_spec, name, multi): + # flopy3 marks both multi-package, but MF6 reads one OBS6 per parent + assert linked_spec.components[name].multi is multi + + +def test_migrate_obs_keeps_any_model_dims(linked_spec): + # utl-obs's parent gains `model`; it still sees any model's dims + assert {"nodes", "nlay"} <= linked_spec.inherited_dims("utl-obs") + + def test_migrate_subpackage_keeps_model_dims(linked_spec): # tvk's parent is now gwf-npf, not `package`; it must still see grid dims assert {"nodes", "nlay"} <= linked_spec.inherited_dims("utl-tvk") diff --git a/autotest/dfns/test_schema_relations.py b/autotest/dfns/test_schema_relations.py index 4eba0286..d9613306 100644 --- a/autotest/dfns/test_schema_relations.py +++ b/autotest/dfns/test_schema_relations.py @@ -409,6 +409,10 @@ def test_covering_selector(): assert covering_selector([wel, spc], all_) == "package" # no single term covers a model and an exchange: concrete names assert covering_selector([nam, exg], all_) == ["exg-gwfgwf", "gwf-nam"] + # models and packages: their types + gwt = Model(name="gwt-nam") + all_[gwt.name] = gwt + assert covering_selector([wel, npf, nam, gwt], all_) == ["model", "package"] def test_children_list_parent(): 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 c45965f4..b892523c 100644 --- a/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py +++ b/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py @@ -7,6 +7,7 @@ from modflow_devtools.dfn import schema as v1 from modflow_devtools.dfns import schema as v2 +from modflow_devtools.dfns.observations import OBSERVATIONS from modflow_devtools.misc import try_literal_eval _IDENT_RE = re.compile(r"^[A-Za-z_]\w*$") @@ -114,6 +115,11 @@ def try_parse_bool(v: Any, default: bool = False) -> bool: # to the base type when matching name-file ftypes (InputLoadType.f90). _FTYPE_VARIANTS = frozenset({"evta", "rcha", "spca", "rivg", "chdg", "welg", "drng", "ghbg"}) +# Components flopy3 marks `multi-package` that MF6 reads at most once per +# parent. utl-obs: one OBS6 per package (BoundaryPackage.f90 errors on a +# second) and per model (the last row wins). The DFN header stays for flopy3. +_SINGLE_INSTANCE = frozenset({"utl-obs"}) + _MODEL_TYPES = frozenset({*_DEPENDENT_VARS, "prt"}) @@ -2158,7 +2164,8 @@ def _lookup(rname: str) -> dict | None: ) is_stress_pkg = is_stress_package(name, meta) subtype = "advanced" if is_advanced else "stress" if is_stress_pkg else None - return v2.Package(**d, subtype=subtype, multi=is_multi_package(meta)) + multi = is_multi_package(meta) and name not in _SINGLE_INSTANCE + return v2.Package(**d, subtype=subtype, multi=multi) def subpackage_keys(metas: Mapping[str, list[str]]) -> dict[str, str]: @@ -2206,8 +2213,13 @@ def link_components( The links themselves are made per file, by `to_v2_0_0_dev2`. Only concrete links count: a name file's type selectors (``model``, ``package``, ...) pick among components whose parents are already known. + The exception is OBS: a model with observation types reads one OBS6 file + from its packages block, so it links ``utl-obs`` like a package does. """ links = {name: _links(c) for name, c in components.items()} + for name in OBSERVATIONS: + if name.endswith("-nam") and name in components: + links[name] = [*links[name], "utl-obs"] for name, meta in metas.items(): if name not in components: