From 1d6a1da78c47607d50562880e64103eb4f77cea0 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 22 Jul 2024 16:22:52 -0400 Subject: [PATCH 01/11] stub compound parameters (record and keystring) --- flopy4/compound.py | 171 ++++++++++++++++++++++++++++++++++++++++++ flopy4/parameter.py | 8 ++ flopy4/scalar.py | 18 +++-- test/test_block.py | 7 ++ test/test_compound.py | 1 + test/test_dfn.py | 4 +- 6 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 flopy4/compound.py create mode 100644 test/test_compound.py diff --git a/flopy4/compound.py b/flopy4/compound.py new file mode 100644 index 0000000..c7cefb1 --- /dev/null +++ b/flopy4/compound.py @@ -0,0 +1,171 @@ +from abc import abstractmethod +from collections import UserList +from typing import Any, List, Tuple + +from flopy4.parameter import MFParameter, MFReader +from flopy4.scalar import MFScalar +from flopy4.utils import strip + + +class MFCompound(MFParameter, UserList): + @abstractmethod + def __init__( + self, + *components, + 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, + shape=None, + default_value=None, + ): + UserList.__init__(self, list(components)) + + +class MFRecord(MFCompound): + def __init__( + self, + *components, + block=None, + name=None, + type=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, + shape=None, + default_value=None, + ): + super().__init__( + *components, + block=block, + name=name, + type=type, + 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, + shape=shape, + default_value=default_value, + ) + + @property + def value(self) -> Tuple[Any]: + return tuple([s.value for s in self.data]) + + @value.setter + def value(self, value: Tuple[Any]): + assert len(value) == len(self.data) + for i in range(len(self.data)): + self.data[i].value = value[i] + + @classmethod + def load(cls, f, *components, **kwargs) -> "MFRecord": + line = strip(f.readline()).lower() + + if not any(line): + raise ValueError("Record line may not be empty") + + kwargs["name"] = line.split()[0].lower() + scalars = MFRecord.parse(line, *components) + return cls(*scalars, **kwargs) + + @staticmethod + def parse(line, *components) -> List[MFScalar]: + # todo + pass + + +class MFKeystring(MFCompound): + def __init__( + self, + *components, + block=None, + name=None, + type=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, + shape=None, + default_value=None, + ): + super().__init__( + *components, + block=block, + name=name, + type=type, + 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, + shape=shape, + default_value=default_value, + ) + + @property + def value(self) -> List[Any]: + return [s.value for s in self.data] + + @value.setter + def value(self, value: List[Any]): + assert len(value) == len(self.data) + for i in range(len(self.data)): + self.data[i].value = value[i] + + @classmethod + def load(cls, f, *components, **kwargs) -> "MFKeystring": + scalars = [] + + while True: + line = strip(f.readline()).lower() + if line == "": + raise ValueError("Early EOF, aborting") + if line == "\n": + break + + scalars.append(MFKeystring.parse(line, *components)) + + return cls(*scalars, **kwargs) + + @staticmethod + def parse(line, *components) -> List[MFScalar]: + # todo + pass diff --git a/flopy4/parameter.py b/flopy4/parameter.py index eb7f94b..243fe56 100644 --- a/flopy4/parameter.py +++ b/flopy4/parameter.py @@ -30,6 +30,7 @@ class MFParamSpec: block: Optional[str] = None name: Optional[str] = None + type: Optional[str] = None longname: Optional[str] = None description: Optional[str] = None deprecated: bool = False @@ -127,6 +128,7 @@ def __init__( self, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -144,6 +146,7 @@ def __init__( super().__init__( block=block, name=name, + type=type, longname=longname, description=description, deprecated=deprecated, @@ -165,6 +168,11 @@ def value(self) -> Optional[Any]: """Get the parameter's value, if loaded.""" pass + @abstractmethod + def value(self, value): + """Set the parameter's value.""" + pass + class MFParameters(UserDict): """ diff --git a/flopy4/scalar.py b/flopy4/scalar.py index b99d98b..e674d2a 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -8,7 +8,7 @@ PAD = " " -class MFScalar(MFParameter): +class MFScalar[T](MFParameter): @abstractmethod def __init__( self, @@ -49,11 +49,15 @@ def __init__( ) @property - def value(self): + def value(self) -> T: return self._value + @value.setter + def value(self, value: T): + self._value = value + -class MFKeyword(MFScalar): +class MFKeyword(MFScalar[bool]): def __init__( self, value=None, @@ -109,7 +113,7 @@ def write(self, f): f.write(f"{PAD}{self.name.upper()}\n") -class MFInteger(MFScalar): +class MFInteger(MFScalar[int]): def __init__( self, value=None, @@ -163,7 +167,7 @@ def write(self, f): f.write(f"{PAD}{self.name.upper()} {self.value}\n") -class MFDouble(MFScalar): +class MFDouble(MFScalar[float]): def __init__( self, value=None, @@ -217,7 +221,7 @@ def write(self, f): f.write(f"{PAD}{self.name.upper()} {self.value}\n") -class MFString(MFScalar): +class MFString(MFScalar[str]): def __init__( self, value=None, @@ -282,7 +286,7 @@ def from_str(cls, value): return e -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 d367b42..911ded7 100644 --- a/test/test_block.py +++ b/test/test_block.py @@ -2,6 +2,7 @@ from flopy4.array import MFArray from flopy4.block import MFBlock +from flopy4.compound import MFRecord from flopy4.scalar import MFDouble, MFFilename, MFInteger, MFKeyword, MFString @@ -14,6 +15,12 @@ class TestBlock(MFBlock): s = MFString(description="string", optional=False) f = MFFilename(description="filename", optional=False) a = MFArray(description="array", shape=(3)) + r = MFRecord( + MFKeyword(name="rk", description="keyword"), + MFInteger(name="ri", description="int"), + description="record", + optional=False, + ) def test_members(): diff --git a/test/test_compound.py b/test/test_compound.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/test_compound.py @@ -0,0 +1 @@ + diff --git a/test/test_dfn.py b/test/test_dfn.py index ae9f1d0..309ade4 100644 --- a/test/test_dfn.py +++ b/test/test_dfn.py @@ -1,8 +1,8 @@ from pathlib import Path -from flopy4.dfn import Dfn, DfnSet +from conftest import PROJ_ROOT_PATH -PROJ_ROOT_PATH = Path(__file__).parents[1] +from flopy4.dfn import Dfn, DfnSet class TestDfn(Dfn): From 4846fa33633cc3b828e44f26c4fbf996d7b907f3 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 22 Jul 2024 17:47:41 -0400 Subject: [PATCH 02/11] record tests passing --- flopy4/array.py | 2 ++ flopy4/block.py | 12 ++++--- flopy4/compound.py | 89 ++++++++++++++++++++++++++++++++++------------ flopy4/scalar.py | 65 +++++++++++++++++++++++++++------ test/test_block.py | 28 +++++++++++++-- 5 files changed, 157 insertions(+), 39 deletions(-) diff --git a/flopy4/array.py b/flopy4/array.py index e07cc8a..8e57032 100644 --- a/flopy4/array.py +++ b/flopy4/array.py @@ -189,6 +189,7 @@ def __init__( factor=None, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -206,6 +207,7 @@ def __init__( self, block=block, name=name, + type=type, longname=longname, description=description, deprecated=deprecated, diff --git a/flopy4/block.py b/flopy4/block.py index 5668fa9..597fb9c 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -4,6 +4,7 @@ from typing import Any from flopy4.array import MFArray +from flopy4.compound import MFKeystring, MFRecord from flopy4.parameter import MFParameter, MFParameters from flopy4.utils import strip @@ -89,12 +90,15 @@ def load(cls, f, **kwargs): if param is not None: f.seek(pos) spec = asdict(param.with_name(key).with_block(name)) - kwargs = {**kwargs, **spec} - if type(param) is MFArray: + kwrgs = {**kwargs, **spec} + ptype = type(param) + if ptype is MFArray: # TODO: inject from model somehow? # and remove special handling here - kwargs["cwd"] = "" - params[key] = type(param).load(f, **kwargs) + kwrgs["cwd"] = "" + if ptype is MFRecord or ptype is MFKeystring: + kwrgs["scalars"] = param.data + params[key] = ptype.load(f, **kwrgs) return cls(name, index, params) diff --git a/flopy4/compound.py b/flopy4/compound.py index c7cefb1..5db8790 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -1,19 +1,23 @@ from abc import abstractmethod from collections import UserList -from typing import Any, List, Tuple +from io import StringIO +from typing import Any, Iterator, List, Tuple from flopy4.parameter import MFParameter, MFReader from flopy4.scalar import MFScalar from flopy4.utils import strip +PAD = " " + class MFCompound(MFParameter, UserList): @abstractmethod def __init__( self, - *components, + scalars, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -28,13 +32,32 @@ def __init__( shape=None, default_value=None, ): - UserList.__init__(self, list(components)) + MFParameter.__init__( + self, + block, + name, + type, + longname, + description, + deprecated, + in_record, + layered, + optional, + numeric_index, + preserve_case, + repeating, + tagged, + reader, + shape, + default_value, + ) + UserList.__init__(self, scalars) class MFRecord(MFCompound): def __init__( self, - *components, + scalars, block=None, name=None, type=None, @@ -53,7 +76,7 @@ def __init__( default_value=None, ): super().__init__( - *components, + scalars, block=block, name=name, type=type, @@ -72,6 +95,10 @@ def __init__( default_value=default_value, ) + @property + def scalars(self) -> Tuple[MFScalar]: + return tuple(self.data.copy()) + @property def value(self) -> Tuple[Any]: return tuple([s.value for s in self.data]) @@ -83,26 +110,40 @@ def value(self, value: Tuple[Any]): self.data[i].value = value[i] @classmethod - def load(cls, f, *components, **kwargs) -> "MFRecord": + def load(cls, f, scalars, **kwargs) -> "MFRecord": line = strip(f.readline()).lower() if not any(line): raise ValueError("Record line may not be empty") - kwargs["name"] = line.split()[0].lower() - scalars = MFRecord.parse(line, *components) - return cls(*scalars, **kwargs) + split = line.split() + kwargs["name"] = split.pop(0).lower() + line = " ".join(split) + return cls(list(MFRecord.parse(line, scalars, **kwargs)), **kwargs) @staticmethod - def parse(line, *components) -> List[MFScalar]: - # todo - pass + def parse(line, scalars, **kwargs) -> Iterator[MFScalar]: + for scalar in scalars: + split = line.split() + stype = type(scalar) + words = len(scalar) + head = " ".join(split[:words]) + tail = " ".join(split[words:]) + line = tail + with StringIO(head) as f: + yield stype.load(f, **kwargs) + + def write(self, f): + f.write(f"{PAD}{self.name.upper()}") + last = len(self) - 1 + for i, param in enumerate(self.data): + param.write(f, newline=i == last) class MFKeystring(MFCompound): def __init__( self, - *components, + scalars, block=None, name=None, type=None, @@ -121,7 +162,7 @@ def __init__( default_value=None, ): super().__init__( - *components, + scalars, block=block, name=name, type=type, @@ -140,6 +181,10 @@ def __init__( default_value=default_value, ) + @property + def scalars(self) -> List[MFScalar]: + return self.data.copy() + @property def value(self) -> List[Any]: return [s.value for s in self.data] @@ -151,8 +196,8 @@ def value(self, value: List[Any]): self.data[i].value = value[i] @classmethod - def load(cls, f, *components, **kwargs) -> "MFKeystring": - scalars = [] + def load(cls, f, scalars, **kwargs) -> "MFKeystring": + loaded = [] while True: line = strip(f.readline()).lower() @@ -161,11 +206,11 @@ def load(cls, f, *components, **kwargs) -> "MFKeystring": if line == "\n": break - scalars.append(MFKeystring.parse(line, *components)) + scalar = scalars.pop() + loaded.append(type(scalar).load(line, **kwargs)) - return cls(*scalars, **kwargs) + return cls(loaded, **kwargs) - @staticmethod - def parse(line, *components) -> List[MFScalar]: - # todo - pass + def write(self, f): + for param in self.data: + param.write(f) diff --git a/flopy4/scalar.py b/flopy4/scalar.py index e674d2a..a009471 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -15,6 +15,7 @@ def __init__( value=None, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -33,6 +34,7 @@ def __init__( super().__init__( block, name, + type, longname, description, deprecated, @@ -63,6 +65,7 @@ def __init__( value=None, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -81,6 +84,7 @@ def __init__( value, block, name, + type, longname, description, deprecated, @@ -96,6 +100,9 @@ def __init__( default_value, ) + def __len__(self): + return 1 + @classmethod def load(cls, f, **kwargs) -> "MFKeyword": line = strip(f.readline()).lower() @@ -108,9 +115,11 @@ def load(cls, f, **kwargs) -> "MFKeyword": kwargs["name"] = line return cls(value=True, **kwargs) - def write(self, f): + def write(self, f, newline=True): if self.value: - f.write(f"{PAD}{self.name.upper()}\n") + f.write( + f"{PAD}" f"{self.name.upper()}" f"{'\n' if newline else ''}" + ) class MFInteger(MFScalar[int]): @@ -119,6 +128,7 @@ def __init__( value=None, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -137,6 +147,7 @@ def __init__( value, block, name, + type, longname, description, deprecated, @@ -152,6 +163,9 @@ def __init__( default_value, ) + def __len__(self): + return 2 + @classmethod def load(cls, f, **kwargs) -> "MFInteger": line = strip(f.readline()).lower() @@ -163,8 +177,13 @@ def load(cls, f, **kwargs) -> "MFInteger": kwargs["name"] = words[0] return cls(value=int(words[1]), **kwargs) - def write(self, f): - f.write(f"{PAD}{self.name.upper()} {self.value}\n") + def write(self, f, newline=True): + f.write( + f"{PAD}" + f"{self.name.upper()} " + f"{self.value}" + f"{'\n' if newline else ''}" + ) class MFDouble(MFScalar[float]): @@ -173,6 +192,7 @@ def __init__( value=None, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -191,6 +211,7 @@ def __init__( value, block, name, + type, longname, description, deprecated, @@ -206,6 +227,9 @@ def __init__( default_value, ) + def __len__(self): + return 2 + @classmethod def load(cls, f, **kwargs) -> "MFDouble": line = strip(f.readline()).lower() @@ -217,8 +241,13 @@ def load(cls, f, **kwargs) -> "MFDouble": kwargs["name"] = words[0] return cls(value=float(words[1]), **kwargs) - def write(self, f): - f.write(f"{PAD}{self.name.upper()} {self.value}\n") + def write(self, f, newline=True): + f.write( + f"{PAD}" + f"{self.name.upper()} " + f"{self.value}" + f"{'\n' if newline else ''}" + ) class MFString(MFScalar[str]): @@ -227,6 +256,7 @@ def __init__( value=None, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -245,6 +275,7 @@ def __init__( value, block, name, + type, longname, description, deprecated, @@ -260,6 +291,9 @@ def __init__( default_value, ) + def __len__(self): + return None if self._value is None else len(self._value.split()) + @classmethod def load(cls, f, **kwargs) -> "MFString": line = strip(f.readline()).lower() @@ -271,8 +305,13 @@ def load(cls, f, **kwargs) -> "MFString": kwargs["name"] = words[0] return cls(value=words[1], **kwargs) - def write(self, f): - f.write(f"{PAD}{self.name.upper()} {self.value}\n") + def write(self, f, newline=True): + f.write( + f"{PAD}" + f"{self.name.upper()} " + f"{self.value}" + f"{'\n' if newline else ''}" + ) class MFFileInout(Enum): @@ -293,6 +332,7 @@ def __init__( value=None, block=None, name=None, + type=None, longname=None, description=None, deprecated=False, @@ -312,6 +352,7 @@ def __init__( value, block, name, + type, longname, description, deprecated, @@ -327,6 +368,9 @@ def __init__( default_value, ) + def __len__(self): + return 3 + @classmethod def load(cls, f, **kwargs) -> "MFFilename": line = strip(f.readline()) @@ -348,9 +392,10 @@ def load(cls, f, **kwargs) -> "MFFilename": **kwargs, ) - def write(self, f): + def write(self, f, newline=True): f.write( f"{PAD}{self.name.upper()} " f"{self.inout.value.upper()} " - f"{self.value}\n" + f"{self.value}" + f"{'\n' if newline else ''}" ) diff --git a/test/test_block.py b/test/test_block.py index 911ded7..f01fe19 100644 --- a/test/test_block.py +++ b/test/test_block.py @@ -16,8 +16,11 @@ class TestBlock(MFBlock): f = MFFilename(description="filename", optional=False) a = MFArray(description="array", shape=(3)) r = MFRecord( - MFKeyword(name="rk", description="keyword"), - MFInteger(name="ri", description="int"), + scalars=[ + MFKeyword(name="rk", description="keyword"), + MFInteger(name="ri", description="int"), + MFDouble(name="rd", description="double"), + ], description="record", optional=False, ) @@ -25,7 +28,7 @@ class TestBlock(MFBlock): def test_members(): params = TestBlock.params - assert len(params) == 6 + assert len(params) == 7 k = params["k"] assert isinstance(k, MFKeyword) @@ -57,6 +60,11 @@ def test_members(): assert a.description == "array" assert a.optional + r = params["r"] + assert isinstance(r, MFRecord) + assert r.description == "record" + assert not r.optional + def test_load_write(tmp_path): name = "options" @@ -68,6 +76,7 @@ def test_load_write(tmp_path): f.write(" D 1.0\n") f.write(" S value\n") f.write(f" F FILEIN {fpth}\n") + f.write(" R RK RI 2 RD 2.0\n") f.write(" A\n INTERNAL\n 1.0 2.0 3.0\n") f.write(f"END {name.upper()}\n") @@ -90,15 +99,28 @@ def test_load_write(tmp_path): assert block.f == fpth assert np.allclose(block.a, np.array([1.0, 2.0, 3.0])) + assert isinstance(TestBlock.r, MFRecord) + assert TestBlock.r.name == "r" + assert len(TestBlock.r.scalars) == 3 + assert isinstance(TestBlock.r.scalars[0], MFKeyword) + assert isinstance(TestBlock.r.scalars[1], MFInteger) + assert isinstance(TestBlock.r.scalars[2], MFDouble) + + assert isinstance(block.r, tuple) + assert block.r == (True, 2, 2.0) + # 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 "BEGIN OPTIONS \n" in lines 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 # assert " A\n INTERNAL\n 1.0 2.0 3.0\n" in lines + assert " R RK RI 2 RD 2.0\n" in lines + assert "END OPTIONS\n" in lines From b8ad751b6c0eb2534d195ad964e1fcde2f9bc7df Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 22 Jul 2024 22:30:54 -0400 Subject: [PATCH 03/11] broken wip --- flopy4/array.py | 6 +- flopy4/block.py | 65 +++++++++++------- flopy4/compound.py | 110 ++++++++++++++++++------------ flopy4/constants.py | 30 ++++++++ flopy4/package.py | 23 ++++--- flopy4/{parameter.py => param.py} | 40 ++++++----- flopy4/scalar.py | 20 ++---- test/test_block.py | 78 +++++++++++++++------ test/test_package.py | 32 ++++----- 9 files changed, 259 insertions(+), 145 deletions(-) rename flopy4/{parameter.py => param.py} (89%) diff --git a/flopy4/array.py b/flopy4/array.py index 8e57032..e6cb9f6 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, MFReader +from flopy4.param import MFParam, MFReader class NumPyArrayMixin: @@ -174,7 +174,7 @@ def from_string(cls, string): return e -class MFArray(MFParameter, NumPyArrayMixin): +class MFArray(MFParam, NumPyArrayMixin): """ A MODFLOW 6 array backed by a 1-dimensional NumPy array, which is reshaped as needed for various views. Supports @@ -203,7 +203,7 @@ def __init__( reader=MFReader.urword, default_value=None, ): - MFParameter.__init__( + MFParam.__init__( self, block=block, name=name, diff --git a/flopy4/block.py b/flopy4/block.py index 597fb9c..168d8c1 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -1,28 +1,42 @@ from abc import ABCMeta from collections import UserDict from dataclasses import asdict -from typing import Any +from io import StringIO from flopy4.array import MFArray from flopy4.compound import MFKeystring, MFRecord -from flopy4.parameter import MFParameter, MFParameters +from flopy4.param import MFParam, MFParams from flopy4.utils import strip +def single_keystring(members): + params = list(members.values()) + return len(params) == 1 and isinstance(params[0], MFKeystring) + + +def get_param(members, key): + return list(members.values())[0] if single_keystring(members) else members.get(key) + + class MFBlockMeta(type): def __new__(cls, clsname, bases, attrs): new = super().__new__(cls, clsname, bases, attrs) - if clsname == "MFBlock": + if clsname.startswith("MF"): return new # add parameter specification as class attribute - new.params = MFParameters( + block_name = clsname.replace("Block", "").lower() + params = MFParams( { - k: v + k: v.with_name(k).with_block(block_name) for k, v in attrs.items() - if issubclass(type(v), MFParameter) + if issubclass(type(v), MFParam) } ) + new.params = params + keystrings = [p for p in params if isinstance(p, MFKeystring)] + if len(keystrings) > 1: + raise ValueError("Only one keystring allowed per block") return new @@ -31,7 +45,7 @@ class MFBlockMappingMeta(MFBlockMeta, ABCMeta): pass -class MFBlock(MFParameters, metaclass=MFBlockMappingMeta): +class MFBlock(MFParams, metaclass=MFBlockMappingMeta): """ MF6 input block. Maps parameter names to parameters. @@ -51,22 +65,20 @@ def __init__(self, name=None, index=None, params=None): self.name = name self.index = index super().__init__(params) - for key, param in self.items(): - setattr(self, key, param) - def __getattribute__(self, name: str) -> Any: - # shortcut to parameter value for instance attribute. - # the class attribute is the full parameter instance. - attr = super().__getattribute__(name) - return attr.value if isinstance(attr, MFParameter) else attr + def __str__(self): + buffer = StringIO() + self.write(buffer) + return buffer.getvalue() @property - def params(self) -> MFParameters: + def params(self) -> MFParams: """Block parameters.""" return self.data @classmethod def load(cls, f, **kwargs): + """Load the block from file.""" name = None index = None found = False @@ -75,18 +87,23 @@ def load(cls, f, **kwargs): while True: pos = f.tell() - line = strip(f.readline()).lower() - words = line.split() + line = f.readline() + if line == "": + raise ValueError("Early EOF, aborting") + if line == "\n": + continue + words = strip(line).lower().split() key = words[0] if key == "begin": found = True name = words[1] if len(words) > 2 and str.isdigit(words[2]): - index = words[2] + index = int(words[2]) elif key == "end": break elif found: - param = members.get(key) + + param = get_param(members, key) if param is not None: f.seek(pos) spec = asdict(param.with_name(key).with_block(name)) @@ -96,20 +113,22 @@ def load(cls, f, **kwargs): # TODO: inject from model somehow? # and remove special handling here kwrgs["cwd"] = "" - if ptype is MFRecord or ptype is MFKeystring: - kwrgs["scalars"] = param.data + if ptype is MFRecord: + kwrgs["params"] = param.data + if ptype is MFKeystring: + kwrgs["params"] = param.data params[key] = ptype.load(f, **kwrgs) return cls(name, index, params) def write(self, f): + """Write the block to file.""" 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) + super().write(f) f.write(end) diff --git a/flopy4/compound.py b/flopy4/compound.py index 5db8790..aa1622e 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -1,20 +1,22 @@ -from abc import abstractmethod -from collections import UserList +from abc import abstractmethod, ABCMeta +from collections.abc import Iterable, Mapping +from dataclasses import asdict from io import StringIO -from typing import Any, Iterator, List, Tuple +from pprint import pformat +from typing import Any, List -from flopy4.parameter import MFParameter, MFReader +from flopy4.param import MFParam, MFParams, MFReader from flopy4.scalar import MFScalar from flopy4.utils import strip PAD = " " -class MFCompound(MFParameter, UserList): +class MFCompound(MFParam, MFParams): @abstractmethod def __init__( self, - scalars, + params, block=None, name=None, type=None, @@ -32,7 +34,8 @@ def __init__( shape=None, default_value=None, ): - MFParameter.__init__( + MFParams.__init__(self, {k: p.with_name(k) for k, p in params.items()}) + MFParam.__init__( self, block, name, @@ -51,13 +54,35 @@ def __init__( shape, default_value, ) - UserList.__init__(self, scalars) + + # def __repr__(self): + # return pformat(self.data) + + # def __str__(self): + # buffer = StringIO() + # self.write(buffer) + # return buffer.getvalue() + + @property + def value(self) -> Mapping[str, Any]: + """Get component names/values.""" + return {k: s.value for k, s in self.data.items() if s.value is not None} + + @value.setter + def value(self, **kwargs): + """Set component names/values by keyword arguments.""" + val_len = len(kwargs) + exp_len = len(self.data) + if exp_len != val_len: + raise ValueError(f"Expected {exp_len} values, got {val_len}") + for key in self.data.keys(): + self.data[key].value = kwargs[key] class MFRecord(MFCompound): def __init__( self, - scalars, + params, block=None, name=None, type=None, @@ -76,7 +101,7 @@ def __init__( default_value=None, ): super().__init__( - scalars, + params, block=block, name=name, type=type, @@ -96,21 +121,17 @@ def __init__( ) @property - def scalars(self) -> Tuple[MFScalar]: - return tuple(self.data.copy()) - - @property - def value(self) -> Tuple[Any]: - return tuple([s.value for s in self.data]) + def value(self) -> List[Any]: + return [s.value for s in self.data] @value.setter - def value(self, value: Tuple[Any]): + def value(self, value: Iterable[Any]): assert len(value) == len(self.data) for i in range(len(self.data)): self.data[i].value = value[i] @classmethod - def load(cls, f, scalars, **kwargs) -> "MFRecord": + def load(cls, f, params, **kwargs) -> "MFRecord": line = strip(f.readline()).lower() if not any(line): @@ -119,19 +140,24 @@ def load(cls, f, scalars, **kwargs) -> "MFRecord": split = line.split() kwargs["name"] = split.pop(0).lower() line = " ".join(split) - return cls(list(MFRecord.parse(line, scalars, **kwargs)), **kwargs) + return cls(MFRecord.parse(line, params, **kwargs), **kwargs) @staticmethod - def parse(line, scalars, **kwargs) -> Iterator[MFScalar]: - for scalar in scalars: + def parse(line, params, **kwargs) -> List[MFScalar]: + loaded = [] + + for param in params: split = line.split() - stype = type(scalar) - words = len(scalar) + stype = type(param) + words = len(param) head = " ".join(split[:words]) tail = " ".join(split[words:]) line = tail + kwrgs = {**kwargs, **asdict(param)} with StringIO(head) as f: - yield stype.load(f, **kwargs) + loaded.append(stype.load(f, **kwrgs)) + + return loaded def write(self, f): f.write(f"{PAD}{self.name.upper()}") @@ -143,7 +169,7 @@ def write(self, f): class MFKeystring(MFCompound): def __init__( self, - scalars, + params, block=None, name=None, type=None, @@ -162,7 +188,7 @@ def __init__( default_value=None, ): super().__init__( - scalars, + params, block=block, name=name, type=type, @@ -181,36 +207,32 @@ def __init__( default_value=default_value, ) - @property - def scalars(self) -> List[MFScalar]: - return self.data.copy() - - @property - def value(self) -> List[Any]: - return [s.value for s in self.data] - - @value.setter - def value(self, value: List[Any]): - assert len(value) == len(self.data) - for i in range(len(self.data)): - self.data[i].value = value[i] - @classmethod - def load(cls, f, scalars, **kwargs) -> "MFKeystring": + def load(cls, f, params, **kwargs) -> "MFKeystring": + """Load the keystring from file.""" loaded = [] while True: line = strip(f.readline()).lower() if line == "": - raise ValueError("Early EOF, aborting") + raise ValueError("Early EOF") if line == "\n": + continue + + split = line.split() + first = split[0] + + if first == "end": break - scalar = scalars.pop() - loaded.append(type(scalar).load(line, **kwargs)) + param = params.pop(first) + kwrgs = {**kwargs, **asdict(param)} + with StringIO(line) as ff: + loaded.append(type(param).load(ff, **kwrgs)) return cls(loaded, **kwargs) def write(self, f): + """Write the keystring to file.""" for param in self.data: param.write(f) diff --git a/flopy4/constants.py b/flopy4/constants.py index f1fc31b..62df028 100644 --- a/flopy4/constants.py +++ b/flopy4/constants.py @@ -1,3 +1,6 @@ +from enum import Enum + + class CommonNames: iprn = "IPRN" internal = "INTERNAL" @@ -9,3 +12,30 @@ class CommonNames: unstructured = "unstructured" empty = "" end = "END" + + +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 MFReader(Enum): + """ + MF6 procedure with which to read input. + """ + + urword = "urword" + u1ddbl = "u1dbl" + readarray = "readarray" + + @classmethod + def from_str(cls, value): + for e in cls: + if value.lower() == e.value: + return e \ No newline at end of file diff --git a/flopy4/package.py b/flopy4/package.py index af73721..41672c8 100644 --- a/flopy4/package.py +++ b/flopy4/package.py @@ -1,15 +1,16 @@ from abc import ABCMeta from collections import UserDict from itertools import groupby +from io import StringIO from typing import Any from flopy4.block import MFBlock, MFBlockMeta, MFBlocks -from flopy4.parameter import MFParameter, MFParameters +from flopy4.param import MFParam, MFParams from flopy4.utils import strip -def get_block(clsname, params): - return MFBlockMeta(clsname, (MFBlock,), params)(params=params) +def get_block(pkg_name, block_name, params): + return MFBlockMeta(f"{pkg_name.title()}{block_name.title()}Block", (MFBlock,), params)(params=params, name=block_name) class MFPackageMeta(type): @@ -22,18 +23,19 @@ def __new__(cls, clsname, bases, attrs): # attributes. subclass mfblock dynamically based # on each block parameter specification. pkg_name = clsname.replace("Package", "") - params = MFParameters( + params = MFParams( { k: v.with_name(k) for k, v in attrs.items() - if issubclass(type(v), MFParameter) + if issubclass(type(v), MFParam) } ) new.params = params new.blocks = MFBlocks( { block_name: get_block( - clsname=f"{pkg_name.title()}{block_name.title()}Block", + pkg_name=pkg_name, + block_name=block_name, params={p.name: p for p in block}, ) for block_name, block in groupby( @@ -57,6 +59,11 @@ class MFPackage(UserDict, metaclass=MFPackageMappingMeta): TODO: reimplement with `ChainMap`? """ + def __str__(self): + buffer = StringIO() + self.write(buffer) + return buffer.getvalue() + def __getattribute__(self, name: str) -> Any: value = super().__getattribute__(name) if name == "data": @@ -73,9 +80,9 @@ def __getattribute__(self, name: str) -> Any: return params[name].value if param is not None else value @property - def params(self) -> MFParameters: + def params(self) -> MFParams: """Package parameters.""" - return MFParameters( + return MFParams( { name: param for block in self.data diff --git a/flopy4/parameter.py b/flopy4/param.py similarity index 89% rename from flopy4/parameter.py rename to flopy4/param.py index 243fe56..c564863 100644 --- a/flopy4/parameter.py +++ b/flopy4/param.py @@ -2,24 +2,10 @@ from ast import literal_eval from collections import UserDict from dataclasses import dataclass, fields -from enum import Enum +from io import StringIO from typing import Any, Optional, Tuple - -class MFReader(Enum): - """ - MF6 procedure with which to read input. - """ - - urword = "urword" - u1ddbl = "u1dbl" - readarray = "readarray" - - @classmethod - def from_str(cls, value): - for e in cls: - if value.lower() == e.value: - return e +from flopy4.constants import MFReader @dataclass @@ -94,7 +80,7 @@ def with_block(self, block) -> "MFParamSpec": return self -class MFParameter(MFParamSpec): +class MFParam(MFParamSpec): """ MODFLOW 6 input parameter. Can be a scalar or compound of scalars, an array, or a list (i.e. a table). `MFParameter` @@ -162,6 +148,11 @@ def __init__( default_value=default_value, ) + def __str__(self): + buffer = StringIO() + self.write(buffer) + return buffer.getvalue() + @property @abstractmethod def value(self) -> Optional[Any]: @@ -174,7 +165,7 @@ def value(self, value): pass -class MFParameters(UserDict): +class MFParams(UserDict): """ Mapping of parameter names to parameters. Supports dictionary and attribute access. @@ -184,3 +175,16 @@ def __init__(self, params=None): super().__init__(params) for key, param in self.items(): setattr(self, key, param) + + def __getattribute__(self, name: str) -> Any: + # shortcut to parameter value for instance attribute. + # the class attribute is the full parameter instance. + attr = super().__getattribute__(name) + return attr.value if isinstance(attr, MFParam) else attr + + def write(self, f): + for param in self.values(): + param.write(f) + + + diff --git a/flopy4/scalar.py b/flopy4/scalar.py index a009471..6352a19 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -1,14 +1,14 @@ from abc import abstractmethod -from enum import Enum from pathlib import Path -from flopy4.parameter import MFParameter, MFReader +from flopy4.constants import MFFileInout +from flopy4.param import MFParam, MFReader from flopy4.utils import strip PAD = " " -class MFScalar[T](MFParameter): +class MFScalar[T](MFParam): @abstractmethod def __init__( self, @@ -50,6 +50,9 @@ def __init__( default_value, ) + # def __repr__(self): + # return f"{self.name}: {self.value}" + @property def value(self) -> T: return self._value @@ -314,17 +317,6 @@ def write(self, f, newline=True): ) -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[Path]): def __init__( self, diff --git a/test/test_block.py b/test/test_block.py index f01fe19..a41dff3 100644 --- a/test/test_block.py +++ b/test/test_block.py @@ -2,7 +2,7 @@ from flopy4.array import MFArray from flopy4.block import MFBlock -from flopy4.compound import MFRecord +from flopy4.compound import MFRecord, MFKeystring from flopy4.scalar import MFDouble, MFFilename, MFInteger, MFKeyword, MFString @@ -16,11 +16,11 @@ class TestBlock(MFBlock): f = MFFilename(description="filename", optional=False) a = MFArray(description="array", shape=(3)) r = MFRecord( - scalars=[ - MFKeyword(name="rk", description="keyword"), - MFInteger(name="ri", description="int"), - MFDouble(name="rd", description="double"), - ], + params={ + "rk": MFKeyword(), + "ri": MFInteger(), + "rd": MFDouble(), + }, description="record", optional=False, ) @@ -30,37 +30,37 @@ def test_members(): params = TestBlock.params assert len(params) == 7 - k = params["k"] + k = params.k assert isinstance(k, MFKeyword) assert k.description == "keyword" assert k.optional - i = params["i"] + i = params.i assert isinstance(i, MFInteger) assert i.description == "int" assert i.optional - d = params["d"] + d = params.d assert isinstance(d, MFDouble) assert d.description == "double" assert d.optional - s = params["s"] + s = params.s assert isinstance(s, MFString) assert s.description == "string" assert not s.optional - f = params["f"] + f = params.f assert isinstance(f, MFFilename) assert f.description == "filename" assert not f.optional - a = params["a"] + a = params.a assert isinstance(a, MFArray) assert a.description == "array" assert a.optional - r = params["r"] + r = params.r assert isinstance(r, MFRecord) assert r.description == "record" assert not r.optional @@ -84,13 +84,13 @@ def test_load_write(tmp_path): with open(fpth, "r") as f: block = TestBlock.load(f) - # class attributes: param specifications + # class attribute as param specification 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 + # instance attribute as shortcut to param valu assert isinstance(block.k, bool) assert block.k assert block.i == 1 @@ -101,10 +101,10 @@ def test_load_write(tmp_path): assert isinstance(TestBlock.r, MFRecord) assert TestBlock.r.name == "r" - assert len(TestBlock.r.scalars) == 3 - assert isinstance(TestBlock.r.scalars[0], MFKeyword) - assert isinstance(TestBlock.r.scalars[1], MFInteger) - assert isinstance(TestBlock.r.scalars[2], MFDouble) + assert len(TestBlock.r.params) == 3 + assert isinstance(TestBlock.r.params[0], MFKeyword) + assert isinstance(TestBlock.r.params[1], MFInteger) + assert isinstance(TestBlock.r.params[2], MFDouble) assert isinstance(block.r, tuple) assert block.r == (True, 2, 2.0) @@ -124,3 +124,43 @@ def test_load_write(tmp_path): # assert " A\n INTERNAL\n 1.0 2.0 3.0\n" in lines assert " R RK RI 2 RD 2.0\n" in lines assert "END OPTIONS\n" in lines + + +class IndexedBlock(MFBlock): + ks = MFKeystring( + params={ + "first": MFKeyword(), + "frequency": MFInteger(), + }, + description="keystring", + optional=False, + ) + + +def test_load_write_indexed(tmp_path): + name = "indexed" + fpth = tmp_path / f"{name}.txt" + with open(fpth, "w") as f: + f.write(f"BEGIN {name.upper()} 1\n") + f.write(" FIRST\n") + f.write(f"END {name.upper()}\n") + f.write("\n") + f.write(f"BEGIN {name.upper()} 2\n") + f.write(" FIRST\n") + f.write(" FREQUENCY 2\n") + f.write(f"END {name.upper()}\n") + + with open(fpth, "r") as f: + period1 = IndexedBlock.load(f) + period2 = IndexedBlock.load(f) + + assert period1.index == 1 + assert period2.index == 2 + + # class attributes as param specification + assert isinstance(IndexedBlock.ks, MFKeystring) + assert IndexedBlock.ks.name == "ks" + assert IndexedBlock.ks.block == name + + # instance attribute as shortcut to param value + assert period1.ks == ["first"] \ No newline at end of file diff --git a/test/test_package.py b/test/test_package.py index 6e32255..e88759c 100644 --- a/test/test_package.py +++ b/test/test_package.py @@ -28,37 +28,37 @@ def test_member_params(): params = TestPackage.params assert len(params) == 6 - k = params["k"] + k = params.k assert isinstance(k, MFKeyword) assert k.block == "options" assert k.description == "keyword" assert k.optional - i = params["i"] + i = params.i assert isinstance(i, MFInteger) assert i.block == "options" assert i.description == "int" assert i.optional - d = params["d"] + d = params.d assert isinstance(d, MFDouble) assert d.block == "options" assert d.description == "double" assert d.optional - s = params["s"] + s = params.s assert isinstance(s, MFString) assert s.block == "options" assert s.description == "string" assert not s.optional - f = params["f"] + f = params.f assert isinstance(f, MFFilename) assert f.block == "options" assert f.description == "filename" assert not f.optional - a = params["a"] + a = params.a assert isinstance(a, MFArray) assert a.block == "packagedata" assert a.description == "array" @@ -68,40 +68,40 @@ def test_member_blocks(): blocks = TestPackage.blocks assert len(blocks) == 2 - block = blocks["options"] + block = blocks.options assert isinstance(block, MFBlock) assert len(block.params) == 5 - k = block["k"] + k = block.k assert isinstance(k, MFKeyword) assert k.description == "keyword" assert k.optional - i = block["i"] + i = block.i assert isinstance(i, MFInteger) assert i.description == "int" assert i.optional - d = block["d"] + d = block.d assert isinstance(d, MFDouble) assert d.description == "double" assert d.optional - s = block["s"] + s = block.s assert isinstance(s, MFString) assert s.description == "string" assert not s.optional - f = block["f"] + f = block.f assert isinstance(f, MFFilename) assert f.description == "filename" assert not f.optional - block = blocks["packagedata"] + block = blocks.packagedata assert isinstance(block, MFBlock) assert len(block.params) == 1 - a = block["a"] + a = block.a assert isinstance(a, MFArray) assert a.description == "array" @@ -131,13 +131,13 @@ def test_load_write(tmp_path): assert len(package.blocks) == 2 assert len(package.params) == 6 - # class attributes: param specifications + # class attribute as param specification assert isinstance(TestPackage.k, MFKeyword) assert TestPackage.k.name == "k" assert TestPackage.k.block == "options" assert TestPackage.k.description == "keyword" - # instance attributes: shortcut access to param values + # instance attribute as shortcut to param value assert isinstance(package.k, bool) assert package.k assert package.i == 1 From f83e0da7b39e9cfdb423fc25ace42c519c8bba9e Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 24 Jul 2024 00:20:39 -0400 Subject: [PATCH 04/11] working I think --- flopy4/block.py | 52 +++++++++++++++++++++++++++++-------------- flopy4/compound.py | 46 ++++++++++++++++++-------------------- flopy4/constants.py | 2 +- flopy4/package.py | 6 +++-- flopy4/param.py | 14 ------------ flopy4/scalar.py | 3 --- flopy4/utils.py | 6 +++++ test/test_block.py | 16 ++++++------- test/test_compound.py | 1 - test/test_package.py | 12 +++++----- 10 files changed, 82 insertions(+), 76 deletions(-) delete mode 100644 test/test_compound.py diff --git a/flopy4/block.py b/flopy4/block.py index 168d8c1..e9aeae7 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -2,11 +2,12 @@ from collections import UserDict from dataclasses import asdict from io import StringIO +from typing import Any from flopy4.array import MFArray from flopy4.compound import MFKeystring, MFRecord from flopy4.param import MFParam, MFParams -from flopy4.utils import strip +from flopy4.utils import find_upper, strip def single_keystring(members): @@ -15,28 +16,38 @@ def single_keystring(members): def get_param(members, key): - return list(members.values())[0] if single_keystring(members) else members.get(key) + return ( + list(members.values())[0] + if single_keystring(members) + else members.get(key) + ) class MFBlockMeta(type): def __new__(cls, clsname, bases, attrs): new = super().__new__(cls, clsname, bases, attrs) - if clsname.startswith("MF"): + if clsname == "MFBlock": return new - # add parameter specification as class attribute - block_name = clsname.replace("Block", "").lower() - params = MFParams( - { - k: v.with_name(k).with_block(block_name) - for k, v in attrs.items() - if issubclass(type(v), MFParam) - } + # detect block name + block_name = ( + clsname[list(find_upper(clsname))[1] :] + .replace("Block", "") + .lower() ) - new.params = params - keystrings = [p for p in params if isinstance(p, MFKeystring)] - if len(keystrings) > 1: + + # add parameter specification class attributes + params = { + k: v.with_name(k).with_block(block_name) + for k, v in attrs.items() + if issubclass(type(v), MFParam) + } + if len([p for p in params if isinstance(p, MFKeystring)]) > 1: raise ValueError("Only one keystring allowed per block") + for key, param in params.items(): + setattr(new, key, param) + new.params = MFParams(params) + return new @@ -66,6 +77,12 @@ def __init__(self, name=None, index=None, params=None): self.index = index super().__init__(params) + def __getattribute__(self, name: str) -> Any: + # shortcut to parameter value for instance attribute. + # the class attribute is the full parameter instance. + attr = super().__getattribute__(name) + return attr.value if isinstance(attr, MFParam) else attr + def __str__(self): buffer = StringIO() self.write(buffer) @@ -102,7 +119,6 @@ def load(cls, f, **kwargs): elif key == "end": break elif found: - param = get_param(members, key) if param is not None: f.seek(pos) @@ -114,9 +130,9 @@ def load(cls, f, **kwargs): # and remove special handling here kwrgs["cwd"] = "" if ptype is MFRecord: - kwrgs["params"] = param.data + kwrgs["params"] = param.data.copy() if ptype is MFKeystring: - kwrgs["params"] = param.data + kwrgs["params"] = param.data.copy() params[key] = ptype.load(f, **kwrgs) return cls(name, index, params) @@ -137,3 +153,5 @@ class MFBlocks(UserDict): def __init__(self, blocks=None): super().__init__(blocks) + for key, block in self.items(): + setattr(self, key, block) diff --git a/flopy4/compound.py b/flopy4/compound.py index aa1622e..d98b49d 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -1,9 +1,8 @@ -from abc import abstractmethod, ABCMeta +from abc import abstractmethod from collections.abc import Iterable, Mapping from dataclasses import asdict from io import StringIO -from pprint import pformat -from typing import Any, List +from typing import Any, Dict, List from flopy4.param import MFParam, MFParams, MFReader from flopy4.scalar import MFScalar @@ -55,18 +54,17 @@ def __init__( default_value, ) - # def __repr__(self): - # return pformat(self.data) - - # def __str__(self): - # buffer = StringIO() - # self.write(buffer) - # return buffer.getvalue() - + @property + def params(self) -> MFParams: + """Component parameters.""" + return self.data + @property def value(self) -> Mapping[str, Any]: """Get component names/values.""" - return {k: s.value for k, s in self.data.items() if s.value is not None} + return { + k: s.value for k, s in self.data.items() if s.value is not None + } @value.setter def value(self, **kwargs): @@ -122,7 +120,7 @@ def __init__( @property def value(self) -> List[Any]: - return [s.value for s in self.data] + return [s.value for s in self.data.values()] @value.setter def value(self, value: Iterable[Any]): @@ -143,10 +141,10 @@ def load(cls, f, params, **kwargs) -> "MFRecord": return cls(MFRecord.parse(line, params, **kwargs), **kwargs) @staticmethod - def parse(line, params, **kwargs) -> List[MFScalar]: - loaded = [] + def parse(line, params, **kwargs) -> Dict[str, MFScalar]: + loaded = dict() - for param in params: + for param_name, param in params.items(): split = line.split() stype = type(param) words = len(param) @@ -155,14 +153,14 @@ def parse(line, params, **kwargs) -> List[MFScalar]: line = tail kwrgs = {**kwargs, **asdict(param)} with StringIO(head) as f: - loaded.append(stype.load(f, **kwrgs)) + loaded[param_name] = stype.load(f, **kwrgs) return loaded def write(self, f): f.write(f"{PAD}{self.name.upper()}") last = len(self) - 1 - for i, param in enumerate(self.data): + for i, param in enumerate(self.data.values()): param.write(f, newline=i == last) @@ -210,7 +208,7 @@ def __init__( @classmethod def load(cls, f, params, **kwargs) -> "MFKeystring": """Load the keystring from file.""" - loaded = [] + loaded = dict() while True: line = strip(f.readline()).lower() @@ -218,17 +216,17 @@ def load(cls, f, params, **kwargs) -> "MFKeystring": raise ValueError("Early EOF") if line == "\n": continue - + split = line.split() - first = split[0] + key = split[0] - if first == "end": + if key == "end": break - param = params.pop(first) + param = params.pop(key) kwrgs = {**kwargs, **asdict(param)} with StringIO(line) as ff: - loaded.append(type(param).load(ff, **kwrgs)) + loaded[key] = type(param).load(ff, **kwrgs) return cls(loaded, **kwargs) diff --git a/flopy4/constants.py b/flopy4/constants.py index 62df028..5682b04 100644 --- a/flopy4/constants.py +++ b/flopy4/constants.py @@ -38,4 +38,4 @@ class MFReader(Enum): def from_str(cls, value): for e in cls: if value.lower() == e.value: - return e \ No newline at end of file + return e diff --git a/flopy4/package.py b/flopy4/package.py index 41672c8..41bada2 100644 --- a/flopy4/package.py +++ b/flopy4/package.py @@ -1,7 +1,7 @@ from abc import ABCMeta from collections import UserDict -from itertools import groupby from io import StringIO +from itertools import groupby from typing import Any from flopy4.block import MFBlock, MFBlockMeta, MFBlocks @@ -10,7 +10,9 @@ def get_block(pkg_name, block_name, params): - return MFBlockMeta(f"{pkg_name.title()}{block_name.title()}Block", (MFBlock,), params)(params=params, name=block_name) + return MFBlockMeta( + f"{pkg_name.title()}{block_name.title()}Block", (MFBlock,), params + )(params=params, name=block_name) class MFPackageMeta(type): diff --git a/flopy4/param.py b/flopy4/param.py index c564863..7d3d2d8 100644 --- a/flopy4/param.py +++ b/flopy4/param.py @@ -159,11 +159,6 @@ def value(self) -> Optional[Any]: """Get the parameter's value, if loaded.""" pass - @abstractmethod - def value(self, value): - """Set the parameter's value.""" - pass - class MFParams(UserDict): """ @@ -176,15 +171,6 @@ def __init__(self, params=None): for key, param in self.items(): setattr(self, key, param) - def __getattribute__(self, name: str) -> Any: - # shortcut to parameter value for instance attribute. - # the class attribute is the full parameter instance. - attr = super().__getattribute__(name) - return attr.value if isinstance(attr, MFParam) else attr - def write(self, f): for param in self.values(): param.write(f) - - - diff --git a/flopy4/scalar.py b/flopy4/scalar.py index 6352a19..2d00dda 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -50,9 +50,6 @@ def __init__( default_value, ) - # def __repr__(self): - # return f"{self.name}: {self.value}" - @property def value(self) -> T: return self._value diff --git a/flopy4/utils.py b/flopy4/utils.py index d80f7cf..c1cb167 100644 --- a/flopy4/utils.py +++ b/flopy4/utils.py @@ -1,3 +1,9 @@ +def find_upper(s): + for i in range(len(s)): + if s[i].isupper(): + yield i + + def strip(line): """ Remove comments and replace commas from input text diff --git a/test/test_block.py b/test/test_block.py index a41dff3..92482bb 100644 --- a/test/test_block.py +++ b/test/test_block.py @@ -2,7 +2,7 @@ from flopy4.array import MFArray from flopy4.block import MFBlock -from flopy4.compound import MFRecord, MFKeystring +from flopy4.compound import MFKeystring, MFRecord from flopy4.scalar import MFDouble, MFFilename, MFInteger, MFKeyword, MFString @@ -102,12 +102,12 @@ def test_load_write(tmp_path): assert isinstance(TestBlock.r, MFRecord) assert TestBlock.r.name == "r" assert len(TestBlock.r.params) == 3 - assert isinstance(TestBlock.r.params[0], MFKeyword) - assert isinstance(TestBlock.r.params[1], MFInteger) - assert isinstance(TestBlock.r.params[2], MFDouble) + assert isinstance(TestBlock.r.params["rk"], MFKeyword) + assert isinstance(TestBlock.r.params["ri"], MFInteger) + assert isinstance(TestBlock.r.params["rd"], MFDouble) - assert isinstance(block.r, tuple) - assert block.r == (True, 2, 2.0) + assert isinstance(block.r, list) + assert block.r == [True, 2, 2.0] # test block write fpth2 = tmp_path / f"{name}2.txt" @@ -149,7 +149,7 @@ def test_load_write_indexed(tmp_path): f.write(" FIRST\n") f.write(" FREQUENCY 2\n") f.write(f"END {name.upper()}\n") - + with open(fpth, "r") as f: period1 = IndexedBlock.load(f) period2 = IndexedBlock.load(f) @@ -163,4 +163,4 @@ def test_load_write_indexed(tmp_path): assert IndexedBlock.ks.block == name # instance attribute as shortcut to param value - assert period1.ks == ["first"] \ No newline at end of file + assert period1.ks == ["first"] diff --git a/test/test_compound.py b/test/test_compound.py deleted file mode 100644 index 8b13789..0000000 --- a/test/test_compound.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/test/test_package.py b/test/test_package.py index e88759c..978cd7a 100644 --- a/test/test_package.py +++ b/test/test_package.py @@ -72,27 +72,27 @@ def test_member_blocks(): assert isinstance(block, MFBlock) assert len(block.params) == 5 - k = block.k + k = block["k"] assert isinstance(k, MFKeyword) assert k.description == "keyword" assert k.optional - i = block.i + i = block["i"] assert isinstance(i, MFInteger) assert i.description == "int" assert i.optional - d = block.d + d = block["d"] assert isinstance(d, MFDouble) assert d.description == "double" assert d.optional - s = block.s + s = block["s"] assert isinstance(s, MFString) assert s.description == "string" assert not s.optional - f = block.f + f = block["f"] assert isinstance(f, MFFilename) assert f.description == "filename" assert not f.optional @@ -101,7 +101,7 @@ def test_member_blocks(): assert isinstance(block, MFBlock) assert len(block.params) == 1 - a = block.a + a = block["a"] assert isinstance(a, MFArray) assert a.description == "array" From e86d368dad8a5fbfe082f4c6c1b5b574f04965bb Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 24 Jul 2024 00:54:48 -0400 Subject: [PATCH 05/11] fix --- flopy4/block.py | 21 ++++++++++++--------- flopy4/compound.py | 2 ++ flopy4/scalar.py | 2 ++ test/test_block.py | 17 +++++++++-------- test/test_package.py | 1 - 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/flopy4/block.py b/flopy4/block.py index e9aeae7..da4a9f6 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -15,12 +15,15 @@ def single_keystring(members): return len(params) == 1 and isinstance(params[0], MFKeystring) -def get_param(members, key): - return ( - list(members.values())[0] - if single_keystring(members) - else members.get(key) - ) +def get_param(members, key, block): + ks = [m for m in members.values() if isinstance(m, MFKeystring)] + if len(ks) == 1: + param = ks[0] + else: + param = members.get(key) + param.name = key + param.block = block + return param class MFBlockMeta(type): @@ -119,10 +122,10 @@ def load(cls, f, **kwargs): elif key == "end": break elif found: - param = get_param(members, key) + param = get_param(members, key, name) if param is not None: f.seek(pos) - spec = asdict(param.with_name(key).with_block(name)) + spec = asdict(param) kwrgs = {**kwargs, **spec} ptype = type(param) if ptype is MFArray: @@ -133,7 +136,7 @@ def load(cls, f, **kwargs): kwrgs["params"] = param.data.copy() if ptype is MFKeystring: kwrgs["params"] = param.data.copy() - params[key] = ptype.load(f, **kwrgs) + params[param.name] = ptype.load(f, **kwrgs) return cls(name, index, params) diff --git a/flopy4/compound.py b/flopy4/compound.py index d98b49d..a791c1c 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -211,6 +211,7 @@ def load(cls, f, params, **kwargs) -> "MFKeystring": loaded = dict() while True: + pos = f.tell() line = strip(f.readline()).lower() if line == "": raise ValueError("Early EOF") @@ -221,6 +222,7 @@ def load(cls, f, params, **kwargs) -> "MFKeystring": key = split[0] if key == "end": + f.seek(pos) break param = params.pop(key) diff --git a/flopy4/scalar.py b/flopy4/scalar.py index 2d00dda..c9496da 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -1,11 +1,13 @@ from abc import abstractmethod from pathlib import Path +from typing import TypeVar from flopy4.constants import MFFileInout from flopy4.param import MFParam, MFReader from flopy4.utils import strip PAD = " " +T = TypeVar("T") class MFScalar[T](MFParam): diff --git a/test/test_block.py b/test/test_block.py index 92482bb..9e47b02 100644 --- a/test/test_block.py +++ b/test/test_block.py @@ -138,17 +138,17 @@ class IndexedBlock(MFBlock): def test_load_write_indexed(tmp_path): - name = "indexed" - fpth = tmp_path / f"{name}.txt" + block_name = "indexed" + fpth = tmp_path / f"{block_name}.txt" with open(fpth, "w") as f: - f.write(f"BEGIN {name.upper()} 1\n") + f.write(f"BEGIN {block_name.upper()} 1\n") f.write(" FIRST\n") - f.write(f"END {name.upper()}\n") + f.write(f"END {block_name.upper()}\n") f.write("\n") - f.write(f"BEGIN {name.upper()} 2\n") + f.write(f"BEGIN {block_name.upper()} 2\n") f.write(" FIRST\n") f.write(" FREQUENCY 2\n") - f.write(f"END {name.upper()}\n") + f.write(f"END {block_name.upper()}\n") with open(fpth, "r") as f: period1 = IndexedBlock.load(f) @@ -160,7 +160,8 @@ def test_load_write_indexed(tmp_path): # class attributes as param specification assert isinstance(IndexedBlock.ks, MFKeystring) assert IndexedBlock.ks.name == "ks" - assert IndexedBlock.ks.block == name + assert IndexedBlock.ks.block == block_name # instance attribute as shortcut to param value - assert period1.ks == ["first"] + assert period1.ks == {"first": True} + assert period2.ks == {"first": True, "frequency": 2} diff --git a/test/test_package.py b/test/test_package.py index 978cd7a..810a0ca 100644 --- a/test/test_package.py +++ b/test/test_package.py @@ -117,7 +117,6 @@ def test_load_write(tmp_path): f.write(" D 1.0\n") f.write(" S value\n") f.write(f" F FILEIN {fpth}\n") - f.write(f" A\nINTERNAL\n{array}\n") f.write("END OPTIONS\n") f.write("\n") f.write("BEGIN PACKAGEDATA\n") From 73d88c41eee60ea4afdf486ebd6459b814801dc2 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 24 Jul 2024 01:11:21 -0400 Subject: [PATCH 06/11] no generic type hints --- flopy4/block.py | 1 + flopy4/param.py | 3 ++- flopy4/scalar.py | 18 ++++++++---------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/flopy4/block.py b/flopy4/block.py index da4a9f6..38f237b 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -2,6 +2,7 @@ from collections import UserDict from dataclasses import asdict from io import StringIO +from pprint import pformat from typing import Any from flopy4.array import MFArray diff --git a/flopy4/param.py b/flopy4/param.py index 7d3d2d8..0f4fc62 100644 --- a/flopy4/param.py +++ b/flopy4/param.py @@ -3,6 +3,7 @@ from collections import UserDict from dataclasses import dataclass, fields from io import StringIO +from pprint import pformat from typing import Any, Optional, Tuple from flopy4.constants import MFReader @@ -152,7 +153,7 @@ def __str__(self): buffer = StringIO() self.write(buffer) return buffer.getvalue() - + @property @abstractmethod def value(self) -> Optional[Any]: diff --git a/flopy4/scalar.py b/flopy4/scalar.py index c9496da..203d6f0 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -1,16 +1,14 @@ from abc import abstractmethod from pathlib import Path -from typing import TypeVar from flopy4.constants import MFFileInout from flopy4.param import MFParam, MFReader from flopy4.utils import strip PAD = " " -T = TypeVar("T") -class MFScalar[T](MFParam): +class MFScalar(MFParam): @abstractmethod def __init__( self, @@ -53,15 +51,15 @@ def __init__( ) @property - def value(self) -> T: + def value(self): return self._value @value.setter - def value(self, value: T): + def value(self, value): self._value = value -class MFKeyword(MFScalar[bool]): +class MFKeyword(MFScalar): def __init__( self, value=None, @@ -124,7 +122,7 @@ def write(self, f, newline=True): ) -class MFInteger(MFScalar[int]): +class MFInteger(MFScalar): def __init__( self, value=None, @@ -188,7 +186,7 @@ def write(self, f, newline=True): ) -class MFDouble(MFScalar[float]): +class MFDouble(MFScalar): def __init__( self, value=None, @@ -252,7 +250,7 @@ def write(self, f, newline=True): ) -class MFString(MFScalar[str]): +class MFString(MFScalar): def __init__( self, value=None, @@ -316,7 +314,7 @@ def write(self, f, newline=True): ) -class MFFilename(MFScalar[Path]): +class MFFilename(MFScalar): def __init__( self, inout=MFFileInout.filein, From ec4b828324befe28cee800dc1974e7f1f9c15993 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 24 Jul 2024 01:12:59 -0400 Subject: [PATCH 07/11] ruff --- flopy4/block.py | 1 - flopy4/param.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/flopy4/block.py b/flopy4/block.py index 38f237b..da4a9f6 100644 --- a/flopy4/block.py +++ b/flopy4/block.py @@ -2,7 +2,6 @@ from collections import UserDict from dataclasses import asdict from io import StringIO -from pprint import pformat from typing import Any from flopy4.array import MFArray diff --git a/flopy4/param.py b/flopy4/param.py index 0f4fc62..7d3d2d8 100644 --- a/flopy4/param.py +++ b/flopy4/param.py @@ -3,7 +3,6 @@ from collections import UserDict from dataclasses import dataclass, fields from io import StringIO -from pprint import pformat from typing import Any, Optional, Tuple from flopy4.constants import MFReader @@ -153,7 +152,7 @@ def __str__(self): buffer = StringIO() self.write(buffer) return buffer.getvalue() - + @property @abstractmethod def value(self) -> Optional[Any]: From fa3038e00032ade2b9bee32ad65fc3fd69f70dfe Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 24 Jul 2024 01:17:06 -0400 Subject: [PATCH 08/11] fix SyntaxError: f-string expression part cannot include a backslash --- flopy4/scalar.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/flopy4/scalar.py b/flopy4/scalar.py index 203d6f0..cebc575 100644 --- a/flopy4/scalar.py +++ b/flopy4/scalar.py @@ -118,7 +118,7 @@ def load(cls, f, **kwargs) -> "MFKeyword": def write(self, f, newline=True): if self.value: f.write( - f"{PAD}" f"{self.name.upper()}" f"{'\n' if newline else ''}" + f"{PAD}" f"{self.name.upper()}" + ("\n" if newline else "") ) @@ -181,8 +181,7 @@ def write(self, f, newline=True): f.write( f"{PAD}" f"{self.name.upper()} " - f"{self.value}" - f"{'\n' if newline else ''}" + f"{self.value}" + ("\n" if newline else "") ) @@ -245,8 +244,7 @@ def write(self, f, newline=True): f.write( f"{PAD}" f"{self.name.upper()} " - f"{self.value}" - f"{'\n' if newline else ''}" + f"{self.value}" + ("\n" if newline else "") ) @@ -309,8 +307,7 @@ def write(self, f, newline=True): f.write( f"{PAD}" f"{self.name.upper()} " - f"{self.value}" - f"{'\n' if newline else ''}" + f"{self.value}" + ("\n" if newline else "") ) @@ -385,6 +382,5 @@ def write(self, f, newline=True): f.write( f"{PAD}{self.name.upper()} " f"{self.inout.value.upper()} " - f"{self.value}" - f"{'\n' if newline else ''}" + f"{self.value}" + ("\n" if newline else "") ) From 6cb000978cc372c81aca0a51ac666ecc59075c4e Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 24 Jul 2024 01:19:09 -0400 Subject: [PATCH 09/11] remove old mfrecord value getter/setter --- flopy4/compound.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/flopy4/compound.py b/flopy4/compound.py index a791c1c..2191d5a 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -118,16 +118,6 @@ def __init__( default_value=default_value, ) - @property - def value(self) -> List[Any]: - return [s.value for s in self.data.values()] - - @value.setter - def value(self, value: Iterable[Any]): - assert len(value) == len(self.data) - for i in range(len(self.data)): - self.data[i].value = value[i] - @classmethod def load(cls, f, params, **kwargs) -> "MFRecord": line = strip(f.readline()).lower() From 093705ff19bb085aa00c601e6423f22af5af7eb7 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 24 Jul 2024 01:21:04 -0400 Subject: [PATCH 10/11] ruff --- flopy4/compound.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flopy4/compound.py b/flopy4/compound.py index 2191d5a..b80d3a0 100644 --- a/flopy4/compound.py +++ b/flopy4/compound.py @@ -1,8 +1,8 @@ from abc import abstractmethod -from collections.abc import Iterable, Mapping +from collections.abc import Mapping from dataclasses import asdict from io import StringIO -from typing import Any, Dict, List +from typing import Any, Dict from flopy4.param import MFParam, MFParams, MFReader from flopy4.scalar import MFScalar From c9e907aaefa2a0b6e42ad07a1d91a7847bb4d2f7 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 24 Jul 2024 01:24:22 -0400 Subject: [PATCH 11/11] fix test --- test/test_block.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_block.py b/test/test_block.py index 9e47b02..c99df6d 100644 --- a/test/test_block.py +++ b/test/test_block.py @@ -106,8 +106,8 @@ def test_load_write(tmp_path): assert isinstance(TestBlock.r.params["ri"], MFInteger) assert isinstance(TestBlock.r.params["rd"], MFDouble) - assert isinstance(block.r, list) - assert block.r == [True, 2, 2.0] + assert isinstance(block.r, dict) + assert block.r == {"rd": 2.0, "ri": 2, "rk": True} # test block write fpth2 = tmp_path / f"{name}2.txt"