diff --git a/docs/examples/array_example.py b/docs/examples/array_example.py index afc641f..468f501 100644 --- a/docs/examples/array_example.py +++ b/docs/examples/array_example.py @@ -12,11 +12,28 @@ # name: python3 # --- -# This example demonstrates the `MFArray` class. +# # Array variables +# +# This example demonstrates how to work with array input variables. +# +# ## Overview +# +# FloPy works natively with NumPy arrays. Array input data can be provided +# as `ndarray` (or anything acting like one). FloPy also provides an array +# subclass `MFArray` supporting some special behaviors: +# +# - more efficient memory usage for constant arrays +# - convenient layered array manipulation +# - applying a multiplication factor +# +# TODO: rewrite the io stuff (external/internal) once that is moved to a +# separate layer from MFArray # + from pathlib import Path +from tempfile import TemporaryDirectory +import flopy import git import matplotlib.pyplot as plt import numpy as np @@ -271,3 +288,218 @@ mlmfa.sum() mlmfa.min(), mlmfa.mean(), mlmfa.max() + + +# ## Grid-shaped array data +# +# Most MODFLOW array data are two (row, column) or three (layer, +# row, column) dimensional and represent data on the model grid. +# grid. Other MODFLOW array data contain data by stress period. +# The following list summarizes the types of MODFLOW array data. + +# * Time-invariant multi-dimensional array data. This includes: +# 1. One and two dimensional arrays that do not have a layer dimension. +# Examples include `top`, `delc`, and `delr`. +# 2. Three dimensional arrays that can contain a layer dimension. +# Examples include `botm`, `idomain`, and `k`. +# * Transient arrays that can change with time and therefore contain arrays of +# data for one or more stress periods. Examples include `irch` and +# `recharge` in the `RCHA` package. +# +# In the example below a three dimensional ndarray is constructed for the +# `DIS` package's `botm` array. First, the a simulation and groundwater-flow +# model are set up. + +# set up where simulation workspace will be stored +temp_dir = TemporaryDirectory() +workspace = temp_dir.name +name = "grid_array_example" + +# create the FloPy simulation and tdis objects +sim = flopy.mf6.MFSimulation( + sim_name=name, exe_name="mf6", version="mf6", sim_ws=workspace +) +tdis = flopy.mf6.modflow.mftdis.ModflowTdis( + sim, + pname="tdis", + time_units="DAYS", + nper=2, + perioddata=[(1.0, 1, 1.0), (1.0, 1, 1.0)], +) +# create the Flopy groundwater flow (gwf) model object +model_nam_file = f"{name}.nam" +gwf = flopy.mf6.ModflowGwf(sim, modelname=name, model_nam_file=model_nam_file) +# create the flopy iterative model solver (ims) package object +ims = flopy.mf6.modflow.mfims.ModflowIms(sim, pname="ims", complexity="SIMPLE") + +# Then a three-dimensional ndarray of floating point values is created using +# numpy's `linspace` method. + +bot = np.linspace(-50.0 / 3.0, -3.0, 3) +delrow = delcol = 4.0 + +# The `DIS` package is then created passing the three-dimensional array to the +# `botm` parameter. The `botm` array defines the model's cell bottom +# elevations. + +dis = flopy.mf6.modflow.mfgwfdis.ModflowGwfdis( + gwf, + pname="dis", + nogrb=True, + nlay=3, + nrow=10, + ncol=10, + delr=delrow, + delc=delcol, + top=0.0, + botm=bot, +) + +# ## Adding MODFLOW Grid Array Data +# MODFLOW grid array data, like the data found in the `NPF` package's +# `GridData` block, can be specified as: +# +# 1. A constant value +# 2. A n-dimensional list +# 3. A numpy ndarray +# +# Additionally, layered grid data (generally arrays with a layer dimension) can +# be specified by layer. +# +# In the example below `icelltype` is specified as constants by layer, `k` is +# specified as a numpy ndarray, `k22` is specified as an array by layer, and +# `k33` is specified as a constant. + +# First `k` is set up as a 3 layer, by 10 row, by 10 column array with all +# values set to 10.0 using numpy's full method. + +k = np.full((3, 10, 10), 10.0) + +# Next `k22` is set up as a three dimensional list of nested lists. This +# option can be useful for those that are familiar with python lists but are +# not familiar with the numpy library. + +k22_row = [] +for row in range(0, 10): + k22_row.append(8.0) +k22_layer = [] +for col in range(0, 10): + k22_layer.append(k22_row) +k22 = [k22_layer, k22_layer, k22_layer] + +# `K33` is set up as a single constant value. Whenever an array has all the +# same values the easiest and most efficient way to set it up is as a constant +# value. Constant values also take less space to store. + +k33 = 1.0 + +# The `k`, `k22`, and `k33` values defined above are then passed in on +# construction of the npf package. + +npf = flopy.mf6.ModflowGwfnpf( + gwf, + pname="npf", + save_flows=True, + icelltype=[1, 1, 1], + k=k, + k22=k22, + k33=k33, + xt3doptions="xt3d rhs", + rewet_record="REWET WETFCT 1.0 IWETIT 1 IHDWET 0", +) + +# ### Layered Data +# +# When we look at what will be written to the npf input file, we +# see that the entire `npf.k22` array is written as one long array with the +# number of values equal to `nlay` * `nrow` * `ncol`. And this whole-array +# specification may be of use in some cases. Often times, however, it is +# easier to work with each layer separately. An `MFArray` object, such as +# `npf.k22` can be converted to a layered array as follows. + +npf.k22.make_layered() + +# By changing `npf.k22` to layered, we are then able to manage each layer +# separately. Before doing so, however, we need to pass in data that can be +# separated into three layers. An array of the correct size is one option. + +shp = npf.k22.array.shape +a = np.arange(shp[0] * shp[1] * shp[2]).reshape(shp) +npf.k22 = a + +# Now that `npf.k22` has been set to be layered, if we print information about +# it, we see that each layer is stored separately, however, `npf.k22.array` +# will still return a full three-dimensional array. + +type(npf.k22) +npf.k22 + +# We also see that each layer is printed separately to the npf +# Package input file, and that the LAYERED keyword is activated: + +npf.k22 + +# Working with a layered array provides lots of flexibility. For example, +# constants can be set for some layers, but arrays for others: + +npf.k22.set_data([1, a[2], 200]) +npf.k22 + +# The array can be interacted with as usual for NumPy arrays: +npf.k22 = np.stack( + [ + 100 * np.ones((10, 10)), + 50 * np.ones((10, 10)), + 30 * np.ones((10, 10)), + ] +) +npf.k22 + +# ## Adding MODFLOW Stress Period Array Data +# Transient array data spanning multiple stress periods must be specified as a +# dictionary of arrays, where the dictionary key is the stress period, +# expressed as a zero-based integer, and the dictionary value is the grid +# data for that stress period. + +# In the following example a `RCHA` package is created. First a dictionary +# is created that contains recharge for the model's two stress periods. +# Recharge is specified as a constant value in this example, though it could +# also be specified as a 3-dimensional ndarray or list of lists. + +rch_sp1 = 0.01 +rch_sp2 = 0.03 +rch_spd = {0: rch_sp1, 1: rch_sp2} + +# The `RCHA` package is created and the dictionary constructed above is passed +# in as the `recharge` parameter. + +rch = flopy.mf6.ModflowGwfrcha( + gwf, readasarrays=True, pname="rch", print_input=True, recharge=rch_spd +) + +# Below the `NPF` `k` array is retrieved using the various methods highlighted +# above. + +# First, view the `k` array. + +npf.k + +# `repr` gives a string representation of the data. + +repr(npf.k) + +# `str` gives a similar string representation of the data. + +str(npf.k) + +# Next, view the 4-dimensional array. + +rch.recharge + +# `repr` gives a string representation of the data. + +repr(rch.recharge) + +# str gives a similar representation of the data. + +str(rch.recharge) diff --git a/docs/examples/attrs_demo.py b/docs/examples/attrs_demo.py new file mode 100644 index 0000000..2ac0a52 --- /dev/null +++ b/docs/examples/attrs_demo.py @@ -0,0 +1,246 @@ +# # Attrs demo + +# This example demonstrates a tentative `attrs`-based object model. + +from pathlib import Path +from typing import List, Literal, Optional, Union + +# ## GWF IC +import numpy as np +from attr import asdict, define, field, fields_dict +from cattr import Converter +from numpy.typing import NDArray + +# We can define block classes where variable descriptions become +# the variable's docstring. Ideally we can come up with a Python +# input specification that is equivalent to (and convertible to) +# the original MF6 input specification, while knowing as little +# as possible about the MF6 input format; but anything we can't +# get rid of can go in field `metadata`. + + +@define +class Options: + export_array_ascii: bool = field( + default=False, + metadata={"longname": "export array variables to netcdf output files"}, + ) + """ + keyword that specifies input griddata arrays should be + written to layered ascii output files. + """ + + export_array_netcdf: bool = field( + default=False, metadata={"longname": "starting head"} + ) + """ + keyword that specifies input griddata arrays should be + written to the model output netcdf file. + """ + + +# Eventually we may be able to take advantage of NumPy +# support for shape parameters: +# https://github.com/numpy/numpy/issues/16544 +# +# We can still take advantage of type parameters. + + +@define +class PackageData: + strt: NDArray[np.float64] = field( + metadata={"longname": "starting head", "shape": ("nodes")} + ) + """ + is the initial (starting) head---that is, head at the + beginning of the GWF Model simulation. STRT must be specified for + all simulations, including steady-state simulations. One value is + read for every model cell. For simulations in which the first stress + period is steady state, the values used for STRT generally do not + affect the simulation (exceptions may occur if cells go dry and (or) + rewet). The execution time, however, will be less if STRT includes + hydraulic heads that are close to the steady-state solution. A head + value lower than the cell bottom can be provided if a cell should + start as dry. + """ + + +# Putting it all together: + + +@define +class GwfIc: + options: Options = field() + packagedata: PackageData = field() + + +# ## GWF OC +# +# The output control package has a more complicated variable structure. +# Below docstrings/descriptions are omitted for space-saving. + + +@define +class Format: + columns: int = field() + width: int = field() + digits: int = field() + format: Literal["exponential", "fixed", "general", "scientific"] = field() + + +@define +class Options: + budget_file: Optional[Path] = field(default=None) + budget_csv_file: Optional[Path] = field(default=None) + head_file: Optional[Path] = field(default=None) + printhead: Optional[Format] = field(default=None) + + +# It's awkward to have single-parameter classes, but +# it's the only way I got `cattrs` to distinguish a +# number of choices with the same shape in a union +# like `OCSetting`. There may be a better way. + + +@define +class All: + all: bool = field() + + +@define +class First: + first: bool = field() + + +@define +class Last: + last: bool = field() + + +@define +class Steps: + steps: List[int] = field() + + +@define +class Frequency: + frequency: int = field() + + +PrintSave = Literal["print", "save"] +RType = Literal["budget", "head"] +OCSetting = Union[All, First, Last, Steps, Frequency] + + +@define +class OutputControlData: + printsave: PrintSave = field() + rtype: RType = field() + ocsetting: OCSetting = field() + + @classmethod + def from_tuple(cls, t): + t = list(t) + printsave = t.pop(0) + rtype = t.pop(0) + ocsetting = { + "all": All, + "first": First, + "last": Last, + "steps": Steps, + "frequency": Frequency, + }[t.pop(0).lower()](t) + return cls(printsave, rtype, ocsetting) + + +Period = List[OutputControlData] +Periods = List[Period] + + +@define +class GwfOc: + options: Options = field() + periods: Periods = field() + + +# We now set up a `cattrs` converter to convert an unstructured +# representation of the package input data to a structured form. + +converter = Converter() + + +# Register a hook for the `OutputControlData.from_tuple` method. +# MODFLOW 6 defines records as tuples, from which we'll need to +# instantiate objects. + + +def output_control_data_hook(value, _) -> OutputControlData: + return OutputControlData.from_tuple(value) + + +converter.register_structure_hook(OutputControlData, output_control_data_hook) + + +# We can inspect the input specification with `attrs` machinery. + + +spec = fields_dict(OutputControlData) +assert len(spec) == 3 + +ocsetting = spec["ocsetting"] +assert ocsetting.type is OCSetting + + +# We can define a block with some data. + + +options = Options( + budget_file="some/file/path.cbc", +) +assert isinstance(options.budget_file, str) # TODO path +assert len(asdict(options)) == 4 + + +# We can load a record from a tuple. + + +ocdata = OutputControlData.from_tuple(("print", "budget", "steps", 1, 3, 5)) +assert ocdata.printsave == "print" +assert ocdata.rtype == "budget" +assert ocdata.ocsetting == Steps([1, 3, 5]) + + +# We can load the full package from an unstructured dictionary, +# as would be returned by a separate IO layer in the future. +# (Either hand-written or using e.g. lark.) + + +gwfoc = converter.structure( + { + "options": { + "budget_file": "some/file/path.cbc", + "head_file": "some/file/path.hds", + "printhead": { + "columns": 1, + "width": 10, + "digits": 8, + "format": "scientific", + }, + }, + "periods": [ + [ + ("print", "budget", "steps", 1, 3, 5), + ("save", "head", "frequency", 2), + ] + ], + }, + GwfOc, +) +assert gwfoc.options.budget_file == Path("some/file/path.cbc") +assert gwfoc.options.printhead.width == 10 +assert gwfoc.options.printhead.format == "scientific" +period = gwfoc.periods[0] +assert len(period) == 2 +assert period[0] == OutputControlData.from_tuple( + ("print", "budget", "steps", 1, 3, 5) +) diff --git a/docs/examples/grid_array_example_wip.py b/docs/examples/grid_array_example_wip.py deleted file mode 100644 index 2a15ed4..0000000 --- a/docs/examples/grid_array_example_wip.py +++ /dev/null @@ -1,243 +0,0 @@ -# # Grid array data -# -# **Note**: This example is adapted from MF6 tutorial 7 in the -# flopy repository. -# -# TODO: this example needs some rewriting once we've converged -# on an array API for constant/layered/factor arrays. IMO (WPB) -# supporting these cases in an `ndarray` subclass (or a class -# behaving like a numpy array) like Josh has prototyped is the -# way to go, but we can think on some API choices, like maybe -# accessing layers like `.layer(0)` for layered grids? for now, -# just changed access patterns from `.get_data()` / `.set_data()` -# to direct attribute access and removed some unnecessary stuff -# -# ## Overview -# -# MODFLOW array data is stored in NumPy `ndarray`s. Some custom -# array data structures are used internally, all of which behave -# implement NumPy ufuncs and generally behave like NumPy arrays. -# -# Most MODFLOW array data are two (row, column) or three (layer, -# row, column) dimensional and represent data on the model grid. -# grid. Other MODFLOW array data contain data by stress period. -# The following list summarizes the types of MODFLOW array data. - -# * Time-invariant multi-dimensional array data. This includes: -# 1. One and two dimensional arrays that do not have a layer dimension. -# Examples include `top`, `delc`, and `delr`. -# 2. Three dimensional arrays that can contain a layer dimension. -# Examples include `botm`, `idomain`, and `k`. -# * Transient arrays that can change with time and therefore contain arrays of -# data for one or more stress periods. Examples include `irch` and -# `recharge` in the `RCHA` package. -# -# In the example below a three dimensional ndarray is constructed for the -# `DIS` package's `botm` array. First, the a simulation and groundwater-flow -# model are set up. - -# package import -from tempfile import TemporaryDirectory - -import flopy -import numpy as np - -# set up where simulation workspace will be stored -temp_dir = TemporaryDirectory() -workspace = temp_dir.name -name = "grid_array_example" - -# create the FloPy simulation and tdis objects -sim = flopy.mf6.MFSimulation( - sim_name=name, exe_name="mf6", version="mf6", sim_ws=workspace -) -tdis = flopy.mf6.modflow.mftdis.ModflowTdis( - sim, - pname="tdis", - time_units="DAYS", - nper=2, - perioddata=[(1.0, 1, 1.0), (1.0, 1, 1.0)], -) -# create the Flopy groundwater flow (gwf) model object -model_nam_file = f"{name}.nam" -gwf = flopy.mf6.ModflowGwf(sim, modelname=name, model_nam_file=model_nam_file) -# create the flopy iterative model solver (ims) package object -ims = flopy.mf6.modflow.mfims.ModflowIms(sim, pname="ims", complexity="SIMPLE") - -# Then a three-dimensional ndarray of floating point values is created using -# numpy's `linspace` method. - -bot = np.linspace(-50.0 / 3.0, -3.0, 3) -delrow = delcol = 4.0 - -# The `DIS` package is then created passing the three-dimensional array to the -# `botm` parameter. The `botm` array defines the model's cell bottom -# elevations. - -dis = flopy.mf6.modflow.mfgwfdis.ModflowGwfdis( - gwf, - pname="dis", - nogrb=True, - nlay=3, - nrow=10, - ncol=10, - delr=delrow, - delc=delcol, - top=0.0, - botm=bot, -) - -# ## Adding MODFLOW Grid Array Data -# MODFLOW grid array data, like the data found in the `NPF` package's -# `GridData` block, can be specified as: -# -# 1. A constant value -# 2. A n-dimensional list -# 3. A numpy ndarray -# -# Additionally, layered grid data (generally arrays with a layer dimension) can -# be specified by layer. -# -# In the example below `icelltype` is specified as constants by layer, `k` is -# specified as a numpy ndarray, `k22` is specified as an array by layer, and -# `k33` is specified as a constant. - -# First `k` is set up as a 3 layer, by 10 row, by 10 column array with all -# values set to 10.0 using numpy's full method. - -k = np.full((3, 10, 10), 10.0) - -# Next `k22` is set up as a three dimensional list of nested lists. This -# option can be useful for those that are familiar with python lists but are -# not familiar with the numpy library. - -k22_row = [] -for row in range(0, 10): - k22_row.append(8.0) -k22_layer = [] -for col in range(0, 10): - k22_layer.append(k22_row) -k22 = [k22_layer, k22_layer, k22_layer] - -# `K33` is set up as a single constant value. Whenever an array has all the -# same values the easiest and most efficient way to set it up is as a constant -# value. Constant values also take less space to store. - -k33 = 1.0 - -# The `k`, `k22`, and `k33` values defined above are then passed in on -# construction of the npf package. - -npf = flopy.mf6.ModflowGwfnpf( - gwf, - pname="npf", - save_flows=True, - icelltype=[1, 1, 1], - k=k, - k22=k22, - k33=k33, - xt3doptions="xt3d rhs", - rewet_record="REWET WETFCT 1.0 IWETIT 1 IHDWET 0", -) - -# ### Layered Data -# -# When we look at what will be written to the npf input file, we -# see that the entire `npf.k22` array is written as one long array with the -# number of values equal to `nlay` * `nrow` * `ncol`. And this whole-array -# specification may be of use in some cases. Often times, however, it is -# easier to work with each layer separately. An `MFArray` object, such as -# `npf.k22` can be converted to a layered array as follows. - -npf.k22.make_layered() - -# By changing `npf.k22` to layered, we are then able to manage each layer -# separately. Before doing so, however, we need to pass in data that can be -# separated into three layers. An array of the correct size is one option. - -shp = npf.k22.array.shape -a = np.arange(shp[0] * shp[1] * shp[2]).reshape(shp) -npf.k22 = a - -# Now that `npf.k22` has been set to be layered, if we print information about -# it, we see that each layer is stored separately, however, `npf.k22.array` -# will still return a full three-dimensional array. - -type(npf.k22) -npf.k22 - -# We also see that each layer is printed separately to the npf -# Package input file, and that the LAYERED keyword is activated: - -npf.k22 - -# Working with a layered array provides lots of flexibility. For example, -# constants can be set for some layers, but arrays for others: - -npf.k22.set_data([1, a[2], 200]) -npf.k22 - -# The array can be interacted with as usual for NumPy arrays: -npf.k22 = np.stack( - [ - 100 * np.ones((10, 10)), - 50 * np.ones((10, 10)), - 30 * np.ones((10, 10)), - ] -) -npf.k22 - -# ## Adding MODFLOW Stress Period Array Data -# Transient array data spanning multiple stress periods must be specified as a -# dictionary of arrays, where the dictionary key is the stress period, -# expressed as a zero-based integer, and the dictionary value is the grid -# data for that stress period. - -# In the following example a `RCHA` package is created. First a dictionary -# is created that contains recharge for the model's two stress periods. -# Recharge is specified as a constant value in this example, though it could -# also be specified as a 3-dimensional ndarray or list of lists. - -rch_sp1 = 0.01 -rch_sp2 = 0.03 -rch_spd = {0: rch_sp1, 1: rch_sp2} - -# The `RCHA` package is created and the dictionary constructed above is passed -# in as the `recharge` parameter. - -rch = flopy.mf6.ModflowGwfrcha( - gwf, readasarrays=True, pname="rch", print_input=True, recharge=rch_spd -) - -# Below the `NPF` `k` array is retrieved using the various methods highlighted -# above. - -# First, view the `k` array. - -npf.k - -# `repr` gives a string representation of the data. - -repr(npf.k) - -# `str` gives a similar string representation of the data. - -str(npf.k) - -# Next, view the 4-dimensional array. - -rch.recharge - -# `repr` gives a string representation of the data. - -repr(rch.recharge) - -# str gives a similar representation of the data. - -str(rch.recharge) - -try: - temp_dir.cleanup() -except PermissionError: - # can occur on windows: https://docs.python.org/3/library/tempfile.html#tempfile.TemporaryDirectory - pass diff --git a/docs/examples/input_data_model_example.py b/docs/examples/input_data_model_example.py index b17f96b..5c694b7 100644 --- a/docs/examples/input_data_model_example.py +++ b/docs/examples/input_data_model_example.py @@ -20,14 +20,14 @@ # Package *-- "1+" Variable # ``` # -# Components are generally mutable: subcomponents can be added/removed and -# variables can be manipulated. +# Note that this is not identical to the underlying object model, which +# is yet to be determined. TODO: update this once we have a full prototype. # # # Variable types # # Variables are scalars, paths, arrays, or composite types: list, sum, union. # -# MODFLOW 6 defines the following scalars types: +# MODFLOW 6 defines the following scalar types: # # - `keyword` # - `integer` @@ -45,6 +45,9 @@ # or unions of records as items. # # We map this typology roughly to the following in Python: +# +# TODO: update the following as we develop a more concrete idea of what +# type hints corresponding to the mf6 input data model will look like # + from os import PathLike diff --git a/docs/examples/record_example.py b/docs/examples/record_example.py new file mode 100644 index 0000000..df1a057 --- /dev/null +++ b/docs/examples/record_example.py @@ -0,0 +1,12 @@ +# # Record variables +# +# This example demonstrates how to work with record input variables. +# +# A record variable is a product type. Record fields can be scalars, +# other record variables, or unions of such. +# +# MODFLOW 6 represents records as (possibly variadic) tuples. FloPy +# supports both a low-level tuple interface for records, conforming +# to MODFLOW 6, and a high-level, strongly-typed record interface. +# +# TODO flesh out diff --git a/test/attrs/test_gwfic.py b/test/attrs/test_gwfic.py deleted file mode 100644 index d407ee0..0000000 --- a/test/attrs/test_gwfic.py +++ /dev/null @@ -1,48 +0,0 @@ -import numpy as np -from attr import define, field -from numpy.typing import NDArray - - -@define -class Options: - export_array_ascii: bool = field( - default=False, - metadata={"longname": "export array variables to netcdf output files"}, - ) - """ - keyword that specifies input griddata arrays should be - written to layered ascii output files. - """ - - export_array_netcdf: bool = field( - default=False, metadata={"longname": "starting head"} - ) - """ - keyword that specifies input griddata arrays should be - written to the model output netcdf file. - """ - - -@define -class PackageData: - strt: NDArray[np.float64] = field( - metadata={"longname": "starting head", "shape": ("nodes")} - ) - """ - is the initial (starting) head---that is, head at the - beginning of the GWF Model simulation. STRT must be specified for - all simulations, including steady-state simulations. One value is - read for every model cell. For simulations in which the first stress - period is steady state, the values used for STRT generally do not - affect the simulation (exceptions may occur if cells go dry and (or) - rewet). The execution time, however, will be less if STRT includes - hydraulic heads that are close to the steady-state solution. A head - value lower than the cell bottom can be provided if a cell should - start as dry. - """ - - -@define -class GwfIc: - options: Options = field() - packagedata: PackageData = field() diff --git a/test/attrs/test_gwfoc.py b/test/attrs/test_gwfoc.py deleted file mode 100644 index 2860d2c..0000000 --- a/test/attrs/test_gwfoc.py +++ /dev/null @@ -1,209 +0,0 @@ -from pathlib import Path -from typing import List, Literal, Optional, Union - -from attr import asdict, define, field, fields_dict -from cattr import Converter - -ArrayFormat = Literal["exponential", "fixed", "general", "scientific"] - - -@define -class PrintFormat: - columns: int = field() - """ - number of columns for writing data - """ - - width: int = field() - """ - width for writing each number - """ - - digits: int = field() - """ - number of digits to use for writing a number - """ - - array_format: ArrayFormat = field() - """ - write format can be EXPONENTIAL, FIXED, GENERAL, or SCIENTIFIC - """ - - -@define -class Options: - budget_file: Optional[Path] = field(default=None) - """ - name of the output file to write budget information - """ - - budget_csv_file: Optional[Path] = field(default=None) - """ - name of the comma-separated value (CSV) output - file to write budget summary information. - A budget summary record will be written to this - file for each time step of the simulation. - """ - - head_file: Optional[Path] = field(default=None) - """ - name of the output file to write head information. - """ - - print_format: Optional[PrintFormat] = field(default=None) - """ - specify format for printing to the listing file - """ - - -@define -class All: - all: bool = field() - """ - keyword to indicate save for all time steps in period. - """ - - -@define -class First: - first: bool = field() - """ - keyword to indicate save for first step in period. - """ - - -@define -class Last: - last: bool = field() - """ - keyword to indicate save for last step in period - """ - - -@define -class Steps: - steps: List[int] = field() - """ - save for each step specified - """ - - -@define -class Frequency: - frequency: int = field() - """ - save at the specified time step frequency. - """ - - -# It's awkward to have single-parameter contexts, but -# it's the only way I got `cattrs` to distinguish the -# choices in the union. There is likely a better way. - - -PrintSave = Literal["print", "save"] -RType = Literal["budget", "head"] -OCSetting = Union[All, First, Last, Steps, Frequency] - - -@define -class OutputControlData: - printsave: PrintSave = field() - rtype: RType = field() - ocsetting: OCSetting = field() - - @classmethod - def from_tuple(cls, t): - t = list(t) - printsave = t.pop(0) - rtype = t.pop(0) - ocsetting = { - "all": All, - "first": First, - "last": Last, - "steps": Steps, - "frequency": Frequency, - }[t.pop(0).lower()](t) - return cls(printsave, rtype, ocsetting) - - -Period = List[OutputControlData] -Periods = List[Period] - - -@define -class GwfOc: - options: Options = field() - periods: Periods = field() - - -# Converter - -converter = Converter() - - -def output_control_data_hook(value, _) -> OutputControlData: - return OutputControlData.from_tuple(value) - - -converter.register_structure_hook(OutputControlData, output_control_data_hook) - - -# Tests - - -def test_spec(): - spec = fields_dict(OutputControlData) - assert len(spec) == 3 - - ocsetting = spec["ocsetting"] - assert ocsetting.type is OCSetting - - -def test_options_to_dict(): - options = Options( - budget_file="some/file/path.cbc", - ) - assert isinstance(options.budget_file, str) # TODO path - assert len(asdict(options)) == 4 - - -def test_output_control_data_from_tuple(): - ocdata = OutputControlData.from_tuple( - ("print", "budget", "steps", 1, 3, 5) - ) - assert ocdata.printsave == "print" - assert ocdata.rtype == "budget" - assert ocdata.ocsetting == Steps([1, 3, 5]) - - -def test_gwfoc_from_dict(): - gwfoc = converter.structure( - { - "options": { - "budget_file": "some/file/path.cbc", - "head_file": "some/file/path.hds", - "print_format": { - "columns": 1, - "width": 10, - "digits": 8, - "array_format": "scientific", - }, - }, - "periods": [ - [ - ("print", "budget", "steps", 1, 3, 5), - ("save", "head", "frequency", 2), - ] - ], - }, - GwfOc, - ) - assert gwfoc.options.budget_file == Path("some/file/path.cbc") - assert gwfoc.options.print_format.width == 10 - assert gwfoc.options.print_format.array_format == "scientific" - period = gwfoc.periods[0] - assert len(period) == 2 - assert period[0] == OutputControlData.from_tuple( - ("print", "budget", "steps", 1, 3, 5) - )