diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index b9391bc6d..1f758f0de 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12'] - os: [windows-latest, macOS-13, ubuntu-latest] + os: [windows-latest, macOS-13, macos-latest, ubuntu-latest] fail-fast: false steps: - uses: actions/checkout@v4 @@ -48,7 +48,7 @@ jobs: strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12'] - os: [windows-latest, macOS-13, ubuntu-latest] + os: [windows-latest, macOS-13, macos-latest, ubuntu-latest] fail-fast: false steps: - name: Set up Python @@ -73,7 +73,7 @@ jobs: strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12'] - os: [windows-latest, macOS-13, ubuntu-latest] + os: [windows-latest, macOS-13, ubuntu-latest, macos-latest] fail-fast: false steps: - uses: actions/checkout@v4 @@ -81,6 +81,9 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - if: ${{ matrix.os == 'macos-latest' || matrix.os == 'macOS-13'}} + run: | + brew reinstall --build-from-source --formula ./shell/apple/libomp.rb - name: Install dependencies run: | python --version @@ -88,12 +91,21 @@ jobs: pip install -r requirements.txt python -m pip install -e . - name: Run Tests + if: ${{ matrix.os != 'macos-latest' }} run: | coverage erase coverage run --context=${{ matrix.os }}.py${{ matrix.python-version }} --source=wntr --omit="*/tests/*","*/sim/network_isolation/network_isolation.py","*/sim/aml/evaluator.py" -m pytest --doctest-modules --doctest-glob="*.rst" wntr coverage run --context=${{ matrix.os }}.py${{ matrix.python-version }} --source=wntr --omit="*/tests/*","*/sim/network_isolation/network_isolation.py","*/sim/aml/evaluator.py" --append -m pytest --doctest-glob="*.rst" documentation env: COVERAGE_FILE: .coverage.${{ matrix.python-version }}.${{ matrix.os }} + - name: Run Tests (ARM-processor) + if: ${{ matrix.os == 'macos-latest'}} + # doctests are not flexible enough to skip EPANET=v2.0 errors on ARM processor, so do not run doctests on ARM system + run: | + coverage erase + coverage run --context=${{ matrix.os }}.py${{ matrix.python-version }} --source=wntr --omit="*/tests/*","*/sim/network_isolation/network_isolation.py","*/sim/aml/evaluator.py" -m pytest --doctest-modules --doctest-glob="*.rst" wntr + env: + COVERAGE_FILE: .coverage.${{ matrix.python-version }}.${{ matrix.os }} - name: Save coverage uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 810a40e74..03c74289f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,98 @@ +# Developer IDE directories /.project -/.spyproject -_build/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# VS Code project settings +*.code-workspace +/.vscode + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Distribution / packaging +.Python build/ -wntr.egg-info/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg docker/ /.vscode +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Mac cleanup +**/.DS_Store + +# Other items / test output +temp* *.pyd *.pyc *.egg -*.coverage *.ipynb *.html *.pickle *.xlsx -temp* - examples/*.inp wntr/tests/*.png wntr/tests/*.tif +# Documentation build files +documentation/_build documentation/_local documentation/apidoc + +# WNTR specific +wntr/sim/aml/*.so +wntr/sim/aml/*.dll +wntr/sim/aml/*.dylib +wntr/sim/network_isolation/*.so +wntr/sim/network_isolation/*.dll +wntr/sim/network_isolation/*.dylib diff --git a/MANIFEST.in b/MANIFEST.in index 1961f8a2e..0f6c9b053 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,4 +7,6 @@ include wntr/sim/aml/evaluator* include wntr/sim/aml/numpy.i include wntr/sim/network_isolation/network_isolation* include wntr/sim/network_isolation/numpy.i -include wntr/tests/networks_for_testing/*.inp \ No newline at end of file +include wntr/tests/networks_for_testing/*.inp +include wntr/library/msx/*.json +include wntr/library/msx/*.msx diff --git a/documentation/advancedsim.rst b/documentation/advancedsim.rst index 4a8be4fe7..4b82988e4 100644 --- a/documentation/advancedsim.rst +++ b/documentation/advancedsim.rst @@ -248,3 +248,387 @@ The solution for :math:`u` and :math:`v` is then returned and printed to four si 1.618 >>> np.round(m.v.value,4) 2.618 + +Building MSX models +------------------- + +The following two examples illustrate how to build :class:`~wntr.msx.model.MsxModel` objects in WNTR. +There is also a jupyter notebook in the examples/demos source directory called multisource-cl-decay.ipynb +that demonstrates this process with Net3. + +.. _msx_example1_lead: + +Plumbosolvency of lead +^^^^^^^^^^^^^^^^^^^^^^ + +The following example builds the plumbosolvency of lead model +described in :cite:p:`bwms20`. The model represents plumbosolvency +in lead pipes within a dwelling. +The MSX model is built without a specific water network model in mind. + +Model development starts by defining the model +name, +title, +description, and +reference. + +.. doctest:: + + >>> import wntr.msx + >>> msx = wntr.msx.MsxModel() + >>> msx.name = "lead_ppm" + >>> msx.title = "Lead Plumbosolvency Model (from Burkhardt et al 2020)" + >>> msx.desc = "Parameters for EPA HPS Simulator Model" + >>> msx.references.append( + ... """J. B. Burkhardt, et al. (2020) https://doi.org/10.1061/(asce)wr.1943-5452.0001304""" + ... ) + >>> msx + MsxModel(name='lead_ppm') + +Model options are added as follows: + +.. doctest:: + + >>> msx.options = { + ... "report": { + ... "species": {"PB2": "YES"}, + ... "species_precision": {"PB2": 5}, + ... "nodes": "all", + ... "links": "all", + ... }, + ... "timestep": 1, + ... "area_units": "M2", + ... "rate_units": "SEC", + ... "rtol": 1e-08, + ... "atol": 1e-08, + ... } + +There is only one species defined in this model, which is dissolved lead. + +======================== =============== ================================= ======================== +Name Type Units Note +------------------------ --------------- --------------------------------- ------------------------ +:math:`Pb` bulk species :math:`\mathrm{μg}_\mathrm{(Pb)}` dissolved lead +======================== =============== ================================= ======================== + +The species is added to the MsxModel using the using the +:meth:`~wntr.msx.model.MsxModel.add_species` method. +This method adds the new species to the model and also return a copy of the new species object. + +.. doctest:: + + >>> msx.add_species(name="PB2", species_type='bulk', units="ug", note="dissolved lead (Pb)") + Species(name='PB2', species_type='BULK', units='ug', atol=None, rtol=None, note='dissolved lead (Pb)') + + +The new species can be accessed by using the item's name and indexing on the model's +:attr:`~wntr.msx.model.MsxModel.reaction_system` attribute. + + >>> PB2 = msx.reaction_system['PB2'] + >>> PB2 + Species(name='PB2', species_type='BULK', units='ug', atol=None, rtol=None, note='dissolved lead (Pb)') + +The model also includes two constants and one parameter. + +=============== =============== =============== ================================= ======================== +Type Name Value Units Note +--------------- --------------- --------------- --------------------------------- ------------------------ +constant :math:`M` 0.117 :math:`\mathrm{μg~m^{-2}~s^{-1}}` desorption rate +constant :math:`E` 140.0 :math:`\mathrm{μg~L^{-1}}` saturation level +parameter :math:`F` 0 `flag` is pipe made of lead? +=============== =============== =============== ================================= ======================== + +These are added to the MsxModel using the using the +:meth:`~wntr.msx.model.MsxModel.add_constant` and +:meth:`~wntr.msx.model.MsxModel.add_parameter` methods. +methods. + +.. doctest:: + + >>> msx.add_constant("M", value=0.117, note="Desorption rate (ug/m^2/s)", units="ug * m^(-2) * s^(-1)") + Constant(name='M', value=0.117, units='ug * m^(-2) * s^(-1)', note='Desorption rate (ug/m^2/s)') + >>> msx.add_constant("E", value=140.0, note="saturation/plumbosolvency level (ug/L)", units="ug/L") + Constant(name='E', value=140.0, units='ug/L', note='saturation/plumbosolvency level (ug/L)') + >>> msx.add_parameter("F", global_value=0, note="determines which pipes are made of lead") + Parameter(name='F', global_value=0.0, note='determines which pipes are made of lead') + +If the value of one of these needs to be modified, then it can be accessed and modified as an object +in the same manner as other WNTR objects. + +.. doctest:: + + >>> M = msx.reaction_system.constants['M'] + >>> M.value = 0.118 + >>> M + Constant(name='M', value=0.118, units='ug * m^(-2) * s^(-1)', note='Desorption rate (ug/m^2/s)') + + +Note that all models must include both pipe and tank reactions. +Since the model only has reactions within +pipes, tank reactions need to be unchanging. +The system of equations defined as follows: + +.. math:: + + \frac{d}{dt}Pb_p &= F_p \, Av_p \, M \frac{\left( E - Pb_p \right)}{E}, &\quad\text{for all pipes}~p~\text{in network} \\ + \frac{d}{dt}Pb_t &= 0, & \quad\text{for all tanks}~t~\text{in network} + +Note that the pipe reaction has a variable that has not been defined, :math:`Av`; +this variable is a pre-defined hydraulic variable. The list of these variables can be found in +the EPANET-MSX documentation, and also in the :attr:`~wntr.msx.base.HYDRAULIC_VARIABLES` +documentation. The reactions are defined as follows: + +================ ============== ========================================================================== +Reaction type Dynamics type Reaction expression +---------------- -------------- -------------------------------------------------------------------------- +pipe rate :math:`F \cdot Av \cdot M \cdot \left( E - Pb \right) / E` +tank rate :math:`0` +================ ============== ========================================================================== + +The reactions are added to the MsxModel using the :meth:`~wntr.msx.model.MsxModel.add_reaction` +method. + +.. doctest:: + + >>> msx.add_reaction("PB2", "pipe", "RATE", expression="F * Av * M * (E - PB2) / E") + Reaction(species_name='PB2', expression_type='RATE', expression='F * Av * M * (E - PB2) / E') + + +If the species is saved as an object, as was done above, then it can be passed instead of the species name. + +.. doctest:: + + >>> msx.add_reaction(PB2, "tank", "rate", expression="0") + Reaction(species_name='PB2', expression_type='RATE', expression='0') + + +Arsenic oxidation and adsorption +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This example models monochloramine oxidation of arsenite/arsenate and wall +adsorption/desorption, as given in section 3 of the EPANET-MSX user manual :cite:p:`shang2023`. + +The system of equations for the reaction in pipes is given in Eq. (2.4) through (2.7) +in :cite:p:`shang2023`. This is a simplified model, taken from :cite:p:`gscl94`. + +.. math:: + + \frac{d}{dt}{(\mathsf{As}^\mathrm{III})} &= -k_a ~ {(\mathsf{As}^\mathrm{III})} ~ {(\mathsf{NH_2Cl})} \\ + \frac{d}{dt}{(\mathsf{As}^\mathrm{V})} &= k_a ~ {(\mathsf{As}^\mathrm{III})} ~ {(\mathsf{NH_2CL})} - Av \left( k_1 \left(S_\max - {(\mathsf{As}^\mathrm{V}_s)} \right) {(\mathsf{As}^\mathrm{V})} - k_2 ~ {(\mathsf{As}^\mathrm{V}_s)} \right) \\ + \frac{d}{dt}{(\mathsf{NH_2Cl})} &= -k_b ~ {(\mathsf{NH_2Cl})} \\ + {(\mathsf{As}^\mathrm{V}_s)} &= \frac{k_s ~ S_\max ~ {(\mathsf{As}^\mathrm{V})}}{1 + k_s {(\mathsf{As}^\mathrm{V})}} + + +where the various species, coefficients, and expressions are described in the tables below. + + +.. list-table:: Options + :header-rows: 1 + :widths: 3 3 10 + + * - Option + - Code + - Description + * - Rate units + - "HR" + - :math:`\mathrm{h}^{-1}` + * - Area units + - "M2" + - :math:`\mathrm{m}^2` + + +.. list-table:: Species + :header-rows: 1 + :widths: 2 2 2 3 4 6 + + * - Name + - Type + - Value + - Symbol + - Units + - Note + * - AS3 + - Bulk + - "UG" + - :math:`{\mathsf{As}^\mathrm{III}}` + - :math:`\require{upgreek}\upmu\mathrm{g~L^{-1}}` + - dissolved arsenite + * - AS5 + - Bulk + - "UG" + - :math:`{\mathsf{As}^\mathrm{V}}` + - :math:`\require{upgreek}\upmu\mathrm{g~L^{-1}}` + - dissolved arsenate + * - AStot + - Bulk + - "UG" + - :math:`{\mathsf{As}^\mathrm{tot}}` + - :math:`\require{upgreek}\upmu\mathrm{g~L^{-1}}` + - dissolved arsenic (total) + * - NH2CL + - Bulk + - "MG" + - :math:`{\mathsf{NH_2Cl}}` + - :math:`\mathrm{mg~L^{-1}}` + - dissolved monochloramine + * - AS5s + - Wall + - "UG" + - :math:`{\mathsf{As}^\mathrm{V}_{s}}` + - :math:`\require{upgreek}\upmu\mathrm{g}~\mathrm{m}^{-2}` + - adsorped arsenate (surface) + + +.. list-table:: Coefficients + :header-rows: 1 + :widths: 2 2 2 3 4 6 + + * - Name + - Type + - Value + - Symbol + - Units + - Note + * - Ka + - Const + - :math:`10` + - :math:`k_a` + - :math:`\mathrm{mg}^{-1}_{\left(\mathsf{NH_2Cl}\right)}~\mathrm{h}^{-1}` + - arsenite oxidation + * - Kb + - Const + - :math:`0.1` + - :math:`k_b` + - :math:`\mathrm{h}^{-1}` + - chloromine decay + * - K1 + - Const + - :math:`5.0` + - :math:`k_1` + - :math:`\require{upgreek}\textrm{L}~\upmu\mathrm{g}^{-1}_{\left(\mathsf{As}^\mathrm{V}\right)}~\mathrm{h}^{-1}` + - arsenate adsorption + * - K2 + - Const + - :math:`1.0` + - :math:`k_2` + - :math:`\textrm{L}~\mathrm{h}^{-1}` + - arsenate desorption + * - Smax + - Const + - :math:`50.0` + - :math:`S_{\max}` + - :math:`\require{upgreek}\upmu\mathrm{g}_{\left(\mathsf{As}^\mathrm{V}\right)}~\mathrm{m}^{-2}` + - arsenate adsorption limit + + +.. list-table:: Other terms + :header-rows: 1 + :widths: 3 3 2 3 10 + + * - Name + - Symbol + - Expression + - Units + - Note + * - Ks + - :math:`k_s` + - :math:`{k_1}/{k_2}` + - :math:`\require{upgreek}\upmu\mathrm{g}^{-1}_{\left(\mathsf{As}^\mathrm{V}\right)}` + - equilibrium adsorption coefficient + + +.. list-table:: Pipe reactions + :header-rows: 1 + :widths: 3 3 16 + + * - Species + - Type + - Expression + * - AS3 + - Rate + - :math:`-k_a \, {\mathsf{As}^\mathrm{III}} \, {\mathsf{NH_2Cl}}` + * - AS5 + - Rate + - :math:`k_a \, {\mathsf{As}^\mathrm{III}} \, {\mathsf{NH_2Cl}} -Av \left( k_1 \left(S_{\max}-{\mathsf{As}^\mathrm{V}_{s}} \right) {\mathsf{As}^\mathrm{V}} - k_2 \, {\mathsf{As}^\mathrm{V}_{s}} \right)` + * - NH2CL + - Rate + - :math:`-k_b \, {\mathsf{NH_2Cl}}` + * - AStot + - Formula + - :math:`{\mathsf{As}^\mathrm{III}} + {\mathsf{As}^\mathrm{V}}` + * - AS5s + - Equil + - :math:`k_s \, S_{\max} \frac{{\mathsf{As}^\mathrm{V}}}{1 + k_s \, {\mathsf{As}^\mathrm{V}}} - {\mathsf{As}^\mathrm{V}_{s}}` + + +.. list-table:: Tank reactions + :header-rows: 1 + :widths: 3 3 16 + + * - Species + - Type + - Expression + * - AS3 + - Rate + - :math:`-k_a \, {\mathsf{As}^\mathrm{III}} \, {\mathsf{NH_2Cl}}` + * - AS5 + - Rate + - :math:`k_a \, {\mathsf{As}^\mathrm{III}} \, {\mathsf{NH_2Cl}}` + * - NH2CL + - Rate + - :math:`-k_b \, {\mathsf{NH_2Cl}}` + * - AStot + - Formula + - :math:`{\mathsf{As}^\mathrm{III}} + {\mathsf{As}^\mathrm{V}}` + * - AS5s + - Equil + - :math:`0` (`not present in tanks`) + + +The model is created in WTNR as shown below. + +.. doctest:: + + >>> msx = wntr.msx.MsxModel() + >>> msx.name = "arsenic_chloramine" + >>> msx.title = "Arsenic Oxidation/Adsorption Example" + + >>> AS3 = msx.add_species(name="AS3", species_type="BULK", units="UG", note="Dissolved arsenite") + >>> AS5 = msx.add_species(name="AS5", species_type="BULK", units="UG", note="Dissolved arsenate") + >>> AStot = msx.add_species(name="AStot", species_type="BULK", units="UG", + ... note="Total dissolved arsenic") + >>> AS5s = msx.add_species(name="AS5s", species_type="WALL", units="UG", note="Adsorbed arsenate") + >>> NH2CL = msx.add_species(name="NH2CL", species_type="BULK", units="MG", note="Monochloramine") + + >>> Ka = msx.add_constant("Ka", 10.0, units="1 / (MG * HR)", note="Arsenite oxidation rate coeff") + >>> Kb = msx.add_constant("Kb", 0.1, units="1 / HR", note="Monochloramine decay rate coeff") + >>> K1 = msx.add_constant("K1", 5.0, units="M^3 / (UG * HR)", note="Arsenate adsorption coeff") + >>> K2 = msx.add_constant("K2", 1.0, units="1 / HR", note="Arsenate desorption coeff") + >>> Smax = msx.add_constant("Smax", 50.0, units="UG / M^2", note="Arsenate adsorption limit") + + >>> Ks = msx.add_term(name="Ks", expression="K1/K2", note="Equil. adsorption coeff") + + >>> _ = msx.add_reaction(species_name="AS3", reaction_type="pipes", expression_type="rate", + ... expression="-Ka*AS3*NH2CL", note="Arsenite oxidation") + >>> _ = msx.add_reaction("AS5", "pipes", "rate", "Ka*AS3*NH2CL - Av*(K1*(Smax-AS5s)*AS5 - K2*AS5s)", + ... note="Arsenate production less adsorption") + >>> _ = msx.add_reaction( + ... species_name="NH2CL", reaction_type="pipes", expression_type="rate", expression="-Kb*NH2CL", + ... note="Monochloramine decay") + >>> _ = msx.add_reaction("AS5s", "pipe", "equil", "Ks*Smax*AS5/(1+Ks*AS5) - AS5s", + ... note="Arsenate adsorption") + >>> _ = msx.add_reaction(species_name="AStot", reaction_type="pipes", expression_type="formula", + ... expression="AS3 + AS5", note="Total arsenic") + >>> _ = msx.add_reaction(species_name="AS3", reaction_type="tank", expression_type="rate", + ... expression="-Ka*AS3*NH2CL", note="Arsenite oxidation") + >>> _ = msx.add_reaction(species_name="AS5", reaction_type="tank", expression_type="rate", + ... expression="Ka*AS3*NH2CL", note="Arsenate production") + >>> _ = msx.add_reaction(species_name="NH2CL", reaction_type="tank", expression_type="rate", + ... expression="-Kb*NH2CL", note="Monochloramine decay") + >>> _ = msx.add_reaction(species_name="AStot", reaction_type="tanks", expression_type="formula", + ... expression="AS3 + AS5", note="Total arsenic") + + >>> msx.options.area_units = "M2" + >>> msx.options.rate_units = "HR" + >>> msx.options.rtol = 0.001 + >>> msx.options.atol = 0.0001 diff --git a/documentation/citations.bib b/documentation/citations.bib index 6e5b3eef6..d0a2c6b4c 100644 --- a/documentation/citations.bib +++ b/documentation/citations.bib @@ -198,7 +198,7 @@ @article{mazumder2022 number = "5", pages = "728--743", year = "2022", - publisher = "Taylor \\& Francis", + publisher = "Taylor \& Francis", doi = "10.1080/15732479.2020.1864415" } @@ -259,7 +259,7 @@ @article{nikolopoulos2020b number = "3", pages = "256--270", year = "2022", - publisher = "Taylor \\& Francis", + publisher = "Taylor \& Francis", doi = "10.1080/1573062X.2021.1995446" } diff --git a/documentation/references.bib b/documentation/references.bib index 50df2e953..83ad41235 100644 --- a/documentation/references.bib +++ b/documentation/references.bib @@ -23,7 +23,7 @@ @article{barr13 author = "Barker, Kash and Ramirez-Marquez, Jose Emmanuel and Rocco, Claudio M.", doi = "10.1016/j.ress.2013.03.012", issn = "0951-8320", - journal = "Reliability Engineering \\& System Safety", + journal = "Reliability Engineering \& System Safety", pages = "89--97", title = "Resilience-based network component importance measures", volume = "117", @@ -39,6 +39,18 @@ @misc{bieni19 year = "2019" } +@article{bwms20, + author="Burkhardt, J. B. and Woo, J. and Mason, J. and Shang, F. and Triantafyllidou, S. and Schock, M.R., and Lytle, D., and Murray, R." , + year = 2020, + title="Framework for Modeling Lead in Premise Plumbing Systems Using EPANET", + journal="Journal of Water Resources Planning and Management", + volume=146, + number=12, + doi="10.1061/(asce)wr.1943-5452.0001304", + eprint="33627937", + eprintclass="PMID" +} + @book{crlo02, author = "Crowl, D.A. and Louvar, J.F.", address = "Upper Saddle River, NJ", @@ -79,6 +91,15 @@ @misc{gacl18 year = "2018" } +@article{gscl94, + author = "Gu, B. and Schmitt, J. and Chen, Z. and Liang, L. and McCarthy, J.F.", + title = "Adsorption and desorption of natural organic matter on iron oxide: mechanisms and models", + year = "1994", + journal = "Environmental Science and Technology", + volume = "28", + pages = "38--46" +} + @inproceedings{hass08, author = "Hagberg, Aric A. and Schult, Daniel A. and Swart, Pieter J.", booktitle = "Proceedings of the 7th {Python} in Science Conference ({SciPy2008}), August 19-24, Pasadena, CA, USA", @@ -92,7 +113,7 @@ @inproceedings{hass08 @article{hunt07, author = "Hunter, John D. and others", doi = "10.1109/MCSE.2007.55", - journal = "Computing in Science \\& Engineering", + journal = "Computing in Science \& Engineering", number = "3", pages = "90--95", title = "Matplotlib: A {2D} Graphics Environment", @@ -258,6 +279,17 @@ @misc{sphc16 optcomment = "modified from below preferred citation, per Plotly" } +@techreport{shang2023, + author = "Shang, F. and Rossman, L. and Uber, J.", + institution = "U.S. Environmental Protection Agency", + title = "{EPANET}-{MSX} 2.0 User Manual", + year = "2023", + address = "Cincinnati, OH", + number = "EPA/600/R--22/199", + url = "https://cfpub.epa.gov/si/si_public_record_Report.cfm?dirEntryId=358205&Lab=CESER", + urldate = "2023-11-17", +} + @article{todi00, author = "Todini, Ezio", doi = "10.1016/S1462-0758(00)00049-2", diff --git a/documentation/resultsobject.rst b/documentation/resultsobject.rst index eff4c44ce..5aa8e1ba6 100644 --- a/documentation/resultsobject.rst +++ b/documentation/resultsobject.rst @@ -208,3 +208,19 @@ For example, DataFrames can be saved to Excel files using: .. note:: The Pandas method ``to_excel`` requires the Python package **openpyxl** :cite:p:`gacl18`, which is an optional dependency of WNTR. + + +Water quality results +--------------------- +Water quality metrics are stored under the 'quality' key of the node and link results +if the EpanetSimulator is used. The units of the quality results depend on the quality +parameter that is used (see :ref:`_water_quality_simulation`) and can be the age, +concentration, or the fraction of water that belongs to a tracer. If the parameter +is set to 'NONE', then the quality results will be zero. + +The quality of a link is equal to the average across the length of the link. The quality +at a node is the instantaneous value. + +When using the EPANET-MSX water quality model, each species is given its own key in the +node and link results objects, and the 'quality' results still references the EPANET +water quality results. diff --git a/documentation/userguide.rst b/documentation/userguide.rst index f38baf977..428d29551 100644 --- a/documentation/userguide.rst +++ b/documentation/userguide.rst @@ -55,6 +55,7 @@ U.S. Department of Energy's National Nuclear Security Administration under contr hydraulics waterquality + waterquality_msx resultsobject .. toctree:: diff --git a/documentation/waterquality.rst b/documentation/waterquality.rst index fa784d531..1c0c9f8a8 100644 --- a/documentation/waterquality.rst +++ b/documentation/waterquality.rst @@ -17,7 +17,12 @@ Water quality simulation ================================== Water quality simulations can only be run using the EpanetSimulator. - +This includes the ability to run +EPANET 2.00.12 Programmer's Toolkit :cite:p:`ross00` or +EPANET 2.2.0 Programmer's Toolkit :cite:p:`rwts20` for single species, water age, and tracer analysis. + +WNTR also includes the ability to run EPANET-MSX 2.0 :cite:p:`shang2023`, see :ref:`msx_water_quality` for more information. + After defining water quality options and sources (described in the :ref:`wq_options` and :ref:`sources` sections below), a hydraulic and water quality simulation using the EpanetSimulator is run using the following code: @@ -34,7 +39,7 @@ The results include a quality value for each node (see :ref:`simulation_results` .. _wq_options: Water quality options ------------------------- +--------------------- Three types of water quality analysis are supported. These options include water age, tracer, and chemical concentration. * **Water age**: A water quality simulation can be used to compute water age at every node. @@ -83,7 +88,7 @@ More information on water network options can be found in :ref:`options`. .. _sources: Sources ------------- +------- Sources are required for CHEMICAL water quality analysis. Sources can still be defined, but *will not* be used if AGE, TRACE, or NONE water quality analysis is selected. Sources are added to the water network model using the :class:`~wntr.network.model.WaterNetworkModel.add_source` method. @@ -126,15 +131,16 @@ In the example below, the strength of the source is changed from 1000 to 1500. >>> source = wn.get_source('Source') >>> print(source) - + >>> source.strength_timeseries.base_value = 1500 >>> print(source) - + When creating a water network model from an EPANET INP file, the sources that are defined in the [SOURCES] section are added to the water network model. These sources are given the name 'INP#' where # is an integer representing the number of sources in the INP file. + .. The following is not shown in the UM _wq_pdd: diff --git a/documentation/waterquality_msx.rst b/documentation/waterquality_msx.rst new file mode 100644 index 000000000..375af2b9e --- /dev/null +++ b/documentation/waterquality_msx.rst @@ -0,0 +1,105 @@ +.. raw:: latex + + \clearpage + +.. doctest:: + :hide: + + >>> import wntr + >>> try: + ... wn = wntr.network.model.WaterNetworkModel('../examples/networks/Net3.inp') + ... wn.msx = wntr.msx.MsxModel('../examples/data/Net3_arsenic.msx') + ... except: + ... wn = wntr.network.model.WaterNetworkModel('examples/networks/Net3.inp') + ... wn.msx = wntr.msx.MsxModel('examples/data/Net3_arsenic.msx') + +.. _msx_water_quality: + +Multi-species water quality simulation +======================================= + +The EpanetSimulator can use EPANET-MSX 2.0 :cite:p:`shang2023` to run +multi-species water quality simulations. +Additional multi-species simulation options are discussed in :ref:`advanced_simulation`. + +A multi-species analysis is run if a :class:`~wntr.msx.model.MsxModel` is added to the +:class:`~wntr.network.model.WaterNetworkModel`, as shown below. +In this example, the MsxModel is created from a MSX file (see :cite:p:`shang2023` for more information on file format). + +.. doctest:: + + >>> import wntr # doctest: +SKIP + + >>> wn = wntr.network.WaterNetworkModel('networks/Net3.inp') # doctest: +SKIP + >>> wn.msx = wntr.msx.MsxModel('data/Net3_arsenic.msx') # doctest: +SKIP + + >>> sim = wntr.sim.EpanetSimulator(wn) + >>> results = sim.run_sim() + +The results include a quality value for each node, link, and species +(see :ref:`simulation_results` for more details). + +Multi-species model +------------------- +In addition to creating an MsxModel from a MSX file, the MsxModel +can be built from scratch and modified using WNTR. +For example, the user can +add and remove species using :class:`~wntr.msx.model.MsxModel.add_species` and :class:`~wntr.msx.model.MsxModel.remove_species`, or +add and remove reactions using :class:`~wntr.msx.model.MsxModel.add_reaction` and :class:`~wntr.msx.model.MsxModel.remove_reaction`. +See the API documentation for the :class:`~wntr.msx.model.MsxModel` for a complete list of methods. + +Variables +~~~~~~~~~ +Variables include **species**, **coefficients**, and **terms**. +These are used in **expressions** to define the dynamics of the reaction. All variables have at least two +attributes: a name and a note. +The variable name must be a valid EPANET-MSX id, which primarily +means no spaces are permitted. However, it may be useful to ensure that the name is a valid python +variable name, so that it can be used to identify the variable in your code as well. +The note can be a string, a dictionary with the keys "pre" and "post", or an :class:`~wntr.epanet.util.ENcomment` object +(which has a "pre" and "post" attribute). See the ENcomment documentation for details on the meaning; +in this example the string form of the note is used. + +There are two different types of coefficients that can be used in reaction expressions: **constants** +and **parameters**. Constants have a single value in every expression. Parameters have a global value +that is used by default, but which can be modified on a per-pipe or per-tank basis. + +Pre-defined hydraulic variables can be found in +the EPANET-MSX documentation, and are also defined in WNTR as :attr:`~wntr.msx.base.HYDRAULIC_VARIABLES`. + +Reactions +~~~~~~~~~ +All species must have two reactions defined for the model to be run successfully in EPANET-MSX by WNTR. +One is a **pipe reaction**, the other is a **tank reaction**. + +Examples that illustrate how to build MSX models in WNTR are included in :ref:`advanced_simulation`. + +Reaction library +----------------- +WNTR also contains a library of MSX models that are accessed through the +:class:`~wntr.library.msx.MsxLibrary`. +This includes the following models: + +* `Arsenic oxidation/adsorption `_ :cite:p:`shang2023` +* `Batch chloramine decay `_ +* `Lead plumbosolvency `_ :cite:p:`bwms20` +* `Nicotine/chlorine reaction `_ +* `Nicotine/chlorine reaction with reactive intermediate `_ + +The models are stored in JSON format. +Additional models can be loaded into the library by setting a user specified path. +Additional models could also be added directly to the WNTR Reactions library. + +The following example loads the Lead plumbosolvency model (lead_ppm) from the MsxLibrary. + +.. doctest:: + + >>> import wntr.library.msx + >>> reaction_library = wntr.library.msx.MsxLibrary() + + >>> print(reaction_library.model_name_list()) # doctest: +SKIP + ['arsenic_chloramine', 'batch_chloramine_decay', 'lead_ppm', 'nicotine', 'nicotine_ri'] + + >>> lead_ppm = reaction_library.get_model("lead_ppm") + >>> print(lead_ppm) + MsxModel(name='lead_ppm') diff --git a/documentation/wntr-api.rst b/documentation/wntr-api.rst index f3fc7c4c1..27a6b7d71 100644 --- a/documentation/wntr-api.rst +++ b/documentation/wntr-api.rst @@ -21,6 +21,7 @@ API documentation wntr.graphics wntr.metrics wntr.morph + wntr.msx wntr.network wntr.scenario wntr.sim diff --git a/examples/data/Net3_arsenic.msx b/examples/data/Net3_arsenic.msx new file mode 100644 index 000000000..51c37cafd --- /dev/null +++ b/examples/data/Net3_arsenic.msx @@ -0,0 +1,59 @@ +[TITLE] +Arsenic Oxidation/Adsorption Example + +[OPTIONS] + AREA_UNITS M2 ;Surface concentration is mass/m2 + RATE_UNITS HR ;Reaction rates are concentration/hour + SOLVER RK5 ;5-th order Runge-Kutta integrator + TIMESTEP 360 ;360 sec (5 min) solution time step + RTOL 0.001 ;Relative concentration tolerance + ATOL 0.0001 ;Absolute concentration tolerance + +[SPECIES] + BULK AS3 UG ;Dissolved arsenite + BULK AS5 UG ;Dissolved arsenate + BULK AStot UG ;Total dissolved arsenic + WALL AS5s UG ;Adsorbed arsenate + BULK NH2CL MG ;Monochloramine + +[COEFFICIENTS] + CONSTANT Ka 10.0 ;Arsenite oxidation rate coefficient + CONSTANT Kb 0.1 ;Monochloramine decay rate coefficient + CONSTANT K1 5.0 ;Arsenate adsorption coefficient + CONSTANT K2 1.0 ;Arsenate desorption coefficient + CONSTANT Smax 50 ;Arsenate adsorption saturation limit + +[TERMS] + Ks K1/K2 ;Equil. adsorption coeff. + +[PIPES] + ;Arsenite oxidation + RATE AS3 -Ka*AS3*NH2CL + ;Arsenate production + RATE AS5 Ka*AS3*NH2CL - Av*(K1*(Smax-AS5s)*AS5 - K2*AS5s) + ;Monochloramine decay + RATE NH2CL -Kb*NH2CL + ;Arsenate adsorption + EQUIL AS5s Ks*Smax*AS5/(1+Ks*AS5) - AS5s + ;Total bulk arsenic + FORMULA AStot AS3 + AS5 + +[TANKS] + RATE AS3 -Ka*AS3*NH2CL + RATE AS5 Ka*AS3*NH2CL + RATE NH2CL -Kb*NH2CL + FORMULA AStot AS3 + AS5 + +[QUALITY] + ;Initial conditions (= 0 if not specified here) + NODE River AS3 10.0 + NODE River NH2CL 2.5 + NODE Lake NH2CL 2.5 + +[REPORT] + NODES All ;Report results for nodes C and D + LINKS All ;Report results for pipe 5 + SPECIES AStot YES ;Report results for each specie + SPECIES AS5 YES + SPECIES AS5s YES + SPECIES NH2CL YES diff --git a/examples/data/msx_as.msx b/examples/data/msx_as.msx new file mode 100644 index 000000000..b20e0854b --- /dev/null +++ b/examples/data/msx_as.msx @@ -0,0 +1,58 @@ +[TITLE] +Arsenic Oxidation/Adsorption Example + +[OPTIONS] + AREA_UNITS M2 ;Surface concentration is mass/m2 + RATE_UNITS HR ;Reaction rates are concentration/hour + SOLVER RK5 ;5-th order Runge-Kutta integrator + TIMESTEP 360 ;360 sec (5 min) solution time step + RTOL 0.001 ;Relative concentration tolerance + ATOL 0.0001 ;Absolute concentration tolerance + +[SPECIES] + BULK AS3 UG ;Dissolved arsenite + BULK AS5 UG ;Dissolved arsenate + BULK AStot UG ;Total dissolved arsenic + WALL AS5s UG ;Adsorbed arsenate + BULK NH2CL MG ;Monochloramine + +[COEFFICIENTS] + CONSTANT Ka 10.0 ;Arsenite oxidation rate coefficient + CONSTANT Kb 0.1 ;Monochloramine decay rate coefficient + CONSTANT K1 5.0 ;Arsenate adsorption coefficient + CONSTANT K2 1.0 ;Arsenate desorption coefficient + CONSTANT Smax 50 ;Arsenate adsorption saturation limit + +[TERMS] + Ks K1/K2 ;Equil. adsorption coeff. + +[PIPES] + ;Arsenite oxidation + RATE AS3 -Ka*AS3*NH2CL + ;Arsenate production + RATE AS5 Ka*AS3*NH2CL - Av*(K1*(Smax-AS5s)*AS5 - K2*AS5s) + ;Monochloramine decay + RATE NH2CL -Kb*NH2CL + ;Arsenate adsorption + EQUIL AS5s Ks*Smax*AS5/(1+Ks*AS5) - AS5s + ;Total bulk arsenic + FORMULA AStot AS3 + AS5 + +[TANKS] + RATE AS3 -Ka*AS3*NH2CL + RATE AS5 Ka*AS3*NH2CL + RATE NH2CL -Kb*NH2CL + FORMULA AStot AS3 + AS5 + +[QUALITY] + ;Initial conditions (= 0 if not specified here) + NODE Source AS3 10.0 + NODE Source NH2CL 2.5 + +[REPORT] + NODES C D ;Report results for nodes C and D + LINKS 5 ;Report results for pipe 5 + SPECIES AStot YES ;Report results for each specie + SPECIES AS5 YES + SPECIES AS5s YES + SPECIES NH2CL YES diff --git a/examples/demos/multisource-cl-decay.ipynb b/examples/demos/multisource-cl-decay.ipynb new file mode 100644 index 000000000..ad0da5885 --- /dev/null +++ b/examples/demos/multisource-cl-decay.ipynb @@ -0,0 +1,392 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# WNTR / EPANET-MSX demo\n", + "This demo shows a simple example of multispecies chlorine decay taken from the \n", + "EPANETMSX user manual. The Net3 example network from EPANET is used, and two \n", + "different decay coefficients are assigned - one for each source of water.\n", + "The river uses decay coefficient k1, the lake uses decay coefficient k2, and \n", + "the two values are an order of magnitude different. Once the initial example,\n", + "from the EPANETMSX user manual, has been run, parameter sensitivity is performed\n", + "to look at the impacts of different decay coefficients for the river source." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from pprint import pprint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Load the network model, optionally remove EPANET quality" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "import wntr\n", + "\n", + "\n", + "wn = wntr.network.WaterNetworkModel(\"../networks/Net3.inp\")\n", + "\n", + "wn.options.quality.parameter = \"NONE\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Add a new MSX model to the water network, set options" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "wn.add_msx_model()\n", + "wn.msx.title = \"Multisource Chlorine Decay\"\n", + "wn.msx.references.append(\n", + " \"\"\"(2023) Shang F, L Rossman, and J Uber. \n", + "\"EPANET-MSX 2.0 User Manual\". EPA/600/R-22/199\"\"\"\n", + ")\n", + "wn.msx.options.area_units = \"FT2\"\n", + "wn.msx.options.rate_units = \"DAY\"\n", + "wn.msx.options.timestep = 300" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Add the MSX reaction system information\n", + "This reaction system comes from the EPANET-MSX user manual. There are two species: Free Chlorine and a tracer. The tracer is used to select which decay coefficient is being used. The river is source 1, the lake is source 2.\n", + "\n", + "The amount of free chlorine is based on the rate reaction:\n", + "\n", + "$$\n", + " \\frac{d}{dt}\\mathrm{Cl_2} = -(k_1 T_1 + k_2(1-T_1)) \\mathrm{Cl_2}\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "T1 = wn.msx.add_species(\"T1\", \"bulk\", units=\"MG\", note=\"Source 1 Tracer\")\n", + "Cl2 = wn.msx.add_species(\"CL2\", \"bulk\", units=\"MG\", note=\"Free Chlorine\")\n", + "print(repr(Cl2))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "k1 = wn.msx.add_constant(\"k1\", 1.3, units=\"1/day\")\n", + "k2 = wn.msx.add_constant(\"k2\", 17.7, units=\"1/day\")\n", + "print(repr(k2))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "rxn_T1 = wn.msx.add_reaction(\"T1\", \"pipe\", \"rate\", \"0\")\n", + "rxn_Cl2 = wn.msx.add_reaction(\"CL2\", \"pipe\", \"rate\", \"-(k1*T1 + k2*(1-T1))*CL2\")\n", + "print(repr(rxn_Cl2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Set up the initial quality\n", + "In this example, the initial quality is based on the two sources: the tracer, indicating which source is which, is set to 1.0 for the river; the chlorine is being boosted at the sources to the same level, 1.2 mg/L." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from wntr.msx.elements import InitialQuality\n", + "\n", + "net_data = wn.msx.network_data\n", + "net_data.initial_quality[\"T1\"] = InitialQuality(node_values={\"River\": 1.0})\n", + "net_data.initial_quality[\"CL2\"] = InitialQuality(node_values={\"River\": 1.2, \"Lake\": 1.2})\n", + "pprint(net_data.initial_quality)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Run the simulation and view the results\n", + "With the MSX model attached to the WaterNetworkModel, there is nothing different in how the EpanetSimulator is called. Results are saved in keys with the species' name." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sim = wntr.sim.EpanetSimulator(wn)\n", + "res = sim.run_sim()\n", + "print(\"Node results:\", \", \".join([k for k in res.node.keys()]))\n", + "print(\"Link results:\", \", \".join([k for k in res.link.keys()]))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Generate some graphics that show how the river fraction changes through time." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "_ = wntr.graphics.plot_network(\n", + " wn,\n", + " node_attribute=res.node[\"T1\"].loc[3600 * 12, :],\n", + " title=\"12 h\",\n", + " node_colorbar_label=\"River\\nFraction\",\n", + ")\n", + "_ = wntr.graphics.plot_network(\n", + " wn,\n", + " node_attribute=res.node[\"T1\"].loc[3600 * 24, :],\n", + " title=\"24 h\",\n", + " node_colorbar_label=\"River\\nFraction\",\n", + ")\n", + "_ = wntr.graphics.plot_network(\n", + " wn,\n", + " node_attribute=res.node[\"T1\"].loc[3600 * 36, :],\n", + " title=\"36 h\",\n", + " node_colorbar_label=\"River\\nFraction\",\n", + ")\n", + "query = \"117\" # '191', '269', '117'\n", + "res.node[\"CL2\"][query].plot()\n", + "res.node[\"T1\"][query].plot()\n", + "plt.title(\"Node {}\\nk1 = {:.1f}, k2 = {:.1f}\".format(query, k1.value, k2.value))\n", + "_ = plt.legend([\"Cl2\", \"T1\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Look at impact of different k1 values on residuals" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "d_k1 = dict()\n", + "k1 = wn.msx.reaction_system.constants[\"k1\"]\n", + "for i in range(7):\n", + " # Increase the reaction rate\n", + " newk = 1.3 + i * 2.6\n", + " k1.value = newk\n", + " resk = sim.run_sim()\n", + " d_k1[newk] = resk" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# res.node[\"T1\"].loc[0:3600*36, query].plot(style='k.')\n", + "for newk, resk in d_k1.items():\n", + " resk.node[\"CL2\"].loc[0 : 3600 * 36, query].plot()\n", + "plt.legend([\"{:.1f}\".format(k) for k in d_k1.keys()], title=\"k1 (1/day)\")\n", + "plt.title(\"Chlorine residual at node {}\".format(query))\n", + "plt.xlabel(\"Seconds\")\n", + "plt.ylabel(\"Concentraion [mg/L]\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Save the model\n", + "The model is now saved in two formats: the EPANET-MSX style format as Net3.msx and then as a JSON file, Net3-msx.json.\n", + "We also can save the model as in a library format; this strips the JSON file of any network-specific information so that it only contains the species, constants, and reaction dynamics." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "from wntr.msx import io as msxio\n", + "\n", + "msxio.write_msxfile(wn.msx, \"Net3.msx\")\n", + "msxio.write_json(wn.msx, \"Net3-msx.json\")\n", + "msxio.write_json(wn.msx, \"multisource-cl.json\", as_library=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can look at the file that was written out, and we can read in the two JSON files to see how the library format has stripped out the network data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(\"Net3.msx\", \"r\") as fin:\n", + " print(fin.read())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "\n", + "with_net: dict = None\n", + "without_net: dict = None\n", + "\n", + "with open(\"Net3-msx.json\", \"r\") as fin:\n", + " with_net = json.load(fin)\n", + "with open(\"multisource-cl.json\", \"r\") as fin:\n", + " without_net = json.load(fin)\n", + "\n", + "print(\"With network data:\")\n", + "pprint(with_net[\"network_data\"])\n", + "\n", + "print(\"As a library:\")\n", + "pprint(without_net[\"network_data\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Using the MSX WNTR library\n", + "WNTR now includes a library functionality that allows a user to access certain objects by name.\n", + "The MSX integration includes adding a library of certain reaction models that are described in\n", + "the EPANET-MSX user manual. This section demonstrates how to use the model that was just saved\n", + "in the library." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from wntr.library.msx import MsxLibrary\n", + "\n", + "my_library = MsxLibrary(extra_paths=[\".\"]) # load files from the current directory\n", + "my_library.model_name_list()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that this has pulled in a lot more files than might be expected. This is because it is getting the default models (the first five models) followed by all the models in the current directory ('.'). It grabs any file that has the extension .json or .msx, which means that it has pulled in \"temp\", since this demo has run the EpanetSimulator several times, Net3, Net3-msx, and multisource-cl, because they were just created.\n", + "\n", + "The models are accessed by name. To see how they are different, compare the initial quality for the \"Net3\" model (which came from the .msx file created above) and the \"multisource-cl\" model (created with as_library=True)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_library.get_model(\"Net3\").network_data.initial_quality" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "my_library.get_model(\"multisource-cl\").network_data.initial_quality" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, examine a model that comes from the built-in data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "arsenic = my_library.get_model(\"arsenic_chloramine\")\n", + "for key, value in arsenic.reaction_system.variables():\n", + " print(repr(value))\n", + "for key, value in arsenic.reaction_system.reactions():\n", + " print(repr(value))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/networks/msx_as.inp b/examples/networks/msx_as.inp new file mode 100644 index 000000000..0887e00db --- /dev/null +++ b/examples/networks/msx_as.inp @@ -0,0 +1,36 @@ +[TITLE] +EPANET-MSX Example Network + +[JUNCTIONS] +;ID Elev Demand Pattern + A 0 4.1 + B 0 3.4 + C 0 5.5 + D 0 2.3 + +[RESERVOIRS] +;ID Head Pattern + Source 100 + +[PIPES] +;ID Node1 Node2 Length Diameter Roughness + 1 Source A 1000 200 100 + 2 A B 800 150 100 + 3 A C 1200 200 100 + 4 B C 1000 150 100 + 5 C D 2000 150 100 + +[TIMES] + Duration 48 + Hydraulic Timestep 1:00 + Quality Timestep 0:05 + Report Timestep 2 + Report Start 0 + Statistic NONE + +[OPTIONS] + Units CMH + Headloss H-W + Quality NONE + +[END] diff --git a/shell/apple/libomp.rb b/shell/apple/libomp.rb new file mode 100644 index 000000000..b32dbf9fd --- /dev/null +++ b/shell/apple/libomp.rb @@ -0,0 +1,74 @@ +class Libomp < Formula + desc "LLVM's OpenMP runtime library" + homepage "https://openmp.llvm.org/" + url "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.0/openmp-14.0.0.src.tar.xz" + sha256 "28a1cbdd3dfdd331e4ed2dda2b4477fc418e455c883bd0d1d6acc331118e4688" + license "MIT" + + livecheck do + url "https://llvm.org/" + regex(/LLVM (\d+\.\d+\.\d+)/i) + end + + bottle do + sha256 cellar: :any, arm64_monterey: "cf1058b26e1a778e523d51562c99b4145aea1b1cb89f1c60b3315677a86c7a08" + sha256 cellar: :any, arm64_big_sur: "bbf77a1a151f00a18e340ab1f655fb87fe787a85834518f1dc44bf0c52ae7d4c" + sha256 cellar: :any, monterey: "e66d2009d6d205c19499dcb453dfac4376ab6bdba805987be00ddbbab65a0818" + sha256 cellar: :any, big_sur: "ed9dc636a5fc8c2a0cfb1643f7932d742ae4805c3f193a9e56cab7d7cf7342e7" + sha256 cellar: :any, catalina: "c72ce9beecde09052e7eac3550b0286ed9bfb2d14f1dd5954705ab5fb25f231b" + sha256 cellar: :any_skip_relocation, x86_64_linux: "9fe14d5f4c8b472de1fad74278da6ba38da7322775b8a88ac61de0c373c4ad10" + end + + depends_on "cmake" => :build + depends_on :xcode => :build # Sometimes CLT cannot build arm64 + uses_from_macos "llvm" => :build + + on_linux do + keg_only "provided by LLVM, which is not keg-only on Linux" + end + + def install + # Disable LIBOMP_INSTALL_ALIASES, otherwise the library is installed as + # libgomp alias which can conflict with GCC's libgomp. + + args = ["-DLIBOMP_INSTALL_ALIASES=OFF"] + args << "-DOPENMP_ENABLE_LIBOMPTARGET=OFF" if OS.linux? + + # Build universal binary + ENV.permit_arch_flags + ENV.runtime_cpu_detection + args << "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64" + + system "cmake", "-S", "openmp-#{version}.src", "-B", "build/shared", *std_cmake_args, *args + system "cmake", "--build", "build/shared" + system "cmake", "--install", "build/shared" + + system "cmake", "-S", "openmp-#{version}.src", "-B", "build/static", + "-DLIBOMP_ENABLE_SHARED=OFF", + *std_cmake_args, *args + system "cmake", "--build", "build/static" + system "cmake", "--install", "build/static" + end + + test do + (testpath/"test.cpp").write <<~EOS + #include + #include + int main (int argc, char** argv) { + std::array arr = {0,0}; + #pragma omp parallel num_threads(2) + { + size_t tid = omp_get_thread_num(); + arr.at(tid) = tid + 1; + } + if(arr.at(0) == 1 && arr.at(1) == 2) + return 0; + else + return 1; + } + EOS + system ENV.cxx, "-Werror", "-Xpreprocessor", "-fopenmp", "test.cpp", "-std=c++11", + "-L#{lib}", "-lomp", "-o", "test" + system "./test" + end + end \ No newline at end of file diff --git a/wntr/__init__.py b/wntr/__init__.py index 08ae6d83e..a5641bc86 100644 --- a/wntr/__init__.py +++ b/wntr/__init__.py @@ -7,6 +7,7 @@ from wntr import graphics from wntr import gis from wntr import utils +from wntr import msx __version__ = '1.3.0rc2' diff --git a/wntr/epanet/Windows/EN2setup.exe b/wntr/epanet/Windows/EN2setup.exe deleted file mode 100644 index c2672af1e..000000000 Binary files a/wntr/epanet/Windows/EN2setup.exe and /dev/null differ diff --git a/wntr/epanet/Windows/epanet2.bas b/wntr/epanet/Windows/epanet2.bas deleted file mode 100644 index f6ebc367c..000000000 --- a/wntr/epanet/Windows/epanet2.bas +++ /dev/null @@ -1,194 +0,0 @@ - -'EPANET2.BAS -' -'Declarations of functions in the EPANET PROGRAMMERs TOOLKIT -'(EPANET2.DLL) - -'Last updated on 4/3/07 - -' These are codes used by the DLL functions -Global Const EN_ELEVATION = 0 ' Node parameters -Global Const EN_BASEDEMAND = 1 -Global Const EN_PATTERN = 2 -Global Const EN_EMITTER = 3 -Global Const EN_INITQUAL = 4 -Global Const EN_SOURCEQUAL = 5 -Global Const EN_SOURCEPAT = 6 -Global Const EN_SOURCETYPE = 7 -Global Const EN_TANKLEVEL = 8 -Global Const EN_DEMAND = 9 -Global Const EN_HEAD = 10 -Global Const EN_PRESSURE = 11 -Global Const EN_QUALITY = 12 -Global Const EN_SOURCEMASS = 13 -Global Const EN_INITVOLUME = 14 -Global Const EN_MIXMODEL = 15 -Global Const EN_MIXZONEVOL = 16 - -Global Const EN_TANKDIAM = 17 -Global Const EN_MINVOLUME = 18 -Global Const EN_VOLCURVE = 19 -Global Const EN_MINLEVEL = 20 -Global Const EN_MAXLEVEL = 21 -Global Const EN_MIXFRACTION = 22 -Global Const EN_TANK_KBULK = 23 - -Global Const EN_DIAMETER = 0 ' Link parameters -Global Const EN_LENGTH = 1 -Global Const EN_ROUGHNESS = 2 -Global Const EN_MINORLOSS = 3 -Global Const EN_INITSTATUS = 4 -Global Const EN_INITSETTING = 5 -Global Const EN_KBULK = 6 -Global Const EN_KWALL = 7 -Global Const EN_FLOW = 8 -Global Const EN_VELOCITY = 9 -Global Const EN_HEADLOSS = 10 -Global Const EN_STATUS = 11 -Global Const EN_SETTING = 12 -Global Const EN_ENERGY = 13 - -Global Const EN_DURATION = 0 ' Time parameters -Global Const EN_HYDSTEP = 1 -Global Const EN_QUALSTEP = 2 -Global Const EN_PATTERNSTEP = 3 -Global Const EN_PATTERNSTART = 4 -Global Const EN_REPORTSTEP = 5 -Global Const EN_REPORTSTART = 6 -Global Const EN_RULESTEP = 7 -Global Const EN_STATISTIC = 8 -Global Const EN_PERIODS = 9 - -Global Const EN_NODECOUNT = 0 'Component counts -Global Const EN_TANKCOUNT = 1 -Global Const EN_LINKCOUNT = 2 -Global Const EN_PATCOUNT = 3 -Global Const EN_CURVECOUNT = 4 -Global Const EN_CONTROLCOUNT = 5 - -Global Const EN_JUNCTION = 0 ' Node types -Global Const EN_RESERVOIR = 1 -Global Const EN_TANK = 2 - -Global Const EN_CVPIPE = 0 ' Link types -Global Const EN_PIPE = 1 -Global Const EN_PUMP = 2 -Global Const EN_PRV = 3 -Global Const EN_PSV = 4 -Global Const EN_PBV = 5 -Global Const EN_FCV = 6 -Global Const EN_TCV = 7 -Global Const EN_GPV = 8 - -Global Const EN_NONE = 0 ' Quality analysis types -Global Const EN_CHEM = 1 -Global Const EN_AGE = 2 -Global Const EN_TRACE = 3 - -Global Const EN_CONCEN = 0 ' Source quality types -Global Const EN_MASS = 1 -Global Const EN_SETPOINT = 2 -Global Const EN_FLOWPACED = 3 - -Global Const EN_CFS = 0 ' Flow units types -Global Const EN_GPM = 1 -Global Const EN_MGD = 2 -Global Const EN_IMGD = 3 -Global Const EN_AFD = 4 -Global Const EN_LPS = 5 -Global Const EN_LPM = 6 -Global Const EN_MLD = 7 -Global Const EN_CMH = 8 -Global Const EN_CMD = 9 - -Global Const EN_TRIALS = 0 ' Misc. options -Global Const EN_ACCURACY = 1 -Global Const EN_TOLERANCE = 2 -Global Const EN_EMITEXPON = 3 -Global Const EN_DEMANDMULT = 4 - -Global Const EN_LOWLEVEL = 0 ' Control types -Global Const EN_HILEVEL = 1 -Global Const EN_TIMER = 2 -Global Const EN_TIMEOFDAY = 3 - -Global Const EN_AVERAGE = 1 'Time statistic types -Global Const EN_MINIMUM = 2 -Global Const EN_MAXIMUM = 3 -Global Const EN_RANGE = 4 - -Global Const EN_MIX1 = 0 'Tank mixing models -Global Const EN_MIX2 = 1 -Global Const EN_FIFO = 2 -Global Const EN_LIFO = 3 - -Global Const EN_NOSAVE = 0 ' Save-results-to-file flag -Global Const EN_SAVE = 1 -Global Const EN_INITFLOW = 10 ' Re-initialize flow flag - -'These are the external functions that comprise the DLL - - Declare Function ENepanet Lib "epanet2.dll" (ByVal F1 As String, ByVal F2 As String, ByVal F3 As String, ByVal F4 As Any) As Long - Declare Function ENopen Lib "epanet2.dll" (ByVal F1 As String, ByVal F2 As String, ByVal F3 As String) As Long - Declare Function ENsaveinpfile Lib "epanet2.dll" (ByVal F As String) As Long - Declare Function ENclose Lib "epanet2.dll" () As Long - - Declare Function ENsolveH Lib "epanet2.dll" () As Long - Declare Function ENsaveH Lib "epanet2.dll" () As Long - Declare Function ENopenH Lib "epanet2.dll" () As Long - Declare Function ENinitH Lib "epanet2.dll" (ByVal SaveFlag As Long) As Long - Declare Function ENrunH Lib "epanet2.dll" (T As Long) As Long - Declare Function ENnextH Lib "epanet2.dll" (Tstep As Long) As Long - Declare Function ENcloseH Lib "epanet2.dll" () As Long - Declare Function ENsavehydfile Lib "epanet2.dll" (ByVal F As String) As Long - Declare Function ENusehydfile Lib "epanet2.dll" (ByVal F As String) As Long - - Declare Function ENsolveQ Lib "epanet2.dll" () As Long - Declare Function ENopenQ Lib "epanet2.dll" () As Long - Declare Function ENinitQ Lib "epanet2.dll" (ByVal SaveFlag As Long) As Long - Declare Function ENrunQ Lib "epanet2.dll" (T As Long) As Long - Declare Function ENnextQ Lib "epanet2.dll" (Tstep As Long) As Long - Declare Function ENstepQ Lib "epanet2.dll" (Tleft As Long) As Long - Declare Function ENcloseQ Lib "epanet2.dll" () As Long - - Declare Function ENwriteline Lib "epanet2.dll" (ByVal S As String) As Long - Declare Function ENreport Lib "epanet2.dll" () As Long - Declare Function ENresetreport Lib "epanet2.dll" () As Long - Declare Function ENsetreport Lib "epanet2.dll" (ByVal S As String) As Long - - Declare Function ENgetcontrol Lib "epanet2.dll" (ByVal Cindex As Long, Ctype As Long, Lindex As Long, Setting As Single, Nindex As Long, Level As Single) As Long - Declare Function ENgetcount Lib "epanet2.dll" (ByVal Code As Long, Value As Long) As Long - Declare Function ENgetoption Lib "epanet2.dll" (ByVal Code As Long, Value As Single) As Long - Declare Function ENgettimeparam Lib "epanet2.dll" (ByVal Code As Long, Value As Long) As Long - Declare Function ENgetflowunits Lib "epanet2.dll" (Code As Long) As Long - Declare Function ENgetpatternindex Lib "epanet2.dll" (ByVal ID As String, Index As Long) As Long - Declare Function ENgetpatternid Lib "epanet2.dll" (ByVal Index As Long, ByVal ID As String) As Long - Declare Function ENgetpatternlen Lib "epanet2.dll" (ByVal Index As Long, L As Long) As Long - Declare Function ENgetpatternvalue Lib "epanet2.dll" (ByVal Index As Long, ByVal Period As Long, Value As Single) As Long - Declare Function ENgetqualtype Lib "epanet2.dll" (QualCode As Long, TraceNode As Long) As Long - Declare Function ENgeterror Lib "epanet2.dll" (ByVal ErrCode As Long, ByVal ErrMsg As String, ByVal N As Long) - - Declare Function ENgetnodeindex Lib "epanet2.dll" (ByVal ID As String, Index As Long) As Long - Declare Function ENgetnodeid Lib "epanet2.dll" (ByVal Index As Long, ByVal ID As String) As Long - Declare Function ENgetnodetype Lib "epanet2.dll" (ByVal Index As Long, Code As Long) As Long - Declare Function ENgetnodevalue Lib "epanet2.dll" (ByVal Index As Long, ByVal Code As Long, Value As Single) As Long - - Declare Function ENgetlinkindex Lib "epanet2.dll" (ByVal ID As String, Index As Long) As Long - Declare Function ENgetlinkid Lib "epanet2.dll" (ByVal Index As Long, ByVal ID As String) As Long - Declare Function ENgetlinktype Lib "epanet2.dll" (ByVal Index As Long, Code As Long) As Long - Declare Function ENgetlinknodes Lib "epanet2.dll" (ByVal Index As Long, Node1 As Long, Node2 As Long) As Long - Declare Function ENgetlinkvalue Lib "epanet2.dll" (ByVal Index As Long, ByVal Code As Long, Value As Single) As Long - - Declare Function ENgetversion Lib "epanet2.dll" (Value As Long) As Long - - Declare Function ENsetcontrol Lib "epanet2.dll" (ByVal Cindex As Long, ByVal Ctype As Long, ByVal Lindex As Long, ByVal Setting As Single, ByVal Nindex As Long, ByVal Level As Single) As Long - Declare Function ENsetnodevalue Lib "epanet2.dll" (ByVal Index As Long, ByVal Code As Long, ByVal Value As Single) As Long - Declare Function ENsetlinkvalue Lib "epanet2.dll" (ByVal Index As Long, ByVal Code As Long, ByVal Value As Single) As Long - Declare Function ENaddpattern Lib "epanet2.dll" (ByVal ID As String) As Long - Declare Function ENsetpattern Lib "epanet2.dll" (ByVal Index as Long, F as Any, ByVal N as Long) as Long - Declare Function ENsetpatternvalue Lib "epanet2.dll" (ByVal Index As Long, ByVal Period As Long, ByVal Value As Single) As Long - Declare Function ENsettimeparam Lib "epanet2.dll" (ByVal Code As Long, ByVal Value As Long) As Long - Declare Function ENsetoption Lib "epanet2.dll" (ByVal Code As Long, ByVal Value As Single) As Long - Declare Function ENsetstatusreport Lib "epanet2.dll" (ByVal Code As Long) As Long - Declare Function ENsetqualtype Lib "epanet2.dll" (ByVal QualCode As Long, ByVal ChemName As String, ByVal ChemUnits As String, ByVal TraceNode As String) As Long - diff --git a/wntr/epanet/Windows/epanet2.def b/wntr/epanet/Windows/epanet2.def deleted file mode 100644 index 62b35dd89..000000000 --- a/wntr/epanet/Windows/epanet2.def +++ /dev/null @@ -1,58 +0,0 @@ -LIBRARY EPANET2.DLL - -EXPORTS - ENaddpattern = _ENaddpattern@4 - ENclose = _ENclose@0 - ENcloseH = _ENcloseH@0 - ENcloseQ = _ENcloseQ@0 - ENepanet = _ENepanet@16 - ENgetcontrol = _ENgetcontrol@24 - ENgetcount = _ENgetcount@8 - ENgeterror = _ENgeterror@12 - ENgetflowunits = _ENgetflowunits@4 - ENgetlinkid = _ENgetlinkid@8 - ENgetlinkindex = _ENgetlinkindex@8 - ENgetlinknodes = _ENgetlinknodes@12 - ENgetlinktype = _ENgetlinktype@8 - ENgetlinkvalue = _ENgetlinkvalue@12 - ENgetnodeid = _ENgetnodeid@8 - ENgetnodeindex = _ENgetnodeindex@8 - ENgetnodetype = _ENgetnodetype@8 - ENgetnodevalue = _ENgetnodevalue@12 - ENgetoption = _ENgetoption@8 - ENgetpatternid = _ENgetpatternid@8 - ENgetpatternindex = _ENgetpatternindex@8 - ENgetpatternlen = _ENgetpatternlen@8 - ENgetpatternvalue = _ENgetpatternvalue@12 - ENgetqualtype = _ENgetqualtype@8 - ENgettimeparam = _ENgettimeparam@8 - ENgetversion = _ENgetversion@4 - ENinitH = _ENinitH@4 - ENinitQ = _ENinitQ@4 - ENnextH = _ENnextH@4 - ENnextQ = _ENnextQ@4 - ENopen = _ENopen@12 - ENopenH = _ENopenH@0 - ENopenQ = _ENopenQ@0 - ENreport = _ENreport@0 - ENresetreport = _ENresetreport@0 - ENrunH = _ENrunH@4 - ENrunQ = _ENrunQ@4 - ENsaveH = _ENsaveH@0 - ENsavehydfile = _ENsavehydfile@4 - ENsaveinpfile = _ENsaveinpfile@4 - ENsetcontrol = _ENsetcontrol@24 - ENsetlinkvalue = _ENsetlinkvalue@12 - ENsetnodevalue = _ENsetnodevalue@12 - ENsetoption = _ENsetoption@8 - ENsetpattern = _ENsetpattern@12 - ENsetpatternvalue = _ENsetpatternvalue@12 - ENsetqualtype = _ENsetqualtype@16 - ENsetreport = _ENsetreport@4 - ENsetstatusreport = _ENsetstatusreport@4 - ENsettimeparam = _ENsettimeparam@8 - ENsolveH = _ENsolveH@0 - ENsolveQ = _ENsolveQ@0 - ENstepQ = _ENstepQ@4 - ENusehydfile = _ENusehydfile@4 - ENwriteline = _ENwriteline@4 diff --git a/wntr/epanet/Windows/epanet2.dll b/wntr/epanet/Windows/epanet2.dll deleted file mode 100644 index cdebe8f86..000000000 Binary files a/wntr/epanet/Windows/epanet2.dll and /dev/null differ diff --git a/wntr/epanet/Windows/epanet2.lib b/wntr/epanet/Windows/epanet2.lib deleted file mode 100644 index 7c60cb2dd..000000000 Binary files a/wntr/epanet/Windows/epanet2.lib and /dev/null differ diff --git a/wntr/epanet/Windows/epanet2.pas b/wntr/epanet/Windows/epanet2.pas deleted file mode 100644 index dba24586a..000000000 --- a/wntr/epanet/Windows/epanet2.pas +++ /dev/null @@ -1,264 +0,0 @@ -unit epanet2; - -{ Declarations of imported procedures from the EPANET PROGRAMMERs TOOLKIT } -{ (EPANET2.DLL) } - -{Last updated on 2/25/08} - -interface - -const - -{ These are codes used by the DLL functions } - EN_ELEVATION = 0; { Node parameters } - EN_BASEDEMAND = 1; - EN_PATTERN = 2; - EN_EMITTER = 3; - EN_INITQUAL = 4; - EN_SOURCEQUAL = 5; - EN_SOURCEPAT = 6; - EN_SOURCETYPE = 7; - EN_TANKLEVEL = 8; - EN_DEMAND = 9; - EN_HEAD = 10; - EN_PRESSURE = 11; - EN_QUALITY = 12; - EN_SOURCEMASS = 13; - EN_INITVOLUME = 14; - EN_MIXMODEL = 15; - EN_MIXZONEVOL = 16; - - EN_TANKDIAM = 17; - EN_MINVOLUME = 18; - EN_VOLCURVE = 19; - EN_MINLEVEL = 20; - EN_MAXLEVEL = 21; - EN_MIXFRACTION = 22; - EN_TANK_KBULK = 23; - - EN_DIAMETER = 0; { Link parameters } - EN_LENGTH = 1; - EN_ROUGHNESS = 2; - EN_MINORLOSS = 3; - EN_INITSTATUS = 4; - EN_INITSETTING = 5; - EN_KBULK = 6; - EN_KWALL = 7; - EN_FLOW = 8; - EN_VELOCITY = 9; - EN_HEADLOSS = 10; - EN_STATUS = 11; - EN_SETTING = 12; - EN_ENERGY = 13; - - EN_DURATION = 0; { Time parameters } - EN_HYDSTEP = 1; - EN_QUALSTEP = 2; - EN_PATTERNSTEP = 3; - EN_PATTERNSTART = 4; - EN_REPORTSTEP = 5; - EN_REPORTSTART = 6; - EN_RULESTEP = 7; - EN_STATISTIC = 8; - EN_PERIODS = 9; - - EN_NODECOUNT = 0; { Component counts } - EN_TANKCOUNT = 1; - EN_LINKCOUNT = 2; - EN_PATCOUNT = 3; - EN_CURVECOUNT = 4; - EN_CONTROLCOUNT = 5; - - EN_JUNCTION = 0; { Node types } - EN_RESERVOIR = 1; - EN_TANK = 2; - - EN_CVPIPE = 0; { Link types } - EN_PIPE = 1; - EN_PUMP = 2; - EN_PRV = 3; - EN_PSV = 4; - EN_PBV = 5; - EN_FCV = 6; - EN_TCV = 7; - EN_GPV = 8; - - EN_NONE = 0; { Quality analysis types } - EN_CHEM = 1; - EN_AGE = 2; - EN_TRACE = 3; - - EN_CONCEN = 0; { Source quality types } - EN_MASS = 1; - EN_SETPOINT = 2; - EN_FLOWPACED = 3; - - EN_CFS = 0; { Flow units types } - EN_GPM = 1; - EN_MGD = 2; - EN_IMGD = 3; - EN_AFD = 4; - EN_LPS = 5; - EN_LPM = 6; - EN_MLD = 7; - EN_CMH = 8; - EN_CMD = 9; - - EN_TRIALS = 0; { Option types } - EN_ACCURACY = 1; - EN_TOLERANCE = 2; - EN_EMITEXPON = 3; - EN_DEMANDMULT = 4; - - EN_LOWLEVEL = 0; { Control types } - EN_HILEVEL = 1; - EN_TIMER = 2; - EN_TIMEOFDAY = 3; - - EN_AVERAGE = 1; { Time statistic types } - EN_MINIMUM = 2; - EN_MAXIMUM = 3; - EN_RANGE = 4; - - EN_MIX1 = 0; { Tank mixing models } - EN_MIX2 = 1; - EN_FIFO = 2; - EN_LIFO = 3; - - EN_NOSAVE = 0; { Save-results-to-file flag } - EN_SAVE = 1; - EN_INITFLOW = 10; { Re-initialize flow flag } - - function ENepanet(F1: Pchar; F2: Pchar; F3: Pchar; F4: Pointer): Integer; stdcall; - function ENopen(F1: Pchar; F2: Pchar; F3: Pchar): Integer; stdcall; - function ENsaveinpfile(F: Pchar): Integer; stdcall; - function ENclose: Integer; stdcall; - - function ENsolveH: Integer; stdcall; - function ENsaveH: Integer; stdcall; - function ENopenH: Integer; stdcall; - function ENinitH(SaveFlag: Integer): Integer; stdcall; - function ENrunH(var T: LongInt): Integer; stdcall; - function ENnextH(var Tstep: LongInt): Integer; stdcall; - function ENcloseH: Integer; stdcall; - function ENsavehydfile(F: Pchar): Integer; stdcall; - function ENusehydfile(F: Pchar): Integer; stdcall; - - function ENsolveQ: Integer; stdcall; - function ENopenQ: Integer; stdcall; - function ENinitQ(SaveFlag: Integer): Integer; stdcall; - function ENrunQ(var T: LongInt): Integer; stdcall; - function ENnextQ(var Tstep: LongInt): Integer; stdcall; - function ENstepQ(var Tleft: LongInt): Integer; stdcall; - function ENcloseQ: Integer; stdcall; - - function ENwriteline(S: Pchar): Integer; stdcall; - function ENreport: Integer; stdcall; - function ENresetreport: Integer; stdcall; - function ENsetreport(S: Pchar): Integer; stdcall; - - function ENgetcontrol(Cindex: Integer; var Ctype: Integer; var Lindex: Integer; var Setting: Single; - var Nindex: Integer; var Level: Single): Integer; stdcall; - function ENgetcount(Code: Integer; var Count: Integer): Integer; stdcall; - function ENgetoption(Code: Integer; var Value: Single): Integer; stdcall; - function ENgettimeparam(Code: Integer; var Value: LongInt): Integer; stdcall; - function ENgetflowunits(var Code: Integer): Integer; stdcall; - function ENgetpatternindex(ID: Pchar; var Index: Integer): Integer; stdcall; - function ENgetpatternid(Index: Integer; ID: Pchar): Integer; stdcall; - function ENgetpatternlen(Index: Integer; var Len: Integer): Integer; stdcall; - function ENgetpatternvalue(Index: Integer; Period: Integer; var Value: Single): Integer; stdcall; - function ENgetqualtype(var QualCode: Integer; var TraceNode: Integer): Integer; stdcall; - function ENgeterror(ErrCode: Integer; ErrMsg: Pchar; N: Integer): Integer; stdcall; - - function ENgetnodeindex(ID: Pchar; var Index: Integer): Integer; stdcall; - function ENgetnodeid(Index: Integer; ID: Pchar): Integer; stdcall; - function ENgetnodetype(Index: Integer; var Code: Integer): Integer; stdcall; - function ENgetnodevalue(Index: Integer; Code: Integer; var Value: Single): Integer; stdcall; - - function ENgetlinkindex(ID: Pchar; var Index: Integer): Integer; stdcall; - function ENgetlinkid(Index: Integer; ID: Pchar): Integer; stdcall; - function ENgetlinktype(Index: Integer; var Code: Integer): Integer; stdcall; - function ENgetlinknodes(Index: Integer; var Node1: Integer; var Node2: Integer): Integer; stdcall; - function ENgetlinkvalue(Index: Integer; Code: Integer; var Value: Single): Integer; stdcall; - - function ENgetversion(var Value: Integer): Integer; stdcall; - - function ENsetcontrol(Cindex: Integer; Ctype: Integer; Lindex: Integer; Setting: Single; - Nindex: Integer; Level: Single): Integer; stdcall; - function ENsetnodevalue(Index: Integer; Code: Integer; Value: Single): Integer; stdcall; - function ENsetlinkvalue(Index: Integer; Code: Integer; Value: Single): Integer; stdcall; - function ENaddpattern(ID: Pchar): Integer; stdcall; - function ENsetpattern(Index: Integer; F: array of Single; N: Integer): Integer; stdcall; - function ENsetpatternvalue(Index: Integer; Period: Integer; Value: Single): Integer; stdcall; - function ENsettimeparam(Code: Integer; Value: LongInt): Integer; stdcall; - function ENsetoption(Code: Integer; Value: Single): Integer; stdcall; - function ENsetstatusreport(Code: Integer): Integer; stdcall; - function ENsetqualtype(QualCode: Integer; ChemName: Pchar; ChemUnits: Pchar; TraceNodeID: Pchar): Integer; stdcall; - -implementation - - function ENepanet; external 'EPANET2.DLL'; - function ENopen; external 'EPANET2.DLL'; - function ENsaveinpfile; external 'EPANET2.DLL'; - function ENclose; external 'EPANET2.DLL'; - - function ENsolveH; external 'EPANET2.DLL'; - function ENsaveH; external 'EPANET2.DLL'; - function ENopenH; external 'EPANET2.DLL'; - function ENinitH; external 'EPANET2.DLL'; - function ENrunH; external 'EPANET2.DLL'; - function ENnextH; external 'EPANET2.DLL'; - function ENcloseH; external 'EPANET2.DLL'; - function ENsavehydfile; external 'EPANET2.DLL'; - function ENusehydfile; external 'EPANET2.DLL'; - - function ENsolveQ; external 'EPANET2.DLL'; - function ENopenQ; external 'EPANET2.DLL'; - function ENinitQ; external 'EPANET2.DLL'; - function ENrunQ; external 'EPANET2.DLL'; - function ENnextQ; external 'EPANET2.DLL'; - function ENstepQ; external 'EPANET2.DLL'; - function ENcloseQ; external 'EPANET2.DLL'; - - function ENwriteline; external 'EPANET2.DLL'; - function ENreport; external 'EPANET2.DLL'; - function ENresetreport; external 'EPANET2.DLL'; - function ENsetreport; external 'EPANET2.DLL'; - - function ENgetcontrol; external 'EPANET2.DLL'; - function ENgetcount; external 'EPANET2.DLL'; - function ENgetoption; external 'EPANET2.DLL'; - function ENgettimeparam; external 'EPANET2.DLL'; - function ENgetflowunits; external 'EPANET2.DLL'; - function ENgetpatternindex; external 'EPANET2.DLL'; - function ENgetpatternid; external 'EPANET2.DLL'; - function ENgetpatternlen; external 'EPANET2.DLL'; - function ENgetpatternvalue; external 'EPANET2.DLL'; - function ENgetqualtype; external 'EPANET2.DLL'; - function ENgeterror; external 'EPANET2.DLL'; - - function ENgetnodeindex; external 'EPANET2.DLL'; - function ENgetnodeid; external 'EPANET2.DLL'; - function ENgetnodetype; external 'EPANET2.DLL'; - function ENgetnodevalue; external 'EPANET2.DLL'; - - function ENgetlinkindex; external 'EPANET2.DLL'; - function ENgetlinkid; external 'EPANET2.DLL'; - function ENgetlinktype; external 'EPANET2.DLL'; - function ENgetlinknodes; external 'EPANET2.DLL'; - function ENgetlinkvalue; external 'EPANET2.DLL'; - - function ENgetversion; external 'EPANET2.DLL'; - - function ENsetcontrol; external 'EPANET2.DLL'; - function ENsetnodevalue; external 'EPANET2.DLL'; - function ENsetlinkvalue; external 'EPANET2.DLL'; - function ENaddpattern; external 'EPANET2.DLL'; - function ENsetpattern; external 'EPANET2.DLL'; - function ENsetpatternvalue; external 'EPANET2.DLL'; - function ENsettimeparam; external 'EPANET2.DLL'; - function ENsetoption; external 'EPANET2.DLL'; - function ENsetstatusreport; external 'EPANET2.DLL'; - function ENsetqualtype; external 'EPANET2.DLL'; - -end. diff --git a/wntr/epanet/Windows/epanet22_win32.dll b/wntr/epanet/Windows/epanet22_win32.dll deleted file mode 100644 index f91a2aff8..000000000 Binary files a/wntr/epanet/Windows/epanet22_win32.dll and /dev/null differ diff --git a/wntr/epanet/__init__.py b/wntr/epanet/__init__.py index 38953580e..5a0dece5f 100644 --- a/wntr/epanet/__init__.py +++ b/wntr/epanet/__init__.py @@ -3,4 +3,4 @@ """ from .io import InpFile #, BinFile, HydFile, RptFile from .util import FlowUnits, MassUnits, HydParam, QualParam, EN -from . import toolkit, io, util, exceptions +from . import toolkit, io, util, exceptions, msx diff --git a/wntr/epanet/libepanet/darwin-arm/libepanet2.dylib b/wntr/epanet/libepanet/darwin-arm/libepanet2.dylib new file mode 100644 index 000000000..a446a9999 Binary files /dev/null and b/wntr/epanet/libepanet/darwin-arm/libepanet2.dylib differ diff --git a/wntr/epanet/libepanet/darwin-arm/libepanetmsx.dylib b/wntr/epanet/libepanet/darwin-arm/libepanetmsx.dylib new file mode 100644 index 000000000..102663927 Binary files /dev/null and b/wntr/epanet/libepanet/darwin-arm/libepanetmsx.dylib differ diff --git a/wntr/epanet/libepanet/darwin-x64/libepanet2.dylib b/wntr/epanet/libepanet/darwin-x64/libepanet2.dylib new file mode 100644 index 000000000..e3d7f1009 Binary files /dev/null and b/wntr/epanet/libepanet/darwin-x64/libepanet2.dylib differ diff --git a/wntr/epanet/Darwin/libepanet.dylib b/wntr/epanet/libepanet/darwin-x64/libepanet20.dylib old mode 100755 new mode 100644 similarity index 100% rename from wntr/epanet/Darwin/libepanet.dylib rename to wntr/epanet/libepanet/darwin-x64/libepanet20.dylib diff --git a/wntr/epanet/Darwin/libepanet22.dylib b/wntr/epanet/libepanet/darwin-x64/libepanet22.dylib old mode 100755 new mode 100644 similarity index 100% rename from wntr/epanet/Darwin/libepanet22.dylib rename to wntr/epanet/libepanet/darwin-x64/libepanet22.dylib diff --git a/wntr/epanet/libepanet/darwin-x64/libepanetmsx.dylib b/wntr/epanet/libepanet/darwin-x64/libepanetmsx.dylib new file mode 100644 index 000000000..361bac217 Binary files /dev/null and b/wntr/epanet/libepanet/darwin-x64/libepanetmsx.dylib differ diff --git a/wntr/epanet/libepanet/linux-x64/libepanet2.so b/wntr/epanet/libepanet/linux-x64/libepanet2.so new file mode 100644 index 000000000..8e9d34195 Binary files /dev/null and b/wntr/epanet/libepanet/linux-x64/libepanet2.so differ diff --git a/wntr/epanet/Linux/libepanet2_amd64.so b/wntr/epanet/libepanet/linux-x64/libepanet20.so similarity index 100% rename from wntr/epanet/Linux/libepanet2_amd64.so rename to wntr/epanet/libepanet/linux-x64/libepanet20.so diff --git a/wntr/epanet/Linux/libepanet22_amd64.so b/wntr/epanet/libepanet/linux-x64/libepanet22.so old mode 100755 new mode 100644 similarity index 100% rename from wntr/epanet/Linux/libepanet22_amd64.so rename to wntr/epanet/libepanet/linux-x64/libepanet22.so diff --git a/wntr/epanet/libepanet/linux-x64/libepanetmsx.so b/wntr/epanet/libepanet/linux-x64/libepanetmsx.so new file mode 100644 index 000000000..a72151866 Binary files /dev/null and b/wntr/epanet/libepanet/linux-x64/libepanetmsx.so differ diff --git a/wntr/epanet/libepanet/windows-x64/epanet2.dll b/wntr/epanet/libepanet/windows-x64/epanet2.dll new file mode 100644 index 000000000..658d0494a Binary files /dev/null and b/wntr/epanet/libepanet/windows-x64/epanet2.dll differ diff --git a/wntr/epanet/Windows/epanet2_amd64.dll b/wntr/epanet/libepanet/windows-x64/epanet20.dll similarity index 100% rename from wntr/epanet/Windows/epanet2_amd64.dll rename to wntr/epanet/libepanet/windows-x64/epanet20.dll diff --git a/wntr/epanet/Windows/epanet22_amd64.dll b/wntr/epanet/libepanet/windows-x64/epanet22.dll similarity index 100% rename from wntr/epanet/Windows/epanet22_amd64.dll rename to wntr/epanet/libepanet/windows-x64/epanet22.dll diff --git a/wntr/epanet/libepanet/windows-x64/epanetmsx.dll b/wntr/epanet/libepanet/windows-x64/epanetmsx.dll new file mode 100644 index 000000000..604bbb496 Binary files /dev/null and b/wntr/epanet/libepanet/windows-x64/epanetmsx.dll differ diff --git a/wntr/epanet/msx/__init__.py b/wntr/epanet/msx/__init__.py new file mode 100644 index 000000000..5fbc99912 --- /dev/null +++ b/wntr/epanet/msx/__init__.py @@ -0,0 +1,26 @@ +# coding: utf-8 +""" +The wntr.epanet.msx package provides EPANET-MSX compatibility functions for +WNTR. + +The following environment variable must be set, or the command `set_msx_path` +must be run prior to trying to instantiate the EPANET-MSX toolkit. + +.. envvar:: WNTR_PATH_TO_EPANETMSX + + The full path to the directory where EPANET-MSX has been installed. + Specifically, the directory should contain both toolkit files, epanet2.dll + and epanetmsx.dll (or the appropriate equivalent files for your system + architecture). +""" + +import os as _os + +def set_msx_path(path): + if not _os.path.isdir(path): + raise FileNotFoundError('Directory not found, {}'.format(path)) + _os.environ['WNTR_PATH_TO_EPANETMSX'] = path + +from .io import MsxFile, MsxBinFile +from .toolkit import MSXepanet + diff --git a/wntr/epanet/msx/enums.py b/wntr/epanet/msx/enums.py new file mode 100644 index 000000000..b5e67ae78 --- /dev/null +++ b/wntr/epanet/msx/enums.py @@ -0,0 +1,548 @@ +# coding: utf-8 +""" +The wntr.epanet.msx.enums module contains EPANET-MSX enum types, for use in +toolkit API calls. +""" + +from enum import IntEnum +from wntr.utils.enumtools import add_get + +@add_get(prefix='MSX_') +class TkObjectType(IntEnum): + r"""Enumeration for object type used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + NODE + LINK + PIPE + TANK + SPECIES + TERM + PARAMETER + CONSTANT + PATTERN + """ + NODE = 0 + """EPANET node""" + LINK = 1 + """EPANET link""" + PIPE = 1 + """EPANET pipe""" + TANK = 2 + """EPANET tank""" + SPECIES = 3 + """MSX species""" + TERM = 4 + """MSX term""" + PARAMETER = 5 + """MSX parameter""" + CONSTANT = 6 + """MSX constant""" + PATTERN = 7 + """**MSX** pattern""" + MAX_OBJECTS = 8 + + +@add_get(prefix='MSX_') +class TkSourceType(IntEnum): + r"""Enumeration for source type used in EPANET-MSX. + + .. warning:: These enum values start with -1. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + NOSOURCE + CONCEN + MASS + SETPOINT + FLOWPACED + """ + NOSOURCE = -1 + """No source""" + CONCEN = 0 + """Concentration based source""" + MASS = 1 + """Constant mass source""" + SETPOINT = 2 + """Setpoint source""" + FLOWPACED = 3 + """Flow-paced source""" + + +@add_get(prefix='MSX_') +class TkUnitSystem(IntEnum): + r"""Enumeration for the units system used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + US + SI + """ + US = 0 + """US units (ft, ft2, gal)""" + SI = 1 + """SI units (m, m2, m3)""" + + +@add_get(prefix='MSX_') +class TkFlowUnits(IntEnum): + r"""Enumeration for the flow units used in EPANET-MSX (determined from + EPANET INP file read in with the toolkit). + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + CFS + GPM + MGD + IMGD + AFD + LPS + LPM + MLD + CMH + CMD + """ + CFS = 0 + """cubic feet per second""" + GPM = 1 + """gallons (US) per minute""" + MGD = 2 + """million gallons (US) per day""" + IMGD = 3 + """million Imperial gallons per day""" + AFD = 4 + """acre-feet (US) per day""" + LPS = 5 + """liters per second""" + LPM = 6 + """liters per minute""" + MLD = 7 + """million liters per day""" + CMH = 8 + """cubic meters per hour""" + CMD = 9 + """cubic meters per day""" + + +@add_get(prefix='MSX_') +class TkMixType(IntEnum): + r"""Enumeration for the mixing model used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + MIX1 + MIX2 + FIFO + LIFO + """ + MIX1 = 0 + """full mixing, 1 compartment""" + MIX2 = 1 + """full mixing, 2 comparments""" + FIFO = 2 + """first in, first out""" + LIFO = 3 + """last in, first out""" + + +@add_get(prefix='MSX_') +class TkSpeciesType(IntEnum): + r"""Enumeration for species type used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + BULK + WALL + """ + BULK = 0 + """bulk species""" + WALL = 1 + """wall species""" + + +@add_get(prefix='MSX_') +class TkExpressionType(IntEnum): + r"""Enumeration for the expression type used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + NO_EXPR + RATE + FORMULA + EQUIL + """ + NO_EXPR = 0 + """no expression defined""" + RATE = 1 + """expression is a rate expression""" + FORMULA = 2 + """expression is a formula expression""" + EQUIL = 3 + """expression is an equilibrium expression""" + + +@add_get(prefix='MSX_') +class TkSolverType(IntEnum): + r"""Enumeration for the solver type used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + EUL + RK5 + ROS2 + """ + EUL = 0 + """Euler solver""" + RK5 = 1 + """Runga-Kutta 5th order solver""" + ROS2 = 2 + """Ros 2nd order solver""" + + +@add_get(prefix='MSX_') +class TkCouplingType(IntEnum): + r"""Enumeration for the coupling type option used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + NO_COUPLING + FULL_COUPLING + """ + NO_COUPLING = 0 + """no coupling""" + FULL_COUPLING = 1 + """full coupling""" + + +@add_get(prefix='MSX_') +class TkMassUnits(IntEnum): + r"""Enumeration for mass units used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + MG + UG + MOLE + MMOLE + """ + MG = 0 + """milligrams""" + UG = 1 + """micrograms""" + MOLE = 2 + """mole""" + MMOLE = 3 + """millimole""" + + +@add_get(prefix='MSX_') +class TkAreaUnits(IntEnum): + r"""Enumeration for area units used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + FT2 + M2 + CM2 + """ + FT2 = 0 + """square feet""" + M2 = 1 + """square meters""" + CM2 = 2 + """square centimeters""" + + +@add_get(prefix='MSX_') +class TkRateUnits(IntEnum): + r"""Enumeration for rate units used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + SECONDS + MINUTES + HOURS + DAYS + """ + SECONDS = 0 + """per second""" + MINUTES = 1 + """per minute""" + HOURS = 2 + """per hour""" + DAYS = 3 + """per day""" + + +@add_get(prefix='MSX_') +class TkUnits(IntEnum): + r"""Position for units used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + LENGTH_UNITS + DIAM_UNITS + AREA_UNITS + VOL_UNITS + FLOW_UNITS + CONC_UNITS + RATE_UNITS + """ + LENGTH_UNITS = 0 + """the length unit index""" + DIAM_UNITS = 1 + """the diameter unit index""" + AREA_UNITS = 2 + """the area unit index""" + VOL_UNITS = 3 + """the volume unit index""" + FLOW_UNITS = 4 + """the flow unit index""" + CONC_UNITS = 5 + """the concentration unit index""" + RATE_UNITS = 6 + """the rate unit index""" + MAX_UNIT_TYPES = 7 + + +@add_get(prefix='MSX_') +class TkHydVar(IntEnum): + r"""Enumeration for hydraulic variable used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + DIAMETER + FLOW + VELOCITY + REYNOLDS + SHEAR + FRICTION + AREAVOL + ROUGHNESS + LENGTH + """ + DIAMETER = 1 + """pipe diameter""" + FLOW = 2 + """pipe flow rate""" + VELOCITY = 3 + """segment velocity""" + REYNOLDS = 4 + """Reynolds number""" + SHEAR = 5 + """shear velocity""" + FRICTION = 6 + """friction factor""" + AREAVOL = 7 + """area / volume ratio""" + ROUGHNESS = 8 + """roughness number""" + LENGTH = 9 + """pipe or segment length""" + MAX_HYD_VARS = 10 + + +@add_get(prefix='MSX_') +class TkTstat(IntEnum): + r"""Enumeration used for time statistic in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + SERIES + AVERAGE + MINIMUM + MAXIMUM + RANGE + """ + SERIES = 0 + """output a time series""" + AVERAGE = 1 + """output the average""" + MINIMUM = 2 + """output the minimum""" + MAXIMUM = 3 + """output the maximum""" + RANGE = 4 + """output the range (max - min)""" + + +@add_get(prefix='MSX_') +class TkOption(IntEnum): + r"""Enumeration used for choosing an option in EPANET-MSX toolkit. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + AREA_UNITS_OPTION + RATE_UNITS_OPTION + SOLVER_OPTION + COUPLING_OPTION + TIMESTEP_OPTION + RTOL_OPTION + ATOL_OPTION + COMPILER_OPTION + MAXSEGMENT_OPTION + PECLETNUMBER_OPTION + """ + AREA_UNITS_OPTION = 0 + """area units""" + RATE_UNITS_OPTION = 1 + """rate units""" + SOLVER_OPTION = 2 + """solver""" + COUPLING_OPTION = 3 + """coupling""" + TIMESTEP_OPTION = 4 + """timestep size""" + RTOL_OPTION = 5 + """relative tolerance (global)""" + ATOL_OPTION = 6 + """absolute tolerance (global)""" + COMPILER_OPTION =7 + """compiler option""" + MAXSEGMENT_OPTION = 8 + """max segments""" + PECLETNUMBER_OPTION = 9 + """peclet number""" + + +@add_get(prefix='MSX_') +class TkCompiler(IntEnum): + r"""Enumeration used for specifying compiler options in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + NO_COMPILER + VC + GC + """ + NO_COMPILER = 0 + """do not compile reaction dynamics""" + VC = 1 + """use Visual C to compile reaction dynamics""" + GC = 2 + """use Gnu C to compile reaction dynamics""" + + +@add_get(prefix='MSX_') +class TkFileMode(IntEnum): + r"""Enumeration for file model used in EPANET-MSX. + + .. warning:: These enum values start with 0. + + .. rubric:: Enum Members + + The following enum names are defined, and, if using the :meth:`get` method, + then they are case insensitive and can be optionally prefixed with "MSX\_". + + .. autosummary:: + SCRATCH_FILE + SAVED_FILE + USED_FILE + """ + SCRATCH_FILE = 0 + """use a scratch file""" + SAVED_FILE = 1 + """save the file""" + USED_FILE = 2 + """use a saved file""" diff --git a/wntr/epanet/msx/exceptions.py b/wntr/epanet/msx/exceptions.py new file mode 100644 index 000000000..dc8759543 --- /dev/null +++ b/wntr/epanet/msx/exceptions.py @@ -0,0 +1,157 @@ +# coding: utf-8 +""" +The wntr.epanet.msx.exceptions module contains Exceptions for EPANET-MSX +IO operations. +""" + +from typing import List + +from ..exceptions import EpanetException + +MSX_ERROR_CODES = { + # MSX syntax errors + 401: "too many characters", + 402: "too few input items", + 403: "invalid keyword: '%s'", + 404: "invalid numeric value: '%s'", + 405: "reference to undefined object: '%s'", + 406: "illegal use of reserved name: '%s'", + 407: "name already used by another object: '%s'", + 408: "species already assigned an expression: '%s'", + 409: "illegal math expression", + 410: "option no longer supported", + 411: "term '%s' contains a cyclic reference", + # MSX runtime errors + 501: "insufficient memory available", + 502: "no EPANET data file supplied", + 503: "could not open MSX input file %s", + 504: "could not open hydraulic results file", + 505: "could not read hydraulic results file", + 506: "could not read MSX input file %s", + 507: "too few pipe reaction expressions", + 508: "too few tank reaction expressions", + 509: "could not open differential equation solver", + 510: "could not open algebraic equation solver", + 511: "could not open binary results file", + 512: "read/write error on binary results file", + 513: "could not integrate reaction rate expressions", + 514: "could not solve reaction equilibrium expressions", + 515: "reference made to an unknown type of object %s", + 516: "reference made to an illegal object index %s", + 517: "reference made to an undefined object ID %s", + 518: "invalid property values were specified", + 519: "an MSX project was not opened", + 520: "an MSX project is already opened", + 522: "could not compile chemistry functions", + 523: "could not load functions from compiled chemistry file", + 524: "illegal math operation", +} +"""A dictionary of the error codes and their meanings from the EPANET-MSX toolkit. + +:meta hide-value: +""" + + +class EpanetMsxException(EpanetException): + def __init__(self, code: int, *args: List[object], line_num=None, + line=None) -> None: + """Exception class for EPANET-MSX Toolkit and IO exceptions + + Parameters + ---------- + code : int or str or MSXErrors + EPANET-MSX error code (int) or a string mapping to the MSXErrors + enum members + args : additional non-keyword arguments, optional + If there is a string-format within the error code's text, these + will be used to replace the format, otherwise they will be output + at the end of the Exception message. + line_num : int, optional + Line number, if reading an INP file, by default None + line : str, optional + Contents of the line, by default None + """ + if not code or int(code) < 400: + return super().__init__(code, *args, line_num=line_num, line=line) + msg = MSX_ERROR_CODES.get(code, "unknown MSX error number {}".format(code)) + if args is not None: + args = [*args] + if r"%" in msg and len(args) > 0: + msg = msg % args.pop(0) + if len(args) > 0: + msg = msg + " " + str(args) + if line_num: + msg = msg + ", at line {}".format(line_num) + if line: + msg = msg + ":\n " + str(line) + msg = "(Error {}) ".format(code) + msg + super(Exception, self).__init__(msg) + + +class MSXSyntaxError(EpanetMsxException, SyntaxError): + def __init__(self, code, *args, line_num=None, line=None) -> None: + """MSX-specific error that is due to a syntax error in an msx-file. + + Parameters + ---------- + code : int or str or MSXErrors + EPANET-MSX error code (int) or a string mapping to the MSXErrors + enum members + args : additional non-keyword arguments, optional + If there is a string-format within the error code's text, these + will be used to replace the format, otherwise they will be output + at the end of the Exception message. + line_num : int, optional + Line number, if reading an INP file, by default None + line : str, optional + Contents of the line, by default None + """ + super().__init__(code, *args, line_num=line_num, line=line) + + +class MSXKeyError(EpanetMsxException, KeyError): + def __init__(self, code, name, *args, line_num=None, line=None) -> None: + """MSX-specific error that is due to a missing or unacceptably named + variable/speces/etc. + + Parameters + ---------- + code : int or str or MSXErrors + EPANET-MSX error code (int) or a string mapping to the MSXErrors + enum members + name : str + Key/name/id that is missing + args : additional non-keyword arguments, optional + If there is a string-format within the error code's text, these + will be used to replace the format, otherwise they will be output + at the end of the Exception message. + line_num : int, optional + Line number, if reading an INP file, by default None + line : str, optional + Contents of the line, by default None + """ + super().__init__(code, name, *args, line_num=line_num, line=line) + + +class MSXValueError(EpanetMsxException, ValueError): + def __init__(self, code, value, *args, line_num=None, line=None) -> None: + """MSX-specific error that is related to an invalid value. + + Parameters + ---------- + code : int or str or MSXErrors + EPANET-MSX error code (int) or a string mapping to the MSXErrors + enum members + value : Any + Value that is invalid + args : additional non-keyword arguments, optional + If there is a string-format within the error code's text, these + will be used to replace the format, otherwise they will be output + at the end of the Exception message. + line_num : int, optional + Line number, if reading an INP file, by default None + line : str, optional + Contents of the line, by default None + """ + + super().__init__(code, value, *args, line_num=line_num, line=line) diff --git a/wntr/epanet/msx/io.py b/wntr/epanet/msx/io.py new file mode 100644 index 000000000..47e35d5c6 --- /dev/null +++ b/wntr/epanet/msx/io.py @@ -0,0 +1,922 @@ +# coding: utf-8 +""" +The wntr.epanet.msx io module contains methods for reading/writing EPANET +MSX input and output files. +""" + +import datetime +import logging +import sys +from typing import Union + +import numpy as np +import pandas as pd +from wntr.msx.elements import Constant, Parameter, Species, Term + +import wntr.network +from wntr.epanet.msx.exceptions import EpanetMsxException, MSXValueError +from wntr.epanet.util import ENcomment +from wntr.msx.base import VariableType, SpeciesType +from wntr.msx.model import MsxModel + +sys_default_enc = sys.getdefaultencoding() + +logger = logging.getLogger(__name__) + +MAX_LINE = 1024 + +_INP_SECTIONS = [ + "[TITLE]", + "[OPTIONS]", + "[SPECIES]", + "[COEFFICIENTS]", + "[TERMS]", + "[PIPES]", + "[TANKS]", + "[SOURCES]", + "[QUALITY]", + "[PARAMETERS]", + "[DIFFUSIVITY]", + "[PATTERNS]", + "[REPORT]", +] + + +def _split_line(line): + _vc = line.split(";", 1) + _cmnt = None + _vals = None + if len(_vc) == 0: + pass + elif len(_vc) == 1: + _vals = _vc[0].split() + elif _vc[0] == "": + _cmnt = _vc[1].strip() + else: + _vals = _vc[0].split() + _cmnt = _vc[1].strip() + return _vals, _cmnt + + +class MsxFile(object): + """An EPANET-MSX input file reader. + + .. rubric:: Class Methods + .. autosummary:: + :nosignatures: + + read + write + + """ + + def __init__(self): + self.rxn: MsxModel = None + self.sections = dict() + for sec in _INP_SECTIONS: + self.sections[sec] = [] + self.top_comments = [] + self.patterns = dict() + + @classmethod + def read(cls, msx_filename: str, rxn_model: MsxModel = None): + """ + Read an EPANET-MSX input file (.msx) and load data into a MsxModel. + Only MSX 2.0 files are recognized. + + Parameters + ---------- + msx_file : str + Filename of the .msx file to read in + rxn_model : MsxModel, optional + Multi-species water quality model to put data into, + by default None (new model) + + Returns + ------- + MsxModel + Multi-species water quality model with the new species, reactions + and other options added + """ + if rxn_model is None: + rxn_model = MsxModel() + obj = cls() + obj.rxn = rxn_model + # if not isinstance(msx_file, list): + # msx_file = [msx_file] + rxn_model._orig_file = msx_filename + + obj.patterns = dict() + obj.top_comments = [] + obj.sections = dict() + for sec in _INP_SECTIONS: + obj.sections[sec] = [] + + def _read(): + section = None + lnum = 0 + edata = {"fname": msx_filename} + with open(msx_filename, "r", encoding=sys_default_enc) as f: + for line in f: + lnum += 1 + edata["lnum"] = lnum + line = line.strip() + nwords = len(line.split()) + if len(line) == 0 or nwords == 0: + # Blank line + continue + elif line.startswith("["): + vals = line.split(None, 1) + sec = vals[0].upper() + # Add handlers to deal with extra 'S'es (or missing 'S'es) in INP file + if sec not in _INP_SECTIONS: + trsec = sec.replace("]", "S]") + if trsec in _INP_SECTIONS: + sec = trsec + if sec not in _INP_SECTIONS: + trsec = sec.replace("S]", "]") + if trsec in _INP_SECTIONS: + sec = trsec + edata["sec"] = sec + if sec in _INP_SECTIONS: + section = sec + # logger.info('%(fname)s:%(lnum)-6d %(sec)13s section found' % edata) + continue + # elif sec == "[END]": + # # logger.info('%(fname)s:%(lnum)-6d %(sec)13s end of file found' % edata) + # section = None + # break + else: + logger.warning('%(fname)s:%(lnum)d: Invalid section "%(sec)s"' % edata) + raise EpanetMsxException(201, note="at line {}:\n{}".format(lnum, line)) + elif section is None and line.startswith(";"): + obj.top_comments.append(line[1:]) + continue + elif section is None: + logger.debug("Found confusing line: %s", repr(line)) + raise EpanetMsxException(201, note="at line {}:\n{}".format(lnum, line)) + # We have text, and we are in a section + obj.sections[section].append((lnum, line)) + + try: + _read() + obj._read_title() + obj._read_options() + obj._read_species() + obj._read_coefficients() + obj._read_terms() + obj._read_pipes() + obj._read_tanks() + obj._read_source_dict() + obj._read_quality() + obj._read_parameters() + obj._read_diffusivity() + obj._read_patterns() + obj._read_report() + return obj.rxn + except Exception as e: + raise EpanetMsxException(200) from e + + @classmethod + def write(cls, filename: str, msx: MsxModel): + """Write an MSX input file. + + Parameters + ---------- + filename : str + Filename to write + rxn : MsxModel + Multi-species water quality model + """ + obj = cls() + obj.rxn = msx + with open(filename, "w") as fout: + fout.write("; WNTR-reactions MSX file generated {}\n".format(datetime.datetime.now())) + fout.write("\n") + obj._write_title(fout) + obj._write_options(fout) + obj._write_species(fout) + obj._write_coefficients(fout) + obj._write_terms(fout) + obj._write_pipes(fout) + obj._write_tanks(fout) + obj._write_diffusivity(fout) + obj._write_parameters(fout) + obj._write_patterns(fout) + obj._write_report(fout) + obj._write_quality(fout) + obj._write_source_dict(fout) + + def _read_title(self): + lines = [] + title = None + comments = "" + for lnum, line in self.sections["[TITLE]"]: + vals, comment = _split_line(line) + if title is None and vals is not None: + title = " ".join(vals) + if comment: + lines.append(comment.strip()) + if self.top_comments: + comments = "\n".join(self.top_comments) + if len(lines) > 0: + comments = comments + ("\n" if comments else "") + "\n".join(lines) + self.rxn.title = title + self.rxn.description = comments + + def _read_options(self): + for lnum, line in self.sections["[OPTIONS]"]: + vals, comment = _split_line(line) + try: + if len(vals) < 2: + raise EpanetMsxException(402, note="at line {} of [OPTIONS] section:\n{}".format(lnum, line)) + name, val = vals[0].upper(), vals[1].upper() + if name == "AREA_UNITS": + self.rxn._options.area_units = val + elif name == "RATE_UNITS": + self.rxn._options.rate_units = val + elif name == "SOLVER": + self.rxn._options.solver = val + elif name == "COUPLING": + self.rxn._options.coupling = val + elif name == "TIMESTEP": + self.rxn._options.timestep = int(val) + elif name == "ATOL": + self.rxn._options.atol = float(val) + elif name == "RTOL": + self.rxn._options.rtol = float(val) + elif name == "COMPILER": + self.rxn._options.compiler = val + elif name == "SEGMENTS": + self.rxn._options.segments = int(val) + elif name == "PECLET": + self.rxn._options.peclet = int(val) + else: + raise EpanetMsxException(403, note="at line {} of [OPTIONS] section:\n{}".format(lnum, line)) + except ValueError: + raise EpanetMsxException(404, note="at line {} of [OPTIONS] section:\n{}".format(lnum, line)) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [OPTIONS] section:\n{}".format(lnum, line)) from e + + def _read_species(self): + note = ENcomment() + for lnum, line in self.sections["[SPECIES]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) < 3: + raise EpanetMsxException(402, note="at line {} of [SPECIES] section:\n{}".format(lnum, line)) + try: + typ = SpeciesType.get(vals[0], allow_none=False) + except ValueError as e: + raise MSXValueError(403, vals[0], note="at line {} of [SPECIES] section:\n{}".format(lnum, line)) from e + if len(vals) == 3: + self.rxn.add_species(vals[1], typ, vals[2], note=note) + elif len(vals) == 5: + self.rxn.add_species(vals[1], typ, vals[2], float(vals[3]), float(vals[4]), note=note) + else: + raise EpanetMsxException(201, note="at line {} of [SPECIES] section:\n{}".format(lnum, line)) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [SPECIES] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_coefficients(self): + note = ENcomment() + for lnum, line in self.sections["[COEFFICIENTS]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) < 3: + raise EpanetMsxException(402, note="at line {} of [COEFFICIENTS] section:\n{}".format(lnum, line)) + typ = VariableType.get(vals[0], allow_none=False) + if typ is VariableType.CONSTANT: + self.rxn.add_constant(vals[1], float(vals[2]), note=note) + elif typ is VariableType.PARAMETER: + self.rxn.add_parameter(vals[1], float(vals[2]), note=note) + else: + raise MSXValueError(403, vals[0], note="at line {} of [COEFFICIENTS] section:\n{}".format(lnum, line)) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [COEFFICIENTS] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_terms(self): + note = ENcomment() + for lnum, line in self.sections["[TERMS]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) < 2: + raise SyntaxError("Invalid [TERMS] entry") + self.rxn.add_term(vals[0], " ".join(vals[1:]), note=note) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [TERMS] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_pipes(self): + note = ENcomment() + for lnum, line in self.sections["[PIPES]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) < 3: + raise SyntaxError("Invalid [PIPES] entry") + reaction = self.rxn.add_reaction(vals[1], "pipe", vals[0], " ".join(vals[2:]), note=note) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [PIPES] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_tanks(self): + note = ENcomment() + for lnum, line in self.sections["[TANKS]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) < 3: + raise SyntaxError("Invalid [TANKS] entry") + self.rxn.add_reaction(vals[1], "tank", vals[0], " ".join(vals[2:]), note=note) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [TANKS] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_source_dict(self): + note = ENcomment() + for lnum, line in self.sections["[SOURCES]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) == 4: + typ, node, spec, strength = vals + pat = None + else: + typ, node, spec, strength, pat = vals + if spec not in self.rxn.reaction_system: + raise ValueError("Undefined species in [SOURCES] section: {}".format(spec)) + if spec not in self.rxn.network_data.sources.keys(): + self.rxn.network_data.sources[spec] = dict() + source = dict(source_type=typ, strength=strength, pattern=pat, note=note) + self.rxn.network_data.sources[spec][node] = source + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [SOURCES] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_quality(self): + note = ENcomment() + for lnum, line in self.sections["[QUALITY]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) == 4: + cmd, netid, spec, concen = vals + else: + cmd, spec, concen = vals + if cmd[0].lower() not in ["g", "n", "l"]: + raise SyntaxError("Unknown first word in [QUALITY] section") + if spec not in self.rxn.reaction_system.species: + raise ValueError("Undefined species in [QUALITY] section: {}".format(spec)) + # FIXME: check for existence + # if spec not in self.rxn.net.initial_quality: + # self.rxn.net.new_quality_values(spec) + # self.rxn._inital_qual_dict[spec]["global"] = None + # self.rxn._inital_qual_dict[spec]["nodes"] = dict() + # self.rxn._inital_qual_dict[spec]["links"] = dict() + if cmd[0].lower() == "g": + self.rxn.network_data.initial_quality[spec].global_value = float(concen) + elif cmd[0].lower() == "n": + self.rxn.network_data.initial_quality[spec].node_values[netid] = float(concen) + elif cmd[1].lower() == "l": + self.rxn.network_data.initial_quality[spec].link_values[netid] = float(concen) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [QUALITY] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_parameters(self): + note = ENcomment() + for lnum, line in self.sections["[PARAMETERS]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + typ, netid, paramid, value = vals + if paramid not in self.rxn.reaction_system.parameters: + raise ValueError("Invalid parameter {}".format(paramid)) + value = float(value) + if typ.lower()[0] == "p": + self.rxn.network_data.parameter_values[paramid].pipe_values[netid] = value + elif typ.lower()[0] == "t": + self.rxn.network_data.parameter_values[paramid].tank_values[netid] = value + else: + raise ValueError("Invalid parameter type {}".format(typ)) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [PARAMETERS] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_diffusivity(self): + note = ENcomment() + for lnum, line in self.sections["[DIFFUSIVITY]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) != 2: + raise SyntaxError("Invalid [DIFFUSIVITIES] entry") + species = self.rxn.reaction_system.species[vals[0]] + species.diffusivity = float(vals[1]) + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [DIFFUSIVITIES] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _read_patterns(self): + _patterns = dict() + for lnum, line in self.sections["[PATTERNS]"]: + # read the lines for each pattern -- patterns can be multiple lines of arbitrary length + line = line.split(";")[0] + current = line.split() + if current == []: + continue + pattern_name = current[0] + if pattern_name not in _patterns: + _patterns[pattern_name] = [] + for i in current[1:]: + _patterns[pattern_name].append(float(i)) + else: + for i in current[1:]: + _patterns[pattern_name].append(float(i)) + for pattern_name, pattern in _patterns.items(): + # add the patterns to the water newtork model + self.rxn.network_data.add_pattern(pattern_name, pattern) + + def _read_report(self): + note = ENcomment() + for lnum, line in self.sections["[REPORT]"]: + vals, comment = _split_line(line) + if vals is None: + if comment is not None: + note.pre.append(comment) + continue + try: + if comment is not None: + note.post = comment + if len(vals) == 0: + continue + if len(vals) < 2: + raise SyntaxError("Invalid number of arguments in [REPORT] section") + cmd = vals[0][0].lower() + if cmd == "n": # NODES + if self.rxn._options.report.nodes is None: + if vals[1].upper() == "ALL": + self.rxn._options.report.nodes = "ALL" + else: + self.rxn._options.report.nodes = list() + self.rxn._options.report.nodes.extend(vals[1:]) + elif isinstance(self.rxn._options.report.nodes, list): + if vals[1].upper() == "ALL": + self.rxn._options.report.nodes = "ALL" + else: + self.rxn._options.report.nodes.extend(vals[1:]) + elif cmd == "l": # LINKS + if self.rxn._options.report.links is None: + if vals[1].upper() == "ALL": + self.rxn._options.report.links = "ALL" + else: + self.rxn._options.report.links = list() + self.rxn._options.report.links.extend(vals[1:]) + elif isinstance(self.rxn._options.report.links, list): + if vals[1].upper() == "ALL": + self.rxn._options.report.links = "ALL" + else: + self.rxn._options.report.links.extend(vals[1:]) + elif cmd == "f": + self.rxn._options.report.report_filename = vals[1] + elif cmd == "p": + self.rxn._options.report.pagesize = vals[1] + elif cmd == "s": + if vals[1] not in self.rxn.reaction_system.species: + raise ValueError("Undefined species in [REPORT] section: {}".format(vals[1])) + self.rxn._options.report.species[vals[1]] = True if vals[2].lower().startswith("y") else False + if len(vals) == 4: + self.rxn._options.report.species_precision[vals[1]] = int(vals[3]) + else: + raise SyntaxError("Invalid syntax in [REPORT] section: unknown first word") + except EpanetMsxException: + raise + except Exception as e: + raise EpanetMsxException(201, note="at line {} of [REPORT] section:\n{}".format(lnum, line)) from e + else: + note = ENcomment() + + def _write_title(self, fout): + fout.write("[TITLE]\n") + fout.write(" {}\n".format(self.rxn.title)) + fout.write("\n") + + def _write_options(self, fout): + opts = self.rxn._options + fout.write("[OPTIONS]\n") + fout.write(" AREA_UNITS {}\n".format(opts.area_units.upper())) + fout.write(" RATE_UNITS {}\n".format(opts.rate_units.upper())) + fout.write(" SOLVER {}\n".format(opts.solver.upper())) + fout.write(" COUPLING {}\n".format(opts.coupling.upper())) + fout.write(" TIMESTEP {}\n".format(opts.timestep)) + fout.write(" ATOL {}\n".format(opts.atol)) + fout.write(" RTOL {}\n".format(opts.rtol)) + fout.write(" COMPILER {}\n".format(opts.compiler.upper())) + fout.write(" SEGMENTS {}\n".format(opts.segments)) + fout.write(" PECLET {}\n".format(opts.peclet)) + fout.write("\n") + + def _write_species(self, fout): + fout.write("[SPECIES]\n") + + def to_msx_string(spec: Species) -> str: + tols = spec.get_tolerances() + if tols is None: + tolstr = "" + else: + tolstr = " {:12.6g} {:12.6g}".format(*tols) + return "{:<12s} {:<8s} {:<8s}{:s}".format( + spec.species_type.name.upper(), + spec.name, + spec.units, + tolstr, + ) + + for var in self.rxn.reaction_system.species.values(): + if isinstance(var.note, ENcomment): + fout.write("{}\n".format(var.note.wrap_msx_string(to_msx_string(var)))) + elif isinstance(var.note, str): + fout.write(" {} ; {}\n".format(to_msx_string(var), var.note)) + else: + fout.write(" {}\n".format(to_msx_string(var))) + fout.write("\n") + + def _write_coefficients(self, fout): + fout.write("[COEFFICIENTS]\n") + + def to_msx_string(coeff: Union[Constant, Parameter]) -> str: + # if self.units is not None: + # post = r' ; {"units"="' + str(self.units) + r'"}' + # else: + post = "" + return "{:<12s} {:<8s} {:<16s}{}".format( + coeff.var_type.name.upper(), + coeff.name, + str(coeff.global_value if isinstance(coeff, Parameter) else coeff.value), + post, + ) + + for var in self.rxn.reaction_system.constants.values(): + if isinstance(var.note, ENcomment): + fout.write("{}\n".format(var.note.wrap_msx_string(to_msx_string(var)))) + elif isinstance(var.note, str): + fout.write(" {} ; {}\n".format(to_msx_string(var), var.note)) + else: + fout.write(" {}\n".format(to_msx_string(var))) + for var in self.rxn.reaction_system.parameters.values(): + if isinstance(var.note, ENcomment): + fout.write("{}\n".format(var.note.wrap_msx_string(to_msx_string(var)))) + elif isinstance(var.note, str): + fout.write(" {} ; {}\n".format(to_msx_string(var), var.note)) + else: + fout.write(" {}\n".format(to_msx_string(var))) + fout.write("\n") + + def _write_terms(self, fout): + fout.write("[TERMS]\n") + + def to_msx_string(term: Term) -> str: + return "{:<8s} {:<64s}".format(term.name, term.expression) + + for var in self.rxn.reaction_system.terms.values(): + if isinstance(var.note, ENcomment): + fout.write("{}\n".format(var.note.wrap_msx_string(to_msx_string(var)))) + elif isinstance(var.note, str): + fout.write(" {} ; {}\n".format(to_msx_string(var), var.note)) + else: + fout.write(" {}\n".format(to_msx_string(var))) + fout.write("\n") + + def _write_pipes(self, fout): + fout.write("[PIPES]\n") + for spec in self.rxn.reaction_system.species.values(): + var = spec.pipe_reaction + if var is None: + raise MSXValueError(507, note=" species {}".format(str(spec))) + if isinstance(var.note, ENcomment): + fout.write( + "{}\n".format( + var.note.wrap_msx_string( + "{:<12s} {:<8s} {:<32s}".format(var.expression_type.name.upper(), str(var.species_name), var.expression) + ) + ) + ) + elif isinstance(var.note, str): + fout.write( + " {} ; {}\n".format( + "{:<12s} {:<8s} {:<32s}".format(var.expression_type.name.upper(), str(var.species_name), var.expression), + var.note, + ) + ) + else: + fout.write( + " {}\n".format( + "{:<12s} {:<8s} {:<32s}".format(var.expression_type.name.upper(), str(var.species_name), var.expression) + ) + ) + fout.write("\n") + + def _write_tanks(self, fout): + fout.write("[TANKS]\n") + for spec in self.rxn.reaction_system.species.values(): + if spec.species_type.name == 'WALL': + continue + try: + var = spec.tank_reaction + except KeyError: + logger.warn('Species {} does not have a tank reaction - this may be a problem'.format(str(spec))) + continue + if var is None: + raise MSXValueError(508, note=" species {}".format(str(spec))) + if isinstance(var.note, ENcomment): + fout.write( + "{}\n".format( + var.note.wrap_msx_string( + "{:<12s} {:<8s} {:<32s}".format(var.expression_type.name.upper(), str(var.species_name), var.expression) + ) + ) + ) + elif isinstance(var.note, str): + fout.write( + " {} ; {}\n".format( + "{:<12s} {:<8s} {:<32s}".format(var.expression_type.name.upper(), str(var.species_name), var.expression), + var.note, + ) + ) + else: + fout.write( + " {}\n".format( + "{:<12s} {:<8s} {:<32s}".format(var.expression_type.name.upper(), str(var.species_name), var.expression) + ) + ) + fout.write("\n") + + def _write_source_dict(self, fout): + fout.write("[SOURCES]\n") + for species in self.rxn.network_data.sources.keys(): + for node, src in self.rxn.network_data.sources[species].items(): + if isinstance(src["note"], ENcomment): + fout.write( + src["note"].wrap_msx_string( + "{:<10s} {:<8s} {:<8s} {:12s} {:<12s}".format( + src["source_type"], + node, + species, + src["strength"], + src["pattern"] if src["pattern"] is not None else "", + ) + ) + ) + elif isinstance(src["note"], str): + fout.write( + " {:<10s} {:<8s} {:<8s} {} {:<12s} ; {}\n".format( + src["source_type"], + node, + species, + src["strength"], + src["pattern"] if src["pattern"] is not None else "", + src["note"], + ) + ) + else: + fout.write( + " {:<10s} {:<8s} {:<8s} {} {:<12s}\n".format( + src["source_type"], + node, + species, + src["strength"], + src["pattern"] if src["pattern"] is not None else "", + ) + ) + if src["note"] is not None: + fout.write("\n") + fout.write("\n") + + def _write_quality(self, fout): + fout.write("[QUALITY]\n") + for species, val in self.rxn.network_data.initial_quality.items(): + for typ in ["node_values", "link_values"]: + for node, conc in getattr(val, typ).items(): + fout.write(" {:<8s} {:<8s} {:<8s} {}\n".format(typ.upper()[0:4], node, species, conc)) + if val.global_value: + fout.write(" {:<8s} {:<8s} {}\n".format("GLOBAL", species, val.global_value)) + fout.write("\n") + + def _write_parameters(self, fout): + fout.write("[PARAMETERS]\n") + for name, var in self.rxn.network_data.parameter_values.items(): + had_entries = False + for pipeID, value in var.pipe_values.items(): + fout.write(" PIPE {:<8s} {:<8s} {}\n".format(pipeID, name, value)) + had_entries = True + for tankID, value in var.tank_values.items(): + fout.write(" PIPE {:<8s} {:<8s} {}\n".format(tankID, name, value)) + had_entries = True + if had_entries: + fout.write("\n") + fout.write("\n") + + def _write_patterns(self, fout): + fout.write("[PATTERNS]\n") + for pattern_name, pattern in self.rxn.network_data.patterns.items(): + num_columns = 10 + count = 0 + for i in pattern: # .multipliers: + if count % num_columns == 0: + fout.write("\n {:<8s} {:g}".format(pattern_name, i)) + else: + fout.write(" {:g}".format(i)) + count += 1 + fout.write("\n") + fout.write("\n") + + def _write_diffusivity(self, fout): + fout.write("[DIFFUSIVITY]\n") + for name in self.rxn.species_name_list: + spec: Species = self.rxn.reaction_system.species[name] + if spec.diffusivity is not None: + fout.write(" {:<8s} {}\n".format(name, spec.diffusivity)) + fout.write("\n") + + def _write_report(self, fout): + fout.write("[REPORT]\n") + if self.rxn._options.report.nodes is not None: + if isinstance(self.rxn._options.report.nodes, str): + fout.write(" NODES {}\n".format(self.rxn.options.report.nodes)) + else: + fout.write(" NODES {}\n".format(" ".join(self.rxn.options.report.nodes))) + if self.rxn._options.report.links is not None: + if isinstance(self.rxn._options.report.links, str): + fout.write(" LINKS {}\n".format(self.rxn.options.report.links)) + else: + fout.write(" LINKS {}\n".format(" ".join(self.rxn.options.report.links))) + for spec, val in self.rxn._options.report.species.items(): + fout.write( + " SPECIES {:<8s} {:<3s} {}\n".format( + spec, + "YES" if val else "NO", + self.rxn._options.report.species_precision[spec] + if spec in self.rxn._options.report.species_precision.keys() + else "", + ) + ) + if self.rxn._options.report.report_filename: + fout.write(" FILE {}\n".format(self.rxn._options.report.report_filename)) + if self.rxn._options.report.pagesize: + fout.write(" PAGESIZE {}\n".format(self.rxn._options.report.pagesize)) + fout.write("\n") + + +def MsxBinFile(filename, wn, res = None): + duration = int(wn.options.time.duration) + if res is None: + from wntr.sim.results import SimulationResults + res = SimulationResults() + with open(filename, "rb") as fin: + ftype = "=f4" + idlen = 32 + prolog = np.fromfile(fin, dtype=np.int32, count=6) + magic1 = prolog[0] + version = prolog[1] + nnodes = prolog[2] + nlinks = prolog[3] + nspecies = prolog[4] + reportstep = prolog[5] + species_list = [] + node_list = wn.node_name_list + link_list = wn.link_name_list + # print(magic1, version, nnodes, nlinks, nspecies, reportstep) + + species_mass = [] + for i in range(nspecies): + species_len = np.fromfile(fin, dtype=np.int32, count=1)[0] + # print(species_len) + species_name = "".join(chr(f) for f in np.fromfile(fin, dtype=np.uint8, count=species_len) if f != 0) + # print(species_name) + species_list.append(species_name) + species_mass.append("".join(chr(f) for f in np.fromfile(fin, dtype=np.uint8, count=16) if f != 0)) + + timerange = range(0, duration + 1, reportstep) + + tr = len(timerange) + + row1 = ["node"] * nnodes * len(species_list) + ["link"] * nlinks * len(species_list) + row2 = [] + for i in [nnodes, nlinks]: + for j in species_list: + row2.append([j] * i) + row2 = [y for x in row2 for y in x] + row3 = [node_list for i in species_list] + [link_list for i in species_list] + row3 = [y for x in row3 for y in x] + + tuples = list(zip(row1, row2, row3)) + index = pd.MultiIndex.from_tuples(tuples, names=["type", "species", "name"]) + + try: + data = np.fromfile(fin, dtype=np.dtype(ftype), count=tr * (len(species_list * (nnodes + nlinks)))) + data = np.reshape(data, (tr, len(species_list * (nnodes + nlinks)))) + except Exception as e: + print(e) + print("oops") + + postlog = np.fromfile(fin, dtype=np.int32, count=4) + offset = postlog[0] + numreport = postlog[1] + errorcode = postlog[2] + magicnew = postlog[3] + if errorcode != 0: + print(f"ERROR CODE: {errorcode}") + print(offset, numreport) + + df_fin = pd.DataFrame(index=index, columns=timerange).transpose() + if magic1 == magicnew: + # print("Magic# Match") + df_fin = pd.DataFrame(data.transpose(), index=index, columns=timerange) + df_fin = df_fin.transpose() + + else: + print("Magic#s do not match!") + for species in species_list: + res.node[species] = df_fin['node'][species] + res.link[species] = df_fin['link'][species] + return res diff --git a/wntr/epanet/msx/toolkit.py b/wntr/epanet/msx/toolkit.py new file mode 100644 index 000000000..335ae9f4f --- /dev/null +++ b/wntr/epanet/msx/toolkit.py @@ -0,0 +1,766 @@ +# coding: utf-8 +""" +The wntr.epanet.msx.toolkit module is a Python extension for the EPANET-MSX +Programmers Toolkit DLLs. + +.. note:: + + Code in this section is based on code from "EPANET-MSX-Python-wrapper", + licensed under the BSD license. See LICENSE.md for details. +""" +import ctypes +import logging +import os +import os.path +import platform +import sys +from typing import Union + +from pkg_resources import resource_filename + +from wntr.epanet.msx.enums import TkObjectType, TkSourceType + +from ..toolkit import ENepanet +from .exceptions import (MSX_ERROR_CODES, EpanetMsxException, MSXKeyError, + MSXValueError) + +logger = logging.getLogger(__name__) + +epanet_toolkit = "wntr.epanet.toolkit" + +if os.name in ["nt", "dos"]: + libepanet = resource_filename(__name__, "../libepanet/windows-x64/epanet2.dll") + libmsx = resource_filename(__name__, "../libepanet/windows-x64/epanetmsx.dll") +elif sys.platform in ["darwin"]: + if 'arm' in platform.platform().lower(): + libepanet = resource_filename(__name__, "../libepanet/darwin-arm/libepanet2.dylib") + libmsx = resource_filename(__name__, "../libepanet/darwin-arm/libepanetmsx.dylib") + else: + libepanet = resource_filename(__name__, "../libepanet/darwin-x64/libepanet2.dylib") + libmsx = resource_filename(__name__, "../libepanet/darwin-x64/libepanetmsx.dylib") +else: + libepanet = resource_filename(__name__, "../libepanet/linux-x64/libepanet2.so") + libmsx = resource_filename(__name__, "../libepanet/linux-x64/libepanetmsx.so") + +dylib_dir = os.environ.get('DYLD_FALLBACK_LIBRARY_PATH','') +if dylib_dir != '': + if 'arm' in platform.platform().lower(): + dylib_dir = dylib_dir + ':' + resource_filename(__name__, "../libepanet/darwin-arm") + else: + dylib_dir = dylib_dir + ':' + resource_filename(__name__, "../libepanet/darwin-x64") + os.environ['DYLD_FALLBACK_LIBRARY_PATH'] = dylib_dir + + +class MSXepanet(ENepanet): + def __init__(self, inpfile="", rptfile="", binfile="", msxfile=""): + + self.ENlib = None + self.errcode = 0 + self.errcodelist = [] + self.cur_time = 0 + + self.Warnflag = False + self.Errflag = False + self.fileLoaded = False + + self.inpfile = inpfile + self.rptfile = rptfile + self.binfile = binfile + self.msxfile = msxfile + + try: + if os.name in ["nt", "dos"]: + self.ENlib = ctypes.windll.LoadLibrary(libmsx) + else: + self.ENlib = ctypes.cdll.LoadLibrary(libmsx) + except: + raise + finally: + self._project = None + return + + def _error(self, *args): + """Print the error text the corresponds to the error code returned""" + if not self.errcode: + return + # errtxt = self.ENlib.ENgeterror(self.errcode) + errtext = MSX_ERROR_CODES.get(self.errcode, 'unknown error') + if '%' in errtext and len(args) == 1: + errtext % args + if self.errcode >= 100: + self.Errflag = True + logger.error("EPANET error {} - {}".format(self.errcode, errtext)) + raise EpanetMsxException(self.errcode) + return + + + def ENopen(self, inpfile=None, rptfile=None, binfile=None): + """ + Opens an EPANET input file and reads in network data + + Parameters + ---------- + inpfile : str + EPANET INP file (default to constructor value) + rptfile : str + Output file to create (default to constructor value) + binfile : str + Binary output file to create (default to constructor value) + """ + if self.fileLoaded: + self.ENclose() + if self.fileLoaded: + raise RuntimeError("File is loaded and cannot be closed") + if inpfile is None: + inpfile = self.inpfile + if rptfile is None: + rptfile = self.rptfile + if binfile is None: + binfile = self.binfile + inpfile = inpfile.encode("latin-1") + rptfile = rptfile.encode("latin-1") + binfile = binfile.encode("latin-1") + self.errcode = self.ENlib.MSXENopen(inpfile, rptfile, binfile) + self._error() + if self.errcode < 100: + self.fileLoaded = True + return + + def ENclose(self): + """Frees all memory and files used by EPANET""" + self.errcode = self.ENlib.MSXENclose() + self._error() + if self.errcode < 100: + self.fileLoaded = False + return + + # ----------running the simulation----------------------------------------- + def MSXopen(self, msxfile): + """Opens the MSX Toolkit to analyze a particular distribution system. + + Parameters + ---------- + msxfile : str + Name of the MSX input file + """ + if msxfile is not None: + msxfile = ctypes.c_char_p(msxfile.encode()) + ierr = self.ENlib.MSXopen(msxfile) + if ierr != 0: + raise EpanetMsxException(ierr, msxfile) + + def MSXclose(self): + """Closes down the Toolkit system (including all files being processed)""" + ierr = self.ENlib.MSXclose() + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXusehydfile(self, filename): + """Uses the contents of the specified file as the current binary + hydraulics file + + Parameters + ---------- + filename : str + Name of the hydraulics file to use + """ + ierr = self.ENlib.MSXusehydfile(ctypes.c_char_p(filename.encode())) + if ierr != 0: + raise EpanetMsxException(ierr, filename) + + def MSXsolveH(self): + """Runs a complete hydraulic simulation with results + for all time periods written to the binary Hydraulics file.""" + ierr = self.ENlib.MSXsolveH() + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXinit(self, saveFlag=0): + """Initializes the MSX system before solving for water quality results + in step-wise fashion set saveFlag to 1 if water quality results should + be saved to a scratch binary file, or to 0 is not saved to file""" + saveFlag = int(saveFlag) + ierr = self.ENlib.MSXinit(saveFlag) + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXsolveQ(self): + """Solves for water quality over the entire simulation period and saves + the results to an internal scratch file""" + ierr = self.ENlib.MSXsolveQ() + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXstep(self): + """Advances the water quality simulation one water quality time step. + The time remaining in the overall simulation is returned as tleft, the + current time as t.""" + t = ctypes.c_long() + tleft = ctypes.c_long() + ierr = self.ENlib.MSXstep(ctypes.byref(t), ctypes.byref(tleft)) + if ierr != 0: + raise EpanetMsxException(ierr) + out = [t.value, tleft.value] + return out + + def MSXsaveoutfile(self, filename): + """Saves water quality results computed for each node, link and + reporting time period to a named binary file + + Parameters + ---------- + filename : str + Save a binary results file + """ + ierr = self.ENlib.MSXsaveoutfile(ctypes.c_char_p(filename.encode())) + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXsavemsxfile(self, filename): + """Saves the data associated with the current MSX project into a new + MSX input file + + Parameters + ---------- + filename : str + Name of the MSX input file to create + """ + ierr = self.ENlib.MSXsavemsxfile(ctypes.c_char_p(filename.encode())) + if ierr != 0: + raise EpanetMsxException(ierr, filename) + + def MSXreport(self): + """Writes water quality simulations results as instructed by the MSX + input file to a text file""" + ierr = self.ENlib.MSXreport() + if ierr != 0: + raise EpanetMsxException(ierr) + + # ---------get parameters-------------------------------------------------- + def MSXgetindex(self, _type: Union[int, TkObjectType], name): + """Gets the internal index of an MSX object given its name. + + Parameters + ---------- + _type : int, str or ObjectType + Type of object to get an index for + name : str + Name of the object to get an index for + + Returns + ------- + int + Internal index + + Raises + ------ + MSXKeyError + If an invalid str is passed for _type + MSXValueError + If _type is not a valid MSX object type + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + type_ind = int(_type) + ind = ctypes.c_int() + ierr = self.ENlib.MSXgetindex(type_ind, ctypes.c_char_p(name.encode()), ctypes.byref(ind)) + if ierr != 0: + raise EpanetMsxException(ierr, repr(dict(_type=_type, name=name))) + return ind.value + + def MSXgetIDlen(self, _type, index): + """Get the number of characters in the ID name of an MSX object + given its internal index number. + + Parameters + ---------- + _type : int, str or ObjectType + Type of object to get an index for + index : int + Index of the object to get the ID length for + + Returns + ------- + int + Length of the object ID + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + type_ind = int(_type) + len = ctypes.c_int() + ierr = self.ENlib.MSXgetIDlen(type_ind, ctypes.c_int(index), ctypes.byref(len)) + if ierr != 0: + raise EpanetMsxException(ierr, repr(dict(_type=_type, index=index))) + return len.value + + def MSXgetID(self, _type, index): + """Get the ID name of an object given its internal index number + + Parameters + ---------- + _type : int, str or ObjectType + Type of object to get an index for + index : int + Index of the object to get the ID for + + Returns + ------- + str + Object ID + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + type_ind = int(_type) + maxlen = 32 + id = ctypes.create_string_buffer(maxlen) + ierr = self.ENlib.MSXgetID(type_ind, ctypes.c_int(index), ctypes.byref(id), ctypes.c_int(maxlen - 1)) + if ierr != 0: + raise EpanetMsxException(ierr, repr(dict(_type=_type, index=index))) + # the .decode() added my MF 6/3/21 + return id.value.decode() + + def MSXgetinitqual(self, _type, node_link_index, species_index): + """Get the initial concentration of a particular chemical species + assigned to a specific node or link of the pipe network + + Parameters + ---------- + _type : str, int or ObjectType + Type of object + node_link_index : int + Object index + species_index : int + Species index + + Returns + ------- + float + Initial quality value for that node or link + + Raises + ------ + MSXKeyError + Type passed in for ``_type`` is not valid + MSXValueError + Value for ``_type`` is not valid + EpanetMsxException + Any other error from the C-API + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + if _type not in [TkObjectType.NODE, TkObjectType.LINK]: + raise MSXValueError(515, repr(_type)) + type_ind = int(_type) + iniqual = ctypes.c_double() + ierr = self.ENlib.MSXgetinitqual(ctypes.c_int(type_ind), ctypes.c_int(node_link_index), ctypes.c_int(species_index), ctypes.byref(iniqual)) + if ierr != 0: + raise EpanetMsxException(ierr, repr(dict(_type=_type, node_link_index=node_link_index, species_index=species_index))) + return iniqual.value + + def MSXgetqual(self, _type, node_link_index, species_index): + """Get a chemical species concentration at a given node or the + average concentration along a link at the current simulation time step + + Parameters + ---------- + _type : str, int or ObjectType + Type of object + node_link_index : int + Object index + species_index : int + Species index + + Returns + ------- + float + Current quality value for that node or link + + Raises + ------ + MSXKeyError + Type passed in for ``_type`` is not valid + MSXValueError + Value for ``_type`` is not valid + EpanetMsxException + Any other error from the C-API + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + if _type not in [TkObjectType.NODE, TkObjectType.LINK]: + raise MSXValueError(515, repr(_type)) + type_ind = int(_type) + qual = ctypes.c_double() + ierr = self.ENlib.MSXgetqual(ctypes.c_int(type_ind), ctypes.c_int(node_link_index), ctypes.c_int(species_index), ctypes.byref(qual)) + if ierr != 0: + raise EpanetMsxException(ierr, repr(dict(_type=_type, node_link_index=node_link_index, species_index=species_index))) + return qual.value + + def MSXgetconstant(self, constant_index): + """Get the value of a particular reaction constant + + Parameters + ---------- + constant_index : int + Index to the constant + + Returns + ------- + float + Value of the constant + + Raises + ------ + EpanetMsxException + Toolkit error occurred + """ + const = ctypes.c_double() + ierr = self.ENlib.MSXgetconstant(constant_index, ctypes.byref(const)) + if ierr != 0: + raise EpanetMsxException(ierr, constant_index) + return const.value + + def MSXgetparameter(self, _type, node_link_index, param_index): + """Get the value of a particular reaction parameter for a given + TANK or PIPE. + + Parameters + ---------- + _type : int or str or Enum + Get the type of the parameter + node_link_index : int + Link index + param_index : int + Parameter variable index + + Returns + ------- + float + Parameter value + + Raises + ------ + MSXKeyError + If there is no such _type + MSXValueError + If the _type is improper + EpanetMsxException + Any other error + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + if _type not in [TkObjectType.NODE, TkObjectType.LINK]: + raise MSXValueError(515, repr(_type)) + type_ind = int(_type) + param = ctypes.c_double() + ierr = self.ENlib.MSXgetparameter(ctypes.c_int(type_ind), ctypes.c_int(node_link_index), ctypes.c_int(param_index), ctypes.byref(param)) + if ierr != 0: + raise EpanetMsxException(ierr, repr(dict(_type=_type, node_link_index=node_link_index, param_index=param_index))) + return param.value + + def MSXgetsource(self, node_index, species_index): + """Get information on any external source of a particular + chemical species assigned to a specific node of the pipe network + + Parameters + ---------- + node_index : int + Node index + species_index : int + Species index + + Returns + ------- + list + [source type, level, and pattern] where level is the baseline + concentration (or mass flow rate) of the source and pattern the + index of the time pattern used to add variability to the source's + baseline level (0 if no pattern defined for the source) + """ + level = ctypes.c_double() + _type = ctypes.c_int() + pat = ctypes.c_int() + ierr = self.ENlib.MSXgetsource(ctypes.c_int(node_index), ctypes.c_int(species_index), ctypes.byref(_type), ctypes.byref(level), ctypes.byref(pat)) + if ierr != 0: + raise EpanetMsxException(ierr, repr(dict(node_index=node_index, species_index=species_index))) + src_out = [TkSourceType.get(_type.value), level.value, pat.value] + return src_out + + def MSXgetpatternlen(self, pat): + """Get the number of time periods within a SOURCE time pattern. + + Parameters + ---------- + pat : int + Pattern index + + Returns + ------- + int + Number of time periods in the pattern + """ + len = ctypes.c_int() + ierr = self.ENlib.MSXgetpatternlen(pat, ctypes.byref(len)) + if ierr != 0: + raise EpanetMsxException(ierr) + return len.value + + def MSXgetpatternvalue(self, pat, period): + """Get the multiplier at a specific time period for a given + SOURCE time pattern + + Parameters + ---------- + pat : int + Pattern index + period : int + 1-indexed period of the pattern to retrieve + + Returns + ------- + Multiplier + """ + val = ctypes.c_double() + ierr = self.ENlib.MSXgetpatternvalue(pat, period, ctypes.byref(val)) + if ierr != 0: + raise EpanetMsxException(ierr) + return val.value + + def MSXgetcount(self, _type): + """Get the number of objects of a specified type. + + Parameters + ---------- + _type : int or str or Enum + Type of object to count + + Returns + ------- + int + Number of objects of specified type + + Raises + ------ + MSXKeyError + If the _type is invalid + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + type_ind = int(_type) + count = ctypes.c_int() + ierr = self.ENlib.MSXgetcount(type_ind, ctypes.byref(count)) + if ierr != 0: + raise EpanetMsxException(ierr) + return count.value + + def MSXgetspecies(self, species_index): + """Get the attributes of a chemical species given its internal + index number. + + Parameters + ---------- + species_index : int + Species index to query (starting from 1 as listed in the MSX input + file) + + Returns + ------- + int, str, float, float + Type, units, aTol, and rTol for the species + """ + type_ind = ctypes.c_int() + units = ctypes.create_string_buffer(15) + aTol = ctypes.c_double() + rTol = ctypes.c_double() + ierr = self.ENlib.MSXgetspecies(species_index, ctypes.byref(type_ind), ctypes.byref(units), ctypes.byref(aTol), ctypes.byref(rTol)) + if ierr != 0: + raise EpanetMsxException(ierr) + spe_out = [type_ind.value, units.value, aTol.value, rTol.value] + return spe_out + + def MSXgeterror(self, errcode, len=100): + """Get the text for an error message given its error code + + Parameters + ---------- + errcode : int + Error code + len : int, optional + Length of the error message, by default 100 and minimum 80 + + Returns + ------- + str + String decoded from the DLL + + Warning + ------- + Getting string parameters in this way is not recommended, because it + requires setting up string arrays that may or may not be the correct + size. Use the wntr.epanet.msx.enums package to get error information. + """ + errmsg = ctypes.create_string_buffer(len) + self.ENlib.MSXgeterror(errcode, ctypes.byref(errmsg), len) + return errmsg.value.decode() + + # --------------set parameters----------------------------------- + + def MSXsetconstant(self, ind, value): + """Set a new value to a specific reaction constant + + Parameters + ---------- + ind : int + Index to the variable + value : float + Value to give the constant + """ + ierr = self.ENlib.MSXsetconstant(ctypes.c_int(ind), ctypes.c_double(value)) + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXsetparameter(self, _type, ind, param, value): + """Set a value to a particular reaction parameter for a given TANK + or PIPE + + Parameters + ---------- + _type : int or str or enum + Type of value to set + ind : int + Tank or pipe index + param : int + Parameter variable index + value : float + Value to be set + + Raises + ------ + MSXKeyError + If there is no such _type + MSXValueError + If the _type is invalid + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + if _type not in [TkObjectType.NODE, TkObjectType.LINK]: + raise MSXValueError(515, repr(_type)) + type_ind = int(_type) + ierr = self.ENlib.MSXsetparameter(ctypes.c_int(type_ind), ctypes.c_int(ind), ctypes.c_int(param), ctypes.c_double(value)) + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXsetinitqual(self, _type, ind, spe, value): + """Set the initial concentration of a particular chemical species + assigned to a specific node or link of the pipe network. + + Parameters + ---------- + _type : int or str or enum + Type of network element to set + ind : int + Index of the network element + spe : int + Index of the species + value : float + Initial quality value + """ + try: + _type = TkObjectType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + if _type not in [TkObjectType.NODE, TkObjectType.LINK]: + raise MSXValueError(515, repr(_type)) + type_ind = int(_type) + ierr = self.ENlib.MSXsetinitqual(ctypes.c_int(type_ind), ctypes.c_int(ind), ctypes.c_int(spe), ctypes.c_double(value)) + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXsetsource(self, node, spe, _type, level, pat): + """Set the attributes of an external source of a particular chemical + species in a specific node of the pipe network + + Parameters + ---------- + node : int + Node index + spe : int + Species index + _type : int or str or enum + Type of source + level : float + Source quality value + pat : int + Pattern index + """ + try: + _type = TkSourceType.get(_type) + except KeyError: + raise MSXKeyError(515, repr(_type)) + type_ind = int(_type) + ierr = self.ENlib.MSXsetsource(ctypes.c_int(node), ctypes.c_int(spe), ctypes.c_int(type_ind), ctypes.c_double(level), ctypes.c_int(pat)) + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXsetpattern(self, pat, mult): + """Set multipliers to a given MSX SOURCE time pattern + + Parameters + ---------- + pat : int + Pattern index + mult : list-like + Pattern multipliers + """ + length = len(mult) + cfactors_type = ctypes.c_double * length + cfactors = cfactors_type() + for i in range(length): + cfactors[i] = float(mult[i]) + ierr = self.ENlib.MSXsetpattern(ctypes.c_int(pat), cfactors, ctypes.c_int(length)) + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXsetpatternvalue(self, pat, period, value): + """Set the multiplier factor for a specific period within a SOURCE time + pattern. + + Parameters + ---------- + pat : int + Pattern index + period : int + 1-indexed pattern time period index + value : float + Value to set at that time period + """ + ierr = self.ENlib.MSXsetpatternvalue(ctypes.c_int(pat), ctypes.c_int(period), ctypes.c_double(value)) + if ierr != 0: + raise EpanetMsxException(ierr) + + def MSXaddpattern(self, patternid): + """Add a new, empty MSX source time pattern to an MSX project. + + Parameters + ---------- + patternid : str + Name of the new pattern + """ + ierr = self.ENlib.MSXaddpattern(ctypes.c_char_p(patternid.encode())) + if ierr != 0: + raise EpanetMsxException(ierr) diff --git a/wntr/epanet/toolkit.py b/wntr/epanet/toolkit.py index 95cb56834..41cf9ba22 100644 --- a/wntr/epanet/toolkit.py +++ b/wntr/epanet/toolkit.py @@ -20,11 +20,14 @@ epanet_toolkit = "wntr.epanet.toolkit" if os.name in ["nt", "dos"]: - libepanet = resource_filename(__name__, "Windows/epanet2.dll") + libepanet = "libepanet/windows-x64/epanet22.dll" elif sys.platform in ["darwin"]: - libepanet = resource_filename(__name__, "Darwin/libepanet.dylib") + if 'arm' in platform.platform().lower(): + libepanet = "libepanet/darwin-arm/libepanet2.dylib" + else: + libepanet = "libepanet/darwin-x64/libepanet22.dylib" else: - libepanet = resource_filename(__name__, "Linux/libepanet2.so") + libepanet = "libepanet/linux-x64/libepanet22.so" def ENgetwarning(code, sec=-1): @@ -101,37 +104,25 @@ def __init__(self, inpfile="", rptfile="", binfile="", version=2.2): self.rptfile = rptfile self.binfile = binfile - if float(version) == 2.0: - libnames = ["epanet2_x86", "epanet2", "epanet"] - if "64" in platform.machine(): - libnames.insert(0, "epanet2_amd64") - elif float(version) == 2.2: - libnames = ["epanet22", "epanet22_win32"] - if "64" in platform.machine(): - libnames.insert(0, "epanet22_amd64") - for lib in libnames: - try: - if os.name in ["nt", "dos"]: - libepanet = resource_filename(epanet_toolkit, "Windows/%s.dll" % lib) - self.ENlib = ctypes.windll.LoadLibrary(libepanet) - elif sys.platform in ["darwin"]: - libepanet = resource_filename(epanet_toolkit, "Darwin/lib%s.dylib" % lib) - self.ENlib = ctypes.cdll.LoadLibrary(libepanet) - else: - libepanet = resource_filename(epanet_toolkit, "Linux/lib%s.so" % lib) - self.ENlib = ctypes.cdll.LoadLibrary(libepanet) - return - except Exception as E1: - if lib == libnames[-1]: - raise E1 - pass - finally: - if version >= 2.2 and "32" not in lib: - self._project = ctypes.c_uint64() - elif version >= 2.2: - self._project = ctypes.c_uint32() - else: - self._project = None + try: + if float(version) == 2.0: + libname = libepanet.replace('epanet22.','epanet20.') + if 'arm' in platform.platform(): + raise NotImplementedError('ARM-based processors not supported for version 2.0 of EPANET. Please use version=2.2') + else: + libname = libepanet + libname = resource_filename(__name__, libname) + if os.name in ["nt", "dos"]: + self.ENlib = ctypes.windll.LoadLibrary(libname) + else: + self.ENlib = ctypes.cdll.LoadLibrary(libname) + except: + raise + finally: + if version >= 2.2: + self._project = ctypes.c_uint64() + else: + self._project = None return def isOpen(self): diff --git a/wntr/epanet/util.py b/wntr/epanet/util.py index c09e51832..f44b289bc 100644 --- a/wntr/epanet/util.py +++ b/wntr/epanet/util.py @@ -1,9 +1,13 @@ """ The wntr.epanet.util module contains unit conversion utilities based on EPANET units. """ +from __future__ import annotations + +import dataclasses import enum import logging - +from dataclasses import dataclass, field +from typing import List, Optional, Union, TypedDict import numpy as np import pandas as pd @@ -33,8 +37,8 @@ class SizeLimits(enum.Enum): """ - Limits on the size of character arrays used to store ID names - and text messages. + Limits on the size of character arrays used to store ID names + and text messages. """ # // ! < Max. # characters in ID name EN_MAX_ID = 31 @@ -44,9 +48,9 @@ class SizeLimits(enum.Enum): class InitHydOption(enum.Enum): """ - Hydraulic initialization options. - These options are used to initialize a new hydraulic analysis - when EN_initH is called. + Hydraulic initialization options. + These options are used to initialize a new hydraulic analysis + when EN_initH is called. """ # !< Don't save hydraulics; don't re-initialize flows EN_NOSAVE = 0 @@ -1384,3 +1388,39 @@ def from_si( return param._from_si(to_units, data, mass_units, reaction_order) else: raise RuntimeError("Invalid parameter: %s" % param) + + +@dataclass +class ENcomment: + """A class for storing EPANET configuration file comments with objects. + + Attributes + ---------- + pre : list of str + a list of comments to put before the output of a configuration line + post : str + a single comment that is attached to the end of the line + """ + pre: List[str] = field(default_factory=list) + post: str = None + + def wrap_msx_string(self, string) -> str: + if self.pre is None or len(self.pre) == 0: + if self.post is None: + return ' ' + string + else: + return ' ' + string + ' ; ' + self.post + elif self.post is None: + return '\n; ' + '\n\n; '.join(self.pre) + '\n\n ' + string + else: + return '\n; ' + '\n\n; '.join(self.pre) + '\n\n ' + string + ' ; ' + self.post + + def to_dict(self): + return dataclasses.asdict(self) + +NoteType = Union[str, dict, ENcomment] +"""An object that stores EPANET compatible annotation data. + +A note (or comment) can be a string, a dictionary of the form :code:`{'pre': List[str], 'post': str}`, +or an :class:`wntr.epanet.util.ENcomment` object. +""" diff --git a/wntr/graphics/network.py b/wntr/graphics/network.py index 1b270bba4..7b49704fd 100644 --- a/wntr/graphics/network.py +++ b/wntr/graphics/network.py @@ -3,10 +3,14 @@ water network model. """ import logging +import math import networkx as nx import pandas as pd import matplotlib.pyplot as plt +import matplotlib.path as mpath from matplotlib import animation +import matplotlib as mpl +import numpy as np try: import plotly @@ -21,6 +25,24 @@ logger = logging.getLogger(__name__) + +arrow_verts = [ + (0.0, 0.0), + (0.5, 0.5), + (0.5, -0.5), + (0.0, 0.0), +] + +arrow_marker = mpath.Path(arrow_verts) + +def _get_angle(line, loc=0.5): + # calculate orientation angle + p1 = line.interpolate(loc-0.01, normalized=True) + p2 = line.interpolate(loc+0.01, normalized=True) + angle = math.atan2(p2.y-p1.y, p2.x - p1.x) # radians + angle = math.degrees(angle) + return angle + def _format_node_attribute(node_attribute, wn): if isinstance(node_attribute, str): @@ -42,12 +64,13 @@ def _format_link_attribute(link_attribute, wn): link_attribute = dict(link_attribute) return link_attribute - -def plot_network(wn, node_attribute=None, link_attribute=None, title=None, - node_size=20, node_range=[None,None], node_alpha=1, node_cmap=None, node_labels=False, - link_width=1, link_range=[None,None], link_alpha=1, link_cmap=None, link_labels=False, - add_colorbar=True, node_colorbar_label='Node', link_colorbar_label='Link', - directed=False, ax=None, show_plot=True, filename=None): + +def plot_network( + wn, node_attribute=None, link_attribute=None, title=None, + node_size=20, node_range=None, node_alpha=1, node_cmap=None, node_labels=False, + link_width=1, link_range=None, link_alpha=1, link_cmap=None, link_labels=False, + add_colorbar=True, node_colorbar_label=None, link_colorbar_label=None, + directed=False, legend=False, ax=None, show_plot=True, filename=None): """ Plot network graphic @@ -126,7 +149,7 @@ def plot_network(wn, node_attribute=None, link_attribute=None, title=None, ax: matplotlib axes object, optional Axes for plotting (None indicates that a new figure with a single axes will be used) - + show_plot: bool, optional If True, show plot with plt.show() @@ -137,113 +160,182 @@ def plot_network(wn, node_attribute=None, link_attribute=None, title=None, ------- ax : matplotlib axes object """ - if ax is None: # create a new figure plt.figure(facecolor='w', edgecolor='k') ax = plt.gca() - # Graph - G = wn.to_graph() - if not directed: - G = G.to_undirected() - - # Position - pos = nx.get_node_attributes(G,'pos') - if len(pos) == 0: - pos = None - - # Define node properties - add_node_colorbar = add_colorbar + if title is not None: + ax.set_title(title) + + aspect = "equal" + + tank_marker = "D" + reservoir_marker = "s" + + if link_cmap is None: + link_cmap = plt.get_cmap('Spectral_r') + if node_cmap is None: + node_cmap = plt.get_cmap('Spectral_r') + + if link_range is None: + link_range = (None, None) + if node_range is None: + node_range = (None, None) + + # use attribute name if no other label is provided + if node_colorbar_label is None and isinstance(node_attribute, str): + node_colorbar_label = node_attribute + if link_colorbar_label is None and isinstance(link_attribute, str): + link_colorbar_label = link_attribute + + wn_gis = wn.to_gis() + # add node_type so that node assets can be plotted separately + wn_gis.junctions["node_type"] = "Junction" + wn_gis.tanks["node_type"] = "Tank" + wn_gis.reservoirs["node_type"] = "Reservoir" + link_gdf = pd.concat((wn_gis.pipes, wn_gis.pumps, wn_gis.valves)) + node_gdf = pd.concat((wn_gis.junctions, wn_gis.tanks, wn_gis.reservoirs)) + + # Node attribute + node_kwds = {} + node_cbar = add_colorbar if node_attribute is not None: + node_gdf["_attribute"] = _format_node_attribute(node_attribute, wn) + node_kwds["column"] = "_attribute" + # handle cbar/cmap if isinstance(node_attribute, list): - if node_cmap is None: - node_cmap = ['red', 'red'] - add_node_colorbar = False - - if node_cmap is None: - node_cmap = plt.get_cmap('Spectral_r') - elif isinstance(node_cmap, list): - if len(node_cmap) == 1: - node_cmap = node_cmap*2 - node_cmap = custom_colormap(len(node_cmap), node_cmap) - - node_attribute = _format_node_attribute(node_attribute, wn) - nodelist,nodecolor = zip(*node_attribute.items()) - + node_kwds["cmap"] = custom_colormap(2,["red", "red"]) + node_cbar = False + elif isinstance(node_attribute, (dict, pd.Series, str)): + node_kwds["cmap"] = node_cmap + + # manually extract min/max if no range is given + node_attribute_values = node_gdf[node_kwds["column"]] + if node_range[0] is None: + node_kwds["vmin"] = np.nanmin(node_attribute_values) + else: + node_kwds["vmin"] = node_range[0] + if node_range[1] is None: + node_kwds["vmax"] = np.nanmax(node_attribute_values) + else: + node_kwds["vmax"] = node_range[1] + else: + raise TypeError("attribute must be dict, Series, list, or str") else: - nodelist = None - nodecolor = 'k' + node_kwds["color"] = "black" + node_cbar = False + + node_kwds["alpha"] = node_alpha + node_kwds["markersize"] = node_size - add_link_colorbar = add_colorbar + node_cbar_kwds = {} + node_cbar_kwds["shrink"] = 0.5 + node_cbar_kwds["pad"] = 0.0 + node_cbar_kwds["alpha"] = node_alpha + node_cbar_kwds["label"] = node_colorbar_label + + # Link attribute + link_kwds = {} + link_cbar = add_colorbar if link_attribute is not None: + link_gdf["_attribute"] = pd.Series(_format_link_attribute(link_attribute, wn)) + link_kwds["column"] = "_attribute" + # handle cbar/cmap if isinstance(link_attribute, list): - if link_cmap is None: - link_cmap = ['red', 'red'] - add_link_colorbar = False - - if link_cmap is None: - link_cmap = plt.get_cmap('Spectral_r') - elif isinstance(link_cmap, list): - if len(link_cmap) == 1: - link_cmap = link_cmap*2 - link_cmap = custom_colormap(len(link_cmap), link_cmap) + link_kwds["cmap"] = custom_colormap(2,["red", "red"]) + link_cbar = False + elif isinstance(link_attribute, (dict, pd.Series, str)): + link_kwds["cmap"] = link_cmap - link_attribute = _format_link_attribute(link_attribute, wn) - - # Replace link_attribute dictionary defined as - # {link_name: attr} with {(start_node, end_node, link_name): attr} - attr = {} - for link_name, value in link_attribute.items(): - link = wn.get_link(link_name) - attr[(link.start_node_name, link.end_node_name, link_name)] = value - link_attribute = attr - - linklist,linkcolor = zip(*link_attribute.items()) + # manually extract min/max if no range is given + link_attribute_values = link_gdf[link_kwds["column"]] + if link_range[0] is None: + link_kwds["vmin"] = np.nanmin(link_attribute_values) + else: + link_kwds["vmin"] = link_range[0] + if link_range[1] is None: + link_kwds["vmax"] = np.nanmax(link_attribute_values) + else: + link_kwds["vmax"] = link_range[1] + else: + raise TypeError("attribute must be dict, Series, list, or str") else: - linklist = None - linkcolor = 'k' + link_kwds["color"] = "black" + link_cbar = False - if title is not None: - ax.set_title(title) - - edge_background = nx.draw_networkx_edges(G, pos, edge_color='grey', - width=0.5, ax=ax) + link_kwds["linewidth"] = link_width + link_kwds["alpha"] = link_alpha + + background_link_kwds = {} + background_link_kwds["color"] = "grey" + background_link_kwds["linewidth"] = link_width / 2 + background_link_kwds["alpha"] = link_alpha + + link_cbar_kwds = {} + link_cbar_kwds["shrink"] = 0.5 + link_cbar_kwds["pad"] = 0.05 + link_cbar_kwds["label"] = link_colorbar_label + link_cbar_kwds["alpha"] = link_alpha + + missing_node_kwds={"color": "black"} + missing_link_kwds={"color": "black"} + + # plot nodes - each type is plotted separately to allow for different marker types + node_gdf[node_gdf.node_type == "Junction"].plot( + ax=ax, aspect=aspect, zorder=3, legend=False, label="Junction", missing_kwds=missing_node_kwds, **node_kwds) + + node_kwds["markersize"] = node_size * 2.0 + node_gdf[node_gdf.node_type == "Tank"].plot( + ax=ax, aspect=aspect, zorder=4, marker=tank_marker, legend=False, label="Tank", missing_kwds=missing_node_kwds, **node_kwds) - nodes = nx.draw_networkx_nodes(G, pos, - nodelist=nodelist, node_color=nodecolor, node_size=node_size, - alpha=node_alpha, cmap=node_cmap, vmin=node_range[0], vmax = node_range[1], - linewidths=0, ax=ax) - edges = nx.draw_networkx_edges(G, pos, edgelist=linklist, arrows=directed, - edge_color=linkcolor, width=link_width, alpha=link_alpha, edge_cmap=link_cmap, - edge_vmin=link_range[0], edge_vmax=link_range[1], ax=ax) + node_kwds["markersize"] = node_size * 3.0 + node_gdf[node_gdf.node_type == "Reservoir"].plot( + ax=ax, aspect=aspect, zorder=5, marker=reservoir_marker, legend=False, label="Reservoir", missing_kwds=missing_node_kwds,**node_kwds) + + if node_cbar: + sm = mpl.cm.ScalarMappable(cmap=node_kwds["cmap"]) + sm.set_clim(node_kwds["vmin"], node_kwds["vmax"]) + + node_cbar = ax.figure.colorbar(sm, ax=ax, **node_cbar_kwds) + + # plot links + # background + link_gdf.plot( + ax=ax, aspect=aspect, zorder=1, legend=False, **background_link_kwds) + + # main plot + link_gdf.plot( + ax=ax, aspect=aspect, zorder=2, legend=False, missing_kwds=missing_link_kwds, **link_kwds) + + if link_cbar: + sm = mpl.cm.ScalarMappable(cmap=link_kwds["cmap"]) + sm.set_clim(link_kwds["vmin"], link_kwds["vmax"]) + + link_cbar = ax.figure.colorbar(sm, ax=ax, **link_cbar_kwds) + if node_labels: - labels = dict(zip(wn.node_name_list, wn.node_name_list)) - nx.draw_networkx_labels(G, pos, labels, font_size=7, ax=ax) + for x, y, label in zip(node_gdf.geometry.x, node_gdf.geometry.y, node_gdf.index): + ax.annotate(label, xy=(x, y))#, xytext=(3, 3),)# textcoords="offset points") + if link_labels: - labels = {} - for link_name in wn.link_name_list: - link = wn.get_link(link_name) - labels[(link.start_node_name, link.end_node_name)] = link_name - nx.draw_networkx_edge_labels(G, pos, labels, font_size=7, ax=ax) - if add_node_colorbar and node_attribute: - clb = plt.colorbar(nodes, shrink=0.5, pad=0, ax=ax) - clb.ax.set_title(node_colorbar_label, fontsize=10) - if add_link_colorbar and link_attribute: - if link_range[0] is None: - vmin = min(link_attribute.values()) - else: - vmin = link_range[0] - if link_range[1] is None: - vmax = max(link_attribute.values()) - else: - vmax = link_range[1] - sm = plt.cm.ScalarMappable(cmap=link_cmap, norm=plt.Normalize(vmin=vmin, vmax=vmax)) - sm.set_array([]) - clb = plt.colorbar(sm, shrink=0.5, pad=0.05, ax=ax) - clb.ax.set_title(link_colorbar_label, fontsize=10) - + midpoints = link_gdf.geometry.apply(lambda x: x.interpolate(0.5, normalized=True)) + for x, y, label in zip(midpoints.geometry.x, midpoints.geometry.y, link_gdf.index): + ax.annotate(label, xy=(x, y))#, xytext=(3, 3),)# textcoords="offset points") + + if directed: + link_gdf["_midpoint"] = link_gdf.geometry.interpolate(0.5, normalized=True) + link_gdf["_angle"] = link_gdf.apply(lambda row: _get_angle(row.geometry), axis=1) + for idx , row in link_gdf.iterrows(): + x,y = row["_midpoint"].x, row["_midpoint"].y + angle = row["_angle"] + ax.scatter(x,y, color="black", s=50, marker=(3,0, angle-90)) + + if legend: + handles, labels = ax.get_legend_handles_labels() + leg = ax.legend(handles, labels, loc='upper right', title="Legend") + ax.axis('off') if filename: diff --git a/wntr/library/msx/__init__.py b/wntr/library/msx/__init__.py new file mode 100644 index 000000000..403cb8c23 --- /dev/null +++ b/wntr/library/msx/__init__.py @@ -0,0 +1 @@ +from ._msxlibrary import MsxLibrary diff --git a/wntr/library/msx/_msxlibrary.py b/wntr/library/msx/_msxlibrary.py new file mode 100644 index 000000000..117be0c82 --- /dev/null +++ b/wntr/library/msx/_msxlibrary.py @@ -0,0 +1,386 @@ +# coding: utf-8 +""" +The wntr.msx.library module includes a library of multi-species water +models + +.. rubric:: Environment Variable + +.. envvar:: WNTR_LIBRARY_PATH + + This environment variable, if set, will add additional folder(s) to the + path to search for multi-species water quality model files, + (files with an ".msx", ".yaml", or ".json" file extension). + Multiple folders should be separated using the "``;``" character. + See :class:`~wntr.msx.library.ReactionLibrary` for more details. +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any, ItemsView, Iterator, KeysView, List, Tuple, Union, ValuesView + +from pkg_resources import resource_filename + +from wntr.msx.base import ExpressionType, ReactionType, SpeciesType +from wntr.msx.model import MsxModel + +try: + import yaml + + yaml_err = None +except ImportError as e: + yaml = None + yaml_err = e + +PIPE = ReactionType.PIPE +TANK = ReactionType.TANK +BULK = SpeciesType.BULK +WALL = SpeciesType.WALL +RATE = ExpressionType.RATE +EQUIL = ExpressionType.EQUIL +FORMULA = ExpressionType.FORMULA + +logger = logging.getLogger(__name__) + + +class MsxLibrary: + """Library of multi-species water quality models + + This object can be accessed and treated like a dictionary, where keys are + the model names and the values are the model objects. + + Paths are added/processed in the following order: + + 1. the builtin directory of reactions, + 2. any paths specified in the environment variable described below, with + directories listed first having the highest priority, + 3. any extra paths specified in the constructor, searched in the order + provided. + + Once created, the library paths cannot be modified. However, a model can + be added to the library using :meth:`add_model_from_file` or + :meth:`add_models_from_dir`. + + """ + + def __init__(self, extra_paths: List[str] = None, include_builtins=True, + include_envvar_paths=True, load=True) -> None: + """Library of multi-species water quality models + + Parameters + ---------- + extra_paths : list of str, optional + User-specified list of reaction library directories, by default + None + include_builtins : bool, optional + Load files built-in with WNTR, by default True + include_envvar_paths : bool, optional + Load files from the paths specified in + :envvar:`WNTR_LIBRARY_PATH`, by default True + load : bool or str, optional + Load the files immediately on creation, by default True. + If a string, then it will be passed as the `duplicates` argument + to the load function. See :meth:`reset_and_reload` for details. + + Raises + ------ + TypeError + If `extra_paths` is not a list + """ + if extra_paths is None: + extra_paths = list() + elif not isinstance(extra_paths, (list, tuple)): + raise TypeError("Expected a list or tuple, got {}".format(type(extra_paths))) + + self.__library_paths = list() + + self.__data = dict() + + if include_builtins: + default_path = os.path.abspath(resource_filename(__name__, '.')) + if default_path not in self.__library_paths: + self.__library_paths.append(default_path) + + if include_envvar_paths: + environ_path = os.environ.get("WNTR_LIBRARY_PATH", None) + if environ_path: + lib_folders = environ_path.split(";") + for folder in lib_folders: + if folder not in self.__library_paths: + self.__library_paths.append(os.path.abspath(folder)) + + for folder in extra_paths: + self.__library_paths.append(os.path.abspath(folder)) + if load: + if isinstance(load, str): + self.reset_and_reload(duplicates=load) + else: + self.reset_and_reload() + + def __repr__(self) -> str: + if len(self.__library_paths) > 3: + return "{}(initial_paths=[{}, ..., {}])".format(self.__class__.__name__, repr(self.__library_paths[0]), repr(self.__library_paths[-1])) + return "{}({})".format(self.__class__.__name__, repr(self.__library_paths)) + + def path_list(self) -> List[str]: + """List of paths used to populate the library + + Returns + ------- + list of str + Copy of the paths used to **initially** populate the library + """ + return self.__library_paths.copy() + + def reset_and_reload(self, duplicates: str = "error") -> List[Tuple[str, str, Any]]: + """Load data from the configured directories into a library of models + + Note, this function is not recursive and does not 'walk' any of the + library's directories to look for subfolders. + + The ``duplicates`` argument specifies how models that have the same name, + or that have the same filename if a name isn't specified in the file, + are handled. **Warning**, if two files in the same directory have models + with the same name, there is no guarantee which will be read in first. + + The first directory processed is the builtin library data. Next, any + paths specified in the environment variable are searched in the order listed + in the variable. Finally, any directories specified by the user in the + constructor are processed in the order listed. + + Parameters + ---------- + duplicates : {"error" | "skip" | "replace"}, optional + by default ``"error"`` + + - A value of of ``"error"`` raises an exception and stops execution. + - A value of ``"skip"`` will skip models with the same `name` as a model that already + exists in the library. + - A value of ``"replace"`` will replace any existing model with a model that is read + in that has the same `name`. + + Returns + ------- + (filename, reason, obj) : (str, str, Any) + the file not read in, the cause of the problem, and the object that was skipped/overwritten + or the exception raised + + Raises + ------ + TypeError + If `duplicates` is not a string + ValueError + If `duplicates` is not a valid value + IOError + If `path_to_folder` is not a directory + KeyError + If `duplicates` is ``"error"`` and two models have the same name + """ + if duplicates and not isinstance(duplicates, str): + raise TypeError("The `duplicates` argument must be None or a string") + elif duplicates.lower() not in ["error", "skip", "replace"]: + raise ValueError('The `duplicates` argument must be None, "error", "skip", or "replace"') + + load_errors = list() + for folder in self.__library_paths: + errs = self.add_models_from_dir(folder, duplicates=duplicates) + load_errors.extend(errs) + return load_errors + + def add_models_from_dir(self, path_to_dir: str, duplicates: str = "error") -> List[Tuple[str, str, Union[MsxModel, Exception]]]: + """Load all valid model files in a folder + + Note, this function is not recursive and does not 'walk' a directory + tree. + + Parameters + ---------- + path_to_dir : str + Path to the folder to search + duplicates : {"error", "skip", "replace"}, optional + Specifies how models that have the same name, or that have the same + filename if a name isn't specified in the file, are handled. + **Warning**, if two files in the same directory have models with + the same name, there is no guarantee which will be read in first, + by default ``"error"`` + - A value of of ``"error"`` raises an exception and stops execution + - A value of ``"skip"`` will skip models with the same `name` as a + model that already exists in the library. + - A value of ``"replace"`` will replace any existing model with a + model that is read in that has the same `name`. + + Returns + ------- + (filename, reason, obj) : tuple[str, str, Any] + File not read in, the cause of the problem, and the object that was + skipped/overwritten or the exception raised + + Raises + ------ + TypeError + If `duplicates` is not a string + ValueError + If `duplicates` is not a valid value + IOError + If `path_to_folder` is not a directory + KeyError + If `duplicates` is ``"error"`` and two models have the same name + """ + if duplicates and not isinstance(duplicates, str): + raise TypeError("The `duplicates` argument must be None or a string") + elif duplicates.lower() not in ["error", "skip", "replace"]: + raise ValueError('The `duplicates` argument must be None, "error", "skip", or "replace"') + if not os.path.isdir(path_to_dir): + raise IOError("The following path is not valid/not a folder, {}".format(path_to_dir)) + load_errors = list() + folder = path_to_dir + files = os.listdir(folder) + for file in files: + ext = os.path.splitext(file)[1] + if ext is None or ext.lower() not in [".msx", ".json", ".yaml"]: + continue + if ext.lower() == ".msx": + try: + new = MsxModel(file) + except Exception as e: + logger.exception("Error reading file {}".format(os.path.join(folder, file))) + load_errors.append((os.path.join(folder, file), "load-failed", e)) + continue + elif ext.lower() == ".json": + with open(os.path.join(folder, file), "r") as fin: + try: + new = MsxModel.from_dict(json.load(fin)) + except Exception as e: + logger.exception("Error reading file {}".format(os.path.join(folder, file))) + load_errors.append((os.path.join(folder, file), "load-failed", e)) + continue + elif ext.lower() == ".yaml": + if yaml is None: + logger.exception("Error reading file {}".format(os.path.join(folder, file)), exc_info=yaml_err) + load_errors.append((os.path.join(folder, file), "load-failed", yaml_err)) + continue + with open(os.path.join(folder, file), "r") as fin: + try: + new = MsxModel.from_dict(yaml.safe_load(fin)) + except Exception as e: + logger.exception("Error reading file {}".format(os.path.join(folder, file))) + load_errors.append((os.path.join(folder, file), "load-failed", e)) + continue + else: # pragma: no cover + raise RuntimeError("This should be impossible to reach, since `ext` is checked above") + new._orig_file = os.path.join(folder, file) + if not new.name: + new.name = os.path.splitext(os.path.split(file)[1])[0] + if new.name not in self.__data: + self.__data[new.name] = new + else: # this name exists in the library + name = new.name + if not duplicates or duplicates.lower() == "error": + raise KeyError('A model named "{}" already exists in the model; failed processing "{}"'.format(new.name, os.path.join(folder, file))) + elif duplicates.lower() == "skip": + load_errors.append((new._orig_file, "skipped", new)) + continue + elif duplicates.lower() == "replace": + old = self.__data[name] + load_errors.append((old._orig_file, "replaced", old)) + self.__data[name] = new + else: # pragma: no cover + raise RuntimeError("This should be impossible to get to, since `duplicates` is checked above") + return load_errors + + def add_model_from_file(self, path_and_filename: str, name: str = None): + """Load a model from a file and add it to the library + + Note, this **does not check** to see if a model exists with the same + name, and it will automatically overwrite the existing model if one + does exist. + + Parameters + ---------- + path_to_file : str + The full path and filename where the model is described. + name : str, optional + The name to use for the model instead of the name provided in the + file or the filename, by default None + """ + if not os.path.isfile(path_and_filename): + raise IOError("The following path does not identify a file, {}".format(path_and_filename)) + + ext = os.path.splitext(path_and_filename)[1] + if ext is None or ext.lower() not in [".msx", ".json", ".yaml"]: + raise IOError("The file is in an unknown format, {}".format(ext)) + if ext.lower() == ".msx": + new = MsxModel(path_and_filename) + elif ext.lower() == ".json": + with open(path_and_filename, "r") as fin: + new = MsxModel.from_dict(json.load(fin)) + elif ext.lower() == ".yaml": + if yaml is None: + raise RuntimeError("Unable to import yaml") from yaml_err + with open(path_and_filename, "r") as fin: + new = MsxModel.from_dict(yaml.safe_load(fin)) + else: # pragma: no cover + raise RuntimeError("This should be impossible to reach, since ext is checked above") + new._orig_file = path_and_filename + if not new.name: + new.name = os.path.splitext(os.path.split(path_and_filename)[1])[0] + if name is not None: + new.name = name + self.__data[new.name] = new + + def get_model(self, name: str) -> MsxModel: + """Get a model from the library by model name + + Parameters + ---------- + name : str + Name of the model + + Returns + ------- + MsxModel + Model object + """ + return self.__data[name] + + def model_name_list(self) -> List[str]: + """Get the names of all models in the library + + Returns + ------- + list of str + list of model names + """ + return list(self.keys()) + + def __getitem__(self, __key: Any) -> Any: + return self.__data.__getitem__(__key) + + def __setitem__(self, __key: Any, __value: Any) -> None: + return self.__data.__setitem__(__key, __value) + + def __delitem__(self, __key: Any) -> None: + return self.__data.__delitem__(__key) + + def __contains__(self, __key: object) -> bool: + return self.__data.__contains__(__key) + + def __iter__(self) -> Iterator: + return self.__data.__iter__() + + def __len__(self) -> int: + return self.__data.__len__() + + def keys(self) -> KeysView: + return self.__data.keys() + + def items(self) -> ItemsView: + return self.__data.items() + + def values(self) -> ValuesView: + return self.__data.values() + + def clear(self) -> None: + return self.__data.clear() diff --git a/wntr/library/msx/arsenic_chloramine.json b/wntr/library/msx/arsenic_chloramine.json new file mode 100644 index 000000000..8661afaa3 --- /dev/null +++ b/wntr/library/msx/arsenic_chloramine.json @@ -0,0 +1,203 @@ +{ + "wntr-version": "", + "name": "arsenic_chloramine", + "title": "Arsenic Oxidation/Adsorption Example", + "description": "This example models monochloramine oxidation of arsenite/arsenate and wall adsoption/desorption, as given in section 3 of the EPANET-MSX user manual", + "references": [ + "Shang, F., Rossman, L. A., and Uber, J.G. (2023). EPANET-MSX 2.0 User Manual. U.S. Environmental Protection Agency, Cincinnati, OH. EPA/600/R-22/199." + ], + "reaction_system": { + "species": [ + { + "name": "AS3", + "species_type": "bulk", + "units": "UG", + "atol": null, + "rtol": null, + "note": "Dissolved arsenite" + }, + { + "name": "AS5", + "species_type": "bulk", + "units": "UG", + "atol": null, + "rtol": null, + "note": "Dissolved arsenate" + }, + { + "name": "AStot", + "species_type": "bulk", + "units": "UG", + "atol": null, + "rtol": null, + "note": "Total dissolved arsenic" + }, + { + "name": "AS5s", + "species_type": "wall", + "units": "UG", + "atol": null, + "rtol": null, + "note": "Adsorbed arsenate" + }, + { + "name": "NH2CL", + "species_type": "bulk", + "units": "MG", + "atol": null, + "rtol": null, + "note": "Monochloramine" + } + ], + "constants": [ + { + "name": "Ka", + "value": 10.0, + "units": "1 / (MG * HR)", + "note": "Arsenite oxidation rate coefficient" + }, + { + "name": "Kb", + "value": 0.1, + "units": "1 / HR", + "note": "Monochloramine decay rate coefficient" + }, + { + "name": "K1", + "value": 5.0, + "units": "M^3 / (UG * HR)", + "note": "Arsenate adsorption coefficient" + }, + { + "name": "K2", + "value": 1.0, + "units": "1 / HR", + "note": "Arsenate desorption coefficient" + }, + { + "name": "Smax", + "value": 50.0, + "units": "UG / M^2", + "note": "Arsenate adsorption limit" + } + ], + "parameters": [], + "terms": [ + { + "name": "Ks", + "expression": "K1/K2", + "note": "Equil. adsorption coeff." + } + ], + "pipe_reactions": [ + { + "species_name": "AS3", + "expression_type": "rate", + "expression": "-Ka*AS3*NH2CL", + "note": "Arsenite oxidation" + }, + { + "species_name": "AS5", + "expression_type": "rate", + "expression": "Ka*AS3*NH2CL - Av*(K1*(Smax-AS5s)*AS5 - K2*AS5s)", + "note": "Arsenate production less adsorption" + }, + { + "species_name": "NH2CL", + "expression_type": "rate", + "expression": "-Kb*NH2CL", + "note": "Monochloramine decay" + }, + { + "species_name": "AS5s", + "expression_type": "equil", + "expression": "Ks*Smax*AS5/(1+Ks*AS5) - AS5s", + "note": "Arsenate adsorption" + }, + { + "species_name": "AStot", + "expression_type": "formula", + "expression": "AS3 + AS5", + "note": "Total arsenic" + } + ], + "tank_reactions": [ + { + "species_name": "AS3", + "expression_type": "rate", + "expression": "-Ka*AS3*NH2CL", + "note": "Arsenite oxidation" + }, + { + "species_name": "AS5", + "expression_type": "rate", + "expression": "Ka*AS3*NH2CL", + "note": "Arsenate production" + }, + { + "species_name": "NH2CL", + "expression_type": "rate", + "expression": "-Kb*NH2CL", + "note": "Monochloramine decay" + }, + { + "species_name": "AStot", + "expression_type": "formula", + "expression": "AS3 + AS5", + "note": "Total arsenic" + } + ] + }, + "network_data": { + "initial_quality": { + "AS3": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "AS5": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "AStot": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "AS5s": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "NH2CL": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + } + }, + "parameter_values": {}, + "sources": {}, + "patterns": {} + }, + "options": { + "timestep": 360, + "area_units": "M2", + "rate_units": "HR", + "solver": "RK5", + "coupling": "NONE", + "rtol": 0.001, + "atol": 0.0001, + "compiler": "NONE", + "segments": 5000, + "peclet": 1000, + "report": { + "pagesize": null, + "report_filename": null, + "species": {}, + "species_precision": {}, + "nodes": null, + "links": null + } + } +} \ No newline at end of file diff --git a/wntr/library/msx/batch_chloramine_decay.json b/wntr/library/msx/batch_chloramine_decay.json new file mode 100644 index 000000000..6563c38e0 --- /dev/null +++ b/wntr/library/msx/batch_chloramine_decay.json @@ -0,0 +1,415 @@ +{ + "wntr-version": "", + "name": "batch_chloramine_decay", + "title": "Batch chloramine decay example", + "description": null, + "references": [], + "reaction_system": { + "species": [ + { + "name": "HOCL", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "hypochlorous acid" + }, + { + "name": "NH3", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "ammonia" + }, + { + "name": "NH2CL", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "monochloramine" + }, + { + "name": "NHCL2", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "dichloramine" + }, + { + "name": "I", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "unknown intermediate" + }, + { + "name": "OCL", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "hypochlorite ion" + }, + { + "name": "NH4", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "ammonium ion" + }, + { + "name": "ALK", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "total alkalinity" + }, + { + "name": "H", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "hydrogen ion" + }, + { + "name": "OH", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "hydroxide ion" + }, + { + "name": "CO3", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "carbonate ion" + }, + { + "name": "HCO3", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "bicarbonate ion" + }, + { + "name": "H2CO3", + "species_type": "bulk", + "units": "mol", + "atol": null, + "rtol": null, + "note": "dissolved carbon dioxide" + }, + { + "name": "chloramine", + "species_type": "bulk", + "units": "mmol", + "atol": null, + "rtol": null, + "note": "monochloramine in mmol/L" + } + ], + "constants": [], + "parameters": [ + { + "name": "k1", + "global_value": 15000000000.0 + }, + { + "name": "k2", + "global_value": 0.076 + }, + { + "name": "k3", + "global_value": 1000000.0 + }, + { + "name": "k4", + "global_value": 0.0023 + }, + { + "name": "k6", + "global_value": 220000000.0 + }, + { + "name": "k7", + "global_value": 400000.0 + }, + { + "name": "k8", + "global_value": 100000000.0 + }, + { + "name": "k9", + "global_value": 30000000.0 + }, + { + "name": "k10", + "global_value": 55.0 + } + ], + "terms": [ + { + "name": "k5", + "expression": "(2.5e7*H) + (4.0e4*H2CO3) + (800*HCO3)" + }, + { + "name": "a1", + "expression": "k1 * HOCL * NH3" + }, + { + "name": "a2", + "expression": "k2 * NH2CL" + }, + { + "name": "a3", + "expression": "k3 * HOCL * NH2CL" + }, + { + "name": "a4", + "expression": "k4 * NHCL2" + }, + { + "name": "a5", + "expression": "k5 * NH2CL * NH2CL" + }, + { + "name": "a6", + "expression": "k6 * NHCL2 * NH3 * H" + }, + { + "name": "a7", + "expression": "k7 * NHCL2 * OH" + }, + { + "name": "a8", + "expression": "k8 * I * NHCL2" + }, + { + "name": "a9", + "expression": "k9 * I * NH2CL" + }, + { + "name": "a10", + "expression": "k10 * NH2CL * NHCL2" + } + ], + "pipe_reactions": [ + { + "species_name": "HOCL", + "expression_type": "rate", + "expression": "-a1 + a2 - a3 + a4 + a8" + }, + { + "species_name": "NH3", + "expression_type": "rate", + "expression": "-a1 + a2 + a5 - a6" + }, + { + "species_name": "NH2CL", + "expression_type": "rate", + "expression": "a1 - a2 - a3 + a4 - a5 + a6 - a9 - a10" + }, + { + "species_name": "NHCL2", + "expression_type": "rate", + "expression": "a3 - a4 + a5 - a6 - a7 - a8 - a10" + }, + { + "species_name": "I", + "expression_type": "rate", + "expression": "a7 - a8 - a9" + }, + { + "species_name": "H", + "expression_type": "rate", + "expression": "0" + }, + { + "species_name": "ALK", + "expression_type": "rate", + "expression": "0" + }, + { + "species_name": "OCL", + "expression_type": "equil", + "expression": "H * OCL - 3.16E-8 * HOCL" + }, + { + "species_name": "NH4", + "expression_type": "equil", + "expression": "H * NH3 - 5.01e-10 * NH4" + }, + { + "species_name": "CO3", + "expression_type": "equil", + "expression": "H * CO3 - 5.01e-11 * HCO3" + }, + { + "species_name": "H2CO3", + "expression_type": "equil", + "expression": "H * HCO3 - 5.01e-7 * H2CO3" + }, + { + "species_name": "HCO3", + "expression_type": "equil", + "expression": "ALK - HC03 - 2*CO3 - OH + H" + }, + { + "species_name": "OH", + "expression_type": "equil", + "expression": "H * OH - 1.0e-14" + }, + { + "species_name": "chloramine", + "expression_type": "formula", + "expression": "1000 * NH2CL" + } + ], + "tank_reactions": [] + }, + "network_data": { + "initial_quality": { + "HOCL": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "NH3": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "NH2CL": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "NHCL2": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "I": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "OCL": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "NH4": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "ALK": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "H": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "OH": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "CO3": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "HCO3": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "H2CO3": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "chloramine": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + } + }, + "parameter_values": { + "k1": { + "pipe_values": {}, + "tank_values": {} + }, + "k2": { + "pipe_values": {}, + "tank_values": {} + }, + "k3": { + "pipe_values": {}, + "tank_values": {} + }, + "k4": { + "pipe_values": {}, + "tank_values": {} + }, + "k6": { + "pipe_values": {}, + "tank_values": {} + }, + "k7": { + "pipe_values": {}, + "tank_values": {} + }, + "k8": { + "pipe_values": {}, + "tank_values": {} + }, + "k9": { + "pipe_values": {}, + "tank_values": {} + }, + "k10": { + "pipe_values": {}, + "tank_values": {} + } + }, + "sources": {}, + "patterns": {} + }, + "options": { + "timestep": 360, + "area_units": "ft2", + "rate_units": "hr", + "solver": "RK5", + "coupling": "NONE", + "rtol": 0.0001, + "atol": 0.0001, + "compiler": "NONE", + "segments": 5000, + "peclet": 1000, + "report": { + "pagesize": null, + "report_filename": null, + "species": {}, + "species_precision": {}, + "nodes": null, + "links": null + } + } +} \ No newline at end of file diff --git a/wntr/library/msx/lead_ppm.json b/wntr/library/msx/lead_ppm.json new file mode 100644 index 000000000..7d483200b --- /dev/null +++ b/wntr/library/msx/lead_ppm.json @@ -0,0 +1,98 @@ +{ + "wntr-version": "", + "name": "lead_ppm", + "title": "Lead Plumbosolvency Model (from Burkhardt et al 2020)", + "description": "Parameters for EPA HPS Simulator Model", + "references": [ + "J. B. Burkhardt, et al. (2020) \"Framework for Modeling Lead in Premise Plumbing Systems Using EPANET\". J Water Resour Plan Manag. 146(12). https://doi.org/10.1061/(asce)wr.1943-5452.0001304 PMID:33627937" + ], + "reaction_system": { + "species": [ + { + "name": "PB2", + "species_type": "bulk", + "units": "ug", + "atol": null, + "rtol": null, + "note": "dissolved lead (Pb)" + } + ], + "constants": [ + { + "name": "M", + "value": 0.117, + "units": "ug * m^(-2) * s^(-1)", + "note": "Desorption rate (ug/m^2/s)" + }, + { + "name": "E", + "value": 140.0, + "units": "ug/L", + "note": "saturation/plumbosolvency level (ug/L)" + } + ], + "parameters": [ + { + "name": "F", + "global_value": 0.0, + "note": "determines which pipes have reactions" + } + ], + "terms": [], + "pipe_reactions": [ + { + "species_name": "PB2", + "expression_type": "rate", + "expression": "F * Av * M * (E - PB2) / E" + } + ], + "tank_reactions": [ + { + "species_name": "PB2", + "expression_type": "rate", + "expression": "0" + } + ] + }, + "network_data": { + "initial_quality": { + "PB2": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + } + }, + "parameter_values": { + "F": { + "pipe_values": {}, + "tank_values": {} + } + }, + "sources": {}, + "patterns": {} + }, + "options": { + "timestep": 1, + "area_units": "M2", + "rate_units": "SEC", + "solver": "RK5", + "coupling": "NONE", + "rtol": 1e-08, + "atol": 1e-08, + "compiler": "NONE", + "segments": 5000, + "peclet": 1000, + "report": { + "pagesize": null, + "report_filename": null, + "species": { + "PB2": "YES" + }, + "species_precision": { + "PB2": 5 + }, + "nodes": "all", + "links": "all" + } + } +} \ No newline at end of file diff --git a/wntr/library/msx/nicotine.json b/wntr/library/msx/nicotine.json new file mode 100644 index 000000000..999f7b888 --- /dev/null +++ b/wntr/library/msx/nicotine.json @@ -0,0 +1,108 @@ +{ + "wntr-version": "", + "name": "nicotine", + "title": "Nicotine - Chlorine reaction", + "description": null, + "references": [], + "reaction_system": { + "species": [ + { + "name": "Nx", + "species_type": "bulk", + "units": "mg", + "atol": null, + "rtol": null, + "note": "Nicotine" + }, + { + "name": "HOCL", + "species_type": "bulk", + "units": "mg", + "atol": null, + "rtol": null, + "note": "Free chlorine" + } + ], + "constants": [ + { + "name": "kd", + "value": 0.00233, + "units": "min^(-1)", + "note": "decay rate" + }, + { + "name": "K1", + "value": 0.0592, + "units": "L * min^(-1) * mg^(-1)", + "note": "decay constant for chlorine as function of mass(Nic)" + }, + { + "name": "K2", + "value": 0.184, + "units": "L * min^(-1) * mg^(-1)", + "note": "decay constant for nicotine as function of mass(Cl)" + } + ], + "parameters": [], + "terms": [ + { + "name": "RxCl", + "expression": "kd * HOCL + K1 * Nx * HOCL" + }, + { + "name": "RxN", + "expression": "K2 * Nx * HOCL" + } + ], + "pipe_reactions": [ + { + "species_name": "Nx", + "expression_type": "rate", + "expression": "-RxN" + }, + { + "species_name": "HOCL", + "expression_type": "rate", + "expression": "-RxCl" + } + ], + "tank_reactions": [] + }, + "network_data": { + "initial_quality": { + "Nx": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "HOCL": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + } + }, + "parameter_values": {}, + "sources": {}, + "patterns": {} + }, + "options": { + "timestep": 1, + "area_units": "m2", + "rate_units": "min", + "solver": "RK5", + "coupling": "NONE", + "rtol": 0.0001, + "atol": 0.0001, + "compiler": "NONE", + "segments": 5000, + "peclet": 1000, + "report": { + "pagesize": null, + "report_filename": null, + "species": {}, + "species_precision": {}, + "nodes": null, + "links": null + } + } +} \ No newline at end of file diff --git a/wntr/library/msx/nicotine_ri.json b/wntr/library/msx/nicotine_ri.json new file mode 100644 index 000000000..6554f0469 --- /dev/null +++ b/wntr/library/msx/nicotine_ri.json @@ -0,0 +1,158 @@ +{ + "wntr-version": "", + "name": "nicotine_ri", + "title": "Nicotine - Chlorine reaction with reactive intermediate", + "description": null, + "references": [], + "reaction_system": { + "species": [ + { + "name": "Nx", + "species_type": "bulk", + "units": "mg", + "atol": null, + "rtol": null, + "note": "Nicotine" + }, + { + "name": "HOCL", + "species_type": "bulk", + "units": "mg", + "atol": null, + "rtol": null, + "note": "Free Chlorine" + }, + { + "name": "NX2", + "species_type": "bulk", + "units": "mg", + "atol": null, + "rtol": null, + "note": "Intermediate Nicotine Reactive" + } + ], + "constants": [ + { + "name": "kd", + "value": 3e-05, + "units": "1/min", + "note": "decay constant for chlorine over time" + }, + { + "name": "K1", + "value": 0.0975, + "units": "L * min^(-1) * mg^(-1)", + "note": "decay constant for chlorine as function of mass(Nic)" + }, + { + "name": "K2", + "value": 0.573, + "units": "L * min^(-1) * mg^(-1)", + "note": "decay constant for nicotine as function of mass(Cl)" + }, + { + "name": "K3", + "value": 0.0134, + "units": "L * min^(-1) * mg^(-1)", + "note": "decay constant for nicotine as function of mass(Nic2)" + }, + { + "name": "K4", + "value": 0.0219, + "units": "L * min^(-1) * mg^(-1)", + "note": "decay constant for nicotine as function of mass(Cl)" + } + ], + "parameters": [], + "terms": [ + { + "name": "RXCL", + "expression": "kd * HOCL + K1 * Nx * HOCL + K3 * NX2 * HOCL" + }, + { + "name": "RXN", + "expression": "K2 * Nx * HOCL" + }, + { + "name": "RXNX2", + "expression": "K2 * Nx * HOCL - K4 * NX2 * HOCL" + } + ], + "pipe_reactions": [ + { + "species_name": "Nx", + "expression_type": "rate", + "expression": "-RXN" + }, + { + "species_name": "HOCL", + "expression_type": "rate", + "expression": "-RXCL" + }, + { + "species_name": "NX2", + "expression_type": "rate", + "expression": "RXNX2" + } + ], + "tank_reactions": [ + { + "species_name": "Nx", + "expression_type": "rate", + "expression": "-RXN" + }, + { + "species_name": "HOCL", + "expression_type": "rate", + "expression": "-RXCL" + }, + { + "species_name": "NX2", + "expression_type": "rate", + "expression": "RXNX2" + } + ] + }, + "network_data": { + "initial_quality": { + "Nx": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "HOCL": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + }, + "NX2": { + "global_value": 0.0, + "node_values": {}, + "link_values": {} + } + }, + "parameter_values": {}, + "sources": {}, + "patterns": {} + }, + "options": { + "timestep": 1, + "area_units": "m2", + "rate_units": "min", + "solver": "RK5", + "coupling": "NONE", + "rtol": 1e-10, + "atol": 1e-10, + "compiler": "NONE", + "segments": 5000, + "peclet": 1000, + "report": { + "pagesize": null, + "report_filename": null, + "species": {}, + "species_precision": {}, + "nodes": null, + "links": null + } + } +} \ No newline at end of file diff --git a/wntr/msx/__init__.py b/wntr/msx/__init__.py new file mode 100644 index 000000000..3daaa7e4b --- /dev/null +++ b/wntr/msx/__init__.py @@ -0,0 +1,12 @@ +# coding: utf-8 +""" +The wntr.msx package contains methods to define EPANET Multi-species Extension +(MSX) water quality models. +""" + +from .base import VariableType, SpeciesType, ReactionType, ExpressionType +from .elements import Species, Constant, Parameter, Term, Reaction, HydraulicVariable, MathFunction +from .model import MsxModel +from .options import MsxSolverOptions + +from . import base, elements, model, options, io diff --git a/wntr/msx/base.py b/wntr/msx/base.py new file mode 100644 index 000000000..7fbf0065f --- /dev/null +++ b/wntr/msx/base.py @@ -0,0 +1,739 @@ +# 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, +) + +def cot(x): + return 1 / tan(x) + +def arccot(x): + return 1 / arctan(1 / x) + +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"] + + +@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) + + +@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) + + +@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) + + +@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) + + +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__ + """ + + 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()]) + ")" + + @abstractmethod + def to_dict(self) -> dict: + """Represent the object as a dictionary""" + raise NotImplementedError + + +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__ + """ + + 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 + + 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()]) + ")" + + +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__ + """ + + 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""" + + @abstractmethod + def add_variable(self, obj: VariableBase) -> None: + """Add a variable to the system""" + raise NotImplementedError + + @abstractmethod + def add_reaction(self, obj: ReactionBase) -> None: + """Add a reaction to the system""" + raise NotImplementedError + + @abstractmethod + def variables(self) -> Generator[Any, Any, Any]: + """Generator looping through all variables""" + raise NotImplementedError + + @abstractmethod + def reactions(self) -> Generator[Any, Any, Any]: + """Generator looping through all reactions""" + raise NotImplementedError + + @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__() + + +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 + + @abstractmethod + def to_dict(self) -> dict: + """Represent the object as a dictionary""" + raise NotImplementedError + + +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. + """ + + @abstractmethod + def to_dict(self) -> dict: + """Represent the object as a dictionary""" + raise NotImplementedError + + +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 + """ + + 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 + + @abstractmethod + def to_dict(self) -> dict: + """Represent the object as a dictionary""" + raise NotImplementedError + + @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 diff --git a/wntr/msx/elements.py b/wntr/msx/elements.py new file mode 100644 index 000000000..a031211ed --- /dev/null +++ b/wntr/msx/elements.py @@ -0,0 +1,671 @@ +# coding: utf-8 +""" +The wntr.msx.elements module includes concrete implementations of the +multi-species water quality model elements, including species, constants, +parameters, terms, and reactions. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Tuple, Union + +from wntr.epanet.util import ENcomment, NoteType +from wntr.utils.disjoint_mapping import KeyExistsError + +from .base import ( + ExpressionType, + ReactionType, + SpeciesType, + VariableType, + VariableBase, + VariableValuesBase, + ReactionBase, + ReactionSystemBase, +) + +logger = logging.getLogger(__name__) + + +class Species(VariableBase): + """Biological or chemical species. + + Parameters + ---------- + name + Species name + species_type + Species type + units + Units of mass for this species, see :attr:`units` property. + atol : float | None + Absolute tolerance when solving this species' equations, by default + None + rtol : float | None + Relative tolerance when solving this species' equations, by default + None + note + Supplementary information regarding this variable, by default None + (see :class:`~wntr.epanet.util.NoteType`) + diffusivity + Diffusivity of the species in water, by default None + _vars + Reaction system this species is a part of, by default None + _vals + Initial quality values for this species, by default None + + Raises + ------ + KeyExistsError + If the name has already been used + TypeError + If `atol` and `rtol` are not the same type + ValueError + If `atol` or `rtol` ≤ 0 + + Notes + ----- + EPANET-MSX requires that `atol` and `rtol` either both be omitted, or + both be provided. In order to enforce this, the arguments passed for + `atol` and `rtol` must both be None or both be positive values. + """ + + def __init__( + self, + name: str, + species_type: Union[SpeciesType, str], + units: str, + atol: float = None, + rtol: float = None, + *, + note: NoteType = None, + diffusivity: float = None, + _vars: ReactionSystemBase = None, + _vals: VariableValuesBase = None, + ) -> None: + super().__init__(name, note=note) + if _vars is not None and not isinstance(_vars, ReactionSystemBase): + raise TypeError("Invalid type for _vars, {}".format(type(_vars))) + if _vals is not None and not isinstance(_vals, InitialQuality): + raise TypeError("Invalid type for _vals, {}".format(type(_vals))) + if _vars is not None and name in _vars: + raise KeyExistsError("This variable name is already taken") + species_type = SpeciesType.get(species_type) + if species_type is None: + raise TypeError("species_type cannot be None") + self._species_type: SpeciesType = species_type + self._tolerances: Tuple[float, float] = None + self.set_tolerances(atol, rtol) + self.units: str = units + """Units of mass for this species. For bulk species, concentration is + this unit divided by liters, for wall species, concentration is this + unit divided by the model's area-unit (see options). + """ + self.diffusivity: float = diffusivity + """Diffusivity of this species in water, if being used, by default None""" + self._vars: ReactionSystemBase = _vars + self._vals: InitialQuality = _vals + + def set_tolerances(self, atol: float, rtol: float): + """Set the absolute and relative tolerance for the solvers. + + The user must set both values, or neither value (None). Values must be + positive. + + Parameters + ---------- + atol + Absolute tolerance to use + rtol + Relative tolerance to use + + Raises + ------ + TypeError + If only one of `atol` or `rtol` is a float + ValueError + If `atol` or `rtol` ≤ 0 + """ + if (atol is None) ^ (rtol is None): + raise TypeError("atol and rtol must both be float or both be None") + if atol is None: + self._tolerances = None + elif atol <= 0 or rtol <= 0: + raise ValueError("atol and rtol must both be positive, got atol={}, rtol={}".format(atol, rtol)) + else: + self._tolerances = (atol, rtol) + + def get_tolerances(self) -> Union[Tuple[float, float], None]: + """Get the custom solver tolerances for this species. + + Returns + ------- + (atol, rtol) : (float, float) or None + Absolute and relative tolerances, respectively, if they are set + """ + return self._tolerances + + def clear_tolerances(self): + """Set both tolerances to None, reverting to the global options value. + """ + self._tolerances = None + + @property + def atol(self) -> float: + """Absolute tolerance. Must be set using :meth:`set_tolerances`""" + if self._tolerances is not None: + return self._tolerances[0] + return None + + @property + def rtol(self) -> float: + """Relative tolerance. Must be set using :meth:`set_tolerances`""" + if self._tolerances is not None: + return self._tolerances[1] + return None + + @property + def var_type(self) -> VariableType: + """Type of variable, :attr:`~wntr.msx.base.VariableType.SPECIES`.""" + return VariableType.SPECIES + + @property + def species_type(self) -> SpeciesType: + """Type of species, either :attr:`~wntr.msx.base.SpeciesType.BULK` + or :attr:`~wntr.msx.base.SpeciesType.WALL`""" + return self._species_type + + @property + def initial_quality(self) -> 'InitialQuality': + """Initial quality values for the network""" + if self._vals is not None: + return self._vals + else: + raise TypeError("This species is not linked to a NetworkData obejct, please `relink` your model") + + @property + def pipe_reaction(self) -> 'Reaction': + """Pipe reaction definition""" + if self._vars is not None: + return self._vars.pipe_reactions[self.name] + else: + raise AttributeError("This species is not connected to a ReactionSystem") + + @property + def tank_reaction(self) -> 'Reaction': + """Tank reaction definition""" + if self._vars is not None: + return self._vars.tank_reactions[self.name] + else: + raise AttributeError("This species is not connected to a ReactionSystem") + + def to_dict(self) -> Dict[str, Any]: + """Dictionary representation of the object + + The species dictionary has the following format, as described using a json schema. + + .. code:: json + + { + "title": "Species", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "species_type": { + "enum": ["bulk", "wall"] + }, + "units": { + "type": "string" + }, + "atol": { + "type": "number", + "exclusiveMinimum": 0 + }, + "rtol": { + "type": "number", + "exclusiveMinimum": 0 + }, + "note": { + "type": "string" + }, + "diffusivity": { + "type": "number", + "minimum": 0 + } + }, + "required": ["name", "species_type", "units", "pipe_reaction", "tank_reaction"], + "dependentRequired": {"atol": ["rtol"], "rtol":["atol"]} + } + + """ + ret = dict(name=self.name, species_type=self.species_type.name.lower(), units=self.units, atol=self.atol, rtol=self.rtol) + + if self.diffusivity: + ret["diffusivity"] = self.diffusivity + + if isinstance(self.note, ENcomment): + ret["note"] = self.note.to_dict() + elif isinstance(self.note, (str, dict, list)): + ret["note"] = self.note + + return ret + + +class Constant(VariableBase): + """Constant coefficient for use in expressions. + + Parameters + ---------- + name + Name of the variable. + value + Constant value. + units + Units for the variable, by default None + note + Supplementary information regarding this variable, by default None + _vars + Reaction system this constant is a part of, by default None + """ + + def __init__(self, name: str, value: float, *, units: str = None, note: Union[str, dict, ENcomment] = None, _vars: ReactionSystemBase = None) -> None: + super().__init__(name, note=note) + if _vars is not None and not isinstance(_vars, ReactionSystemBase): + raise TypeError("Invalid type for _vars, {}".format(type(_vars))) + if _vars is not None and name in _vars: + raise KeyExistsError("This variable name is already taken") + self.value: float = float(value) + """Value of the constant""" + self.units: str = units + """Units of the constant""" + self._vars: ReactionSystemBase = _vars + + def __call__(self, *, t=None) -> Any: + return self.value + + @property + def var_type(self) -> VariableType: + """Type of variable, :attr:`~wntr.msx.base.VariableType.CONSTANT`.""" + return VariableType.CONSTANT + + def to_dict(self) -> Dict[str, Any]: + """Dictionary representation of the object""" + ret = dict(name=self.name, value=self.value) + if self.units: + ret["units"] = self.units + if isinstance(self.note, ENcomment): + ret["note"] = self.note.to_dict() + elif isinstance(self.note, (str, dict, list)): + ret["note"] = self.note + return ret + + +class Parameter(VariableBase): + """Parameterized variable for use in expressions. + + Parameters + ---------- + name + Name of this parameter. + global_value + Global value for the parameter if otherwise unspecified. + units + Units for this parameter, by default None + note + Supplementary information regarding this variable, by default None + _vars + Reaction system this parameter is a part of, by default None + _vals + Network-specific values for this parameter, by default None + """ + def __init__( + self, name: str, global_value: float, *, units: str = None, note: Union[str, dict, ENcomment] = None, _vars: ReactionSystemBase = None, _vals: VariableValuesBase = None + ) -> None: + super().__init__(name, note=note) + if _vars is not None and not isinstance(_vars, ReactionSystemBase): + raise TypeError("Invalid type for _vars, {}".format(type(_vars))) + if _vals is not None and not isinstance(_vals, ParameterValues): + raise TypeError("Invalid type for _vals, {}".format(type(_vals))) + if _vars is not None and name in _vars: + raise KeyExistsError("This variable name is already taken") + self.global_value: float = float(global_value) + self.units: str = units + self._vars: ReactionSystemBase = _vars + self._vals: ParameterValues = _vals + + def __call__(self, *, pipe: str = None, tank: str = None) -> float: + """Get the value of the parameter for a given pipe or tank. + + If there is no specific, different value for the requested pipe + or tank, then the global value will be returned. *This is true even* + *if the pipe or tank does not exist in the network, so caution is* + *advised*. + + Parameters + ---------- + pipe + Name of a pipe to get the parameter value for, by default None + tank + Name of a pipe to get the parameter value for, by default None + + Returns + ------- + float + Value at the specified pipe or tank, or the global value + + Raises + ------ + TypeError + If both pipe and tank are specified + ValueError + If there is no ParameterValues object defined for and linked to + this parameter + """ + if pipe is not None and tank is not None: + raise TypeError("Both pipe and tank cannot be specified at the same time") + elif self._vals is None and (pipe is not None or tank is not None): + raise ValueError("No link provided to network-specific parameter values") + if pipe: + return self._vals.pipe_values.get(pipe, self.global_value) + elif tank: + return self._vals.tank_values.get(pipe, self.global_value) + return self.global_value + + @property + def var_type(self) -> VariableType: + """Type of variable, :attr:`~wntr.msx.base.VariableType.PARAMETER`.""" + return VariableType.PARAMETER + + def to_dict(self) -> Dict[str, Any]: + """Dictionary representation of the object""" + ret = dict(name=self.name, global_value=self.global_value) + if self.units: + ret["units"] = self.units + if isinstance(self.note, ENcomment): + ret["note"] = self.note.to_dict() + elif isinstance(self.note, (str, dict, list)): + ret["note"] = self.note + return ret + + +class Term(VariableBase): + """Named expression (term) that can be used in expressions + + Parameters + ---------- + name + Variable name. + expression + Mathematical expression to be aliased + note + Supplementary information regarding this variable, by default None + _vars + Reaction system this species is a part of, by default None + """ + def __init__(self, name: str, expression: str, *, + note: Union[str, dict, ENcomment] = None, + _vars: ReactionSystemBase = None) -> None: + super().__init__(name, note=note) + if _vars is not None and not isinstance(_vars, ReactionSystemBase): + raise TypeError("Invalid type for _vars, {}".format(type(_vars))) + if _vars is not None and name in _vars: + raise KeyExistsError("This variable name is already taken") + self.expression: str = expression + """Expression that is aliased by this term""" + self._vars: ReactionSystemBase = _vars + + @property + def var_type(self) -> VariableType: + """Type of variable, :attr:`~wntr.msx.base.VariableType.TERM`.""" + return VariableType.TERM + + def to_dict(self) -> Dict[str, Any]: + """Dictionary representation of the object""" + ret = dict(name=self.name, expression=self.expression) + if isinstance(self.note, ENcomment): + ret["note"] = self.note.to_dict() + elif isinstance(self.note, (str, dict, list)): + ret["note"] = self.note + return ret + + +class ReservedName(VariableBase): + """Reserved name that should not be used + + Parameters + ---------- + name + Reserved name. + note + Supplementary information regarding this variable, by default None + + Raises + ------ + KeyExistsError + If the name has already been registered + """ + + def __init__(self, name: str, *, note: Union[str, dict, ENcomment] = None) -> None: + self.name: str = name + self.note: Union[str, dict, ENcomment] = note + + @property + def var_type(self) -> VariableType: + """Type of variable, :attr:`~wntr.msx.base.VariableType.RESERVED`.""" + return VariableType.RESERVED + + # def to_dict(self) -> Dict[str, Any]: + # """Dictionary representation of the object""" + # return "{}({})".format(self.__class__.__name__, ", ".join(["name={}".format(repr(self.name)), "note={}".format(repr(self.note))])) + + +class HydraulicVariable(ReservedName): + """Reserved name for hydraulic variables + + The user should not need to create any variables using this class, they + are created automatically by the MsxModel object during initialization. + + Parameters + ---------- + name + Name of the variable (predefined by MSX) + units + Units for hydraulic variable, by default None + note + Supplementary information regarding this variable, by default None + """ + + def __init__(self, name: str, units: str = None, *, note: Union[str, dict, ENcomment] = None) -> None: + super().__init__(name, note=note) + self.units: str = units + """Hydraulic variable's units""" + + +class MathFunction(ReservedName): + """Reserved name for math functions + + Parameters + ---------- + name + Function name + func + Callable function + note + Supplementary information regarding this variable, by default None + """ + + def __init__(self, name: str, func: callable, *, note: Union[str, dict, ENcomment] = None) -> None: + super().__init__(name, note=note) + self.func: callable = func + """A callable function or SymPy function""" + + def __call__(self, *args: Any, **kwds: Any) -> Any: + """Evaluate the function using any specified args and kwds.""" + return self.func(*args, **kwds) + + +class Reaction(ReactionBase): + """Water quality reaction dynamics definition for a specific species. + + Parameters + ---------- + species_name + Species (object or name) this reaction is applicable to. + reaction_type + Reaction type (location), from {PIPE, TANK} + expression_type + Expression type (left-hand-side) of the equation, from + {RATE, EQUIL, FORMULA} + expression + Mathematical expression for the right-hand-side of the reaction + equation. + note + Supplementary information regarding this variable, by default None + _vars + Reaction system this species is a part of, by default None + """ + + def __init__( + self, + species_name: str, + reaction_type: ReactionType, + expression_type: ExpressionType, + expression: str, + *, + note: Union[str, dict, ENcomment] = None, + _vars: ReactionSystemBase = None, + ) -> None: + super().__init__(species_name=species_name, note=note) + if _vars is not None and not isinstance(_vars, ReactionSystemBase): + raise TypeError("Invalid type for _vars, {}".format(type(_vars))) + expression_type = ExpressionType.get(expression_type) + reaction_type = ReactionType.get(reaction_type) + if reaction_type is None: + raise TypeError("Required argument reaction_type cannot be None") + if expression_type is None: + raise TypeError("Required argument expression_type cannot be None") + self.__rxn_type: ReactionType = reaction_type + self._expr_type: ExpressionType = expression_type + if not expression: + raise TypeError("expression cannot be None") + self.expression: str = expression + """Mathematical expression (right-hand-side)""" + self._vars: ReactionSystemBase = _vars + + @property + def expression_type(self) -> ExpressionType: + """Expression type (left-hand-side), either + :attr:`~wntr.msx.base.ExpressionType.RATE`, + :attr:`~wntr.msx.base.ExpressionType.EQUIL`, or + :attr:`~wntr.msx.base.ExpressionType.FORMULA`""" + return self._expr_type + + @property + def reaction_type(self) -> ReactionType: + """Reaction type (location), either + :attr:`~wntr.msx.base.ReactionType.PIPE` or + :attr:`~wntr.msx.base.ReactionType.TANK`""" + return self.__rxn_type + + def to_dict(self) -> dict: + """Dictionary representation of the object""" + ret = dict(species_name=str(self.species_name), expression_type=self.expression_type.name.lower(), expression=self.expression) + if isinstance(self.note, ENcomment): + ret["note"] = self.note.to_dict() + elif isinstance(self.note, (str, dict, list)): + ret["note"] = self.note + return ret + + +class InitialQuality(VariableValuesBase): + """Initial quality values for a species in a specific network. + + Parameters + ---------- + global_value + Global initial quality value, by default 0.0 + node_values + Any different initial quality values for specific nodes, + by default None + link_values + Any different initial quality values for specific links, + by default None + """ + + def __init__(self, global_value: float = 0.0, node_values: dict = None, link_values: dict = None): + self.global_value = global_value + """Global initial quality values for this species.""" + self._node_values = node_values if node_values is not None else dict() + self._link_values = link_values if link_values is not None else dict() + + def __repr__(self) -> str: + return self.__class__.__name__ + "(global_value={}, node_values=<{} entries>, link_values=<{} entries>)".format( + self.global_value, len(self._node_values), len(self._link_values) + ) + + @property + def var_type(self) -> VariableType: + """Type of variable, :attr:`~wntr.msx.base.VariableType.SPECIES`, + this object holds data for.""" + return VariableType.SPECIES + + @property + def node_values(self) -> Dict[str, float]: + """Mapping that overrides the global_value of the initial quality at + specific nodes""" + return self._node_values + + @property + def link_values(self) -> Dict[str, float]: + """Mapping that overrides the global_value of the initial quality in + specific links""" + return self._link_values + + def to_dict(self) -> Dict[str, Dict[str, float]]: + """Dictionary representation of the object""" + return dict(global_value=self.global_value, node_values=self._node_values.copy(), link_values=self._link_values.copy()) + + +class ParameterValues(VariableValuesBase): + """Pipe and tank specific values of a parameter for a specific network. + + Parameters + ---------- + pipe_values + Any different values for this parameter for specific pipes, + by default None + tank_values + Any different values for this parameter for specific tanks, + by default None + """ + + def __init__(self, *, pipe_values: dict = None, tank_values: dict = None) -> None: + self._pipe_values = pipe_values if pipe_values is not None else dict() + self._tank_values = tank_values if tank_values is not None else dict() + + def __repr__(self) -> str: + return self.__class__.__name__ + "(pipe_values=<{} entries>, tank_values=<{} entries>)".format(len(self._pipe_values), len(self._tank_values)) + + @property + def var_type(self) -> VariableType: + """Type of variable, :attr:`~wntr.msx.base.VariableType.PARAMETER`, + this object holds data for.""" + return VariableType.PARAMETER + + @property + def pipe_values(self) -> Dict[str, float]: + """Mapping that overrides the global_value of a parameter for a + specific pipe""" + return self._pipe_values + + @property + def tank_values(self) -> Dict[str, float]: + """Mapping that overrides the global_value of a parameter for a + specific tank""" + return self._tank_values + + def to_dict(self) -> Dict[str, Dict[str, float]]: + """Dictionary representation of the object""" + return dict(pipe_values=self._pipe_values.copy(), tank_values=self._tank_values.copy()) diff --git a/wntr/msx/io.py b/wntr/msx/io.py new file mode 100644 index 000000000..9de85b821 --- /dev/null +++ b/wntr/msx/io.py @@ -0,0 +1,123 @@ +""" +The wntr.msx.io module includes functions that convert the MSX reaction +model to other data formats, create an MSX model from a file, and write +the MSX model to a file. +""" +import logging +import json + + +logger = logging.getLogger(__name__) + +def to_dict(msx) -> dict: + """ + Convert a MsxModel into a dictionary + + Parameters + ---------- + msx : MsxModel + The MSX reaction model. + + Returns + ------- + dict + Dictionary representation of the MsxModel + """ + return msx.to_dict() + +def from_dict(d: dict): + """ + Create or append a MsxModel from a dictionary + + Parameters + ---------- + d : dict + Dictionary representation of the water network model. + + Returns + ------- + MsxModel + + """ + from wntr.msx.model import MsxModel + return MsxModel.from_dict(d) + +def write_json(msx, path_or_buf, as_library=False, indent=4, **kw_json): + """ + Write the MSX model to a JSON file + + Parameters + ---------- + msx : MsxModel + The model to output. + path_or_buf : str or IO stream + Name of the file or file pointer. + as_library : bool, optional + Strip out network-specific elements if True, by default False. + kw_json : keyword arguments + Arguments to pass directly to :meth:`json.dump`. + """ + d = to_dict(msx) + if as_library: + d.get('network_data', {}).get('initial_quality',{}).clear() + d.get('network_data', {}).get('parameter_values',{}).clear() + d.get('network_data', {}).get('sources',{}).clear() + d.get('network_data', {}).get('patterns',{}).clear() + d.get('options', {}).get('report',{}).setdefault('nodes', None) + d.get('options', {}).get('report',{}).setdefault('links', None) + if isinstance(path_or_buf, str): + with open(path_or_buf, "w") as fout: + json.dump(d, fout, indent=indent, **kw_json) + else: + json.dump(d, path_or_buf, indent=indent, **kw_json) + +def read_json(path_or_buf, **kw_json): + """ + Create or append a WaterNetworkModel from a JSON file + + Parameters + ---------- + f : str + Name of the file or file pointer. + kw_json : keyword arguments + Keyword arguments to pass to `json.load`. + + Returns + ------- + MsxModel + + """ + if isinstance(path_or_buf, str): + with open(path_or_buf, "r") as fin: + d = json.load(fin, **kw_json) + else: + d = json.load(path_or_buf, **kw_json) + return from_dict(d) + +def write_msxfile(msx, filename): + """ + Write an EPANET-MSX input file (.msx) + + Parameters + ---------- + msx : MsxModel + The model to write + filename : str + The filename to use for output + """ + from wntr.epanet.msx.io import MsxFile + MsxFile.write(filename, msx) + +def read_msxfile(filename, append=None): + """ + Read in an EPANET-MSX input file (.msx) + + Parameters + ---------- + filename : str + The filename to read in. + append : MsxModel + An existing model to add data into, by default None. + """ + from wntr.epanet.msx.io import MsxFile + return MsxFile.read(filename, append) diff --git a/wntr/msx/model.py b/wntr/msx/model.py new file mode 100644 index 000000000..644684fd1 --- /dev/null +++ b/wntr/msx/model.py @@ -0,0 +1,790 @@ +# coding: utf-8 +""" +The wntr.msx.model module includes methods to build a multi-species water +quality model. +""" + +from __future__ import annotations + +import logging +import warnings +from typing import Dict, Generator, List, Union +from wntr.epanet.util import NoteType + +from wntr.utils.disjoint_mapping import KeyExistsError + +from .base import ( + EXPR_FUNCTIONS, + HYDRAULIC_VARIABLES, + QualityModelBase, + ReactionBase, + NetworkDataBase, + ReactionSystemBase, + ExpressionType, + ReactionType, + SpeciesType, + VariableType, +) +from .elements import Constant, HydraulicVariable, InitialQuality, MathFunction, Parameter, ParameterValues, Reaction, Species, Term +from .options import MsxSolverOptions + +logger = logging.getLogger(__name__) + +MsxVariable = Union[Constant, HydraulicVariable, MathFunction, Parameter, Species, Term] +"""A class that is a valid MSX variable class""" + + +class MsxReactionSystem(ReactionSystemBase): + """Registry for all the variables registered in the multi-species reactions + model. + + This object can be used like a mapping. + """ + + def __init__(self) -> None: + super().__init__() + self._vars.add_disjoint_group("reserved") + self._species = self._vars.add_disjoint_group("species") + self._const = self._vars.add_disjoint_group("constant") + self._param = self._vars.add_disjoint_group("parameter") + self._term = self._vars.add_disjoint_group("term") + self._rxns = dict(pipe=dict(), tank=dict()) + self._pipes = self._rxns["pipe"] + self._tanks = self._rxns["tank"] + + @property + def species(self) -> Dict[str, Species]: + """Dictionary view onto only species""" + return self._species + + @property + def constants(self) -> Dict[str, Constant]: + """Dictionary view onto only constants""" + return self._const + + @property + def parameters(self) -> Dict[str, Parameter]: + """Dictionary view onto only parameters""" + return self._param + + @property + def terms(self) -> Dict[str, Term]: + """Dictionary view onto only named terms""" + return self._term + + @property + def pipe_reactions(self) -> Dict[str, Reaction]: + """Dictionary view onto pipe reactions""" + return self._pipes + + @property + def tank_reactions(self) -> Dict[str, Reaction]: + """Dictionary view onto tank reactions""" + return self._tanks + + def add_variable(self, variable: MsxVariable) -> None: + """Add a variable object to the registry. + + The appropriate group is determined by querying the object's + var_type attribute. + + Parameters + ---------- + variable + Variable to add. + + Raises + ------ + TypeError + If `variable` is not an MsxVariable + KeyExistsError + If `variable` has a name that is already used in the registry + """ + if not isinstance(variable, (Species, Constant, Parameter, Term, MathFunction, HydraulicVariable)): + raise TypeError("Expected AVariable object") + if variable.name in self: + raise KeyExistsError("Variable name {} already exists in model".format(variable.name)) + variable._vars = self + self._vars.add_item_to_group(variable.var_type.name.lower(), variable.name, variable) + + def add_reaction(self, reaction: Reaction) -> None: + """Add a reaction to the model + + Parameters + ---------- + reaction : Reaction + Water quality reaction definition + + Raises + ------ + TypeError + If `reaction` is not a Reaction + KeyError + If the `species_name` in the `reaction` does not exist in the model + """ + if not isinstance(reaction, Reaction): + raise TypeError("Expected a Reaction object") + if reaction.species_name not in self: + raise KeyError("Species {} does not exist in the model".format(reaction.species_name)) + self._rxns[reaction.reaction_type.name.lower()][reaction.species_name] = reaction + + def variables(self) -> Generator[tuple, None, None]: + """Generator looping through all variables""" + for k, v in self._vars.items(): + if v.var_type.name.lower() not in ['reserved']: + yield k, v + + def reactions(self) -> Generator[tuple, None, None]: + """Generator looping through all reactions""" + for k2, v in self._rxns.items(): + for k1, v1 in v.items(): + yield k1, v1 + + def to_dict(self) -> dict: + """Dictionary representation of the MsxModel.""" + return dict( + species=[v.to_dict() for v in self._species.values()], + constants=[v.to_dict() for v in self._const.values()], + parameters=[v.to_dict() for v in self._param.values()], + terms=[v.to_dict() for v in self._term.values()], + pipe_reactions=[v.to_dict() for v in self.pipe_reactions.values()], + tank_reactions=[v.to_dict() for v in self.tank_reactions.values()], + ) + + +class MsxNetworkData(NetworkDataBase): + """Network-specific values associated with a multi-species water + quality model + + Data is copied from dictionaries passed in, so once created, the + dictionaries passed are not connected to this object. + + Parameters + ---------- + patterns : dict, optional + Patterns to use for sources + sources : dict, optional + Sources defined for the model + initial_quality : dict, optional + Initial values for different species at different nodes, links, and + the global value + parameter_values : dict, optional + Parameter values for different pipes and tanks + + Notes + ----- + ``patterns`` + Dictionary keyed by pattern name (str) with values being the + multipliers (list of float) + ``sources`` + Dictionary keyed by species name (str) with values being + dictionaries keyed by junction name (str) with values being the + dictionary of settings for the source + ``initial_quality`` + Dictionary keyed by species name (str) with values being either an + :class:`~wntr.msx.elements.InitialQuality` object or the + appropriate dictionary representation thereof. + ``parameter_values`` + Dictionary keyed by parameter name (str) with values being either + a :class:`~wntr.msx.elements.ParameterValues` object or the + appropriate dictionary representation thereof. + """ + + def __init__(self, patterns: Dict[str, List[float]] = None, + sources: Dict[str, Dict[str, dict]] = None, + initial_quality: Dict[str, Union[dict, InitialQuality]] = None, + parameter_values: Dict[str, Union[dict, ParameterValues]] = None) -> None: + if sources is None: + sources = dict() + if initial_quality is None: + initial_quality = dict() + if patterns is None: + patterns = dict() + if parameter_values is None: + parameter_values = dict() + self._source_dict = dict() + self._pattern_dict = dict() + self._initial_quality_dict: Dict[str, InitialQuality] = dict() + self._parameter_value_dict: Dict[str, ParameterValues] = dict() + + self._source_dict = sources.copy() + self._pattern_dict = patterns.copy() + for k, v in initial_quality.items(): + self._initial_quality_dict[k] = InitialQuality(**v) + for k, v in parameter_values.items(): + self._parameter_value_dict[k] = ParameterValues(**v) + + @property + def sources(self): + """Dictionary of sources, keyed by species name""" + return self._source_dict + + @property + def initial_quality(self) -> Dict[str, InitialQuality]: + """Dictionary of initial quality values, keyed by species name""" + return self._initial_quality_dict + + @property + def patterns(self): + """Dictionary of patterns, specific for the water quality model, keyed + by pattern name. + + .. note:: the WaterNetworkModel cannot see these patterns, so names can + be reused, so be careful. Likewise, this model cannot see the + WaterNetworkModel patterns, so this could be a source of some + confusion. + """ + return self._pattern_dict + + @property + def parameter_values(self) -> Dict[str, ParameterValues]: + """Dictionary of parameter values, keyed by parameter name""" + return self._parameter_value_dict + + def add_pattern(self, name: str, multipliers: List[float]): + """Add a water quality model specific pattern. + + Arguments + --------- + name : str + Pattern name + multipliers : list of float + Pattern multipliers + """ + self._pattern_dict[name] = multipliers + + def init_new_species(self, species: Species): + """(Re)set the initial quality values for a species + + Arguments + --------- + species : Species + Species to (re)initialized. + + Returns + ------- + InitialQuality + New initial quality values + """ + self._initial_quality_dict[str(species)] = InitialQuality() + if isinstance(species, Species): + species._vals = self._initial_quality_dict[str(species)] + return self._initial_quality_dict[str(species)] + + def remove_species(self, species: Union[Species, str]): + """Remove a species from the network specific model + + Arguments + --------- + species : Species or str + Species to be removed from the network data + """ + if isinstance(species, Species): + species._vals = None + try: + self._initial_quality_dict.__delitem__(str(species)) + except KeyError: + pass + + def init_new_parameter(self, param: Parameter): + """(Re)initialize parameter values for a parameter + + Arguments + --------- + param : Parameter + Parameter to be (re)initialized with network data + + Returns + ------- + ParameterValues + New network data for the specific parameter + """ + self._parameter_value_dict[str(param)] = ParameterValues() + if isinstance(param, Parameter): + param._vals = self._parameter_value_dict[str(param)] + return self._parameter_value_dict[str(param)] + + def remove_parameter(self, param: Union[Parameter, str]): + """Remove values associated with a specific parameter + + Ignores non-parameters. + + Arguments + --------- + param : Parameter or str + Parameter or parameter name to be removed from the network data + """ + if isinstance(param, Parameter): + param._vals = None + try: + self._parameter_value_dict.__delitem__(str(param)) + except KeyError: + pass + + def to_dict(self) -> dict: + ret = dict(initial_quality=dict(), parameter_values=dict(), sources=dict(), patterns=dict()) + for k, v in self._initial_quality_dict.items(): + ret["initial_quality"][k] = v.to_dict() + for k, v in self._parameter_value_dict.items(): + ret["parameter_values"][k] = v.to_dict() + ret["sources"] = self._source_dict.copy() + ret["patterns"] = self._pattern_dict.copy() + return ret + + +class MsxModel(QualityModelBase): + """Multi-species water quality model + + Arguments + --------- + msx_file_name : str, optional + MSX file to to load into the MsxModel object, by default None + """ + + def __init__(self, msx_file_name=None) -> None: + super().__init__(msx_file_name) + self._references: List[Union[str, Dict[str, str]]] = list() + self._options: MsxSolverOptions = MsxSolverOptions() + self._rxn_system: MsxReactionSystem = MsxReactionSystem() + self._net_data: MsxNetworkData = MsxNetworkData() + self._wn = None + + for v in HYDRAULIC_VARIABLES: + self._rxn_system.add_variable(HydraulicVariable(**v)) + for k, v in EXPR_FUNCTIONS.items(): + self._rxn_system.add_variable(MathFunction(name=k.lower(), func=v)) + self._rxn_system.add_variable(MathFunction(name=k.capitalize(), func=v)) + self._rxn_system.add_variable(MathFunction(name=k.upper(), func=v)) + + if msx_file_name is not None: + from wntr.epanet.msx.io import MsxFile + + MsxFile.read(msx_file_name, self) + + def __repr__(self) -> str: + ret = "{}(".format(self.__class__.__name__) + if self.name: + ret = ret + "name={}".format(repr(self.name)) + elif self.title: + ret = ret + "title={}".format(repr(self.title)) + elif self._orig_file: + ret = ret + "{}".format(repr(self._orig_file)) + ret = ret + ")" + return ret + + @property + def references(self) -> List[Union[str, Dict[str, str]]]: + """List of strings or mappings that provide references for this model + + .. note:: + This property is a list, and should be modified using + append/insert/remove. Members of the list should be json + serializable (i.e., strings or dicts of strings). + """ + return self._references + + @property + def reaction_system(self) -> MsxReactionSystem: + """Reaction variables defined for this model""" + return self._rxn_system + + @property + def network_data(self) -> MsxNetworkData: + """Network-specific values added to this model""" + return self._net_data + + @property + def options(self) -> MsxSolverOptions: + """MSX model options""" + return self._options + + @property + def species_name_list(self) -> List[str]: + """Get a list of species names""" + return list(self.reaction_system.species.keys()) + + @property + def constant_name_list(self) -> List[str]: + """Get a list of coefficient names""" + return list(self.reaction_system.constants.keys()) + + @property + def parameter_name_list(self) -> List[str]: + """Get a list of coefficient names""" + return list(self.reaction_system.parameters.keys()) + + @property + def term_name_list(self) -> List[str]: + """Get a list of function (MSX 'terms') names""" + return list(self.reaction_system.terms.keys()) + + @options.setter + def options(self, value: Union[dict, MsxSolverOptions]): + if isinstance(value, dict): + self._options = MsxSolverOptions.factory(value) + elif not isinstance(value, MsxSolverOptions): + raise TypeError("Expected a MsxSolverOptions object, got {}".format(type(value))) + else: + self._options = value + + def add_species( + self, + name: str, + species_type: SpeciesType, + units: str, + atol: float = None, + rtol: float = None, + note: NoteType = None, + diffusivity: float = None, + ) -> Species: + """Add a species to the model + + Arguments + --------- + name : str + Species name + species_type : SpeciesType + Type of species, either BULK or WALL + units : str + Mass units for this species + atol : float, optional unless rtol is not None + Absolute solver tolerance for this species, by default None + rtol : float, optional unless atol is not None + Relative solver tolerance for this species, by default None + note : NoteType, optional keyword + Supplementary information regarding this variable, by default None + (see also :class:`~wntr.epanet.util.ENcomment`) + diffusivity : float, optional + Diffusivity of this species in water + + Raises + ------ + KeyExistsError + If a variable with this name already exists + ValueError + If `atol` or `rtol` ≤ 0 + + Returns + ------- + Species + New species + """ + if name in self._rxn_system: + raise KeyExistsError("Variable named {} already exists in model as type {}".format(name, self._rxn_system._vars.get_groupname(name))) + species_type = SpeciesType.get(species_type, allow_none=False) + iq = self.network_data.init_new_species(name) + new = Species( + name=name, + species_type=species_type, + units=units, + atol=atol, + rtol=rtol, + note=note, + _vars=self._rxn_system, + _vals=iq, + diffusivity=diffusivity, + ) + self.reaction_system.add_variable(new) + return new + + def remove_species(self, variable_or_name): + """Remove a species from the model + + Removes from both the reaction_system and the network_data. + + Parameters + ---------- + variable_or_name : Species or str + Species (or name of the species) to be removed + + Raises + ------ + KeyError + If `variable_or_name` is not a species in the model + """ + name = str(variable_or_name) + if name not in self.reaction_system.species: + raise KeyError('The specified variable is not a registered species in the reaction system') + self.network_data.remove_species(name) + self.reaction_system.__delitem__(name) + + def add_constant(self, name: str, value: float, units: str = None, note: NoteType = None) -> Constant: + """Add a constant coefficient to the model + + Arguments + --------- + name : str + Name of the coefficient + value : float + Constant value of the coefficient + units : str, optional + Units for this coefficient, by default None + note : NoteType, optional + Supplementary information regarding this variable, by default None + + Raises + ------ + KeyExistsError + Variable with this name already exists + + Returns + ------- + Constant + New constant coefficient + """ + if name in self._rxn_system: + raise KeyExistsError("Variable named {} already exists in model as type {}".format(name, self._rxn_system._vars.get_groupname(name))) + new = Constant(name=name, value=value, units=units, note=note, _vars=self._rxn_system) + self.reaction_system.add_variable(new) + return new + + def remove_constant(self, variable_or_name): + """Remove a constant coefficient from the model + + Parameters + ---------- + variable_or_name : Constant or str + Constant (or name of the constant) to be removed + + Raises + ------ + KeyError + If `variable_or_name` is not a constant coefficient in the model + """ + name = str(variable_or_name) + if name not in self.reaction_system.constants: + raise KeyError('The specified variable is not a registered constant in the reaction system') + self.reaction_system.__delitem__(name) + + def add_parameter(self, name: str, global_value: float, units: str = None, note: NoteType = None) -> Parameter: + """Add a parameterized coefficient to the model + + Arguments + --------- + name : str + Name of the parameter + global_value : float + Global value of the coefficient (can be overridden for specific + pipes/tanks) + units : str, optional + Units for the coefficient, by default None + note : NoteType, optional keyword + Supplementary information regarding this variable, by default None + (see also :class:`~wntr.epanet.util.ENcomment`). + + Raises + ------ + KeyExistsError + If a variable with this name already exists + + Returns + ------- + Parameter + New parameterized coefficient + """ + if name in self._rxn_system: + raise KeyExistsError("Variable named {} already exists in model as type {}".format(name, self._rxn_system._vars.get_groupname(name))) + pv = self.network_data.init_new_parameter(name) + new = Parameter(name=name, global_value=global_value, units=units, note=note, _vars=self._rxn_system, _vals=pv) + self.reaction_system.add_variable(new) + return new + + def remove_parameter(self, variable_or_name): + """Remove a parameterized coefficient from the model + + Parameters + ---------- + variable_or_name : Parameter or str + Parameter (or name of the parameter) to be removed + + Raises + ------ + KeyError + If `variable_or_name` is not a parameter in the model + """ + name = str(variable_or_name) + if name not in self.reaction_system.parameters: + raise KeyError('The specified variable is not a registered parameter in the reaction system') + self.network_data.remove_parameter(name) + self.reaction_system.__delitem__(name) + + def add_term(self, name: str, expression: str, note: NoteType = None) -> Term: + """Add a named expression (term) to the model + + Parameters + ---------- + name : str + Name of the functional term to be added + expression : str + Expression that the term defines + note : NoteType, optional keyword + Supplementary information regarding this variable, by default None + (see also :class:`~wntr.epanet.util.ENcomment`) + + Raises + ------ + KeyExistsError + if a variable with this name already exists + + Returns + ------- + Term + New term + """ + if name in self._rxn_system: + raise KeyError("Variable named {} already exists in model as type {}".format(name, self._rxn_system._vars.get_groupname(name))) + new = Term(name=name, expression=expression, note=note, _vars=self._rxn_system) + self.reaction_system.add_variable(new) + return new + + def remove_term(self, variable_or_name): + """Remove a named expression (term) from the model + + Parameters + ---------- + variable_or_name : Term or str + Term (or name of the term) to be deleted + + Raises + ------ + KeyError + If `variable_or_name` is not a term in the model + """ + name = str(variable_or_name) + if name not in self.reaction_system.terms: + raise KeyError('The specified variable is not a registered term in the reaction system') + self.reaction_system.__delitem__(name) + + def add_reaction(self, species_name: Union[Species, str], reaction_type: ReactionType, expression_type: ExpressionType, expression: str, note: NoteType = None) -> ReactionBase: + """Add a reaction to a species in the model + + Note that all species need to have both a pipe and tank reaction + defined unless all species are bulk species and the tank reactions are + identical to the pipe reactions. However, it is not recommended that + users take this approach. + + Once added, access the reactions from the species' object. + + Arguments + --------- + species_name : Species or str + Species (or name of species) the reaction is being defined for + reaction_type: ReactionType + Reaction type (location), from {PIPE, TANK} + expression_type : ExpressionType + Expression type (left-hand-side) of the equation, from {RATE, + EQUIL, FORMULA} + expression : str + Expression defining the reaction + note : NoteType, optional keyword + Supplementary information regarding this reaction, by default None + (see also :class:`~wntr.epanet.util.ENcomment`) + + Raises + ------ + TypeError + If a variable that is not species is passed + + Returns + ------- + MsxReactionSystem + New reaction object + """ + species_name = str(species_name) + species = self.reaction_system.species[species_name] + if species.var_type is not VariableType.SPECIES: + raise TypeError("Variable {} is not a Species, is a {}".format(species.name, species.var_type)) + reaction_type = ReactionType.get(reaction_type, allow_none=False) + expression_type = ExpressionType.get(expression_type, allow_none=False) + new = Reaction( + reaction_type=reaction_type, + expression_type=expression_type, + species_name=species_name, + expression=expression, + note=note, + ) + self.reaction_system.add_reaction(new) + return new + + def remove_reaction(self, species_name: str, reaction_type: ReactionType) -> None: + """Remove a reaction at a specified location from a species + + Parameters + ---------- + species : Species or str + Species (or name of the species) of the reaction to remove + reaction_type : ReactionType + Reaction type (location) of the reaction to remove + """ + reaction_type = ReactionType.get(reaction_type, allow_none=False) + species_name = str(species_name) + del self.reaction_system._rxns[reaction_type.name.lower()][species_name] + + def to_dict(self) -> dict: + """Dictionary representation of the MsxModel""" + from wntr import __version__ + + return { + "version": "wntr-{}".format(__version__), + "name": self.name, + "title": self.title, + "description": self.description if self.description is None or "\n" not in self.description else self.description.splitlines(), + "references": self.references.copy(), + "reaction_system": self.reaction_system.to_dict(), + "network_data": self.network_data.to_dict(), + "options": self.options.to_dict(), + } + + @classmethod + def from_dict(cls, data) -> "MsxModel": + """Create a new multi-species water quality model from a dictionary + + Parameters + ---------- + data : dict + Model data + """ + from wntr import __version__ + + ver = data.get("version", None) + if ver != 'wntr-{}'.format(__version__): + logger.warn("Importing from a file created by a different version of wntr, compatibility not guaranteed") + # warnings.warn("Importing from a file created by a different version of wntr, compatibility not guaranteed") + new = cls() + new.name = data.get("name", None) + new.title = data.get("title", None) + new.description = data.get("description", None) + if isinstance(new.description, (list, tuple)): + desc = "\n".join(new.description) + new.description = desc + new.references.extend(data.get("references", list())) + + rxn_sys = data.get("reaction_system", dict()) + for var in rxn_sys.get("species", list()): + new.add_species(**var) + for var in rxn_sys.get("constants", list()): + new.add_constant(**var) + for var in rxn_sys.get("parameters", list()): + new.add_parameter(**var) + for var in rxn_sys.get("terms", list()): + new.add_term(**var) + for rxn in rxn_sys.get("pipe_reactions", list()): + rxn["reaction_type"] = "pipe" + new.add_reaction(**rxn) + for rxn in rxn_sys.get("tank_reactions", list()): + rxn["reaction_type"] = "tank" + new.add_reaction(**rxn) + + new._net_data = MsxNetworkData(**data.get("network_data", dict())) + for species in new.reaction_system.species: + if species not in new.network_data.initial_quality: + new.network_data.init_new_species(species) + for param in new.reaction_system.parameters: + if param not in new.network_data.parameter_values: + new.network_data.init_new_parameter(param) + + opts = data.get("options", None) + if opts: + new.options = opts + + return new diff --git a/wntr/msx/options.py b/wntr/msx/options.py new file mode 100644 index 000000000..cb9a01510 --- /dev/null +++ b/wntr/msx/options.py @@ -0,0 +1,179 @@ +# coding: utf-8 +""" +The wntr.msx.options module includes options for multi-species water quality +models +""" + +import logging +from typing import Dict, List, Literal, Union + +from wntr.network.options import _OptionsBase + +logger = logging.getLogger(__name__) + + +class MsxReportOptions(_OptionsBase): + """ + Report options + + Parameters + ---------- + report_filename : str + Filename for the EPANET-MSX report file, by default this will be + the prefix plus ".rpt". + species : dict[str, bool] + Output species concentrations + species_precision : dict[str, float] + Output species concentrations with the specified precision + nodes : None, "ALL", or list + Output node information. If a list of node names is provided, + EPANET-MSX only provides report information for those nodes. + links : None, "ALL", or list + Output link information. If a list of link names is provided, + EPANET-MSX only provides report information for those links. + pagesize : str + Page size for EPANET-MSX report output + + """ + + def __init__( + self, + pagesize: list = None, + report_filename: str = None, + species: Dict[str, bool] = None, + species_precision: Dict[str, float] = None, + nodes: Union[Literal["ALL"], List[str]] = None, + links: Union[Literal["ALL"], List[str]] = None, + ): + self.pagesize = pagesize + """Pagesize for the report""" + self.report_filename = report_filename + """Prefix of the report filename (will add .rpt)""" + self.species = species if species is not None else dict() + """Turn individual species outputs on and off, by default no species are output""" + self.species_precision = species_precision if species_precision is not None else dict() + """Output precision for the concentration of a specific species""" + self.nodes = nodes + """List of nodes to print output for, or 'ALL' for all nodes, by default None""" + self.links = links + """List of links to print output for, or 'ALL' for all links, by default None""" + + def __setattr__(self, name, value): + if name not in ["pagesize", "report_filename", "species", "nodes", "links", "species_precision"]: + raise AttributeError("%s is not a valid attribute of ReportOptions" % name) + self.__dict__[name] = value + + +class MsxSolverOptions(_OptionsBase): + """ + Solver options + + Parameters + ---------- + timestep : int >= 1 + Water quality timestep (seconds), by default 60 (one minute). + area_units : str, optional + Units of area to use in surface concentration forms, by default + ``M2``. Valid values are ``FT2``, ``M2``, or ``CM2``. + rate_units : str, optional + Time units to use in all rate reactions, by default ``MIN``. Valid + values are ``SEC``, ``MIN``, ``HR``, or ``DAY``. + solver : str, optional + Solver to use, by default ``RK5``. Options are ``RK5`` (5th order + Runge-Kutta method), ``ROS2`` (2nd order Rosenbrock method), or + ``EUL`` (Euler method). + coupling : str, optional + Use coupling method for solution, by default ``NONE``. Valid + options are ``FULL`` or ``NONE``. + atol : float, optional + Absolute concentration tolerance, by default 0.01 (regardless of + species concentration units). + rtol : float, optional + Relative concentration tolerance, by default 0.001 (±0.1%). + compiler : str, optional + Whether to use a compiler, by default ``NONE``. Valid options are + ``VC``, ``GC``, or ``NONE`` + segments : int, optional + Maximum number of segments per pipe (MSX 2.0 or newer only), by + default 5000. + peclet : int, optional + Peclet threshold for applying dispersion (MSX 2.0 or newer only), + by default 1000. + report : MsxReportOptions or dict + Options on how to report out results. + + """ + + def __init__( + self, + timestep: int = 360, + area_units: str = "M2", + rate_units: str = "MIN", + solver: str = "RK5", + coupling: str = "NONE", + atol: float = 1.0e-4, + rtol: float = 1.0e-4, + compiler: str = "NONE", + segments: int = 5000, + peclet: int = 1000, + # global_initial_quality: Dict[str, float] = None, + report: MsxReportOptions = None, + ): + self.timestep: int = timestep + """Timestep, in seconds, by default 360""" + self.area_units: str = area_units + """Units used to express pipe wall surface area where, by default FT2. + Valid values are FT2, M2, and CM2.""" + self.rate_units: str = rate_units + """Units in which all reaction rate terms are expressed, by default HR. + Valid values are HR, MIN, SEC, and DAY.""" + self.solver: str = solver + """Solver to use, by default EUL. Valid values are EUL, RK5, and + ROS2.""" + self.coupling: str = coupling + """Whether coupling should occur during solving, by default NONE. Valid + values are NONE and FULL.""" + self.rtol: float = rtol + """Relative tolerance used during solvers ROS2 and RK5, by default + 0.001 for all species. Can be overridden on a per-species basis.""" + self.atol: float = atol + """Absolute tolerance used by the solvers, by default 0.01 for all + species regardless of concentration units. Can be overridden on a + per-species basis.""" + self.compiler: str = compiler + """Compiler to use if the equations should be compiled by EPANET-MSX, + by default NONE. Valid options are VC, GC and NONE.""" + self.segments: int = segments + """Number of segments per-pipe to use, by default 5000.""" + self.peclet: int = peclet + """Threshold for applying dispersion, by default 1000.""" + self.report: MsxReportOptions = MsxReportOptions.factory(report) + """Reporting output options.""" + + def __setattr__(self, name, value): + if name == "report": + if not isinstance(value, (MsxReportOptions, dict, tuple, list)): + raise ValueError("report must be a ReportOptions or convertable object") + value = MsxReportOptions.factory(value) + elif name in {"timestep"}: + try: + value = max(1, int(value)) + except ValueError: + raise ValueError("%s must be an integer >= 1" % name) + elif name in ["atol", "rtol"]: + try: + value = float(value) + except ValueError: + raise ValueError("%s must be a number", name) + elif name in ["segments", "peclet"]: + try: + value = int(value) + except ValueError: + raise ValueError("%s must be a number", name) + elif name not in ["area_units", "rate_units", "solver", "coupling", "compiler"]: + raise ValueError("%s is not a valid member of MsxSolverOptions") + self.__dict__[name] = value + + def to_dict(self): + """Dictionary representation of the options""" + return dict(self) diff --git a/wntr/network/base.py b/wntr/network/base.py index c89defe97..111397720 100644 --- a/wntr/network/base.py +++ b/wntr/network/base.py @@ -234,19 +234,20 @@ def tag(self, tag): @property def initial_quality(self): - """float: The initial quality (concentration) at the node""" + """float or dict: Initial quality (concentration) at the node, or + a dict of species-->quality for multi-species quality""" if not self._initial_quality: return 0.0 return self._initial_quality @initial_quality.setter def initial_quality(self, value): - if value and not isinstance(value, (list, float, int)): + if value and not isinstance(value, (list, float, int, dict)): raise ValueError('Initial quality must be a float or a list') self._initial_quality = value @property def coordinates(self): - """tuple: The node coordinates, (x,y)""" + """tuple: Node coordinates, (x,y)""" return self._coordinates @coordinates.setter def coordinates(self, coordinates): @@ -320,6 +321,7 @@ class Link(six.with_metaclass(abc.ABCMeta, object)): end_node_name initial_status initial_setting + initial_quality tag vertices @@ -361,6 +363,7 @@ def __init__(self, wn, link_name, start_node_name, end_node_name): # Model state variables self._user_status = LinkStatus.Opened self._internal_status = LinkStatus.Active + self._initial_quality = None self._prev_setting = None self._setting = None self._flow = None @@ -488,6 +491,18 @@ def status(self, status): " behavior, use initial_status.") # self._user_status = status + @property + def initial_quality(self): + """float or dict : a dict of species and quality if multispecies is active""" + if not self._initial_quality: + return 0.0 + return self._initial_quality + @initial_quality.setter + def initial_quality(self, value): + if value and not isinstance(value, (list, float, int, dict)): + raise ValueError('Initial quality must be a float or a list') + self._initial_quality = value + @property def quality(self): """float : (read-only) current simulated average link quality""" diff --git a/wntr/network/elements.py b/wntr/network/elements.py index b451992e9..ee730a35b 100644 --- a/wntr/network/elements.py +++ b/wntr/network/elements.py @@ -888,7 +888,8 @@ class Pipe(Link): "minor_loss", "initial_status", "check_valve"] - _optional_attributes = ["bulk_coeff", + _optional_attributes = ["initial_quality", + "bulk_coeff", "wall_coeff", "vertices", "tag"] @@ -1044,6 +1045,7 @@ class Pump(Link): speed_timeseries initial_status initial_setting + initial_quality efficiency energy_price energy_pattern @@ -1074,7 +1076,8 @@ class Pump(Link): "base_speed", "speed_pattern_name", "initial_status"] - _optional_attributes = ["initial_setting", + _optional_attributes = ["initial_quality", + "initial_setting", "efficiency", "energy_pattern", "energy_price", @@ -1256,6 +1259,7 @@ class HeadPump(Pump): speed_timeseries initial_status initial_setting + initial_quality pump_type pump_curve_name efficiency @@ -1476,6 +1480,7 @@ class PowerPump(Pump): speed_timeseries initial_status initial_setting + initial_quality pump_type power efficiency @@ -1566,6 +1571,7 @@ class Valve(Link): valve_type initial_status initial_setting + initial_quality vertices tag @@ -1592,7 +1598,8 @@ class Valve(Link): "minor_loss", "initial_setting", "initial_status"] - _optional_attributes = ["vertices", + _optional_attributes = ["initial_quality", + "vertices", "tag"] def __init__(self, name, start_node_name, end_node_name, wn): @@ -2628,7 +2635,7 @@ class Source(object): """ # def __init__(self, name, node_registry, pattern_registry): - def __init__(self, model, name, node_name, source_type, strength, pattern=None): + def __init__(self, model, name, node_name, source_type, strength, pattern=None, species=None): self._strength_timeseries = TimeSeries(model._pattern_reg, strength, pattern, name) self._pattern_reg = model._pattern_reg self._pattern_reg.add_usage(pattern, (name, 'Source')) @@ -2637,6 +2644,7 @@ def __init__(self, model, name, node_name, source_type, strength, pattern=None): self._name = name self._node_name = node_name self._source_type = source_type + self._species = None def __eq__(self, other): if not type(self) == type(other): @@ -2648,8 +2656,8 @@ def __eq__(self, other): return False def __repr__(self): - fmt = "" - return fmt.format(self.name, self.node_name, self.source_type, self._strength_timeseries.base_value, self._strength_timeseries.pattern_name) + fmt = "" + return fmt.format(self.name, self.node_name, self.source_type, self._strength_timeseries.base_value, self._strength_timeseries.pattern_name, repr(self._species)) @property def strength_timeseries(self): @@ -2680,6 +2688,13 @@ def source_type(self): def source_type(self, value): self._source_type = value + @property + def species(self): + """str : species name for multispecies reactions, by default None""" + @species.setter + def species(self, value): + self._species = str(value) + def to_dict(self): ret = dict() ret['name'] = self.name @@ -2687,4 +2702,6 @@ def to_dict(self): ret['source_type'] = self.source_type ret['strength'] = self.strength_timeseries.base_value ret['pattern'] = self.strength_timeseries.pattern_name + if self.species: + ret['species'] = self.species return ret diff --git a/wntr/network/io.py b/wntr/network/io.py index 8d9878a43..6a63a0a51 100644 --- a/wntr/network/io.py +++ b/wntr/network/io.py @@ -50,6 +50,7 @@ def to_dict(wn) -> dict: version="wntr-{}".format(__version__), comment="WaterNetworkModel - all values given in SI units", name=wn.name, + references=wn._references.copy(), options=wn._options.to_dict(), curves=wn._curve_reg.to_list(), patterns=wn._pattern_reg.to_list(), @@ -84,6 +85,7 @@ def from_dict(d: dict, append=None): "version", "comment", "name", + "references", "options", "curves", "patterns", @@ -101,6 +103,8 @@ def from_dict(d: dict, append=None): wn = append if "name" in d: wn.name = d["name"] + if "references" in d: + wn._references = d["references"] if "options" in d: wn.options.__init__(**d["options"]) if "curves" in d: diff --git a/wntr/network/model.py b/wntr/network/model.py index 26aaeffc1..b828fa456 100644 --- a/wntr/network/model.py +++ b/wntr/network/model.py @@ -4,6 +4,7 @@ """ import logging from collections import OrderedDict +from typing import List, Union from warnings import warn import networkx as nx @@ -59,6 +60,8 @@ def __init__(self, inp_file_name=None): # Network name self.name = None + self._references: List[Union[str, dict]] = list() + """A list of references that document the source of this model.""" self._options = Options() self._node_reg = NodeRegistry(self) @@ -67,6 +70,7 @@ def __init__(self, inp_file_name=None): self._curve_reg = CurveRegistry(self) self._controls = OrderedDict() self._sources = SourceRegistry(self) + self._msx = None self._node_reg._finalize_(self) self._link_reg._finalize_(self) @@ -311,6 +315,30 @@ def gpvs(self): """Iterator over all general purpose valves (GPVs)""" return self._link_reg.gpvs + @property + def msx(self): + """A multispecies water quality model, if defined""" + return self._msx + + @msx.setter + def msx(self, model): + if model is None: + self._msx = None + return + from wntr.msx.base import QualityModelBase + if not isinstance(model, QualityModelBase): + raise TypeError('Expected QualityModelBase (or derived), got {}'.format(type(model))) + self._msx = model + + def add_msx_model(self, msx_filename=None): + """Add an msx model from a MSX input file (.msx extension)""" + from wntr.msx.model import MsxModel + self._msx = MsxModel(msx_file_name=msx_filename) + + def remove_msx_model(self): + """Remove an msx model from the network""" + self._msx = None + """ ### # ### Create blank, unregistered objects (for direct assignment) diff --git a/wntr/sim/epanet.py b/wntr/sim/epanet.py index 2fe6b9f87..f2280b37d 100644 --- a/wntr/sim/epanet.py +++ b/wntr/sim/epanet.py @@ -3,7 +3,7 @@ from wntr.sim.core import WaterNetworkSimulator from wntr.network.io import write_inpfile -import wntr.epanet.io +import wntr.epanet import warnings import logging @@ -105,9 +105,11 @@ def run_sim(self, file_prefix='temp', save_hyd=False, use_hyd=False, hydfile=Non inpfile = file_prefix + '.inp' write_inpfile(self._wn, inpfile, units=self._wn.options.hydraulic.inpfile_units, version=version) enData = wntr.epanet.toolkit.ENepanet(version=version) + self.enData = enData rptfile = file_prefix + '.rpt' outfile = file_prefix + '.bin' - + if self._wn._msx is not None: + save_hyd = True if hydfile is None: hydfile = file_prefix + '.hyd' enData.ENopen(inpfile, rptfile, outfile) @@ -130,5 +132,26 @@ def run_sim(self, file_prefix='temp', save_hyd=False, use_hyd=False, hydfile=Non results = self.reader.read(outfile, convergence_error, self._wn.options.hydraulic.headloss=='D-W') + if self._wn._msx is not None: + # Attributed to Matthew's package + msxfile = file_prefix + '.msx' + rptfile = file_prefix + '.msx-rpt' + binfile = file_prefix + '.msx-bin' + msxfile2 = file_prefix + '.check.msx' + wntr.epanet.msx.io.MsxFile.write(msxfile, self._wn._msx) + msx = wntr.epanet.msx.MSXepanet(inpfile, rptfile, outfile, msxfile) + msx.ENopen(inpfile, rptfile, outfile) + msx.MSXopen(msxfile) + msx.MSXusehydfile(hydfile) + msx.MSXinit() + msx.MSXsolveH() + msx.MSXsolveQ() + msx.MSXreport() + msx.MSXsaveoutfile(binfile) + msx.MSXsavemsxfile(msxfile2) + msx.MSXclose() + msx.ENclose() + results = wntr.epanet.msx.io.MsxBinFile(binfile, self._wn, results) + return results diff --git a/wntr/tests/networks_for_testing/msx_example.inp b/wntr/tests/networks_for_testing/msx_example.inp new file mode 100644 index 000000000..0887e00db --- /dev/null +++ b/wntr/tests/networks_for_testing/msx_example.inp @@ -0,0 +1,36 @@ +[TITLE] +EPANET-MSX Example Network + +[JUNCTIONS] +;ID Elev Demand Pattern + A 0 4.1 + B 0 3.4 + C 0 5.5 + D 0 2.3 + +[RESERVOIRS] +;ID Head Pattern + Source 100 + +[PIPES] +;ID Node1 Node2 Length Diameter Roughness + 1 Source A 1000 200 100 + 2 A B 800 150 100 + 3 A C 1200 200 100 + 4 B C 1000 150 100 + 5 C D 2000 150 100 + +[TIMES] + Duration 48 + Hydraulic Timestep 1:00 + Quality Timestep 0:05 + Report Timestep 2 + Report Start 0 + Statistic NONE + +[OPTIONS] + Units CMH + Headloss H-W + Quality NONE + +[END] diff --git a/wntr/tests/networks_for_testing/msx_example.msx b/wntr/tests/networks_for_testing/msx_example.msx new file mode 100644 index 000000000..b20e0854b --- /dev/null +++ b/wntr/tests/networks_for_testing/msx_example.msx @@ -0,0 +1,58 @@ +[TITLE] +Arsenic Oxidation/Adsorption Example + +[OPTIONS] + AREA_UNITS M2 ;Surface concentration is mass/m2 + RATE_UNITS HR ;Reaction rates are concentration/hour + SOLVER RK5 ;5-th order Runge-Kutta integrator + TIMESTEP 360 ;360 sec (5 min) solution time step + RTOL 0.001 ;Relative concentration tolerance + ATOL 0.0001 ;Absolute concentration tolerance + +[SPECIES] + BULK AS3 UG ;Dissolved arsenite + BULK AS5 UG ;Dissolved arsenate + BULK AStot UG ;Total dissolved arsenic + WALL AS5s UG ;Adsorbed arsenate + BULK NH2CL MG ;Monochloramine + +[COEFFICIENTS] + CONSTANT Ka 10.0 ;Arsenite oxidation rate coefficient + CONSTANT Kb 0.1 ;Monochloramine decay rate coefficient + CONSTANT K1 5.0 ;Arsenate adsorption coefficient + CONSTANT K2 1.0 ;Arsenate desorption coefficient + CONSTANT Smax 50 ;Arsenate adsorption saturation limit + +[TERMS] + Ks K1/K2 ;Equil. adsorption coeff. + +[PIPES] + ;Arsenite oxidation + RATE AS3 -Ka*AS3*NH2CL + ;Arsenate production + RATE AS5 Ka*AS3*NH2CL - Av*(K1*(Smax-AS5s)*AS5 - K2*AS5s) + ;Monochloramine decay + RATE NH2CL -Kb*NH2CL + ;Arsenate adsorption + EQUIL AS5s Ks*Smax*AS5/(1+Ks*AS5) - AS5s + ;Total bulk arsenic + FORMULA AStot AS3 + AS5 + +[TANKS] + RATE AS3 -Ka*AS3*NH2CL + RATE AS5 Ka*AS3*NH2CL + RATE NH2CL -Kb*NH2CL + FORMULA AStot AS3 + AS5 + +[QUALITY] + ;Initial conditions (= 0 if not specified here) + NODE Source AS3 10.0 + NODE Source NH2CL 2.5 + +[REPORT] + NODES C D ;Report results for nodes C and D + LINKS 5 ;Report results for pipe 5 + SPECIES AStot YES ;Report results for each specie + SPECIES AS5 YES + SPECIES AS5s YES + SPECIES NH2CL YES diff --git a/wntr/tests/test_epanet_msx_io.py b/wntr/tests/test_epanet_msx_io.py new file mode 100644 index 000000000..793e2d381 --- /dev/null +++ b/wntr/tests/test_epanet_msx_io.py @@ -0,0 +1,42 @@ +import unittest +import warnings +from os.path import abspath, dirname, join + +import numpy as np +import pandas as pd +import wntr +import wntr.msx +import wntr.epanet.msx + +testdir = dirname(abspath(str(__file__))) +test_network_dir = join(testdir, "networks_for_testing") +inp_filename = join(test_network_dir, "msx_example.inp") +msx_filename = join(test_network_dir, "msx_example.msx") + + +class Test(unittest.TestCase): + @classmethod + def setUpClass(self): + pass + + @classmethod + def tearDownClass(self): + pass + + def test_msx_io(self): + wn_model = wntr.network.WaterNetworkModel(inp_file_name=inp_filename) + msx_model = wntr.msx.MsxModel(msx_file_name=msx_filename) + wntr.epanet.InpFile().write("test.inp", wn_model) + wntr.epanet.msx.MsxFile().write("test.msx", msx_model) + msx_model2 = wntr.msx.MsxModel(msx_file_name="test.msx") + true_vars = ["AS3", "AS5", "AS5s", "AStot", "NH2CL", "Ka", "Kb", "K1", "K2", "Smax", "Ks"] + true_vars.sort() + in_vars = msx_model.species_name_list + msx_model.constant_name_list + msx_model.parameter_name_list + msx_model.term_name_list + in_vars.sort() + io_vars = msx_model2.species_name_list + msx_model2.constant_name_list + msx_model2.parameter_name_list + msx_model2.term_name_list + io_vars.sort() + self.assertListEqual(true_vars, in_vars) + self.assertListEqual(true_vars, io_vars) + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/wntr/tests/test_epanet_msx_sim.py b/wntr/tests/test_epanet_msx_sim.py new file mode 100644 index 000000000..fdc5c9db0 --- /dev/null +++ b/wntr/tests/test_epanet_msx_sim.py @@ -0,0 +1,50 @@ +import unittest +import warnings +from os.path import abspath, dirname, join + +import numpy as np +import pandas as pd +import wntr +import wntr.msx +import wntr.epanet.msx + +testdir = dirname(abspath(str(__file__))) +test_network_dir = join(testdir, "networks_for_testing") +inp_filename = join(test_network_dir, "msx_example.inp") +msx_filename = join(test_network_dir, "msx_example.msx") + + +class TestEpanetMSXSim(unittest.TestCase): + @classmethod + def setUpClass(self): + pass + + @classmethod + def tearDownClass(self): + pass + + def test_msx_sim(self): + wn = wntr.network.WaterNetworkModel(inp_file_name=inp_filename) + wn.add_msx_model(msx_filename=msx_filename) + sim = wntr.sim.EpanetSimulator(wn) + msx_model = wntr.msx.MsxModel(msx_file_name=msx_filename) + + # run sim + res = sim.run_sim() + + # check results object keys + for species in wn.msx.species_name_list: + assert species in res.node.keys() + assert species in res.link.keys() + + + # sanity check at test point + expected = 10.032905 # Node 'C' at time 136800 for AStot + error = abs( + (res.node["AStot"].loc[136800, "C"] - expected) / expected + ) + self.assertLess(error, 0.0001) # 0.01% error + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/wntr/tests/test_epanet_msx_tooklit.py b/wntr/tests/test_epanet_msx_tooklit.py new file mode 100644 index 000000000..6235cba73 --- /dev/null +++ b/wntr/tests/test_epanet_msx_tooklit.py @@ -0,0 +1,34 @@ +import unittest +import warnings +from os.path import abspath, dirname, join + +import numpy as np +import pandas as pd +import wntr +import wntr.msx +import wntr.epanet.msx +import wntr.epanet.msx.toolkit + +testdir = dirname(abspath(str(__file__))) +test_network_dir = join(testdir, "networks_for_testing") +inp_filename = join(test_network_dir, 'msx_example.inp') +msx_filename = join(test_network_dir, 'msx_example.msx') + +class Test(unittest.TestCase): + @classmethod + def setUpClass(self): + pass + + @classmethod + def tearDownClass(self): + pass + + def test_msx_io(self): + wn_model = wntr.network.WaterNetworkModel(inp_file_name=inp_filename) + msx_model = wntr.msx.MsxModel(msx_file_name=msx_filename) + wntr.epanet.msx.toolkit.MSXepanet(inp_filename, msxfile=msx_filename) + + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/wntr/tests/test_epanet_toolkit.py b/wntr/tests/test_epanet_toolkit.py index 75c258e55..a81673b64 100644 --- a/wntr/tests/test_epanet_toolkit.py +++ b/wntr/tests/test_epanet_toolkit.py @@ -1,8 +1,14 @@ import unittest from os.path import abspath, dirname, join, exists +import sys, platform import wntr.epanet.toolkit +if 'darwin' in sys.platform.lower() and 'arm' in platform.platform().lower(): + skip_v2_tests_on_arm = True +else: + skip_v2_tests_on_arm = False + testdir = dirname(abspath(__file__)) datadir = join(testdir, "..", "..", "examples", "networks") @@ -11,6 +17,8 @@ class TestEpanetToolkit(unittest.TestCase): def test_isOpen(self): for version in [2.0, 2.2,]: + if version == 2.0 and skip_v2_tests_on_arm: + continue # skip v2.0 tests on mac silicon processor enData = wntr.epanet.toolkit.ENepanet(version=version) enData.inpfile = join(datadir, "Net1.inp") self.assertEqual(0, enData.isOpen()) @@ -19,6 +27,8 @@ def test_isOpen(self): def test_ENgetcount(self): for version in [2.0, 2.2,]: + if version == 2.0 and skip_v2_tests_on_arm: + continue # skip v2.0 tests on mac silicon processor enData = wntr.epanet.toolkit.ENepanet(version=version) enData.inpfile = join(datadir, "Net1.inp") enData.ENopen(enData.inpfile, "temp.rpt") @@ -30,6 +40,8 @@ def test_ENgetcount(self): def test_ENgetflowunits(self): for version in [2.0, 2.2,]: + if version == 2.0 and skip_v2_tests_on_arm: + continue # skip v2.0 tests on mac silicon processor enData = wntr.epanet.toolkit.ENepanet(version=version) enData.inpfile = join(datadir, "Net1.inp") enData.ENopen(enData.inpfile, "temp.rpt") @@ -39,6 +51,8 @@ def test_ENgetflowunits(self): def test_EN_timeparam(self): for version in [2.0, 2.2,]: + if version == 2.0 and skip_v2_tests_on_arm: + continue # skip v2.0 tests on mac silicon processor enData = wntr.epanet.toolkit.ENepanet(version=version) enData.inpfile = join(datadir, "Net1.inp") enData.ENopen(enData.inpfile, "temp.rpt") @@ -51,6 +65,8 @@ def test_EN_timeparam(self): def test_ENgetindex_ENgetvalue(self): for version in [2.0, 2.2,]: + if version == 2.0 and skip_v2_tests_on_arm: + continue # skip v2.0 tests on mac silicon processor enData = wntr.epanet.toolkit.ENepanet(version=version) enData.inpfile = join(datadir, "Net1.inp") enData.ENopen(enData.inpfile, "temp.rpt") @@ -74,6 +90,8 @@ def test_ENgetindex_ENgetvalue(self): def test_ENsaveinpfile(self): for version in [2.0, 2.2,]: + if version == 2.0 and skip_v2_tests_on_arm: + continue # skip v2.0 tests on mac silicon processor enData = wntr.epanet.toolkit.ENepanet(version=version) enData.inpfile = join(datadir, "Net1.inp") enData.ENopen(enData.inpfile, "temp.rpt") @@ -94,6 +112,8 @@ def test_runepanet(self): def test_runepanet_step(self): for version in [2.0, 2.2,]: + if version == 2.0 and skip_v2_tests_on_arm: + continue # skip v2.0 tests on mac silicon processor enData = wntr.epanet.toolkit.ENepanet(version=version) enData.inpfile = join(datadir, "Net1.inp") enData.ENopen(enData.inpfile, "temp_runepanet_step.rpt", "temp_runepanet_step.bin") diff --git a/wntr/tests/test_graphics.py b/wntr/tests/test_graphics.py index 0374ebef3..8caed7e45 100644 --- a/wntr/tests/test_graphics.py +++ b/wntr/tests/test_graphics.py @@ -5,7 +5,12 @@ import warnings from os.path import abspath, dirname, isfile, join +import networkx as nx import matplotlib.pylab as plt +import matplotlib +from wntr.graphics.color import custom_colormap +import pandas as pd +import numpy as np import wntr testdir = dirname(abspath(str(__file__))) @@ -22,7 +27,6 @@ def test_plot_network1(self): inp_file = join(ex_datadir, "Net6.inp") wn = wntr.network.WaterNetworkModel(inp_file) - plt.figure() wntr.graphics.plot_network(wn) plt.savefig(filename, format="png") plt.close() @@ -37,7 +41,7 @@ def test_plot_network2(self): filename = abspath(join(testdir, "plot_network2_undirected.png")) if isfile(filename): os.remove(filename) - plt.figure() + wntr.graphics.plot_network( wn, node_attribute="elevation", link_attribute="length" ) @@ -50,7 +54,7 @@ def test_plot_network2(self): filename = abspath(join(testdir, "plot_network2_directed.png")) if isfile(filename): os.remove(filename) - plt.figure() + wntr.graphics.plot_network( wn, node_attribute="elevation", link_attribute="length", directed=True ) @@ -67,7 +71,6 @@ def test_plot_network3(self): inp_file = join(ex_datadir, "Net1.inp") wn = wntr.network.WaterNetworkModel(inp_file) - plt.figure() wntr.graphics.plot_network( wn, node_attribute=["11", "21"], @@ -87,7 +90,6 @@ def test_plot_network4(self): inp_file = join(ex_datadir, "Net1.inp") wn = wntr.network.WaterNetworkModel(inp_file) - plt.figure() wntr.graphics.plot_network( wn, node_attribute={"11": 5, "21": 10}, @@ -108,7 +110,6 @@ def test_plot_network5(self): wn = wntr.network.WaterNetworkModel(inp_file) pop = wntr.metrics.population(wn) - plt.figure() wntr.graphics.plot_network( wn, node_attribute=pop, node_range=[0, 500], title="Population" ) @@ -117,6 +118,95 @@ def test_plot_network5(self): self.assertTrue(isfile(filename)) + def test_plot_network6(self): + # legend + filename = abspath(join(testdir, "plot_network6.png")) + if isfile(filename): + os.remove(filename) + + inp_file = join(ex_datadir, "Net6.inp") + wn = wntr.network.WaterNetworkModel(inp_file) + + wntr.graphics.plot_network( + wn, node_attribute="elevation", link_attribute="diameter", + add_colorbar=True, legend=True + ) + plt.savefig(filename, format="png") + plt.close() + + self.assertTrue(isfile(filename)) + + def test_plot_network_options(self): + # NOTE:to compare with the old plot_network set compare=True. + # this should be set to false for regular testing + compare = False + + cmap = matplotlib.colormaps['viridis'] + + inp_file = join(ex_datadir, "Net6.inp") + wn = wntr.network.WaterNetworkModel(inp_file) + + random_node_values = pd.Series( + np.random.rand(len(wn.node_name_list)), index=wn.node_name_list) + random_link_values = pd.Series( + np.random.rand(len(wn.link_name_list)), index=wn.link_name_list) + random_pipe_values = pd.Series( + np.random.rand(len(wn.pipe_name_list)), index=wn.pipe_name_list) + random_node_dict_subset = dict(random_node_values.iloc[:10]) + random_link_dict_subset = dict(random_link_values.iloc[:10]) + node_list = list(wn.node_name_list[:10]) + link_list = list(wn.link_name_list[:10]) + + kwarg_list = [ + {"node_attribute": "elevation", + "node_range": [0,20], + "node_alpha": 0.5, + "node_colorbar_label": "test_label"}, + {"link_attribute": "diameter", + "link_range": [0,None], + "link_alpha": 0.5, + "link_colorbar_label": "test_label"}, + {"link_attribute": "diameter", + "node_attribute": "elevation"}, + {"node_labels": True, + "link_labels": True}, + {"node_attribute": "elevation", + "add_colorbar": False}, + {"link_attribute": "diameter", + "add_colorbar": False}, + {"node_attribute": node_list}, + {"node_attribute": random_node_values}, + {"node_attribute": random_node_dict_subset}, + {"link_attribute": link_list}, + {"link_attribute": random_link_values}, + {"link_attribute": random_link_dict_subset}, + {"directed": True}, + {"link_attribute": random_pipe_values, + "node_size": 0, + "link_cmap": cmap, + "link_range": [0,1], + "link_width": 1.5}, + ] + + for kwargs in kwarg_list: + filename = abspath(join(testdir, "plot_network_options.png")) + if isfile(filename): + os.remove(filename) + if compare: + fig, ax = plt.subplots(1,2) + wntr.graphics.plot_network(wn, ax=ax[0], title="GIS plot_network", **kwargs) + plot_network_nx(wn, ax=ax[1], title="NX plot_network", **kwargs) + fig.savefig(filename, format="png") + plt.close(fig) + else: + wntr.graphics.plot_network(wn, **kwargs) + plt.savefig(filename, format="png") + plt.close() + + self.assertTrue(isfile(filename)) + os.remove(filename) + + def test_plot_interactive_network1(self): filename = abspath(join(testdir, "plot_interactive_network1.html")) @@ -235,6 +325,241 @@ def test_custom_colormap(self): ) self.assertEqual(cmp.N, 3) self.assertEqual(cmp.name, "custom") + + +# old plotting function using networkx backend to compare with geopandas +def plot_network_nx(wn, node_attribute=None, link_attribute=None, title=None, + node_size=20, node_range=[None,None], node_alpha=1, node_cmap=None, node_labels=False, + link_width=1, link_range=[None,None], link_alpha=1, link_cmap=None, link_labels=False, + add_colorbar=True, node_colorbar_label='Node', link_colorbar_label='Link', + directed=False, ax=None, show_plot=True, filename=None): + """ + Plot network graphic + + Parameters + ---------- + wn : wntr WaterNetworkModel + A WaterNetworkModel object + + node_attribute : None, str, list, pd.Series, or dict, optional + + - If node_attribute is a string, then a node attribute dictionary is + created using node_attribute = wn.query_node_attribute(str) + - If node_attribute is a list, then each node in the list is given a + value of 1. + - If node_attribute is a pd.Series, then it should be in the format + {nodeid: x} where nodeid is a string and x is a float. + - If node_attribute is a dict, then it should be in the format + {nodeid: x} where nodeid is a string and x is a float + + link_attribute : None, str, list, pd.Series, or dict, optional + + - If link_attribute is a string, then a link attribute dictionary is + created using edge_attribute = wn.query_link_attribute(str) + - If link_attribute is a list, then each link in the list is given a + value of 1. + - If link_attribute is a pd.Series, then it should be in the format + {linkid: x} where linkid is a string and x is a float. + - If link_attribute is a dict, then it should be in the format + {linkid: x} where linkid is a string and x is a float. + + title: str, optional + Plot title + + node_size: int, optional + Node size + + node_range: list, optional + Node color range ([None,None] indicates autoscale) + + node_alpha: int, optional + Node transparency + + node_cmap: matplotlib.pyplot.cm colormap or list of named colors, optional + Node colormap + + node_labels: bool, optional + If True, the graph will include each node labelled with its name. + + link_width: int, optional + Link width + + link_range : list, optional + Link color range ([None,None] indicates autoscale) + + link_alpha : int, optional + Link transparency + + link_cmap: matplotlib.pyplot.cm colormap or list of named colors, optional + Link colormap + + link_labels: bool, optional + If True, the graph will include each link labelled with its name. + + add_colorbar: bool, optional + Add colorbar + + node_colorbar_label: str, optional + Node colorbar label + + link_colorbar_label: str, optional + Link colorbar label + + directed: bool, optional + If True, plot the directed graph + + ax: matplotlib axes object, optional + Axes for plotting (None indicates that a new figure with a single + axes will be used) + + show_plot: bool, optional + If True, show plot with plt.show() + + filename : str, optional + Filename used to save the figure + + Returns + ------- + ax : matplotlib axes object + """ + + def _format_node_attribute(node_attribute, wn): + + if isinstance(node_attribute, str): + node_attribute = wn.query_node_attribute(node_attribute) + if isinstance(node_attribute, list): + node_attribute = dict(zip(node_attribute,[1]*len(node_attribute))) + if isinstance(node_attribute, pd.Series): + node_attribute = dict(node_attribute) + + return node_attribute + + def _format_link_attribute(link_attribute, wn): + + if isinstance(link_attribute, str): + link_attribute = wn.query_link_attribute(link_attribute) + if isinstance(link_attribute, list): + link_attribute = dict(zip(link_attribute,[1]*len(link_attribute))) + if isinstance(link_attribute, pd.Series): + link_attribute = dict(link_attribute) + + return link_attribute + + if ax is None: # create a new figure + plt.figure(facecolor='w', edgecolor='k') + ax = plt.gca() + + # Graph + G = wn.to_graph() + if not directed: + G = G.to_undirected() + + # Position + pos = nx.get_node_attributes(G,'pos') + if len(pos) == 0: + pos = None + + # Define node properties + add_node_colorbar = add_colorbar + if node_attribute is not None: + + if isinstance(node_attribute, list): + if node_cmap is None: + node_cmap = ['red', 'red'] + add_node_colorbar = False + + if node_cmap is None: + node_cmap = plt.get_cmap('Spectral_r') + elif isinstance(node_cmap, list): + if len(node_cmap) == 1: + node_cmap = node_cmap*2 + node_cmap = custom_colormap(len(node_cmap), node_cmap) + + node_attribute = _format_node_attribute(node_attribute, wn) + nodelist,nodecolor = zip(*node_attribute.items()) + + else: + nodelist = None + nodecolor = 'k' + + add_link_colorbar = add_colorbar + if link_attribute is not None: + + if isinstance(link_attribute, list): + if link_cmap is None: + link_cmap = ['red', 'red'] + add_link_colorbar = False + + if link_cmap is None: + link_cmap = plt.get_cmap('Spectral_r') + elif isinstance(link_cmap, list): + if len(link_cmap) == 1: + link_cmap = link_cmap*2 + link_cmap = custom_colormap(len(link_cmap), link_cmap) + + link_attribute = _format_link_attribute(link_attribute, wn) + + # Replace link_attribute dictionary defined as + # {link_name: attr} with {(start_node, end_node, link_name): attr} + attr = {} + for link_name, value in link_attribute.items(): + link = wn.get_link(link_name) + attr[(link.start_node_name, link.end_node_name, link_name)] = value + link_attribute = attr + + linklist,linkcolor = zip(*link_attribute.items()) + else: + linklist = None + linkcolor = 'k' + + if title is not None: + ax.set_title(title) + + edge_background = nx.draw_networkx_edges(G, pos, edge_color='grey', + width=0.5, ax=ax) + + nodes = nx.draw_networkx_nodes(G, pos, + nodelist=nodelist, node_color=nodecolor, node_size=node_size, + alpha=node_alpha, cmap=node_cmap, vmin=node_range[0], vmax = node_range[1], + linewidths=0, ax=ax) + edges = nx.draw_networkx_edges(G, pos, edgelist=linklist, arrows=directed, + edge_color=linkcolor, width=link_width, alpha=link_alpha, edge_cmap=link_cmap, + edge_vmin=link_range[0], edge_vmax=link_range[1], ax=ax) + if node_labels: + labels = dict(zip(wn.node_name_list, wn.node_name_list)) + nx.draw_networkx_labels(G, pos, labels, font_size=7, ax=ax) + if link_labels: + labels = {} + for link_name in wn.link_name_list: + link = wn.get_link(link_name) + labels[(link.start_node_name, link.end_node_name)] = link_name + nx.draw_networkx_edge_labels(G, pos, labels, font_size=7, ax=ax) + if add_node_colorbar and node_attribute: + clb = plt.colorbar(nodes, shrink=0.5, pad=0, ax=ax) + clb.ax.set_title(node_colorbar_label, fontsize=10) + if add_link_colorbar and link_attribute: + if link_range[0] is None: + vmin = min(link_attribute.values()) + else: + vmin = link_range[0] + if link_range[1] is None: + vmax = max(link_attribute.values()) + else: + vmax = link_range[1] + sm = plt.cm.ScalarMappable(cmap=link_cmap, norm=plt.Normalize(vmin=vmin, vmax=vmax)) + sm.set_array([]) + clb = plt.colorbar(sm, shrink=0.5, pad=0.05, ax=ax) + clb.ax.set_title(link_colorbar_label, fontsize=10) + + ax.axis('off') + + if filename: + plt.savefig(filename) + + if show_plot is True: + plt.show(block=False) + + return ax if __name__ == "__main__": diff --git a/wntr/tests/test_metrics_healthimpacts.py b/wntr/tests/test_metrics_healthimpacts.py index 3f00893d0..c4d211582 100644 --- a/wntr/tests/test_metrics_healthimpacts.py +++ b/wntr/tests/test_metrics_healthimpacts.py @@ -1,8 +1,14 @@ import unittest from os.path import abspath, dirname, join +import sys, platform import wntr +if 'darwin' in sys.platform.lower() and 'arm' in platform.platform().lower(): + skip_v2_tests_on_arm = True +else: + skip_v2_tests_on_arm = False + testdir = dirname(abspath(str(__file__))) datadir = join(testdir, "networks_for_testing") netdir = join(testdir, "..", "..", "examples", "networks") @@ -16,6 +22,7 @@ class TestHealthImpactsMetric(unittest.TestCase): def test_mass_consumed(self): inp_file = join(netdir, "Net3.inp") + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') wn = wntr.network.WaterNetworkModel(inp_file) wn.options.time.hydraulic_timestep = 15*60 @@ -53,6 +60,7 @@ def test_mass_consumed(self): self.assertLess(error, 0.01) # 1% error def test_volume_consumed(self): + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') inp_file = join(netdir, "Net3.inp") @@ -92,6 +100,7 @@ def test_volume_consumed(self): self.assertLess(error, 0.01) # 1% error def test_extent_contaminated(self): + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') inp_file = join(netdir, "Net3.inp") diff --git a/wntr/tests/test_msx_elements.py b/wntr/tests/test_msx_elements.py new file mode 100644 index 000000000..e65f796f3 --- /dev/null +++ b/wntr/tests/test_msx_elements.py @@ -0,0 +1,190 @@ +import unittest +import warnings +from os.path import abspath, dirname, join + +import numpy as np +import pandas as pd +import wntr + +testdir = dirname(abspath(str(__file__))) +test_network_dir = join(testdir, "networks_for_testing") +test_data_dir = join(testdir, "data_for_testing") +ex_datadir = join(testdir, "..", "..", "examples", "networks") + + +class Test(unittest.TestCase): + @classmethod + def setUpClass(self): + pass + + @classmethod + def tearDownClass(self): + pass + + def test_ReactionVariable_reserved_name_exception(self): + self.assertRaises(ValueError, wntr.msx.Species, "D", 'bulk', "mg") + self.assertRaises(ValueError, wntr.msx.Species, "Q", 'wall', "mg") + self.assertRaises(ValueError, wntr.msx.Constant, "Re", 0.52) + self.assertRaises(ValueError, wntr.msx.Parameter, "Re", 0.52) + self.assertRaises(ValueError, wntr.msx.Term, "Re", 0.52) + + def test_RxnVariable_values(self): + const1 = wntr.msx.Constant("Kb", 0.482, note="test") + self.assertEqual(const1.value, 0.482) + param2 = wntr.msx.Parameter("Kb", 0.482, note="test") + self.assertEqual(param2.global_value, 0.482) + + def test_RxnVariable_string_functions(self): + species1 = wntr.msx.Species('Cl', 'BULK', "mg") + const1 = wntr.msx.Constant("Kb", 0.482) + param1 = wntr.msx.Parameter("Ka", 0.482, note="foo") + term1 = wntr.msx.Term("T0", "-3.2 * Kb * Cl^2", note="bar") + + self.assertEqual(str(species1), "Cl") + self.assertEqual(str(const1), "Kb") + self.assertEqual(str(param1), "Ka") + self.assertEqual(str(term1), "T0") + + def test_Species_tolerances(self): + # """RxnSpecies(*s) tolerance settings""" + species1 = wntr.msx.Species("Cl", 'bulk', "mg") + species2 = wntr.msx.Species("Cl", 'wall', "mg", 0.01, 0.0001, note="Testing stuff") + self.assertIsNone(species1.get_tolerances()) + self.assertIsNotNone(species2.get_tolerances()) + self.assertTupleEqual(species2.get_tolerances(), (0.01, 0.0001)) + self.assertRaises(TypeError, species1.set_tolerances, None, 0.0001) + self.assertRaises(ValueError, species1.set_tolerances, -0.51, 0.01) + self.assertRaises(ValueError, species1.set_tolerances, 0.0, 0.0) + species1.set_tolerances(0.01, 0.0001) + self.assertEqual(species1.atol, 0.01) + self.assertEqual(species1.rtol, 0.0001) + species1.set_tolerances(None, None) + self.assertIsNone(species1.atol) + self.assertIsNone(species1.rtol) + species2.clear_tolerances() + self.assertIsNone(species1.atol) + self.assertIsNone(species1.rtol) + self.assertIsNone(species2.get_tolerances()) + + def test_BulkSpecies_creation(self): + # """BlukSpecies object creation (direct instantiation)""" + self.assertRaises(TypeError, wntr.msx.Species, "Re", 'bulk') + self.assertRaises(ValueError, wntr.msx.Species, "Re", 'bulk', "mg") + # self.assertRaises(TypeError, wntr.msx.Species, "Cl", 1, "mg", 0.01, None) + # self.assertRaises(TypeError, wntr.msx.Species, "Cl", wntr.msx.base.SpeciesType.BULK, "mg", None, 0.01) + species = wntr.msx.Species("Cl", 1, "mg") + self.assertEqual(species.name, "Cl") + self.assertEqual(species.units, "mg") + self.assertEqual(species.species_type, wntr.msx.SpeciesType.BULK) + self.assertIsNone(species.atol) + self.assertIsNone(species.rtol) + self.assertIsNone(species.note) + species = wntr.msx.Species("Cl",'bulk', "mg", 0.01, 0.0001, note="Testing stuff") + self.assertEqual(species.species_type, wntr.msx.SpeciesType.BULK) + self.assertEqual(species.name, "Cl") + self.assertEqual(species.units, "mg") + self.assertEqual(species.atol, 0.01) + self.assertEqual(species.rtol, 0.0001) + self.assertEqual(species.note, "Testing stuff") + + def test_WallSpecies_creation(self): + # """WallSpecies object creation (direct instantiation)""" + self.assertRaises(TypeError, wntr.msx.Species, "Re", 'W') + self.assertRaises(ValueError, wntr.msx.Species, "Re", 'Wall', "mg") + self.assertRaises(TypeError, wntr.msx.Species, "Cl", 2, "mg", 0.01) + self.assertRaises(TypeError, wntr.msx.Species, "Cl", 'w', "mg", None, 0.01) + species = wntr.msx.Species( "Cl", 'w', "mg") + self.assertEqual(species.name, "Cl") + self.assertEqual(species.units, "mg") + self.assertEqual(species.species_type, wntr.msx.SpeciesType.WALL) + self.assertIsNone(species.atol) + self.assertIsNone(species.rtol) + self.assertIsNone(species.note) + species = wntr.msx.Species( "Cl", 'w', "mg", 0.01, 0.0001, note="Testing stuff") + self.assertEqual(species.species_type, wntr.msx.SpeciesType.WALL) + self.assertEqual(species.name, "Cl") + self.assertEqual(species.units, "mg") + self.assertEqual(species.atol, 0.01) + self.assertEqual(species.rtol, 0.0001) + self.assertEqual(species.note, "Testing stuff") + + def test_Constant_creation(self): + self.assertRaises(TypeError, wntr.msx.Constant, "Re") + self.assertRaises(ValueError, wntr.msx.Constant, "Re", 2.48) + const1 = wntr.msx.Constant("Kb", 0.482, note="test") + # FIXME: Find a way to suppress warning printing + # self.assertWarns(RuntimeWarning, wntr.reaction.Constant, 'Kb1', 0.83, pipe_values={'a',1}) + self.assertEqual(const1.name, "Kb") + self.assertEqual(const1.value, 0.482) + self.assertEqual(const1.var_type, wntr.msx.VariableType.CONST) + self.assertEqual(const1.note, "test") + + def test_Parameter_creation(self): + self.assertRaises(TypeError, wntr.msx.Parameter, "Re") + self.assertRaises(ValueError, wntr.msx.Parameter, "Re", 2.48) + param1 = wntr.msx.Parameter("Kb", 0.482, note="test") + self.assertEqual(param1.name, "Kb") + self.assertEqual(param1.global_value, 0.482) + self.assertEqual(param1.var_type, wntr.msx.VariableType.PARAM) + self.assertEqual(param1.note, "test") + # test_pipe_dict = {"PIPE": 0.38} + # test_tank_dict = {"TANK": 222.23} + # param2 = wntr.msx.Parameter("Kb", 0.482, note="test", pipe_values=test_pipe_dict, tank_values=test_tank_dict) + # self.assertDictEqual(param2.pipe_values, test_pipe_dict) + # self.assertDictEqual(param2.tank_values, test_tank_dict) + + def test_RxnTerm_creation(self): + self.assertRaises(TypeError, wntr.msx.Term, "Re") + self.assertRaises(ValueError, wntr.msx.Term, "Re", "1.0*Re") + term1 = wntr.msx.Term("T0", "-3.2 * Kb * Cl^2", note="bar") + self.assertEqual(term1.name, "T0") + self.assertEqual(term1.expression, "-3.2 * Kb * Cl^2") + self.assertEqual(term1.var_type, wntr.msx.VariableType.TERM) + self.assertEqual(term1.note, "bar") + + def test_Reaction(self): + equil1 = wntr.msx.Reaction("Cl", wntr.msx.ReactionType.PIPE, 'equil', "-Ka + Kb * Cl + T0") + self.assertEqual(equil1.species_name, "Cl") + self.assertEqual(equil1.expression, "-Ka + Kb * Cl + T0") + self.assertEqual(equil1.expression_type, wntr.msx.ExpressionType.EQUIL) + rate1 = wntr.msx.Reaction("Cl", wntr.msx.ReactionType.TANK, 'rate', "-Ka + Kb * Cl + T0", note="Foo Bar") + self.assertEqual(rate1.species_name, "Cl") + self.assertEqual(rate1.expression, "-Ka + Kb * Cl + T0") + self.assertEqual(rate1.expression_type, wntr.msx.ExpressionType.RATE) + self.assertEqual(rate1.note, "Foo Bar") + formula1 = wntr.msx.Reaction("Cl", wntr.msx.ReactionType.PIPE, 'formula', "-Ka + Kb * Cl + T0") + self.assertEqual(formula1.species_name, "Cl") + self.assertEqual(formula1.expression, "-Ka + Kb * Cl + T0") + self.assertEqual(formula1.expression_type, wntr.msx.ExpressionType.FORMULA) + + def test_MsxModel_creation_specific_everything(self): + rxn_model1 = wntr.msx.MsxModel() + bulk1 = wntr.msx.Species("Cl", 'b', "mg") + wall1 = wntr.msx.Species("ClOH", 'w', "mg", 0.01, 0.0001, note="Testing stuff") + const1 = wntr.msx.Constant("Kb", 0.482) + param1 = wntr.msx.Parameter("Ka", 0.482, note="foo") + term1 = wntr.msx.Term("T0", "-3.2 * Kb * Cl^2", note="bar") + equil1 = wntr.msx.Reaction(bulk1, wntr.msx.ReactionType.PIPE, 'equil', "-Ka + Kb * Cl + T0") + rate1 = wntr.msx.Reaction(bulk1, wntr.msx.ReactionType.TANK, 'rate', "-Ka + Kb * Cl + T0", note="Foo Bar") + formula1 = wntr.msx.Reaction(wall1, wntr.msx.ReactionType.PIPE, 'formula', "-Ka + Kb * Cl + T0") + + bulk2 = rxn_model1.add_species("Cl", 'bulk', "mg") + wall2 = rxn_model1.add_species("ClOH", 'wall', "mg", 0.01, 0.0001, note="Testing stuff") + const2 = rxn_model1.add_constant("Kb", 0.482) + param2 = rxn_model1.add_parameter("Ka", 0.482, note="foo") + term2 = rxn_model1.add_term("T0", "-3.2 * Kb * Cl^2", note="bar") + equil2 = rxn_model1.add_reaction("Cl", "pipe", "equil", "-Ka + Kb * Cl + T0") + rate2 = rxn_model1.add_reaction("Cl", "tank", wntr.msx.ExpressionType.R, "-Ka + Kb * Cl + T0", note="Foo Bar") + formula2 = rxn_model1.add_reaction("ClOH", "PIPE", "formula", "-Ka + Kb * Cl + T0") + self.assertDictEqual(bulk1.to_dict(), bulk2.to_dict()) + self.assertDictEqual(wall1.to_dict(), wall2.to_dict()) + self.assertDictEqual(const1.to_dict(), const2.to_dict()) + self.assertDictEqual(param1.to_dict(), param2.to_dict()) + self.assertDictEqual(term1.to_dict(), term2.to_dict()) + self.assertDictEqual(equil1.to_dict(), equil2.to_dict()) + self.assertDictEqual(rate1.to_dict(), rate2.to_dict()) + self.assertDictEqual(formula1.to_dict(), formula2.to_dict()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/wntr/tests/test_network.py b/wntr/tests/test_network.py index 56bbfdc2c..2a8342d18 100644 --- a/wntr/tests/test_network.py +++ b/wntr/tests/test_network.py @@ -1128,6 +1128,7 @@ def test_valid_gis_names(self): data_columns.append(data.index.name) # Check that all data columns are valid + print(set(data_columns)-set(valid_columns)) assert len(set(data_columns)-set(valid_columns)) == 0 # Check that all required columns are in the data assert len(set(required_columns)-set(data_columns)) == 0 diff --git a/wntr/tests/test_sim_waterquality.py b/wntr/tests/test_sim_waterquality.py index 0d357ec12..ef8985619 100644 --- a/wntr/tests/test_sim_waterquality.py +++ b/wntr/tests/test_sim_waterquality.py @@ -1,8 +1,13 @@ import unittest from os.path import abspath, dirname, join - +import sys, platform import wntr +if 'darwin' in sys.platform.lower() and 'arm' in platform.platform().lower(): + skip_v2_tests_on_arm = True +else: + skip_v2_tests_on_arm = False + testdir = dirname(abspath(str(__file__))) datadir = join(testdir, "..", "..", "examples", "networks") @@ -18,6 +23,7 @@ class TestWaterQualitySimulations(unittest.TestCase): def test_setpoint_waterquality_simulation(self): inp_file = join(datadir, "Net3.inp") + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') wn = wntr.network.WaterNetworkModel(inp_file) wn.options.time.hydraulic_timestep = 15*60 @@ -43,6 +49,7 @@ def test_setpoint_waterquality_simulation(self): def test_flowpaced_waterquality_simulation(self): inp_file = join(datadir, "Net3.inp") + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') wn = wntr.network.WaterNetworkModel(inp_file) wn.options.time.hydraulic_timestep = 15*60 @@ -67,6 +74,7 @@ def test_flowpaced_waterquality_simulation(self): def test_mass_waterquality_simulation(self): inp_file = join(datadir, "Net3.inp") + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') wn = wntr.network.WaterNetworkModel(inp_file) wn.options.time.hydraulic_timestep = 15*60 @@ -91,6 +99,7 @@ def test_mass_waterquality_simulation(self): def test_conc_waterquality_simulation(self): inp_file = join(datadir, "Net3.inp") + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') wn = wntr.network.WaterNetworkModel(inp_file) wn.options.time.hydraulic_timestep = 15*60 @@ -116,6 +125,7 @@ def test_conc_waterquality_simulation(self): def test_age_waterquality_simulation(self): inp_file = join(datadir, "Net3.inp") + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') wn = wntr.network.WaterNetworkModel(inp_file) wn.options.time.hydraulic_timestep = 15*60 @@ -139,7 +149,8 @@ def test_age_waterquality_simulation(self): def test_trace_waterquality_simulation(self): inp_file = join(datadir, "Net3.inp") - + if skip_v2_tests_on_arm: self.skipTest('skipped test due to skip_tests_flag') + wn = wntr.network.WaterNetworkModel(inp_file) wn.options.time.hydraulic_timestep = 15*60 wn.options.time.quality_timestep = 15*60 diff --git a/wntr/utils/disjoint_mapping.py b/wntr/utils/disjoint_mapping.py new file mode 100644 index 000000000..fd2572bf8 --- /dev/null +++ b/wntr/utils/disjoint_mapping.py @@ -0,0 +1,211 @@ +# coding: utf-8 +""" +A set of utility classes that is similar to the 'registry' objects in the wntr.network +class, but more general, and therefore usable for other extensions, such as multi-species +water quality models. +""" + +from collections.abc import MutableMapping +from typing import Any, Dict, Hashable, ItemsView, Iterator, KeysView, Set, ValuesView + + +class WrongGroupError(KeyError): + """The key exists but is in a different disjoint group""" + pass + + +class KeyExistsError(KeyError): + """The name already exists in the model""" + pass + + +class DisjointMapping(MutableMapping): + """A mapping with keys that are also divided into disjoint groups of keys. + + The main purpose of this utility class is to perform implicit name collision checking + while also allowing both the groups and the main dictionary to be used as dictionaries + -- i.e., using `__*item__` methods and `mydict[key]` methods. + """ + + __data: Dict[Hashable, Hashable] = None + __key_groupnames: Dict[Hashable, str] = None + __groups: Dict[str, "DisjointMappingGroup"] = None + __usage: Dict[Hashable, Set[Any]] = None + + def __init__(self, *args, **kwargs): + self.__data: Dict[Hashable, Any] = dict(*args, **kwargs) + self.__key_groupnames: Dict[Hashable, str] = dict() + self.__groups: Dict[str, "DisjointMappingGroup"] = dict() + self.__usage: Dict[Hashable, Set[Any]] = dict() + for k, v in self.__data.items(): + self.__key_groupnames[k] = None + self.__usage[k] = set() + + def add_disjoint_group(self, name, cls = None): + if name in self.__groups.keys(): + raise KeyError("Disjoint group already exists within registry") + if cls is None: + new = DisjointMappingGroup(name, self) + elif issubclass(cls, DisjointMappingGroup): + new = cls(name, self) + else: + raise TypeError('cls must be a subclass of DisjointMappingGroup, got {}'.format(cls)) + self.__groups.__setitem__(name, new) + return new + + def get_disjoint_group(self, name: str): + return self.__groups[name] + + def get_groupname(self, _key: Hashable): + return self.__key_groupnames[_key] + + def add_item_to_group(self, groupname, key, value): + current = self.__key_groupnames.get(key, None) + if current is not None and groupname != current: + raise WrongGroupError("The key '{}' is already used in a different group '{}'".format(key, groupname)) + if groupname is not None: + group = self.__groups[groupname] + group._data.__setitem__(key, value) + self.__key_groupnames[key] = groupname + return self.__data.__setitem__(key, value) + + def move_item_to_group(self, new_group_name, key): + value = self.__data[key] + current = self.get_groupname(key) + if new_group_name is not None: + new_group = self.__groups[new_group_name] + new_group._data[key] = value + if current is not None: + old_group = self.__groups[current] + old_group._data.__delitem__(key) + self.__key_groupnames[key] = new_group_name + + def remove_item_from_group(self, groupname, key): + current = self.__key_groupnames.get(key, None) + if groupname != current: + raise WrongGroupError("The key '{}' is in a different group '{}'".format(key, groupname)) + if groupname is not None: + self.__groups[groupname]._data.__delitem__(key) + + def __getitem__(self, __key: Any) -> Any: + return self.__data.__getitem__(__key) + + def __setitem__(self, __key: Any, __value: Any) -> None: + current = self.__key_groupnames.get(__key, None) + if current is not None: + self.__groups[current]._data[__key] = __value + return self.__data.__setitem__(__key, __value) + + def __delitem__(self, __key: Any) -> None: + current = self.__key_groupnames.get(__key, None) + if current is not None: + self.__groups[current]._data.__delitem__(__key) + return self.__data.__delitem__(__key) + + def __contains__(self, __key: object) -> bool: + return self.__data.__contains__(__key) + + def __iter__(self) -> Iterator: + return self.__data.__iter__() + + def __len__(self) -> int: + return self.__data.__len__() + + def keys(self) -> KeysView: + return self.__data.keys() + + def items(self) -> ItemsView: + return self.__data.items() + + def values(self) -> ValuesView: + return self.__data.values() + + def clear(self) -> None: + raise RuntimeError("You cannot clear this") + + def popitem(self) -> tuple: + raise RuntimeError("You cannot pop this") + + +class DisjointMappingGroup(MutableMapping): + """A dictionary that checks a namespace for existing entries. + + To create a new instance, pass a set to act as a namespace. If the namespace does not + exist, a new namespace will be instantiated. If it does exist, then a new, disjoint + dictionary will be created that checks the namespace keys before it will allow a new + item to be added to the dictionary. An item can only belong to one of the disjoint dictionaries + associated with the namespace. + + Examples + -------- + Assume there is a namespace `nodes` that has two distinct subsets of objects, `tanks` + and `reservoirs`. A name for a tank cannot also be used for a reservoir, and a node + cannot be both a `tank` and a `reservoir`. A DisjointNamespaceDict allows two separate + dictionaries to be kept, one for each subtype, but the keys within the two dictionaries + will be ensured to not overlap. + + Parameters + ---------- + __keyspace : set + the name of the namespace for consistency checking + args, kwargs : Any + regular arguments and keyword arguments passed to the underlying dictionary + + """ + + __name: str = None + __keyspace: "DisjointMapping" = None + _data: dict = None + + def __new__(cls, name: str, _keyspace: "DisjointMapping"): + if name is None: + raise TypeError("A name must be specified") + if _keyspace is None: + raise TypeError("A registry must be specified") + newobj = super().__new__(cls) + return newobj + + def __init__(self, name: str, _keyspace: "DisjointMapping"): + if name is None: + raise TypeError("A name must be specified") + if _keyspace is None: + raise TypeError("A registry must be specified") + self.__name: str = name + self.__keyspace: DisjointMapping = _keyspace + self._data = dict() + + def __getitem__(self, __key: Any) -> Any: + return self._data[__key] + + def __setitem__(self, __key: Any, __value: Any) -> None: + return self.__keyspace.add_item_to_group(self.__name, __key, __value) + + def __delitem__(self, __key: Any) -> None: + return self.__keyspace.remove_item_from_group(self.__name, __key) + + def __contains__(self, __key: object) -> bool: + return self._data.__contains__(__key) + + def __iter__(self) -> Iterator: + return self._data.__iter__() + + def __len__(self) -> int: + return self._data.__len__() + + def keys(self) -> KeysView: + return self._data.keys() + + def items(self) -> ItemsView: + return self._data.items() + + def values(self) -> ValuesView: + return self._data.values() + + def clear(self) -> None: + raise RuntimeError("Cannot clear a group") + + def popitem(self) -> tuple: + raise RuntimeError("Cannot pop from a group") + + def __repr__(self) -> str: + return "{}(name={}, data={})".format(self.__class__.__name__, repr(self.__name), self._data) diff --git a/wntr/utils/enumtools.py b/wntr/utils/enumtools.py new file mode 100644 index 000000000..a05e3c6fc --- /dev/null +++ b/wntr/utils/enumtools.py @@ -0,0 +1,132 @@ +# coding: utf-8 +"""Decorators for use with enum classes. +""" + +from enum import Enum +from typing import Union +import functools + +def add_get(cls=None, *, prefix=None, abbrev=False, allow_none=True): + """Decorator that will add a ``get()`` classmethod to an enum class. + + Parameters + ---------- + prefix : str, optional + A prefix to strip off any string values passed in, by default None + abbrev : bool, optional + Allow truncating to the first character for checks, by default False + allow_none : bool, optional + Allow None to be be passed through without raising error, by default True + + Returns + ------- + class + the modified class + + Notes + ----- + The ``get`` method behaves as follows: + + For an integer, the integer value will be used to select the proper member. + For an :class:`Enum` object, the object's ``name`` will be used, and it will + be processed as a string. For a string, the method will: + + 0. if ``allow_none`` is False, then raise a TypeError if the value is None, otherwise + pass None back to calling function for processing + 1. capitalize the string + 2. remove leading or trailing spaces + 3. convert interior spaces or dashes to underscores + 4. optionally, remove a specified prefix from a string (using ``prefix``, which + should have a default assigned by the :func:`wntr.utils.enumtools.add_get` + function.) + + It will then try to get the member with the name corresponding to the converted + string. + + 5. optionally, if ``abbrev`` is True, then the string will be truncated to the first + letter, only, after trying to use the full string as passed in. The ``abbrev`` + parameter will have a default value based on how the :func:`~wntr.utils.enumtools.add_get` + decorator was called on this class. + + """ + if prefix is None: + prefix = '' + if abbrev is None: + abbrev = False + if allow_none is None: + allow_none = True + if cls is None: + return functools.partial(add_get, prefix=prefix, abbrev=abbrev, allow_none=allow_none) + + @functools.wraps(cls) + def wrap(cls, prefix, abbrev): + """Perform the decorator action""" + + def get(cls, value: Union[str, int, Enum], prefix='', abbrev=False, allow_none=True): + """Get the proper enum based on the name or value of the argument. + + See :func:`~wntr.utils.enumtools.add_get` for details on how this function works. + + + Parameters + ---------- + value : Union[str, int, Enum] + the value to be checked, if it is an Enum, then the name will be used + prefix : str, optional + a prefix to strip from the beginning of ``value``, default blank or set by decorator + abbrev : bool, optional + whether to try a single-letter version of ``value``, default False or set by decorator + allow_none : bool, optional + passing None will return None, otherwise will raise TypeError, default True or set by decorator + + Returns + ------- + Enum + the enum member that corresponds to the name or value passed in + + Raises + ------ + TypeError + if ``value`` is an invalid type + ValueError + if ``value`` is invalid + + """ + if value is None and allow_none: + return None + elif value is None: + raise TypeError('A value is mandatory, but got None') + name = str(value) + if isinstance(value, cls): + return value + elif isinstance(value, int): + return cls(value) + elif isinstance(value, str): + name = value.upper().strip().replace('-', '_').replace(' ', '_') + if name.startswith(prefix): + name = name[len(prefix):] + elif isinstance(value, Enum): + name = str(value.name).upper().strip().replace('-', '_').replace(' ', '_') + if name.startswith(prefix): + name = name[len(prefix):] + else: + raise TypeError('Invalid type for value: %s'%type(value)) + if abbrev: + try: + return cls[name] + except KeyError as e: + try: + return cls[name[0]] + except KeyError: + raise ValueError(repr(value)) from e + else: + try: + return cls[name] + except KeyError as e: + raise ValueError(repr(value)) from e + + setattr(cls, "get", classmethod(functools.partial(get, prefix=prefix, abbrev=abbrev))) + return cls + + return wrap(cls, prefix, abbrev) +