diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml index 07e7b73bcc..6a2d8292dc 100644 --- a/.github/workflows/commit.yml +++ b/.github/workflows/commit.yml @@ -133,7 +133,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] - python-version: [ 3.8, 3.9, "3.10", "3.11", "3.12" ] + python-version: [ 3.9, "3.10", "3.11", "3.12" ] defaults: run: shell: bash -l {0} @@ -181,10 +181,13 @@ jobs: working-directory: autotest run: | pytest -v -m="not example" -n=auto --cov=flopy --cov-append --cov-report=xml --durations=0 --keep-failed=.failed --dist loadfile - coverage report env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Report coverage + working-directory: autotest + run: coverage report + - name: Upload failed test outputs uses: actions/upload-artifact@v4 if: failure() diff --git a/autotest/test_codegen.py b/autotest/test_codegen.py new file mode 100644 index 0000000000..f4ef3773c1 --- /dev/null +++ b/autotest/test_codegen.py @@ -0,0 +1,162 @@ +import traceback +from ast import Assign, ClassDef, expr +from ast import parse as parse_ast +from pprint import pformat +from shutil import copytree +from typing import List, Union +from warnings import warn + +import pytest +from modflow_devtools.misc import run_cmd + +from autotest.conftest import get_project_root_path +from flopy.mf6.utils.codegen.context import get_context_names +from flopy.mf6.utils.codegen.dfn import Dfn +from flopy.mf6.utils.codegen.make import ( + DfnName, + make_all, + make_context, + make_contexts, + make_targets, +) + +PROJ_ROOT = get_project_root_path() +MF6_PATH = PROJ_ROOT / "flopy" / "mf6" +TGT_PATH = MF6_PATH / "modflow" +DFN_PATH = MF6_PATH / "data" / "dfn" +DFN_NAMES = [ + dfn.stem + for dfn in DFN_PATH.glob("*.dfn") + if dfn.stem not in ["common", "flopy"] +] + + +@pytest.mark.parametrize( + "dfn, n_flat, n_params", [("gwf-ic", 2, 2), ("prt-prp", 40, 18)] +) +def test_make_context(dfn, n_flat, n_params): + with open(DFN_PATH / "common.dfn") as f: + commonvars = Dfn.load(f) + + with open(DFN_PATH / f"{dfn}.dfn") as f: + dfn = DfnName(*dfn.split("-")) + definition = Dfn.load(f, name=dfn) + + context_names = get_context_names(dfn) + context_name = context_names[0] + context = make_context(context_name, definition, commonvars) + assert len(context_names) == 1 + assert len(context.variables) == n_params + assert len(context.definition.metadata) == n_flat + 1 # +1 for metadata + + +@pytest.mark.skip(reason="TODO") +@pytest.mark.parametrize("dfn_name", ["gwf-ic", "prt-prp", "gwf-nam"]) +def test_make_contexts(dfn_name): + with open(DFN_PATH / "common.dfn") as f: + common = Dfn.load(f) + + # TODO + + +@pytest.mark.parametrize("dfn_name", DFN_NAMES) +def test_make_targets(dfn_name, function_tmpdir): + with open(DFN_PATH / "common.dfn") as f: + common = Dfn.load(f) + + with open(DFN_PATH / f"{dfn_name}.dfn", "r") as f: + dfn_name = DfnName(*dfn_name.split("-")) + dfn = Dfn.load(f, name=dfn_name) + + make_targets(dfn, function_tmpdir, common=common) + for ctx_name in get_context_names(dfn_name): + run_cmd("ruff", "format", function_tmpdir, verbose=True) + run_cmd("ruff", "check", "--fix", function_tmpdir, verbose=True) + assert (function_tmpdir / ctx_name.target).is_file() + + +def test_make_all(function_tmpdir): + make_all(DFN_PATH, function_tmpdir, verbose=True) + + +def compare_ast( + node1: Union[expr, List[expr]], node2: Union[expr, List[expr]] +) -> bool: + t1 = type(node1) + t2 = type(node2) + if t1 is not t2: + print(f"type mismatch: {t1} != {t2}") + return False + + if t1 is ClassDef: + assert t2 is ClassDef + assert node1.name == node2.name + for base1, base2 in zip(node1.bases, node2.bases): + + def _id(b): + attrs = ["id", "name", "attr"] + for attr in attrs: + try: + return getattr(b, attr) + except: + pass + return None + + assert _id(base1) == _id(base2) + + body1, body2 = node1.body, node2.body + assert len(body1) == len(body2), f"body mismatch in {node1.name}" + + for b1, b2 in zip(body1, body2): + if isinstance(b1, Assign): + assert isinstance(b2, Assign) + b1tgts = set(sorted([t.id for t in b1.targets])) + b2tgts = set(sorted([t.id for t in b2.targets])) + diff = b1tgts ^ b2tgts + if any(diff): + warn( + f"assignment targets don't match in {node1.name}\n" + f"=> symmetric difference:\n{pformat(diff)}\n" + f"=> prev - test:\n{pformat(b1tgts - b2tgts)}\n" + f"=> test - prev:\n{pformat(b2tgts - b1tgts)}\n" + ) + + +def test_equivalence(function_tmpdir): + prev_dir = function_tmpdir / "prev" + test_dir = function_tmpdir / "test" + test_dir.mkdir() + copytree(TGT_PATH, prev_dir) + make_all(DFN_PATH, test_dir, verbose=True) + prev_files = list(prev_dir.glob("*.py")) + test_files = list(test_dir.glob("*.py")) + prev_names = set([p.name for p in prev_files]) + test_names = set([p.name for p in test_files]) + diff = prev_names ^ test_names + assert not any(diff), ( + f"previous files don't match test files\n" + f"=> symmetric difference:\n{pformat(diff)}\n" + f"=> prev - test:\n{pformat(prev_names - test_names)}\n" + f"=> test - prev:\n{pformat(test_names - prev_names)}\n" + ) + for prev_file, test_file in zip(prev_files, test_files): + prev = parse_ast(open(prev_file).read()) + try: + test = parse_ast(open(test_file).read()) + except: + raise ValueError( + f"Failed to parse {test_file}: {traceback.format_exc()}" + ) + prev_classes = [n for n in prev.body if isinstance(n, ClassDef)] + test_classes = [n for n in test.body if isinstance(n, ClassDef)] + prev_clsnames = set([c.name for c in prev_classes]) + test_clsnames = set([c.name for c in test_classes]) + diff = prev_clsnames ^ test_clsnames + assert not any(diff), ( + f"previous classes don't match test classes in {test_file.name}\n" + f"=> symmetric difference:\n{pformat(diff)}\n" + f"=> prev - test:\n{pformat(prev_clsnames - test_clsnames)}\n" + f"=> test - prev:\n{pformat(test_clsnames - prev_clsnames)}\n" + ) + for prev_cls, test_cls in zip(prev_classes, test_classes): + compare_ast(prev_cls, test_cls) diff --git a/autotest/test_dfn.py b/autotest/test_dfn.py new file mode 100644 index 0000000000..1899457177 --- /dev/null +++ b/autotest/test_dfn.py @@ -0,0 +1,22 @@ +import pytest + +from autotest.conftest import get_project_root_path +from flopy.mf6.utils.codegen.dfn import Dfn +from flopy.mf6.utils.codegen.make import DfnName + +PROJ_ROOT = get_project_root_path() +MF6_PATH = PROJ_ROOT / "flopy" / "mf6" +TGT_PATH = MF6_PATH / "modflow" +DFN_PATH = MF6_PATH / "data" / "dfn" +DFN_NAMES = [ + dfn.stem + for dfn in DFN_PATH.glob("*.dfn") + if dfn.stem not in ["common", "flopy"] +] + + +@pytest.mark.parametrize("dfn_name", DFN_NAMES) +def test_load_dfn(dfn_name): + dfn_path = DFN_PATH / f"{dfn_name}.dfn" + with open(dfn_path, "r") as f: + dfn = Dfn.load(f, name=DfnName(*dfn_name.split("-"))) diff --git a/docs/mf6_dev_guide.md b/docs/mf6_dev_guide.md index 61c364d3de..8f95ee9bbb 100644 --- a/docs/mf6_dev_guide.md +++ b/docs/mf6_dev_guide.md @@ -10,12 +10,17 @@ FPMF6 uses meta-data files located in flopy/mf6/data/dfn to define the model and All meta-data can be accessed from the flopy.mf6.data.mfstructure.MFStructure class. This is a singleton class, meaning only one instance of this class can be created. The class contains a sim_struct attribute (which is a flopy.mf6.data.mfstructure.MFSimulationStructure object) which contains all of the meta-data for all package files. Meta-data is stored in a structured format. MFSimulationStructure contains MFModelStructure and MFInputFileStructure objects, which contain the meta-data for each model type and each "simulation-level" package (tdis, ims, ...). MFModelStructure contains model specific meta-data and a MFInputFileStructure object for each package in that model. MFInputFileStructure contains package specific meta-data and a MFBlockStructure object for each block contained in the package file. MFBlockStructure contains block specific meta-data and a MFDataStructure object for each data structure defined in the block, and MFDataStructure contains data structure specific meta-data and a MFDataItemStructure object for each data item contained in the data structure. Data structures define the structure of data that is naturally grouped together, for example, the data in a numpy recarray. Data item structures define the structure of specific pieces of data, for example, a single column of a numpy recarray. The meta-data defined in these classes provides all the information FloPy needs to read and write MODFLOW 6 package and name files, create the Flopy interface, and check the data for various constraints. - -*** -MFStructure --+ MFSimulationStructure --+ MFModelStructure --+ MFInputFileStructure --+ MFBlockStructure --+ MFDataStructure --+ MFDataItemStructure - -Figure 1: FPMF6 generic data structure classes. Lines connecting classes show a relationship defined between the two connected classes. A "*" next to the class means that the class is a sub-class of the connected class. A "+" next to the class means that the class is contained within the connected class. -*** +```mermaid +classDiagram + MFStructure --* "1" MFSimulationStructure : has + MFSimulationStructure --* "1+" MFModelStructure : has + MFModelStructure --* "1" MFInputFileStructure : has + MFInputFileStructure --* "1+" MFBlockStructure : has + MFBlockStructure --* "1+" MFDataStructure : has + MFDataStructure --* "1+" MFDataItemStructure : has +``` + +Figure 1: Generic data structure hierarchy. Connections show composition relationships. Package and Data Base Classes ----------------------------------------------- @@ -23,25 +28,26 @@ Package and Data Base Classes The package and data classes are related as shown below in figure 2. On the top of the figure 2 is the MFPackage class, which is the base class for all packages. MFPackage contains generic methods for building data objects and reading and writing the package to a file. MFPackage contains a MFInputFileStructure object that defines how the data is structured in the package file. MFPackage also contains a dictionary of blocks (MFBlock). The MFBlock class is a generic class used to represent a block within a package. MFBlock contains a MFBlockStructure object that defines how the data in the block is structured. MFBlock also contains a dictionary of data objects (subclasses of MFData) contained in the block and a list of block headers (MFBlockHeader) for that block. Block headers contain the block's name and optionally data items (eg. iprn). -*** -MFPackage --+ MFBlock --+ MFData - -MFPackage --+ MFInputFileStructure - -MFBlock --+ MFBlockStructure - -MFData --+ MFDataStructure - -MFData --* MFArray --* MFTransientArray - -MFData --* MFList --* MFTransientList - -MFData --* MFScalar --* MFTransientScalar - -MFTransientData --* MFTransientArray, MFTransientList, MFTransientScalar +```mermaid +classDiagram + +MFPackage --* "1+" MFBlock : has +MFBlock --* "1+" MFData : has +MFPackage --* "1" MFInputFileStructure : has +MFBlock --* "1" MFBlockStructure : has +MFData --* "1" MFDataStructure : has +MFData --|> MFArray +MFArray --|> MFTransientArray +MFData --|> MFList +MFList --|> MFTransientList +MFData --|> MFScalar +MFScalar --|> MFTransientScalar +MFTransientData --|> MFTransientArray +MFTransientData --|> MFTransientList +MFTransientData --|> MFTransientScalar +``` Figure 2: FPMF6 package and data classes. Lines connecting classes show a relationship defined between the two connected classes. A "*" next to the class means that the class is a sub-class of the connected class. A "+" next to the class means that the class is contained within the connected class. -*** There are three main types of data, MFList, MFArray, and MFScalar data. All three of these data types are derived from the MFData abstract base class. MFList data is the type of data stored in a spreadsheet with different column headings. For example, the data describing a flow barrier are of type MFList. MFList data is stored in numpy recarrays. MFArray data is data of a single type (eg. all integer values). For example, the model's HK values are of type MFArray. MFArrays are stored in numpy ndarrays. MFScalar data is a single data item. Most MFScalar data are options. All MFData subclasses contain an MFDataStructure object that defines the expected structure and types of the data. diff --git a/flopy/mf6/data/dfn/exg-gwfgwf.dfn b/flopy/mf6/data/dfn/exg-gwfgwf.dfn index 0f68acead6..02caa91b21 100644 --- a/flopy/mf6/data/dfn/exg-gwfgwf.dfn +++ b/flopy/mf6/data/dfn/exg-gwfgwf.dfn @@ -61,6 +61,7 @@ name cvoptions type record variablecv dewatered reader urword optional true +class_attr false longname vertical conductance options description none diff --git a/flopy/mf6/data/dfn/gwe-lke.dfn b/flopy/mf6/data/dfn/gwe-lke.dfn index b59b50420b..47b82a3fcc 100644 --- a/flopy/mf6/data/dfn/gwe-lke.dfn +++ b/flopy/mf6/data/dfn/gwe-lke.dfn @@ -442,7 +442,7 @@ description real or character value that defines the temperature of external inf block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -451,7 +451,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwe-mwe.dfn b/flopy/mf6/data/dfn/gwe-mwe.dfn index c805b6533f..c55b6f4324 100644 --- a/flopy/mf6/data/dfn/gwe-mwe.dfn +++ b/flopy/mf6/data/dfn/gwe-mwe.dfn @@ -408,7 +408,7 @@ description real or character value that defines the injection solute temperatur block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -417,7 +417,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwe-sfe.dfn b/flopy/mf6/data/dfn/gwe-sfe.dfn index 610e3911ff..d556241ff1 100644 --- a/flopy/mf6/data/dfn/gwe-sfe.dfn +++ b/flopy/mf6/data/dfn/gwe-sfe.dfn @@ -441,7 +441,7 @@ description real or character value that defines the temperature of inflow $(^{\ block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -450,7 +450,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwe-uze.dfn b/flopy/mf6/data/dfn/gwe-uze.dfn index 1f272617b7..10fda90e89 100644 --- a/flopy/mf6/data/dfn/gwe-uze.dfn +++ b/flopy/mf6/data/dfn/gwe-uze.dfn @@ -399,7 +399,7 @@ description real or character value that states what fraction of the simulated u block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -408,7 +408,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwf-lak.dfn b/flopy/mf6/data/dfn/gwf-lak.dfn index 3dc9e940c0..4dc7714adf 100644 --- a/flopy/mf6/data/dfn/gwf-lak.dfn +++ b/flopy/mf6/data/dfn/gwf-lak.dfn @@ -845,7 +845,7 @@ description real or character value that defines the bed slope for the lake outl block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -854,7 +854,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwf-maw.dfn b/flopy/mf6/data/dfn/gwf-maw.dfn index 2e957ec2c8..9bb9ddb812 100644 --- a/flopy/mf6/data/dfn/gwf-maw.dfn +++ b/flopy/mf6/data/dfn/gwf-maw.dfn @@ -723,7 +723,7 @@ description height above the pump elevation (SCALING\_LENGTH). If the simulated block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -732,7 +732,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwf-nam.dfn b/flopy/mf6/data/dfn/gwf-nam.dfn index f4e6ba4839..9ef47195f3 100644 --- a/flopy/mf6/data/dfn/gwf-nam.dfn +++ b/flopy/mf6/data/dfn/gwf-nam.dfn @@ -38,6 +38,7 @@ name newtonoptions type record newton under_relaxation reader urword optional true +class_attr false longname newton keyword and options description none diff --git a/flopy/mf6/data/dfn/gwf-npf.dfn b/flopy/mf6/data/dfn/gwf-npf.dfn index 23cb314c2a..3a2a7284a3 100644 --- a/flopy/mf6/data/dfn/gwf-npf.dfn +++ b/flopy/mf6/data/dfn/gwf-npf.dfn @@ -42,6 +42,7 @@ name cvoptions type record variablecv dewatered reader urword optional true +class_attr false longname vertical conductance options description none @@ -123,6 +124,7 @@ name xt3doptions type record xt3d rhs reader urword optional true +class_attr false longname keyword to activate XT3D description none diff --git a/flopy/mf6/data/dfn/gwf-sfr.dfn b/flopy/mf6/data/dfn/gwf-sfr.dfn index eb3967e441..7b77b096a1 100644 --- a/flopy/mf6/data/dfn/gwf-sfr.dfn +++ b/flopy/mf6/data/dfn/gwf-sfr.dfn @@ -880,7 +880,7 @@ description character string that defines the path and filename for the file con block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -889,7 +889,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwt-lkt.dfn b/flopy/mf6/data/dfn/gwt-lkt.dfn index 6dbca6ffb1..484c0d1210 100644 --- a/flopy/mf6/data/dfn/gwt-lkt.dfn +++ b/flopy/mf6/data/dfn/gwt-lkt.dfn @@ -421,7 +421,7 @@ description real or character value that defines the concentration of external i block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -430,7 +430,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwt-mwt.dfn b/flopy/mf6/data/dfn/gwt-mwt.dfn index b2b4346785..05270b10c8 100644 --- a/flopy/mf6/data/dfn/gwt-mwt.dfn +++ b/flopy/mf6/data/dfn/gwt-mwt.dfn @@ -388,7 +388,7 @@ description real or character value that defines the injection solute concentrat block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -397,7 +397,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwt-sft.dfn b/flopy/mf6/data/dfn/gwt-sft.dfn index 5323f4c7c5..a26b4eb5ef 100644 --- a/flopy/mf6/data/dfn/gwt-sft.dfn +++ b/flopy/mf6/data/dfn/gwt-sft.dfn @@ -421,7 +421,7 @@ description real or character value that defines the concentration of inflow $(M block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -430,7 +430,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/gwt-uzt.dfn b/flopy/mf6/data/dfn/gwt-uzt.dfn index 00524848bd..9898b610d5 100644 --- a/flopy/mf6/data/dfn/gwt-uzt.dfn +++ b/flopy/mf6/data/dfn/gwt-uzt.dfn @@ -16,6 +16,7 @@ type string shape (naux) reader urword optional true +class_attr true longname keyword to specify aux variables description REPLACE auxnames {'{#1}': 'Groundwater Transport'} @@ -399,7 +400,7 @@ description real or character value that defines the concentration of unsaturate block period name auxiliaryrecord -type record auxiliary auxname auxval +type record aux auxname auxval shape tagged in_record true @@ -408,7 +409,7 @@ longname description block period -name auxiliary +name aux type keyword shape in_record true diff --git a/flopy/mf6/data/dfn/utl-obs.dfn b/flopy/mf6/data/dfn/utl-obs.dfn index d75ce62e47..474c8b22b3 100644 --- a/flopy/mf6/data/dfn/utl-obs.dfn +++ b/flopy/mf6/data/dfn/utl-obs.dfn @@ -29,6 +29,9 @@ shape block_variable true in_record false reader urword +class_attr false +init_build false +init_param false optional false longname description diff --git a/flopy/mf6/data/dfn/utl-tas.dfn b/flopy/mf6/data/dfn/utl-tas.dfn index 6316beba5c..81c6fd25bc 100644 --- a/flopy/mf6/data/dfn/utl-tas.dfn +++ b/flopy/mf6/data/dfn/utl-tas.dfn @@ -19,6 +19,7 @@ type keyword shape reader urword optional false +in_record true longname description xxx @@ -29,6 +30,7 @@ shape any1d tagged false reader urword optional false +in_record true longname description Name by which a package references a particular time-array series. The name must be unique among all time-array series used in a package. @@ -48,6 +50,7 @@ type keyword shape reader urword optional false +in_record true longname description xxx @@ -59,6 +62,7 @@ shape tagged false reader urword optional false +in_record true longname description Interpolation method, which is either STEPWISE or LINEAR. @@ -78,6 +82,7 @@ type keyword shape reader urword optional false +in_record true longname description xxx @@ -88,6 +93,7 @@ shape time_series_name tagged false reader urword optional false +in_record true longname description Scale factor, which will multiply all array values in time series. SFAC is an optional attribute; if omitted, SFAC = 1.0. diff --git a/flopy/mf6/data/dfn/utl-ts.dfn b/flopy/mf6/data/dfn/utl-ts.dfn index a7165ea382..86ea2dceb5 100644 --- a/flopy/mf6/data/dfn/utl-ts.dfn +++ b/flopy/mf6/data/dfn/utl-ts.dfn @@ -20,6 +20,7 @@ type keyword shape reader urword optional false +in_record true longname description xxx @@ -30,6 +31,7 @@ shape any1d tagged false reader urword optional false +in_record true longname description Name by which a package references a particular time-array series. The name must be unique among all time-array series used in a package. @@ -49,6 +51,7 @@ type keyword shape reader urword optional false +in_record true longname description xxx @@ -59,6 +62,7 @@ valid stepwise linear linearend shape time_series_names tagged false reader urword +in_record true optional false longname description Interpolation method, which is either STEPWISE or LINEAR. @@ -79,6 +83,8 @@ type keyword shape reader urword optional false +init_build false +init_param false longname description xxx @@ -89,6 +95,8 @@ valid stepwise linear linearend shape tagged false reader urword +init_build false +init_param false optional false longname description Interpolation method, which is either STEPWISE or LINEAR. @@ -108,6 +116,7 @@ name sfacs type keyword shape reader urword +in_record true optional false longname description xxx @@ -119,6 +128,7 @@ shape 1 and package_dim.model_dim[0].model_name is not None and package_dim.model_dim[0].model_name.lower() diff --git a/flopy/mf6/mfmodel.py b/flopy/mf6/mfmodel.py index 3a54e4f525..bcccbe087b 100644 --- a/flopy/mf6/mfmodel.py +++ b/flopy/mf6/mfmodel.py @@ -81,8 +81,11 @@ def __init__( structure=None, model_rel_path=".", verbose=False, + parent=None, **kwargs, ): + if parent: + simulation = parent super().__init__(simulation.simulation_data, modelname) self.simulation = simulation self.simulation_data = simulation.simulation_data diff --git a/flopy/mf6/utils/__init__.py b/flopy/mf6/utils/__init__.py index dd19d3a5d2..92605c7722 100644 --- a/flopy/mf6/utils/__init__.py +++ b/flopy/mf6/utils/__init__.py @@ -1,6 +1,4 @@ -from . import createpackages from .binarygrid_util import MfGrdFile -from .generate_classes import generate_classes from .lakpak_utils import get_lak_connections from .mfsimlistfile import MfSimulationList from .model_splitter import Mf6Splitter diff --git a/flopy/mf6/utils/codegen/context.py b/flopy/mf6/utils/codegen/context.py new file mode 100644 index 0000000000..da783345aa --- /dev/null +++ b/flopy/mf6/utils/codegen/context.py @@ -0,0 +1,646 @@ +from ast import literal_eval +from collections import namedtuple +from dataclasses import dataclass, replace +from keyword import kwlist +from os import PathLike +from typing import ( + Any, + Dict, + Iterable, + Iterator, + List, + Literal, + NamedTuple, + Optional, + Tuple, + Union, + get_origin, +) + +import numpy as np +from numpy.typing import NDArray + +from flopy.mf6.utils.codegen.dfn import Dfn, DfnName, Metadata +from flopy.mf6.utils.codegen.ref import Refs +from flopy.mf6.utils.codegen.render import renderable +from flopy.mf6.utils.codegen.shim import SHIM +from flopy.mf6.utils.codegen.spec import Ref, Var, VarKind, Vars +from flopy.mf6.utils.codegen.utils import _try_get_type_name + +_SCALAR_TYPES = { + "keyword": bool, + "integer": int, + "double precision": float, + "string": str, +} +_NP_SCALAR_TYPES = { + "keyword": np.bool_, + "integer": np.int_, + "double precision": np.float64, + "string": np.str_, +} + + +class ContextName(NamedTuple): + """ + Uniquely identifies an input context by its name, which + consists of a <= 3-letter left term and optional right + term also of <= 3 letters. + + Notes + ----- + A single `DefinitionName` may be associated with one or + more `ContextName`s. For instance, a model DFN file will + produce both a NAM package class and also a model class. + + From the `ContextName` several other things are derived, + including: + + - the input context class' name + - a description of the context class + - the name of the source file to write + - the base class the context inherits from + + """ + + l: str + r: Optional[str] + + @property + def title(self) -> str: + """ + The input context's unique title. This is not + identical to `f"{l}{r}` in some cases, but it + remains unique. The title is substituted into + the file name and class name. + """ + + l, r = self + if self == ("sim", "nam"): + return "simulation" + if l is None: + return r + if r is None: + return l + if l == "sim": + return r + if l in ["sln", "exg"]: + return r + return f"{l}{r}" + + @property + def base(self) -> str: + """Base class from which the input context should inherit.""" + _, r = self + if self == ("sim", "nam"): + return "MFSimulationBase" + if r is None: + return "MFModel" + return "MFPackage" + + @property + def target(self) -> str: + """The source file name to generate.""" + return f"mf{self.title}.py" + + @property + def description(self) -> str: + """A description of the input context.""" + l, r = self + title = self.title.title() + if self.base == "MFPackage": + return f"Modflow{title} defines a {r.upper()} package." + elif self.base == "MFModel": + return f"Modflow{title} defines a {l.upper()} model." + elif self.base == "MFSimulationBase": + return """ + MFSimulation is used to load, build, and/or save a MODFLOW 6 simulation. + A MFSimulation object must be created before creating any of the MODFLOW 6 + model objects.""" + + +def get_context_names(dfn_name: DfnName) -> List[ContextName]: + """ + Returns a list of contexts this definition produces. + + Notes + ----- + An input definition may produce one or more input contexts. + + Model definition files produce both a model class context and + a model namefile package context. The same goes for simulation + definition files. All other definition files produce a single + context. + """ + if dfn_name.r == "nam": + if dfn_name.l == "sim": + return [ + ContextName(None, dfn_name.r), # nam pkg + ContextName(*dfn_name), # simulation + ] + else: + return [ + ContextName(*dfn_name), # nam pkg + ContextName(dfn_name.l, None), # model + ] + elif (dfn_name.l, dfn_name.r) in [ + ("gwf", "mvr"), + ("gwf", "gnc"), + ("gwt", "mvt"), + ]: + return [ContextName(*dfn_name), ContextName(None, dfn_name.r)] + return [ContextName(*dfn_name)] + + +@renderable(**SHIM) +@dataclass +class Context: + """ + An input context. Each of these is specified by a definition file + and becomes a generated class. A definition file may specify more + than one input context (e.g. model DFNs yield a model class and a + package class). + + Notes + ----- + A context class minimally consists of a name, a map of variables, + a map of records, and a list of metadata. + + The context class may inherit from a base class, and may specify + a parent context within which it can be created (the parent then + becomes the first `__init__` method parameter). + + """ + + name: ContextName + definition: Dfn + variables: Vars + records: Vars + """ + A separate map of record variables is maintained because we will + generate named tuples for record types, and complex filtering of + e.g. nested maps of variables is awkward or impossible in Jinja. + TODO: make this a prerendering step + """ + base: Optional[type] = None + parent: Optional[Union[type, str]] = None + description: Optional[str] = None + reference: bool = False + references: Optional[Refs] = None + + +def make_context( + name: ContextName, + definition: Dfn, + commonvars: Optional[Dfn] = None, + references: Optional[Refs] = None, +) -> Context: + """ + Extract a context descriptor from an input definition: + a structured representation of the input context that + can be used to generate an input data interface layer. + + Notes + ----- + Each input definition corresponds to a generated Python + source file. A definition may produce one or more input + context classes. + + A map of other definitions may be provided, in which case a + parameter in this context may act as kind of "foreign key", + identifying another context as a subpackage which this one + is related to. + """ + + commonvars = commonvars or dict() + reference = Ref.from_dfn(definition) + references = references or dict() + referenced = dict() + records = dict() + + def _ntname(s): + """ + Convert a record name to the name of a corresponding named tuple. + + Notes + ----- + Dashes and underscores are removed, with title-casing for clauses + separated by them, and a trailing "record" is removed if present. + + """ + return ( + s.title().replace("record", "").replace("-", "_").replace("_", "") + ) + + def _parent() -> Optional[str]: + """ + Get the context's parent(s), i.e. context(s) which can + own an instance of this context. If this context is a + subpackage which can have multiple parent types, this + will be a Union of possible parent types, otherwise a + single parent type. + + Notes + ----- + We return a string directly instead of a type to avoid + the need to import `MFSimulation` in this file (avoids + potential for circular imports). + """ + l, r = definition.name + if (l, r) == ("sim", "nam") and name == ("sim", "nam"): + return None + if l in ["sim", "exg", "sln"]: + return "MFSimulation" + if name.r is None: + return "MFSimulation" + if reference: + if len(reference.parents) > 1: + return f"Union[{', '.join([_try_get_type_name(t) for t in reference.parents])}]" + return reference.parents[0] + return "MFModel" + + parent = _parent() + + def _convert(var: Dict[str, Any], wrap: bool = False) -> Var: + """ + Transform a variable from its original representation in + an input definition to a specification suitable for type + hints, docstrings, an `__init__` method's signature, etc. + + This involves expanding nested type hierarchies, mapping + types to roughly equivalent Python primitives/composites, + and other shaping. + + The rules for optional variable defaults are as follows: + If a `default_value` is not provided, keywords are `False` + by default, everything else is `None`. + + If `wrap` is true, scalars will be wrapped as records with + keywords represented as string literals. This is useful for + unions, to distinguish between choices having the same type. + + Any filepath variable whose name functions as a foreign key + for another context will be given a pointer to the context. + + + Notes + ----- + This function does most of the work in the whole module. + A bit of a beast, and Codacy complains it's too complex, + but having it here allows using the outer function scope + (including the input definition, etc) without a bunch of + extra function parameters. And what it's doing is fairly + straightforward: map a variable specification from a DFN + into a corresponding Python representation. + + """ + + _name = var["name"] + _type = var.get("type", "unknown") + block = var.get("block", None) + shape = var.get("shape", None) + shape = None if shape == "" else shape + optional = var.get("optional", True) + in_record = var.get("in_record", False) + tagged = var.get("tagged", False) + description = var.get("description", "") + children = dict() + is_record = False + + def _description(descr: str) -> str: + """ + Make substitutions from common variable definitions, + remove backslashes, TODO: generate/insert citations. + """ + descr = descr.replace("\\", "") + _, replace, tail = descr.strip().partition("REPLACE") + if replace: + key, _, subs = tail.strip().partition(" ") + subs = literal_eval(subs) + cmn_var = commonvars.get(key, None) + if cmn_var is None: + raise ValueError(f"Common variable not found: {key}") + descr = cmn_var.get("description", "") + if any(subs): + return descr.replace("\\", "").replace( + "{#1}", subs["{#1}"] + ) + return descr + return descr + + def _fields(record_name: str) -> Vars: + """Recursively load/convert a record's fields.""" + record = definition[record_name] + field_names = record["type"].split()[1:] + fields: Dict[str, Var] = { + n: _convert(field, wrap=False) + for n, field in definition.items() + if n in field_names + } + field_names = list(fields.keys()) + + # if the record represents a file... + if "file" in record_name: + # remove filein/fileout + for term in ["filein", "fileout"]: + if term in field_names: + fields.pop(term) + + # remove leading keyword + keyword = next(iter(fields), None) + if keyword: + fields.pop(keyword) + + # set the type + n = list(fields.keys())[0] + path_field = fields[n] + path_field._type = Union[str, PathLike] + fields[n] = path_field + + # if tagged, remove the leading keyword + elif record.get("tagged", False): + keyword = next(iter(fields), None) + if keyword: + fields.pop(keyword) + + return fields + + # go through all the possible input types + # from top (composite) to bottom (scalar): + # + # - list + # - union + # - record + # - array + # - scalar + + # list input, child is the item type + if _type.startswith("recarray"): + # make sure columns are defined + names = _type.split()[1:] + n_names = len(names) + if n_names < 1: + raise ValueError(f"Missing recarray definition: {_type}") + + # list input can have records or unions as rows. + # lists which have a consistent record type are + # regular, inconsistent record types irregular. + + # regular tabular/columnar data (1 record type) can be + # defined with a nested record (i.e. explicit) or with + # fields directly inside the recarray (implicit). list + # data for unions/keystrings necessarily comes nested. + + is_explicit_record = len(names) == 1 and definition[names[0]][ + "type" + ].startswith("record") + + def _is_implicit_scalar_record(): + # if the record is defined implicitly and it has + # only scalar fields + types = [ + _try_get_type_name(v["type"]) + for n, v in definition.items() + if n in names + ] + scalar_types = list(_SCALAR_TYPES.keys()) + return all(t in scalar_types for t in types) + + if is_explicit_record: + record_name = names[0] + record_spec = definition[record_name] + record = _convert(record_spec, wrap=False) + children = {_ntname(record_name).lower(): record} + type_ = Iterable[record._type] + elif _is_implicit_scalar_record(): + record_name = _name + fields = _fields(record_name) + field_types = [f._type for f in fields.values()] + record_type = Tuple[tuple(field_types)] + record = Var( + name=record_name, + _type=record_type, + block=block, + children=fields, + description=description, + ) + records[_ntname(record_name)] = replace( + record, name=_ntname(record_name) + ) + record_type = namedtuple( + _ntname(record_name), + [_ntname(k) for k in fields.keys()], + ) + record = replace( + record, + _type=record_type, + name=_ntname(record_name).lower(), + ) + children = {_ntname(record_name): record} + type_ = Iterable[record_type] + else: + # implicit complex record (i.e. some fields are records or unions) + fields = { + n: _convert(definition[n], wrap=False) for n in names + } + first = list(fields.values())[0] + single = len(fields) == 1 + record_name = first.name if single else _name + field_types = [f._type for f in fields.values()] + record_type = ( + first._type + if (single and get_origin(first._type) is Union) + else Tuple[tuple(field_types)] + ) + record = Var( + name=record_name, + _type=record_type, + block=block, + children=first.children if single else fields, + description=description, + ) + records[_ntname(record_name)] = replace( + record, name=_ntname(record_name) + ) + record_type = namedtuple( + _ntname(record_name), + [_ntname(k) for k in fields.keys()], + ) + record = replace( + record, + _type=record_type, + name=_ntname(record_name).lower(), + ) + children = {_ntname(record_name): record} + type_ = Iterable[record_type] + + # union (product), children are record choices + elif _type.startswith("keystring"): + names = _type.split()[1:] + children = {n: _convert(definition[n], wrap=True) for n in names} + type_ = Union[tuple([c._type for c in children.values()])] + + # record (sum) type, children are fields + elif _type.startswith("record"): + children = _fields(_name) + if len(children) > 1: + record_type = Tuple[ + tuple([f._type for f in children.values()]) + ] + elif len(children) == 1: + t = list(children.values())[0]._type + # make sure we don't double-wrap tuples + record_type = t if get_origin(t) is tuple else Tuple[(t,)] + # TODO: if record has 1 field, accept value directly? + type_ = record_type + is_record = True + + # are we wrapping a var into a record + # as a choice in a union? if so use a + # string literal for the keyword e.g. + # `Tuple[Literal[...], T]` + elif wrap: + field_name = _name + field = _convert(var, wrap=False) + field_type = ( + Literal[field_name] if field._type is bool else field._type + ) + record_type = ( + Tuple[Literal[field_name]] + if field._type is bool + else Tuple[Literal[field_name], field._type] + ) + children = {field_name: replace(field, _type=field_type)} + type_ = record_type + is_record = True + + # at this point, if it has a shape, it's an array.. + # but if it's in a record make it a variadic tuple, + # and if its item type is a string use an iterable. + elif shape is not None: + scalars = list(_SCALAR_TYPES.keys()) + if in_record: + if _type not in scalars: + raise TypeError(f"Unsupported repeating type: {_type}") + type_ = Tuple[_SCALAR_TYPES[_type], ...] + elif _type in scalars and _SCALAR_TYPES[_type] is str: + type_ = Iterable[_SCALAR_TYPES[_type]] + else: + if _type not in _NP_SCALAR_TYPES.keys(): + raise TypeError(f"Unsupported array type: {_type}") + type_ = NDArray[_NP_SCALAR_TYPES[_type]] + + # finally a bog standard scalar + else: + # if it's a keyword, there are 2 cases where we want to convert + # it to a string literal: 1) it tags another variable, or 2) it + # is being wrapped into a record as a choice in a union + tag = _type == "keyword" and (tagged or wrap) + type_ = Literal[_name] if tag else _SCALAR_TYPES.get(_type, _type) + + # format the variable description + description = _description(description) + + # keywords default to False, everything else to None + default = var.get("default", False if type_ is bool else None) + if isinstance(default, str) and type_ is not str: + try: + default = literal_eval(default) + except: + pass + if _name in ["continue", "print_input"]: # hack... + default = None + + # if name is a reserved keyword, add a trailing underscore to it. + # convert dashes to underscores since it may become a class attr. + name_ = (f"{_name}_" if _name in kwlist else _name).replace("-", "_") + + # create var + var_ = Var( + name=name_, + _type=type_, + block=block, + description=description, + default=default, + children=children, + ) + + # if the var is a foreign key, register the referenced context + ref_ = references.get(_name, None) + if ref_: + var_.reference = ref_ + referenced[_name] = ref_ + + # if the var is a record, make a named tuple for it + if is_record: + records[_ntname(name_)] = replace(var_, name=_ntname(name_)) + if any(children): + type_ = namedtuple( + _ntname(name_), [_ntname(k) for k in children.keys()] + ) + var_._type = type_ + + # wrap the var's type with Optional if it's optional + if optional: + var_._type = ( + Optional[type_] + if (type_ is not bool and not in_record and not wrap) + else type_ + ) + + return var_ + + def _variables() -> Vars: + """ + Return all input variables for an input context class. + + Notes + ----- + Not all variables become parameters; nested variables + will become components of composite parameters, e.g., + record fields, keystring (union) choices, list items. + + Variables may be added, depending on the context type. + """ + + vars_ = definition.copy() + vars_ = { + name: _convert(var, wrap=False) + for name, var in vars_.items() + # filter composite components + # since we've already inflated + # their parents in the hierarchy + if not var.get("in_record", False) + } + + # set the name since we may have altered + # it when creating the variable (e.g. to + # avoid name/reserved keyword collisions) + return {v.name: v for v in vars_.values()} + + return Context( + name=name, + definition=definition, + variables=_variables(), + records=records, + base=name.base, + parent=parent, + description=name.description, + reference=reference, + references=referenced, + ) + + +def make_contexts( + definition: Dfn, + commonvars: Optional[Dfn] = None, + references: Optional[Refs] = None, +) -> Iterator[Context]: + """Generate one or more input contexts from the given input definition.""" + for name in get_context_names(definition.name): + yield make_context( + name=name, + definition=definition, + commonvars=commonvars, + references=references, + ) diff --git a/flopy/mf6/utils/codegen/dfn.py b/flopy/mf6/utils/codegen/dfn.py new file mode 100644 index 0000000000..8641d0eb21 --- /dev/null +++ b/flopy/mf6/utils/codegen/dfn.py @@ -0,0 +1,138 @@ +from collections import UserDict +from collections.abc import MutableMapping +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Tuple + +from boltons.dictutils import OrderedMultiDict + + +class DfnName(NamedTuple): + """ + Uniquely identifies an input definition by its name, which + consists of a <= 3-letter left term and an optional right + term, also <= 3 letters. + + Notes + ----- + A single `DefinitionName` may be associated with one or + more `ContextName`s. For instance, a model DFN file will + produce both a NAM package class and also a model class. + """ + + l: str + r: str + + +Metadata = List[str] + + +@dataclass +class Dfn(MutableMapping): + """ + An MF6 input definition. + + Notes + ----- + Duplicate variable names are supported by an `OrderedMultiDict` + this class maintains alongside a `UserDict`-managed standard + dictionary; the former is retrievable with the `omd` property. + + This class should not be modified after loading. + """ + + variables: OrderedMultiDict + metadata: Optional[Metadata] + name: Optional[DfnName] + + def __init__( + self, + variables: Iterable[Tuple[str, Dict[str, Any]]], + metadata: Optional[Metadata] = None, + name: Optional[DfnName] = None, + ): + self.variables = OrderedMultiDict(variables) + self.metadata = metadata + self.name = name + + def __getitem__(self, key): + return self.variables.getlist(key) + + def __setitem__(self, key, val): + self.variables.__setitem__(key, val) + + def __delitem__(self, key): + self.variables.__delitem__(key) + + def __len__(self): + return len(self.variables.values(multi=True)) + + def __iter__(self): + return iter(self.variables) + + def __repr__(self): + return self.variables.__repr__() + + @classmethod + def load(cls, f, name: Optional[DfnName] = None) -> "Dfn": + """ + Load an input definition from a definition file. + """ + + meta = None + vars_ = list() + var = dict() + + for line in f: + # remove whitespace/etc from the line + line = line.strip() + + # record context name and flopy metadata + # attributes, skip all other comment lines + if line.startswith("#"): + _, sep, tail = line.partition("flopy") + if sep == "flopy": + if meta is None: + meta = list() + tail = tail.strip() + if "solution_package" in tail: + tail = tail.split() + tail.pop(1) + meta.append(tail) + continue + _, sep, tail = line.partition("package-type") + if sep == "package-type": + if meta is None: + meta = list + meta.append(f"{sep} {tail.strip()}") + continue + _, sep, tail = line.partition("solution_package") + continue + + # if we hit a newline and the parameter dict + # is nonempty, we've reached the end of its + # block of attributes + if not any(line): + if any(var): + n = var["name"] + vars_.append((n, var)) + var = dict() + continue + + # split the attribute's key and value and + # store it in the parameter dictionary + key, _, value = line.partition(" ") + if key == "default_value": + key = "default" + if value in ["true", "false"]: + value = value == "true" + var[key] = value + + # add the final parameter + if any(var): + n = var["name"] + vars_.append((n, var)) + + return cls(variables=vars_, name=name, metadata=meta) + + +Dfns = Dict[str, Dfn] diff --git a/flopy/mf6/utils/codegen/make.py b/flopy/mf6/utils/codegen/make.py new file mode 100644 index 0000000000..d9bcee70bb --- /dev/null +++ b/flopy/mf6/utils/codegen/make.py @@ -0,0 +1,100 @@ +from pathlib import Path +from typing import ( + Optional, +) +from warnings import warn + +from jinja2 import Environment, PackageLoader +from modflow_devtools.misc import run_cmd + +# noqa: F401 +from flopy.mf6.utils.codegen.context import ( + get_context_names, + make_context, + make_contexts, +) +from flopy.mf6.utils.codegen.dfn import Dfn, DfnName, Dfns +from flopy.mf6.utils.codegen.ref import Ref, Refs + +_TEMPLATE_LOADER = PackageLoader("flopy", "mf6/utils/templates/") +_TEMPLATE_ENV = Environment(loader=_TEMPLATE_LOADER) +_TEMPLATE_NAME = "context.py.jinja" +_TEMPLATE = _TEMPLATE_ENV.get_template(_TEMPLATE_NAME) + + +def make_targets( + dfn: Dfn, + outdir: Path, + common: Optional[Dfn] = None, + refs: Optional[Refs] = None, + verbose: bool = False, +): + """Generate Python source file(s) from the given input definition.""" + + for context in make_contexts( + definition=dfn, commonvars=common, references=refs + ): + target = outdir / context.name.target + with open(target, "w") as f: + source = _TEMPLATE.render(**context.render()) + f.write(source) + if verbose: + print(f"Wrote {target}") + + +def make_all(dfndir: Path, outdir: Path, verbose: bool = False): + """Generate Python source files from the DFN files in the given location.""" + + # find definition files + paths = [ + p for p in dfndir.glob("*.dfn") if p.stem not in ["common", "flopy"] + ] + + # try to load common variables + common_path = dfndir / "common.dfn" + if not common_path.is_file: + warn("No common input definition file...") + common = None + else: + with open(common_path, "r") as f: + common = Dfn.load(f) + + # load all the input definitions before we generate input + # contexts so we can create foreign key refs between them. + dfns: Dfns = {} + refs: Refs = {} + for p in paths: + name = DfnName(*p.stem.split("-")) + with open(p) as f: + dfn = Dfn.load(f, name=name) + dfns[name] = dfn + ref = Ref.from_dfn(dfn) + if ref: + # key is the name of the file record + # that's the reference's foreign key + refs[ref.key] = ref + + # generate target files + for dfn in dfns.values(): + with open(p) as f: + make_targets( + dfn=dfn, + outdir=outdir, + refs=refs, + common=common, + verbose=verbose, + ) + + # generate __init__.py file + init_path = outdir / "__init__.py" + with open(init_path, "w") as f: + for dfn in dfns.values(): + for ctx in get_context_names(dfn.name): + prefix = "MF" if ctx.base == "MFSimulationBase" else "Modflow" + f.write( + f"from .mf{ctx.title} import {prefix}{ctx.title.title()}\n" + ) + + # format the generated files + run_cmd("ruff", "format", outdir, verbose=verbose) + run_cmd("ruff", "check", "--fix", outdir, verbose=True) diff --git a/flopy/mf6/utils/codegen/ref.py b/flopy/mf6/utils/codegen/ref.py new file mode 100644 index 0000000000..4162475a18 --- /dev/null +++ b/flopy/mf6/utils/codegen/ref.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from typing import Dict, List, Optional, Union + +from flopy.mf6.utils.codegen.dfn import Dfn + + +@dataclass +class Ref: + """ + A foreign-key-like reference between a file input variable + and another input definition. This allows an input context + to refer to another input context, by including a filepath + variable whose name acts as a foreign key for a different + input context. Extra parameters are added to the referring + context's `__init__` method, so a selected "value" variable + defined in the referenced context can be provided directly + as an alternative to the file path (foreign key) variable. + + Parameters + ---------- + key : str + The name of the foreign key file input variable. + val : str + The name of the selected variable in the referenced context. + abbr : str + An abbreviation of the referenced context's name. + param : str + The subpackage parameter name. TODO: explain + parents : List[Union[str, type]] + The subpackage's supported parent types. + """ + + key: str + val: str + abbr: str + param: str + parents: List[Union[type, str]] + description: Optional[str] + + @classmethod + def from_dfn(cls, dfn: Dfn) -> Optional["Ref"]: + if not dfn.metadata: + return None + + lines = { + "subpkg": next( + iter( + m + for m in dfn.metadata + if isinstance(m, str) and m.startswith("subpac") + ), + None, + ), + "parent": next( + iter( + m + for m in dfn.metadata + if isinstance(m, str) and m.startswith("parent") + ), + None, + ), + } + + def _subpkg(): + line = lines["subpkg"] + _, key, abbr, param, val = line.split() + descr = dfn.get(val, dict()).get("description", None) + return { + "key": key, + "val": val, + "abbr": abbr, + "param": param, + "description": descr, + } + + def _parents(): + line = lines["parent"] + _, _, _type = line.split() + return _type.split("/") + + return ( + cls(**_subpkg(), parents=_parents()) + if all(v for v in lines.values()) + else None + ) + + +Refs = Dict[str, Ref] diff --git a/flopy/mf6/utils/codegen/render.py b/flopy/mf6/utils/codegen/render.py new file mode 100644 index 0000000000..146abe925f --- /dev/null +++ b/flopy/mf6/utils/codegen/render.py @@ -0,0 +1,169 @@ +from dataclasses import asdict +from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Union + +from flopy.mf6.utils.codegen.utils import ( + _try_get_enum_value, + _try_get_type_name, +) + +Predicate = Callable[[Any], bool] +Transform = Callable[[Any], Dict[str, str]] +Entry = Tuple[str, Any] +Entries = Iterable[Entry] + + +def renderable( + maybe_cls=None, + *, + keep_none: Optional[Iterable[str]] = None, + quote_str: Optional[Iterable[str]] = None, + set_pairs: Optional[Iterable[Tuple[Predicate, Entries]]] = None, + transform: Optional[Iterable[Tuple[Predicate, Transform]]] = None, + type_name: Optional[Iterable[str]] = None, +): + """ + Decorator for dataclasses which are meant + to be passed into a Jinja template. The + decorator adds a `.render()` method to + the decorated class, which recursively + converts the instance to a dictionary + with (by default) the `asdict()` builtin + `dataclasses` module function, plus a + few modifications to make the instance + easier to work with from the template. + + By default, attributes with value `None` + are dropped before conversion to a `dict`. + To specify that a given attribute should + remain even with a `None` value, use the + `keep_none` parameter. + + When a string value is to become the RHS + of an assignment or an argument-passing + expression, it needs to be wrapped with + quotation marks before insertion into + the template. To indicate an attribute's + value should be wrapped with quotation + marks, use the `quote_str` parameter. + + Straightforward stringification of `type` + doesn't always give a suitable result for + use within a template; `type_name` can be + used to specify attributes whose value is + a `type` that needs conversion to a more + template-friendly string. + + Finally, arbitrary transformations can be + specified with the `transform` parameter, + which accepts a set of predicate/function + pairs; see below for more information on + how to use the transformation mechanism. + + Notes + ----- + Jinja supports attribute- and dictionary- + based access on arbitrary objects but does + not support arbitrary expressions, and has + only a limited set of custom filters. This + can make it awkward to express some things. + + This decorator is intended as a convenient + way to modify dataclass instances to make + them more palatable for templates. It also + aims to keep keep edge cases incidental to + the current design of MF6 input framework + cleanly isolated from the reimplementation + of which this code is a part. + + The `dataclasses` module provides a builtin + `asdict()` function to recursively convert + a nested object hierarchy to a dictionary; + this function has a `dict_factory` function + parameter which can be used to change how a + `dict` is constructed from each instance of + a dataclass found within the root instance. + + The basic idea behind this decorator is for + the developer to specify conditions in which + a given dataclass instance should be altered, + and a function to make the alteration. These + are provided as a collection of `Predicate`/ + `Transform` pairs. + + Transformations might be for convenience, or + to handle special cases where an object has + some other need for modification. Edge cases + in the MF6 compatibility layer (for example, + some of the logic in `mfstructure.py` which + determines the members of generated classes) + can be isolated as rendering transformations. + This allows keeping more general templating + infrastructure free of incidental complexity + while we move toward a leaner core framework. + + Because a transformation function accepts an + instance of a dataclass and converts it to a + dictionary, only one transformation function + can be applied per dataclass instance. Where + multiple predicates evaluate to true for the + instance, only the first is applied. + + """ + + quote_str = quote_str or list() + keep_none = keep_none or list() + set_pairs = set_pairs or list() + transform = transform or list() + type_name = type_name or list() + + def __renderable(cls): + def _render(d: dict) -> dict: + def _render_val(k, v): + v = _try_get_enum_value(v) + if k in type_name: + v = _try_get_type_name(v) + if k in quote_str and isinstance(v, str): + v = f'"{v}"' + return v + + # drop nones except where requested to keep them + return { + k: _render_val(k, v) + for k, v in d.items() + if (k in keep_none or v is not None) + } + + def _dict(o): + # apply the first transform with a matching predicate + d = dict(o) + for p, t in transform: + if p(o): + d = t(o) + + for p, e in set_pairs: + if not p(d): + continue + if e is None: + raise ValueError(f"No value for entry {k}") + for k, v in e: + if callable(v): + v = v(d) + d[k] = v + + return d + + def render(self) -> dict: + """ + Recursively render the dataclass instance. + """ + return _render( + asdict(self, dict_factory=lambda o: _render(_dict(o))) + ) + + setattr(cls, "render", render) + return cls + + # first arg value depends on the decorator usage: + # class if `@renderable`, `None` if `@renderable()`. + # referenced from https://github.com/python-attrs/attrs/blob/a59c5d7292228dfec5480388b5f6a14ecdf0626c/src/attr/_next_gen.py#L405C4-L406C65 + return __renderable if maybe_cls is None else __renderable(maybe_cls) diff --git a/flopy/mf6/utils/codegen/shim.py b/flopy/mf6/utils/codegen/shim.py new file mode 100644 index 0000000000..ed77e983d3 --- /dev/null +++ b/flopy/mf6/utils/codegen/shim.py @@ -0,0 +1,508 @@ +import os +from os import PathLike +from typing import Iterable, List, Optional, Union, get_args, get_origin + +from numpy.typing import ArrayLike +from pandas import DataFrame + +from flopy.mf6.utils.codegen.dfn import Metadata +from flopy.mf6.utils.codegen.spec import Var, VarKind + + +def _add_exg_vars(ctx): + """ + Add initializer parameters for an exchange input context. + Exchanges need different parameters than a typical package. + """ + d = dict(ctx) + a = d["name"].r[:3] + b = d["name"].r[:3] + default = f"{a.upper()}6-{b.upper()}6" + vars_ = d["variables"].copy() + vars_ = { + "parent": Var( + name="parent", + _type="MFSimulation", + description=( + "Simulation that this package is a part of. " + "Package is automatically added to simulation " + "when it is initialized." + ), + ), + "loading_package": Var( + name="loading_package", + _type=bool, + description=( + "Do not set this parameter. It is intended for " + "debugging and internal processing purposes only." + ), + default=False, + ), + "exgtype": Var( + name="exgtype", + _type=str, + default=default, + description="The exchange type.", + ), + "exgmnamea": Var( + name="exgmnamea", + _type=str, + description="The name of the first model in the exchange.", + ), + "exgmnameb": Var( + name="exgmnameb", + _type=str, + description="The name of the second model in the exchange.", + ), + **vars_, + "filename": Var( + name="filename", + _type=Union[str, PathLike], + description="File name for this package.", + ), + "pname": Var( + name="pname", + _type=str, + description="Package name for this package.", + ), + } + + if d["references"]: + for key, ref in d["references"].items(): + if key not in vars_: + continue + vars_[ref["val"]] = Var( + name=ref["val"], + description=ref.get("description", None), + reference=ref, + ) + + d["variables"] = vars_ + return d + + +def _add_pkg_vars(ctx): + """Add variables for a package context.""" + d = dict(ctx) + parent_name = "parent" + vars_ = d["variables"].copy() + vars_ = { + parent_name: Var( + name=parent_name, + _type=d["parent"], + description="Parent that this package is part of.", + ), + "loading_package": Var( + name="loading_package", + _type=bool, + description=( + "Do not set this variable. It is intended for debugging " + "and internal processing purposes only." + ), + default=False, + ), + **vars_, + "filename": Var( + name="filename", + _type=str, + description="File name for this package.", + ), + "pname": Var( + name="pname", + _type=str, + description="Package name for this package.", + ), + } + + if d["name"].l == "utl": + vars_["parent_file"] = Var( + name="parent_file", + _type=Union[str, PathLike], + description=( + "Parent package file that references this package. Only needed " + "for utility packages (mfutl*). For example, mfutllaktab package " + "must have a mfgwflak package parent_file." + ), + ) + + if d["references"] and d["name"] != (None, "nam"): + for key, ref in d["references"].items(): + if key not in vars_: + continue + vars_[ref["val"]] = Var( + name=ref["val"], + description=ref.get("description", None), + reference=ref, + ) + + d["variables"] = vars_ + return d + + +def _add_mdl_vars(ctx): + """Add variables for a model context.""" + d = dict(ctx) + vars_ = d["variables"].copy() + vars_ = { + "simulation": Var( + name="simulation", + _type="MFSimulation", + description=( + "Simulation that this model is part of. " + "Model is automatically added to the simulation " + "when it is initialized." + ), + ), + "modelname": Var( + name="modelname", + _type=str, + description="The name of the model.", + default="model", + ), + "model_nam_file": Var( + name="model_nam_file", + _type=Optional[Union[str, PathLike]], + description=( + "The relative path to the model name file from model working folder." + ), + ), + "version": Var( + name="version", + _type=str, + description="The version of modflow", + default="mf6", + ), + "exe_name": Var( + name="exe_name", + _type=str, + description="The executable name.", + default="mf6", + ), + "model_rel_path": Var( + name="model_ws", + _type=Union[str, PathLike], + description="The model working folder path.", + default=os.curdir, + ), + **vars_, + } + + if d["references"]: + for key, ref in d["references"].items(): + if key not in vars_: + continue + vars_[ref["val"]] = Var( + name=ref["val"], + description=ref.get("description", None), + reference=ref, + ) + + d["variables"] = vars_ + return d + + +def _add_sim_vars(ctx): + """Add variables for a simulation context.""" + d = dict(ctx) + vars_ = d["variables"].copy() + skip_init = [ + "tdis6", + "models", + "exchanges", + "mxiter", + "solutiongroup", + ] + for k in skip_init: + var = vars_.get(k, None) + if var: + var["init_param"] = False + vars_[k] = var + vars_ = { + "sim_name": Var( + name="sim_name", + _type=str, + default="sim", + description="Name of the simulation.", + ), + "version": Var( + name="version", + _type=str, + default="mf6", + ), + "exe_name": Var( + name="exe_name", + _type=Union[str, PathLike], + default="mf6", + ), + "sim_ws": Var( + name="sim_ws", + _type=Union[str, PathLike], + default=os.curdir, + ), + "verbosity_level": Var( + name="verbosity_level", + _type=int, + default=1, + ), + "write_headers": Var( + name="write_headers", + _type=bool, + default=True, + ), + "use_pandas": Var( + name="use_pandas", + _type=bool, + default=True, + ), + "lazy_io": Var( + name="lazy_io", + _type=bool, + default=False, + ), + **vars_, + } + + if d["references"]: + for key, ref in d["references"].items(): + if key not in vars_: + continue + vars_[ref["val"]] = Var( + name=ref["val"], + description=ref.get("description", None), + reference=ref, + ) + + d["variables"] = vars_ + return d + + +def _add_ctx_vars(o): + d = dict(o) + if d["name"].base == "MFSimulationBase": + return _add_sim_vars(d) + elif d["name"].base == "MFModel": + return _add_mdl_vars(d) + elif d["name"].base == "MFPackage": + if d["name"].l == "exg": + return _add_exg_vars(d) + else: + return _add_pkg_vars(d) + return d + + +def _is_ctx(o) -> bool: + d = dict(o) + return "name" in d and "base" in d + + +def _is_var(o) -> bool: + d = dict(o) + return "name" in d and "_type" in d + + +def _init_param(o) -> bool: + """Whether the var is an `__init__` method parameter.""" + d = dict(o) + if d["name"] in [ + "packages", + "tdis6", + "models", + "exchanges", + "mxiter", + "solutiongroup", + ]: + return False + if d.get("ref", None): + return False + return True + + +def _init_assign(o) -> bool: + """ + Whether to assign arguments to self in the + `__init__` method. if this is false, assume + the template has conditionals for any more + involved initialization needs. + """ + d = dict(o) + return d["name"] in ["exgtype", "exgnamea", "exgnameb"] + + +def _init_build(o) -> bool: + """ + Whether to call `build_mfdata()` on the variable. + in the `__init__` method. + """ + d = dict(o) + ref = d.get("ref", None) + if ref: + return False + if d["name"] in [ + "parent", + "loading_package", + "exgtype", + "exgnamea", + "exgnameb", + "filename", + "pname", + "parent_file" "simulation", + "modelname", + "model_nam_file", + "version", + "exe_name", + "model_rel_path", + "sim_name", + "sim_ws", + "verbosity_level", + "write_headers", + "use_pandas", + "lazy_io", + ]: + return False + return True + + +def _init_super(o) -> bool: + """ + Whether to pass the variable to `super().__init__()` + by name in the `__init__` method.""" + d = dict(o) + return d["name"] in [ + "parent", + "loading_package", + "filename", + "pname", + "simulation", + "modelname", + "model_nam_file", + "version", + "exe_name", + "model_rel_path", + "sim_name", + "sim_ws", + "verbosity_level", + "write_headers", + "use_pandas", + "lazy_io", + ] + + +def _class_attr(o) -> bool: + """Whether to add a class attribute for the variable.""" + d = dict(o) + if d.get("ref", None): + return True + kind = VarKind.from_type(d["_type"]) + if kind != VarKind.Scalar: + return True + return False + + +def _kind(o) -> VarKind: + # the variable's general shape. because Jinja + # doesn't allow arbitrary expressions, and it + # doesn't seem to have a subclass test filter, + # we need this for template conditional exprs. + d = dict(o) + return VarKind.from_type(d["_type"]) + + +def _loose_type(o) -> type: + """ + Derive a "loose" (lenient) typing attribute + from the variable's type, which can be more + accepting than the variable's specification. + Used for init method params, while the spec + itself (in e.g. the class docstring) can be + the more descriptive (i.e. unmodified) type. + """ + d = dict(o) + if d["kind"] == VarKind.Array: + # arrays can be described as NDArray with a + # type parameter, or ndarray with type and + # shape parameters, while init params can + # be specified more loosely as ArrayLike. + return ArrayLike + if d["kind"] == VarKind.List: + # lists can be iterables regardless whether + # regular. if regular then accept dataframe + _iterable = Iterable[get_args(d["_type"])[0]] + children = list(d["children"].values()) + if ( + any(children) + and VarKind.from_type(children[0]["_type"]) == VarKind.Union + ): + return _iterable + return Union[_iterable, DataFrame] + # TODO transient lists: + # map of lists by stress period, or... + # iterable appled to all stress periods + return d["_type"] + + +def _dfn(o) -> List[Metadata]: + """ + Get a list of the class' original definition attributes + as a partial, internal reproduction of the DFN contents. + + Notes + ----- + Currently, generated classes have a `.dfn` property that + reproduces the corresponding DFN sans a few attributes. + This represents the DFN in raw form, before adapting to + Python, consolidating nested types, etc. + """ + + d = dict(o) + dfn = d["definition"] + + def _fmt_var(var: Union[Var, List[Var]]) -> List[str]: + exclude = ["longname", "description"] + + def _fmt_name(k, v): + return v.replace("-", "_") if k == "name" else v + + return [ + " ".join([k, str(_fmt_name(k, v))]).strip() + for k, v in var.items() + if k not in exclude + ] + + meta = dfn["metadata"] or list() + return [["header"] + [m for m in meta]] + [ + _fmt_var(var) for var in dfn["data"].values() + ] + + +SHIM = { + "keep_none": ["default", "block", "metadata"], + "quote_str": ["default"], + "set_pairs": [ + (_is_ctx, [("dfn", _dfn)]), + ( + _is_var, + [ + ("kind", _kind), + ("loose_type", _loose_type), + ("init_param", _init_param), + ("init_assign", _init_assign), + ("init_build", _init_build), + ("init_super", _init_super), + ("class_attr", _class_attr), + ], + ), + ], + "type_name": ["_type"], + "transform": [ + # context-specific parameters + # for the `__init__()` method. + # do it as a `transform` (not + # `set_pairs`) so we are able + # to control the param order. + (_is_ctx, _add_ctx_vars) + ], +} +""" +Arguments for `renderable` as applied to `Context` +to support the current `flopy.mf6` input framework. +""" diff --git a/flopy/mf6/utils/codegen/spec.py b/flopy/mf6/utils/codegen/spec.py new file mode 100644 index 0000000000..21253ef8ca --- /dev/null +++ b/flopy/mf6/utils/codegen/spec.py @@ -0,0 +1,88 @@ +import collections +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Optional, Union, get_args, get_origin + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from flopy.mf6.utils.codegen.dfn import Metadata +from flopy.mf6.utils.codegen.ref import Ref + + +class VarKind(Enum): + """ + An input variable's kind. This is an enumeration + of the general shapes of data MODFLOW 6 accepts, + convertible to/from Python primitives/composites. + """ + + Array = "array" + Scalar = "scalar" + Record = "record" + Union = "union" + List = "list" + + @classmethod + def from_type(cls, t: type) -> Optional["VarKind"]: + origin = get_origin(t) + args = get_args(t) + if origin is Union: + if len(args) >= 2 and args[-1] is type(None): + if len(args) > 2: + return VarKind.Union + return cls.from_type(args[0]) + return VarKind.Union + if origin is np.ndarray or origin is NDArray or origin is ArrayLike: + return VarKind.Array + elif origin is collections.abc.Iterable or origin is list: + return VarKind.List + elif origin is tuple: + return VarKind.Record + try: + if issubclass(t, (bool, int, float, str)): + return VarKind.Scalar + except: + pass + return None + + def to_type(self) -> type: + # TODO + pass + + +@dataclass +class Var: + """An input variable specification.""" + + name: str + _type: Union[type, str] + block: Optional[str] + description: Optional[str] + default: Optional[Any] + children: Optional[Dict[str, "Var"]] + metadata: Optional[Metadata] + reference: Optional[Ref] + + def __init__( + self, + name: str, + _type: Optional[type] = None, + block: Optional[str] = None, + description: Optional[str] = None, + default: Optional[Any] = None, + children: Optional["Vars"] = None, + metadata: Optional[Metadata] = None, + reference: Optional[Ref] = None, + ): + self.name = name + self._type = _type or Any + self.block = block + self.description = description + self.default = default + self.children = children + self.metadata = metadata + self.reference = reference + + +Vars = Dict[str, Var] diff --git a/flopy/mf6/utils/codegen/utils.py b/flopy/mf6/utils/codegen/utils.py new file mode 100644 index 0000000000..db659f147a --- /dev/null +++ b/flopy/mf6/utils/codegen/utils.py @@ -0,0 +1,41 @@ +import collections +from enum import Enum +from typing import Any, ForwardRef, Literal, Union, get_args, get_origin + +import numpy as np + + +def _try_get_type_name(t) -> str: + """Convert a type to a name suitable for templating.""" + origin = get_origin(t) + args = get_args(t) + if origin is Literal: + args = ['"' + a + '"' for a in args] + return f"Literal[{', '.join(args)}]" + elif origin is Union: + if len(args) >= 2 and args[-1] is type(None): + if len(args) > 2: + return f"Optional[Union[{', '.join([_try_get_type_name(a) for a in args[:-1]])}]]" + return f"Optional[{_try_get_type_name(args[0])}]" + return f"Union[{', '.join([_try_get_type_name(a) for a in args])}]" + elif origin is tuple: + return f"Tuple[{', '.join([_try_get_type_name(a) for a in args])}]" + elif origin is collections.abc.Iterable: + return f"Iterable[{', '.join([_try_get_type_name(a) for a in args])}]" + elif origin is list: + return f"List[{', '.join([_try_get_type_name(a) for a in args])}]" + elif origin is np.ndarray: + return f"NDArray[np.{_try_get_type_name(args[1].__args__[0])}]" + elif origin is np.dtype: + return str(t) + elif isinstance(t, ForwardRef): + return t.__forward_arg__ + elif t is Ellipsis: + return "..." + elif isinstance(t, type): + return t.__qualname__ + return t + + +def _try_get_enum_value(v: Any) -> Any: + return v.value if isinstance(v, Enum) else v diff --git a/flopy/mf6/utils/createpackages.py b/flopy/mf6/utils/createpackages.py index e1a57fb094..6d76aa8cf6 100644 --- a/flopy/mf6/utils/createpackages.py +++ b/flopy/mf6/utils/createpackages.py @@ -81,1056 +81,14 @@ """ -import datetime -import os -import textwrap -from enum import Enum +from pathlib import Path -# keep below as absolute imports -from flopy.mf6.data import mfdatautil, mfstructure -from flopy.utils import datautil +from flopy.mf6.utils.codegen.make import make_all - -class PackageLevel(Enum): - sim_level = 0 - model_level = 1 - - -def build_doc_string(param_name, param_type, param_desc, indent): - return f"{indent}{param_name} : {param_type}\n{indent * 2}* {param_desc}" - - -def generator_type(data_type): - if ( - data_type == mfstructure.DataType.scalar_keyword - or data_type == mfstructure.DataType.scalar - ): - # regular scalar - return "ScalarTemplateGenerator" - elif ( - data_type == mfstructure.DataType.scalar_keyword_transient - or data_type == mfstructure.DataType.scalar_transient - ): - # transient scalar - return "ScalarTemplateGenerator" - elif data_type == mfstructure.DataType.array: - # array - return "ArrayTemplateGenerator" - elif data_type == mfstructure.DataType.array_transient: - # transient array - return "ArrayTemplateGenerator" - elif data_type == mfstructure.DataType.list: - # list - return "ListTemplateGenerator" - elif ( - data_type == mfstructure.DataType.list_transient - or data_type == mfstructure.DataType.list_multiple - ): - # transient or multiple list - return "ListTemplateGenerator" - - -def clean_class_string(name): - if len(name) > 0: - clean_string = name.replace(" ", "_") - clean_string = clean_string.replace("-", "_") - version = mfstructure.MFStructure().get_version_string() - # FIX: remove all numbers - if clean_string[-1] == version: - clean_string = clean_string[:-1] - return clean_string - return name - - -def build_dfn_string(dfn_list, header, package_abbr, flopy_dict): - dfn_string = " dfn = [" - line_length = len(dfn_string) - leading_spaces = " " * line_length - first_di = True - - # process header - dfn_string = f'{dfn_string}\n{leading_spaces}["header", ' - for key, value in header.items(): - if key == "multi-package": - dfn_string = f'{dfn_string}\n{leading_spaces} "multi-package", ' - if key == "package-type": - dfn_string = ( - f'{dfn_string}\n{leading_spaces} "package-type ' f'{value}"' - ) - - # process solution packages - if package_abbr in flopy_dict["solution_packages"]: - model_types = '", "'.join( - flopy_dict["solution_packages"][package_abbr] - ) - dfn_string = ( - f"{dfn_string}\n{leading_spaces} " - f'["solution_package", "{model_types}"], ' - ) - dfn_string = f"{dfn_string}],\n{leading_spaces}" - - # process all data items - for data_item in dfn_list: - line_length += 1 - if not first_di: - dfn_string = f"{dfn_string},\n{leading_spaces}" - line_length = len(leading_spaces) - else: - first_di = False - dfn_string = f"{dfn_string}[" - first_line = True - # process each line in a data item - for line in data_item: - line = line.strip() - # do not include the description of longname - if not line.lower().startswith( - "description" - ) and not line.lower().startswith("longname"): - line = line.replace('"', "'") - line_length += len(line) + 4 - if not first_line: - dfn_string = f"{dfn_string}," - if line_length < 77: - # added text fits on the current line - if first_line: - dfn_string = f'{dfn_string}"{line}"' - else: - dfn_string = f'{dfn_string} "{line}"' - else: - # added text does not fit on the current line - line_length = len(line) + len(leading_spaces) + 2 - if line_length > 79: - # added text too long to fit on a single line, wrap - # text as needed - line = f'"{line}"' - lines = textwrap.wrap( - line, - 75 - len(leading_spaces), - drop_whitespace=True, - ) - lines[0] = f"{leading_spaces} {lines[0]}" - line_join = f' "\n{leading_spaces} "' - dfn_string = f"{dfn_string}\n{line_join.join(lines)}" - else: - dfn_string = f'{dfn_string}\n{leading_spaces} "{line}"' - first_line = False - - dfn_string = f"{dfn_string}]" - dfn_string = f"{dfn_string}]" - return dfn_string - - -def create_init_var(clean_ds_name, data_structure_name, init_val=None): - if init_val is None: - init_val = clean_ds_name - - init_var = f" self.{clean_ds_name} = self.build_mfdata(" - leading_spaces = " " * len(init_var) - if len(init_var) + len(data_structure_name) + 2 > 79: - second_line = f'\n "{data_structure_name}",' - if len(second_line) + len(clean_ds_name) + 2 > 79: - init_var = f"{init_var}{second_line}\n {init_val})" - else: - init_var = f"{init_var}{second_line} {init_val})" - else: - init_var = f'{init_var}"{data_structure_name}",' - if len(init_var) + len(clean_ds_name) + 2 > 79: - init_var = f"{init_var}\n{leading_spaces}{init_val})" - else: - init_var = f"{init_var} {init_val})" - return init_var - - -def create_basic_init(clean_ds_name): - return f" self.{clean_ds_name} = {clean_ds_name}\n" - - -def create_property(clean_ds_name): - return f" {clean_ds_name} = property(get_{clean_ds_name}, set_{clean_ds_name})" - - -def format_var_list(base_string, var_list, is_tuple=False): - if is_tuple: - base_string = f"{base_string}(" - extra_chars = 4 - else: - extra_chars = 2 - line_length = len(base_string) - leading_spaces = " " * line_length - # determine if any variable name is too long to fit - for item in var_list: - if line_length + len(item) + extra_chars > 80: - leading_spaces = " " - base_string = f"{base_string}\n{leading_spaces}" - line_length = len(leading_spaces) - break - - for index, item in enumerate(var_list): - if is_tuple: - item = f"'{item}'" - if index == len(var_list) - 1: - next_var_str = item - else: - next_var_str = f"{item}," - line_length += len(item) + extra_chars - if line_length > 80: - base_string = f"{base_string}\n{leading_spaces}{next_var_str}" - else: - if base_string[-1] == ",": - base_string = f"{base_string} " - base_string = f"{base_string}{next_var_str}" - if is_tuple: - return f"{base_string}))" - else: - return f"{base_string})" - - -def create_package_init_var( - parameter_name, package_abbr, data_name, clean_ds_name -): - one_line = ( - f" self._{package_abbr}_package = self.build_child_package(" - ) - one_line_b = f'"{package_abbr}", {parameter_name},' - leading_spaces = " " * len(one_line) - two_line = f'\n{leading_spaces}"{data_name}",' - three_line = f"\n{leading_spaces}self._{clean_ds_name})" - return f"{one_line}{one_line_b}{two_line}{three_line}" - - -def add_var( - init_vars, - class_vars, - options_param_list, - init_param_list, - package_properties, - doc_string, - data_structure_dict, - default_value, - name, - python_name, - description, - path, - data_type, - basic_init=False, - construct_package=None, - construct_data=None, - parameter_name=None, - set_param_list=None, - mf_nam=False, -): - if set_param_list is None: - set_param_list = [] - clean_ds_name = datautil.clean_name(python_name) - if construct_package is None: - # add variable initialization lines - if basic_init: - init_vars.append(create_basic_init(clean_ds_name)) - else: - init_vars.append(create_init_var(clean_ds_name, name)) - # add to parameter list - if default_value is None: - default_value = "None" - init_param_list.append(f"{clean_ds_name}={default_value}") - if path is not None and "options" in path: - options_param_list.append(f"{clean_ds_name}={default_value}") - # add to set parameter list - set_param_list.append(f"{clean_ds_name}={clean_ds_name}") - else: - clean_parameter_name = datautil.clean_name(parameter_name) - # init hidden variable - init_vars.append(create_init_var(f"_{clean_ds_name}", name, "None")) - if mf_nam: - options_param_list.append( - [f"{parameter_name}_data=None", parameter_name] - ) - else: - # init child package - init_vars.append( - create_package_init_var( - clean_parameter_name, - construct_package, - construct_data, - clean_ds_name, - ) - ) - # add to parameter list - init_param_list.append(f"{clean_parameter_name}=None") - # add to set parameter list - set_param_list.append( - f"{clean_parameter_name}={clean_parameter_name}" - ) - - package_properties.append(create_property(clean_ds_name)) - doc_string.add_parameter(description, model_parameter=True) - data_structure_dict[python_name] = 0 - if class_vars is not None: - gen_type = generator_type(data_type) - if gen_type != "ScalarTemplateGenerator": - new_class_var = f" {clean_ds_name} = {gen_type}(" - class_vars.append(format_var_list(new_class_var, path, True)) - return gen_type - return None - - -def build_init_string( - init_string, init_param_list, whitespace=" " -): - line_chars = len(init_string) - for index, param in enumerate(init_param_list): - if isinstance(param, list): - param = param[0] - if index + 1 < len(init_param_list): - line_chars += len(param) + 2 - else: - line_chars += len(param) + 3 - if line_chars > 79: - if len(param) + len(whitespace) + 1 > 79: - # try to break apart at = sign - param_list = param.split("=") - if len(param_list) == 2: - init_string = "{},\n{}{}=\n{}{}".format( - init_string, - whitespace, - param_list[0], - whitespace, - param_list[1], - ) - line_chars = len(param_list[1]) + len(whitespace) + 1 - continue - init_string = f"{init_string},\n{whitespace}{param}" - line_chars = len(param) + len(whitespace) + 1 - else: - init_string = f"{init_string}, {param}" - return f"{init_string}):\n" - - -def build_model_load(model_type): - model_load_c = ( - " Methods\n -------\n" - " load : (simulation : MFSimulationData, model_name : " - "string,\n namfile : string, " - "version : string, exe_name : string,\n model_ws : " - "string, strict : boolean) : MFSimulation\n" - " a class method that loads a model from files" - '\n """' - ) - - model_load = ( - " @classmethod\n def load(cls, simulation, structure, " - "modelname='NewModel',\n " - "model_nam_file='modflowtest.nam', version='mf6',\n" - " exe_name='mf6', strict=True, " - "model_rel_path='.',\n" - " load_only=None):\n " - "return mfmodel.MFModel.load_base(cls, simulation, structure, " - "modelname,\n " - "model_nam_file, '{}6', version,\n" - " exe_name, strict, " - "model_rel_path,\n" - " load_only)" - "\n".format(model_type) - ) - return model_load, model_load_c - - -def build_sim_load(): - sim_load_c = ( - " Methods\n -------\n" - " load : (sim_name : str, version : " - "string,\n exe_name : str or PathLike, " - "sim_ws : str or PathLike, strict : bool,\n verbosity_level : " - "int, load_only : list, verify_data : bool,\n " - "write_headers : bool, lazy_io : bool, use_pandas : bool,\n " - ") : MFSimulation\n" - " a class method that loads a simulation from files" - '\n """' - ) - - sim_load = ( - " @classmethod\n def load(cls, sim_name='modflowsim', " - "version='mf6',\n " - "exe_name: Union[str, os.PathLike] = 'mf6',\n " - "sim_ws: Union[str, os.PathLike] = os.curdir,\n " - "strict=True, verbosity_level=1, load_only=None,\n " - "verify_data=False, write_headers=True,\n " - "lazy_io=False, use_pandas=True):\n " - "return mfsimbase.MFSimulationBase.load(cls, sim_name, version, " - "\n " - "exe_name, sim_ws, strict,\n" - " verbosity_level, " - "load_only,\n " - "verify_data, write_headers, " - "\n lazy_io, use_pandas)" - "\n" - ) - return sim_load, sim_load_c - - -def build_model_init_vars(param_list): - init_var_list = [] - # build set data calls - for param in param_list: - if not isinstance(param, list): - param_parts = param.split("=") - init_var_list.append( - f" self.name_file.{param_parts[0]}.set_data({param_parts[0]})" - ) - init_var_list.append("") - # build attributes - for param in param_list: - if isinstance(param, list): - pkg_name = param[1] - param_parts = param[0].split("=") - init_var_list.append( - f" self.{param_parts[0]} = " - f"self._create_package('{pkg_name}', {param_parts[0]})" - ) - else: - param_parts = param.split("=") - init_var_list.append( - f" self.{param_parts[0]} = self.name_file.{param_parts[0]}" - ) - - return "\n".join(init_var_list) - - -def create_packages(): - indent = " " - init_string_def = " def __init__(self" - - # load JSON file - file_structure = mfstructure.MFStructure(load_from_dfn_files=True) - sim_struct = file_structure.sim_struct - - # assemble package list of buildable packages - package_list = [] - for package in sim_struct.utl_struct_objs.values(): - # add utility packages to list - package_list.append( - ( - package, - PackageLevel.model_level, - "utl", - package.dfn_list, - package.file_type, - package.header, - ) - ) - package_list.append( - ( - sim_struct.name_file_struct_obj, - PackageLevel.sim_level, - "", - sim_struct.name_file_struct_obj.dfn_list, - sim_struct.name_file_struct_obj.file_type, - sim_struct.name_file_struct_obj.header, - ) - ) - for package in sim_struct.package_struct_objs.values(): - # add simulation level package to list - package_list.append( - ( - package, - PackageLevel.sim_level, - "", - package.dfn_list, - package.file_type, - package.header, - ) - ) - for model_key, model in sim_struct.model_struct_objs.items(): - package_list.append( - ( - model.name_file_struct_obj, - PackageLevel.model_level, - model_key, - model.name_file_struct_obj.dfn_list, - model.name_file_struct_obj.file_type, - model.name_file_struct_obj.header, - ) - ) - for package in model.package_struct_objs.values(): - package_list.append( - ( - package, - PackageLevel.model_level, - model_key, - package.dfn_list, - package.file_type, - package.header, - ) - ) - - util_path, tail = os.path.split(os.path.realpath(__file__)) - init_file = open( - os.path.join(util_path, "..", "modflow", "__init__.py"), - "w", - newline="\n", - ) - init_file.write("from .mfsimulation import MFSimulation # isort:skip\n") - - nam_import_string = ( - "from .. import mfmodel\nfrom ..data.mfdatautil " - "import ArrayTemplateGenerator, ListTemplateGenerator" - ) - - # loop through packages list - init_file_imports = [] - flopy_dict = file_structure.flopy_dict - for package in package_list: - data_structure_dict = {} - package_properties = [] - init_vars = [] - init_param_list = [] - options_param_list = [] - set_param_list = [] - class_vars = [] - template_gens = [] - - package_abbr = clean_class_string( - f"{clean_class_string(package[2])}{package[0].file_type}" - ).lower() - dfn_string = build_dfn_string( - package[3], package[5], package_abbr, flopy_dict - ) - package_name = clean_class_string( - "{}{}{}".format( - clean_class_string(package[2]), - package[0].file_prefix, - package[0].file_type, - ) - ).lower() - if package[0].description: - doc_string = mfdatautil.MFDocString(package[0].description) - else: - if package[2]: - package_container_text = f" within a {package[2]} model" - else: - package_container_text = "" - ds = "Modflow{} defines a {} package{}.".format( - package_name.title(), - package[0].file_type, - package_container_text, - ) - if package[0].file_type == "mvr": - # mvr package warning - if package[2]: - ds = ( - "{} This package\n can only be used to move " - "water between packages within a single model." - "\n To move water between models use ModflowMvr" - ".".format(ds) - ) - else: - ds = ( - "{} This package can only be used to move\n " - "water between two different models. To move " - "water between two packages\n in the same " - 'model use the "model level" mover package (ex. ' - "ModflowGwfmvr).".format(ds) - ) - - doc_string = mfdatautil.MFDocString(ds) - - if package[0].dfn_type == mfstructure.DfnType.exch_file: - exgtype = ( - f'"{package_abbr[0:3].upper()}6-{package_abbr[3:].upper()}6"' - ) - - add_var( - init_vars, - None, - options_param_list, - init_param_list, - package_properties, - doc_string, - data_structure_dict, - exgtype, - "exgtype", - "exgtype", - build_doc_string( - "exgtype", - "", - "is the exchange type (GWF-GWF or GWF-GWT).", - indent, - ), - None, - None, - True, - ) - add_var( - init_vars, - None, - options_param_list, - init_param_list, - package_properties, - doc_string, - data_structure_dict, - None, - "exgmnamea", - "exgmnamea", - build_doc_string( - "exgmnamea", - "", - "is the name of the first model that is " - "part of this exchange.", - indent, - ), - None, - None, - True, - ) - add_var( - init_vars, - None, - options_param_list, - init_param_list, - package_properties, - doc_string, - data_structure_dict, - None, - "exgmnameb", - "exgmnameb", - build_doc_string( - "exgmnameb", - "", - "is the name of the second model that is " - "part of this exchange.", - indent, - ), - None, - None, - True, - ) - init_vars.append( - " simulation.register_exchange_file(self)\n" - ) - - # loop through all blocks - for block in package[0].blocks.values(): - for data_structure in block.data_structures.values(): - # only create one property for each unique data structure name - if data_structure.name not in data_structure_dict: - mf_sim = ( - "parent_name_type" in package[0].header - and package[0].header["parent_name_type"][1] - == "MFSimulation" - ) - mf_nam = package[0].file_type == "nam" - if ( - data_structure.construct_package is not None - and not mf_sim - and not mf_nam - ): - c_pkg = data_structure.construct_package - else: - c_pkg = None - tg = add_var( - init_vars, - class_vars, - options_param_list, - init_param_list, - package_properties, - doc_string, - data_structure_dict, - data_structure.default_value, - data_structure.name, - data_structure.python_name, - data_structure.get_doc_string(79, indent, indent), - data_structure.path, - data_structure.get_datatype(), - False, - # c_pkg, - data_structure.construct_package, - data_structure.construct_data, - data_structure.parameter_name, - set_param_list, - mf_nam, - ) - if tg is not None and tg not in template_gens: - template_gens.append(tg) - - import_string = "from .. import mfpackage" - if template_gens: - import_string += "\nfrom ..data.mfdatautil import " - import_string += ", ".join(sorted(template_gens)) - # add extra docstrings for additional variables - doc_string.add_parameter( - " filename : String\n File name for this package." - ) - doc_string.add_parameter( - " pname : String\n Package name for this package." - ) - doc_string.add_parameter( - " parent_file : MFPackage\n " - "Parent package file that references this " - "package. Only needed for\n utility " - "packages (mfutl*). For example, mfutllaktab " - "package must have \n a mfgwflak " - "package parent_file." - ) - - # build package builder class string - init_vars.append(" self._init_complete = True") - init_vars = "\n".join(init_vars) - package_short_name = clean_class_string(package[0].file_type).lower() - class_def_string = "class Modflow{}(mfpackage.MFPackage):\n".format( - package_name.title() - ) - class_def_string = class_def_string.replace("-", "_") - class_var_string = ( - '{}\n package_abbr = "{}"\n _package_type = ' - '"{}"\n dfn_file_name = "{}"' - "\n".format( - "\n".join(class_vars), - package_abbr, - package[4], - package[0].dfn_file_name, - ) - ) - init_string_full = init_string_def - init_string_sim = f"{init_string_def}, simulation" - # add variables to init string - doc_string.add_parameter( - " loading_package : bool\n " - "Do not set this parameter. It is intended " - "for debugging and internal\n " - "processing purposes only.", - beginning_of_list=True, - ) - if "parent_name_type" in package[0].header: - init_var = package[0].header["parent_name_type"][0] - parent_type = package[0].header["parent_name_type"][1] - elif package[1] == PackageLevel.sim_level: - init_var = "simulation" - parent_type = "MFSimulation" - else: - init_var = "model" - parent_type = "MFModel" - doc_string.add_parameter( - f" {init_var} : {parent_type}\n " - f"{init_var.capitalize()} that this package is a part " - "of. Package is automatically\n " - f"added to {init_var} when it is " - "initialized.", - beginning_of_list=True, - ) - init_string_full = ( - f"{init_string_full}, {init_var}, loading_package=False" - ) - init_param_list.append("filename=None") - init_param_list.append("pname=None") - init_param_list.append("**kwargs") - init_string_full = build_init_string(init_string_full, init_param_list) - - # build init code - parent_init_string = " super().__init__(" - spaces = " " * len(parent_init_string) - parent_init_string = ( - '{}{}, "{}", filename, pname,\n{}' - "loading_package, **kwargs)\n\n" - " # set up variables".format( - parent_init_string, init_var, package_short_name, spaces - ) - ) - local_datetime = datetime.datetime.now(datetime.timezone.utc) - comment_string = ( - "# DO NOT MODIFY THIS FILE DIRECTLY. THIS FILE " - "MUST BE CREATED BY\n# mf6/utils/createpackages.py\n" - "# FILE created on {} UTC".format( - local_datetime.strftime("%B %d, %Y %H:%M:%S") - ) - ) - # assemble full package string - package_string = "{}\n{}\n\n\n{}{}\n{}\n{}\n\n{}{}\n{}\n".format( - comment_string, - import_string, - class_def_string, - doc_string.get_doc_string(), - class_var_string, - dfn_string, - init_string_full, - parent_init_string, - init_vars, - ) - - # open new Packages file - pb_file = open( - os.path.join(util_path, "..", "modflow", f"mf{package_name}.py"), - "w", - newline="\n", - ) - pb_file.write(package_string) - if ( - package[0].sub_package - and package_abbr != "utltab" - and ( - "parent_name_type" not in package[0].header - or package[0].header["parent_name_type"][1] != "MFSimulation" - ) - ): - set_param_list.append("filename=filename") - set_param_list.append("pname=pname") - set_param_list.append("child_builder_call=True") - whsp_1 = " " - whsp_2 = " " - - file_prefix = package[0].dfn_file_name[0:3] - chld_doc_string = ( - ' """\n {}Packages is a container ' - "class for the Modflow{} class.\n\n " - "Methods\n ----------" - "\n".format(package_name.title(), package_name.title()) - ) - - # write out child packages class - chld_cls = ( - "\n\nclass {}Packages(mfpackage.MFChildPackage" "s):\n".format( - package_name.title() - ) - ) - chld_var = ( - f" package_abbr = " - f'"{package_name.title().lower()}packages"\n\n' - ) - chld_init = " def initialize(self" - chld_init = build_init_string( - chld_init, init_param_list[:-1], whsp_1 - ) - init_pkg = "\n self.init_package(new_package, filename)" - params_init = ( - " new_package = Modflow" - f"{package_name.title()}(self._cpparent" - ) - params_init = build_init_string( - params_init, set_param_list, whsp_2 - ) - chld_doc_string = ( - "{} initialize\n Initializes a new " - "Modflow{} package removing any sibling " - "child\n packages attached to the same " - "parent package. See Modflow{} init\n " - " documentation for definition of " - "parameters.\n".format( - chld_doc_string, package_name.title(), package_name.title() - ) - ) - - chld_appn = "" - params_appn = "" - append_pkg = "" - if package_abbr != "utlobs": # Hard coded obs no multi-pkg support - chld_appn = "\n\n def append_package(self" - chld_appn = build_init_string( - chld_appn, init_param_list[:-1], whsp_1 - ) - append_pkg = ( - "\n self._append_package(new_package, filename)" - ) - params_appn = ( - " new_package = Modflow" - f"{file_prefix.capitalize()}" - f"{package_short_name}(self._cpparent" - ) - params_appn = build_init_string( - params_appn, set_param_list, whsp_2 - ) - chld_doc_string = ( - "{} append_package\n Adds a " - "new Modflow{}{} package to the container." - " See Modflow{}{}\n init " - "documentation for definition of " - "parameters.\n".format( - chld_doc_string, - file_prefix.capitalize(), - package_short_name, - file_prefix.capitalize(), - package_short_name, - ) - ) - chld_doc_string = f'{chld_doc_string} """\n' - packages_str = "{}{}{}{}{}{}{}{}{}\n".format( - chld_cls, - chld_doc_string, - chld_var, - chld_init, - params_init[:-2], - init_pkg, - chld_appn, - params_appn[:-2], - append_pkg, - ) - pb_file.write(packages_str) - pb_file.close() - - init_file_imports.append( - f"from .mf{package_name} import Modflow{package_name.title()}\n" - ) - - if package[0].dfn_type == mfstructure.DfnType.model_name_file: - # build model file - init_vars = build_model_init_vars(options_param_list) - - options_param_list.insert(0, "model_rel_path='.'") - options_param_list.insert(0, "exe_name='mf6'") - options_param_list.insert(0, "version='mf6'") - options_param_list.insert(0, "model_nam_file=None") - options_param_list.insert(0, "modelname='model'") - options_param_list.append("**kwargs,") - init_string_sim = build_init_string( - init_string_sim, options_param_list - ) - sim_name = clean_class_string(package[2]) - class_def_string = "class Modflow{}(mfmodel.MFModel):\n".format( - sim_name.capitalize() - ) - class_def_string = class_def_string.replace("-", "_") - doc_string.add_parameter( - " sim : MFSimulation\n " - "Simulation that this model is a part " - "of. Model is automatically\n " - "added to simulation when it is " - "initialized.", - beginning_of_list=True, - model_parameter=True, - ) - doc_string.description = ( - f"Modflow{sim_name} defines a {sim_name} model" - ) - class_var_string = f" model_type = '{sim_name}'\n" - mparent_init_string = " super().__init__(" - spaces = " " * len(mparent_init_string) - mparent_init_string = ( - "{}simulation, model_type='{}6',\n{}" - "modelname=modelname,\n{}" - "model_nam_file=model_nam_file,\n{}" - "version=version, exe_name=exe_name,\n{}" - "model_rel_path=model_rel_path,\n{}" - "**kwargs," - ")\n".format( - mparent_init_string, - sim_name, - spaces, - spaces, - spaces, - spaces, - spaces, - ) - ) - load_txt, doc_text = build_model_load(sim_name) - package_string = "{}\n{}\n\n\n{}{}\n{}\n{}\n{}{}\n{}\n\n{}".format( - comment_string, - nam_import_string, - class_def_string, - doc_string.get_doc_string(True), - doc_text, - class_var_string, - init_string_sim, - mparent_init_string, - init_vars, - load_txt, - ) - md_file = open( - os.path.join(util_path, "..", "modflow", f"mf{sim_name}.py"), - "w", - newline="\n", - ) - md_file.write(package_string) - md_file.close() - init_file_imports.append( - f"from .mf{sim_name} import Modflow{sim_name.capitalize()}\n" - ) - elif package[0].dfn_type == mfstructure.DfnType.sim_name_file: - # build simulation file - init_vars = build_model_init_vars(options_param_list) - - options_param_list.insert(0, "lazy_io=False") - options_param_list.insert(0, "use_pandas=True") - options_param_list.insert(0, "write_headers=True") - options_param_list.insert(0, "verbosity_level=1") - options_param_list.insert( - 0, "sim_ws: Union[str, os.PathLike] = " "os.curdir" - ) - options_param_list.insert( - 0, "exe_name: Union[str, os.PathLike] " '= "mf6"' - ) - options_param_list.insert(0, "version='mf6'") - options_param_list.insert(0, "sim_name='sim'") - init_string_sim = " def __init__(self" - init_string_sim = build_init_string( - init_string_sim, options_param_list - ) - class_def_string = ( - "class MFSimulation(mfsimbase." "MFSimulationBase):\n" - ) - doc_string.add_parameter( - " sim_name : str\n" " Name of the simulation", - beginning_of_list=True, - model_parameter=True, - ) - doc_string.description = ( - "MFSimulation is used to load, build, and/or save a MODFLOW " - "6 simulation. \n A MFSimulation object must be created " - "before creating any of the MODFLOW 6 \n model objects." - ) - sparent_init_string = " super().__init__(" - spaces = " " * len(sparent_init_string) - sparent_init_string = ( - "{}sim_name=sim_name,\n{}" - "version=version,\n{}" - "exe_name=exe_name,\n{}" - "sim_ws=sim_ws,\n{}" - "verbosity_level=verbosity_level,\n{}" - "write_headers=write_headers,\n{}" - "lazy_io=lazy_io,\n{}" - "use_pandas=use_pandas,\n{}" - ")\n".format( - sparent_init_string, - spaces, - spaces, - spaces, - spaces, - spaces, - spaces, - spaces, - spaces, - ) - ) - sim_import_string = ( - "import os\n" - "from typing import Union\n" - "from .. import mfsimbase" - ) - - load_txt, doc_text = build_sim_load() - package_string = "{}\n{}\n\n\n{}{}\n{}\n{}{}\n{}\n\n{}".format( - comment_string, - sim_import_string, - class_def_string, - doc_string.get_doc_string(False, True), - doc_text, - init_string_sim, - sparent_init_string, - init_vars, - load_txt, - ) - sim_file = open( - os.path.join(util_path, "..", "modflow", "mfsimulation.py"), - "w", - newline="\n", - ) - sim_file.write(package_string) - sim_file.close() - init_file_imports.append( - "from .mfsimulation import MFSimulation\n" - ) - - # Sort the imports - for line in sorted(init_file_imports, key=lambda x: x.split()[3]): - init_file.write(line) - init_file.close() +_MF6_PATH = Path(__file__).parents[1] +_DFN_PATH = _MF6_PATH / "data" / "dfn" +_TGT_PATH = _MF6_PATH / "modflow" if __name__ == "__main__": - create_packages() + make_all(_DFN_PATH, _TGT_PATH) diff --git a/flopy/mf6/utils/generate_classes.py b/flopy/mf6/utils/generate_classes.py index 32c1d6978c..5f59135171 100644 --- a/flopy/mf6/utils/generate_classes.py +++ b/flopy/mf6/utils/generate_classes.py @@ -2,9 +2,10 @@ import shutil import tempfile import time +from pathlib import Path from warnings import warn -from .createpackages import create_packages +from .createpackages import make_all thisfilepath = os.path.dirname(os.path.abspath(__file__)) flopypth = os.path.join(thisfilepath, "..", "..") @@ -14,6 +15,10 @@ default_owner = "MODFLOW-USGS" default_repo = "modflow6" +_MF6_PATH = Path(__file__).parents[1] +_DFN_PATH = _MF6_PATH / "data" / "dfn" +_TGT_PATH = _MF6_PATH / "modflow" + def delete_files(files, pth, allow_failure=False, exclude=None): if exclude is None: @@ -189,7 +194,7 @@ def generate_classes( delete_mf6_classes() print(" Create mf6 classes using the downloaded definition files.") - create_packages() + make_all(_DFN_PATH, _TGT_PATH) list_files(os.path.join(flopypth, "mf6", "modflow")) diff --git a/flopy/mf6/utils/templates/attrs.jinja b/flopy/mf6/utils/templates/attrs.jinja new file mode 100644 index 0000000000..b05a8e19d7 --- /dev/null +++ b/flopy/mf6/utils/templates/attrs.jinja @@ -0,0 +1,17 @@ + {%- if base == "MFPackage" %} + {% for var in variables.values() if var.class_attr %} + {%- if var.kind == "list" or var.kind == "record" or var.kind == "union" %} + {{ var.name }} = ListTemplateGenerator(({% if name.l is not none and name.l != "sim" and name.l != "sln" and name.l != "utl" and name.l != "exg" %}"{{ name.l }}6", {% endif %}"{{ name.r }}", "{{ var.block }}", "{{ var.name }}")) + {%- elif var.kind == "array" %} + {{ var.name }} = ArrayTemplateGenerator(({% if name.l is not none and name.l != "sim" and name.l != "sln" and name.l != "utl" and name.l != "exg" %}"{{ name.l }}6", {% endif %}"{{ name.r }}", "{{ var.block }}", "{{ var.name }}")) + {%- endif -%} + {%- endfor -%} + {% endif -%} + {%- if base == "MFModel" %} + model_type = "{{ name.title }}" + {%- elif base == "MFPackage" %} + package_abbr = "{% if name.l != "sln" and name.l != "sim" and name.l != "exg" and name.l is not none %}{{ name.l }}{% endif %}{{ name.r }}" + _package_type = "{{ name.r }}" + dfn_file_name = "{% if name.l is not none %}{{ name.l }}-{% elif name.l is none %}sim-{% endif %}{{ name.r }}.dfn" + dfn = {{ dfn|pprint|indent(10) }} + {% endif -%} \ No newline at end of file diff --git a/flopy/mf6/utils/templates/context.py.jinja b/flopy/mf6/utils/templates/context.py.jinja new file mode 100644 index 0000000000..22a075ec84 --- /dev/null +++ b/flopy/mf6/utils/templates/context.py.jinja @@ -0,0 +1,29 @@ +# autogenerated file, do not modify +from os import PathLike, curdir +import typing +import numpy as np +from typing import Any, Optional, Tuple, List, Dict, Union, Literal, Iterable +from numpy.typing import NDArray + +from flopy.mf6.data.mfdatautil import ArrayTemplateGenerator, ListTemplateGenerator +from flopy.mf6.mfpackage import MFPackage, MFChildPackages +from flopy.mf6.mfmodel import MFModel +{# avoid circular import; some pkgs (e.g. mfnam) are used by mfsimbase.py #} +{% if base == "MFSimulationBase" %} +from flopy.mf6.mfsimbase import MFSimulationBase +{% endif %} + +{% include "records.jinja" %} + +class {% if base == "MFSimulationBase" %}MF{% else %}Modflow{% endif %}{{ name.title.title() }}({{ base }}): + {% include "docstring.jinja" %} + + {% include "attrs.jinja" %} + + {% include "init.jinja" %} + + {% include "load.jinja" %} + +{% if reference is not none and name.r != "hpc" %} +{% include "packages.jinja" %} +{% endif %} \ No newline at end of file diff --git a/flopy/mf6/utils/templates/docstring.jinja b/flopy/mf6/utils/templates/docstring.jinja new file mode 100644 index 0000000000..488b567d45 --- /dev/null +++ b/flopy/mf6/utils/templates/docstring.jinja @@ -0,0 +1,12 @@ +""" + {{ description }} + + Parameters + ---------- + {% include "docstring_params.jinja" %} + + Methods + ------- + {% include "docstring_methods.jinja" %} + """ + diff --git a/flopy/mf6/utils/templates/docstring_methods.jinja b/flopy/mf6/utils/templates/docstring_methods.jinja new file mode 100644 index 0000000000..41daf5715d --- /dev/null +++ b/flopy/mf6/utils/templates/docstring_methods.jinja @@ -0,0 +1,13 @@ +{% if base == "MFSimulationBase" %} + load : (sim_name : str, version : string, + exe_name : str or PathLike, sim_ws : str or PathLike, strict : bool, + verbosity_level : int, load_only : list, verify_data : bool, + write_headers : bool, lazy_io : bool, use_pandas : bool, + ) : MFSimulation + a class method that loads a simulation from files +{% elif base == "MFModel" %} + load : (simulation : MFSimulationData, model_name : string, + namfile : string, version : string, exe_name : string, + model_ws : string, strict : boolean) : MFSimulation + a class method that loads a model from files +{% endif %} \ No newline at end of file diff --git a/flopy/mf6/utils/templates/docstring_params.jinja b/flopy/mf6/utils/templates/docstring_params.jinja new file mode 100644 index 0000000000..f914c758c7 --- /dev/null +++ b/flopy/mf6/utils/templates/docstring_params.jinja @@ -0,0 +1,9 @@ +{%- for v in variables.values() recursive %} + * {{ v.name }} : {{ v._type }} +{%- if v.description is defined and v.description is not none %} +{{ v.description|wordwrap|indent(4 + (loop.depth * 4), first=True) }} +{%- endif %} +{%- if v.children is defined and v.children is not none and loop.depth < 2 -%} +{{ loop(v.children.values())|indent(4) }} +{%- endif %} +{% endfor -%} \ No newline at end of file diff --git a/flopy/mf6/utils/templates/init.jinja b/flopy/mf6/utils/templates/init.jinja new file mode 100644 index 0000000000..5b9be805cd --- /dev/null +++ b/flopy/mf6/utils/templates/init.jinja @@ -0,0 +1,68 @@ +def __init__( + self, + {% if parent and base != "MFModel" and name.l != "exg" -%} + {{ parent|lower|replace("mf", "")|replace("union", "")|replace("[", "")|replace("]", "")|replace(", ", "_or_") }} = None, + {%- endif %} + {%- for name, var in variables.items() if var.init_param %} + {%- if var._type == "MFSimulation" %} + {{ name }} = None, + {%- elif var.default is defined %} + {{ name }}: {{ var._type }} = {{ var.default }}, + {%- else -%} + {{ name }}: {{ var._type }}, + {% endif -%} + {%- endfor %} + **kwargs, + ): + {% if parent and base != "MFModel" and name.l != "exg" -%} + if {{ parent|lower|replace("mf", "")|replace("union", "")|replace("[", "")|replace("]", "")|replace(", ", "_or_") }}: + parent = {{ parent|lower|replace("mf", "")|replace("union", "")|replace("[", "")|replace("]", "")|replace(", ", "_or_") }} + {%- endif %} +{% if base == "MFSimulationBase" or base == "MFModel" %} + super().__init__( + {%- for n, var in variables.items() if var.init_super %} + {{ n }}={{ n }}, + {%- endfor %} + **kwargs + ) + {%- for n, var in variables.items() %} + {%- if var.block == "options" and var.init_build %} + self.name_file.{{ n }}.set_data({{ n }}) + self.{{ n }} = self.name_file.{{ n }} + {% endif -%} + {%- if var.reference is defined and base != "MFModel" %} + self.{{ var.reference.param }} = self._create_package( + "{{ var.reference.abbr }}", + {{ var.reference.param }} + ) + {% endif -%} + {% endfor -%} +{% elif base == "MFPackage" %} + super().__init__( + {%- for n, var in variables.items() if var.init_super %} + {{ n }}={{ n }}, + {%- endfor %} + package_type="{{ name.r }}", + **kwargs + ) + {% for n, var in variables.items() -%} + {%- if var.init_assign -%} + self.{{ n }} = {{ n }} + {% elif var.reference is defined and var.init_build -%} + self._{{ n }} = self.build_mfdata("{{ n }}", None) + {% elif var.reference is defined and name.r != "nam" -%} + self._{{ var.reference.abbr }}_package = self.build_child_package( + "{{ var.reference.abbr }}", + {{ var.reference.val }}, + "{{ var.reference.param }}", + self._{{ var.reference.key }} + ) + {% elif var.init_build -%} + self.{{ n }} = self.build_mfdata("{% if n == "continue_" %}continue{% else %}{{ n }}{% endif %}", {{ n }}) + {% endif -%} + {% if name.l == "exg" and n == "exgmnameb" -%} + parent.register_exchange_file(self) + {% endif -%} + {%- endfor -%} + self._init_complete = True +{% endif %} \ No newline at end of file diff --git a/flopy/mf6/utils/templates/load.jinja b/flopy/mf6/utils/templates/load.jinja new file mode 100644 index 0000000000..5ba8cc9f89 --- /dev/null +++ b/flopy/mf6/utils/templates/load.jinja @@ -0,0 +1,58 @@ +{% if base == "MFSimulationBase" %} + @classmethod + def load( + cls, + sim_name="modflowsim", + version="mf6", + exe_name: Union[str, PathLike] = "mf6", + sim_ws: Union[str, PathLike] = curdir, + strict=True, + verbosity_level=1, + load_only=None, + verify_data=False, + write_headers=True, + lazy_io=False, + use_pandas=True, + ): + return MFSimulationBase.load( + cls, + sim_name, + version, + exe_name, + sim_ws, + strict, + verbosity_level, + load_only, + verify_data, + write_headers, + lazy_io, + use_pandas, + ) +{% elif base == "MFModel" %} + @classmethod + def load( + cls, + simulation, + structure, + modelname="NewModel", + model_nam_file="modflowtest.nam", + version="mf6", + exe_name="mf6", + strict=True, + model_rel_path=curdir, + load_only=None, + ): + return MFModel.load_base( + cls, + simulation, + structure, + modelname, + model_nam_file, + "{{ name.title }}6", + version, + exe_name, + strict, + model_rel_path, + load_only, + ) +{% endif %} \ No newline at end of file diff --git a/flopy/mf6/utils/templates/packages.jinja b/flopy/mf6/utils/templates/packages.jinja new file mode 100644 index 0000000000..b1dfbaf72f --- /dev/null +++ b/flopy/mf6/utils/templates/packages.jinja @@ -0,0 +1,60 @@ +class {{ name.title.title() }}Packages(MFChildPackages): + """ + {{ name.title.title() }}Packages is a container class for the Modflow{{ name.title.title() }} class. + + Methods + ------- + initialize + Initializes a new Modflow{{ name.title.title() }} package removing any sibling child + packages attached to the same parent package. See Modflow{{ name.title.title() }} init + documentation for definition of parameters. + append_package + Adds a new Modflow{{ name.title.title() }} package to the container. See Modflow{{ name.title.title() }} + init documentation for definition of parameters. + """ + + package_abbr = "{{ name.title.lower() }}packages" + + def initialize( + self, + {%- for n, var in variables.items() if (n != "parent" and n != "loading_package" and var.init_param) %} + {%- if var._type == "MFSimulation" %} + {{ n }} = None, + {%- elif var.default is defined %} + {{ n }}: {{ var._type }} = {{ var.default }}, + {%- else -%} + {{ n }}: {{ var._type }}, + {% endif -%} + {%- endfor %} + ): + new_package = Modflow{{ name.title.title() }}( + self._cpparent, + {%- for n, var in variables.items() if (n != "parent" and n != "loading_package" and var.init_param) %} + {{ n }}={{ n }}, + {%- endfor %} + child_builder_call=True, + ) + self.init_package(new_package, filename) + +{% if name.r != "obs" %} + def append_package( + self, + {%- for n, var in variables.items() if (n != "parent" and n != "loading_package" and var.init_param) %} + {%- if var._type == "MFSimulation" %} + {{ n }} = None, + {%- elif var.default is defined %} + {{ n }}: {{ var._type }} = {{ var.default }}, + {%- else -%} + {{ n }}: {{ var._type }}, + {% endif -%} + {%- endfor %} + ): + new_package = Modflow{{ name.title.title() }}( + self._cpparent, + {%- for n, var in variables.items() if (n != "parent" and n != "loading_package" and var.init_param) %} + {{ n }}={{ n }}, + {%- endfor %} + child_builder_call=True, + ) + self._append_package(new_package, filename) +{% endif %} \ No newline at end of file diff --git a/flopy/mf6/utils/templates/records.jinja b/flopy/mf6/utils/templates/records.jinja new file mode 100644 index 0000000000..2ca88eb8ef --- /dev/null +++ b/flopy/mf6/utils/templates/records.jinja @@ -0,0 +1,18 @@ +{%- for name, var in records.items() -%} +{{ name }} = {{ var._type }} +""" +{%- if var.description is defined %} +{{ var.description|wordwrap }} +{%- endif %} +{%- for v in var.children.values() recursive %} + * {{ v.name }} : {{ v._type }} +{%- if v.description is defined and v.description is not none %} +{{ v.description|wordwrap|indent(4 + (loop.depth * 4), first=True) }} +{%- endif %} +{%- if v.children is defined -%} +{{ loop(v.children.values())|indent(4) }} +{%- endif %} +{% endfor -%} +""" + +{% endfor -%} diff --git a/pyproject.toml b/pyproject.toml index f18d5cd164..c4d566b94a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,13 @@ classifiers = [ ] requires-python = ">=3.8" dependencies = [ + "boltons", + "Jinja2>=3.0", "numpy>=1.20.3", "matplotlib >=1.4.0", - "pandas >=2.0.0" + "pandas >=2.0.0", + # necessary for createpackages.py + "ruff" ] dynamic = ["version", "readme"] @@ -39,7 +43,7 @@ dynamic = ["version", "readme"] dev = ["flopy[lint,test,optional,doc]"] lint = [ "cffconvert", - "ruff" + # "ruff" ] test = [ "flopy[lint]",