diff --git a/flopy4/converter.py b/flopy4/converter.py deleted file mode 100644 index e69de29..0000000 diff --git a/flopy4/io/lark.py b/flopy4/io/lark.py new file mode 100644 index 0000000..de7392f --- /dev/null +++ b/flopy4/io/lark.py @@ -0,0 +1,25 @@ +import numpy as np + + +def parse_word(self, w): + (w,) = w + return str(w) + + +def parse_string(self, s): + return " ".join(s) + + +def parse_int(self, i): + (i,) = i + return int(i) + + +def parse_float(self, f): + (f,) = f + return float(f) + + +def parse_array(self, a): + (a,) = a + return np.array(a) diff --git a/flopy4/binary.py b/flopy4/mf6/binary.py similarity index 100% rename from flopy4/binary.py rename to flopy4/mf6/binary.py diff --git a/flopy4/mf6/io/converter.py b/flopy4/mf6/io/converter.py new file mode 100644 index 0000000..78be12c --- /dev/null +++ b/flopy4/mf6/io/converter.py @@ -0,0 +1,3 @@ +def make_converter(): + TODO + pass diff --git a/flopy4/mf6/io/parser.py b/flopy4/mf6/io/parser.py index 31c166b..80be2da 100644 --- a/flopy4/mf6/io/parser.py +++ b/flopy4/mf6/io/parser.py @@ -5,20 +5,28 @@ MF6_GRAMMAR = r""" // component -component: _NL* (block _NL+)* _NL* +component: _NL* (block _NL+)+ _NL* // block -block: _paramblock | _listblock -_paramblock: _BEGIN paramblock _NL params _END paramblock +block: _dictblock | _listblock +_dictblock: _BEGIN dictblock _NL dict _END dictblock _listblock: _BEGIN listblock _NL list _END listblock -paramblock: PARAMBLOCK +dictblock: DICTBLOCK listblock: LISTBLOCK [_blockindex] _blockindex: INT _BEGIN: "begin"i _END: "end"i +// dict +dict.1: (param _NL)* + +// list adapted from https://github.com/lark-parser/lark/blob/master/examples/composition/csv.lark +// negative priority for records because the pattern is so indiscriminate +list.-1: record* +record.-1: _record+ _NL +_record: scalar + // parameter -params.1: (param _NL)* param: key | _pair _pair: key value key: PARAM @@ -54,12 +62,6 @@ factor: "FACTOR" NUMBER iprn: "IPRN" INT -// list adapted from https://github.com/lark-parser/lark/blob/master/examples/composition/csv.lark -// negative priority for records because the pattern is so indiscriminate -list.-1: record* -record.-1: _record+ _NL -_record: scalar - // newline _NL: /(\r?\n[\t ]*)+/ @@ -80,7 +82,7 @@ def make_parser( params: Iterable[str], - param_blocks: Iterable[str], + dict_blocks: Iterable[str], list_blocks: Iterable[str], ): """ @@ -92,18 +94,18 @@ def make_parser( We specify blocks containing parameters separately from blocks that contain a list. These must be handled separately because the pattern for list elements (records) casts a wider net than - the pattern for parameters, causing parameter blocks to parse - as lists otherwise. + the pattern for parameters, which can cause a dictionary block + of named parameters to parse as a block with a list of records. """ params = "|".join(['"' + n + '"i' for n in params]) - param_blocks = "|".join(['"' + n + '"i' for n in param_blocks]) + dict_blocks = "|".join(['"' + n + '"i' for n in dict_blocks]) list_blocks = "|".join(['"' + n + '"i' for n in list_blocks]) grammar = linesep.join( [ MF6_GRAMMAR, f"PARAM: ({params})", - f"PARAMBLOCK: ({param_blocks})", + f"DICTBLOCK: ({dict_blocks})", f"LISTBLOCK: ({list_blocks})", ] ) diff --git a/flopy4/mf6/io/spec/__init__.py b/flopy4/mf6/io/spec/__init__.py new file mode 100644 index 0000000..578901e --- /dev/null +++ b/flopy4/mf6/io/spec/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["make_parser", "DFNTransformer"] + +from flopy4.mf6.io.spec.parser import make_parser +from flopy4.mf6.io.spec.transformer import DFNTransformer diff --git a/flopy4/mf6/io/spec/parser.py b/flopy4/mf6/io/spec/parser.py new file mode 100644 index 0000000..39d4e6d --- /dev/null +++ b/flopy4/mf6/io/spec/parser.py @@ -0,0 +1,78 @@ +from os import linesep + +from lark import Lark + +ATTRIBUTES = [ + "block", + "name", + "type", + "reader", + "optional", + "true", + "mf6internal", + "longname", + "description", + "layered", + "shape", + "valid", + "tagged", + "in_record", + "preserve_case", + "default_value", + "numeric_index", + "deprecated", +] + +DFN_GRAMMAR = r""" +// dfn +dfn: _NL* (block _NL*)+ _NL* + +// block +block: _header parameter* +_header: _hash _dashes _headtext _dashes _NL+ +_headtext: component subcompnt blockname +component: _word +subcompnt: _word +blockname: _word + +// parameter +parameter.+1: _paramhead _NL (attribute _NL)* +_paramhead: paramblock _NL paramname +paramblock: "block" _word +paramname: "name" _word + +// attribute +attribute.-1: key value +key: ATTRIBUTE +value: string + +// string +_word: /[a-zA-z0-9.;\(\)\-\,\\\/]+/ +string: _word+ + +// newline +_NL: /(\r?\n[\t ]*)+/ + +// comment format +_hash: /\#/ +_dashes: /[\-]+/ + +%import common.SH_COMMENT -> COMMENT +%import common.WORD +%import common.WS_INLINE + +%ignore WS_INLINE +""" +""" +EBNF description for the MODFLOW 6 definition language. +""" + + +def make_parser(): + """ + Create a parser for the MODFLOW 6 definition language. + """ + + attributes = "|".join(['"' + n + '"i' for n in ATTRIBUTES]) + grammar = linesep.join([DFN_GRAMMAR, f"ATTRIBUTE: ({attributes})"]) + return Lark(grammar, start="dfn") diff --git a/flopy4/mf6/io/spec/transformer.py b/flopy4/mf6/io/spec/transformer.py new file mode 100644 index 0000000..79790fc --- /dev/null +++ b/flopy4/mf6/io/spec/transformer.py @@ -0,0 +1,63 @@ +from lark import Transformer + +from flopy4.io.lark import parse_string + + +class DFNTransformer(Transformer): + """ + Transforms a parse tree for the MODFLOW 6 + specification language into a nested AST + suitable for generating an object model. + + Notes + ----- + Rather than a flat list of parameters for each component, + which a subsequent step is responsible for turning into a + an object hierarchy, we derive the hierarchical parameter + structure from the DFN file and return a dict of blocks, + each of which is a dict of parameters. + + This can be fed to a Jinja template to generate component + modules. + """ + + def key(self, k): + (k,) = k + return str(k).lower() + + def value(self, v): + (v,) = v + return str(v) + + def attribute(self, p): + return str(p[0]), str(p[1]) + + def parameter(self, p): + return dict(p[1:]) + + def paramname(self, n): + (n,) = n + return "name", str(n) + + def paramblock(self, b): + (b,) = b + return "block", str(b) + + def component(self, c): + (c,) = c + return "component", str(c) + + def subcompnt(self, s): + (s,) = s + return "subcomponent", str(s) + + def blockname(self, b): + (b,) = b + return "block", str(b) + + def block(self, b): + params = {p["name"]: p for p in b[6:]} + return b[4][1], params + + string = parse_string + dfn = dict diff --git a/flopy4/mf6/io/transformer.py b/flopy4/mf6/io/transformer.py index 51e9e38..2cd4887 100644 --- a/flopy4/mf6/io/transformer.py +++ b/flopy4/mf6/io/transformer.py @@ -3,6 +3,14 @@ import numpy as np from lark import Transformer +from flopy4.io.lark import ( + parse_array, + parse_float, + parse_int, + parse_string, + parse_word, +) + class MF6Transformer(Transformer): """ @@ -25,29 +33,6 @@ def key(self, k): (k,) = k return str(k).lower() - def word(self, w): - (w,) = w - return str(w) - - def path(self, p): - _, p = p - return Path(p) - - def string(self, s): - return " ".join(s) - - def int(self, i): - (i,) = i - return int(i) - - def float(self, f): - (f,) = f - return float(f) - - def array(self, a): - (a,) = a - return a - def constantarray(self, a): # TODO factor out `ConstantArray` # array-like class from `MFArray` @@ -65,27 +50,35 @@ def externalarray(self, a): # TODO pass - record = tuple - list = list + def path(self, p): + _, p = p + return Path(p) def param(self, p): k = p[0] v = True if len(p) == 1 else p[1] return k, v - params = dict - def block(self, b): return tuple(b[:2]) - def paramblock(self, bn): - return str(bn[0]).lower() + def dictblock(self, b): + return str(b[0]).lower() - def listblock(self, bn): - name = str(bn[0]) - if len(bn) == 2: - index = int(bn[1]) + def listblock(self, b): + name = str(b[0]) + if len(b) == 2: + index = int(b[1]) name = f"{name} {index}" return name.lower() + word = parse_word + string = parse_string + int = parse_int + float = parse_float + array = parse_array + record = tuple + list = list + dict = dict + params = dict component = dict diff --git a/test/test_lark.py b/test/test_lark.py index 6dd3b95..f270e64 100644 --- a/test/test_lark.py +++ b/test/test_lark.py @@ -3,7 +3,10 @@ import numpy as np -from flopy4.mf6.io import MF6Transformer, make_parser +from flopy4.mf6.io import MF6Transformer +from flopy4.mf6.io import make_parser as make_mf6_parser +from flopy4.mf6.io.spec import DFNTransformer +from flopy4.mf6.io.spec import make_parser as make_dfn_parser COMPONENT = """ BEGIN OPTIONS @@ -29,24 +32,24 @@ """ -PARSER = make_parser( +MF6_PARSER = make_mf6_parser( params=["k", "i", "d", "s", "f", "a"], - param_blocks=["options", "packagedata"], + dict_blocks=["options", "packagedata"], list_blocks=["period"], ) -TRANSFORMER = MF6Transformer() +MF6_TRANSFORMER = MF6Transformer() -def test_parse(): - tree = PARSER.parse(COMPONENT) +def test_parse_mf6(): + tree = MF6_PARSER.parse(COMPONENT) # view the parse tree with e.g. # pytest test/test_lark.py::test_parse -s print(linesep + tree.pretty()) -def test_transform(): - tree = PARSER.parse(COMPONENT) - data = TRANSFORMER.transform(tree) +def test_transform_mf6(): + tree = MF6_PARSER.parse(COMPONENT) + data = MF6_TRANSFORMER.transform(tree) assert data["options"] == { "d": 1.0, "f": Path("some/path"), @@ -58,3 +61,76 @@ def test_transform(): assert data["period 1"][0] == ("FIRST",) assert data["period 1"][1] == ("FREQUENCY", 2) assert data["period 2"][0] == ("STEPS", 1, 2, 3) + + +DFN_PARSER = make_dfn_parser() +DFN_TRANSFORMER = DFNTransformer() + +PROJ_ROOT = Path(__file__).parents[1] +DFNS_PATH = PROJ_ROOT / "spec" / "dfn" +DFN_PATH = DFNS_PATH / "gwf-ic.dfn" + + +def test_parse_dfn(): + tree = DFN_PARSER.parse(open(DFN_PATH).read()) + print(tree.pretty()) + + +def test_transform_dfn(): + tree = DFN_PARSER.parse(open(DFN_PATH).read()) + data = DFN_TRANSFORMER.transform(tree) + assert data["options"] == { + "export_array_ascii": { + "description": "keyword that specifies " + "input griddata arrays " + "should be written to " + "layered ascii output " + "files.", + "longname": "export array variables to " "layered ascii files.", + "mf6internal": "export_ascii", + "name": "export_array_ascii", + "optional": "true", + "reader": "urword", + "type": "keyword", + }, + "export_array_netcdf": { + "description": "keyword that specifies " + "input griddata arrays " + "should be written to the " + "model output netcdf file.", + "longname": "export array variables to " "netcdf output files.", + "mf6internal": "export_nc", + "name": "export_array_netcdf", + "optional": "true", + "reader": "urword", + "type": "keyword", + }, + } + assert data["griddata"] == { + "strt": { + "default_value": "1.0", + "description": "is the initial (starting) head---that " + "is, head at the beginning of the GWF " + "Model simulation. STRT must be " + "specified for all simulations, " + "including steady-state simulations. One " + "value is read for every model cell. For " + "simulations in which the first stress " + "period is steady state, the values used " + "for STRT generally do not affect the " + "simulation (exceptions may occur if " + "cells go dry and (or) rewet). The " + "execution time, however, will be less " + "if STRT includes hydraulic heads that " + "are close to the steady-state solution. " + "A head value lower than the cell bottom " + "can be provided if a cell should start " + "as dry.", + "layered": "true", + "longname": "starting head", + "name": "strt", + "reader": "readarray", + "shape": "(nodes)", + "type": "double precision", + } + }