From 86ea8e36df29d22c05b79c0c2d3aec65a05e80bc Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 18 Jun 2026 07:41:54 -0700 Subject: [PATCH 1/8] refactor: consolidate component/data model --- autotest/test_interface.py | 38 +- modflowapi/extensions/advpaks.py | 108 +--- modflowapi/extensions/apiexchange.py | 8 +- modflowapi/extensions/apimodel.py | 59 +- modflowapi/extensions/apisimulation.py | 75 +-- modflowapi/extensions/data.py | 779 ++++++------------------ modflowapi/extensions/datamodel.py | 47 +- modflowapi/extensions/pakbase.py | 810 ++++++++----------------- 8 files changed, 513 insertions(+), 1411 deletions(-) diff --git a/autotest/test_interface.py b/autotest/test_interface.py index 3562761..0061ac4 100644 --- a/autotest/test_interface.py +++ b/autotest/test_interface.py @@ -7,7 +7,7 @@ from modflow_devtools.misc import set_dir from modflowapi import Callbacks, ModflowApi, run_simulation -from modflowapi.extensions.pakbase import AdvancedPackage, ArrayPackage, ListPackage +from modflowapi.extensions.pakbase import Package data_pth = Path("../docs/examples/data") pytestmark = pytest.mark.extensions @@ -71,16 +71,16 @@ def callback(sim, step): raise AssertionError("ApiModel has advanced prior to initialization callback") dis = model.dis - if not isinstance(dis, ArrayPackage): - raise TypeError("DIS package has incorrect base class type") + if "idomain" not in dis.variable_names: + raise TypeError("DIS package should have grid array variables") wel = model.wel - if not isinstance(wel, ListPackage): - raise TypeError("WEL package has incorrect base class type") + if wel.stress_period_data is None: + raise TypeError("WEL package should have stress period data") gnc = model.gnc - if not isinstance(gnc, AdvancedPackage): - raise TypeError("GNC package has incorrect base class type") + if not isinstance(gnc, Package): + raise TypeError("GNC package has incorrect type") rch = model.rch if len(rch) != 2: @@ -158,16 +158,16 @@ def callback(sim, step): raise AssertionError("ApiModel has advanced prior to initialization callback") dis = model.dis - if not isinstance(dis, ArrayPackage): - raise TypeError("DIS package has incorrect base class type") + if "idomain" not in dis.variable_names: + raise TypeError("DIS package should have grid array variables") chd = model.chd_left - if not isinstance(chd, ListPackage): - raise TypeError("CHD package has incorrect base class type") + if chd.stress_period_data is None: + raise TypeError("CHD package should have stress period data") hfb = model.hfb - if not isinstance(hfb, AdvancedPackage): - raise TypeError("HFB package has incorrect base class type") + if not isinstance(hfb, Package): + raise TypeError("HFB package has incorrect type") chd = model.chd if len(chd) != 2: @@ -236,16 +236,16 @@ def callback(sim, step): raise AssertionError("ApiModel has advanced prior to initialization callback") dis = model.dis - if not isinstance(dis, ArrayPackage): - raise TypeError("DIS package has incorrect base class type") + if "idomain" not in dis.variable_names: + raise TypeError("DIS package should have grid array variables") rch = model.rch - if not isinstance(rch, ListPackage): - raise TypeError("RCH package has incorrect base class type") + if rch.stress_period_data is None: + raise TypeError("RCH package should have stress period data") mvr = model.mvr - if not isinstance(mvr, AdvancedPackage): - raise TypeError("MVR package has incorrect base class type") + if not isinstance(mvr, Package): + raise TypeError("MVR package has incorrect type") top = dis.top.values if not isinstance(top, np.ndarray): diff --git a/modflowapi/extensions/advpaks.py b/modflowapi/extensions/advpaks.py index f0c6ae0..4be1a5e 100644 --- a/modflowapi/extensions/advpaks.py +++ b/modflowapi/extensions/advpaks.py @@ -1,107 +1,5 @@ -import numpy as np - -from .data import ListInput from .pakbase import AdvancedPackage - -class SfrPakage(AdvancedPackage): - """ - Container for SFR and SFR like packages - - Parameters - ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "SFR" - pkg_name : str - package name (in the mf6 variables) - sim_package : bool - boolean flag for simulation level packages. Ex. TDIS, IMS - """ - - def __init__(self, model, pkg_type, pkg_name, sim_package=False): - super().__init__(model, pkg_type, pkg_name, sim_package) - - self._diversion_var_arrs = [] - self._set_advanced_variable_addrs("diversions", "_diversion_var_addrs") - self._diversion_vars = ListInput(self, self._diversion_var_arrs, spd=False) - - @property - def diversions(self): - return self._diversion_vars - - @diversions.setter - def diversions(self, recarray): - """ - Setter object to update the diversions data - - """ - if isinstance(recarray, np.recarray): - self._diversion_vars.values = recarray - elif isinstance(recarray, ListInput): - self._diversion_vars.values = recarray.values - elif recarray is None: - self._diversion_vars.values = recarray - else: - raise TypeError(f"{type(recarray)} is not a supported diversions type") - - -class LakPackage(AdvancedPackage): - """ - Container for LAK and LAK like packages - - Parameters - ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "LAK" - pkg_name : str - package name (in the mf6 variables) - sim_package : bool - boolean flag for simulation level packages. Ex. TDIS, IMS - """ - - def __init__(self, model, pkg_type, pkg_name, sim_package=False): - super().__init__(model, pkg_type, pkg_name, sim_package) - - -class MawPackage(AdvancedPackage): - """ - Container for MAW and MAW like packages - - Parameters - ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "MAW" - pkg_name : str - package name (in the mf6 variables) - sim_package : bool - boolean flag for simulation level packages. Ex. TDIS, IMS - """ - - def __init__(self, model, pkg_type, pkg_name, sim_package=False): - super().__init__(model, pkg_type, pkg_name, sim_package) - - -class UzfPackage(AdvancedPackage): - """ - Container for UZF and UZF like packages - - Parameters - ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "UZF" - pkg_name : str - package name (in the mf6 variables) - sim_package : bool - boolean flag for simulation level packages. Ex. TDIS, IMS - """ - - def __init__(self, model, pkg_type, pkg_name, sim_package=False): - super().__init__(model, pkg_type, pkg_name, sim_package) +# Backward compatibility alias; SFR diversions are now handled generically +# by Package._build_advanced_inputs via adv_pkgvars["sfr"]["diversions"]. +SfrPackage = AdvancedPackage diff --git a/modflowapi/extensions/apiexchange.py b/modflowapi/extensions/apiexchange.py index de9c3b3..9b71ced 100644 --- a/modflowapi/extensions/apiexchange.py +++ b/modflowapi/extensions/apiexchange.py @@ -1,5 +1,4 @@ from .apimodel import ApiMbase -from .pakbase import ListPackage class ApiExchange(ApiMbase): @@ -16,9 +15,4 @@ class ApiExchange(ApiMbase): """ def __init__(self, mf6, name): - pkg_types = { - "gwf-gwf": ListPackage, - "gwt-gwt": ListPackage, - "gwe-gwe": ListPackage, - } - super().__init__(mf6, name, pkg_types) + super().__init__(mf6, name) diff --git a/modflowapi/extensions/apimodel.py b/modflowapi/extensions/apimodel.py index 7936d84..31bd704 100644 --- a/modflowapi/extensions/apimodel.py +++ b/modflowapi/extensions/apimodel.py @@ -1,7 +1,9 @@ +import math + import numpy as np -from .datamodel import get_package_type, gridshape -from .pakbase import AdvancedPackage, ArrayPackage, ListPackage, package_factory +from .datamodel import gridshape +from .pakbase import Package class ApiMbase: @@ -33,7 +35,7 @@ def package_list(self): """ Returns a list of package objects for the model """ - return [package for _, package in self.package_dict.items()] + return list(self.package_dict.values()) @property def package_names(self): @@ -44,7 +46,7 @@ def package_names(self): @property def package_types(self): - return list(set([package.pkg_type for package in self.package_list])) + return list({p.pkg_type for p in self.package_list}) def _set_package_names(self): """ @@ -56,11 +58,9 @@ def _set_package_names(self): if addr.endswith("PACKAGE_TYPE") and tmp[0] == self.name: pak_types[tmp[1]] = self.mf6.get_value(addr)[0] elif tmp[0] == self.name and len(tmp) == 2: - if tmp[0].startswith("GWF-GWF"): - pak_types[tmp[0]] = "GWF-GWF" - pak_types.pop("dis", None) - elif tmp[0].startswith("GWT-GWT"): - pak_types[tmp[0]] = "GWT-GWT" + parts = tmp[0].rsplit("_", 1)[0].split("-") + if len(parts) == 2 and parts[0] == parts[1]: + pak_types[tmp[0]] = tmp[0].rsplit("_", 1)[0] pak_types.pop("dis", None) self._pak_type = list(pak_types.values()) @@ -72,26 +72,17 @@ def _create_package_list(self): """ for ix, pkg_name in enumerate(self._pkg_names): pkg_type = self._pak_type[ix].lower() - if self._pkg_types is None: - basepackage = get_package_type(pkg_type) + if self._pkg_types is not None and pkg_type in self._pkg_types: + pkg_cls = self._pkg_types[pkg_type] else: - if pkg_type in self._pkg_types: - basepackage = self._pkg_types[pkg_type] - else: - basepackage = AdvancedPackage + pkg_cls = Package - package = package_factory(pkg_type, basepackage) - adj_pkg_name = "".join(pkg_type.split("-")) - - if adj_pkg_name.lower() in ("gwfgwf", "gwtgwt"): - adj_pkg_name = "" - else: - adj_pkg_name = pkg_name + adj_pkg_name = "" if "-" in pkg_type else pkg_name - package = package(basepackage, self, pkg_type, adj_pkg_name) + package = pkg_cls(self, pkg_type, adj_pkg_name) self.package_dict[pkg_name.lower()] = package - def get_package(self, pkg_name) -> ListPackage or ArrayPackage or AdvancedPackage: + def get_package(self, pkg_name) -> "Package": """ Method to get a package @@ -160,16 +151,9 @@ def __repr__(self): else: pass - s += "Packages accessible include: \n" - for typ, baseobj in [ - ("ArrayPackage", ArrayPackage), - ("ListPackage", ListPackage), - ("AdvancedPackage", AdvancedPackage), - ]: - s += f" {typ} objects:\n" - for name, obj in self.package_dict.items(): - if isinstance(obj, baseobj): - s += f" {name}: {type(obj)}\n" + s += "Packages accessible include:\n" + for name, pkg in self.package_dict.items(): + s += f" {name}: {pkg.pkg_type.upper()}\n" return s @@ -263,8 +247,8 @@ def shape(self): """ Returns a tuple of the model shape """ - ivn = self.mf6.get_input_var_names() if self._shape is None: + ivn = self.mf6.get_input_var_names() shape_vars = gridshape[self.dis_type] shape = [] for var in shape_vars: @@ -283,10 +267,7 @@ def size(self): Returns the number of nodes in the model """ if self._size is None: - size = 1 - for dim in self.shape: - size *= dim - self._size = size + self._size = math.prod(self.shape) return self._size @property diff --git a/modflowapi/extensions/apisimulation.py b/modflowapi/extensions/apisimulation.py index f9c98f5..e128330 100644 --- a/modflowapi/extensions/apisimulation.py +++ b/modflowapi/extensions/apisimulation.py @@ -2,7 +2,7 @@ from .apiexchange import ApiExchange from .apimodel import ApiMbase, ApiModel -from .pakbase import ApiSlnPackage, ListPackage, ScalarPackage, package_factory +from .pakbase import ApiSlnPackage, Package class ApiSimulation: @@ -35,9 +35,6 @@ def __init__(self, mf6, models, solutions, exchanges, tdis, ats): self.tdis = tdis self.ats = ats - self._ats_active = True - if ats is None: - self._ats_active = False def __getattr__(self, item): """ @@ -61,7 +58,7 @@ def __repr__(self): if self._exchanges: s += "\tExchanges include:\n" for name, exchange in self._exchanges.items(): - f"\t\t{name}: {type(exchange)}\n" + s += f"\t\t{name}: {type(exchange)}\n" return s @@ -71,7 +68,7 @@ def ats_active(self): Returns a boolean to indicate if the ATS package is used in this simulation. """ - return self._ats_active + return self.ats is not None @property def ats_period(self): @@ -133,9 +130,7 @@ def sln(self): """ if len(self._solutions) > 1: return list(self._solutions.values()) - else: - for sln in self._solutions.values(): - return sln + return next(iter(self._solutions.values())) @property def model_names(self): @@ -149,15 +144,14 @@ def exchange_names(self): """ Returns a list of exchange GWF-GWF names """ - if self._exchanges.keys(): - return list(self._exchanges.keys()) + return list(self._exchanges.keys()) @property def models(self): """ Returns a list of ApiModel objects associated with the simulation """ - return [v for _, v in self._models.items()] + return list(self._models.values()) @property def iteration(self): @@ -256,8 +250,7 @@ def get_exchange(self, exchange_name=None): raise AssertionError("No exchanges are present in this simulation") if exchange_name is None: - for _, exg in self._exchanges: - return exg + return next(iter(self._exchanges.values())) else: if exchange_name in self._exchanges: @@ -283,25 +276,7 @@ def load(mf6): id_var_addr = mf6.get_var_address("ID", name) if name.startswith("SLN"): continue - elif ( - name.startswith("GWFIM") - or name.startswith("GWTIM") - or name.startswith("GWEIM") - or name.startswith("PRTIM") - or name.startswith("CHFIM") - or name.startswith("OLFIM") - or name.startswith("SWFIM") - ): - continue - elif ( - name.startswith("GWFCON") - or name.startswith("GWTCON") - or name.startswith("GWECON") - or name.startswith("PRTCON") - or name.startswith("CHFCON") - or name.startswith("OLFCON") - or name.startswith("SWFCON") - ): + elif name[3:5] in ("IM", "CO"): continue if id_var_addr not in variables: continue @@ -321,22 +296,14 @@ def load(mf6): id_var_addr = mf6.get_var_address("ID", name) if name.lower() in models or name == "TDIS": continue - if ( - name.startswith("GWFIM") - or name.startswith("GWTIM") - or name.startswith("GWEIM") - or name.startswith("PRTIM") - or name.startswith("CHFIM") - or name.startswith("OLFIM") - or name.startswith("SWFIM") - ): + if name[3:5] == "IM": continue if id_var_addr not in variables: continue solution_names.append(t[0]) - idp_names = [i for i in mf6.get_value("__INPUT__/SIM/NAM/SLNMNAMES")] + idp_names = list(mf6.get_value("__INPUT__/SIM/NAM/SLNMNAMES")) solution_types = [ i[:-1].lower() for ix, i in enumerate(mf6.get_value("__INPUT__/SIM/NAM/SLNTYPE")) if idp_names[ix] ] @@ -353,30 +320,24 @@ def load(mf6): solutions = solution_dict - # TDIS package construction - tdis_constructor = package_factory("tdis", ScalarPackage) - tdis = tdis_constructor(ScalarPackage, tmpmdl, "tdis", "tdis", sim_package=True) + tdis = Package(tmpmdl, "tdis", "tdis", sim_package=True) ats = None - # ATS package construction for variable in variables: if variable.startswith("ATS"): - ats_constructor = package_factory("ats", ListPackage) - ats = ats_constructor(ListPackage, tmpmdl, "ats", "ats", sim_package=True) + ats = Package(tmpmdl, "ats", "ats", sim_package=True) break # get the exchanges exchange_names = [] for variable in variables: - if variable.startswith("GWF-GWF") or variable.startswith("GWT-GWT"): - exchange_name = variable.split("/")[0] - if exchange_name not in exchange_names: - exchange_names.append(exchange_name) + exchange_name = variable.split("/")[0] + parts = exchange_name.rsplit("_", 1)[0].split("-") + if len(parts) == 2 and parts[0] == parts[1] and exchange_name not in exchange_names: + exchange_names.append(exchange_name) - # sim_packages: tdis, gwf-gwf, sln exchanges = {} - for exchange_name in exchanges: - exchange = ApiExchange(mf6, exchange_name) - exchanges[exchange_name.lower()] = exchange + for exchange_name in exchange_names: + exchanges[exchange_name.lower()] = ApiExchange(mf6, exchange_name) return ApiSimulation(mf6, models, solutions, exchanges, tdis, ats) diff --git a/modflowapi/extensions/data.py b/modflowapi/extensions/data.py index c104da2..4118ed4 100644 --- a/modflowapi/extensions/data.py +++ b/modflowapi/extensions/data.py @@ -1,41 +1,164 @@ +from abc import ABC, abstractmethod + import numpy as np import pandas as pd import xmipy.errors -class ListInput: +class InputVar(ABC): + """Abstract base for a single named variable in the MODFLOW 6 memory manager.""" + + @property + @abstractmethod + def name(self) -> str: ... + + @property + @abstractmethod + def values(self): ... + + @values.setter + @abstractmethod + def values(self, value): ... + + +class ArrayVar(InputVar): """ - Data object for storing pointers and working with list based input data + A single grid-shaped array variable from the MODFLOW 6 memory manager. Parameters ---------- - parent : ListPackage - modflowapi ListPackage object - var_addrs : list, None - optional list of variable addresses - mf6 : ModflowApi, None - optional ModflowApi object - spd : bool - flag to indicate if the block is loading stress period data or other - list based block data. + parent : Package + modflowapi Package object + var_addr : str + variable address string + mf6 : ModflowApi, optional + required if parent is None """ - def __init__(self, parent, var_addrs=None, mf6=None, spd=True): + def __init__(self, parent, var_addr, mf6=None): + self._ptr = None self.parent = parent - self.var_addrs = var_addrs + self._name = None + self._vshape = None + if self.parent is not None: - if var_addrs is None: - self.var_addrs = self.parent.var_addrs self.mf6 = self.parent.model.mf6 else: - if var_addrs is None or mf6 is None: - raise AssertionError("var_addrs and mf6 must be supplied if parent is None") + if mf6 is None: + raise AssertionError("mf6 must be supplied if parent is None") + self.mf6 = mf6 + + ivn = self.mf6.get_input_var_names() + if var_addr in ivn: + values = self.mf6.get_value_ptr(var_addr) + self._name = var_addr.split("/")[-1].lower() + self._vshape = values.shape + self._ptr = values + + @property + def name(self) -> str: + return self._name + + def __getitem__(self, item): + return self.values[item] + + def __setitem__(self, key, value): + array = self.values + array[key] = value + self.values = array + + @property + def values(self): + if not self.parent._sim_package: + value = np.ones((self.parent.model.size,)) * np.nan + if self._ptr.size == self.parent.model.size: + value[:] = self._ptr.ravel() + else: + value[self.parent.model.nodetouser] = self._ptr.ravel() + return value.reshape(self.parent.model.shape) + else: + return np.copy(self._ptr.ravel()) + + @values.setter + def values(self, array): + if not isinstance(array, np.ndarray): + raise TypeError() + if not self.parent._sim_package: + if array.size != self.parent.model.size: + raise ValueError( + f"{self.name} size {array.size} is not equal to modflow variable size {self.parent.model.size}" + ) + array = array.ravel() + if self._ptr.size != array.size: + array = array[self.parent.model.nodetouser] + if len(self._vshape) > 1: + array.shape = self._vshape + else: + array = array.ravel() + self._ptr[:] = array + + +class ScalarVar(InputVar): + """ + A single scalar variable from the MODFLOW 6 memory manager. + + Parameters + ---------- + name : str + variable name + ptr : np.ndarray + 1-element pointer array from the MODFLOW 6 memory manager + """ + + def __init__(self, name: str, ptr): + self._name = name + self._ptr = ptr + + @property + def name(self) -> str: + return self._name + + @property + def values(self): + return self._ptr[0] + + @values.setter + def values(self, v): + self._ptr[0] = v + + +class ListVar(InputVar): + """ + A single list/recarray block from the MODFLOW 6 memory manager. + + Parameters + ---------- + parent : Package + modflowapi Package object + var_addrs : list + variable address strings + mf6 : ModflowApi, optional + required if parent is None + spd : bool + True for stress period data, False for packagedata + name : str, optional + override the default block name + """ + + _nodevars = ("nodelist", "nexg", "maxats") + def __init__(self, parent, var_addrs, mf6=None, spd=True, name=None): + self.parent = parent + if self.parent is not None: + self.mf6 = self.parent.model.mf6 + else: + if mf6 is None: + raise AssertionError("mf6 must be supplied if parent is None") self.mf6 = mf6 + self._name = name or ("stress_period_data" if spd else "packagedata") self._ptrs = {} self._compound_ptrs = {} - self._nodevars = ("nodelist", "nexg", "maxats") self._bound = "bound" self._maxbound = [0] @@ -44,27 +167,24 @@ def __init__(self, parent, var_addrs=None, mf6=None, spd=True): self._auxvar_name = "auxvar" self._auxnames = [] self._dtype = [] - self._reduced_to_var_addr = {} self._spd = spd + var_addrs = list(var_addrs) if self.parent._idm_enabled: for var in ("BOUND", "AUXVAR"): - self.var_addrs.pop( - self.var_addrs.index(self.mf6.get_var_address(var, self.parent.model.name, self.parent.pkg_name)) + var_addrs.pop( + var_addrs.index(self.mf6.get_var_address(var, self.parent.model.name, self.parent.pkg_name)) ) + var_addrs.append(self.mf6.get_var_address("AUXVAR_IDM", self.parent.model.name, self.parent.pkg_name)) - self.var_addrs.append(self.mf6.get_var_address("AUXVAR_IDM", self.parent.model.name, self.parent.pkg_name)) + self._set_data(var_addrs) - self._set_data() + @property + def name(self) -> str: + return self._name - def _set_data(self): - """ - Method to set stress period data variable pointers to the _ptrs - dictionary. Uses IDM updates instead of bound to access variable - pointers. - """ - # for now we need to add self.parent._bound_vars data to var_addrs - for var_addr in self.var_addrs: + def _set_data(self, var_addrs): + for var_addr in var_addrs: if ":" in var_addr: addr_pieces = var_addr.split("/") compound = addr_pieces[-1] @@ -80,27 +200,12 @@ def _set_data(self): if reduced in ("ndiv",): nbound = self._special_condition_to_values(ctype, values) - setattr( - self, - "_maxbound", - [ - len(values), - ], - ) - setattr( - self, - "_nbound", - [ - nbound, - ], - ) + self._maxbound = [len(values)] + self._nbound = [nbound] else: self._compound_ptrs[ptr_var] = values self._ptrs[reduced] = f"{ctype}:{ptr_var}" - - typ_str = values.dtype.str - dtype = (reduced, typ_str) - self._dtype.append(dtype) + self._dtype.append((reduced, values.dtype.str)) else: try: values = self.mf6.get_value_ptr(var_addr) @@ -116,50 +221,32 @@ def _set_data(self): if not self._spd and reduced == "maxbound": self._nbound = values elif reduced in ("nexg", "maxats", "nlakes", "nmawwells"): - setattr(self, "_maxbound", values) - setattr(self, "_nbound", values) + self._maxbound = values + self._nbound = values elif reduced in ("naux",): - setattr(self, "_naux", values) - elif reduced in ("auxname_cst"): - setattr(self, "_auxnames", list(values)) + self._naux = values + elif reduced in ("auxname_cst",): + self._auxnames = list(values) else: self._ptrs[reduced] = values - self._reduced_to_var_addr[reduced] = var_addr if reduced == self._bound: - # retain this method for advanced packages - for name in self.parent._bound_vars: - typ_str = values.dtype.str - dtype = (name, typ_str) - self._dtype.append(dtype) + for nm in self.parent._bound_vars: + self._dtype.append((nm, values.dtype.str)) elif reduced in self.parent._bound_vars: - typ_str = values.dtype.str - dtype = (reduced, typ_str) - self._dtype.append(dtype) + self._dtype.append((reduced, values.dtype.str)) elif reduced in self._nodevars: - dtype = (reduced, "O") - self._dtype.append(dtype) + self._dtype.append((reduced, "O")) elif "auxvar" in reduced: - self._auxvar_name = reduced # == "auxvar_idm": - if self._naux == 0: + self._auxvar_name = reduced + if self._naux[0] == 0: continue else: for ix in range(self._naux[0]): - typ_str = values.dtype.str - dtype = (self._auxnames[ix], typ_str) - self._dtype.append(dtype) + self._dtype.append((self._auxnames[ix], values.dtype.str)) else: - typ_str = values.dtype.str - dtype = (reduced, typ_str) - self._dtype.append(dtype) + self._dtype.append((reduced, values.dtype.str)) def _ptr_to_recarray(self): - """ - Method to get a recarray of stress period data from modflow pointers - - Returns - ------- - np.recarray - """ if self._nbound[0] == 0: return recarray = np.recarray((self._nbound[0],), self._dtype) @@ -168,7 +255,6 @@ def _ptr_to_recarray(self): continue if isinstance(ptr, str): - # special condition and not a real ptr ctype, ptr_name = ptr.split(":") ptr_vals = self._compound_ptrs[ptr_name] values = self._special_condition_to_values(ctype, ptr_vals) @@ -176,13 +262,11 @@ def _ptr_to_recarray(self): values = np.copy(ptr) if name == self._bound: - # note: keep block around for advanced packages for ix, nm in enumerate(self.parent._bound_vars): bnd_values = values[0 : self._nbound[0], ix] recarray[nm][0 : self._nbound[0]] = bnd_values elif name in self.parent._bound_vars and self.parent._idm_enabled: - # IDM simplification method bnd_values = values[0 : self._nbound[0]].ravel() recarray[name][0 : self._nbound[0]] = bnd_values @@ -201,7 +285,6 @@ def _ptr_to_recarray(self): values -= 1 values = self.parent.model.nodetouser[values] values = list(zip(*np.unravel_index(values, self.parent.model.shape))) - elif name in ("idv", "divreach"): values -= 1 @@ -211,17 +294,6 @@ def _ptr_to_recarray(self): return recarray def _recarray_to_ptr(self, recarray): - """ - Method to update stress period information pointers from user supplied - data - - Parameters - ---------- - recarray : np.recarray - numpy recarray of stress period data - - """ - if recarray is None: self._nbound[0] = 0 return @@ -232,7 +304,6 @@ def _recarray_to_ptr(self, recarray): f"Length of stresses ({len(recarray)},) cannot be larger than maxbound value ({self._maxbound[0]},)" ) self._nbound[0] = len(recarray) - if len(recarray) == 0: return @@ -250,7 +321,6 @@ def _recarray_to_ptr(self, recarray): visited.append(name) elif self._bound in self._ptrs and name in self.parent._bound_vars: - # Check for bound to support advanced packages idx = self.parent._bound_vars.index(name) self._ptrs[self._bound][0 : self._nbound[0], idx] = recarray[name] visited.append(name) @@ -275,21 +345,6 @@ def _recarray_to_ptr(self, recarray): visited.append(name) def _special_condition_to_values(self, ctype, inval): - """ - Method to catch and set special, compound conditions where necessary data - is not directly available from MODFLOW - - Parameters - ---------- - ctype : str - condition type - inval : int, float, np.ndarray - input data value - - Returns - ------- - outval : np.array of values - """ functions = { "range": lambda x: np.arange(0, int(x), dtype=int), "count_nonzero": lambda x: np.count_nonzero(x), @@ -299,59 +354,32 @@ def _special_condition_to_values(self, ctype, inval): ctype = ctype.lower() if ctype in ("range",): inval = inval[0] - - func = functions[ctype] - outval = func(inval) - return outval + return functions[ctype](inval) def _special_condition_to_ptr(self, recarray, name, visited): - """ - Method to catch and set special, compound conditions to the associated MODFLOW - ptr where the user data is not directly available from MODFLOW - - Parameters - ---------- - recarray : np.recarray - recarray of user data - name : str - data column name - visited : list - list of visited data columns used to avoid duplicate processing - - - Returns - ------- - visited : list - """ functions = { - "range": lambda x: [ - len(x), - ], + "range": lambda x: [len(x)], } ctype, ptr_name = self._ptrs[name].split(":") if "where" in ctype: - idx_name = None - val_name = None + idx_name = val_name = None if ctype == "where_idx": idx_name = name for k, v in self._ptrs.items(): - if isinstance(v, str): - if v == f"where_val:{ptr_name}": - val_name = k - break - + if isinstance(v, str) and v == f"where_val:{ptr_name}": + val_name = k + break elif ctype == "where_val": val_name = name for k, v in self._ptrs.items(): - if isinstance(v, str): - if v == f"where_idx:{ptr_name}": - idx_name = k - break + if isinstance(v, str) and v == f"where_idx:{ptr_name}": + idx_name = k + break else: - return + return visited if idx_name is None or val_name is None: - return + return visited idx = list(recarray[idx_name].astype(int)) vals = recarray[val_name].ravel() @@ -359,7 +387,6 @@ def _special_condition_to_ptr(self, recarray, name, visited): vals += 1 self._compound_ptrs[ptr_name][idx] = vals visited.extend([idx_name, val_name]) - else: func = functions[ctype] values = func(recarray[name]) @@ -379,23 +406,16 @@ def __setitem__(self, key, value): @property def dtype(self): - """ - Returns the numpy dtypes for the recarray - """ return self._dtype @property def values(self): - """ - Returns a np.recarray of the current stress_period_data - """ return self._ptr_to_recarray() @values.setter def values(self, recarray): - """ - Setter method to update the current stress_period_data - """ + if isinstance(recarray, ListVar): + recarray = recarray.values self._recarray_to_ptr(recarray) @property @@ -409,259 +429,6 @@ def dataframe(self, dataframe): self._recarray_to_ptr(recarray) -class ArrayPointer: - """ - Data object for storing single pointers and - working with array based input data - - Parameters - ---------- - parent : ArrayPackage - ArrayPackage object - var_addr : str - variable pointer location - mf6 : ModflowApi - optional ModflowApi object - """ - - def __init__(self, parent, var_addr, mf6=None): - self._ptr = None - self.parent = parent - self._mapping = None - self.name = None - self.var_addr = var_addr - self._vshape = None - - if self.parent is not None: - self.mf6 = self.parent.model.mf6 - else: - if mf6 is None: - raise AssertionError("mf6 must be supplied if parent is None") - self._set_array() - - def _set_array(self): - ivn = self.mf6.get_input_var_names() - if self.var_addr in ivn: - values = self.mf6.get_value_ptr(self.var_addr) - reduced = self.var_addr.split("/")[-1].lower() - self._vshape = values.shape - self._ptr = values - self.name = reduced - - def __getitem__(self, item): - return self.values[item] - - def __setitem__(self, key, value): - array = self.values - array[key] = value - self.values = array - - @property - def values(self): - """ - Method to get an array from modflow - - Returns - ------- - np.array of modflow data - """ - if not self.parent._sim_package: - value = np.ones((self.parent.model.size,)) * np.nan - if self._ptr.size == self.parent.model.size: - value[:] = self._ptr.ravel() - else: - value[self.parent.model.nodetouser] = self._ptr.ravel() - return value.reshape(self.parent.model.shape) - else: - return np.copy(self._ptr.ravel()) - - @values.setter - def values(self, array): - """ - Method to update the modflow pointer arrays - - Parameters - ---------- - array : np.array - numpy array - - """ - - if not isinstance(array, np.ndarray): - raise TypeError() - if not self.parent._sim_package: - if array.size != self.parent.model.size: - raise ValueError( - f"{self.name} size {array.size} is not equal to modflow variable size {self.parent.model.size}" - ) - - array = array.ravel() - if self._ptr.size != array.size: - array = array[self.parent.model.nodetouser] - if len(self._vshape) > 1: - array.shape = self._vshape - else: - array = array.ravel() - self._ptr[:] = array - - -class ArrayInput: - """ - Data object for storing pointers and working with array based input data - - Parameters - ---------- - parent : ArrayPackage - modflowapi ArrayPackage object - var_addrs : list, None - optional list of variable addresses - mf6 : ModflowApi, None - optional ModflowApi object - """ - - def __init__(self, parent, var_addrs=None, mf6=None): - self._ptrs = {} - self.parent = parent - # change this to a parent package.mapping - self._mapping = None - - if self.parent is not None: - self.var_addrs = self.parent.var_addrs - self.mf6 = self.parent.model.mf6 - else: - if var_addrs is None or mf6 is None: - raise AssertionError("var_addrs and mf6 must be supplied if parent is None") - self.var_addrs = var_addrs - self.mf6 = mf6 - - self._maxbound = [0] - self._nbound = [0] - self._reduced_to_var_addr = {} - self._set_arrays() - - def _set_arrays(self): - """ - Method to modflow variable pointers to the _ptrs dictionary - """ - ivn = self.mf6.get_input_var_names() - for var_addr in self.var_addrs: - if var_addr in ivn: - ptr = ArrayPointer(self.parent, var_addr) - reduced = var_addr.split("/")[-1].lower() - self._ptrs[reduced] = ptr - self._reduced_to_var_addr[reduced] = var_addr - - def __getattr__(self, item): - """ - Dynamic method to get modflow variables as an attribute - """ - if item in self._ptrs: - return self._ptrs[item] - else: - return super(ArrayInput).__getattribute__(item) - - def __setattr__(self, item, value): - """ - Dynamic method that allows users to set modflow variables to the - _ptr dict - """ - if item in ( - "parent", - "var_addrs", - "_mapping", - "_ptrs", - "mf6", - "_maxbound", - "_nbound", - "_reduced_to_var_addr", - ): - super().__setattr__(item, value) - - elif item in self._ptrs: - if isinstance(value, ArrayPointer): - self._ptrs[item] = value - else: - raise AttributeError(f"{item} is not a valid attribute") - - @property - def variable_names(self): - """ - Returns a list of valid array names that can be accessed by the user - """ - return list(sorted(self._ptrs.keys())) - - def get_ptr(self, item): - """ - Method to get the ArrayPointer object - - Parameters - ---------- - item : str - modflow variable name: Ex. "k11" - - Returns - ------- - ArrayPointer object - """ - if item in self._ptrs: - return self._ptrs[item] - - def set_ptr(self, item, ptr): - """ - Method to set an ArrayPointer object - - Parameters - ---------- - item : str - modflow variable name: Ex. "k11" - ptr : ArrayPointer - ArrayPointer object - """ - if item in self._ptrs: - if isinstance(ptr, ArrayPointer): - self._ptrs[item] = ptr - else: - raise TypeError("An ArrayPointer object must be provided") - - else: - raise KeyError(f"{item} is not accessible in this package") - - def get_array(self, item): - """ - Method to get an array from modflow - - Parameters - ---------- - item : str - modflow variable name. Ex. "k11" - - Returns - ------- - np.array of modflow data - """ - if item in self._ptrs: - return self._ptrs[item].values - else: - raise KeyError(f"{item} is not accessible in this package") - - def set_array(self, item, array): - """ - Method to update the modflow pointer arrays - - Parameters - ---------- - item : str - modflow variable name. Ex. "k11" - array : np.array - numpy array - - """ - if item in self._ptrs: - self._ptrs[item].values = array - else: - raise KeyError(f"{item} is not a valid variable name for this package") - - class AdvancedInput(object): """ Data object for dynamically storing pointers and working with @@ -669,10 +436,10 @@ class AdvancedInput(object): Parameters ---------- - parent : ArrayPackage - modflowapi ArrayPackage object - mf6 : ModflowApi, None - optional ModflowApi object + parent : Package + modflowapi Package object + mf6 : ModflowApi, optional + required if parent is None """ def __init__(self, parent, mf6=None): @@ -686,38 +453,14 @@ def __init__(self, parent, mf6=None): raise AssertionError("mf6 must be supplied if parent is None") self.mf6 = mf6 - def get_variable(self, name, model=None, package=None): - """ - method to assemble a variable address and get a variable from the - ModflowApi instance - - Parameters: - ---------- - name : str - variable name - model : str - optional model name, note this is required if parent is None - package : str - optional package name, note this is required if parent is None - - Returns: - ------- - np.ndarray or scalar float, int, string, or boolean value - depending on data type and length - """ + def get_variable(self, name): if name.lower() in self._ptrs: return self._ptrs[name.lower()] if not self.parent._sim_package: - if self.parent is not None: - var_addr = self.mf6.get_var_address(name.upper(), self.parent.model.name, self.parent.pkg_name) - else: - var_addr = self.mf6.get_var_address(name.upper(), model.upper(), package.upper()) + var_addr = self.mf6.get_var_address(name.upper(), self.parent.model.name, self.parent.pkg_name) else: - if self.parent is not None: - var_addr = self.mf6.get_var_address(name.upper(), self.parent.pkg_name) - else: - var_addr = self.mf6.get_var_address(name.upper(), package.upper()) + var_addr = self.mf6.get_var_address(name.upper(), self.parent.pkg_name) try: values = self.mf6.get_value_ptr(var_addr) @@ -727,34 +470,9 @@ def get_variable(self, name, model=None, package=None): return values.copy() - def set_variable(self, name, values, model=None, package=None): - """ - method to assemble a variable address and get a variable from the - ModflowApi instance - - Parameters: - ---------- - name : str - variable name - values : np.ndarray - numpy array of variable values - model : str - optional model name, note this is required if parent is None - package : str - optional package name, note this is required if parent is None - - Returns: - ------- - np.ndarray or scalar float, int, string, or boolean value - depending on data type and length - """ - if model is None and not self.parent._sim_package: - model = self.parent.model.name - if package is None: - package = self.parent.pkg_name - + def set_variable(self, name, values): if name.lower() not in self._ptrs: - values0 = self.get_variable(name, model, package) + values0 = self.get_variable(name) else: values0 = self._ptrs[name.lower()] @@ -764,7 +482,6 @@ def set_variable(self, name, values, model=None, package=None): ) if name.lower() not in self._ptrs: - # this is a set value situation self.mf6.set_value( self.mf6.get_var_address(name.upper(), self.parent.model.name, self.parent.pkg_name), values, @@ -773,118 +490,6 @@ def set_variable(self, name, values, model=None, package=None): self._ptrs[name.lower()][:] = values[:] -class ScalarInput: - """ - Data object for storing pointers and working with array based input data - - Parameters - ---------- - parent : ArrayPackage - modflowapi ArrayPackage object - var_addrs : list, None - optional list of variable addresses - mf6 : ModflowApi, None - optional ModflowApi object - """ - - def __init__(self, parent, var_addrs=None, mf6=None): - self._ptrs = {} - self.parent = parent - # change this to a parent package.mapping - self._mapping = None - - if self.parent is not None: - self.var_addrs = self.parent.var_addrs - self.mf6 = self.parent.model.mf6 - else: - if var_addrs is None or mf6 is None: - raise AssertionError("var_addrs and mf6 must be supplied if parent is None") - self.var_addrs = var_addrs - self.mf6 = mf6 - - self._reduced_to_var_addr = {} - self._set_scalars() - - def __getattr__(self, item): - """ - Dynamic method to get modflow variables as an attribute - """ - if item in self._ptrs: - return self._ptrs[item] - else: - return super(ArrayInput).__getattribute__(item) - - def __setattr__(self, item, value): - """ - Dynamic method that allows users to set modflow variables to the - _ptr dict - """ - if item in ( - "parent", - "var_addrs", - "_mapping", - "_ptrs", - "mf6", - "_maxbound", - "_nbound", - "_reduced_to_var_addr", - ): - super().__setattr__(item, value) - - elif item in self._ptrs: - self._ptrs[item] = value - else: - raise AttributeError(f"{item} is not a valid attribute") - - @property - def variable_names(self): - """ - Returns a list of valid array names that can be accessed by the user - """ - return list(sorted(self._ptrs.keys())) - - def _set_scalars(self): - """ - Method to modflow variable pointers to the _ptrs dictionary - """ - ivn = self.mf6.get_input_var_names() - for var_addr in self.var_addrs: - if var_addr in ivn: - ptr = self.mf6.get_value_ptr(var_addr) - reduced = var_addr.split("/")[-1].lower() - self._ptrs[reduced] = ptr - self._reduced_to_var_addr[reduced] = var_addr - - def get_value(self, item): - """ - Method to get a scalar value from modflow - - Parameters - ---------- - item : str - modflow variable name. Ex. "NPER" - - Returns - ------- - str, int, float - """ - if item in self._ptrs: - return self._ptrs[item][0] - else: - raise KeyError(f"{item} is not accessible in this package") - - def set_value(self, item, value): - """ - Method to set a scalar value in modflow - - Parameters - ---------- - item : str - modflow variable name. Ex. "NPER" - - value : str, int, float - """ - if item in self._ptrs: - self._ptrs[item][0] = value - else: - raise KeyError(f"{item} is not accessible in this package") +# Backward compatibility aliases +ArrayPointer = ArrayVar +ListInput = ListVar diff --git a/modflowapi/extensions/datamodel.py b/modflowapi/extensions/datamodel.py index 669a0d5..77105cc 100644 --- a/modflowapi/extensions/datamodel.py +++ b/modflowapi/extensions/datamodel.py @@ -160,7 +160,7 @@ "gwe-gwe": ["nexg", "nodem1", "nodem2", "cl1", "cl2", "ihc", "hwva"], # simulation "ats": [ - "maxats", + ("maxats", ()), "iperats", "dt0", "dtmin", @@ -274,48 +274,3 @@ }, "maw": {"packagedata": ["nmawwells", ("ifno:range:nmawwells", "radius", "bot", "strt", "ngwfnodes")]}, } - - -def get_package_type(pkg_type): - from .advpaks import LakPackage, MawPackage, SfrPakage, UzfPackage - from .pakbase import AdvancedPackage, ArrayPackage, ListPackage, ScalarPackage - - pkg_types = { - "dis": ArrayPackage, - "chd": ListPackage, - "drn": ListPackage, - "evt": ListPackage, - "ghb": ListPackage, - "ic": ArrayPackage, - "npf": ArrayPackage, - "rch": ListPackage, - "riv": ListPackage, - "sto": ArrayPackage, - "wel": ListPackage, - # advanced - "sfr": SfrPakage, - "uzf": UzfPackage, - "lak": LakPackage, - "maw": MawPackage, - # "csub": None, - # gwt - "dsp": ArrayPackage, - "cnc": ListPackage, - "ist": ArrayPackage, - "mst": ArrayPackage, - "src": ListPackage, - # gwe - "cnd": ArrayPackage, - "est": ArrayPackage, - "cpt": ListPackage, - "esl": ListPackage, - # prt - "mip": ArrayPackage, - # sim_level pkgs - "tdis": ScalarPackage, - "ats": ListPackage, - } - if pkg_type in pkg_types: - return pkg_types[pkg_type] - else: - return AdvancedPackage diff --git a/modflowapi/extensions/pakbase.py b/modflowapi/extensions/pakbase.py index 2a48c9b..85411cf 100644 --- a/modflowapi/extensions/pakbase.py +++ b/modflowapi/extensions/pakbase.py @@ -1,158 +1,202 @@ import numpy as np -from .data import AdvancedInput, ArrayInput, ListInput, ScalarInput +from .data import AdvancedInput, ArrayVar, ListVar, ScalarVar from .datamodel import adv_pkgvars, pkgvars +_BASE_ATTRS = frozenset({"model", "pkg_name", "pkg_type"}) +_ADV_BLOCK_NAMES = frozenset({"packagedata", "perioddata"}) -class PackageBase: - """ - Base class for packages within the modflow-6 api +class Package: + """ + Package object for MODFLOW 6 API packages. Parameters ---------- model : ApiModel - modflowapi ApiModel object + modflowapi model object pkg_type : str - package type name. ex. 'wel' + package type name, e.g. 'wel' pkg_name : str - modflow package name. ex. 'wel_0' - child_type : str - type of child input package + package name in the MF6 variables, e.g. 'wel_0' sim_package : bool - flag to indicate this is a simulation level package + flag indicating this is a simulation-level package """ - def __init__(self, model, pkg_type, pkg_name, child_type, sim_package): + def __init__(self, model, pkg_type, pkg_name, sim_package=False): self.model = model - self.pkg_name = pkg_name self.pkg_type = pkg_type - self._child_type = child_type + self.pkg_name = pkg_name.upper() self._sim_package = sim_package self._rhs = None self._hcof = None self._bound_vars = [] self._advanced_var_names = None self._idm_enabled = False + self._inputs = {} - var_addrs = [] - if self._child_type != "advanced": - for var in pkgvars[self.pkg_type]: - if isinstance(var, tuple): - bound_vars = [] - for bv in var[-1]: - t = bv.split(":") - if len(t) == 2: - # this is a repeating variable - addr = self.model.mf6.get_var_address(t[-1].upper(), self.model.name, self.pkg_name) - nrep = self.model.mf6.get_value(addr)[0] - if nrep > 1: - for rep in range(nrep): - bound_vars.append(f"{t[0]}{rep}") - else: - bound_vars.append(t[0]) - else: - bound_vars.append(t[0]) + self._variables_adv = None + self._build_inputs() - self._bound_vars = var[-1] - var = var[0] + # ------------------------------------------------------------------ + # Input construction + # ------------------------------------------------------------------ - if sim_package: - var_addrs.append(self.model.mf6.get_var_address(var.upper(), self.pkg_name)) - else: - var_addrs.append(self.model.mf6.get_var_address(var.upper(), self.model.name, self.pkg_name)) + def _build_inputs(self): + if self.pkg_type in adv_pkgvars: + self._build_advanced_inputs() + elif self.pkg_type in pkgvars and any(isinstance(v, tuple) for v in pkgvars[self.pkg_type]): + self._build_list_inputs(pkgvars[self.pkg_type]) + elif self.pkg_type in pkgvars: + self._build_plain_inputs(pkgvars[self.pkg_type]) + + def _build_list_inputs(self, vars_list): + var_addrs = [] + for var in vars_list: + if isinstance(var, tuple): + self._bound_vars = var[-1] + var = var[0] + if self._sim_package: + var_addrs.append(self.model.mf6.get_var_address(var.upper(), self.pkg_name)) + else: + var_addrs.append(self.model.mf6.get_var_address(var.upper(), self.model.name, self.pkg_name)) for var in self._bound_vars: addr_chk = self.model.mf6.get_var_address(var.upper(), self.model.name, self.pkg_name) if addr_chk in self.model.mf6.get_input_var_names(): - # change this to use idm self._idm_enabled = True var_addrs.append(addr_chk) - self.var_addrs = var_addrs - self._variables_adv = AdvancedInput(self) + self._inputs["stress_period_data"] = ListVar(self, var_addrs, spd=True) - @property - def advanced_vars(self): - """ - Returns a list of additional "advanced" variables that are - accessible through the API - """ - if self._advanced_var_names is None: - adv_vars = [] - for var_addr in self.model.mf6.get_input_var_names(): - is_advanced = False - t = var_addr.split("/") - if not self._sim_package: - if t[0] == self.model.name and t[1] == self.pkg_name: - is_advanced = self._check_if_advanced_var(t[-1]) + def _build_plain_inputs(self, vars_list): + ivn = self.model.mf6.get_input_var_names() + for var in vars_list: + if self._sim_package: + addr = self.model.mf6.get_var_address(var.upper(), self.pkg_name) + else: + addr = self.model.mf6.get_var_address(var.upper(), self.model.name, self.pkg_name) + if addr not in ivn: + continue + name = var.lower() + if self._sim_package: + self._inputs[name] = ScalarVar(name, self.model.mf6.get_value_ptr(addr)) + else: + arr_var = ArrayVar(self, addr) + if arr_var.name is not None: + self._inputs[name] = arr_var + + def _build_advanced_inputs(self): + adv_var_dict = adv_pkgvars[self.pkg_type] + + pkg_var_addrs = self._collect_adv_var_addrs(adv_var_dict, "packagedata") + if pkg_var_addrs: + self._inputs["packagedata"] = ListVar(self, pkg_var_addrs, spd=False) + + sp_var_addrs = [] + if "perioddata" in adv_var_dict: + for var in adv_var_dict["perioddata"]: + if isinstance(var, tuple): + use_bound = all(":" not in v for v in var[-1]) + if use_bound: + self._bound_vars = var[-1] + var = var[0] + else: + for v in var[-1]: + if ":" in v: + self._bound_vars.append(v.split(":")[0]) + else: + self._bound_vars.append(v) + sp_var_addrs.append(self._adv_var_addr(v)) + var = None + if var is not None: + sp_var_addrs.append(self.model.mf6.get_var_address(var.upper(), self.model.name, self.pkg_name)) + + if sp_var_addrs: + self._inputs["stress_period_data"] = ListVar(self, sp_var_addrs, spd=True) + + for block in adv_var_dict: + if block in _ADV_BLOCK_NAMES: + continue + var_addrs = self._collect_adv_var_addrs(adv_var_dict, block) + if var_addrs: + self._inputs[block] = ListVar(self, var_addrs, spd=False, name=block) + + def _collect_adv_var_addrs(self, adv_var_dict, block): + var_addrs = [] + if block in adv_var_dict: + for var in adv_var_dict[block]: + if not isinstance(var, tuple): + var_addrs.append(self._adv_var_addr(var)) else: - if t[0] == self.pkg_name: - is_advanced = self._check_if_advanced_var(t[-1]) + for v in var: + var_addrs.append(self._adv_var_addr(v)) + return var_addrs - if is_advanced: - adv_vars.append(t[-1].lower()) + def _adv_var_addr(self, var_str): + return f"{self.model.name}/{self.pkg_name}/{var_str.upper()}" - self._advanced_var_names = adv_vars - return self._advanced_var_names + # ------------------------------------------------------------------ + # Attribute dispatch + # ------------------------------------------------------------------ - def _check_if_advanced_var(self, variable_name): - """ - Method to check if a variable is an advanced variable - - Parameters - ---------- - variable_name : str - variable name to check - - Returns - ------- - bool - """ - if variable_name.lower() in self._bound_vars: - is_advanced = False - elif self.pkg_type not in pkgvars: - is_advanced = True - elif variable_name.lower() in pkgvars[self.pkg_type]: - is_advanced = False - else: - is_advanced = True - return is_advanced + def __repr__(self): + s = f"{self.pkg_type.upper()} Package: {self.pkg_name}\n" + names = self.variable_names + if names: + s += " Accessible variables include:\n" + for name in names: + s += f" {name}\n" + return s - def get_advanced_var(self, name): - """ - Method to get an advanced variable that is not automatically - accessible through stress period data or as an array name - """ - name = name.lower() - if name not in self.advanced_vars: - raise AssertionError(f"{name} is not accessible as an advanced variable for this package") + def __getattr__(self, item): + try: + inputs = object.__getattribute__(self, "_inputs") + except AttributeError: + raise AttributeError(item) + if item in inputs: + v = inputs[item] + return v.values if isinstance(v, ScalarVar) else v + raise AttributeError(item) - values = self._variables_adv.get_variable(name) - if not self._sim_package: - if values.size == self.model.nodetouser.size and self._child_type == "array": - array = np.full(self.model.size, np.nan) - array[self.model.nodetouser] = values - return array + def __setattr__(self, item, value): + if item.startswith("_") or item in _BASE_ATTRS: + object.__setattr__(self, item, value) + return + try: + inputs = object.__getattribute__(self, "_inputs") + except AttributeError: + object.__setattr__(self, item, value) + return + if item in inputs: + inputs[item].values = value + return + raise AttributeError(f"{item} is not a valid attribute for {self.pkg_type}") + + # ------------------------------------------------------------------ + # Static properties + # ------------------------------------------------------------------ - return values + @property + def variable_names(self): + """Returns a sorted list of non-list variable names accessible through the API.""" + return sorted(n for n, v in self._inputs.items() if not isinstance(v, ListVar)) - def set_advanced_var(self, name, values): - """ - Method to set data to an advanced variable - - Parameters - ---------- - name : str - parameter name - values : np.ndarray - numpy array - """ - if not self._sim_package: - if self._child_type == "array" and values.size == self.model.size: - values = values[self.model.nodetouser] + @property + def _spd_var(self): + return next((v for v in self._inputs.values() if isinstance(v, ListVar) and v._spd), None) - self._variables_adv.set_variable(name, values) + @property + def nbound(self): + """Returns the number of active boundaries for the current stress period.""" + lv = self._spd_var + return lv._nbound[0] if lv is not None else None + + @property + def maxbound(self): + """Returns the maximum number of boundaries.""" + lv = self._spd_var + return lv._maxbound[0] if lv is not None else None @property def rhs(self): @@ -162,8 +206,7 @@ def rhs(self): if var_addr in self.model.mf6.get_input_var_names(): self._rhs = self.model.mf6.get_value_ptr(var_addr) else: - return - + return None return np.copy(self._rhs) @rhs.setter @@ -172,7 +215,6 @@ def rhs(self, values): rhs = self.rhs if rhs is None: raise Exception(f"{self.pkg_type} does not have a rhs array") - self._rhs[:] = values[:] @property @@ -183,8 +225,7 @@ def hcof(self): if var_addr in self.model.mf6.get_input_var_names(): self._hcof = self.model.mf6.get_value_ptr(var_addr) else: - return - + return None return np.copy(self._hcof) @hcof.setter @@ -193,440 +234,135 @@ def hcof(self, values): hcof = self.hcof if hcof is None: raise Exception(f"{self.pkg_type} does not have an hcof array") - self._hcof[:] = values[:] - -class ListPackage(PackageBase): - """ - Package object for "list based" input packages such as WEL, DRN, RCH - - Parameters - ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "RCH" - pkg_name : str - package name (in the mf6 variables) - sim_package : bool - flag to indicate this is a simulation level package - """ - - def __init__(self, model, pkg_type, pkg_name, sim_package=False): - super().__init__(model, pkg_type, pkg_name.upper(), "list", sim_package) - - self._variables = ListInput(self) - - def __repr__(self): - s = f"{self.pkg_type.upper()} Package: {self.pkg_name}" - return s - - @property - def nbound(self): - """ - Returns the "nbound" value for the stress period - """ - return self._variables._nbound[0] - - @property - def maxbound(self): - """ - Returns the "maxbound" value for the stress period - """ - return self._variables._maxbound[0] - @property - def stress_period_data(self): - """ - Returns a ListInput object of the current stress_period_data - """ - return self._variables - - @stress_period_data.setter - def stress_period_data(self, recarray): - """ - Setter method to update the current stress_period_data - """ - if isinstance(recarray, np.recarray): - self._variables.values = recarray - elif isinstance(recarray, ListInput): - self._variables.values = recarray.values - elif recarray is None: - self._variables.values = recarray - else: - raise TypeError(f"{type(recarray)} is not a supported stress_period_data type") - - -class ArrayPackage(PackageBase): - """ - Package object for "array based" input packages such as NPF, DIS, - - Parameters - ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "DIS" - pkg_name : str - package name (in the mf6 variables) - sim_package : bool - flag to indicate this is a simulation level package - """ - - def __init__(self, model, pkg_type, pkg_name, sim_package=False): - super().__init__(model, pkg_type, pkg_name.upper(), "array", sim_package) - - self._variables = ArrayInput(self) - - def __repr__(self): - s = f"{self.pkg_type.upper()} Package: {self.pkg_name} \n" - s += " Accessible variables include:\n" - for var_name in self.variable_names: - s += f" {var_name} \n" - return s - - def __setattr__(self, item, value): - """ - Method that enables dynamic variable setting and distributes - modflow variable storage and updates to the data object class - """ - if item in ("model", "pkg_name", "pkg_type", "var_addrs"): - super().__setattr__(item, value) - - elif item.startswith("_"): - super().__setattr__(item, value) + def advanced_vars(self): + """Returns a list of additional variables accessible through get/set_advanced_var.""" + if self._advanced_var_names is None: + adv_vars = [] + for var_addr in self.model.mf6.get_input_var_names(): + t = var_addr.split("/") + is_advanced = False + if not self._sim_package: + if t[0] == self.model.name and t[1] == self.pkg_name: + is_advanced = self._check_if_advanced_var(t[-1]) + else: + if t[0] == self.pkg_name: + is_advanced = self._check_if_advanced_var(t[-1]) + if is_advanced: + adv_vars.append(t[-1].lower()) + self._advanced_var_names = adv_vars + return self._advanced_var_names - elif item in self._variables._ptrs: - self._variables.set_ptr(item, value) + def _check_if_advanced_var(self, variable_name): + if variable_name.lower() in self._bound_vars: + return False + if self.pkg_type not in pkgvars: + return True + if variable_name.lower() in pkgvars[self.pkg_type]: + return False + return True - else: - raise AttributeError(f"{item}") + def get_advanced_var(self, name): + """Get a variable not surfaced through stress_period_data or variable_names.""" + name = name.lower() + if name not in self.advanced_vars: + raise AssertionError(f"{name} is not accessible as an advanced variable for this package") + if self._variables_adv is None: + self._variables_adv = AdvancedInput(self) + values = self._variables_adv.get_variable(name) + if not self._sim_package: + if values.size == self.model.nodetouser.size: + array = np.full(self.model.size, np.nan) + array[self.model.nodetouser] = values + return array + return values - def __getattr__(self, item): - """ - Method to dynamically get modflow variables by attribute - """ - if item in self._variables._ptrs: - return self._variables.get_ptr(item) - else: - return super().__getattribute__(item) + def set_advanced_var(self, name, values): + """Set a variable not surfaced through stress_period_data or variable_names.""" + if not self._sim_package: + if values.size == self.model.size: + values = values[self.model.nodetouser] + if self._variables_adv is None: + self._variables_adv = AdvancedInput(self) + self._variables_adv.set_variable(name, values) - @property - def variable_names(self): - """ - Returns a list of valid modflow variable names that the user can access - """ - return self._variables.variable_names + # ------------------------------------------------------------------ + # Explicit accessor methods (backward compatibility) + # ------------------------------------------------------------------ def get_array(self, item): - """ - Method to get an array from modflow - - Parameters - ---------- - item : str - modflow variable name. Ex. "k11" - - Returns - ------- - np.array of modflow data - """ - return self._variables.get_array(item) + """Get a grid-shaped array variable by name.""" + v = self._inputs.get(item) + if v is None or not isinstance(v, ArrayVar): + raise KeyError(f"{item} is not accessible in this package") + return v.values def set_array(self, item, array): - """ - Method to update the modflow pointer arrays - - Parameters - ---------- - item : str - modflow variable name. Ex. "k11" - array : np.array - numpy array - - """ - self._variables.set_array(item, array) - - -class ScalarPackage(PackageBase): - """ - Container for advanced data packages - - Parameters - ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "RCH" - pkg_name : str - package name (in the mf6 variables) - sim_package : bool - boolean flag for simulation level packages. Ex. TDIS, IMS - """ - - def __init__(self, model, pkg_type, pkg_name, sim_package=False): - super().__init__(model, pkg_type, pkg_name.upper(), "scalar", sim_package) - - self._variables = ScalarInput(self) - - def __repr__(self): - s = f"{self.pkg_type.upper()} Package: {self.pkg_name} \n" - s += " Accessible variables include:\n" - for var_name in self.variable_names: - s += f" {var_name} \n" - return s - - def __setattr__(self, item, value): - """ - Method that enables dynamic variable setting and distributes - modflow variable storage and updates to the data object class - """ - if item in ("model", "pkg_name", "pkg_type", "var_addrs"): - super().__setattr__(item, value) - - elif item.startswith("_"): - super().__setattr__(item, value) - - elif item in self._variables._ptrs: - self._variables.set_value(item, value) - - elif item in ("mxiter",): - # hack for sln-ems - super().__setattr__(item, value) - - else: - raise AttributeError(f"{item}") - - def __getattr__(self, item): - """ - Method to dynamically get modflow variables by attribute - """ - if item in self._variables._ptrs: - return self._variables.get_value(item) - else: - return super().__getattribute__(item) - - @property - def variable_names(self): - """ - Returns a list of valid modflow variable names that the user can access - """ - return self._variables.variable_names + """Set a grid-shaped array variable by name.""" + v = self._inputs.get(item) + if v is None or not isinstance(v, ArrayVar): + raise KeyError(f"{item} is not a valid variable name for this package") + v.values = array def get_value(self, item): - """ - Method to get a scalar value from modflow - - Parameters - ---------- - item : str - modflow variable name. Ex. "NBOUND" - - Returns - ------- - np.array of modflow data - """ - return self._variables.get_value(item) + """Get a scalar variable by name.""" + v = self._inputs.get(item) + if v is None or not isinstance(v, ScalarVar): + raise KeyError(f"{item} is not accessible in this package") + return v.values def set_value(self, item, value): - """ - Method to update the modflow pointer arrays - - Parameters - ---------- - item : str - modflow variable name. Ex. "k11" - array : str, int, float - scalar value - - """ - self._variables.set_value(item, value) - - -class AdvancedPackage(PackageBase): - """ - Container for advanced data packages + """Set a scalar variable by name.""" + v = self._inputs.get(item) + if v is None or not isinstance(v, ScalarVar): + raise KeyError(f"{item} is not accessible in this package") + v.values = value - Parameters - ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "RCH" - pkg_name : str - package name (in the mf6 variables) - sim_package : bool - boolean flag for simulation level packages. Ex. TDIS, IMS - """ - def __init__(self, model, pkg_type, pkg_name, sim_package=False): - super().__init__(model, pkg_type, pkg_name.upper(), "advanced", sim_package) +# ------------------------------------------------------------------ +# Marker subclasses — preserve isinstance compatibility +# ------------------------------------------------------------------ - self._idm_enabled = False - self._package_var_addrs = [] - self._sp_var_addrs = [] - self._package_vars = None - self._sp_vars = None - if pkg_type in adv_pkgvars: - self._adv_var_dict = adv_pkgvars[pkg_type] +class PackageBase(Package): + pass - self._set_advanced_variable_addrs("packagedata", "_package_var_addrs") - if "perioddata" in self._adv_var_dict: - # create variable addresses!!!! - for var in self._adv_var_dict["perioddata"]: - if isinstance(var, tuple): - use_bound = True - for v in var[-1]: - if ":" in v: - use_bound = False - - if use_bound: - self._bound_vars = var[-1] - var = var[0] - else: - for v in var[-1]: - if ":" in v: - tmp = v.split(":")[0] - self._bound_vars.append(tmp) - else: - self._bound_vars.append(v) - var_addr = self._get_advanced_variable_addr(v) - self._sp_var_addrs.append(var_addr) - var = None - - if var is not None: - var_addr = self.model.mf6.get_var_address(var.upper(), self.model.name, self.pkg_name) - self._sp_var_addrs.append(var_addr) - - self._package_vars = ListInput(self, self._package_var_addrs, spd=False) - self._sp_vars = ListInput(self, self._sp_var_addrs) - - def __repr__(self): - s = f"{self.pkg_type.upper()} Package: {self.pkg_name} \n" - s += " Advanced Package, variables only accessible through\n" - s += " get_advanced_var() and set_advanced_var() methods" - return s +class ListPackage(Package): + pass - def _set_advanced_variable_addrs(self, block, attr): - """ - General method for setting advanced variable block addresses - to their attributes. Method is used to reduce code duplication - - Parameters - ---------- - block : str - data block key - attr : str - attribute name - - Returns - ------- - None - """ - var_addrs = [] - if block in self._adv_var_dict: - for var in self._adv_var_dict[block]: - if not isinstance(var, tuple): - var_addrs.append(self._get_advanced_variable_addr(var)) - else: - for v in var: - var_addrs.append(self._get_advanced_variable_addr(v)) - setattr(self, attr, var_addrs) +class ArrayPackage(Package): + pass - def _get_advanced_variable_addr(self, var_str): - """ - Method to create variable addresses for advanced packages that can - include non-standard logic and processing instructions - Parameters - ---------- - var_str : str +class ScalarPackage(Package): + pass - Returns - ------- - var_addr : str - """ - s = f"{self.model.name}/{self.pkg_name}/{var_str.upper()}" - return s - @property - def packagedata(self): - """ - Returns a BlockInput object of the packagedata - """ - return self._package_vars - - @packagedata.setter - def packagedata(self, recarray): - """ - Setter method to update the packagedata - - Parameters - ---------- - recarray : np.recarray, ListInput, or None - - """ - if self._package_vars is not None: - if isinstance(recarray, np.recarray): - self._package_vars.values = recarray - elif isinstance(recarray, ListInput): - self._package_vars.values = recarray.values - elif recarray is None: - self._package_vars.values = recarray - else: - raise TypeError(f"{type(recarray)} is not a supported stress_period_data type") +class AdvancedPackage(Package): + pass - @property - def maxbound(self): - """ - Returns the "maxbound" value for the stress period - """ - if self._sp_vars is not None: - return self._sp_vars._maxbound[0] - @property - def stress_period_data(self): - """ - Returns a ListInput object of the current stress_period_data - """ - return self._sp_vars - - @stress_period_data.setter - def stress_period_data(self, recarray): - """ - Setter method to update the current stress_period_data - """ - if self._sp_vars is not None: - if isinstance(recarray, np.recarray): - self._sp_vars.values = recarray - elif isinstance(recarray, ListInput): - self._sp_vars.values = recarray.values - elif recarray is None: - self._sp_vars.values = recarray - else: - raise TypeError(f"{type(recarray)} is not a supported stress_period_data type") +# ------------------------------------------------------------------ +# Solution package +# ------------------------------------------------------------------ -class ApiSlnPackage(ScalarPackage): +class ApiSlnPackage(Package): """ - Class to access solution packages + Class to access solution packages. Parameters ---------- - model : ApiModel - modflowapi model object - pkg_type : str - package type. Ex. "RCH" + sim : ApiSimulation or ApiMbase + simulation object pkg_name : str - package name (in the mf6 variables) - sim_package : bool - boolean flag for simulation level packages. Ex. TDIS, IMS - sln_type : str - ackronymn for the solution package type, default is "ims" + package name in the MF6 variables + pkg_type : str + solution type abbreviation, default 'ims' """ def __init__(self, sim, pkg_name, pkg_type="ims"): @@ -634,40 +370,12 @@ def __init__(self, sim, pkg_name, pkg_type="ims"): super().__init__(sim, f"sln-{pkg_type}", pkg_name, sim_package=True) - if pkg_type in ("ims",): - mdl = ApiMbase(sim.mf6, pkg_name.upper(), pkg_types={pkg_type: ScalarPackage}) - imslin = ScalarPackage(mdl, "ims", "IMSLINEAR") - for key, ptr in imslin._variables._ptrs.items(): - if key in self._variables._ptrs: - key = f"{imslin.pkg_type}_{key}".lower() - self._variables._ptrs[key] = ptr + if pkg_type == "ims": + mdl = ApiMbase(sim.mf6, pkg_name.upper(), pkg_types={pkg_type: Package}) + imslin = Package(mdl, "ims", "IMSLINEAR", sim_package=True) + for key, var in imslin._inputs.items(): + if key in self._inputs: + key = f"{imslin.pkg_type}_{key}" + self._inputs[key] = var else: - self.mxiter = 10 - - -def package_factory(pkg_type, basepackage): - """ - Method to autogenerate unique package "types" from the base packages: - ArrayPackage, ListPackage, and AdvancedPackage - - Parameters - ---------- - pkg_type : str - package type - basepackage : ArrayPackage, ListPackage, or AdvancedPackage - a base package type - - Returns - Package object : ex. ApiWelPackage - """ - - # hack for now. need a pkg_type variable for robustness - def __init__(self, obj, model, pkg_type, pkg_name, sim_package=False): - obj.__init__(self, model, pkg_type, pkg_name, sim_package=sim_package) - - cls_str = "".join(pkg_type.split("-")) - cls_str = f"{cls_str[0].upper()}{cls_str[1:]}" - - package = type(f"Api{cls_str}Package", (basepackage,), {"__init__": __init__}) - - return package + object.__setattr__(self, "mxiter", 10) From b31b97c8623b18d0c4ea221db328ca1092eaa6b3 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 25 Jun 2026 19:47:15 -0700 Subject: [PATCH 2/8] fix --- modflowapi/extensions/pakbase.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modflowapi/extensions/pakbase.py b/modflowapi/extensions/pakbase.py index 85411cf..0c62759 100644 --- a/modflowapi/extensions/pakbase.py +++ b/modflowapi/extensions/pakbase.py @@ -163,6 +163,11 @@ def __setattr__(self, item, value): if item.startswith("_") or item in _BASE_ATTRS: object.__setattr__(self, item, value) return + for cls in type(self).__mro__: + desc = cls.__dict__.get(item) + if desc is not None and hasattr(desc, "__set__"): + desc.__set__(self, value) + return try: inputs = object.__getattribute__(self, "_inputs") except AttributeError: From 0690186cc90df7b59063f705698be07ad2e7a973 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 25 Jun 2026 19:51:23 -0700 Subject: [PATCH 3/8] no variable properties --- modflowapi/extensions/pakbase.py | 101 ++++++++++++------------------- 1 file changed, 39 insertions(+), 62 deletions(-) diff --git a/modflowapi/extensions/pakbase.py b/modflowapi/extensions/pakbase.py index 0c62759..a48c3cd 100644 --- a/modflowapi/extensions/pakbase.py +++ b/modflowapi/extensions/pakbase.py @@ -28,12 +28,10 @@ def __init__(self, model, pkg_type, pkg_name, sim_package=False): self.pkg_type = pkg_type self.pkg_name = pkg_name.upper() self._sim_package = sim_package - self._rhs = None - self._hcof = None self._bound_vars = [] self._advanced_var_names = None self._idm_enabled = False - self._inputs = {} + self._vars = {} self._variables_adv = None self._build_inputs() @@ -67,7 +65,7 @@ def _build_list_inputs(self, vars_list): self._idm_enabled = True var_addrs.append(addr_chk) - self._inputs["stress_period_data"] = ListVar(self, var_addrs, spd=True) + self._vars["stress_period_data"] = ListVar(self, var_addrs, spd=True) def _build_plain_inputs(self, vars_list): ivn = self.model.mf6.get_input_var_names() @@ -80,18 +78,18 @@ def _build_plain_inputs(self, vars_list): continue name = var.lower() if self._sim_package: - self._inputs[name] = ScalarVar(name, self.model.mf6.get_value_ptr(addr)) + self._vars[name] = ScalarVar(name, self.model.mf6.get_value_ptr(addr)) else: arr_var = ArrayVar(self, addr) if arr_var.name is not None: - self._inputs[name] = arr_var + self._vars[name] = arr_var def _build_advanced_inputs(self): adv_var_dict = adv_pkgvars[self.pkg_type] pkg_var_addrs = self._collect_adv_var_addrs(adv_var_dict, "packagedata") if pkg_var_addrs: - self._inputs["packagedata"] = ListVar(self, pkg_var_addrs, spd=False) + self._vars["packagedata"] = ListVar(self, pkg_var_addrs, spd=False) sp_var_addrs = [] if "perioddata" in adv_var_dict: @@ -113,14 +111,14 @@ def _build_advanced_inputs(self): sp_var_addrs.append(self.model.mf6.get_var_address(var.upper(), self.model.name, self.pkg_name)) if sp_var_addrs: - self._inputs["stress_period_data"] = ListVar(self, sp_var_addrs, spd=True) + self._vars["stress_period_data"] = ListVar(self, sp_var_addrs, spd=True) for block in adv_var_dict: if block in _ADV_BLOCK_NAMES: continue var_addrs = self._collect_adv_var_addrs(adv_var_dict, block) if var_addrs: - self._inputs[block] = ListVar(self, var_addrs, spd=False, name=block) + self._vars[block] = ListVar(self, var_addrs, spd=False, name=block) def _collect_adv_var_addrs(self, adv_var_dict, block): var_addrs = [] @@ -149,14 +147,26 @@ def __repr__(self): s += f" {name}\n" return s + def _try_discover_var(self, name): + """Try to build an ArrayVar for a package-scoped variable by name, return None if unavailable.""" + if self._sim_package: + return None + var_addr = self.model.mf6.get_var_address(name.upper(), self.model.name, self.pkg_name) + arr_var = ArrayVar(self, var_addr) + return arr_var if arr_var.name is not None else None + def __getattr__(self, item): try: - inputs = object.__getattribute__(self, "_inputs") + vars_ = object.__getattribute__(self, "_vars") except AttributeError: raise AttributeError(item) - if item in inputs: - v = inputs[item] + if item in vars_: + v = vars_[item] return v.values if isinstance(v, ScalarVar) else v + var = self._try_discover_var(item) + if var is not None: + vars_[item] = var + return var.values if isinstance(var, ScalarVar) else var raise AttributeError(item) def __setattr__(self, item, value): @@ -169,12 +179,17 @@ def __setattr__(self, item, value): desc.__set__(self, value) return try: - inputs = object.__getattribute__(self, "_inputs") + vars_ = object.__getattribute__(self, "_vars") except AttributeError: object.__setattr__(self, item, value) return - if item in inputs: - inputs[item].values = value + if item in vars_: + vars_[item].values = value + return + var = self._try_discover_var(item) + if var is not None: + vars_[item] = var + vars_[item].values = value return raise AttributeError(f"{item} is not a valid attribute for {self.pkg_type}") @@ -185,11 +200,11 @@ def __setattr__(self, item, value): @property def variable_names(self): """Returns a sorted list of non-list variable names accessible through the API.""" - return sorted(n for n, v in self._inputs.items() if not isinstance(v, ListVar)) + return sorted(n for n, v in self._vars.items() if not isinstance(v, ListVar)) @property def _spd_var(self): - return next((v for v in self._inputs.values() if isinstance(v, ListVar) and v._spd), None) + return next((v for v in self._vars.values() if isinstance(v, ListVar) and v._spd), None) @property def nbound(self): @@ -203,44 +218,6 @@ def maxbound(self): lv = self._spd_var return lv._maxbound[0] if lv is not None else None - @property - def rhs(self): - if not self._sim_package: - if self._rhs is None: - var_addr = self.model.mf6.get_var_address("RHS", self.model.name, self.pkg_name) - if var_addr in self.model.mf6.get_input_var_names(): - self._rhs = self.model.mf6.get_value_ptr(var_addr) - else: - return None - return np.copy(self._rhs) - - @rhs.setter - def rhs(self, values): - if self._rhs is None: - rhs = self.rhs - if rhs is None: - raise Exception(f"{self.pkg_type} does not have a rhs array") - self._rhs[:] = values[:] - - @property - def hcof(self): - if not self._sim_package: - if self._hcof is None: - var_addr = self.model.mf6.get_var_address("HCOF", self.model.name, self.pkg_name) - if var_addr in self.model.mf6.get_input_var_names(): - self._hcof = self.model.mf6.get_value_ptr(var_addr) - else: - return None - return np.copy(self._hcof) - - @hcof.setter - def hcof(self, values): - if self._hcof is None: - hcof = self.hcof - if hcof is None: - raise Exception(f"{self.pkg_type} does not have an hcof array") - self._hcof[:] = values[:] - @property def advanced_vars(self): """Returns a list of additional variables accessible through get/set_advanced_var.""" @@ -299,28 +276,28 @@ def set_advanced_var(self, name, values): def get_array(self, item): """Get a grid-shaped array variable by name.""" - v = self._inputs.get(item) + v = self._vars.get(item) if v is None or not isinstance(v, ArrayVar): raise KeyError(f"{item} is not accessible in this package") return v.values def set_array(self, item, array): """Set a grid-shaped array variable by name.""" - v = self._inputs.get(item) + v = self._vars.get(item) if v is None or not isinstance(v, ArrayVar): raise KeyError(f"{item} is not a valid variable name for this package") v.values = array def get_value(self, item): """Get a scalar variable by name.""" - v = self._inputs.get(item) + v = self._vars.get(item) if v is None or not isinstance(v, ScalarVar): raise KeyError(f"{item} is not accessible in this package") return v.values def set_value(self, item, value): """Set a scalar variable by name.""" - v = self._inputs.get(item) + v = self._vars.get(item) if v is None or not isinstance(v, ScalarVar): raise KeyError(f"{item} is not accessible in this package") v.values = value @@ -378,9 +355,9 @@ def __init__(self, sim, pkg_name, pkg_type="ims"): if pkg_type == "ims": mdl = ApiMbase(sim.mf6, pkg_name.upper(), pkg_types={pkg_type: Package}) imslin = Package(mdl, "ims", "IMSLINEAR", sim_package=True) - for key, var in imslin._inputs.items(): - if key in self._inputs: + for key, var in imslin._vars.items(): + if key in self._vars: key = f"{imslin.pkg_type}_{key}" - self._inputs[key] = var + self._vars[key] = var else: object.__setattr__(self, "mxiter", 10) From bdb3aed617266773e257a4c089f475efb4bc1433 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Fri, 26 Jun 2026 04:35:08 -0700 Subject: [PATCH 4/8] fix array behavior --- modflowapi/extensions/data.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/modflowapi/extensions/data.py b/modflowapi/extensions/data.py index 4118ed4..67aff7b 100644 --- a/modflowapi/extensions/data.py +++ b/modflowapi/extensions/data.py @@ -73,8 +73,12 @@ def values(self): value = np.ones((self.parent.model.size,)) * np.nan if self._ptr.size == self.parent.model.size: value[:] = self._ptr.ravel() - else: + elif self._ptr.size == self.parent.model.nodetouser.size: + # Variable lives on the reduced (active-node) grid value[self.parent.model.nodetouser] = self._ptr.ravel() + else: + # Non-grid variable (e.g. per-boundary rhs/hcof); return raw copy + return np.copy(self._ptr.ravel()) return value.reshape(self.parent.model.shape) else: return np.copy(self._ptr.ravel()) @@ -84,15 +88,21 @@ def values(self, array): if not isinstance(array, np.ndarray): raise TypeError() if not self.parent._sim_package: - if array.size != self.parent.model.size: + if array.size == self.parent.model.size: + array = array.ravel() + if self._ptr.size != array.size: + array = array[self.parent.model.nodetouser] + if len(self._vshape) > 1: + array.shape = self._vshape + elif array.size == self._ptr.size: + # Non-grid variable (e.g. per-boundary rhs/hcof); assign directly + array = array.ravel() + if len(self._vshape) > 1: + array.shape = self._vshape + else: raise ValueError( f"{self.name} size {array.size} is not equal to modflow variable size {self.parent.model.size}" ) - array = array.ravel() - if self._ptr.size != array.size: - array = array[self.parent.model.nodetouser] - if len(self._vshape) > 1: - array.shape = self._vshape else: array = array.ravel() self._ptr[:] = array From 6647f91db8c68b8c02fc0681eb6b665d5dc54afb Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Fri, 26 Jun 2026 08:40:03 -0700 Subject: [PATCH 5/8] numpy compat, with in-place semantics --- modflowapi/extensions/data.py | 24 +++++++++++++++++++++++- modflowapi/extensions/pakbase.py | 2 ++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/modflowapi/extensions/data.py b/modflowapi/extensions/data.py index 67aff7b..063e1bc 100644 --- a/modflowapi/extensions/data.py +++ b/modflowapi/extensions/data.py @@ -3,6 +3,7 @@ import numpy as np import pandas as pd import xmipy.errors +from numpy.lib.mixins import NDArrayOperatorsMixin class InputVar(ABC): @@ -21,7 +22,7 @@ def values(self): ... def values(self, value): ... -class ArrayVar(InputVar): +class ArrayVar(InputVar, NDArrayOperatorsMixin): """ A single grid-shaped array variable from the MODFLOW 6 memory manager. @@ -67,6 +68,25 @@ def __setitem__(self, key, value): array[key] = value self.values = array + def __array__(self, dtype=None, **kwargs): + v = self.values + return np.asarray(v, dtype=dtype) + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + inputs = tuple(np.asarray(x) if isinstance(x, ArrayVar) else x for x in inputs) + out = kwargs.pop("out", None) + result = getattr(ufunc, method)(*inputs, **kwargs) + if out is not None and any(x is self for x in out): + self.values = result + return self + return result + + def __len__(self): + return len(self.values) + + def __repr__(self): + return repr(self.values) + @property def values(self): if not self.parent._sim_package: @@ -85,6 +105,8 @@ def values(self): @values.setter def values(self, array): + if isinstance(array, ArrayVar): + array = np.copy(array._ptr.ravel()) if not isinstance(array, np.ndarray): raise TypeError() if not self.parent._sim_package: diff --git a/modflowapi/extensions/pakbase.py b/modflowapi/extensions/pakbase.py index a48c3cd..3f561af 100644 --- a/modflowapi/extensions/pakbase.py +++ b/modflowapi/extensions/pakbase.py @@ -263,6 +263,8 @@ def get_advanced_var(self, name): def set_advanced_var(self, name, values): """Set a variable not surfaced through stress_period_data or variable_names.""" + if isinstance(values, ArrayVar): + values = np.asarray(values) if not self._sim_package: if values.size == self.model.size: values = values[self.model.nodetouser] From e18ced01f42ab5f5bf8934f282d7a370f15bf6a3 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 30 Jun 2026 05:03:13 -0700 Subject: [PATCH 6/8] separate data model refactor --- modflowapi/extensions/data.py | 840 ++++++++++++++++++++++--------- modflowapi/extensions/pakbase.py | 114 ++++- 2 files changed, 710 insertions(+), 244 deletions(-) diff --git a/modflowapi/extensions/data.py b/modflowapi/extensions/data.py index 063e1bc..5684a76 100644 --- a/modflowapi/extensions/data.py +++ b/modflowapi/extensions/data.py @@ -1,196 +1,41 @@ -from abc import ABC, abstractmethod - import numpy as np import pandas as pd import xmipy.errors -from numpy.lib.mixins import NDArrayOperatorsMixin - - -class InputVar(ABC): - """Abstract base for a single named variable in the MODFLOW 6 memory manager.""" - - @property - @abstractmethod - def name(self) -> str: ... - - @property - @abstractmethod - def values(self): ... - - @values.setter - @abstractmethod - def values(self, value): ... -class ArrayVar(InputVar, NDArrayOperatorsMixin): +class ListInput: """ - A single grid-shaped array variable from the MODFLOW 6 memory manager. + Data object for storing pointers and working with list based input data Parameters ---------- - parent : Package - modflowapi Package object - var_addr : str - variable address string - mf6 : ModflowApi, optional - required if parent is None - """ - - def __init__(self, parent, var_addr, mf6=None): - self._ptr = None - self.parent = parent - self._name = None - self._vshape = None - - if self.parent is not None: - self.mf6 = self.parent.model.mf6 - else: - if mf6 is None: - raise AssertionError("mf6 must be supplied if parent is None") - self.mf6 = mf6 - - ivn = self.mf6.get_input_var_names() - if var_addr in ivn: - values = self.mf6.get_value_ptr(var_addr) - self._name = var_addr.split("/")[-1].lower() - self._vshape = values.shape - self._ptr = values - - @property - def name(self) -> str: - return self._name - - def __getitem__(self, item): - return self.values[item] - - def __setitem__(self, key, value): - array = self.values - array[key] = value - self.values = array - - def __array__(self, dtype=None, **kwargs): - v = self.values - return np.asarray(v, dtype=dtype) - - def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): - inputs = tuple(np.asarray(x) if isinstance(x, ArrayVar) else x for x in inputs) - out = kwargs.pop("out", None) - result = getattr(ufunc, method)(*inputs, **kwargs) - if out is not None and any(x is self for x in out): - self.values = result - return self - return result - - def __len__(self): - return len(self.values) - - def __repr__(self): - return repr(self.values) - - @property - def values(self): - if not self.parent._sim_package: - value = np.ones((self.parent.model.size,)) * np.nan - if self._ptr.size == self.parent.model.size: - value[:] = self._ptr.ravel() - elif self._ptr.size == self.parent.model.nodetouser.size: - # Variable lives on the reduced (active-node) grid - value[self.parent.model.nodetouser] = self._ptr.ravel() - else: - # Non-grid variable (e.g. per-boundary rhs/hcof); return raw copy - return np.copy(self._ptr.ravel()) - return value.reshape(self.parent.model.shape) - else: - return np.copy(self._ptr.ravel()) - - @values.setter - def values(self, array): - if isinstance(array, ArrayVar): - array = np.copy(array._ptr.ravel()) - if not isinstance(array, np.ndarray): - raise TypeError() - if not self.parent._sim_package: - if array.size == self.parent.model.size: - array = array.ravel() - if self._ptr.size != array.size: - array = array[self.parent.model.nodetouser] - if len(self._vshape) > 1: - array.shape = self._vshape - elif array.size == self._ptr.size: - # Non-grid variable (e.g. per-boundary rhs/hcof); assign directly - array = array.ravel() - if len(self._vshape) > 1: - array.shape = self._vshape - else: - raise ValueError( - f"{self.name} size {array.size} is not equal to modflow variable size {self.parent.model.size}" - ) - else: - array = array.ravel() - self._ptr[:] = array - - -class ScalarVar(InputVar): - """ - A single scalar variable from the MODFLOW 6 memory manager. - - Parameters - ---------- - name : str - variable name - ptr : np.ndarray - 1-element pointer array from the MODFLOW 6 memory manager - """ - - def __init__(self, name: str, ptr): - self._name = name - self._ptr = ptr - - @property - def name(self) -> str: - return self._name - - @property - def values(self): - return self._ptr[0] - - @values.setter - def values(self, v): - self._ptr[0] = v - - -class ListVar(InputVar): - """ - A single list/recarray block from the MODFLOW 6 memory manager. - - Parameters - ---------- - parent : Package - modflowapi Package object - var_addrs : list - variable address strings - mf6 : ModflowApi, optional - required if parent is None + parent : ListPackage + modflowapi ListPackage object + var_addrs : list, None + optional list of variable addresses + mf6 : ModflowApi, None + optional ModflowApi object spd : bool - True for stress period data, False for packagedata - name : str, optional - override the default block name + flag to indicate if the block is loading stress period data or other + list based block data. """ - _nodevars = ("nodelist", "nexg", "maxats") - - def __init__(self, parent, var_addrs, mf6=None, spd=True, name=None): + def __init__(self, parent, var_addrs=None, mf6=None, spd=True): self.parent = parent + self.var_addrs = var_addrs if self.parent is not None: + if var_addrs is None: + self.var_addrs = self.parent.var_addrs self.mf6 = self.parent.model.mf6 else: - if mf6 is None: - raise AssertionError("mf6 must be supplied if parent is None") + if var_addrs is None or mf6 is None: + raise AssertionError("var_addrs and mf6 must be supplied if parent is None") + self.mf6 = mf6 - self._name = name or ("stress_period_data" if spd else "packagedata") self._ptrs = {} self._compound_ptrs = {} + self._nodevars = ("nodelist", "nexg", "maxats") self._bound = "bound" self._maxbound = [0] @@ -199,24 +44,27 @@ def __init__(self, parent, var_addrs, mf6=None, spd=True, name=None): self._auxvar_name = "auxvar" self._auxnames = [] self._dtype = [] + self._reduced_to_var_addr = {} self._spd = spd - var_addrs = list(var_addrs) if self.parent._idm_enabled: for var in ("BOUND", "AUXVAR"): - var_addrs.pop( - var_addrs.index(self.mf6.get_var_address(var, self.parent.model.name, self.parent.pkg_name)) + self.var_addrs.pop( + self.var_addrs.index(self.mf6.get_var_address(var, self.parent.model.name, self.parent.pkg_name)) ) - var_addrs.append(self.mf6.get_var_address("AUXVAR_IDM", self.parent.model.name, self.parent.pkg_name)) - self._set_data(var_addrs) + self.var_addrs.append(self.mf6.get_var_address("AUXVAR_IDM", self.parent.model.name, self.parent.pkg_name)) - @property - def name(self) -> str: - return self._name + self._set_data() - def _set_data(self, var_addrs): - for var_addr in var_addrs: + def _set_data(self): + """ + Method to set stress period data variable pointers to the _ptrs + dictionary. Uses IDM updates instead of bound to access variable + pointers. + """ + # for now we need to add self.parent._bound_vars data to var_addrs + for var_addr in self.var_addrs: if ":" in var_addr: addr_pieces = var_addr.split("/") compound = addr_pieces[-1] @@ -232,12 +80,27 @@ def _set_data(self, var_addrs): if reduced in ("ndiv",): nbound = self._special_condition_to_values(ctype, values) - self._maxbound = [len(values)] - self._nbound = [nbound] + setattr( + self, + "_maxbound", + [ + len(values), + ], + ) + setattr( + self, + "_nbound", + [ + nbound, + ], + ) else: self._compound_ptrs[ptr_var] = values self._ptrs[reduced] = f"{ctype}:{ptr_var}" - self._dtype.append((reduced, values.dtype.str)) + + typ_str = values.dtype.str + dtype = (reduced, typ_str) + self._dtype.append(dtype) else: try: values = self.mf6.get_value_ptr(var_addr) @@ -253,32 +116,50 @@ def _set_data(self, var_addrs): if not self._spd and reduced == "maxbound": self._nbound = values elif reduced in ("nexg", "maxats", "nlakes", "nmawwells"): - self._maxbound = values - self._nbound = values + setattr(self, "_maxbound", values) + setattr(self, "_nbound", values) elif reduced in ("naux",): - self._naux = values - elif reduced in ("auxname_cst",): - self._auxnames = list(values) + setattr(self, "_naux", values) + elif reduced in ("auxname_cst"): + setattr(self, "_auxnames", list(values)) else: self._ptrs[reduced] = values + self._reduced_to_var_addr[reduced] = var_addr if reduced == self._bound: - for nm in self.parent._bound_vars: - self._dtype.append((nm, values.dtype.str)) + # retain this method for advanced packages + for name in self.parent._bound_vars: + typ_str = values.dtype.str + dtype = (name, typ_str) + self._dtype.append(dtype) elif reduced in self.parent._bound_vars: - self._dtype.append((reduced, values.dtype.str)) + typ_str = values.dtype.str + dtype = (reduced, typ_str) + self._dtype.append(dtype) elif reduced in self._nodevars: - self._dtype.append((reduced, "O")) + dtype = (reduced, "O") + self._dtype.append(dtype) elif "auxvar" in reduced: - self._auxvar_name = reduced - if self._naux[0] == 0: + self._auxvar_name = reduced # == "auxvar_idm": + if self._naux == 0: continue else: for ix in range(self._naux[0]): - self._dtype.append((self._auxnames[ix], values.dtype.str)) + typ_str = values.dtype.str + dtype = (self._auxnames[ix], typ_str) + self._dtype.append(dtype) else: - self._dtype.append((reduced, values.dtype.str)) + typ_str = values.dtype.str + dtype = (reduced, typ_str) + self._dtype.append(dtype) def _ptr_to_recarray(self): + """ + Method to get a recarray of stress period data from modflow pointers + + Returns + ------- + np.recarray + """ if self._nbound[0] == 0: return recarray = np.recarray((self._nbound[0],), self._dtype) @@ -287,6 +168,7 @@ def _ptr_to_recarray(self): continue if isinstance(ptr, str): + # special condition and not a real ptr ctype, ptr_name = ptr.split(":") ptr_vals = self._compound_ptrs[ptr_name] values = self._special_condition_to_values(ctype, ptr_vals) @@ -294,11 +176,13 @@ def _ptr_to_recarray(self): values = np.copy(ptr) if name == self._bound: + # note: keep block around for advanced packages for ix, nm in enumerate(self.parent._bound_vars): bnd_values = values[0 : self._nbound[0], ix] recarray[nm][0 : self._nbound[0]] = bnd_values elif name in self.parent._bound_vars and self.parent._idm_enabled: + # IDM simplification method bnd_values = values[0 : self._nbound[0]].ravel() recarray[name][0 : self._nbound[0]] = bnd_values @@ -317,6 +201,7 @@ def _ptr_to_recarray(self): values -= 1 values = self.parent.model.nodetouser[values] values = list(zip(*np.unravel_index(values, self.parent.model.shape))) + elif name in ("idv", "divreach"): values -= 1 @@ -326,6 +211,17 @@ def _ptr_to_recarray(self): return recarray def _recarray_to_ptr(self, recarray): + """ + Method to update stress period information pointers from user supplied + data + + Parameters + ---------- + recarray : np.recarray + numpy recarray of stress period data + + """ + if recarray is None: self._nbound[0] = 0 return @@ -336,6 +232,7 @@ def _recarray_to_ptr(self, recarray): f"Length of stresses ({len(recarray)},) cannot be larger than maxbound value ({self._maxbound[0]},)" ) self._nbound[0] = len(recarray) + if len(recarray) == 0: return @@ -353,6 +250,7 @@ def _recarray_to_ptr(self, recarray): visited.append(name) elif self._bound in self._ptrs and name in self.parent._bound_vars: + # Check for bound to support advanced packages idx = self.parent._bound_vars.index(name) self._ptrs[self._bound][0 : self._nbound[0], idx] = recarray[name] visited.append(name) @@ -377,6 +275,21 @@ def _recarray_to_ptr(self, recarray): visited.append(name) def _special_condition_to_values(self, ctype, inval): + """ + Method to catch and set special, compound conditions where necessary data + is not directly available from MODFLOW + + Parameters + ---------- + ctype : str + condition type + inval : int, float, np.ndarray + input data value + + Returns + ------- + outval : np.array of values + """ functions = { "range": lambda x: np.arange(0, int(x), dtype=int), "count_nonzero": lambda x: np.count_nonzero(x), @@ -386,32 +299,59 @@ def _special_condition_to_values(self, ctype, inval): ctype = ctype.lower() if ctype in ("range",): inval = inval[0] - return functions[ctype](inval) + + func = functions[ctype] + outval = func(inval) + return outval def _special_condition_to_ptr(self, recarray, name, visited): + """ + Method to catch and set special, compound conditions to the associated MODFLOW + ptr where the user data is not directly available from MODFLOW + + Parameters + ---------- + recarray : np.recarray + recarray of user data + name : str + data column name + visited : list + list of visited data columns used to avoid duplicate processing + + + Returns + ------- + visited : list + """ functions = { - "range": lambda x: [len(x)], + "range": lambda x: [ + len(x), + ], } ctype, ptr_name = self._ptrs[name].split(":") if "where" in ctype: - idx_name = val_name = None + idx_name = None + val_name = None if ctype == "where_idx": idx_name = name for k, v in self._ptrs.items(): - if isinstance(v, str) and v == f"where_val:{ptr_name}": - val_name = k - break + if isinstance(v, str): + if v == f"where_val:{ptr_name}": + val_name = k + break + elif ctype == "where_val": val_name = name for k, v in self._ptrs.items(): - if isinstance(v, str) and v == f"where_idx:{ptr_name}": - idx_name = k - break + if isinstance(v, str): + if v == f"where_idx:{ptr_name}": + idx_name = k + break else: - return visited + return if idx_name is None or val_name is None: - return visited + return idx = list(recarray[idx_name].astype(int)) vals = recarray[val_name].ravel() @@ -419,6 +359,7 @@ def _special_condition_to_ptr(self, recarray, name, visited): vals += 1 self._compound_ptrs[ptr_name][idx] = vals visited.extend([idx_name, val_name]) + else: func = functions[ctype] values = func(recarray[name]) @@ -438,16 +379,23 @@ def __setitem__(self, key, value): @property def dtype(self): + """ + Returns the numpy dtypes for the recarray + """ return self._dtype @property def values(self): + """ + Returns a np.recarray of the current stress_period_data + """ return self._ptr_to_recarray() @values.setter def values(self, recarray): - if isinstance(recarray, ListVar): - recarray = recarray.values + """ + Setter method to update the current stress_period_data + """ self._recarray_to_ptr(recarray) @property @@ -461,6 +409,259 @@ def dataframe(self, dataframe): self._recarray_to_ptr(recarray) +class ArrayPointer: + """ + Data object for storing single pointers and + working with array based input data + + Parameters + ---------- + parent : ArrayPackage + ArrayPackage object + var_addr : str + variable pointer location + mf6 : ModflowApi + optional ModflowApi object + """ + + def __init__(self, parent, var_addr, mf6=None): + self._ptr = None + self.parent = parent + self._mapping = None + self.name = None + self.var_addr = var_addr + self._vshape = None + + if self.parent is not None: + self.mf6 = self.parent.model.mf6 + else: + if mf6 is None: + raise AssertionError("mf6 must be supplied if parent is None") + self._set_array() + + def _set_array(self): + ivn = self.mf6.get_input_var_names() + if self.var_addr in ivn: + values = self.mf6.get_value_ptr(self.var_addr) + reduced = self.var_addr.split("/")[-1].lower() + self._vshape = values.shape + self._ptr = values + self.name = reduced + + def __getitem__(self, item): + return self.values[item] + + def __setitem__(self, key, value): + array = self.values + array[key] = value + self.values = array + + @property + def values(self): + """ + Method to get an array from modflow + + Returns + ------- + np.array of modflow data + """ + if not self.parent._sim_package: + value = np.ones((self.parent.model.size,)) * np.nan + if self._ptr.size == self.parent.model.size: + value[:] = self._ptr.ravel() + else: + value[self.parent.model.nodetouser] = self._ptr.ravel() + return value.reshape(self.parent.model.shape) + else: + return np.copy(self._ptr.ravel()) + + @values.setter + def values(self, array): + """ + Method to update the modflow pointer arrays + + Parameters + ---------- + array : np.array + numpy array + + """ + + if not isinstance(array, np.ndarray): + raise TypeError() + if not self.parent._sim_package: + if array.size != self.parent.model.size: + raise ValueError( + f"{self.name} size {array.size} is not equal to modflow variable size {self.parent.model.size}" + ) + + array = array.ravel() + if self._ptr.size != array.size: + array = array[self.parent.model.nodetouser] + if len(self._vshape) > 1: + array.shape = self._vshape + else: + array = array.ravel() + self._ptr[:] = array + + +class ArrayInput: + """ + Data object for storing pointers and working with array based input data + + Parameters + ---------- + parent : ArrayPackage + modflowapi ArrayPackage object + var_addrs : list, None + optional list of variable addresses + mf6 : ModflowApi, None + optional ModflowApi object + """ + + def __init__(self, parent, var_addrs=None, mf6=None): + self._ptrs = {} + self.parent = parent + # change this to a parent package.mapping + self._mapping = None + + if self.parent is not None: + self.var_addrs = self.parent.var_addrs + self.mf6 = self.parent.model.mf6 + else: + if var_addrs is None or mf6 is None: + raise AssertionError("var_addrs and mf6 must be supplied if parent is None") + self.var_addrs = var_addrs + self.mf6 = mf6 + + self._maxbound = [0] + self._nbound = [0] + self._reduced_to_var_addr = {} + self._set_arrays() + + def _set_arrays(self): + """ + Method to modflow variable pointers to the _ptrs dictionary + """ + ivn = self.mf6.get_input_var_names() + for var_addr in self.var_addrs: + if var_addr in ivn: + ptr = ArrayPointer(self.parent, var_addr) + reduced = var_addr.split("/")[-1].lower() + self._ptrs[reduced] = ptr + self._reduced_to_var_addr[reduced] = var_addr + + def __getattr__(self, item): + """ + Dynamic method to get modflow variables as an attribute + """ + if item in self._ptrs: + return self._ptrs[item] + else: + return super(ArrayInput).__getattribute__(item) + + def __setattr__(self, item, value): + """ + Dynamic method that allows users to set modflow variables to the + _ptr dict + """ + if item in ( + "parent", + "var_addrs", + "_mapping", + "_ptrs", + "mf6", + "_maxbound", + "_nbound", + "_reduced_to_var_addr", + ): + super().__setattr__(item, value) + + elif item in self._ptrs: + if isinstance(value, ArrayPointer): + self._ptrs[item] = value + else: + raise AttributeError(f"{item} is not a valid attribute") + + @property + def variable_names(self): + """ + Returns a list of valid array names that can be accessed by the user + """ + return list(sorted(self._ptrs.keys())) + + def get_ptr(self, item): + """ + Method to get the ArrayPointer object + + Parameters + ---------- + item : str + modflow variable name: Ex. "k11" + + Returns + ------- + ArrayPointer object + """ + if item in self._ptrs: + return self._ptrs[item] + + def set_ptr(self, item, ptr): + """ + Method to set an ArrayPointer object + + Parameters + ---------- + item : str + modflow variable name: Ex. "k11" + ptr : ArrayPointer + ArrayPointer object + """ + if item in self._ptrs: + if isinstance(ptr, ArrayPointer): + self._ptrs[item] = ptr + else: + raise TypeError("An ArrayPointer object must be provided") + + else: + raise KeyError(f"{item} is not accessible in this package") + + def get_array(self, item): + """ + Method to get an array from modflow + + Parameters + ---------- + item : str + modflow variable name. Ex. "k11" + + Returns + ------- + np.array of modflow data + """ + if item in self._ptrs: + return self._ptrs[item].values + else: + raise KeyError(f"{item} is not accessible in this package") + + def set_array(self, item, array): + """ + Method to update the modflow pointer arrays + + Parameters + ---------- + item : str + modflow variable name. Ex. "k11" + array : np.array + numpy array + + """ + if item in self._ptrs: + self._ptrs[item].values = array + else: + raise KeyError(f"{item} is not a valid variable name for this package") + + class AdvancedInput(object): """ Data object for dynamically storing pointers and working with @@ -468,10 +669,10 @@ class AdvancedInput(object): Parameters ---------- - parent : Package - modflowapi Package object - mf6 : ModflowApi, optional - required if parent is None + parent : ArrayPackage + modflowapi ArrayPackage object + mf6 : ModflowApi, None + optional ModflowApi object """ def __init__(self, parent, mf6=None): @@ -485,14 +686,38 @@ def __init__(self, parent, mf6=None): raise AssertionError("mf6 must be supplied if parent is None") self.mf6 = mf6 - def get_variable(self, name): + def get_variable(self, name, model=None, package=None): + """ + method to assemble a variable address and get a variable from the + ModflowApi instance + + Parameters: + ---------- + name : str + variable name + model : str + optional model name, note this is required if parent is None + package : str + optional package name, note this is required if parent is None + + Returns: + ------- + np.ndarray or scalar float, int, string, or boolean value + depending on data type and length + """ if name.lower() in self._ptrs: return self._ptrs[name.lower()] if not self.parent._sim_package: - var_addr = self.mf6.get_var_address(name.upper(), self.parent.model.name, self.parent.pkg_name) + if self.parent is not None: + var_addr = self.mf6.get_var_address(name.upper(), self.parent.model.name, self.parent.pkg_name) + else: + var_addr = self.mf6.get_var_address(name.upper(), model.upper(), package.upper()) else: - var_addr = self.mf6.get_var_address(name.upper(), self.parent.pkg_name) + if self.parent is not None: + var_addr = self.mf6.get_var_address(name.upper(), self.parent.pkg_name) + else: + var_addr = self.mf6.get_var_address(name.upper(), package.upper()) try: values = self.mf6.get_value_ptr(var_addr) @@ -502,9 +727,34 @@ def get_variable(self, name): return values.copy() - def set_variable(self, name, values): + def set_variable(self, name, values, model=None, package=None): + """ + method to assemble a variable address and get a variable from the + ModflowApi instance + + Parameters: + ---------- + name : str + variable name + values : np.ndarray + numpy array of variable values + model : str + optional model name, note this is required if parent is None + package : str + optional package name, note this is required if parent is None + + Returns: + ------- + np.ndarray or scalar float, int, string, or boolean value + depending on data type and length + """ + if model is None and not self.parent._sim_package: + model = self.parent.model.name + if package is None: + package = self.parent.pkg_name + if name.lower() not in self._ptrs: - values0 = self.get_variable(name) + values0 = self.get_variable(name, model, package) else: values0 = self._ptrs[name.lower()] @@ -514,6 +764,7 @@ def set_variable(self, name, values): ) if name.lower() not in self._ptrs: + # this is a set value situation self.mf6.set_value( self.mf6.get_var_address(name.upper(), self.parent.model.name, self.parent.pkg_name), values, @@ -522,6 +773,147 @@ def set_variable(self, name, values): self._ptrs[name.lower()][:] = values[:] -# Backward compatibility aliases -ArrayPointer = ArrayVar -ListInput = ListVar +class ScalarInput: + """ + Data object for storing pointers and working with array based input data + + Parameters + ---------- + parent : ArrayPackage + modflowapi ArrayPackage object + var_addrs : list, None + optional list of variable addresses + mf6 : ModflowApi, None + optional ModflowApi object + """ + + def __init__(self, parent, var_addrs=None, mf6=None): + self._ptrs = {} + self.parent = parent + # change this to a parent package.mapping + self._mapping = None + + if self.parent is not None: + self.var_addrs = self.parent.var_addrs + self.mf6 = self.parent.model.mf6 + else: + if var_addrs is None or mf6 is None: + raise AssertionError("var_addrs and mf6 must be supplied if parent is None") + self.var_addrs = var_addrs + self.mf6 = mf6 + + self._reduced_to_var_addr = {} + self._set_scalars() + + def __getattr__(self, item): + """ + Dynamic method to get modflow variables as an attribute + """ + if item in self._ptrs: + return self._ptrs[item] + else: + return super(ArrayInput).__getattribute__(item) + + def __setattr__(self, item, value): + """ + Dynamic method that allows users to set modflow variables to the + _ptr dict + """ + if item in ( + "parent", + "var_addrs", + "_mapping", + "_ptrs", + "mf6", + "_maxbound", + "_nbound", + "_reduced_to_var_addr", + ): + super().__setattr__(item, value) + + elif item in self._ptrs: + self._ptrs[item] = value + else: + raise AttributeError(f"{item} is not a valid attribute") + + @property + def variable_names(self): + """ + Returns a list of valid array names that can be accessed by the user + """ + return list(sorted(self._ptrs.keys())) + + def _set_scalars(self): + """ + Method to modflow variable pointers to the _ptrs dictionary + """ + ivn = self.mf6.get_input_var_names() + for var_addr in self.var_addrs: + if var_addr in ivn: + ptr = self.mf6.get_value_ptr(var_addr) + reduced = var_addr.split("/")[-1].lower() + self._ptrs[reduced] = ptr + self._reduced_to_var_addr[reduced] = var_addr + + def get_value(self, item): + """ + Method to get a scalar value from modflow + + Parameters + ---------- + item : str + modflow variable name. Ex. "NPER" + + Returns + ------- + str, int, float + """ + if item in self._ptrs: + return self._ptrs[item][0] + else: + raise KeyError(f"{item} is not accessible in this package") + + def set_value(self, item, value): + """ + Method to set a scalar value in modflow + + Parameters + ---------- + item : str + modflow variable name. Ex. "NPER" + + value : str, int, float + """ + if item in self._ptrs: + self._ptrs[item][0] = value + else: + raise KeyError(f"{item} is not accessible in this package") + + +class ScalarVar: + """ + A single scalar variable from the MODFLOW 6 memory manager. + + Parameters + ---------- + name : str + variable name + ptr : np.ndarray + 1-element pointer array from the MODFLOW 6 memory manager + """ + + def __init__(self, name: str, ptr): + self._name = name + self._ptr = ptr + + @property + def name(self) -> str: + return self._name + + @property + def values(self): + return self._ptr[0] + + @values.setter + def values(self, v): + self._ptr[0] = v diff --git a/modflowapi/extensions/pakbase.py b/modflowapi/extensions/pakbase.py index 3f561af..3520e4d 100644 --- a/modflowapi/extensions/pakbase.py +++ b/modflowapi/extensions/pakbase.py @@ -1,6 +1,6 @@ import numpy as np -from .data import AdvancedInput, ArrayVar, ListVar, ScalarVar +from .data import AdvancedInput, ArrayPointer, ListInput, ScalarVar from .datamodel import adv_pkgvars, pkgvars _BASE_ATTRS = frozenset({"model", "pkg_name", "pkg_type"}) @@ -31,8 +31,10 @@ def __init__(self, model, pkg_type, pkg_name, sim_package=False): self._bound_vars = [] self._advanced_var_names = None self._idm_enabled = False + self._rhs = None + self._hcof = None self._vars = {} - + self._list_vars = {} self._variables_adv = None self._build_inputs() @@ -65,7 +67,7 @@ def _build_list_inputs(self, vars_list): self._idm_enabled = True var_addrs.append(addr_chk) - self._vars["stress_period_data"] = ListVar(self, var_addrs, spd=True) + self._list_vars["stress_period_data"] = ListInput(self, var_addrs, spd=True) def _build_plain_inputs(self, vars_list): ivn = self.model.mf6.get_input_var_names() @@ -80,7 +82,7 @@ def _build_plain_inputs(self, vars_list): if self._sim_package: self._vars[name] = ScalarVar(name, self.model.mf6.get_value_ptr(addr)) else: - arr_var = ArrayVar(self, addr) + arr_var = ArrayPointer(self, addr) if arr_var.name is not None: self._vars[name] = arr_var @@ -89,7 +91,7 @@ def _build_advanced_inputs(self): pkg_var_addrs = self._collect_adv_var_addrs(adv_var_dict, "packagedata") if pkg_var_addrs: - self._vars["packagedata"] = ListVar(self, pkg_var_addrs, spd=False) + self._list_vars["packagedata"] = ListInput(self, pkg_var_addrs, spd=False) sp_var_addrs = [] if "perioddata" in adv_var_dict: @@ -111,14 +113,14 @@ def _build_advanced_inputs(self): sp_var_addrs.append(self.model.mf6.get_var_address(var.upper(), self.model.name, self.pkg_name)) if sp_var_addrs: - self._vars["stress_period_data"] = ListVar(self, sp_var_addrs, spd=True) + self._list_vars["stress_period_data"] = ListInput(self, sp_var_addrs, spd=True) for block in adv_var_dict: if block in _ADV_BLOCK_NAMES: continue var_addrs = self._collect_adv_var_addrs(adv_var_dict, block) if var_addrs: - self._vars[block] = ListVar(self, var_addrs, spd=False, name=block) + self._list_vars[block] = ListInput(self, var_addrs, spd=False, name=block) def _collect_adv_var_addrs(self, adv_var_dict, block): var_addrs = [] @@ -148,11 +150,11 @@ def __repr__(self): return s def _try_discover_var(self, name): - """Try to build an ArrayVar for a package-scoped variable by name, return None if unavailable.""" + """Try to build an ArrayPointer for a package-scoped variable by name, return None if unavailable.""" if self._sim_package: return None var_addr = self.model.mf6.get_var_address(name.upper(), self.model.name, self.pkg_name) - arr_var = ArrayVar(self, var_addr) + arr_var = ArrayPointer(self, var_addr) return arr_var if arr_var.name is not None else None def __getattr__(self, item): @@ -163,10 +165,16 @@ def __getattr__(self, item): if item in vars_: v = vars_[item] return v.values if isinstance(v, ScalarVar) else v + try: + list_vars = object.__getattribute__(self, "_list_vars") + except AttributeError: + raise AttributeError(item) + if item in list_vars: + return list_vars[item] var = self._try_discover_var(item) if var is not None: vars_[item] = var - return var.values if isinstance(var, ScalarVar) else var + return var raise AttributeError(item) def __setattr__(self, item, value): @@ -186,6 +194,14 @@ def __setattr__(self, item, value): if item in vars_: vars_[item].values = value return + try: + list_vars = object.__getattribute__(self, "_list_vars") + except AttributeError: + pass + else: + if item in list_vars: + list_vars[item].values = value + return var = self._try_discover_var(item) if var is not None: vars_[item] = var @@ -199,25 +215,83 @@ def __setattr__(self, item, value): @property def variable_names(self): - """Returns a sorted list of non-list variable names accessible through the API.""" - return sorted(n for n, v in self._vars.items() if not isinstance(v, ListVar)) + """Returns a sorted list of array/scalar variable names accessible through the API.""" + return sorted(self._vars) + + @property + def stress_period_data(self): + """Returns the ListInput for stress period data, or None if not present.""" + return self._list_vars.get("stress_period_data") + + @stress_period_data.setter + def stress_period_data(self, recarray): + lv = self._list_vars.get("stress_period_data") + if lv is not None: + lv.values = recarray @property - def _spd_var(self): - return next((v for v in self._vars.values() if isinstance(v, ListVar) and v._spd), None) + def packagedata(self): + """Returns the ListInput for packagedata, or None if not present.""" + return self._list_vars.get("packagedata") + + @packagedata.setter + def packagedata(self, recarray): + lv = self._list_vars.get("packagedata") + if lv is not None: + lv.values = recarray @property def nbound(self): """Returns the number of active boundaries for the current stress period.""" - lv = self._spd_var + lv = self._list_vars.get("stress_period_data") return lv._nbound[0] if lv is not None else None @property def maxbound(self): """Returns the maximum number of boundaries.""" - lv = self._spd_var + lv = self._list_vars.get("stress_period_data") return lv._maxbound[0] if lv is not None else None + @property + def rhs(self): + if self._sim_package: + return None + if self._rhs is None: + var_addr = self.model.mf6.get_var_address("RHS", self.model.name, self.pkg_name) + if var_addr in self.model.mf6.get_input_var_names(): + self._rhs = self.model.mf6.get_value_ptr(var_addr) + else: + return None + return np.copy(self._rhs) + + @rhs.setter + def rhs(self, values): + if self._rhs is None: + _ = self.rhs + if self._rhs is None: + raise Exception(f"{self.pkg_type} does not have a rhs array") + self._rhs[:] = values[:] + + @property + def hcof(self): + if self._sim_package: + return None + if self._hcof is None: + var_addr = self.model.mf6.get_var_address("HCOF", self.model.name, self.pkg_name) + if var_addr in self.model.mf6.get_input_var_names(): + self._hcof = self.model.mf6.get_value_ptr(var_addr) + else: + return None + return np.copy(self._hcof) + + @hcof.setter + def hcof(self, values): + if self._hcof is None: + _ = self.hcof + if self._hcof is None: + raise Exception(f"{self.pkg_type} does not have an hcof array") + self._hcof[:] = values[:] + @property def advanced_vars(self): """Returns a list of additional variables accessible through get/set_advanced_var.""" @@ -263,8 +337,8 @@ def get_advanced_var(self, name): def set_advanced_var(self, name, values): """Set a variable not surfaced through stress_period_data or variable_names.""" - if isinstance(values, ArrayVar): - values = np.asarray(values) + if isinstance(values, ArrayPointer): + values = np.asarray(values.values) if not self._sim_package: if values.size == self.model.size: values = values[self.model.nodetouser] @@ -279,14 +353,14 @@ def set_advanced_var(self, name, values): def get_array(self, item): """Get a grid-shaped array variable by name.""" v = self._vars.get(item) - if v is None or not isinstance(v, ArrayVar): + if v is None or not isinstance(v, ArrayPointer): raise KeyError(f"{item} is not accessible in this package") return v.values def set_array(self, item, array): """Set a grid-shaped array variable by name.""" v = self._vars.get(item) - if v is None or not isinstance(v, ArrayVar): + if v is None or not isinstance(v, ArrayPointer): raise KeyError(f"{item} is not a valid variable name for this package") v.values = array From cc5e6a41636973f2e2cadb9f1b222c0195907d09 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 30 Jun 2026 06:01:28 -0700 Subject: [PATCH 7/8] compat fixes --- modflowapi/extensions/advpaks.py | 7 +++++-- modflowapi/extensions/pakbase.py | 13 +++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/modflowapi/extensions/advpaks.py b/modflowapi/extensions/advpaks.py index 4be1a5e..3b99984 100644 --- a/modflowapi/extensions/advpaks.py +++ b/modflowapi/extensions/advpaks.py @@ -1,5 +1,8 @@ from .pakbase import AdvancedPackage -# Backward compatibility alias; SFR diversions are now handled generically -# by Package._build_advanced_inputs via adv_pkgvars["sfr"]["diversions"]. +# Backward compatibility aliases SfrPackage = AdvancedPackage +SfrPakage = AdvancedPackage # preserved old typo spelling +LakPackage = AdvancedPackage +MawPackage = AdvancedPackage +UzfPackage = AdvancedPackage diff --git a/modflowapi/extensions/pakbase.py b/modflowapi/extensions/pakbase.py index 3520e4d..08aed78 100644 --- a/modflowapi/extensions/pakbase.py +++ b/modflowapi/extensions/pakbase.py @@ -7,6 +7,11 @@ _ADV_BLOCK_NAMES = frozenset({"packagedata", "perioddata"}) +def _unwrap_list_input(value): + """Return value.values if value is a ListInput, otherwise return value as-is.""" + return value.values if isinstance(value, ListInput) else value + + class Package: """ Package object for MODFLOW 6 API packages. @@ -120,7 +125,7 @@ def _build_advanced_inputs(self): continue var_addrs = self._collect_adv_var_addrs(adv_var_dict, block) if var_addrs: - self._list_vars[block] = ListInput(self, var_addrs, spd=False, name=block) + self._list_vars[block] = ListInput(self, var_addrs, spd=False) def _collect_adv_var_addrs(self, adv_var_dict, block): var_addrs = [] @@ -200,7 +205,7 @@ def __setattr__(self, item, value): pass else: if item in list_vars: - list_vars[item].values = value + list_vars[item].values = _unwrap_list_input(value) return var = self._try_discover_var(item) if var is not None: @@ -227,7 +232,7 @@ def stress_period_data(self): def stress_period_data(self, recarray): lv = self._list_vars.get("stress_period_data") if lv is not None: - lv.values = recarray + lv.values = _unwrap_list_input(recarray) @property def packagedata(self): @@ -238,7 +243,7 @@ def packagedata(self): def packagedata(self, recarray): lv = self._list_vars.get("packagedata") if lv is not None: - lv.values = recarray + lv.values = _unwrap_list_input(recarray) @property def nbound(self): From da85f07d1bdf60070f55373f410598527e457133 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 2 Jul 2026 06:17:27 -0700 Subject: [PATCH 8/8] sim-level package detection --- modflowapi/extensions/apiexchange.py | 2 ++ modflowapi/extensions/apimodel.py | 6 +++--- modflowapi/extensions/data.py | 1 - modflowapi/extensions/pakbase.py | 19 ++++++++++++------- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/modflowapi/extensions/apiexchange.py b/modflowapi/extensions/apiexchange.py index 9b71ced..6e5f7d5 100644 --- a/modflowapi/extensions/apiexchange.py +++ b/modflowapi/extensions/apiexchange.py @@ -14,5 +14,7 @@ class ApiExchange(ApiMbase): modflow exchange name. ex. "GWF-GWF_1" """ + sim_level = True # exchange packages are simulation-level, not model-level + def __init__(self, mf6, name): super().__init__(mf6, name) diff --git a/modflowapi/extensions/apimodel.py b/modflowapi/extensions/apimodel.py index 31bd704..0535c57 100644 --- a/modflowapi/extensions/apimodel.py +++ b/modflowapi/extensions/apimodel.py @@ -20,6 +20,8 @@ class ApiMbase: optional dictionary of package types and ApiPackage class types """ + sim_level = False # True for sim/exchange containers; False for model containers + def __init__(self, mf6, name, pkg_types=None): self.mf6 = mf6 self.name = name @@ -77,9 +79,7 @@ def _create_package_list(self): else: pkg_cls = Package - adj_pkg_name = "" if "-" in pkg_type else pkg_name - - package = pkg_cls(self, pkg_type, adj_pkg_name) + package = pkg_cls(self, pkg_type, pkg_name, sim_package=self.sim_level) self.package_dict[pkg_name.lower()] = package def get_package(self, pkg_name) -> "Package": diff --git a/modflowapi/extensions/data.py b/modflowapi/extensions/data.py index 5684a76..595fb90 100644 --- a/modflowapi/extensions/data.py +++ b/modflowapi/extensions/data.py @@ -494,7 +494,6 @@ def values(self, array): raise ValueError( f"{self.name} size {array.size} is not equal to modflow variable size {self.parent.model.size}" ) - array = array.ravel() if self._ptr.size != array.size: array = array[self.parent.model.nodetouser] diff --git a/modflowapi/extensions/pakbase.py b/modflowapi/extensions/pakbase.py index 08aed78..3a24d4c 100644 --- a/modflowapi/extensions/pakbase.py +++ b/modflowapi/extensions/pakbase.py @@ -4,7 +4,7 @@ from .datamodel import adv_pkgvars, pkgvars _BASE_ATTRS = frozenset({"model", "pkg_name", "pkg_type"}) -_ADV_BLOCK_NAMES = frozenset({"packagedata", "perioddata"}) +_ADV_BLOCK_NAMES = frozenset({"griddata", "packagedata", "perioddata"}) def _unwrap_list_input(value): @@ -50,10 +50,12 @@ def __init__(self, model, pkg_type, pkg_name, sim_package=False): def _build_inputs(self): if self.pkg_type in adv_pkgvars: self._build_advanced_inputs() - elif self.pkg_type in pkgvars and any(isinstance(v, tuple) for v in pkgvars[self.pkg_type]): - self._build_list_inputs(pkgvars[self.pkg_type]) - elif self.pkg_type in pkgvars: - self._build_plain_inputs(pkgvars[self.pkg_type]) + if self.pkg_type in pkgvars: + vars_list = pkgvars[self.pkg_type] + if any(isinstance(v, tuple) for v in vars_list): + self._build_list_inputs(vars_list) + else: + self._build_plain_inputs(vars_list) def _build_list_inputs(self, vars_list): var_addrs = [] @@ -94,6 +96,9 @@ def _build_plain_inputs(self, vars_list): def _build_advanced_inputs(self): adv_var_dict = adv_pkgvars[self.pkg_type] + if "griddata" in adv_var_dict: + self._build_plain_inputs(adv_var_dict["griddata"]) + pkg_var_addrs = self._collect_adv_var_addrs(adv_var_dict, "packagedata") if pkg_var_addrs: self._list_vars["packagedata"] = ListInput(self, pkg_var_addrs, spd=False) @@ -220,8 +225,8 @@ def __setattr__(self, item, value): @property def variable_names(self): - """Returns a sorted list of array/scalar variable names accessible through the API.""" - return sorted(self._vars) + """Returns a sorted list of variable names accessible through the API.""" + return sorted(list(self._vars) + list(self._list_vars)) @property def stress_period_data(self):