Skip to content

Commit 548e5e8

Browse files
authored
feat(dfn): structure dfn component hierarchy (#228)
much in the same way that we structured dfns internally (flat to tree), this organizes the components into a tree which represents valid mf6 simulation structure. tentative, may be subject to change
1 parent adc3771 commit 548e5e8

3 files changed

Lines changed: 140 additions & 2 deletions

File tree

autotest/test_dfn.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def pytest_generate_tests(metafunc):
3030
convert(DFN_DIR, TOML_DIR)
3131
dfn_paths = list(DFN_DIR.glob("*.dfn"))
3232
assert all(
33-
(TOML_DIR / f"{dfn.stem}.toml").is_file()
33+
(TOML_DIR / f"{dfn.stem.replace('-nam', '')}.toml").is_file()
3434
for dfn in dfn_paths
3535
if "common" not in dfn.stem
3636
)
@@ -61,3 +61,65 @@ def test_load_v2(toml_name):
6161
def test_load_all(version):
6262
dfns = Dfn.load_all(VERSIONS[version], version=version)
6363
assert any(dfns)
64+
65+
66+
@requires_pkg("boltons")
67+
def test_load_tree():
68+
import tempfile
69+
70+
import tomli
71+
72+
with tempfile.TemporaryDirectory() as tmp_dir:
73+
tmp_path = Path(tmp_dir)
74+
convert(DFN_DIR, tmp_path)
75+
76+
# Test file conversion and naming
77+
assert (tmp_path / "sim.toml").exists()
78+
assert (tmp_path / "gwf.toml").exists()
79+
assert not (tmp_path / "sim-nam.toml").exists()
80+
81+
# Test parent relationships in files
82+
with (tmp_path / "sim.toml").open("rb") as f:
83+
sim_data = tomli.load(f)
84+
assert sim_data["name"] == "sim"
85+
assert "parent" not in sim_data
86+
87+
with (tmp_path / "gwf.toml").open("rb") as f:
88+
gwf_data = tomli.load(f)
89+
assert gwf_data["name"] == "gwf"
90+
assert gwf_data["parent"] == "sim"
91+
92+
# Test hierarchy enforcement and completeness
93+
dfns = Dfn.load_all(tmp_path, version=2)
94+
roots = [name for name, dfn in dfns.items() if not dfn.get("parent")]
95+
assert len(roots) == 1
96+
assert roots[0] == "sim"
97+
98+
for dfn in dfns.values():
99+
parent = dfn.get("parent")
100+
if parent:
101+
assert parent in dfns
102+
103+
# Test tree building and navigation
104+
tree = Dfn.load_tree(tmp_path, version=2)
105+
assert "sim" in tree
106+
assert tree["sim"]["name"] == "sim"
107+
108+
for model_type in ["gwf", "gwt", "gwe"]:
109+
if model_type in tree["sim"]:
110+
assert tree["sim"][model_type]["name"] == model_type
111+
assert tree["sim"][model_type]["parent"] == "sim"
112+
113+
if "gwf" in tree["sim"]:
114+
gwf_packages = [
115+
k
116+
for k in tree["sim"]["gwf"].keys()
117+
if k.startswith("gwf-") and isinstance(tree["sim"]["gwf"][k], dict)
118+
]
119+
assert len(gwf_packages) > 0
120+
121+
if "gwf-dis" in tree["sim"]["gwf"]:
122+
dis = tree["sim"]["gwf"]["gwf-dis"]
123+
assert dis["name"] == "gwf-dis"
124+
assert dis["parent"] == "gwf"
125+
assert "options" in dis or "dimensions" in dis

modflow_devtools/dfn.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ class Dfn(TypedDict):
190190
name: str
191191
advanced: bool = False
192192
multi: bool = False
193+
parent: str | None = None
193194
ref: Ref | None = None
194195
sln: Sln | None = None
195196
fkeys: Dfns | None = None
@@ -639,6 +640,41 @@ def load_all(dfndir: PathLike, version: FormatVersion = 1) -> Dfns:
639640
else:
640641
raise ValueError(f"Unsupported version, expected one of {version.__args__}")
641642

643+
@staticmethod
644+
def load_tree(dfndir: PathLike, version: FormatVersion = 2) -> dict:
645+
"""Load all definitions and return as hierarchical tree."""
646+
dfns = Dfn.load_all(dfndir, version)
647+
return infer_tree(dfns)
648+
649+
650+
def infer_tree(dfns: dict[str, Dfn]) -> dict:
651+
"""Infer the component hierarchy from definitions.
652+
653+
Enforces single root requirement - must be exactly one component
654+
with no parent, and it must be named 'sim'.
655+
"""
656+
roots = [name for name, dfn in dfns.items() if not dfn.get("parent")]
657+
658+
if len(roots) != 1:
659+
raise ValueError(
660+
f"Expected exactly one root component, found {len(roots)}: {roots}"
661+
)
662+
663+
root_name = roots[0]
664+
if root_name != "sim":
665+
raise ValueError(f"Root component must be named 'sim', found '{root_name}'")
666+
667+
def add_children(node_name: str) -> dict:
668+
node = dfns[node_name].copy()
669+
children = [
670+
name for name, dfn in dfns.items() if dfn.get("parent") == node_name
671+
]
672+
for child in children:
673+
node[child] = add_children(child)
674+
return node
675+
676+
return {root_name: add_children(root_name)}
677+
642678

643679
def get_dfns(
644680
owner: str, repo: str, ref: str, outdir: str | PathLike, verbose: bool = False

modflow_devtools/dfn2toml.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,47 @@ def convert(indir: PathLike, outdir: PathLike):
1717
outdir = Path(outdir).expanduser().absolute()
1818
outdir.mkdir(exist_ok=True, parents=True)
1919
for dfn in Dfn.load_all(indir).values():
20-
with Path.open(outdir / f"{dfn['name']}.toml", "wb") as f:
20+
dfn_name = dfn["name"]
21+
22+
# Determine new filename and parent relationship
23+
if dfn_name == "sim-nam":
24+
filename = "sim.toml"
25+
dfn = dfn.copy()
26+
dfn["name"] = "sim"
27+
# No parent - this is root
28+
elif dfn_name.endswith("-nam"):
29+
# Model name files: gwf-nam -> gwf.toml, parent = "sim"
30+
model_type = dfn_name[:-4] # Remove "-nam"
31+
filename = f"{model_type}.toml"
32+
dfn = dfn.copy()
33+
dfn["name"] = model_type
34+
dfn["parent"] = "sim"
35+
elif dfn_name.startswith("exg-"):
36+
# Exchanges: parent = "sim"
37+
filename = f"{dfn_name}.toml"
38+
dfn = dfn.copy()
39+
dfn["parent"] = "sim"
40+
elif dfn_name.startswith("sln-"):
41+
# Solutions: parent = "sim"
42+
filename = f"{dfn_name}.toml"
43+
dfn = dfn.copy()
44+
dfn["parent"] = "sim"
45+
elif dfn_name.startswith("utl-"):
46+
# Utilities: parent = "sim"
47+
filename = f"{dfn_name}.toml"
48+
dfn = dfn.copy()
49+
dfn["parent"] = "sim"
50+
elif "-" in dfn_name:
51+
# Packages: gwf-dis -> parent = "gwf"
52+
model_type = dfn_name.split("-")[0]
53+
filename = f"{dfn_name}.toml"
54+
dfn = dfn.copy()
55+
dfn["parent"] = model_type
56+
else:
57+
# Default case
58+
filename = f"{dfn_name}.toml"
59+
60+
with Path.open(outdir / filename, "wb") as f:
2161

2262
def drop_none_or_empty(path, key, value):
2363
if value is None or value == "" or value == [] or value == {}:

0 commit comments

Comments
 (0)