From c514f5b4c2df847ca233da000d58a6c046b7c6a8 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 7 Oct 2026 12:57:39 -0700 Subject: [PATCH] feat(dfns): say which model's grid a cellid refers to Make Array.cellid value-carrying, as index already is: true for the component's own model's grid, "1"/"2" for the first/second model of the enclosing context (EXGMNAMEA/EXGMNAMEB under an exchange). Strings, not ints, since True == 1; StrictBool so an int is rejected, not coerced. The migration sets cellidm1/cellidm2 on the exg-* components and cellidn/cellidm/cellidsj on gwf-gnc (1, 2, 1), as MF6 hard-codes. An exchange's cellids must name a model, and a row may not mix true with "1"/"2", union arms included. Co-Authored-By: Claude Opus 5.5 --- .../__snapshots__/v2.0.0.dev2/exg-chfgwf.json | 4 +- .../__snapshots__/v2.0.0.dev2/exg-chfgwf.toml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-chfgwf.yaml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwegwe.json | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwegwe.toml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwegwe.yaml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwfgwf.json | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwfgwf.toml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwfgwf.yaml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwtgwt.json | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwtgwt.toml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-gwtgwt.yaml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-olfgwf.json | 4 +- .../__snapshots__/v2.0.0.dev2/exg-olfgwf.toml | 4 +- .../__snapshots__/v2.0.0.dev2/exg-olfgwf.yaml | 4 +- .../__snapshots__/v2.0.0.dev2/gwf-gnc.json | 6 +- .../__snapshots__/v2.0.0.dev2/gwf-gnc.toml | 6 +- .../__snapshots__/v2.0.0.dev2/gwf-gnc.yaml | 6 +- .../__snapshots__/v2.0.0.dev3/exg-chfgwf.json | 4 +- .../__snapshots__/v2.0.0.dev3/exg-chfgwf.toml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-chfgwf.yaml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwegwe.json | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwegwe.toml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwegwe.yaml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwfgwf.json | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwfgwf.toml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwfgwf.yaml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwtgwt.json | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwtgwt.toml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-gwtgwt.yaml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-olfgwf.json | 4 +- .../__snapshots__/v2.0.0.dev3/exg-olfgwf.toml | 4 +- .../__snapshots__/v2.0.0.dev3/exg-olfgwf.yaml | 4 +- .../__snapshots__/v2.0.0.dev3/gwf-gnc.json | 6 +- .../__snapshots__/v2.0.0.dev3/gwf-gnc.toml | 6 +- .../__snapshots__/v2.0.0.dev3/gwf-gnc.yaml | 6 +- autotest/dfns/test_migrate.py | 54 ++++++++-- autotest/dfns/test_schema_relations.py | 98 ++++++++++++++++++- docs/md/dfnspec.md | 8 +- .../dfns/migrate_to_v2_0_0_dev2.py | 42 ++++---- modflow_devtools/dfns/schema.json | 15 ++- modflow_devtools/dfns/schema.py | 71 +++++++++++--- 42 files changed, 318 insertions(+), 126 deletions(-) diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.json index b3c850a1..61d0846e 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.json @@ -77,7 +77,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -89,7 +89,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.toml index 33e6b128..43cba1fe 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.toml @@ -66,7 +66,7 @@ description = "is the cellid of the cell in model 1, which must be the surface w tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -78,7 +78,7 @@ description = "is the cellid of the cell in model 2, which must be the groundwat tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.yaml index b31dcb7c..46001ca9 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-chfgwf.yaml @@ -72,7 +72,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -86,7 +86,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim bedleak: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.json index 9a04e488..dcdf2775 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.json @@ -151,7 +151,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -163,7 +163,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.toml index 8247087e..47890a2f 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.toml @@ -138,7 +138,7 @@ description = "is the cellid of the cell in model 1 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -150,7 +150,7 @@ description = "is the cellid of the cell in model 2 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.yaml index c37a3f8d..488dd386 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwegwe.yaml @@ -152,7 +152,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -166,7 +166,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim ihc: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.json index 0b4514f0..e813c780 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.json @@ -179,7 +179,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -191,7 +191,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.toml index 085aaa49..70e0ce85 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.toml @@ -162,7 +162,7 @@ description = "is the cellid of the cell in model 1 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -174,7 +174,7 @@ description = "is the cellid of the cell in model 2 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.yaml index 76ad6042..c237bab2 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwfgwf.yaml @@ -198,7 +198,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -212,7 +212,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim ihc: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.json index 3bbfcf01..7e897961 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.json @@ -151,7 +151,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -163,7 +163,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.toml index 8378cb15..12268c37 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.toml @@ -138,7 +138,7 @@ description = "is the cellid of the cell in model 1 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -150,7 +150,7 @@ description = "is the cellid of the cell in model 2 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.yaml index 96eb42e2..cb0b8444 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-gwtgwt.yaml @@ -150,7 +150,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -164,7 +164,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim ihc: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.json b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.json index 2f0161eb..f24d4152 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.json @@ -77,7 +77,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -89,7 +89,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.toml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.toml index 5d1afe45..cd7bb273 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.toml @@ -66,7 +66,7 @@ description = "is the cellid of the cell in model 1, which must be the surface w tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -78,7 +78,7 @@ description = "is the cellid of the cell in model 2, which must be the groundwat tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.yaml index d5f28cbd..3e0e3fe9 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/exg-olfgwf.yaml @@ -72,7 +72,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -86,7 +86,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim bedleak: 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 22b181b7..4df9da8a 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.json @@ -66,7 +66,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -78,7 +78,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] @@ -90,7 +90,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim", "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 0de9a632..82538484 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.toml @@ -57,7 +57,7 @@ description = "is the cellid of the cell, $n$, in which the ghost node is locate tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -69,7 +69,7 @@ description = "is the cellid of the connecting cell, $m$, to which flow occurs f tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] @@ -81,7 +81,7 @@ description = "is the array of CELLIDS for the contributing j cells, which contr tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", "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 6a6d9bcb..1ec6f498 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev2/gwf-gnc.yaml @@ -66,7 +66,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm: @@ -80,7 +80,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim cellidsj: @@ -98,7 +98,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim - numalphaj diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.json index caf2ae19..bce081c9 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.json @@ -77,7 +77,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -89,7 +89,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.toml index a517f13c..5fd79d51 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.toml @@ -66,7 +66,7 @@ description = "is the cellid of the cell in model 1, which must be the surface w tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -78,7 +78,7 @@ description = "is the cellid of the cell in model 2, which must be the groundwat tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.yaml index 703fde46..e41d6529 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-chfgwf.yaml @@ -72,7 +72,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -86,7 +86,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim bedleak: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.json index 2bc969fb..db1a58cc 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.json @@ -154,7 +154,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -166,7 +166,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.toml index fe6e6498..2c95c24f 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.toml @@ -141,7 +141,7 @@ description = "is the cellid of the cell in model 1 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -153,7 +153,7 @@ description = "is the cellid of the cell in model 2 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.yaml index 89ceecc5..fc32e9e6 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwegwe.yaml @@ -154,7 +154,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -168,7 +168,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim ihc: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.json index 6d95f77c..1e51f5b9 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.json @@ -182,7 +182,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -194,7 +194,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.toml index f282d378..afa2e658 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.toml @@ -165,7 +165,7 @@ description = "is the cellid of the cell in model 1 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -177,7 +177,7 @@ description = "is the cellid of the cell in model 2 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.yaml index 9b757442..aa416124 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwfgwf.yaml @@ -200,7 +200,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -214,7 +214,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim ihc: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.json index 2019d0e9..27cc75cb 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.json @@ -154,7 +154,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -166,7 +166,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.toml index 7daa1957..e5df6d64 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.toml @@ -141,7 +141,7 @@ description = "is the cellid of the cell in model 1 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -153,7 +153,7 @@ description = "is the cellid of the cell in model 2 as specified in the simulati tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.yaml index 64dd63ee..0879bbb4 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-gwtgwt.yaml @@ -152,7 +152,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -166,7 +166,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim ihc: diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.json b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.json index 201e5be3..bbee566e 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.json @@ -77,7 +77,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -89,7 +89,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.toml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.toml index bf750af0..6ae09e4c 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.toml @@ -66,7 +66,7 @@ description = "is the cellid of the cell in model 1, which must be the surface w tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -78,7 +78,7 @@ description = "is the cellid of the cell in model 2, which must be the groundwat tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] diff --git a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.yaml b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.yaml index 193f5c46..db976663 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/exg-olfgwf.yaml @@ -72,7 +72,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm2: @@ -86,7 +86,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim bedleak: 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 ca492e93..b1cc724b 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.json +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.json @@ -66,7 +66,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim" ] @@ -78,7 +78,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "2", "shape": [ "ncelldim" ] @@ -90,7 +90,7 @@ "tagged": false, "dtype": "integer", "index": true, - "cellid": true, + "cellid": "1", "shape": [ "ncelldim", "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 61f76cc3..75b1b8a0 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.toml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.toml @@ -57,7 +57,7 @@ description = "is the cellid of the cell, $n$, in which the ghost node is locate tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", ] @@ -69,7 +69,7 @@ description = "is the cellid of the connecting cell, $m$, to which flow occurs f tagged = false dtype = "integer" index = true -cellid = true +cellid = "2" shape = [ "ncelldim", ] @@ -81,7 +81,7 @@ description = "is the array of CELLIDS for the contributing j cells, which contr tagged = false dtype = "integer" index = true -cellid = true +cellid = "1" shape = [ "ncelldim", "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 5427158f..88843bf5 100644 --- a/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.yaml +++ b/autotest/dfns/__snapshots__/v2.0.0.dev3/gwf-gnc.yaml @@ -66,7 +66,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim cellidm: @@ -80,7 +80,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '2' shape: - ncelldim cellidsj: @@ -98,7 +98,7 @@ blocks: tagged: false dtype: integer index: true - cellid: true + cellid: '1' shape: - ncelldim - numalphaj diff --git a/autotest/dfns/test_migrate.py b/autotest/dfns/test_migrate.py index 7ffaa542..25865945 100644 --- a/autotest/dfns/test_migrate.py +++ b/autotest/dfns/test_migrate.py @@ -327,10 +327,12 @@ def test_migrate_auxiliary_stays_self_sizing(dfn_dir): def test_migrate_gnc_cellids(dfn_dir): component = _migrate_dev3(dfn_dir, "gwf-gnc") item = component.blocks["gncdata"].fields["gncdata"].item - for name, shape in [ - ("cellidn", ["ncelldim"]), - ("cellidm", ["ncelldim"]), - ("cellidsj", ["ncelldim", "numalphaj"]), + # Under an exchange, `cellidn` and `cellidsj` are in model 1's grid and + # `cellidm` in model 2's (MF6's GhostNode.f90). + for name, shape, model in [ + ("cellidn", ["ncelldim"], "1"), + ("cellidm", ["ncelldim"], "2"), + ("cellidsj", ["ncelldim", "numalphaj"], "1"), ]: field = item.fields[name] assert isinstance(field, v2.Array) @@ -338,19 +340,55 @@ def test_migrate_gnc_cellids(dfn_dir): "integer", shape, True, - True, + model, ) assert not item.fields["alphasj"].cellid -@pytest.mark.parametrize("name", ["exg-gwfgwf", "exg-gwtgwt"]) +@pytest.mark.parametrize( + "name", ["exg-chfgwf", "exg-gwegwe", "exg-gwfgwf", "exg-gwtgwt", "exg-olfgwf"] +) def test_migrate_exchange_cellids(dfn_dir, name): component = _migrate_dev3(dfn_dir, name) + assert component.subtype == "exchange" item = component.blocks["exchangedata"].fields["exchangedata"].item - for col in ("cellidm1", "cellidm2"): + for col, model in (("cellidm1", "1"), ("cellidm2", "2")): field = item.fields[col] assert isinstance(field, v2.Array) - assert (field.shape, field.index, field.cellid) == (["ncelldim"], True, True) + assert (field.shape, field.index, field.cellid) == (["ncelldim"], True, model) + + +def test_migrate_cellid_models_round_trip(dev3): + # "1"/"2" stay strings on disk in every format, and load back as such. + out, fmt = dev3 + raw = _load(out / f"exg-gwfgwf.{fmt}", fmt) + cols = raw["blocks"]["exchangedata"]["fields"]["exchangedata"]["item"]["fields"] + assert (cols["cellidm1"]["cellid"], cols["cellidm2"]["cellid"]) == ("1", "2") + dfns = v2.Dfns.load(out) + gnc = dfns.components["gwf-gnc"].blocks["gncdata"].fields["gncdata"].item.fields + assert [gnc[c].cellid for c in ("cellidn", "cellidm", "cellidsj")] == ["1", "2", "1"] + + +@pytest.mark.parametrize("name", ["gwf-chd", "gwf-wel", "gwf-hfb", "gwt-cnc"]) +def test_migrate_own_grid_cellids_stay_true(dfn_dir, name): + # Only the `_CELLID_FIELDS` columns name a model; every other cellid is in + # the component's own model's grid. + def _cellids(field): + if isinstance(field, v2.Array) and field.cellid: + yield field + elif isinstance(field, v2.List): + yield from _cellids(field.item) + elif isinstance(field, v2.Record): + for f in field.fields.values(): + yield from _cellids(f) + elif isinstance(field, v2.Union): + for f in field.arms.values(): + yield from _cellids(f) + + component = _migrate_dev3(dfn_dir, name) + cellids = [c for b in component.blocks.values() for f in b.fields.values() for c in _cellids(f)] + assert cellids + assert all(c.cellid is True for c in cellids) @pytest.mark.parametrize( diff --git a/autotest/dfns/test_schema_relations.py b/autotest/dfns/test_schema_relations.py index 4eba0286..59b00e5c 100644 --- a/autotest/dfns/test_schema_relations.py +++ b/autotest/dfns/test_schema_relations.py @@ -217,10 +217,102 @@ def test_dfns_validate_cellid_in_list_item(field): assert "gwf-gnc" in spec.components +def _exchange_ctx(*fields): + item = Record(name="item", fields={f.name: f for f in fields}) + block = Block(name="data", fields={"data": List(name="data", item=item)}) + exg = Package(name="exg-gwfgwf", subtype="exchange", blocks={"data": block}) + return {"exg-gwfgwf": exg} + + +def _cellid_col(name, cellid, shape=("ncelldim",)): + return Array(name=name, dtype="integer", shape=list(shape), index=True, cellid=cellid) + + def test_dfns_validate_cellid_in_exchange(): - field = Array(name="cellidm1", dtype="integer", shape=["ncelldim"], index=True, cellid=True) - spec = Dfns(components=_cellid_ctx(field, name="exg-gwfgwf", parent=None)) - assert "exg-gwfgwf" in spec.components + fields = (_cellid_col("cellidm1", "1"), _cellid_col("cellidm2", "2")) + spec = Dfns(components=_exchange_ctx(*fields)) + item = spec.components["exg-gwfgwf"].blocks["data"].fields["data"].item + assert {n: f.cellid for n, f in item.fields.items()} == {"cellidm1": "1", "cellidm2": "2"} + + +def test_dfns_validate_exchange_cellid_must_name_model(): + fields = (_cellid_col("cellidm1", True), _cellid_col("cellidm2", True)) + with pytest.raises(ValueError, match="exchange's cellid must say which model"): + Dfns(components=_exchange_ctx(*fields)) + + +def test_dfns_validate_cellid_models_under_a_model(): + # GNC under a model: both models are that model, so "1"/"2" are valid. + fields = ( + _cellid_col("cellidn", "1"), + _cellid_col("cellidm", "2"), + _cellid_col("cellidsj", "1", shape=("ncelldim", "n")), + ) + item = Record(name="item", fields={f.name: f for f in fields}) + block = Block(name="data", fields={"data": List(name="data", item=item)}) + pkg = Package( + name="gwf-gnc", + parent="gwf-nam", + blocks={"data": block}, + dims={"n": InputDim(value="2", scope="model")}, + ) + spec = Dfns(components={"gwf-nam": Model(name="gwf-nam", blocks=None), "gwf-gnc": pkg}) + assert "gwf-gnc" in spec.components + + +def test_dfns_validate_cellid_no_mixing(): + item = Record(name="item", fields={"a": _cellid_col("a", "1"), "b": _cellid_col("b", True)}) + block = Block(name="data", fields={"data": List(name="data", item=item)}) + pkg = Package(name="gwf-gnc", parent="gwf-nam", blocks={"data": block}) + with pytest.raises(ValueError, match="mix cellid=True with '1'/'2'"): + Dfns(components={"gwf-nam": Model(name="gwf-nam", blocks=None), "gwf-gnc": pkg}) + + +def _union_cellid_pkg(arm_cellid, sibling_cellid): + union = Union( + name="id", + tagged=False, + arms={ + "cellid": _cellid_col("cellid", arm_cellid), + "boundname": String(name="boundname", tagged=False), + }, + ) + item = Record(name="item", fields={"a": _cellid_col("a", sibling_cellid), "id": union}) + block = Block(name="data", fields={"data": List(name="data", item=item)}) + pkg = Package(name="gwf-gnc", parent="gwf-nam", blocks={"data": block}) + return {"gwf-nam": Model(name="gwf-nam", blocks=None), "gwf-gnc": pkg} + + +def test_dfns_validate_cellid_no_mixing_across_union_arm(): + # A union arm's cellid is read in the same row as its sibling columns. + with pytest.raises(ValueError, match="mix cellid=True with '1'/'2'"): + Dfns(components=_union_cellid_pkg(True, "1")) + + +@pytest.mark.parametrize("cellid", [True, "1"]) +def test_dfns_validate_cellid_union_arm_consistent(cellid): + assert "gwf-gnc" in Dfns(components=_union_cellid_pkg(cellid, cellid)).components + + +def test_dfns_validate_cellid_union_arms_are_alternatives(): + # Arms are never read together, so arms disagreeing is not mixing; each + # is checked against the row's other cellid columns. + union = Union( + name="id", + tagged=False, + arms={"x": _cellid_col("x", "1"), "y": _cellid_col("y", "2")}, + ) + item = Record(name="item", fields={"a": _cellid_col("a", "1"), "id": union}) + block = Block(name="data", fields={"data": List(name="data", item=item)}) + pkg = Package(name="gwf-gnc", parent="gwf-nam", blocks={"data": block}) + Dfns(components={"gwf-nam": Model(name="gwf-nam", blocks=None), "gwf-gnc": pkg}) + + +@pytest.mark.parametrize("value", [1, 2, 0, "true", "3", "a"]) +def test_array_cellid_rejects_non_model_values(value): + # `True == 1`: an int must never be read as (or coerced to) a model or `True`. + with pytest.raises(ValueError, match="cellid"): + _cellid_col("cellid", value) def test_array_cellid_requires_integer_dtype(): diff --git a/docs/md/dfnspec.md b/docs/md/dfnspec.md index 4719f9ca..36071618 100644 --- a/docs/md/dfnspec.md +++ b/docs/md/dfnspec.md @@ -507,9 +507,13 @@ Dimensions are listed fastest-varying first, i.e. in the order elements are read ###### `cellid` -`boolean (default: false)`. Marks the array as a **cellid**, or several: a grid cell reference, resolved against the grid (DIS/DISV/DISU) the column refers to. The first axis must be `ncelldim`, the cell's components (layer, row, column for DIS; layer, cell2d for DISV; node for DISU). Any further axes count cellids. A single cellid has shape `["ncelldim"]`; `gwf-gnc`'s `cellidsj` has shape `["ncelldim", "numalphaj"]`, `numalphaj` cellids written one after another. Only valid when `dtype` is `"integer"`, `index` is set (cellids are 1-based), and the array is a column in a list item. +`boolean | "1" | "2" (default: false)`. Marks the array as a **cellid**, or several: a grid cell reference, resolved against the grid (DIS/DISV/DISU) the column refers to. The first axis must be `ncelldim`, the cell's components (layer, row, column for DIS; layer, cell2d for DISV; node for DISU). Any further axes count cellids. A single cellid has shape `["ncelldim"]`; `gwf-gnc`'s `cellidsj` has shape `["ncelldim", "numalphaj"]`, `numalphaj` cellids written one after another. Only valid when `dtype` is `"integer"`, `index` is set (cellids are 1-based), and the array is a column in a list item. -The leading `ncelldim` is not resolved as a dim in the component's own scope: its value depends on the grid the column refers to, which in an exchange is one of two models' grids, neither of them the exchange's parent. Which model a column refers to is not yet expressed in the schema: in an exchange, `cellidm1` refers to model 1 and `cellidm2` to model 2; in `gwf-gnc` under an exchange, `cellidn` and `cellidsj` refer to model 1 and `cellidm` to model 2. +The value says which grid: `true` for the grid of the component's own model; `"1"` or `"2"` for the grid of the first or second model of the enclosing context, i.e. `EXGMNAMEA` or `EXGMNAMEB` under an exchange. Under a single model, both are that model, so a component read in either context (e.g. `gwf-gnc`) gives the value unconditionally. A cellid column in an exchange ([`subtype`](#subtype) `"exchange"`) must be `"1"` or `"2"`, since an exchange has no grid of its own, and a list item giving `"1"` or `"2"` on one cellid column must give one on every cellid column. For example, in an exchange `cellidm1` is `"1"` and `cellidm2` is `"2"`; in `gwf-gnc`, `cellidn` and `cellidsj` are `"1"` and `cellidm` is `"2"`. The position of a column says nothing about its model: GNC's columns, in order, refer to models 1, 2 and 1. + +The leading `ncelldim` is not resolved as a dim in the component's own scope: its value is the `ncelldim` of the grid the column refers to, by the above. + +**Note:** The model values are strings, not integers, as `true == 1` in many languages (e.g. Python). A consumer should test for the strings (`cellid in ("1", "2")`), never compare `cellid` to an integer; and a reader rejects an integer `cellid`. #### Record 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..9747a830 100644 --- a/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py +++ b/modflow_devtools/dfns/migrate_to_v2_0_0_dev2.py @@ -583,27 +583,32 @@ def _mark_lonely_pk(blocks: dict[str, v2.Block]) -> dict[str, v2.Block]: # per-field allowlist, since v1 has no attribute that signals a cellid # (`numeric_index` only means "needs 1-based/0-based conversion"). In every # entry here the list field's own name matches its enclosing block's name. -_CELLID_FIELDS: dict[str, tuple[str, tuple[str, ...]]] = { - "exg-chfgwf": ("exchangedata", ("cellidm1", "cellidm2")), - "exg-gwegwe": ("exchangedata", ("cellidm1", "cellidm2")), - "exg-gwfgwf": ("exchangedata", ("cellidm1", "cellidm2")), - "exg-gwtgwt": ("exchangedata", ("cellidm1", "cellidm2")), - "exg-olfgwf": ("exchangedata", ("cellidm1", "cellidm2")), - "gwf-gnc": ("gncdata", ("cellidm", "cellidn", "cellidsj")), +# +# Each column also refers to one of two models' grids (see `v2.Cellid`), which +# v1 only says in prose and MF6 hard-codes (IDM's `StructArray.f90` maps +# `CELLIDM1`/`CELLIDM2` to `EXGMNAMEA`/`EXGMNAMEB`; `GhostNode.f90` reads +# `cellidn` and `cellidsj` in model 1's grid, `cellidm` in model 2's). +_CELLID_FIELDS: dict[str, tuple[str, dict[str, Literal["1", "2"]]]] = { + "exg-chfgwf": ("exchangedata", {"cellidm1": "1", "cellidm2": "2"}), + "exg-gwegwe": ("exchangedata", {"cellidm1": "1", "cellidm2": "2"}), + "exg-gwfgwf": ("exchangedata", {"cellidm1": "1", "cellidm2": "2"}), + "exg-gwtgwt": ("exchangedata", {"cellidm1": "1", "cellidm2": "2"}), + "exg-olfgwf": ("exchangedata", {"cellidm1": "1", "cellidm2": "2"}), + "gwf-gnc": ("gncdata", {"cellidn": "1", "cellidm": "2", "cellidsj": "1"}), } -def _as_cellid(field: v2.InputField) -> v2.InputField: +def _as_cellid(field: v2.InputField, model: Literal["1", "2"]) -> v2.InputField: """Rewrite an integer column as an `ncelldim`-led integer array, the axis - `ncelldim` varying fastest (each cellid's components are contiguous).""" + `ncelldim` varying fastest (each cellid's components are contiguous), + referring to the given model's grid. `_mark_cellids` sets `index`.""" if isinstance(field, v2.Integer): base = field.model_dump(include=set(v2.InputFieldBase.model_fields)) base.pop("type", None) # the serializer restores it - return v2.Array(**base, dtype="integer", shape=["ncelldim"]) + return v2.Array(**base, dtype="integer", shape=["ncelldim"], cellid=model, index=True) if isinstance(field, v2.Array) and field.dtype == "integer": - if field.shape[:1] == ["ncelldim"]: - return field - return field.model_copy(update={"shape": ["ncelldim", *field.shape]}) + shape = field.shape if field.shape[:1] == ["ncelldim"] else ["ncelldim", *field.shape] + return field.model_copy(update={"shape": shape, "cellid": model}) return field @@ -613,7 +618,7 @@ def _fix_cellid_fields(name: str, blocks: dict[str, v2.Block]) -> dict[str, v2.B entry = _CELLID_FIELDS.get(name) if entry is None: return blocks - block_name, field_names = entry + block_name, models = entry block = blocks.get(block_name) if block is None: return blocks @@ -622,7 +627,9 @@ def _fix_cellid_fields(name: str, blocks: dict[str, v2.Block]) -> dict[str, v2.B return blocks item = list_field.item updates = { - fname: _as_cellid(item.fields[fname]) for fname in field_names if fname in item.fields + fname: _as_cellid(item.fields[fname], model) + for fname, model in models.items() + if fname in item.fields } new_item = item.model_copy(update={"fields": {**item.fields, **updates}}) new_list = list_field.model_copy(update={"item": new_item}) @@ -670,12 +677,13 @@ def _link(field: Any) -> Any: def _mark_cellids(blocks: dict[str, v2.Block]) -> dict[str, v2.Block]: """Mark every cellid column `index=True, cellid=True`: an integer array in a list item whose first (fastest-varying) axis is `ncelldim`. Every v1 field - shaped by `ncelldim` is a cellid, so this needs no allowlist.""" + shaped by `ncelldim` is a cellid, so this needs no allowlist. A column + `_fix_cellid_fields` already gave a model (`"1"`/`"2"`) keeps it.""" def _mark(field: Any) -> Any: if isinstance(field, v2.Array): if field.dtype == "integer" and field.shape[:1] == ["ncelldim"]: - return field.model_copy(update={"index": True, "cellid": True}) + return field.model_copy(update={"index": True, "cellid": field.cellid or True}) return field if isinstance(field, v2.Record): fields = {n: _mark(f) for n, f in field.fields.items()} diff --git a/modflow_devtools/dfns/schema.json b/modflow_devtools/dfns/schema.json index 59809150..fabbf5b2 100644 --- a/modflow_devtools/dfns/schema.json +++ b/modflow_devtools/dfns/schema.json @@ -148,9 +148,20 @@ "title": "Fk" }, "cellid": { + "anyOf": [ + { + "type": "boolean" + }, + { + "enum": [ + "1", + "2" + ], + "type": "string" + } + ], "default": false, - "title": "Cellid", - "type": "boolean" + "title": "Cellid" } }, "required": [ diff --git a/modflow_devtools/dfns/schema.py b/modflow_devtools/dfns/schema.py index 949701f9..a65d722c 100644 --- a/modflow_devtools/dfns/schema.py +++ b/modflow_devtools/dfns/schema.py @@ -10,6 +10,7 @@ from pydantic import ( BaseModel, SerializationInfo, + StrictBool, computed_field, model_serializer, model_validator, @@ -97,6 +98,13 @@ class String(InputFieldBase): # only the field's description defines (e.g. SFR's `ic`). Index = bool | Literal["signed"] +# `True`: a cellid in the grid of the component's own model. `"1"` / `"2"`: a +# cellid in the grid of the first / second model of the enclosing context, i.e. +# `EXGMNAMEA` / `EXGMNAMEB` under an exchange; under a single model both are +# that model. Strings, not ints, since `True == 1`; and `StrictBool`, so an int +# `1` is rejected rather than coerced to `True`. +Cellid = StrictBool | Literal["1", "2"] + class Integer(InputFieldBase): type: Literal["integer"] = PydanticField(default="integer", frozen=True) @@ -144,7 +152,8 @@ class Array(InputFieldBase): # A cellid, or several: a grid cell reference resolved against the grid # the column refers to (DIS/DISV/DISU). The first (fastest-varying) axis # is `ncelldim`, one cellid's components; any further axes count cellids. - cellid: bool = False + # See `Cellid` for which grid. + cellid: Cellid = False @model_validator(mode="after") def _check_index_dtype(self) -> "Array": @@ -155,13 +164,14 @@ def _check_index_dtype(self) -> "Array": ) if self.cellid and self.dtype != "integer": raise ValueError( - f"Array {self.name!r}: cellid=True requires dtype='integer', got {self.dtype!r}" + f"Array {self.name!r}: cellid={self.cellid!r} requires dtype='integer', " + f"got {self.dtype!r}" ) if self.cellid and not self.index: - raise ValueError(f"Array {self.name!r}: cellid=True requires index=True") + raise ValueError(f"Array {self.name!r}: cellid={self.cellid!r} requires index=True") if self.cellid and self.shape[:1] != ["ncelldim"]: raise ValueError( - f"Array {self.name!r}: cellid=True requires shape to start with " + f"Array {self.name!r}: cellid={self.cellid!r} requires shape to start with " f"'ncelldim', got {self.shape!r}" ) if self.fk is not None and self.dtype != "integer": @@ -1563,31 +1573,60 @@ def _validate_file_links(component: "ComponentBase", spec: "Dfns") -> None: def _validate_cellid_fields(component: "ComponentBase") -> None: """ - A cellid array (`cellid=True`, see `Array.cellid`) is only meaningful as a + A cellid array (`cellid` set, see `Array.cellid`) is only meaningful as a column in a list item, where each row names its cell(s), or in an observation field, which reads like one. Its dtype and shape are checked by `Array` itself. + + Which grid a cellid refers to (see `Cellid`) must be unambiguous: an + exchange's cellids refer to one of its two models, never its own, so each + must say which (`"1"` / `"2"`); and a list item naming either model on one + cellid column must name one on every cellid column (GNC's `cellidn`, + `cellidm`, `cellidsj`). """ + exchange = getattr(component, "subtype", None) == "exchange" - def _check(field: "InputField", in_item: bool) -> None: - if isinstance(field, Array) and field.cellid and not in_item: - raise ValueError( - f"Array {field.name!r}: cellid=True is only valid on a column in a list item" - ) + def _cellids(field: "InputField", in_item: bool) -> list[list[Array]]: + """The cellid columns of each way a row may read `field`: one + alternative per union arm, combined with the cellids beside it.""" + if isinstance(field, Array) and field.cellid: + if not in_item: + raise ValueError( + f"Array {field.name!r}: cellid={field.cellid!r} is only valid on a " + f"column in a list item" + ) + if exchange and field.cellid is True: + raise ValueError( + f"Array {field.name!r}: an exchange's cellid must say which model's " + f"grid it refers to (cellid='1' or '2'), got cellid=True" + ) + return [[field]] if isinstance(field, List): - _check(field.item, True) + _check_item(field.item) elif isinstance(field, Record): + rows: list[list[Array]] = [[]] for subfield in field.fields.values(): - _check(subfield, in_item) + rows = [row + alt for row in rows for alt in _cellids(subfield, in_item)] + return rows elif isinstance(field, Union): - for arm in field.arms.values(): - _check(arm, in_item) + # Arms are alternatives, never read together in one row. + return [alt for arm in field.arms.values() for alt in _cellids(arm, in_item)] + return [[]] + + def _check_item(item: "InputField") -> None: + for cellids in _cellids(item, True): + if len({c.cellid is True for c in cellids}) > 1: + names = {c.name: c.cellid for c in cellids} + raise ValueError( + f"Cellid columns {names!r} mix cellid=True with '1'/'2': if one names " + f"a model, every cellid column in the item must" + ) for block in (component.blocks or {}).values(): for field in block.fields.values(): - _check(field, False) + _cellids(field, False) for obs_field in (component.observations or {}).values(): - _check(obs_field, True) + _check_item(obs_field) def _obs_columns(field: "InputField") -> list[int]: