From f1bf5c24d611eb7adec9ebc87cfe6b50021e703f Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 18 Jul 2024 17:02:26 -0400 Subject: [PATCH] minimal block impl --- flopy4/array.py | 66 ++++++--- flopy4/block.py | 88 ++++++++++++ flopy4/parameter.py | 61 ++++---- flopy4/scalar.py | 315 ++++++++++++++++++++++++++++++++++-------- test/pytest.ini | 4 +- test/test_block.py | 91 ++++++++++++ test/test_examples.py | 3 + test/test_scalar.py | 13 +- 8 files changed, 522 insertions(+), 119 deletions(-) create mode 100644 flopy4/block.py create mode 100644 test/test_block.py diff --git a/flopy4/array.py b/flopy4/array.py index 3d8befd..5b9cf10 100644 --- a/flopy4/array.py +++ b/flopy4/array.py @@ -7,7 +7,7 @@ from flopy.utils.flopy_io import line_strip, multi_line_strip from flopy4.constants import CommonNames -from flopy4.parameter import MFParameter +from flopy4.parameter import MFParameter, MFReader class NumPyArrayMixin: @@ -23,7 +23,7 @@ class NumPyArrayMixin: """ def __iadd__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa += other return self @@ -32,7 +32,7 @@ def __iadd__(self, other): return self def __imul__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa *= other return self @@ -41,7 +41,7 @@ def __imul__(self, other): return self def __isub__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa -= other return self @@ -50,7 +50,7 @@ def __isub__(self, other): return self def __itruediv__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa /= other return self @@ -59,7 +59,7 @@ def __itruediv__(self, other): return self def __ifloordiv__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa /= other return self @@ -68,7 +68,7 @@ def __ifloordiv__(self, other): return self def __ipow__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa /= other return self @@ -77,7 +77,7 @@ def __ipow__(self, other): return self def __add__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa += other return self @@ -86,7 +86,7 @@ def __add__(self, other): return self def __mul__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa *= other return self @@ -95,7 +95,7 @@ def __mul__(self, other): return self def __sub__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa -= other return self @@ -104,7 +104,7 @@ def __sub__(self, other): return self def __truediv__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa /= other return self @@ -113,7 +113,7 @@ def __truediv__(self, other): return self def __floordiv__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa /= other return self @@ -122,7 +122,7 @@ def __floordiv__(self, other): return self def __pow__(self, other): - if self._layered: + if self.layered: for mfa in self._value: mfa /= other return self @@ -187,18 +187,42 @@ def __init__( shape, how=MFArrayType.internal, factor=None, - layered=False, + block=None, name=None, longname=None, description=None, + deprecated=False, + in_record=False, + layered=False, optional=True, + numeric_index=False, + preserve_case=False, + repeating=False, + tagged=False, + reader=MFReader.urword, + default_value=None, ): - MFParameter.__init__(self, name, longname, description, optional) + MFParameter.__init__( + self, + block=block, + name=name, + longname=longname, + description=description, + deprecated=deprecated, + in_record=in_record, + layered=layered, + optional=optional, + numeric_index=numeric_index, + preserve_case=preserve_case, + repeating=repeating, + tagged=tagged, + reader=reader, + default_value=default_value, + ) self._value = array self._shape = shape self._how = how self._factor = factor - self._layered = layered def __getitem__(self, item): return self.raw[item] @@ -206,7 +230,7 @@ def __getitem__(self, item): def __setitem__(self, key, value): values = self.raw values[key] = value - if self._layered: + if self.layered: for ix, mfa in enumerate(self._value): mfa[:] = values[ix] return @@ -247,7 +271,7 @@ def value(self) -> np.ndarray: """ Return the array. """ - if self._layered: + if self.layered: arr = [] for mfa in self._value: arr.append(mfa.value) @@ -263,7 +287,7 @@ def raw(self): """ Return the array without multiplying by `self.factor`. """ - if self._layered: + if self.layered: arr = [] for mfa in self._value: arr.append(mfa.raw) @@ -279,7 +303,7 @@ def factor(self) -> Optional[float]: """ Optional factor by which to multiply array elements. """ - if self._layered: + if self.layered: factor = [mfa.factor for mfa in self._value] return factor @@ -293,7 +317,7 @@ def how(self): """ How the array is to be written to the input file. """ - if self._layered: + if self.layered: how = [mfa.how for mfa in self._value] return how diff --git a/flopy4/block.py b/flopy4/block.py new file mode 100644 index 0000000..208d30f --- /dev/null +++ b/flopy4/block.py @@ -0,0 +1,88 @@ +from collections.abc import MutableMapping +from typing import Any, Dict + +from flopy4.parameter import MFParameter +from flopy4.utils import strip + + +def get_member_params(cls) -> Dict[str, MFParameter]: + if not issubclass(cls, MFBlock): + raise ValueError(f"Expected MFBlock, got {cls}") + + return { + k: v + for k, v in cls.__dict__.items() + if issubclass(type(v), MFParameter) + } + + +class MFBlock(MutableMapping): + def __init__(self, name=None, index=None, *args, **kwargs): + self.name = name + self.index = index + self.params = dict() + self.update(dict(*args, **kwargs)) + for key, param in self.items(): + setattr(self, key, param) + + def __getattribute__(self, name: str) -> Any: + attr = super().__getattribute__(name) + if isinstance(attr, MFParameter): + # shortcut to parameter value for instance attributes. + # the class attribute is the full param specification. + return attr.value + else: + return attr + + def __getitem__(self, key): + return self.params[key] + + def __setitem__(self, key, value): + self.params[key] = value + + def __delitem__(self, key): + del self.params[key] + + def __iter__(self): + return iter(self.params) + + def __len__(self): + return len(self.params) + + @classmethod + def load(cls, f): + name = None + index = None + found = False + params = dict() + members = get_member_params(cls) + while True: + pos = f.tell() + line = strip(f.readline()).lower() + words = line.split() + key = words[0] + if key == "begin": + found = True + name = words[1] + if len(words) > 2 and str.isdigit(words[2]): + index = words[2] + elif key == "end": + break + elif found: + if key in members: + f.seek(pos) + param = members[key] + param.block = name + params[key] = type(param).load(f, spec=param) + + return cls(name, index, **params) + + def write(self, f): + index = self.index if self.index is not None else "" + begin = f"BEGIN {self.name.upper()} {index}\n" + end = f"END {self.name.upper()}\n" + + f.write(begin) + for param in self.values(): + param.write(f) + f.write(end) diff --git a/flopy4/parameter.py b/flopy4/parameter.py index b4bef28..524e1e1 100644 --- a/flopy4/parameter.py +++ b/flopy4/parameter.py @@ -1,5 +1,5 @@ -from abc import ABC, abstractmethod -from dataclasses import dataclass +from abc import abstractmethod +from dataclasses import dataclass, fields from enum import Enum from typing import Any, Optional @@ -9,14 +9,14 @@ class MFReader(Enum): MF6 procedure with which to read input. """ - urword = "URWORD" - u1ddbl = "U1DDBL" - readarray = "READARRAY" + urword = "urword" + u1ddbl = "u1dbl" + readarray = "readarray" @classmethod - def from_string(cls, string): + def from_str(cls, value): for e in cls: - if string.upper() == e.value: + if value.lower() == e.value: return e @@ -35,11 +35,16 @@ class MFParamSpec: layered: bool = False optional: bool = True numeric_index: bool = False + preserve_case: bool = False repeating: bool = False tagged: bool = True reader: MFReader = MFReader.urword default_value: Optional[Any] = None + @classmethod + def fields(cls): + return fields(cls) + @classmethod def load(cls, f) -> "MFParamSpec": spec = dict() @@ -52,30 +57,17 @@ def load(cls, f) -> "MFParamSpec": val = " ".join(words[1:]) # todo dynamically load properties and # filter by type instead of hardcoding - if key in [ - "block", - "name", - "longname", - "description", - "reader", - ]: - spec[key] = val - elif key in [ - "deprecated", - "in_record", - "layered", - "optional", - "numeric_index", - "repeating", - "tagged", - ]: + kw_fields = [f.name for f in cls.fields() if f.type is bool] + if key in kw_fields: spec[key] = val == "true" elif key == "reader": - spec[key] = MFReader.from_string(val) + spec[key] = MFReader.from_str(val) + else: + spec[key] = val return cls(**spec) -class MFParameter(ABC): +class MFParameter(MFParamSpec): """ MODFLOW 6 input parameter. Can be a scalar or compound of scalars, an array, or a list (i.e. a table). `MFParameter` @@ -105,6 +97,7 @@ class MFParameter(ABC): @abstractmethod def __init__( self, + block=None, name=None, longname=None, description=None, @@ -113,33 +106,31 @@ def __init__( layered=False, optional=True, numeric_index=False, + preserve_case=False, repeating=False, tagged=False, reader=MFReader.urword, default_value=None, ): - self.spec = MFParamSpec( + super().__init__( + block=block, name=name, - longname=None, + longname=longname, description=description, deprecated=deprecated, in_record=in_record, layered=layered, optional=optional, numeric_index=numeric_index, + preserve_case=preserve_case, repeating=repeating, tagged=tagged, reader=reader, default_value=default_value, ) - @property - def name(self): - """Get the parameter's name.""" - return self.spec.name - @property @abstractmethod - def value(self): - """Get the parameter's value.""" + def value(self) -> Optional[Any]: + """Get the parameter's value, if loaded.""" pass diff --git a/flopy4/scalar.py b/flopy4/scalar.py index c64fb87..45ca3de 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -1,34 +1,102 @@ from abc import abstractmethod from dataclasses import asdict +from enum import Enum from pathlib import Path from typing import Optional -from flopy4.parameter import MFParameter, MFParamSpec +from flopy4.parameter import MFParameter, MFParamSpec, MFReader from flopy4.utils import strip +PAD = " " -def _asdict(spec: Optional[MFParamSpec]): + +def _as_dict(spec: Optional[MFParamSpec]) -> dict: return dict() if spec is None else asdict(spec) +def _or_empty(spec: Optional[MFParamSpec]) -> MFParamSpec: + return MFParamSpec() if spec is None else spec + + class MFScalar(MFParameter): @abstractmethod def __init__( - self, name=None, longname=None, description=None, optional=False + self, + value=None, + block=None, + name=None, + longname=None, + description=None, + deprecated=False, + in_record=False, + layered=False, + optional=True, + numeric_index=False, + preserve_case=False, + repeating=False, + tagged=False, + reader=MFReader.urword, + default_value=None, ): - super().__init__(name, longname, description, optional) + self._value = value + super().__init__( + block, + name, + longname, + description, + deprecated, + in_record, + layered, + optional, + numeric_index, + preserve_case, + repeating, + tagged, + reader, + default_value, + ) + + @property + def value(self): + return self._value class MFKeyword(MFScalar): def __init__( - self, name=None, longname=None, description=None, optional=False + self, + value=None, + block=None, + name=None, + longname=None, + description=None, + deprecated=False, + in_record=False, + layered=False, + optional=True, + numeric_index=False, + preserve_case=False, + repeating=False, + tagged=False, + reader=MFReader.urword, + default_value=None, ): - super().__init__(name, longname, description, optional) - self._value = False - - @property - def value(self): - return self._value + super().__init__( + value, + block, + name, + longname, + description, + deprecated, + in_record, + layered, + optional, + numeric_index, + preserve_case, + repeating, + tagged, + reader, + default_value, + ) @classmethod def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFKeyword": @@ -39,25 +107,51 @@ def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFKeyword": if " " in line: raise ValueError("Keyword may not contain spaces") - scalar = cls(name=line, **_asdict(spec)) - scalar._value = True - return scalar + spec = _or_empty(spec) + spec.name = line + return cls(value=True, **_as_dict(spec)) def write(self, f): if self.value: - f.write(f"{self.name.upper()}\n") + f.write(f"{PAD}{self.name.upper()}\n") class MFInteger(MFScalar): def __init__( - self, name=None, longname=None, description=None, optional=False + self, + value=None, + block=None, + name=None, + longname=None, + description=None, + deprecated=False, + in_record=False, + layered=False, + optional=True, + numeric_index=False, + preserve_case=False, + repeating=False, + tagged=False, + reader=MFReader.urword, + default_value=None, ): - super().__init__(name, longname, description, optional) - self._value = 0 - - @property - def value(self): - return self._value + super().__init__( + value, + block, + name, + longname, + description, + deprecated, + in_record, + layered, + optional, + numeric_index, + preserve_case, + repeating, + tagged, + reader, + default_value, + ) @classmethod def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFInteger": @@ -67,24 +161,50 @@ def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFInteger": if len(words) != 2: raise ValueError("Expected space-separated: 1) keyword, 2) value") - scalar = cls(name=words[0], **_asdict(spec)) - scalar._value = int(words[1]) - return scalar + spec = _or_empty(spec) + spec.name = words[0] + return cls(value=int(words[1]), **_as_dict(spec)) def write(self, f): - f.write(f"{self.name.upper()} {self.value}\n") + f.write(f"{PAD}{self.name.upper()} {self.value}\n") class MFDouble(MFScalar): def __init__( - self, name=None, longname=None, description=None, optional=False + self, + value=None, + block=None, + name=None, + longname=None, + description=None, + deprecated=False, + in_record=False, + layered=False, + optional=True, + numeric_index=False, + preserve_case=False, + repeating=False, + tagged=False, + reader=MFReader.urword, + default_value=None, ): - super().__init__(name, longname, description, optional) - self._value = 0.0 - - @property - def value(self): - return self._value + super().__init__( + value, + block, + name, + longname, + description, + deprecated, + in_record, + layered, + optional, + numeric_index, + preserve_case, + repeating, + tagged, + reader, + default_value, + ) @classmethod def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFDouble": @@ -94,24 +214,50 @@ def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFDouble": if len(words) != 2: raise ValueError("Expected space-separated: 1) keyword, 2) value") - scalar = cls(name=words[0], **_asdict(spec)) - scalar._value = float(words[1]) - return scalar + spec = _or_empty(spec) + spec.name = words[0] + return cls(value=float(words[1]), **_as_dict(spec)) def write(self, f): - f.write(f"{self.name.upper()} {self.value}\n") + f.write(f"{PAD}{self.name.upper()} {self.value}\n") class MFString(MFScalar): def __init__( - self, name=None, longname=None, description=None, optional=False + self, + value=None, + block=None, + name=None, + longname=None, + description=None, + deprecated=False, + in_record=False, + layered=False, + optional=True, + numeric_index=False, + preserve_case=False, + repeating=False, + tagged=False, + reader=MFReader.urword, + default_value=None, ): - super().__init__(name, longname, description, optional) - self._value = None - - @property - def value(self): - return self._value + super().__init__( + value, + block, + name, + longname, + description, + deprecated, + in_record, + layered, + optional, + numeric_index, + preserve_case, + repeating, + tagged, + reader, + default_value, + ) @classmethod def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFString": @@ -121,41 +267,88 @@ def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFString": if len(words) != 2: raise ValueError("Expected space-separated: 1) keyword, 2) value") - scalar = cls(name=words[0], **_asdict(spec)) - scalar._value = words[1] - return scalar + spec = _or_empty(spec) + spec.name = words[0] + return cls(value=words[1], **_as_dict(spec)) def write(self, f): - f.write(f"{self.name.upper()} {self.value}\n") + f.write(f"{PAD}{self.name.upper()} {self.value}\n") + + +class MFFileInout(Enum): + filein = "filein" + fileout = "fileout" + + @classmethod + def from_str(cls, value): + for e in cls: + if value.lower() == e.value: + return e class MFFilename(MFScalar): def __init__( - self, name=None, longname=None, description=None, optional=False + self, + inout=MFFileInout.filein, + value=None, + block=None, + name=None, + longname=None, + description=None, + deprecated=False, + in_record=False, + layered=False, + optional=True, + numeric_index=False, + preserve_case=False, + repeating=False, + tagged=False, + reader=MFReader.urword, + default_value=None, ): - super().__init__(name, longname, description, optional) - self._value = None - - @property - def value(self): - return self._value + self.inout = inout + super().__init__( + value, + block, + name, + longname, + description, + deprecated, + in_record, + layered, + optional, + numeric_index, + preserve_case, + repeating, + tagged, + reader, + default_value, + ) @classmethod def load(cls, f, spec: Optional[MFParamSpec] = None) -> "MFFilename": line = strip(f.readline()) words = line.split() - if len(words) != 3 or words[1].lower() not in ["filein", "fileout"]: + if len(words) != 3 or words[1].lower() not in MFFileInout: raise ValueError( "Expected space-separated: " "1) keyword, " - "2) FILEIN or FILEOUT, " + f"2) {' or '.join(MFFileInout.from_str(words[1]))}" "3) file path" ) - scalar = cls(name=words[0].lower(), **_asdict(spec)) - scalar._value = Path(words[2]) - return scalar + spec = _or_empty(spec) + spec.name = words[0].lower() + return cls( + inout=MFFileInout.from_str(words[1]), + value=Path(words[2]), + **_as_dict(spec), + ) def write(self, f): - f.write(f"{self.name.upper()} {self.value}\n") + f.write( + f"{PAD}{self.name.upper()} " + f"{self.inout.value.upper()} " + f"{self.value}\n" + ) diff --git a/test/pytest.ini b/test/pytest.ini index a8fbb43..286b921 100644 --- a/test/pytest.ini +++ b/test/pytest.ini @@ -1,4 +1,6 @@ [pytest] addopts = --color=yes python_files = - test_*.py \ No newline at end of file + test_*.py +markers = + slow: tests not completing in a few seconds \ No newline at end of file diff --git a/test/test_block.py b/test/test_block.py new file mode 100644 index 0000000..06be215 --- /dev/null +++ b/test/test_block.py @@ -0,0 +1,91 @@ +from flopy4.block import MFBlock, get_member_params +from flopy4.scalar import MFDouble, MFFilename, MFInteger, MFKeyword, MFString + + +class TestBlock(MFBlock): + __test__ = False # tell pytest not to collect + + k = MFKeyword(description="keyword") + i = MFInteger(description="int") + d = MFDouble(description="double") + s = MFString(description="string", optional=False) + f = MFFilename(description="filename", optional=False) + # a = MFArray(description="array") + + +def test_get_member_params(): + params = get_member_params(TestBlock) + assert len(params) == 5 + + k = params["k"] + assert isinstance(k, MFKeyword) + assert k.description == "keyword" + assert k.optional + + i = params["i"] + assert isinstance(i, MFInteger) + assert i.description == "int" + assert i.optional + + d = params["d"] + assert isinstance(d, MFDouble) + assert d.description == "double" + assert d.optional + + s = params["s"] + assert isinstance(s, MFString) + assert s.description == "string" + assert not s.optional + + f = params["f"] + assert isinstance(f, MFFilename) + assert f.description == "filename" + assert not f.optional + + # a = params["a"] + # assert isinstance(f, MFArray) + # assert f.description == "array" + # assert not f.optional + + +def test_block_load_write_no_index(tmp_path): + name = "options" + fpth = tmp_path / f"{name}.txt" + with open(fpth, "w") as f: + f.write(f"BEGIN {name.upper()}\n") + f.write(" K\n") + f.write(" I 1\n") + f.write(" D 1.0\n") + f.write(" S value\n") + f.write(f" F FILEIN {fpth}\n") + f.write(f"END {name.upper()}\n") + + # test block load + with open(fpth, "r") as f: + block = TestBlock.load(f) + + # class attributes: param specifications + assert isinstance(TestBlock.k, MFKeyword) + assert TestBlock.k.name == "k" + assert TestBlock.k.block == "options" + assert TestBlock.k.description == "keyword" + + # instance attributes: shortcut access to param values + assert isinstance(block.k, bool) + assert block.k + assert block.i == 1 + assert block.d == 1.0 + assert block.s == "value" + assert block.f == fpth + + # test block write + fpth2 = tmp_path / f"{name}2.txt" + with open(fpth2, "w") as f: + block.write(f) + with open(fpth2, "r") as f: + lines = f.readlines() + assert " K\n" in lines + assert " I 1\n" in lines + assert " D 1.0\n" in lines + assert " S value\n" in lines + assert f" F FILEIN {fpth}\n" in lines diff --git a/test/test_examples.py b/test/test_examples.py index c670f9b..74bbee4 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -2,16 +2,19 @@ import sys +import pytest from modflow_devtools.markers import requires_exe from modflow_devtools.misc import run_cmd +@pytest.mark.slow def test_scripts(example_script): args = [sys.executable, example_script] stdout, stderr, retcode = run_cmd(*args, verbose=True) assert not retcode, stdout + stderr +@pytest.mark.slow @requires_exe("jupytext") def test_notebooks(example_script): args = [ diff --git a/test/test_scalar.py b/test/test_scalar.py index 23d5f65..3bfd76b 100644 --- a/test/test_scalar.py +++ b/test/test_scalar.py @@ -1,5 +1,6 @@ import pytest +from flopy4.parameter import MFParamSpec from flopy4.scalar import MFDouble, MFFilename, MFInteger, MFKeyword @@ -10,9 +11,19 @@ def test_keyword_load(tmp_path): f.write(name.upper() + "\n") with open(fpth, "r") as f: - scalar = MFKeyword.load(f) + scalar = MFKeyword.load( + f, + MFParamSpec( + block="options", + description="test keyword parameter", + optional=False, + ), + ) assert scalar.name == name assert scalar.value + assert scalar.block == "options" + assert scalar.description == "test keyword parameter" + assert not scalar.optional def test_keyword_load_empty(tmp_path):