# coding: utf-8
"""
The wntr.msx.base module includes base classes for the multi-species water
quality model
Other than the enum classes, the classes in this module are all abstract
and/or mixin classes, and should not be instantiated directly.
"""
import logging
import os
from abc import ABC, abstractclassmethod, abstractmethod, abstractproperty
from enum import Enum
from typing import Any, Dict, Iterator, Generator
from wntr.epanet.util import NoteType
from wntr.utils.disjoint_mapping import DisjointMapping
from wntr.utils.enumtools import add_get
from numpy import (
abs,
arccos,
arcsin,
arctan,
cos,
cosh,
exp,
heaviside,
log,
log10,
sign,
sin,
sinh,
sqrt,
tan,
tanh,
)
[docs]
def cot(x):
return 1 / tan(x)
[docs]
def arccot(x):
return 1 / arctan(1 / x)
[docs]
def coth(x):
return 1 / tanh(x)
logger = logging.getLogger(__name__)
HYDRAULIC_VARIABLES = [
{"name": "D", "note": "pipe diameter (feet or meters) "},
{
"name": "Kc",
"note": "pipe roughness coefficient (unitless for Hazen-Williams or Chezy-Manning head loss formulas, millifeet or millimeters for Darcy-Weisbach head loss formula)",
},
{"name": "Q", "note": "pipe flow rate (flow units) "},
{"name": "U", "note": "pipe flow velocity (ft/sec or m/sec) "},
{"name": "Re", "note": "flow Reynolds number "},
{"name": "Us", "note": "pipe shear velocity (ft/sec or m/sec) "},
{"name": "Ff", "note": "Darcy-Weisbach friction factor "},
{"name": "Av", "note": "Surface area per unit volume (area units/L) "},
{"name": "Len", "note": "Pipe length (feet or meters)"},
]
"""Hydraulic variables defined in EPANET-MSX.
For reference, the valid values are provided in :numref:`table-msx-hyd-vars`.
.. _table-msx-hyd-vars:
.. table:: Valid hydraulic variables in multi-species quality model expressions.
============== ================================================
**Name** **Description**
-------------- ------------------------------------------------
``D`` pipe diameter
``Kc`` pipe roughness coefficient
``Q`` pipe flow rate
``U`` pipe flow velocity
``Re`` flow Reynolds number
``Us`` pipe shear velocity
``Ff`` Darcy-Weisbach friction factor
``Av`` pipe surface area per unit volume
``Len`` pipe length
============== ================================================
:meta hide-value:
"""
EXPR_FUNCTIONS = dict(
abs=abs,
sgn=sign,
sqrt=sqrt,
step=heaviside,
log=log,
exp=exp,
sin=sin,
cos=cos,
tan=tan,
cot=cot,
asin=arcsin,
acos=arccos,
atan=arctan,
acot=arccot,
sinh=sinh,
cosh=cosh,
tanh=tanh,
coth=coth,
log10=log10,
)
"""Mathematical functions available for use in expressions. See
:numref:`table-msx-funcs` for a list and description of the
different functions recognized. These names, case insensitive, are
considered invalid when naming variables.
.. _table-msx-funcs:
.. table:: Functions defined for use in EPANET-MSX expressions.
============== ================================================================
**Name** **Description**
-------------- ----------------------------------------------------------------
``abs`` absolute value
``sgn`` sign
``sqrt`` square-root
``step`` step function
``exp`` natural number, `e`, raised to a power
``log`` natural logarithm
``log10`` base-10 logarithm
``sin`` sine
``cos`` cosine
``tan`` tangent
``cot`` cotangent
``asin`` arcsine
``acos`` arccosine
``atan`` arctangent
``acot`` arccotangent
``sinh`` hyperbolic sine
``cosh`` hyperbolic cosine
``tanh`` hyperbolic tangent
``coth`` hyperbolic cotangent
``*`` multiplication
``/`` division
``+`` addition
``-`` negation and subtraction
``^`` power/exponents
``(``, ``)`` groupings and function parameters
============== ================================================================
:meta hide-value:
"""
RESERVED_NAMES = (
tuple([v["name"] for v in HYDRAULIC_VARIABLES])
+ tuple([k.lower() for k in EXPR_FUNCTIONS.keys()])
+ tuple([k.upper() for k in EXPR_FUNCTIONS.keys()])
+ tuple([k.capitalize() for k in EXPR_FUNCTIONS.keys()])
)
"""WNTR reserved names. This includes the MSX hydraulic variables
(see :numref:`table-msx-hyd-vars`) and the MSX defined functions
(see :numref:`table-msx-funcs`).
:meta hide-value:
"""
_global_dict = dict()
for k, v in EXPR_FUNCTIONS.items():
_global_dict[k.lower()] = v
_global_dict[k.capitalize()] = v
_global_dict[k.upper()] = v
for v in HYDRAULIC_VARIABLES:
_global_dict[v["name"]] = v["name"]
[docs]
@add_get(abbrev=True)
class VariableType(Enum):
"""Type of reaction variable.
The following types are defined, and aliases of just the first character
are also defined.
.. rubric:: Enum Members
.. autosummary::
SPECIES
CONSTANT
PARAMETER
TERM
RESERVED
.. rubric:: Class Methods
.. autosummary::
:nosignatures:
get
"""
SPECIES = 3
"""Chemical or biological water quality species"""
TERM = 4
"""Functional term, or named expression, for use in reaction expressions"""
PARAMETER = 5
"""Reaction expression coefficient that is parameterized by tank or pipe"""
CONSTANT = 6
"""Constant coefficient for use in reaction expressions"""
RESERVED = 9
"""Variable that is either a hydraulic variable or other reserved word"""
S = SPEC = SPECIES
T = TERM
P = PARAM = PARAMETER
C = CONST = CONSTANT
R = RES = RESERVED
def __repr__(self):
return repr(self.name)
[docs]
@add_get(abbrev=True)
class SpeciesType(Enum):
"""Enumeration for species type
.. warning:: These enum values are not the same as the MSX SpeciesType.
.. rubric:: Enum Members
.. autosummary::
BULK
WALL
.. rubric:: Class Methods
.. autosummary::
:nosignatures:
get
"""
BULK = 1
"""Bulk species"""
WALL = 2
"""Wall species"""
B = BULK
W = WALL
def __repr__(self):
return repr(self.name)
[docs]
@add_get(abbrev=True)
class ReactionType(Enum):
"""Reaction type which specifies the location where the reaction occurs
The following types are defined, and aliases of just the first character
are also defined.
.. rubric:: Enum Members
.. autosummary::
PIPE
TANK
.. rubric:: Class Methods
.. autosummary::
:nosignatures:
get
"""
PIPE = 1
"""Expression describes a reaction in pipes"""
TANK = 2
"""Expression describes a reaction in tanks"""
P = PIPE
T = TANK
def __repr__(self):
return repr(self.name)
[docs]
@add_get(abbrev=True)
class ExpressionType(Enum):
"""Type of reaction expression
The following types are defined, and aliases of just the first character
are also defined.
.. rubric:: Enum Members
.. autosummary::
EQUIL
RATE
FORMULA
.. rubric:: Class Methods
.. autosummary::
:nosignatures:
get
"""
EQUIL = 1
"""Equilibrium expressions where equation is being equated to zero"""
RATE = 2
"""Rate expression where the equation expresses the rate of change of
the given species with respect to time as a function of the other species
in the model"""
FORMULA = 3
"""Formula expression where the concentration of the named species is a
simple function of the remaining species"""
E = EQUIL
R = RATE
F = FORMULA
def __repr__(self):
return repr(self.name)
[docs]
class ReactionBase(ABC):
"""Water quality reaction class.
This is an abstract class for water quality reactions with partial concrete
attribute and method definitions. All parameters and methods documented
here must be defined by a subclass except for the following:
.. rubric:: Concrete attributes
The :meth:`__init__` method defines the following attributes concretely.
Thus, a subclass should call :code:`super().__init__(species_name, note=note)`
at the beginning of its own initialization.
.. autosummary::
~ReactionBase._species_name
~ReactionBase.note
.. rubric:: Concrete properties
The species name is protected, and a reaction cannot be manually assigned
a new species. Therefore, the following property is defined concretely.
.. autosummary::
species_name
.. rubric:: Concrete methods
The following methods are concretely defined, but can be overridden.
.. autosummary::
:nosignatures:
__str__
__repr__
"""
[docs]
def __init__(self, species_name: str, *, note: NoteType = None) -> None:
"""Reaction ABC init method.
Make sure you call this method from your concrete subclass ``__init__`` method:
.. code::
super().__init__(species_name, note=note)
Parameters
----------
species_name : str
Name of the chemical or biological species being modeled using this
reaction
note : (str | dict | ENcomment), optional keyword
Supplementary information regarding this reaction, by default None
(see-also :class:`~wntr.epanet.util.NoteType`)
Raises
------
TypeError
If expression_type is invalid
"""
if species_name is None:
raise TypeError("The species_name cannot be None")
self._species_name: str = str(species_name)
"""Protected name of the species"""
self.note: NoteType = note
"""Optional note regarding the reaction (see :class:`~wntr.epanet.util.NoteType`)
"""
@property
def species_name(self) -> str:
"""Name of the species that has a reaction being defined."""
return self._species_name
@property
@abstractmethod
def reaction_type(self) -> Enum:
"""Reaction type (reaction location)."""
raise NotImplementedError
def __str__(self) -> str:
"""Return the name of the species and the reaction type, indicated by
an arrow. E.g., 'HOCL->PIPE for chlorine reaction in pipes."""
return "{}->{}".format(self.species_name, self.reaction_type.name)
def __repr__(self) -> str:
"""Return a representation of the reaction from the dictionary
representation - see :meth:`to_dict`"""
return "{}(".format(self.__class__.__name__) + ", ".join(["{}={}".format(k, repr(getattr(self, k))) for k, v in self.to_dict().items()]) + ")"
[docs]
@abstractmethod
def to_dict(self) -> dict:
"""Represent the object as a dictionary"""
raise NotImplementedError
[docs]
class VariableBase(ABC):
"""Multi-species water quality model variable
This is an abstract class for water quality model variables with partial
definition of concrete attributes and methods. Parameters and methods
documented here must be defined by a subclass except for the following:
.. rubric:: Concrete attributes
The :meth:`__init__` method defines the following attributes concretely.
Thus, a subclass should call :code:`super().__init__()` at the beginning
of its own initialization.
.. autosummary::
~VariableBase.name
~VariableBase.note
.. rubric:: Concrete methods
The following methods are concretely defined, but can be overridden.
.. autosummary::
:nosignatures:
__str__
__repr__
"""
[docs]
def __init__(self, name: str, *, note: NoteType = None) -> None:
"""Variable ABC init method.
Make sure you call this method from your concrete subclass ``__init__`` method:
.. code::
super().__init__(name, note=note)
Parameters
----------
name : str
Name/symbol for the variable. Must be a valid MSX variable name
note : (str | dict | ENcomment), optional keyword
Supplementary information regarding this variable, by default None
(see-also :class:`~wntr.epanet.util.NoteType`)
Raises
------
KeyExistsError
Name is already taken
ValueError
Name is a reserved word
"""
if name in RESERVED_NAMES:
raise ValueError("Name cannot be a reserved name")
self.name: str = name
"""Name/ID of this variable, must be a valid EPANET/MSX ID"""
self.note: NoteType = note
"""Optional note regarding the variable (see :class:`~wntr.epanet.util.NoteType`)
"""
@property
@abstractmethod
def var_type(self) -> Enum:
"""Type of reaction variable"""
raise NotImplementedError
[docs]
def to_dict(self) -> Dict[str, Any]:
"""Represent the object as a dictionary"""
return dict(name=self.name)
def __str__(self) -> str:
"""Return the name of the variable"""
return self.name
def __repr__(self) -> str:
"""Return a representation of the variable from the dictionary
representation - see :meth:`to_dict`"""
return "{}(".format(self.__class__.__name__) + ", ".join(["{}={}".format(k, repr(getattr(self, k))) for k, v in self.to_dict().items()]) + ")"
[docs]
class ReactionSystemBase(ABC):
"""Abstract class for reaction systems, which contains variables and
reaction expressions.
This class contains the functions necessary to perform dictionary-style
addressing of *variables* by their name. It does not allow dictionary-style
addressing of reactions.
This is an abstract class with some concrete attributes and methods.
Parameters and methods documented here must be defined by a subclass
except for the following:
.. rubric:: Concrete attributes
The :meth:`__init__` method defines the following attributes concretely.
Thus, a subclass should call :code:`super().__init__()` or :code:`super().__init__(filename)`.
.. autosummary::
~ReactionSystemBase._vars
~ReactionSystemBase._rxns
.. rubric:: Concrete methods
The following special methods are concretely provided to directly access
items in the :attr:`_vars` attribute.
.. autosummary::
:nosignatures:
__contains__
__eq__
__ne__
__getitem__
__iter__
__len__
"""
[docs]
def __init__(self) -> None:
"""Constructor for the reaction system.
Make sure you call this method from your concrete subclass ``__init__`` method:
.. code::
super().__init__()
"""
self._vars: DisjointMapping = DisjointMapping()
"""Variables registry, which is mapped to dictionary functions on the
reaction system object"""
self._rxns: Dict[str, Any] = dict()
"""Reactions dictionary"""
[docs]
@abstractmethod
def add_variable(self, obj: VariableBase) -> None:
"""Add a variable to the system"""
raise NotImplementedError
[docs]
@abstractmethod
def add_reaction(self, obj: ReactionBase) -> None:
"""Add a reaction to the system"""
raise NotImplementedError
[docs]
@abstractmethod
def variables(self) -> Generator[Any, Any, Any]:
"""Generator looping through all variables"""
raise NotImplementedError
[docs]
@abstractmethod
def reactions(self) -> Generator[Any, Any, Any]:
"""Generator looping through all reactions"""
raise NotImplementedError
[docs]
@abstractmethod
def to_dict(self) -> dict:
"""Represent the reaction system as a dictionary"""
raise NotImplementedError
def __contains__(self, __key: object) -> bool:
return self._vars.__contains__(__key)
def __eq__(self, __value: object) -> bool:
return self._vars.__eq__(__value)
def __ne__(self, __value: object) -> bool:
return self._vars.__ne__(__value)
def __getitem__(self, __key: str) -> VariableBase:
return self._vars.__getitem__(__key)
def __iter__(self) -> Iterator:
return self._vars.__iter__()
def __len__(self) -> int:
return self._vars.__len__()
[docs]
class VariableValuesBase(ABC):
"""Abstract class for a variable's network-specific values
This class should contain values for different pipes, tanks,
etc., that correspond to a specific network for the reaction
system. It can be used for initial concentration values, or
for initial settings on parameters, but should be information
that is clearly tied to a specific type of variable.
This is a pure abstract class. All parameters
and methods documented here must be defined by a subclass.
"""
@property
@abstractmethod
def var_type(self) -> Enum:
"""Type of variable this object holds data for."""
raise NotImplementedError
[docs]
@abstractmethod
def to_dict(self) -> dict:
"""Represent the object as a dictionary"""
raise NotImplementedError
[docs]
class NetworkDataBase(ABC):
"""Abstract class containing network specific data
This class should be populated with things like initial quality,
sources, parameterized values, etc.
This is a pure abstract class. All parameters
and methods documented here must be defined by a subclass.
"""
[docs]
@abstractmethod
def to_dict(self) -> dict:
"""Represent the object as a dictionary"""
raise NotImplementedError
[docs]
class QualityModelBase(ABC):
"""Abstract multi-species water quality model
This is an abstract class for a water quality model. All parameters and
methods documented here must be defined by a subclass except for the
following:
.. rubric:: Concrete attributes
The :meth:`__init__` method defines the following attributes concretely.
Thus, a subclass should call :code:`super().__init__()` or :code:`super().__init__(filename)`.
.. autosummary::
~QualityModelBase.name
~QualityModelBase.title
~QualityModelBase.description
~QualityModelBase._orig_file
~QualityModelBase._options
~QualityModelBase._rxn_system
~QualityModelBase._net_data
~QualityModelBase._wn
"""
[docs]
def __init__(self, filename=None):
"""QualityModel ABC init method.
Make sure you call this method from your concrete subclass ``__init__`` method:
.. code::
super().__init__(filename=filename)
Parameters
----------
filename : str, optional
File to use to populate the initial data
"""
self.name: str = None if filename is None else os.path.splitext(os.path.split(filename)[1])[0]
"""Name for the model, or the MSX model filename (no spaces allowed)"""
self.title: str = None
"""Title line from the MSX file, must be a single line"""
self.description: str = None
"""Longer description; note that multi-line descriptions may not be
represented well in dictionary form"""
self._orig_file: str = filename
"""Protected original filename, if provided in the constructor"""
self._options = None
"""Protected options data object"""
self._rxn_system: ReactionSystemBase = None
"""Protected reaction system object"""
self._net_data: NetworkDataBase = None
"""Protected network data object"""
self._wn = None
"""Protected water network object"""
@property
@abstractmethod
def options(self):
"""Model options structure
Concrete classes should implement this with the appropriate typing and
also implement a setter method.
"""
raise NotImplementedError
@property
@abstractmethod
def reaction_system(self) -> ReactionSystemBase:
"""Reaction variables defined for this model
Concrete classes should implement this with the appropriate typing.
"""
raise NotImplementedError
@property
@abstractmethod
def network_data(self) -> NetworkDataBase:
"""Network-specific values added to this model
Concrete classes should implement this with the appropriate typing.
"""
raise NotImplementedError
[docs]
@abstractmethod
def to_dict(self) -> dict:
"""Represent the object as a dictionary"""
raise NotImplementedError
[docs]
@classmethod
@abstractmethod
def from_dict(self, data: dict) -> "QualityModelBase":
"""Create a new model from a dictionary
Parameters
----------
data : dict
Dictionary representation of the model
Returns
-------
QualityModelBase
New concrete model
"""
raise NotImplementedError