Skip to content

Commit

Permalink
initial package impl
Browse files Browse the repository at this point in the history
  • Loading branch information
wpbonelli committed Jul 19, 2024
1 parent 2509c9c commit 0d04f3e
Show file tree
Hide file tree
Showing 8 changed files with 374 additions and 124 deletions.
89 changes: 46 additions & 43 deletions flopy4/block.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,60 @@
from collections.abc import MutableMapping
from typing import Any, Dict
from abc import ABCMeta
from collections import UserDict
from dataclasses import asdict
from typing import Any

from flopy4.parameter import MFParameter, MFParameters
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}")
class MFBlockMeta(type):
def __new__(cls, clsname, bases, attrs):
new = super().__new__(cls, clsname, bases, attrs)
if clsname == "MFBlock":
return new

return {
k: v
for k, v in cls.__dict__.items()
if issubclass(type(v), MFParameter)
}
# add parameter specification as class attribute
new.params = MFParameters(
{
k: v
for k, v in attrs.items()
if issubclass(type(v), MFParameter)
}
)
return new


class MFBlock(MutableMapping):
def __init__(self, name=None, index=None, *args, **kwargs):
class MFBlockMappingMeta(MFBlockMeta, ABCMeta):
# http://www.phyast.pitt.edu/~micheles/python/metatype.html
pass


class MFBlock(MFParameters, metaclass=MFBlockMappingMeta):
def __init__(self, name=None, index=None, params=None):
self.name = name
self.index = index
self.params = MFParameters()
self.update(dict(*args, **kwargs))
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)
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
return attr.value if isinstance(attr, MFParameter) else attr

def __delitem__(self, key):
del self.params[key]

def __iter__(self):
return iter(self.params)

def __len__(self):
return len(self.params)
@property
def params(self) -> MFParameters:
"""Block parameters."""
return self.data

@classmethod
def load(cls, f, strict=False):
def load(cls, f):
name = None
index = None
found = False
params = dict()
members = get_member_params(cls)
members = cls.params

while True:
pos = f.tell()
Expand All @@ -70,13 +69,14 @@ def load(cls, f, strict=False):
elif key == "end":
break
elif found:
if not strict or key in members:
param = members.get(key)
if param is not None:
f.seek(pos)
param = members[key]
param.block = name
params[key] = type(param).load(f, spec=param)
params[key] = type(param).load(
f, **asdict(param.with_name(key).with_block(name))
)

return cls(name, index, **params)
return cls(name, index, params)

def write(self, f):
index = self.index if self.index is not None else ""
Expand All @@ -89,5 +89,8 @@ def write(self, f):
f.write(end)


class MFBlocks:
pass
class MFBlocks(UserDict):
"""Mapping of block names to blocks."""

def __init__(self, blocks=None):
super().__init__(blocks)
2 changes: 1 addition & 1 deletion flopy4/dfn.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def get(self, key):

return self._dfns[key]

#def get(self, component, subcomponent):
# def get(self, component, subcomponent):
# key = f"{component.lower()}-{subcomponent.lower()}"
# if key not in self._dfns:
# raise ValueError("DFN does not exist in container")
Expand Down
113 changes: 113 additions & 0 deletions flopy4/package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from abc import ABCMeta
from collections import UserDict
from itertools import groupby
from typing import Any

from flopy4.block import MFBlock, MFBlockMeta, MFBlocks
from flopy4.parameter import MFParameter, MFParameters
from flopy4.utils import strip


def get_block(clsname, params):
return MFBlockMeta(clsname, (MFBlock,), params)(params=params)


class MFPackageMeta(type):
def __new__(cls, clsname, bases, attrs):
new = super().__new__(cls, clsname, bases, attrs)
if clsname == "MFPackage":
return new

# add parameter and block specification as class
# attributes. subclass mfblock dynamically based
# on each block parameter specification.
pkg_name = clsname.replace("Package", "")
params = MFParameters(
{
k: v.with_name(k)
for k, v in attrs.items()
if issubclass(type(v), MFParameter)
}
)
new.params = params
new.blocks = MFBlocks(
{
block_name: get_block(
clsname=f"{pkg_name.title()}{block_name.title()}Block",
params={p.name: p for p in block},
)
for block_name, block in groupby(
params.values(), lambda p: p.block
)
}
)
return new


class MFPackageMappingMeta(MFPackageMeta, ABCMeta):
# http://www.phyast.pitt.edu/~micheles/python/metatype.html
pass


class MFPackage(UserDict, metaclass=MFPackageMappingMeta):
"""
MF6 package base class.
TODO: reimplement with `ChainMap`?
"""

def __getattribute__(self, name: str) -> Any:
value = super().__getattribute__(name)
if name == "data":
return value

# shortcut to parameter value for instance attribute.
# the class attribute is the full parameter instance.
params = {
param_name: param
for block in self.data.values()
for param_name, param in block.items()
}
param = params.get(name)
return params[name].value if param is not None else value

@property
def params(self) -> MFParameters:
"""Package parameters."""
return MFParameters(
{
name: param
for block in self.data
for name, param in block.items()
}
)

@classmethod
def load(cls, f):
"""Load the package from file."""
blocks = dict()
members = cls.blocks

while True:
pos = f.tell()
line = f.readline()
if line == "":
break
line = strip(line).lower()
words = line.split()
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)

pkg = cls()
pkg.update(blocks)
return pkg

def write(self, f):
"""Write the package to file."""
for block in self.data.values():
block.write(f)
79 changes: 41 additions & 38 deletions flopy4/parameter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from abc import abstractmethod
from collections.abc import MutableMapping
from collections import UserDict
from dataclasses import dataclass, fields
from enum import Enum
from typing import Any, Optional
Expand Down Expand Up @@ -44,29 +44,50 @@ class MFParamSpec:

@classmethod
def fields(cls):
"""
Get the MF6 input parameter field specification.
These uniquely describe the MF6 input parameter.
Notes
-----
This is equivalent to `dataclasses.fields(MFParamSpec)`.
"""
return fields(cls)

@classmethod
def load(cls, f) -> "MFParamSpec":
"""
Load an MF6 input input parameter specification
from a definition file.
"""
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":
break
words = line.strip().lower().split()
key = words[0]
val = " ".join(words[1:])
# todo dynamically load properties and
# filter by type instead of hardcoding
kw_fields = [f.name for f in cls.fields() if f.type is bool]
if key in kw_fields:
if key in keywords:
spec[key] = val == "true"
elif key == "reader":
spec[key] = MFReader.from_str(val)
else:
spec[key] = val
return cls(**spec)

def with_name(self, name) -> "MFParamSpec":
"""Set the parameter name and return the parameter."""
self.name = name
return self

def with_block(self, block) -> "MFParamSpec":
"""Set the parameter block and return the parameter."""
self.block = block
return self


class MFParameter(MFParamSpec):
"""
Expand All @@ -77,20 +98,22 @@ class MFParameter(MFParamSpec):
as a data access layer by which higher components (blocks,
packages, etc) can read/write parameters. The former is a
developer task (though it may be automated as classes are
generated from DFNs) while the latter are user-facing APIs.
generated from DFNs) while the latter happens at runtime,
but both APIs are user-facing; the user can first inspect
a package's specification via class attributes, then load
an input file and inspect the package data.
Notes
-----
Specification attributes are set at import time. A parent
block, when defining parameters as class attributes, will
supply a description, whether the parameter is mandatory,
and other information comprising the input specification.
block or package defines parameters as class attributes,
including a description, whether the parameter is optional,
and other information specifying the parameter.
The parameter's value is an instance attribute that is set
at load time. The parameter's parent block will introspect
its constituent parameters, then load each parameter value
from the input file and assign an eponymous attribute with
a value property. This is akin to "hydrating" a definition
at load time. The parameter's parent component introspects
its constituent parameters then loads each parameter value
from the input file. This is like "hydrating" a definition
from a data store as in single-page web applications (e.g.
React, Vue) or ORM frameworks (Django).
"""
Expand Down Expand Up @@ -130,37 +153,17 @@ def __init__(
default_value=default_value,
)

def __get__(self, instance, _):
if instance is None:
return self
else:
return self.value

@property
@abstractmethod
def value(self) -> Optional[Any]:
"""Get the parameter's value, if loaded."""
pass


class MFParameters(MutableMapping):
def __init__(self, *args, **kwargs):
self.params = dict()
self.update(dict(*args, **kwargs))
class MFParameters(UserDict):
"""Mapping of parameter names to parameters."""

def __init__(self, params=None):
super().__init__(params)
for key, param in self.items():
setattr(self, key, param)

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)
Loading

0 comments on commit 0d04f3e

Please sign in to comment.