From c5af5d0b0bced27e1cf083988d82a4d792b41835 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sat, 27 Jul 2024 00:01:49 -0400 Subject: [PATCH 1/5] wip --- flopy4/block.py | 30 +++++++++++++------- flopy4/package.py | 66 +++++++++++++++++++++++--------------------- flopy4/param.py | 6 +++- flopy4/utils.py | 4 --- test/test_block.py | 16 ++++++----- test/test_package.py | 8 ++++-- 6 files changed, 74 insertions(+), 56 deletions(-) diff --git a/flopy4/block.py b/flopy4/block.py index c525c84..acfb539 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -82,24 +82,34 @@ def __init__(self, name=None, index=None, params=None): super().__init__(params) def __getattribute__(self, name: str) -> Any: - if name == "data": - return super().__getattribute__(name) + self_type = type(self) + # shortcut to parameter value for instance attribute. + # the class attribute is the parameter specification. + if name in self_type.params: + return self.value[name] + + # add .params attribute as an alias for .value, this + # overrides the class attribute with specification if name == "params": - return MFParams({k: v.value for k, v in self.data.items()}) + return self.value - param = self.data.get(name) - return ( - param.value - if param is not None - else super().__getattribute__(name) - ) + return super().__getattribute__(name) def __str__(self): buffer = StringIO() self.write(buffer) return buffer.getvalue() + @property + def value(self): + return MFParams({k: v.value for k, v in self.items()}) + + @value.setter + def value(self, value): + # todo set from dict of params + pass + @classmethod def load(cls, f, **kwargs): """Load the block from file.""" @@ -168,5 +178,5 @@ def __repr__(self): def write(self, f): """Write the blocks to file.""" - for block in self.data.values(): + for block in self.values(): block.write(f) diff --git a/flopy4/package.py b/flopy4/package.py index 5365a7d..57876d9 100644 --- a/flopy4/package.py +++ b/flopy4/package.py @@ -78,40 +78,46 @@ def __str__(self): return buffer.getvalue() def __getattribute__(self, name: str) -> Any: - if name == "data": - return super().__getattribute__(name) + self_type = type(self) - if name == "blocks": - return MFBlocks(self.data) - - if name == "params": - # todo cache this - return MFParams( - { - param_name: param - for block in self.values() - for param_name, param in block.items() - } - ) - - # shortcut to block value - if name in self: - return self[name] + # shortcut to block value for instance attribute. + # the class attribute is the block specification. + if name in self_type.blocks: + return self[name].value # shortcut to parameter value for instance attribute. # the class attribute is the parameter specification. - params = { - param_name: param - for block in self.data.values() - for param_name, param in block.items() - } - param = params.get(name) - return ( - param.value - if param is not None - else super().__getattribute__(name) + if name in self_type.params: + return self._param_values()[name] + + # define .blocks and .params attributes with values, + # overriding the class attributes with specification + if name == "blocks": + return self.value + if name == "params": + return self._param_values() + + return super().__getattribute__(name) + + def _param_values(self): + # todo cache + return MFParams( + { + param_name: param.value + for block in self.values() + for param_name, param in block.items() + } ) + @property + def value(self): + return MFBlocks({k: v.value for k, v in self.items()}) + + @value.setter + def value(self, value): + # todo set from dict of blocks + pass + @classmethod def load(cls, f): """Load the package from file.""" @@ -135,9 +141,7 @@ def load(cls, f): f.seek(pos) blocks[name] = type(block).load(f) - pkg = cls() - pkg.update(blocks) - return pkg + return cls(blocks) def write(self, f): """Write the package to file.""" diff --git a/flopy4/param.py b/flopy4/param.py index 437becb..8a71aab 100644 --- a/flopy4/param.py +++ b/flopy4/param.py @@ -29,6 +29,8 @@ class MFParamSpec: repeating: bool = False tagged: bool = True reader: MFReader = MFReader.urword + # todo change to variadic tuple of str and resolve + # actual shape at load time from simulation context shape: Optional[Tuple[int]] = None default_value: Optional[Any] = None @@ -53,6 +55,7 @@ def load(cls, f) -> "MFParamSpec": spec = dict() members = cls.fields() keywords = [f.name for f in members if f.type is bool] + while True: line = f.readline() if not line or line == "\n": @@ -68,6 +71,7 @@ def load(cls, f) -> "MFParamSpec": spec[key] = literal_eval(val) else: spec[key] = val + return cls(**spec) def with_name(self, name) -> "MFParamSpec": @@ -183,5 +187,5 @@ def __repr__(self): def write(self, f, **kwargs): """Write the parameters to file.""" - for param in self.data.values(): + for param in self.values(): param.write(f, **kwargs) diff --git a/flopy4/utils.py b/flopy4/utils.py index 278f2bc..c1cb167 100644 --- a/flopy4/utils.py +++ b/flopy4/utils.py @@ -22,7 +22,3 @@ def strip(line): line = line.split(comment_flag)[0] line = line.strip() return line.replace(",", " ") - - -class hybridproperty: - pass diff --git a/test/test_block.py b/test/test_block.py index 2d5c293..9a977f3 100644 --- a/test/test_block.py +++ b/test/test_block.py @@ -97,14 +97,15 @@ def test_load_write(tmp_path): assert isinstance(TestBlock.r.params["ri"], MFInteger) assert isinstance(TestBlock.r.params["rd"], MFDouble) - # check parameter values (via descriptors) - assert block.k - assert block.i == 1 - assert block.d == 1.0 - assert block.s == "value" - assert block.f == fpth + # check parameter values + assert block.k and block.value["k"] + assert block.i == block.value["i"] == 1 + assert block.d == block.value["d"] == 1.0 + assert block.s == block.value["s"] == "value" + assert block.f == block.value["f"] == fpth assert np.allclose(block.a, np.array([1.0, 2.0, 3.0])) - assert block.r == {"rd": 2.0, "ri": 2, "rk": True} + assert np.allclose(block.value["a"], np.array([1.0, 2.0, 3.0])) + assert block.r == block.value["r"] == {"rd": 2.0, "ri": 2, "rk": True} # test block write fpth2 = tmp_path / f"{name}2.txt" @@ -151,6 +152,7 @@ def test_load_write_indexed(tmp_path): period1 = IndexedBlock.load(f) period2 = IndexedBlock.load(f) + # todo: go to 0-based indexing assert period1.index == 1 assert period2.index == 2 diff --git a/test/test_package.py b/test/test_package.py index 3a55e82..4fe37a2 100644 --- a/test/test_package.py +++ b/test/test_package.py @@ -319,12 +319,14 @@ def test_load_gwfic(tmp_path): assert len(TestGwfIc.blocks) == 2 assert len(TestGwfIc.params) == 3 - assert len(gwfic.blocks) == 2 - assert len(gwfic.params) == 2 # only two params loaded + # assert len(gwfic.blocks) == 2 + # assert len(gwfic.params) == 2 # only two params loaded # instance attributes: shortcut access to param values assert isinstance(gwfic.export_array_ascii, bool) - assert isinstance(gwfic.export_array_netcdf, MFKeyword) + # todo debug key error: need to set empty/default values in + # mfparams (and mfblocks?) ctors for all values not given + # assert isinstance(gwfic.export_array_netcdf, MFKeyword) assert isinstance(gwfic.strt, np.ndarray) assert gwfic.export_array_ascii From e7a338f7dfdec80e79f97596b9a8300df9e5106a Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sat, 27 Jul 2024 13:32:59 -0400 Subject: [PATCH 2/5] cleanup block --- flopy4/block.py | 43 +++++++++++++++++++++++++++++++++++++------ flopy4/package.py | 2 +- flopy4/param.py | 5 +++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/flopy4/block.py b/flopy4/block.py index acfb539..e66b586 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -3,7 +3,7 @@ from dataclasses import asdict from io import StringIO from pprint import pformat -from typing import Any +from typing import Any, Dict, Optional from flopy4.array import MFArray from flopy4.compound import MFKeystring, MFRecord @@ -64,6 +64,7 @@ class MFBlock(MFParams, metaclass=MFBlockMappingMeta): """ MF6 input block. Maps parameter names to parameters. + Notes ----- This class is dynamically subclassed by `MFPackage` @@ -74,12 +75,22 @@ class MFBlock(MFParams, metaclass=MFBlockMappingMeta): attributes expose the parameter value. The block's name and index are discovered upon load. + Likewise the parameter values are populated on load. + They can also be initialized by passing a dictionary + of names/values to `params` when calling `__init__`. + Only recognized parameters (i.e. parameters known to + the block specification) are allowed. """ - def __init__(self, name=None, index=None, params=None): + def __init__( + self, + name: Optional[str] = None, + index: Optional[int] = None, + params: Optional[Dict[str, Any]] = None, + ): self.name = name self.index = index - super().__init__(params) + self.value = params def __getattribute__(self, name: str) -> Any: self_type = type(self) @@ -103,12 +114,32 @@ def __str__(self): @property def value(self): - return MFParams({k: v.value for k, v in self.items()}) + """Get a dictionary of block parameter values.""" + return super().value @value.setter def value(self, value): - # todo set from dict of params - pass + """Set block parameter values from a dictionary.""" + if value is None: + return + + params = dict() + values = value.copy() + + # we assume if a parameter name matches, it's the expected + # type. raise an error if we have any unrecognized params; + # blocks strictly disallow unrecognized params. to make an + # arbitrary set of parameters, use `MFParams` instead. + for param_name, param in type(self).params.copy().items(): + value = values.pop(param_name, None) + value = param.default_value if value is None else value + param.value = value + params[param_name] = param + if any(values): + raise ValueError(f"Unknown parameters:\n{pformat(values)}") + + # populate the internal dict by calling `MFParams.__init__()` + super().__init__(params) @classmethod def load(cls, f, **kwargs): diff --git a/flopy4/package.py b/flopy4/package.py index 57876d9..a8fa3f4 100644 --- a/flopy4/package.py +++ b/flopy4/package.py @@ -14,7 +14,7 @@ def get_block(pkg_name, block_name, params): (MFBlock,), params.copy(), ) - return cls(params=params, name=block_name) + return cls(name=block_name) class MFPackageMeta(type): diff --git a/flopy4/param.py b/flopy4/param.py index 8a71aab..4370bda 100644 --- a/flopy4/param.py +++ b/flopy4/param.py @@ -185,6 +185,11 @@ def __init__(self, params=None): def __repr__(self): return pformat(self.data) + @property + def value(self): + """Get a dictionary of parameter values.""" + return {k: v.value for k, v in self.items()} + def write(self, f, **kwargs): """Write the parameters to file.""" for param in self.values(): From 341912c383e02cd354d89aa1aca2308c9fe18955 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 29 Jul 2024 19:19:34 -0400 Subject: [PATCH 3/5] more cleanup --- flopy4/array.py | 3 -- flopy4/block.py | 43 +++++++++-------- flopy4/compound.py | 10 ++-- flopy4/package.py | 116 +++++++++++++++++++++++++++++++-------------- flopy4/scalar.py | 11 +++-- 5 files changed, 116 insertions(+), 67 deletions(-) diff --git a/flopy4/array.py b/flopy4/array.py index e1d2971..d325632 100644 --- a/flopy4/array.py +++ b/flopy4/array.py @@ -228,9 +228,6 @@ def __init__( self._how = how self._factor = factor - def __get__(self, obj, type=None): - return self if self.value is None else self.value - def __getitem__(self, item): return self.raw[item] diff --git a/flopy4/block.py b/flopy4/block.py index e66b586..cfc9139 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -17,14 +17,14 @@ def get_keystrings(members, name): ] -def get_param(members, name, block): - param = next(iter(get_keystrings(members, name)), None) +def get_param(members, block_name, param_name): + param = next(iter(get_keystrings(members, param_name)), None) if param is None: - param = members.get(name) + param = members.get(param_name) if param is None: - raise ValueError(f"Invalid parameter: {name.upper()}") - param.name = name - param.block = block + raise ValueError(f"Invalid parameter: {param_name.upper()}") + param.name = param_name + param.block = block_name return param @@ -40,8 +40,8 @@ def __new__(cls, clsname, bases, attrs): .lower() ) - # add parameter specification as class attribute. - # dynamically set the parameters' name and block. + # add class attributes for the block parameter specification. + # dynamically set each parameter's name, block and docstring. params = dict() for attr_name, attr in attrs.items(): if issubclass(type(attr), MFParam): @@ -50,6 +50,7 @@ def __new__(cls, clsname, bases, attrs): attr.block = block_name attrs[attr_name] = attr params[attr_name] = attr + attrs["params"] = MFParams(params) return super().__new__(cls, clsname, bases, attrs) @@ -90,7 +91,7 @@ def __init__( ): self.name = name self.index = index - self.value = params + super().__init__(params=params) def __getattribute__(self, name: str) -> Any: self_type = type(self) @@ -101,7 +102,7 @@ def __getattribute__(self, name: str) -> Any: return self.value[name] # add .params attribute as an alias for .value, this - # overrides the class attribute with specification + # overrides the class attribute with the param spec. if name == "params": return self.value @@ -120,26 +121,30 @@ def value(self): @value.setter def value(self, value): """Set block parameter values from a dictionary.""" - if value is None: + + if value is None or not any(value): return params = dict() values = value.copy() - # we assume if a parameter name matches, it's the expected - # type. raise an error if we have any unrecognized params; - # blocks strictly disallow unrecognized params. to make an - # arbitrary set of parameters, use `MFParams` instead. + # check provided parameters. if any are missing, set them + # to default values. assume if a param name matches, its + # type/value are ok (param setters should validate them). for param_name, param in type(self).params.copy().items(): value = values.pop(param_name, None) value = param.default_value if value is None else value param.value = value params[param_name] = param + + # raise an error if we have any unrecognized parameters. + # `MFBlock` strictly disallows unrecognized params. for + # an arbitrary collection of parameters, use `MFParams`. if any(values): raise ValueError(f"Unknown parameters:\n{pformat(values)}") - # populate the internal dict by calling `MFParams.__init__()` - super().__init__(params) + # populate internal dict and set attributes + super().__init__(params=params) @classmethod def load(cls, f, **kwargs): @@ -167,7 +172,7 @@ def load(cls, f, **kwargs): elif key == "end": break elif found: - param = get_param(members, key, name) + param = get_param(members, name, key) if param is not None: f.seek(pos) spec = asdict(param) @@ -183,7 +188,7 @@ def load(cls, f, **kwargs): kwrgs["params"] = param.data.copy() params[param.name] = ptype.load(f, **kwrgs) - return cls(name, index, params) + return cls(name=name, index=index, params=params) def write(self, f): """Write the block to file.""" diff --git a/flopy4/compound.py b/flopy4/compound.py index 70d960f..c0a269d 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -1,5 +1,4 @@ from abc import abstractmethod -from collections.abc import Mapping from dataclasses import asdict from io import StringIO from typing import Any, Dict @@ -54,16 +53,13 @@ def __init__( default_value, ) - def __get__(self, obj, type=None): - return self - @property def params(self) -> MFParams: """Component parameters.""" return MFParams(self.data) @property - def value(self) -> Mapping[str, Any]: + def value(self) -> Dict[str, Any]: """Get component names/values.""" return { k: s.value for k, s in self.data.items() if s.value is not None @@ -119,6 +115,7 @@ def __init__( @classmethod def load(cls, f, params, **kwargs) -> "MFRecord": + """Load a record with the given component parameters from a file.""" line = strip(f.readline()).lower() if not any(line): @@ -131,8 +128,9 @@ def load(cls, f, params, **kwargs) -> "MFRecord": @staticmethod def parse(line, params, **kwargs) -> Dict[str, MFScalar]: - loaded = dict() + """Parse a record with the given component parameters from a string.""" + loaded = dict() for param_name, param in params.items(): split = line.split() stype = type(param) diff --git a/flopy4/package.py b/flopy4/package.py index a8fa3f4..fc0f8fd 100644 --- a/flopy4/package.py +++ b/flopy4/package.py @@ -1,7 +1,8 @@ from abc import ABCMeta from io import StringIO from itertools import groupby -from typing import Any +from pprint import pformat +from typing import Any, Dict, Optional from flopy4.block import MFBlock, MFBlockMeta, MFBlocks from flopy4.param import MFParam, MFParams @@ -14,7 +15,7 @@ def get_block(pkg_name, block_name, params): (MFBlock,), params.copy(), ) - return cls(name=block_name) + return cls(name=block_name, params=params) class MFPackageMeta(type): @@ -25,9 +26,8 @@ def __new__(cls, clsname, bases, attrs): # detect package name pkg_name = clsname.replace("Package", "") - # add parameter and block specification as class - # attributes. subclass mfblock dynamically based - # on each block parameter specification. + # add class attributes for the package parameter specification. + # dynamically set each parameter's name and docstring. params = dict() for attr_name, attr in attrs.items(): if issubclass(type(attr), MFParam): @@ -35,24 +35,24 @@ def __new__(cls, clsname, bases, attrs): attr.name = attr_name attrs[attr_name] = attr params[attr_name] = attr - params = MFParams(params) - blocks = MFBlocks( - { - block_name: get_block( - pkg_name=pkg_name, - block_name=block_name, - params={p.name: p for p in block}, - ) - for block_name, block in groupby( - params.values(), lambda p: p.block - ) - } - ) - attrs["params"] = params - attrs["blocks"] = blocks - for block_name, block in blocks.items(): + # add class attributes for the package block specification. + # subclass `MFBlock` dynamically with class name and params + # as given in the block parameter specification. + blocks = dict() + for block_name, block_params in groupby( + params.values(), lambda p: p.block + ): + block = get_block( + pkg_name=pkg_name, + block_name=block_name, + params={param.name: param for param in block_params}, + ) attrs[block_name] = block + blocks[block_name] = block + + attrs["params"] = MFParams(params) + attrs["blocks"] = MFBlocks(blocks) return super().__new__(cls, clsname, bases, attrs) @@ -64,18 +64,25 @@ class MFPackageMappingMeta(MFPackageMeta, ABCMeta): class MFPackage(MFBlocks, metaclass=MFPackageMappingMeta): """ - MF6 model or simulation component package. + MF6 component package. Maps block names to blocks. + + + Notes + ----- + Subclasses are generated from Jinja2 templates to + match each package in the MODFLOW 6 framework. + TODO: reimplement with `ChainMap`? """ - def __init__(self, blocks=None): - super().__init__(blocks) - - def __str__(self): - buffer = StringIO() - self.write(buffer) - return buffer.getvalue() + def __init__( + self, + name: Optional[str] = None, + blocks: Optional[Dict[str, Dict]] = None, + ): + self.name = name + super().__init__(blocks=blocks) def __getattribute__(self, name: str) -> Any: self_type = type(self) @@ -90,15 +97,22 @@ def __getattribute__(self, name: str) -> Any: if name in self_type.params: return self._param_values()[name] - # define .blocks and .params attributes with values, - # overriding the class attributes with specification + # add .blocks attribute as an alias for .value, this + # overrides the class attribute with the block spec. + # also add a .params attribute, which is a flat dict + # of all block parameter values. if name == "blocks": return self.value - if name == "params": + elif name == "params": return self._param_values() return super().__getattribute__(name) + def __str__(self): + buffer = StringIO() + self.write(buffer) + return buffer.getvalue() + def _param_values(self): # todo cache return MFParams( @@ -111,12 +125,44 @@ def _param_values(self): @property def value(self): + """ + Get a dictionary of package block values. This is a + nested mapping of block names to blocks, where each + block is a mapping of parameter names to parameter + values. + """ return MFBlocks({k: v.value for k, v in self.items()}) @value.setter def value(self, value): - # todo set from dict of blocks - pass + """ + Set package block values from a nested dictionary, + where each block value is a mapping of parameter + names to parameter values. + """ + + if value is None or not any(value): + return + + blocks = dict() + values = value.copy() + + # load provided blocks. if any are missing, set them + # default values. assume if a block name matches, its + # param values are ok (param setters should validate). + for block_name, block in type(self).blocks.copy().items(): + value = values.pop(block_name, None) + block.value = value + blocks[block_name] = block + + # raise an error if we have any unrecognized blocks. + # `MFPackage` strictly disallows unrecognized blocks. + # for an arbitrary collection of blocks, use `MFBlocks`. + if any(values): + raise ValueError(f"Unknown blocks:\n{pformat(values)}") + + # populate internal dict and set attributes + super().__init__(blocks=blocks) @classmethod def load(cls, f): @@ -141,7 +187,7 @@ def load(cls, f): f.seek(pos) blocks[name] = type(block).load(f) - return cls(blocks) + return cls(blocks=blocks) def write(self, f): """Write the package to file.""" diff --git a/flopy4/scalar.py b/flopy4/scalar.py index 729e44b..e1e8404 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -50,16 +50,19 @@ def __init__( default_value, ) - def __get__(self, obj, type=None): - return self if self.value is None else self.value - @property def value(self): return self._value @value.setter def value(self, value): - self._value = value + tvalue = type(value) + if issubclass(tvalue, MFScalar): + self._value = value.value + elif tvalue in [bool, int, float, str, Path]: + self._value = value + else: + raise ValueError(f"Unsupported scalar value: {value}") class MFKeyword(MFScalar): From 15f02f9e30627e5012cdd159d81c8a389ddead52 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 29 Jul 2024 21:11:01 -0400 Subject: [PATCH 4/5] fixes --- flopy4/array.py | 11 ++++++-- flopy4/block.py | 64 ++++++++++++++++++++++++++++++++------------ flopy4/compound.py | 10 ++++--- flopy4/package.py | 6 ++--- flopy4/param.py | 4 +-- flopy4/scalar.py | 11 +++++--- test/test_package.py | 13 +++------ 7 files changed, 79 insertions(+), 40 deletions(-) diff --git a/flopy4/array.py b/flopy4/array.py index d325632..173756f 100644 --- a/flopy4/array.py +++ b/flopy4/array.py @@ -290,8 +290,15 @@ def value(self) -> Optional[np.ndarray]: return self._value.reshape(self._shape) * self.factor @value.setter - def value(self, value: np.ndarray): - assert value.shape == self.shape + def value(self, value: Optional[np.ndarray]): + if value is None: + return + + if value.shape != self.shape: + raise ValueError( + f"Expected array with shape {self.shape}," + f"got shape {value.shape}" + ) self._value = value @property diff --git a/flopy4/block.py b/flopy4/block.py index cfc9139..89b5d38 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -91,6 +91,12 @@ def __init__( ): self.name = name self.index = index + + # if a parameter mapping is provided, coerce it to the + # spec and set default values + if params is not None: + params = type(self).coerce(params, set_default=True) + super().__init__(params=params) def __getattribute__(self, name: str) -> Any: @@ -125,26 +131,47 @@ def value(self, value): if value is None or not any(value): return - params = dict() - values = value.copy() + # coerce the parameter mapping to the spec and set defaults + params = type(self).coerce(value.copy(), set_default=True) + super().__init__(params=params) - # check provided parameters. if any are missing, set them - # to default values. assume if a param name matches, its - # type/value are ok (param setters should validate them). - for param_name, param in type(self).params.copy().items(): - value = values.pop(param_name, None) - value = param.default_value if value is None else value - param.value = value - params[param_name] = param + @classmethod + def coerce( + cls, params: Dict[str, MFParam], set_default: bool = False + ) -> Dict[str, MFParam]: + """ + Check that the dictionary contains only expected parameters + (raising an error if any unknown parameters are provided), + set default values for any missing member parameters, + and ensure provided parameter types are as expected. + """ + + known = dict() + for param_name, param_spec in cls.params.copy().items(): + param = params.pop(param_name, param_spec) + + # make sure param is of expected type + spec_type = type(param_spec) + real_type = type(param) + if real_type is not spec_type: + raise TypeError( + f"Expected '{param_name}' as {spec_type}, got {real_type}" + ) + + # set default value if enabled and none provided + if param.value is None and set_default: + param.value = param_spec.default_value + + # save the param + known[param_name] = param # raise an error if we have any unrecognized parameters. # `MFBlock` strictly disallows unrecognized params. for # an arbitrary collection of parameters, use `MFParams`. - if any(values): - raise ValueError(f"Unknown parameters:\n{pformat(values)}") + if any(params): + raise ValueError(f"Unknown parameters:\n{pformat(params)}") - # populate internal dict and set attributes - super().__init__(params=params) + return known @classmethod def load(cls, f, **kwargs): @@ -202,7 +229,10 @@ def write(self, f): class MFBlocks(UserDict): - """Mapping of block names to blocks.""" + """ + Mapping of block names to blocks. Acts like a + dictionary and supports named attribute access. + """ def __init__(self, blocks=None): super().__init__(blocks) @@ -212,7 +242,7 @@ def __init__(self, blocks=None): def __repr__(self): return pformat(self.data) - def write(self, f): + def write(self, f, **kwargs): """Write the blocks to file.""" for block in self.values(): - block.write(f) + block.write(f, **kwargs) diff --git a/flopy4/compound.py b/flopy4/compound.py index c0a269d..7bd9122 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -1,7 +1,7 @@ from abc import abstractmethod from dataclasses import asdict from io import StringIO -from typing import Any, Dict +from typing import Any, Dict, Optional from flopy4.param import MFParam, MFParams, MFReader from flopy4.scalar import MFScalar @@ -66,8 +66,12 @@ def value(self) -> Dict[str, Any]: } @value.setter - def value(self, value): - """Set component names/values by keyword arguments.""" + def value(self, value: Optional[Dict[str, Any]]): + """Set component names/values.""" + + if value is None: + return + for key, val in value.items(): self.data[key].value = val diff --git a/flopy4/package.py b/flopy4/package.py index fc0f8fd..70a1411 100644 --- a/flopy4/package.py +++ b/flopy4/package.py @@ -95,7 +95,7 @@ def __getattribute__(self, name: str) -> Any: # shortcut to parameter value for instance attribute. # the class attribute is the parameter specification. if name in self_type.params: - return self._param_values()[name] + return self._get_param_values()[name] # add .blocks attribute as an alias for .value, this # overrides the class attribute with the block spec. @@ -104,7 +104,7 @@ def __getattribute__(self, name: str) -> Any: if name == "blocks": return self.value elif name == "params": - return self._param_values() + return self._get_param_values() return super().__getattribute__(name) @@ -113,7 +113,7 @@ def __str__(self): self.write(buffer) return buffer.getvalue() - def _param_values(self): + def _get_param_values(self): # todo cache return MFParams( { diff --git a/flopy4/param.py b/flopy4/param.py index 4370bda..2afd6eb 100644 --- a/flopy4/param.py +++ b/flopy4/param.py @@ -173,8 +173,8 @@ def write(self, f, **kwargs): class MFParams(UserDict): """ - Mapping of parameter names to parameters. - Supports dictionary and attribute access. + Mapping of parameter names to parameters. Acts like + a dictionary and supports named attribute access. """ def __init__(self, params=None): diff --git a/flopy4/scalar.py b/flopy4/scalar.py index e1e8404..db2a0b1 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -56,13 +56,16 @@ def value(self): @value.setter def value(self, value): - tvalue = type(value) - if issubclass(tvalue, MFScalar): + if value is None: + return + + tval = type(value) + if issubclass(tval, MFScalar): self._value = value.value - elif tvalue in [bool, int, float, str, Path]: + elif tval in [bool, int, float, str, Path]: self._value = value else: - raise ValueError(f"Unsupported scalar value: {value}") + raise ValueError(f"Unsupported scalar: {value}") class MFKeyword(MFScalar): diff --git a/test/test_package.py b/test/test_package.py index 4fe37a2..03cb73e 100644 --- a/test/test_package.py +++ b/test/test_package.py @@ -61,7 +61,6 @@ class TestGwfIc(MFPackage): optional=False, # shape="(nodes)", shape=(10), - default_value=False, ) @@ -316,20 +315,16 @@ def test_load_gwfic(tmp_path): with open(fpth, "r") as f: gwfic = TestGwfIc.load(f) - assert len(TestGwfIc.blocks) == 2 - assert len(TestGwfIc.params) == 3 - - # assert len(gwfic.blocks) == 2 - # assert len(gwfic.params) == 2 # only two params loaded + assert len(TestGwfIc.blocks) == len(gwfic.blocks) == 2 + assert len(TestGwfIc.params) == len(gwfic.params) == 3 # instance attributes: shortcut access to param values assert isinstance(gwfic.export_array_ascii, bool) - # todo debug key error: need to set empty/default values in - # mfparams (and mfblocks?) ctors for all values not given - # assert isinstance(gwfic.export_array_netcdf, MFKeyword) + assert isinstance(gwfic.export_array_netcdf, bool) assert isinstance(gwfic.strt, np.ndarray) assert gwfic.export_array_ascii + assert not gwfic.export_array_netcdf assert np.allclose(gwfic.strt, np.array(strt)) From b9eb84a040db0cc56370cd1561ec98217749053a Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 29 Jul 2024 23:55:10 -0400 Subject: [PATCH 5/5] more cleanup/fixes --- flopy4/block.py | 145 +++++++++++++++++++++++++++++-------------- flopy4/compound.py | 19 +++++- flopy4/package.py | 97 +++++++++++++++++++---------- flopy4/param.py | 52 ++++++++++++++-- flopy4/scalar.py | 37 +++++++---- test/test_block.py | 20 ++++++ test/test_package.py | 2 +- 7 files changed, 272 insertions(+), 100 deletions(-) diff --git a/flopy4/block.py b/flopy4/block.py index 89b5d38..4c35323 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -1,28 +1,27 @@ from abc import ABCMeta -from collections import UserDict +from collections import OrderedDict, UserDict from dataclasses import asdict from io import StringIO from pprint import pformat from typing import Any, Dict, Optional from flopy4.array import MFArray -from flopy4.compound import MFKeystring, MFRecord +from flopy4.compound import MFKeystring, MFRecord, get_keystrings from flopy4.param import MFParam, MFParams +from flopy4.scalar import MFScalar from flopy4.utils import find_upper, strip -def get_keystrings(members, name): - return [ - m for m in members.values() if isinstance(m, MFKeystring) and name in m - ] - - -def get_param(members, block_name, param_name): - param = next(iter(get_keystrings(members, param_name)), None) +def get_param(params, block_name, param_name): + """ + Find the first parameter in the collection with + the given name, set its block name, and return it. + """ + param = next(get_keystrings(params, param_name), None) if param is None: - param = members.get(param_name) + param = params.get(param_name) if param is None: - raise ValueError(f"Invalid parameter: {param_name.upper()}") + raise ValueError(f"Invalid parameter: {param_name}") param.name = param_name param.block = block_name return param @@ -119,10 +118,13 @@ def __str__(self): self.write(buffer) return buffer.getvalue() + def __eq__(self, other): + return super().__eq__(other) + @property def value(self): """Get a dictionary of block parameter values.""" - return super().value + return MFParams.value.fget(self) @value.setter def value(self, value): @@ -133,43 +135,46 @@ def value(self, value): # coerce the parameter mapping to the spec and set defaults params = type(self).coerce(value.copy(), set_default=True) - super().__init__(params=params) + MFParams.value.fset(self, params) @classmethod def coerce( - cls, params: Dict[str, MFParam], set_default: bool = False + cls, params: Dict[str, Any], set_default: bool = False ) -> Dict[str, MFParam]: """ - Check that the dictionary contains only expected parameters - (raising an error if any unknown parameters are provided), - set default values for any missing member parameters, - and ensure provided parameter types are as expected. + Check that the dictionary contains only expected parameters, + raising an error if any unrecognized parameters are provided. + + Dictionary values may be subclasses of `MFParam` or values + provided directly. If the former, this function optionally + sets default values for any missing member parameters. """ known = dict() for param_name, param_spec in cls.params.copy().items(): param = params.pop(param_name, param_spec) - # make sure param is of expected type + # make sure param is of expected type. set a + # default value if enabled and none provided. spec_type = type(param_spec) real_type = type(param) - if real_type is not spec_type: + if issubclass(real_type, MFParam): + if param.value is None and set_default: + param.value = param_spec.default_value + elif issubclass(spec_type, MFScalar) and real_type == spec_type.T: + param = spec_type(value=param, **asdict(param_spec)) + else: raise TypeError( f"Expected '{param_name}' as {spec_type}, got {real_type}" ) - # set default value if enabled and none provided - if param.value is None and set_default: - param.value = param_spec.default_value - - # save the param known[param_name] = param - # raise an error if we have any unrecognized parameters. - # `MFBlock` strictly disallows unrecognized params. for - # an arbitrary collection of parameters, use `MFParams`. + # raise an error if we have any unknown parameters. + # `MFBlock` strictly disallows unrecognized params, + # for arbitrary parameter collections use `MFParams`. if any(params): - raise ValueError(f"Unknown parameters:\n{pformat(params)}") + raise ValueError(f"Unrecognized parameters:\n{pformat(params)}") return known @@ -200,20 +205,21 @@ def load(cls, f, **kwargs): break elif found: param = get_param(members, name, key) - if param is not None: - f.seek(pos) - spec = asdict(param) - kwrgs = {**kwargs, **spec} - ptype = type(param) - if ptype is MFArray: - # TODO: inject from model somehow? - # and remove special handling here - kwrgs["cwd"] = "" - if ptype is MFRecord: - kwrgs["params"] = param.data.copy() - if ptype is MFKeystring: - kwrgs["params"] = param.data.copy() - params[param.name] = ptype.load(f, **kwrgs) + if param is None: + continue + f.seek(pos) + spec = asdict(param) + kwrgs = {**kwargs, **spec} + ptype = type(param) + if ptype is MFArray: + # TODO: inject from model somehow? + # and remove special handling here + kwrgs["cwd"] = "" + if ptype is MFRecord: + kwrgs["params"] = param.data.copy() + if ptype is MFKeystring: + kwrgs["params"] = param.data.copy() + params[param.name] = ptype.load(f, **kwrgs) return cls(name=name, index=index, params=params) @@ -231,10 +237,11 @@ def write(self, f): class MFBlocks(UserDict): """ Mapping of block names to blocks. Acts like a - dictionary and supports named attribute access. + dictionary, also supports named attribute access. """ def __init__(self, blocks=None): + MFBlocks.assert_blocks(blocks) super().__init__(blocks) for key, block in self.items(): setattr(self, key, block) @@ -242,6 +249,54 @@ def __init__(self, blocks=None): def __repr__(self): return pformat(self.data) + def __eq__(self, other): + if not isinstance(other, MFBlocks): + raise TypeError(f"Expected MFBlocks, got {type(other)}") + return OrderedDict(sorted(self.value)) == OrderedDict( + sorted(other.value) + ) + + @staticmethod + def assert_blocks(blocks): + """ + Raise an error if any of the given items are + not subclasses of `MFBlock`. + """ + if not blocks: + return + elif isinstance(blocks, dict): + blocks = blocks.values() + not_blocks = [ + b + for b in blocks + if b is not None and not issubclass(type(b), MFBlock) + ] + if any(not_blocks): + raise TypeError(f"Expected MFBlock subclasses, got {not_blocks}") + + @property + def value(self) -> Dict[str, Dict[str, Any]]: + """ + Get a dictionary of package block values. This is a + nested mapping of block names to blocks, where each + block is a mapping of parameter names to parameter + values. + """ + return {k: v.value for k, v in self.items()} + + @value.setter + def value(self, value: Optional[Dict[str, Dict[str, Any]]]): + """Set block values from a nested dictionary.""" + + if value is None or not any(value): + return + + blocks = value.copy() + MFBlocks.assert_blocks(blocks) + self.update(blocks) + for key, block in self.items(): + setattr(self, key, block) + def write(self, f, **kwargs): """Write the blocks to file.""" for block in self.values(): diff --git a/flopy4/compound.py b/flopy4/compound.py index 7bd9122..401393c 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -1,7 +1,7 @@ from abc import abstractmethod from dataclasses import asdict from io import StringIO -from typing import Any, Dict, Optional +from typing import Any, Dict, Iterable, Iterator, Optional from flopy4.param import MFParam, MFParams, MFReader from flopy4.scalar import MFScalar @@ -10,6 +10,19 @@ PAD = " " +def get_keystrings( + params: Iterable[MFParam], name=None +) -> Iterator["MFKeystring"]: + """ + Filter keystring parameters from the given iterable, + optionally matching the given component parameter name. + """ + for param in params.values(): + if isinstance(param, MFKeystring): + if name is None or name in param: + yield param + + class MFCompound(MFParam, MFParams): @abstractmethod def __init__( @@ -98,7 +111,7 @@ def __init__( default_value=None, ): super().__init__( - params, + params=params, block=block, name=name, type=type, @@ -178,7 +191,7 @@ def __init__( default_value=None, ): super().__init__( - params, + params=params, block=block, name=name, type=type, diff --git a/flopy4/package.py b/flopy4/package.py index 70a1411..e89dc88 100644 --- a/flopy4/package.py +++ b/flopy4/package.py @@ -10,6 +10,12 @@ def get_block(pkg_name, block_name, params): + """ + Dynamically subclass `MFBlock`. The class' name is composed from + the given package and block name. The class will have attributes + according to the given parameter specification; parameter values + are not yet initialized. + """ cls = MFBlockMeta( f"{pkg_name.title()}{block_name.title()}Block", (MFBlock,), @@ -70,7 +76,7 @@ class MFPackage(MFBlocks, metaclass=MFPackageMappingMeta): Notes ----- Subclasses are generated from Jinja2 templates to - match each package in the MODFLOW 6 framework. + match packages as specified by definition files. TODO: reimplement with `ChainMap`? @@ -93,7 +99,9 @@ def __getattribute__(self, name: str) -> Any: return self[name].value # shortcut to parameter value for instance attribute. - # the class attribute is the parameter specification. + # the class attribute is the parameter specification, + # and dictionary access on the instance returns the + # full `MFParam` instance. if name in self_type.params: return self._get_param_values()[name] @@ -113,15 +121,25 @@ def __str__(self): self.write(buffer) return buffer.getvalue() - def _get_param_values(self): - # todo cache - return MFParams( - { - param_name: param.value - for block in self.values() - for param_name, param in block.items() - } - ) + def __eq__(self, other): + if not isinstance(other, MFPackage): + raise TypeError(f"Expected MFPackage, got {type(other)}") + return super().__eq__(other) + + def _get_params(self) -> Dict[str, MFParam]: + """Get a flattened dictionary of member parameters.""" + return { + param_name: param + for block in self.values() + for param_name, param in block.items() + } + + def _get_param_values(self) -> Dict[str, Any]: + """Get a flattened dictionary of parameter values.""" + return { + param_name: param.value + for param_name, param in self._get_params().items() + } @property def value(self): @@ -131,7 +149,7 @@ def value(self): block is a mapping of parameter names to parameter values. """ - return MFBlocks({k: v.value for k, v in self.items()}) + return MFBlocks.value.fget(self) @value.setter def value(self, value): @@ -144,28 +162,38 @@ def value(self, value): if value is None or not any(value): return - blocks = dict() - values = value.copy() - - # load provided blocks. if any are missing, set them - # default values. assume if a block name matches, its - # param values are ok (param setters should validate). - for block_name, block in type(self).blocks.copy().items(): - value = values.pop(block_name, None) - block.value = value - blocks[block_name] = block + # coerce the block mapping to the spec and set defaults + blocks = type(self).coerce(value.copy(), set_default=True) + MFBlocks.value.fset(self, blocks) + + @classmethod + def coerce( + cls, blocks: Dict[str, MFBlock], set_default: bool = False + ) -> Dict[str, MFBlock]: + """ + Check that the dictionary contains only known blocks, + raising an error if any unknown blocks are provided. + + Sets default values for any missing member parameters + and ensures provided parameter types are as expected. + """ + + known = dict() + for block_name, block_spec in cls.blocks.copy().items(): + block = blocks.pop(block_name, block_spec) + block = type(block).coerce(block, set_default=set_default) + known[block_name] = block # raise an error if we have any unrecognized blocks. # `MFPackage` strictly disallows unrecognized blocks. - # for an arbitrary collection of blocks, use `MFBlocks`. - if any(values): - raise ValueError(f"Unknown blocks:\n{pformat(values)}") + # for an arbitrary block collection, use `MFBlocks`. + if any(blocks): + raise ValueError(f"Unrecognized blocks:\n{pformat(blocks)}") - # populate internal dict and set attributes - super().__init__(blocks=blocks) + return known @classmethod - def load(cls, f): + def load(cls, f, **kwargs): """Load the package from file.""" blocks = dict() members = cls.blocks @@ -182,13 +210,14 @@ def load(cls, f): key = words[0] if key == "begin": name = words[1] - block = members.get(name) - if block is not None: - f.seek(pos) - blocks[name] = type(block).load(f) + block = members.get(name, None) + if block is None: + continue + f.seek(pos) + blocks[name] = type(block).load(f, **kwargs) return cls(blocks=blocks) - def write(self, f): + def write(self, f, **kwargs): """Write the package to file.""" - super().write(f) + super().write(f, **kwargs) diff --git a/flopy4/param.py b/flopy4/param.py index 2afd6eb..0430a7e 100644 --- a/flopy4/param.py +++ b/flopy4/param.py @@ -1,10 +1,10 @@ from abc import abstractmethod from ast import literal_eval -from collections import UserDict +from collections import OrderedDict, UserDict from dataclasses import dataclass, fields from io import StringIO from pprint import pformat -from typing import Any, Optional, Tuple +from typing import Any, Dict, Optional, Tuple from flopy4.constants import MFReader @@ -159,6 +159,11 @@ def __str__(self): self.write(buffer) return buffer.getvalue() + def __eq__(self, other): + if not isinstance(other, MFParam): + raise TypeError(f"Expected MFParam, got {type(other)}") + return self.value == other.value + @property @abstractmethod def value(self) -> Optional[Any]: @@ -174,10 +179,11 @@ def write(self, f, **kwargs): class MFParams(UserDict): """ Mapping of parameter names to parameters. Acts like - a dictionary and supports named attribute access. + a dictionary, also supports named attribute access. """ def __init__(self, params=None): + MFParams.assert_params(params) super().__init__(params) for key, param in self.items(): setattr(self, key, param) @@ -185,11 +191,49 @@ def __init__(self, params=None): def __repr__(self): return pformat(self.data) + def __eq__(self, other): + if not isinstance(other, MFParams): + raise TypeError(f"Expected MFParams, got {type(other)}") + return OrderedDict(sorted(self.value)) == OrderedDict( + sorted(other.value) + ) + + @staticmethod + def assert_params(params): + """ + Raise an error if any of the given items are not + subclasses of `MFParam`. + """ + if not params: + return + elif isinstance(params, dict): + params = params.values() + not_params = [ + p + for p in params + if p is not None and not issubclass(type(p), MFParam) + ] + if any(not_params): + raise TypeError(f"Expected MFParam subclasses, got {not_params}") + @property - def value(self): + def value(self) -> Dict[str, Any]: """Get a dictionary of parameter values.""" return {k: v.value for k, v in self.items()} + @value.setter + def value(self, value: Optional[Dict[str, Any]]): + """Set parameter values from a dictionary.""" + + if value is None or not any(value): + return + + params = value.copy() + MFParams.assert_params(params) + self.update(params) + for key, param in self.items(): + setattr(self, key, param) + def write(self, f, **kwargs): """Write the parameters to file.""" for param in self.values(): diff --git a/flopy4/scalar.py b/flopy4/scalar.py index db2a0b1..987ce10 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -1,14 +1,16 @@ from abc import abstractmethod from pathlib import Path +from typing import Generic, Optional, TypeVar, get_args from flopy4.constants import MFFileInout from flopy4.param import MFParam, MFReader from flopy4.utils import strip PAD = " " +T = TypeVar("T") -class MFScalar(MFParam): +class MFScalar(MFParam, Generic[T]): @abstractmethod def __init__( self, @@ -31,7 +33,8 @@ def __init__( default_value=None, ): self._value = value - super().__init__( + MFParam.__init__( + self, block, name, type, @@ -50,25 +53,29 @@ def __init__( default_value, ) + def __init_subclass__(cls): + cls.T = get_args(cls.__orig_bases__[0])[0] + super().__init_subclass__() + @property - def value(self): + def value(self) -> T: return self._value @value.setter - def value(self, value): + def value(self, value: Optional[T]): if value is None: return - tval = type(value) - if issubclass(tval, MFScalar): + t = type(value) + if issubclass(t, MFScalar): self._value = value.value - elif tval in [bool, int, float, str, Path]: + elif t is type(self).T: self._value = value else: raise ValueError(f"Unsupported scalar: {value}") -class MFKeyword(MFScalar): +class MFKeyword(MFScalar[bool]): def __init__( self, value=None, @@ -132,7 +139,7 @@ def write(self, f, **kwargs): ) -class MFInteger(MFScalar): +class MFInteger(MFScalar[int]): def __init__( self, value=None, @@ -176,6 +183,10 @@ def __init__( def __len__(self): return 2 + @property + def vtype(self): + return int + @classmethod def load(cls, f, **kwargs) -> "MFInteger": line = strip(f.readline()).lower() @@ -196,7 +207,7 @@ def write(self, f, **kwargs): ) -class MFDouble(MFScalar): +class MFDouble(MFScalar[float]): def __init__( self, value=None, @@ -260,7 +271,7 @@ def write(self, f, **kwargs): ) -class MFString(MFScalar): +class MFString(MFScalar[str]): def __init__( self, value=None, @@ -302,7 +313,7 @@ def __init__( ) def __len__(self): - return None if self._value is None else len(self._value.split()) + return 0 if self._value is None else len(self._value.split()) @classmethod def load(cls, f, **kwargs) -> "MFString": @@ -324,7 +335,7 @@ def write(self, f, **kwargs): ) -class MFFilename(MFScalar): +class MFFilename(MFScalar[Path]): def __init__( self, inout=MFFileInout.filein, diff --git a/test/test_block.py b/test/test_block.py index 9a977f3..680c7d1 100644 --- a/test/test_block.py +++ b/test/test_block.py @@ -1,4 +1,7 @@ +from pathlib import Path + import numpy as np +import pytest from flopy4.array import MFArray from flopy4.block import MFBlock @@ -164,3 +167,20 @@ def test_load_write_indexed(tmp_path): # instance attribute as shortcut to param value assert period1.ks == {"first": True} assert period2.ks == {"first": True, "frequency": 2} + + +def test_set_value(): + block = TestBlock(name="test") + block.value = { + "k": True, + "i": 42, + "d": 2.0, + "s": "hello world", + } + assert block.k + + +def test_set_value_unrecognized(): + block = TestBlock(name="test") + with pytest.raises(ValueError): + block.value = {"p": Path.cwd()} diff --git a/test/test_package.py b/test/test_package.py index 03cb73e..cf87ec2 100644 --- a/test/test_package.py +++ b/test/test_package.py @@ -294,7 +294,7 @@ def test_loadfail_gwfic(tmp_path): try: TestGwfIc.load(f) except ValueError as e: - assert "NOT_AN_OPTION" in str(e) + assert "not_an_option" in str(e) def test_load_gwfic(tmp_path):