Skip to content

Commit 3182e11

Browse files
authored
generate component specific grammars (#181)
rough first cut at a reader supported by typed grammar generation. typed parser/transformer working for a few test pkgs. period data is just defined in terms of nondescript records, everything else is properly defined. maybe eventually we can properly type record fields. I included generated grammars here for now so we can easily see what they look like. but maybe at some point it will be cleaner not to version them.
1 parent c10d4d2 commit 3182e11

File tree

171 files changed

+6147
-679
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

171 files changed

+6147
-679
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ venv/
99
*.egg-info
1010
temp/
1111
.coverage
12+
flopy4/mf6/codec/reader/grammar/generated/*.lark

flopy4/mf6/codec/filters.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Filters shared by both reader and writer."""
2+
3+
from typing import Any
4+
5+
import xarray as xr
6+
from modflow_devtools.dfn.schema.field import Field
7+
from modflow_devtools.dfn.schema.v2 import FieldType
8+
9+
10+
def field_type(value: Any) -> FieldType:
11+
"""Get a value's type according to the MF6 specification."""
12+
13+
if isinstance(value, Field):
14+
return value.type
15+
if isinstance(value, bool):
16+
return "keyword"
17+
if isinstance(value, int):
18+
return "integer"
19+
if isinstance(value, float):
20+
return "double"
21+
if isinstance(value, str):
22+
return "string"
23+
if isinstance(value, tuple):
24+
return "record"
25+
if isinstance(value, xr.DataArray):
26+
if value.dtype == "object":
27+
return "list"
28+
return "array"
29+
if isinstance(value, (list, dict, xr.Dataset)):
30+
return "list"
31+
raise ValueError(f"Unsupported field type: {type(value)}")

flopy4/mf6/codec/reader/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from typing import IO, Any
22

3-
from flopy4.mf6.codec.reader.parser import make_basic_parser
3+
from flopy4.mf6.codec.reader.parser import get_basic_parser
44
from flopy4.mf6.codec.reader.transformer import BasicTransformer
55

6-
BASIC_PARSER = make_basic_parser()
6+
BASIC_PARSER = get_basic_parser()
77
BASIC_TRANSFORMER = BasicTransformer()
88

99

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Convert (TOML/v2) DFNs to Lark grammars."""
2+
3+
import argparse
4+
from os import PathLike
5+
from pathlib import Path
6+
7+
from modflow_devtools.dfn import load_flat, map
8+
9+
from flopy4.mf6.codec.reader.grammar import make_all_grammars
10+
11+
_GRAMMAR_MODULE = Path(__file__).parent / "grammar"
12+
_GRAMMAR_GEN_DIR = _GRAMMAR_MODULE / "generated"
13+
14+
15+
def generate(dfndir: PathLike, outdir: PathLike):
16+
"""Generate lark grammars from DFNs."""
17+
dfndir = Path(dfndir).expanduser().absolute()
18+
outdir = Path(outdir).expanduser().absolute()
19+
outdir.mkdir(exist_ok=True, parents=True)
20+
dfns_v1 = load_flat(dfndir)
21+
dfns_v2 = {name: map(dfn, schema_version=2) for name, dfn in dfns_v1.items()}
22+
make_all_grammars(dfns_v2, outdir)
23+
24+
25+
def main():
26+
parser = argparse.ArgumentParser(description="Generate lark grammars from DFNs.")
27+
parser.add_argument(
28+
"--dfndir",
29+
"-d",
30+
type=str,
31+
help="Directory containing DFN files.",
32+
)
33+
parser.add_argument(
34+
"--outdir",
35+
"-o",
36+
help="Output directory.",
37+
default=_GRAMMAR_GEN_DIR,
38+
)
39+
args = parser.parse_args()
40+
generate(args.dfndir, args.outdir)
41+
42+
43+
if __name__ == "__main__":
44+
main()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from os import PathLike
2+
from pathlib import Path
3+
4+
import jinja2
5+
from modflow_devtools.dfn import Dfn
6+
7+
from flopy4.mf6.codec.reader.grammar import filters
8+
9+
10+
def _get_template_env():
11+
loader = jinja2.PackageLoader("flopy4", "mf6/codec/reader/grammar/templates/")
12+
env = jinja2.Environment(
13+
loader=loader,
14+
trim_blocks=True,
15+
lstrip_blocks=True,
16+
keep_trailing_newline=True,
17+
)
18+
env.filters["field_type"] = filters.field_type
19+
env.filters["record_child_type"] = filters.record_child_type
20+
return env
21+
22+
23+
def _get_template_data(blocks) -> tuple[list[dict], dict[str, object]]:
24+
all_blocks = []
25+
all_fields = {}
26+
27+
for block_name, block_fields in blocks.items():
28+
period_groups = filters.group_period_fields(block_fields)
29+
has_index = block_name == "period"
30+
31+
recarrays = []
32+
grouped_field_names = set()
33+
if period_groups:
34+
for field_names in period_groups.values():
35+
recarray_name = filters.get_recarray_name(block_name)
36+
recarrays.append({"name": recarray_name, "fields": field_names})
37+
grouped_field_names.update(field_names)
38+
39+
all_field_names = list(block_fields.keys())
40+
standalone_fields = [f for f in all_field_names if f not in grouped_field_names]
41+
42+
all_fields.update(block_fields)
43+
all_blocks.append(
44+
{
45+
"name": block_name,
46+
"has_index": has_index,
47+
"standalone_fields": standalone_fields,
48+
"recarrays": recarrays,
49+
}
50+
)
51+
52+
return all_blocks, all_fields
53+
54+
55+
def make_grammar(dfn: Dfn, outdir: PathLike):
56+
"""Generate a Lark grammar file for a single component."""
57+
outdir = Path(outdir).expanduser().resolve().absolute()
58+
env = _get_template_env()
59+
template = env.get_template("component.lark.jinja")
60+
target_path = outdir / f"{dfn.name}.lark"
61+
blocks, fields = _get_template_data(dfn.blocks)
62+
with open(target_path, "w") as f:
63+
name = dfn.name
64+
f.write(template.render(name=name, blocks=blocks, fields=fields))
65+
66+
67+
def make_all_grammars(dfns: dict[str, Dfn], outdir: PathLike):
68+
"""Generate grammars for all components."""
69+
outdir = Path(outdir).expanduser().resolve().absolute()
70+
outdir.mkdir(parents=True, exist_ok=True)
71+
for dfn in dfns.values():
72+
make_grammar(dfn, outdir)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from collections.abc import Mapping
2+
3+
from modflow_devtools.dfn.schema.v2 import FieldV2
4+
5+
6+
def field_type(field: FieldV2) -> str:
7+
match field.type:
8+
case t if t in ["string", "integer", "double"] and field.shape:
9+
if "period" in field.block:
10+
return "list"
11+
return "array"
12+
case "keyword":
13+
return ""
14+
case "union":
15+
return "" # keystrings generate their own union rules
16+
case _:
17+
return field.type
18+
19+
20+
def record_child_type(field: FieldV2) -> str:
21+
"""Get the grammar type for a field within a record context."""
22+
match field.type:
23+
case t if t in ["string", "double", "integer"]:
24+
return t
25+
case "keyword":
26+
return ""
27+
case "union":
28+
return "" # keystrings generate their own union rules
29+
case _:
30+
return field.type
31+
32+
33+
def is_period_list_field(field: FieldV2) -> bool:
34+
"""Check if a field is part of a period block list/recarray."""
35+
if not field.shape or not field.block:
36+
return False
37+
return (
38+
"period" in field.block
39+
and field.type in ["string", "integer", "double"]
40+
and field.shape is not None
41+
)
42+
43+
44+
def group_period_fields(block_fields: Mapping[str, FieldV2]) -> dict[str, list[str]]:
45+
"""
46+
Group period block fields that should be combined into a single list.
47+
48+
Returns a dict mapping the first field name to a list of all field names
49+
in the group. Fields are grouped if they share similar shapes (same base
50+
dimensions like nper, nnodes).
51+
"""
52+
period_fields = {
53+
name: field for name, field in block_fields.items() if is_period_list_field(field)
54+
}
55+
56+
if not period_fields:
57+
return {}
58+
59+
# All period fields in the same block should be combined into one recarray
60+
# Return a single group with all field names
61+
field_names = list(period_fields.keys())
62+
if field_names:
63+
return {field_names[0]: field_names}
64+
return {}
65+
66+
67+
def get_recarray_name(block_name: str) -> str:
68+
"""Get the name for a recarray representing period data in a block."""
69+
# Use similar naming to V1: stress_period_data, perioddata, etc.
70+
if block_name == "period":
71+
return "stress_period_data"
72+
return f"{block_name}data"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Auto-generated grammar for MF6 CHF-CDB
2+
3+
%import typed.integer -> integer
4+
%import typed.double -> double
5+
%import typed.number -> number
6+
%import typed.string -> string
7+
%import typed.array -> array
8+
%import typed.record -> record
9+
%import typed.NEWLINE -> NEWLINE
10+
%import common.WS
11+
%import common.SH_COMMENT
12+
13+
%ignore WS
14+
%ignore SH_COMMENT
15+
16+
start: block*
17+
block: options_block | dimensions_block | period_block
18+
options_block: "begin"i "options"i options_fields "end"i "options"i
19+
dimensions_block: "begin"i "dimensions"i dimensions_fields "end"i "dimensions"i
20+
period_block: "begin"i "period"i block_index period_fields "end"i "period"i block_index
21+
block_index: integer
22+
options_fields: (auxiliary | boundnames | print_input | print_flows | save_flows | obs_filerecord)*
23+
dimensions_fields: (maxbound)*
24+
period_fields: (stress_period_data)*
25+
auxiliary: "auxiliary"i array
26+
boundnames: "boundnames"i
27+
print_input: "print_input"i
28+
print_flows: "print_flows"i
29+
save_flows: "save_flows"i
30+
obs_filerecord: "filein"i "obs6"i string
31+
maxbound: "maxbound"i integer
32+
stress_period_data: record+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Auto-generated grammar for MF6 CHF-CHD
2+
3+
%import typed.integer -> integer
4+
%import typed.double -> double
5+
%import typed.number -> number
6+
%import typed.string -> string
7+
%import typed.array -> array
8+
%import typed.record -> record
9+
%import typed.NEWLINE -> NEWLINE
10+
%import common.WS
11+
%import common.SH_COMMENT
12+
13+
%ignore WS
14+
%ignore SH_COMMENT
15+
16+
start: block*
17+
block: options_block | dimensions_block | period_block
18+
options_block: "begin"i "options"i options_fields "end"i "options"i
19+
dimensions_block: "begin"i "dimensions"i dimensions_fields "end"i "dimensions"i
20+
period_block: "begin"i "period"i block_index period_fields "end"i "period"i block_index
21+
block_index: integer
22+
options_fields: (auxiliary | auxmultname | boundnames | print_input | print_flows | save_flows | ts_filerecord | obs_filerecord)*
23+
dimensions_fields: (maxbound)*
24+
period_fields: (stress_period_data)*
25+
auxiliary: "auxiliary"i array
26+
auxmultname: "auxmultname"i string
27+
boundnames: "boundnames"i
28+
print_input: "print_input"i
29+
print_flows: "print_flows"i
30+
save_flows: "save_flows"i
31+
ts_filerecord: "ts6"i "filein"i string
32+
obs_filerecord: "filein"i "obs6"i string
33+
maxbound: "maxbound"i integer
34+
stress_period_data: record+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Auto-generated grammar for MF6 CHF-CXS
2+
3+
%import typed.integer -> integer
4+
%import typed.double -> double
5+
%import typed.number -> number
6+
%import typed.string -> string
7+
%import typed.array -> array
8+
%import typed.record -> record
9+
%import typed.NEWLINE -> NEWLINE
10+
%import common.WS
11+
%import common.SH_COMMENT
12+
13+
%ignore WS
14+
%ignore SH_COMMENT
15+
16+
start: block*
17+
block: options_block | dimensions_block | packagedata_block | crosssectiondata_block
18+
options_block: "begin"i "options"i options_fields "end"i "options"i
19+
dimensions_block: "begin"i "dimensions"i dimensions_fields "end"i "dimensions"i
20+
packagedata_block: "begin"i "packagedata"i packagedata_fields "end"i "packagedata"i
21+
crosssectiondata_block: "begin"i "crosssectiondata"i crosssectiondata_fields "end"i "crosssectiondata"i
22+
options_fields: (print_input)*
23+
dimensions_fields: (nsections | npoints)*
24+
packagedata_fields: (packagedata)*
25+
crosssectiondata_fields: (crosssectiondata)*
26+
print_input: "print_input"i
27+
nsections: "nsections"i integer
28+
npoints: "npoints"i integer
29+
packagedata: "packagedata"i recarray
30+
crosssectiondata: "crosssectiondata"i recarray
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Auto-generated grammar for MF6 CHF-DFW
2+
3+
%import typed.integer -> integer
4+
%import typed.double -> double
5+
%import typed.number -> number
6+
%import typed.string -> string
7+
%import typed.array -> array
8+
%import typed.record -> record
9+
%import typed.NEWLINE -> NEWLINE
10+
%import common.WS
11+
%import common.SH_COMMENT
12+
13+
%ignore WS
14+
%ignore SH_COMMENT
15+
16+
start: block*
17+
block: options_block | griddata_block
18+
options_block: "begin"i "options"i options_fields "end"i "options"i
19+
griddata_block: "begin"i "griddata"i griddata_fields "end"i "griddata"i
20+
options_fields: (central_in_space | length_conversion | time_conversion | save_flows | print_flows | save_velocity | obs_filerecord | export_array_ascii | dev_swr_conductance)*
21+
griddata_fields: (manningsn | idcxs)*
22+
central_in_space: "central_in_space"i
23+
length_conversion: "length_conversion"i double
24+
time_conversion: "time_conversion"i double
25+
save_flows: "save_flows"i
26+
print_flows: "print_flows"i
27+
save_velocity: "save_velocity"i
28+
obs_filerecord: "obs6"i "filein"i string
29+
export_array_ascii: "export_array_ascii"i
30+
dev_swr_conductance: "dev_swr_conductance"i
31+
manningsn: "manningsn"i array
32+
idcxs: "idcxs"i array

0 commit comments

Comments
 (0)