├── .git_archival.txt ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .readthedocs.yml ├── .zenodo.json ├── CHANGELOG.md ├── CITATION.bib ├── LICENSE ├── README.md ├── docs ├── Makefile └── source │ ├── _static │ └── custom.css │ ├── _templates │ ├── autosummary │ │ ├── class.rst │ │ └── module.rst │ └── layout.html │ ├── api.rst │ ├── changelog.rst │ ├── conf.py │ ├── contents.rst │ ├── index.rst │ └── pics │ ├── 01_pumptest.png │ ├── 01_wells.png │ ├── 02_fit.png │ ├── 02_parainter.png │ ├── 02_paratrace.png │ ├── WTP.ico │ ├── WTP.png │ └── WTP_150.png ├── examples ├── 01_create.py ├── 02_estimate.py ├── 03_estimate_hetero.py ├── 04_estimate_steady.py ├── 05_estimate_steady_het.py ├── 06_triangulate.py ├── 07_diagnostic_plot.py ├── 08_Cooper_Jacob.py └── README.rst ├── pyproject.toml ├── src └── welltestpy │ ├── __init__.py │ ├── data │ ├── __init__.py │ ├── campaignlib.py │ ├── data_io.py │ ├── testslib.py │ └── varlib.py │ ├── estimate │ ├── __init__.py │ ├── estimators.py │ ├── spotpylib.py │ ├── steady_lib.py │ └── transient_lib.py │ ├── process │ ├── __init__.py │ └── processlib.py │ └── tools │ ├── __init__.py │ ├── diagnostic_plots.py │ ├── plotter.py │ └── trilib.py └── tests ├── test_process.py └── test_welltestpy.py /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: 81a2299af80fa77285f0ed8e1e55aca48695e05f 2 | node-date: 2023-04-18T11:57:16+02:00 3 | describe-name: v1.2.0 4 | ref-names: HEAD -> main, tag: v1.2.0 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.txt export-subst 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | tags: 8 | - "*" 9 | pull_request: 10 | branches: 11 | - "main" 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | env: 16 | # needed by coveralls 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | jobs: 20 | source_check: 21 | name: source check 22 | runs-on: ubuntu-latest 23 | strategy: 24 | fail-fast: false 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - name: Set up Python 3.9 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: 3.9 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install --editable .[check] 38 | 39 | - name: black check 40 | run: | 41 | python -m black --check --diff --color . 42 | 43 | - name: isort check 44 | run: | 45 | python -m isort --check --diff --color . 46 | 47 | build_sdist: 48 | name: sdist on ${{ matrix.os }} with py ${{ matrix.python-version }} 49 | runs-on: ${{ matrix.os }} 50 | strategy: 51 | fail-fast: false 52 | matrix: 53 | os: [ubuntu-latest, windows-latest, macos-latest] 54 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 55 | 56 | steps: 57 | - uses: actions/checkout@v2 58 | with: 59 | fetch-depth: '0' 60 | 61 | - name: Set up Python ${{ matrix.python-version }} 62 | uses: actions/setup-python@v2 63 | with: 64 | python-version: ${{ matrix.python-version }} 65 | 66 | - name: Install dependencies 67 | run: | 68 | python -m pip install --upgrade pip 69 | pip install build coveralls>=3.0.0 70 | pip install --editable .[test] 71 | 72 | - name: Run tests 73 | run: | 74 | python -m pytest --cov welltestpy --cov-report term-missing -v tests/ 75 | python -m coveralls --service=github 76 | 77 | - name: Build sdist 78 | run: | 79 | python -m build 80 | 81 | - uses: actions/upload-artifact@v2 82 | if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.9' 83 | with: 84 | path: dist 85 | 86 | upload_to_pypi: 87 | needs: [build_sdist] 88 | runs-on: ubuntu-latest 89 | 90 | steps: 91 | - uses: actions/download-artifact@v2 92 | with: 93 | name: artifact 94 | path: dist 95 | 96 | - name: Publish to Test PyPI 97 | # only if working on main 98 | if: github.ref == 'refs/heads/main' 99 | uses: pypa/gh-action-pypi-publish@release/v1 100 | with: 101 | user: __token__ 102 | password: ${{ secrets.test_pypi_password }} 103 | repository_url: https://test.pypi.org/legacy/ 104 | skip_existing: true 105 | 106 | - name: Publish to PyPI 107 | # only if tagged 108 | if: startsWith(github.ref, 'refs/tags') 109 | uses: pypa/gh-action-pypi-publish@release/v1 110 | with: 111 | user: __token__ 112 | password: ${{ secrets.pypi_password }} 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | docs/output.txt 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # IDE project settings 92 | .spyderproject 93 | .spyproject 94 | .vscode 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | 105 | tags 106 | /test_* 107 | 108 | # own stuff 109 | info/ 110 | 111 | # Cython generated C code 112 | *.c 113 | *.cpp 114 | 115 | 116 | # generated docs 117 | docs/source/examples/ 118 | docs/source/api/ 119 | Cmp_UFZ-campaign.cmp 120 | 121 | # generated version file 122 | src/welltestpy/_version.py 123 | 124 | *.DS_Store 125 | 126 | *.zip 127 | 128 | *.vtu 129 | *.vtr 130 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/source/conf.py 10 | 11 | formats: all 12 | 13 | python: 14 | install: 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - doc 19 | -------------------------------------------------------------------------------- /.zenodo.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "language": "eng", 4 | "keywords": [ 5 | "Groundwater flow equation", 6 | "Groundwater", 7 | "Pumping test", 8 | "Pump test", 9 | "Aquifer analysis", 10 | "Python", 11 | "GeoStat-Framework" 12 | ], 13 | "creators": [ 14 | { 15 | "orcid": "0000-0001-9060-4008", 16 | "affiliation": "Helmholtz Centre for Environmental Research - UFZ", 17 | "name": "Sebastian M\u00fcller" 18 | }, 19 | { 20 | "affiliation": "Utrecht University - The Netherlands", 21 | "name": "Jarno Herrmann" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to **welltestpy** will be documented in this file. 4 | 5 | 6 | ## [1.2.0] - 2023-04 7 | 8 | See [#28](https://github.com/GeoStat-Framework/welltestpy/pull/28), [#31](https://github.com/GeoStat-Framework/welltestpy/pull/31) and [#32](https://github.com/GeoStat-Framework/welltestpy/pull/32) 9 | 10 | ### Enhancements 11 | 12 | - added archive support 13 | - simplify documentation 14 | - new arguments `val_fit_type` and `val_fit_name` for all estimators to select fitting transformation 15 | - `val_fit_name` will be incorporated into the generated plots and the header of the estimation result file 16 | 17 | ### Changes 18 | 19 | - move to `src/` based package structure 20 | - use [hatchling](https://pypi.org/project/hatchling/) as build backend 21 | - drop py36 support 22 | - value names for all arguments in the estimators now need to match the call signatures of the used type-curves 23 | 24 | ### Bugfixes 25 | 26 | - minor fixes for the plotting routines and the estimators 27 | 28 | 29 | ## [1.1.0] - 2021-07 30 | 31 | ### Enhancements 32 | - added `cooper_jacob_correction` to `process` (thanks to Jarno Herrmann) 33 | - added `diagnostic_plots` module (thanks to Jarno Herrmann) 34 | - added `screensize`, `screen`, `aquifer` and `is_piezometer` attribute to `Well` class 35 | - added version information to output files 36 | - added `__repr__` to `Campaign` 37 | 38 | ### Changes 39 | - modernized packaging workflow using `pyproject.toml` 40 | - removed `setup.py` (use `pip>21.1` for editable installs) 41 | - removed `dev` as extra install dependencies 42 | - better exceptions in loading routines 43 | - removed pandas dependency 44 | - simplified readme 45 | 46 | ### Bugfixes 47 | - loading steady pumping tests was not possible due to a bug 48 | 49 | 50 | ## [1.0.3] - 2021-02 51 | 52 | ### Enhancements 53 | - Estimations: run method now provides `plot_style` keyword to control plotting 54 | 55 | ### Changes 56 | - Fit plot style for transient pumping tests was updated 57 | 58 | ### Bugfixes 59 | - Estimations: run method was throwing an Error when setting `run=False` 60 | - Plotter: all plotting routines now respect setted font-type from matplotlib 61 | 62 | 63 | ## [1.0.2] - 2020-09-03 64 | 65 | ### Bugfixes 66 | - `StdyHeadObs` and `StdyObs` weren't usable due to an unnecessary `time` check 67 | 68 | 69 | ## [1.0.1] - 2020-04-09 70 | 71 | ### Bugfixes 72 | - Wrong URL in setup 73 | 74 | 75 | ## [1.0.0] - 2020-04-09 76 | 77 | ### Enhancements 78 | - new estimators 79 | - ExtTheis3D 80 | - ExtTheis2D 81 | - Neuman2004 82 | - Theis 83 | - ExtThiem3D 84 | - ExtThiem2D 85 | - Neuman2004Steady 86 | - Thiem 87 | - better plotting 88 | - unit-tests run with py35-py38 on Linux/Win/Mac 89 | - coverage calculation 90 | - sphinx gallery for examples 91 | - allow style setting in plotting routines 92 | 93 | ### Bugfixes 94 | - estimation results stored as dict (order could alter before) 95 | 96 | ### Changes 97 | - py2 support dropped 98 | - `Fieldsite.coordinates` now returns a `Variable`; `Fieldsite.pos` as shortcut 99 | - `Fieldsite.pumpingrate` now returns a `Variable`; `Fieldsite.rate` as shortcut 100 | - `Fieldsite.auqiferradius` now returns a `Variable`; `Fieldsite.radius` as shortcut 101 | - `Fieldsite.auqiferdepth` now returns a `Variable`; `Fieldsite.depth` as shortcut 102 | - `Well.coordinates` now returns a `Variable`; `Well.pos` as shortcut 103 | - `Well.welldepth` now returns a `Variable`; `Well.depth` as shortcut 104 | - `Well.wellradius` added and returns the radius `Variable` 105 | - `Well.aquiferdepth` now returns a `Variable` 106 | - `Fieldsite.addobservations` renamed to `Fieldsite.add_observations` 107 | - `Fieldsite.delobservations` renamed to `Fieldsite.del_observations` 108 | - `Observation` has changed order of inputs/outputs. Now: `observation`, `time` 109 | 110 | 111 | ## [0.3.2] - 2019-03-08 112 | 113 | ### Bugfixes 114 | - adopt AnaFlow API 115 | 116 | 117 | ## [0.3.1] - 2019-03-08 118 | 119 | ### Bugfixes 120 | - update travis workflow 121 | 122 | 123 | ## [0.3.0] - 2019-02-28 124 | 125 | ### Enhancements 126 | - added documentation 127 | 128 | 129 | ## [0.2.0] - 2018-04-25 130 | 131 | ### Enhancements 132 | - added license 133 | 134 | 135 | ## [0.1.0] - 2018-04-25 136 | 137 | First alpha release of welltespy. 138 | 139 | [Unreleased]: https://github.com/GeoStat-Framework/welltestpy/compare/v1.2.0...HEAD 140 | [1.2.0]: https://github.com/GeoStat-Framework/welltestpy/compare/v1.1.0...v1.2.0 141 | [1.1.0]: https://github.com/GeoStat-Framework/welltestpy/compare/v1.0.3...v1.1.0 142 | [1.0.3]: https://github.com/GeoStat-Framework/welltestpy/compare/v1.0.2...v1.0.3 143 | [1.0.2]: https://github.com/GeoStat-Framework/welltestpy/compare/v1.0.1...v1.0.2 144 | [1.0.1]: https://github.com/GeoStat-Framework/welltestpy/compare/v1.0.0...v1.0.1 145 | [1.0.0]: https://github.com/GeoStat-Framework/welltestpy/compare/v0.3.2...v1.0.0 146 | [0.3.2]: https://github.com/GeoStat-Framework/welltestpy/compare/v0.3.1...v0.3.2 147 | [0.3.1]: https://github.com/GeoStat-Framework/welltestpy/compare/v0.3.0...v0.3.1 148 | [0.3.0]: https://github.com/GeoStat-Framework/welltestpy/compare/v0.2...v0.3.0 149 | [0.2.0]: https://github.com/GeoStat-Framework/welltestpy/compare/v0.1...v0.2 150 | [0.1.0]: https://github.com/GeoStat-Framework/welltestpy/releases/tag/v0.1 151 | -------------------------------------------------------------------------------- /CITATION.bib: -------------------------------------------------------------------------------- 1 | @article{muller_how_2021, 2 | author = {Müller, Sebastian and Leven, Carsten and Dietrich, Peter and Attinger, Sabine and Zech, Alraune}, 3 | title = {How to Find Aquifer Statistics Utilizing Pumping Tests? Two Field Studies Using welltestpy}, 4 | journal = {Groundwater}, 5 | volume = {60}, 6 | number = {1}, 7 | pages = {137-144}, 8 | url = {https://ngwa.onlinelibrary.wiley.com/doi/abs/10.1111/gwat.13121}, 9 | doi = {10.1111/gwat.13121}, 10 | year = {2021}, 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Sebastian Müller, Jarno Herrmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to welltestpy 2 | 3 | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.1229051.svg)](https://doi.org/10.5281/zenodo.1229051) 4 | [![PyPI version](https://badge.fury.io/py/welltestpy.svg)](https://badge.fury.io/py/welltestpy) 5 | [![Build Status](https://github.com/GeoStat-Framework/welltestpy/workflows/Continuous%20Integration/badge.svg?branch=main)](https://github.com/GeoStat-Framework/welltestpy/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/GeoStat-Framework/welltestpy/badge.svg?branch=main)](https://coveralls.io/github/GeoStat-Framework/welltestpy?branch=main) 7 | [![Documentation Status](https://readthedocs.org/projects/welltestpy/badge/?version=latest)](https://geostat-framework.readthedocs.io/projects/welltestpy/en/latest/?badge=latest) 8 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 9 | 10 |

11 | welltestpy-LOGO 12 |

13 | 14 | ## Purpose 15 | 16 | welltestpy provides a framework to handle, process, plot and analyse data from well based field campaigns. 17 | 18 | 19 | ## Installation 20 | 21 | You can install the latest version with the following command: 22 | 23 | pip install welltestpy 24 | 25 | Or from conda 26 | 27 | conda install -c conda-forge welltestpy 28 | 29 | 30 | ## Documentation for welltestpy 31 | 32 | You can find the documentation including tutorials and examples under 33 | https://welltestpy.readthedocs.io. 34 | 35 | 36 | ## Citing welltestpy 37 | 38 | If you are using this package you can cite our 39 | [Groundwater publication](https://doi.org/10.1111/gwat.13121) by: 40 | 41 | > Müller, S., Leven, C., Dietrich, P., Attinger, S. and Zech, A. (2021): 42 | > How to Find Aquifer Statistics Utilizing Pumping Tests? Two Field Studies Using welltestpy. 43 | > Groundwater, https://doi.org/10.1111/gwat.13121 44 | 45 | To cite the code, please visit the [Zenodo page](https://doi.org/10.5281/zenodo.1229051). 46 | 47 | 48 | ## Provided Subpackages 49 | 50 | ```python 51 | welltestpy.data # Subpackage to handle data from field campaigns 52 | welltestpy.estimate # Subpackage to estimate field parameters 53 | welltestpy.process # Subpackage to pre- and post-process data 54 | welltestpy.tools # Subpackage with tools for plotting and triagulation 55 | ``` 56 | 57 | 58 | ## Requirements 59 | 60 | - [NumPy >= 1.14.5](https://www.numpy.org) 61 | - [SciPy >= 1.1.0](https://www.scipy.org) 62 | - [AnaFlow >= 1.0.0](https://github.com/GeoStat-Framework/AnaFlow) 63 | - [SpotPy >= 1.5.0](https://github.com/thouska/spotpy) 64 | - [Matplotlib >= 3.0.0](https://matplotlib.org) 65 | 66 | 67 | ## Contact 68 | 69 | You can contact us via . 70 | 71 | 72 | ## License 73 | 74 | [MIT](https://github.com/GeoStat-Framework/welltestpy/blob/main/LICENSE) 75 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python3 -msphinx 7 | SPHINXPROJ = welltestpy 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/_static/custom.css: -------------------------------------------------------------------------------- 1 | dl.py.property { 2 | display: block !important; 3 | } 4 | -------------------------------------------------------------------------------- /docs/source/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | :members: 7 | :undoc-members: 8 | :inherited-members: 9 | :show-inheritance: 10 | 11 | .. raw:: latex 12 | 13 | \clearpage 14 | -------------------------------------------------------------------------------- /docs/source/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ fullname }} 4 | 5 | .. automodule:: {{ fullname }} 6 | 7 | .. raw:: latex 8 | 9 | \clearpage 10 | -------------------------------------------------------------------------------- /docs/source/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | {% block menu %} 3 | 4 | {{ super() }} 5 |
6 | 7 | 12 |
13 | 14 | 21 |
22 |
23 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | welltestpy API 3 | ============== 4 | 5 | .. automodule:: welltestpy 6 | 7 | .. raw:: latex 8 | 9 | \clearpage 10 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../../CHANGELOG.md 2 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # welltestpy documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jan 5 14:20:43 2018. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # NOTE: 20 | # pip install sphinx_rtd_theme 21 | # is needed in order to build the documentation 22 | import datetime 23 | import warnings 24 | 25 | warnings.filterwarnings( 26 | "ignore", 27 | category=UserWarning, 28 | message="Matplotlib is currently using agg, which is a non-GUI backend, so cannot show the figure.", 29 | ) 30 | 31 | from welltestpy import __version__ as ver 32 | 33 | 34 | def skip(app, what, name, obj, skip, options): 35 | if name in ["__call__"]: 36 | return False 37 | return skip 38 | 39 | 40 | def setup(app): 41 | app.connect("autodoc-skip-member", skip) 42 | 43 | 44 | # -- General configuration ------------------------------------------------ 45 | 46 | # If your documentation needs a minimal Sphinx version, state it here. 47 | # 48 | # needs_sphinx = '1.0' 49 | 50 | # Add any Sphinx extension module names here, as strings. They can be 51 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 52 | # ones. 53 | extensions = [ 54 | "sphinx.ext.autodoc", 55 | "sphinx.ext.doctest", 56 | "sphinx.ext.intersphinx", 57 | "sphinx.ext.coverage", 58 | "sphinx.ext.mathjax", 59 | "sphinx.ext.ifconfig", 60 | "sphinx.ext.viewcode", 61 | "sphinx.ext.autosummary", 62 | "sphinx.ext.napoleon", # parameters look better than with numpydoc only 63 | "numpydoc", 64 | "sphinx_gallery.gen_gallery", 65 | "m2r2", 66 | ] 67 | 68 | # autosummaries from source-files 69 | autosummary_generate = True 70 | # dont show __init__ docstring 71 | autoclass_content = "class" 72 | # sort class members 73 | autodoc_member_order = "groupwise" 74 | # autodoc_member_order = 'bysource' 75 | 76 | # don't add full path to module 77 | add_module_names = False 78 | 79 | # Notes in boxes 80 | napoleon_use_admonition_for_notes = True 81 | # Attributes like parameters 82 | napoleon_use_ivar = True 83 | # keep "Other Parameters" section 84 | # https://github.com/sphinx-doc/sphinx/issues/10330 85 | napoleon_use_param = False 86 | # this is a nice class-doc layout 87 | numpydoc_show_class_members = True 88 | # class members have no separate file, so they are not in a toctree 89 | numpydoc_class_members_toctree = False 90 | # for the covmodels alot of classmembers show up... 91 | numpydoc_show_inherited_class_members = True 92 | # Add any paths that contain templates here, relative to this directory. 93 | templates_path = ["_templates"] 94 | 95 | # The suffix(es) of source filenames. 96 | # You can specify multiple suffix as a list of string: 97 | source_suffix = [".rst", ".md"] 98 | # source_suffix = ".rst" 99 | 100 | # The master toctree document. 101 | # --> this is the sitemap (or content-list in latex -> needs a heading) 102 | # for html: the quickstart (in index.rst) 103 | # gets the "index.html" and is therefore opened first 104 | master_doc = "contents" 105 | 106 | # General information about the project. 107 | curr_year = datetime.datetime.now().year 108 | project = "welltestpy" 109 | copyright = "2018 - {}, Sebastian Müller, Jarno Herrmann".format(curr_year) 110 | author = "Sebastian Müller, Jarno Herrmann" 111 | 112 | # The version info for the project you're documenting, acts as replacement for 113 | # |version| and |release|, also used in various other places throughout the 114 | # built documents. 115 | # 116 | # The short X.Y version. 117 | version = ver 118 | # The full version, including alpha/beta/rc tags. 119 | release = ver 120 | 121 | # The language for content autogenerated by Sphinx. Refer to documentation 122 | # for a list of supported languages. 123 | # 124 | # This is also used if you do content translation via gettext catalogs. 125 | # Usually you set "language" from the command line for these cases. 126 | language = "en" 127 | 128 | # List of patterns, relative to source directory, that match files and 129 | # directories to ignore when looking for source files. 130 | # This patterns also effect to html_static_path and html_extra_path 131 | exclude_patterns = [] 132 | 133 | # The name of the Pygments (syntax highlighting) style to use. 134 | pygments_style = "sphinx" 135 | 136 | # -- Options for HTML output ---------------------------------------------- 137 | 138 | # The theme to use for HTML and HTML Help pages. See the documentation for 139 | # a list of builtin themes. 140 | # 141 | html_theme = "sphinx_rtd_theme" 142 | 143 | # Theme options are theme-specific and customize the look and feel of a theme 144 | # further. For a list of options available for each theme, see the 145 | # documentation. 146 | # 147 | html_theme_options = { 148 | # 'canonical_url': '', 149 | # 'analytics_id': '', 150 | "logo_only": False, 151 | "display_version": True, 152 | "prev_next_buttons_location": "top", 153 | # 'style_external_links': False, 154 | # 'vcs_pageview_mode': '', 155 | # Toc options 156 | "collapse_navigation": False, 157 | "sticky_navigation": True, 158 | "navigation_depth": 6, 159 | "includehidden": True, 160 | "titles_only": False, 161 | } 162 | # Add any paths that contain custom static files (such as style sheets) here, 163 | # relative to this directory. They are copied after the builtin static files, 164 | # so a file named "default.css" will overwrite the builtin "default.css". 165 | html_static_path = ["_static"] 166 | 167 | # These paths are either relative to html_static_path 168 | # or fully qualified paths (eg. https://...) 169 | html_css_files = ["custom.css"] 170 | 171 | # Custom sidebar templates, must be a dictionary that maps document names 172 | # to template names. 173 | # 174 | # This is required for the alabaster theme 175 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 176 | html_sidebars = { 177 | "**": [ 178 | "relations.html", # needs 'show_related': True theme option to display 179 | "searchbox.html", 180 | ] 181 | } 182 | 183 | 184 | # -- Options for HTMLHelp output ------------------------------------------ 185 | 186 | # Output file base name for HTML help builder. 187 | htmlhelp_basename = "welltestpydoc" 188 | # logos for the page 189 | html_logo = "pics/WTP_150.png" 190 | html_favicon = "pics/WTP.ico" 191 | 192 | 193 | # -- Options for LaTeX output --------------------------------------------- 194 | # latex_engine = 'lualatex' 195 | # logo to big 196 | latex_logo = "pics/WTP_150.png" 197 | 198 | # latex_show_urls = 'footnote' 199 | # http://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-latex-output 200 | latex_elements = { 201 | "preamble": r""" 202 | \setcounter{secnumdepth}{1} 203 | \setcounter{tocdepth}{2} 204 | \pagestyle{fancy} 205 | """, 206 | "pointsize": "10pt", 207 | "papersize": "a4paper", 208 | "fncychap": "\\usepackage[Glenn]{fncychap}", 209 | } 210 | 211 | # Grouping the document tree into LaTeX files. List of tuples 212 | # (source start file, target name, title, 213 | # author, documentclass [howto, manual, or own class]). 214 | latex_documents = [ 215 | ( 216 | master_doc, 217 | "welltestpy.tex", 218 | "welltestpy Documentation", 219 | author, 220 | "manual", 221 | ) 222 | ] 223 | # latex_use_parts = True 224 | 225 | # -- Options for manual page output --------------------------------------- 226 | 227 | # One entry per manual page. List of tuples 228 | # (source start file, name, description, authors, manual section). 229 | man_pages = [ 230 | (master_doc, "welltestpy", "welltestpy Documentation", [author], 1) 231 | ] 232 | 233 | 234 | # -- Options for Texinfo output ------------------------------------------- 235 | 236 | # Grouping the document tree into Texinfo files. List of tuples 237 | # (source start file, target name, title, author, 238 | # dir menu entry, description, category) 239 | texinfo_documents = [ 240 | ( 241 | master_doc, 242 | "welltestpy", 243 | "welltestpy Documentation", 244 | author, 245 | "welltestpy", 246 | "Analytical solutions for the groundwater flow equation", 247 | "Miscellaneous", 248 | ) 249 | ] 250 | 251 | suppress_warnings = [ 252 | "image.nonlocal_uri", 253 | # 'app.add_directive', # this evtl. suppresses the numpydoc induced warning 254 | ] 255 | 256 | # Example configuration for intersphinx: refer to the Python standard library. 257 | intersphinx_mapping = { 258 | "Python": ("https://docs.python.org/", None), 259 | "NumPy": ("http://docs.scipy.org/doc/numpy/", None), 260 | "SciPy": ("http://docs.scipy.org/doc/scipy/reference", None), 261 | "matplotlib": ("http://matplotlib.org", None), 262 | "Sphinx": ("http://www.sphinx-doc.org/en/stable/", None), 263 | } 264 | 265 | # -- Sphinx Gallery Options 266 | from sphinx_gallery.sorting import FileNameSortKey 267 | 268 | sphinx_gallery_conf = { 269 | # only show "print" output as output 270 | "capture_repr": (), 271 | # path to your examples scripts 272 | "examples_dirs": ["../../examples"], 273 | # path where to save gallery generated examples 274 | "gallery_dirs": ["examples"], 275 | # Pattern to search for example files 276 | "filename_pattern": "/.*.py", 277 | "ignore_pattern": r"03_estimate_hetero\.py", 278 | # Remove the "Download all examples" button from the top level gallery 279 | "download_all_examples": False, 280 | # Sort gallery example by file name instead of number of lines (default) 281 | "within_subsection_order": FileNameSortKey, 282 | # directory where function granular galleries are stored 283 | "backreferences_dir": None, 284 | # Modules for which function level galleries are created. In 285 | "doc_module": "welltestpy", 286 | # "image_scrapers": ('pyvista', 'matplotlib'), 287 | # "first_notebook_cell": ("%matplotlib inline\n" 288 | # "from pyvista import set_plot_theme\n" 289 | # "set_plot_theme('document')"), 290 | } 291 | -------------------------------------------------------------------------------- /docs/source/contents.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contents 3 | ======== 4 | 5 | .. toctree:: 6 | :includehidden: 7 | :maxdepth: 5 8 | 9 | index 10 | examples/index 11 | api 12 | changelog 13 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. mdinclude:: ../../README.md 2 | -------------------------------------------------------------------------------- /docs/source/pics/01_pumptest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoStat-Framework/welltestpy/81a2299af80fa77285f0ed8e1e55aca48695e05f/docs/source/pics/01_pumptest.png -------------------------------------------------------------------------------- /docs/source/pics/01_wells.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoStat-Framework/welltestpy/81a2299af80fa77285f0ed8e1e55aca48695e05f/docs/source/pics/01_wells.png -------------------------------------------------------------------------------- /docs/source/pics/02_fit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoStat-Framework/welltestpy/81a2299af80fa77285f0ed8e1e55aca48695e05f/docs/source/pics/02_fit.png -------------------------------------------------------------------------------- /docs/source/pics/02_parainter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoStat-Framework/welltestpy/81a2299af80fa77285f0ed8e1e55aca48695e05f/docs/source/pics/02_parainter.png -------------------------------------------------------------------------------- /docs/source/pics/02_paratrace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoStat-Framework/welltestpy/81a2299af80fa77285f0ed8e1e55aca48695e05f/docs/source/pics/02_paratrace.png -------------------------------------------------------------------------------- /docs/source/pics/WTP.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoStat-Framework/welltestpy/81a2299af80fa77285f0ed8e1e55aca48695e05f/docs/source/pics/WTP.ico -------------------------------------------------------------------------------- /docs/source/pics/WTP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoStat-Framework/welltestpy/81a2299af80fa77285f0ed8e1e55aca48695e05f/docs/source/pics/WTP.png -------------------------------------------------------------------------------- /docs/source/pics/WTP_150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GeoStat-Framework/welltestpy/81a2299af80fa77285f0ed8e1e55aca48695e05f/docs/source/pics/WTP_150.png -------------------------------------------------------------------------------- /examples/01_create.py: -------------------------------------------------------------------------------- 1 | """ 2 | Creating a pumping test campaign 3 | -------------------------------- 4 | 5 | In the following we are going to create an artificial pumping test campaign 6 | on a field site. 7 | """ 8 | 9 | import anaflow as ana 10 | import numpy as np 11 | 12 | import welltestpy as wtp 13 | 14 | ############################################################################### 15 | # Create the field-site and the campaign 16 | 17 | field = wtp.FieldSite(name="UFZ", coordinates=[51.353839, 12.431385]) 18 | campaign = wtp.Campaign(name="UFZ-campaign", fieldsite=field) 19 | 20 | ############################################################################### 21 | # Add 4 wells to the campaign 22 | 23 | campaign.add_well(name="well_0", radius=0.1, coordinates=(0.0, 0.0)) 24 | campaign.add_well(name="well_1", radius=0.1, coordinates=(1.0, -1.0)) 25 | campaign.add_well(name="well_2", radius=0.1, coordinates=(2.0, 2.0)) 26 | campaign.add_well(name="well_3", radius=0.1, coordinates=(-2.0, -1.0)) 27 | 28 | ############################################################################### 29 | # Generate artificial drawdown data with the Theis solution 30 | 31 | rate = -1e-4 32 | time = np.geomspace(10, 7200, 10) 33 | transmissivity = 1e-4 34 | storage = 1e-4 35 | rad = [ 36 | campaign.wells["well_0"].radius, # well radius of well_0 37 | campaign.wells["well_0"] - campaign.wells["well_1"], # distance 0-1 38 | campaign.wells["well_0"] - campaign.wells["well_2"], # distance 0-2 39 | campaign.wells["well_0"] - campaign.wells["well_3"], # distance 0-3 40 | ] 41 | drawdown = ana.theis( 42 | time=time, 43 | rad=rad, 44 | storage=storage, 45 | transmissivity=transmissivity, 46 | rate=rate, 47 | ) 48 | 49 | ############################################################################### 50 | # Create a pumping test at well_0 51 | 52 | pumptest = wtp.PumpingTest( 53 | name="well_0", 54 | pumpingwell="well_0", 55 | pumpingrate=rate, 56 | description="Artificial pump test with Theis", 57 | ) 58 | 59 | ############################################################################### 60 | # Add the drawdown observation at the 4 wells 61 | 62 | pumptest.add_transient_obs("well_0", time, drawdown[:, 0]) 63 | pumptest.add_transient_obs("well_1", time, drawdown[:, 1]) 64 | pumptest.add_transient_obs("well_2", time, drawdown[:, 2]) 65 | pumptest.add_transient_obs("well_3", time, drawdown[:, 3]) 66 | 67 | ############################################################################### 68 | # Add the pumping test to the campaign 69 | 70 | campaign.addtests(pumptest) 71 | # optionally make the test (quasi)steady 72 | # campaign.tests["well_0"].make_steady() 73 | 74 | ############################################################################### 75 | # Plot the well constellation and a test overview 76 | campaign.plot_wells() 77 | campaign.plot() 78 | 79 | ############################################################################### 80 | # Save the whole campaign to a file 81 | 82 | campaign.save() 83 | -------------------------------------------------------------------------------- /examples/02_estimate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Estimate homogeneous parameters 3 | ------------------------------- 4 | 5 | Here we estimate transmissivity and storage from a pumping test campaign 6 | with the classical theis solution. 7 | """ 8 | 9 | import welltestpy as wtp 10 | 11 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 12 | estimation = wtp.estimate.Theis("Estimate_theis", campaign, generate=True) 13 | estimation.run() 14 | 15 | ############################################################################### 16 | # In addition, we run a sensitivity analysis, to get an impression 17 | # of the impact of each parameter 18 | 19 | estimation.sensitivity() 20 | -------------------------------------------------------------------------------- /examples/03_estimate_hetero.py: -------------------------------------------------------------------------------- 1 | """ 2 | Estimate heterogeneous parameters 3 | --------------------------------- 4 | 5 | Here we demonstrate how to estimate parameters of heterogeneity, namely 6 | mean, variance and correlation length of log-transmissivity, as well as the 7 | storage with the aid the the extended Theis solution in 2D. 8 | """ 9 | 10 | import welltestpy as wtp 11 | 12 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 13 | estimation = wtp.estimate.ExtTheis2D("Estimate_het2D", campaign, generate=True) 14 | estimation.run() 15 | estimation.sensitivity() 16 | -------------------------------------------------------------------------------- /examples/04_estimate_steady.py: -------------------------------------------------------------------------------- 1 | """ 2 | Estimate steady homogeneous parameters 3 | -------------------------------------- 4 | 5 | Here we estimate transmissivity from the quasi steady state of 6 | a pumping test campaign with the classical thiem solution. 7 | """ 8 | 9 | import welltestpy as wtp 10 | 11 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 12 | estimation = wtp.estimate.Thiem("Estimate_thiem", campaign, generate=True) 13 | estimation.run() 14 | 15 | ############################################################################### 16 | # since we only have one parameter, 17 | # we need a dummy parameter to estimate sensitivity 18 | 19 | estimation.gen_setup(dummy=True) 20 | estimation.sensitivity() 21 | -------------------------------------------------------------------------------- /examples/05_estimate_steady_het.py: -------------------------------------------------------------------------------- 1 | """ 2 | Estimate steady heterogeneous parameters 3 | ---------------------------------------- 4 | 5 | Here we demonstrate how to estimate parameters of heterogeneity, namely 6 | mean, variance and correlation length of log-transmissivity, 7 | with the aid the the extended Thiem solution in 2D. 8 | """ 9 | 10 | import welltestpy as wtp 11 | 12 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 13 | estimation = wtp.estimate.ExtThiem2D("Est_steady_het", campaign, generate=True) 14 | estimation.run() 15 | estimation.sensitivity() 16 | -------------------------------------------------------------------------------- /examples/06_triangulate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Point triangulation 3 | ------------------- 4 | 5 | Often, we only know the distances between wells within a well base field campaign. 6 | To retrieve their spatial positions, we provide a routine, that triangulates 7 | their positions from a given distance matrix. 8 | 9 | If the solution is not unique, all possible constellations will be returned. 10 | """ 11 | 12 | import numpy as np 13 | 14 | from welltestpy.tools import plot_well_pos, sym, triangulate 15 | 16 | dist_mat = np.zeros((4, 4), dtype=float) 17 | dist_mat[0, 1] = 3 # distance between well 0 and 1 18 | dist_mat[0, 2] = 4 # distance between well 0 and 2 19 | dist_mat[1, 2] = 2 # distance between well 1 and 2 20 | dist_mat[0, 3] = 1 # distance between well 0 and 3 21 | dist_mat[1, 3] = 3 # distance between well 1 and 3 22 | dist_mat[2, 3] = -1 # unknown distance between well 2 and 3 23 | dist_mat = sym(dist_mat) # make the distance matrix symmetric 24 | well_const = triangulate(dist_mat, prec=0.1) 25 | 26 | ############################################################################### 27 | # Now we can plot all possible well constellations 28 | 29 | plot_well_pos(well_const) 30 | -------------------------------------------------------------------------------- /examples/07_diagnostic_plot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Diagnostic plot 3 | ------------------- 4 | 5 | A diagnostic plot is a simultaneous plot of the drawdown and the 6 | logarithmic derivative of the drawdown in a log-log plot. 7 | Often, this plot is used to identify the right approach for the aquifer estimations. 8 | 9 | """ 10 | 11 | 12 | import welltestpy as wtp 13 | 14 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 15 | campaign.diagnostic_plot("well_0", "well_1") 16 | -------------------------------------------------------------------------------- /examples/08_Cooper_Jacob.py: -------------------------------------------------------------------------------- 1 | """ 2 | Correcting drawdown: The Cooper-Jacob method 3 | --------------------------------------------- 4 | 5 | Here we demonstrate the correction established by Cooper and Jacob in 1946. 6 | This method corrects drawdown data for the reduction in saturated thickness 7 | resulting from groundwater withdrawal by a pumping well and thereby enables 8 | pumping tests in an unconfined aquifer to be interpreted by methods for 9 | confined aquifers. 10 | """ 11 | 12 | import welltestpy as wtp 13 | 14 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 15 | campaign.tests["well_0"].correct_observations() 16 | campaign.plot() 17 | -------------------------------------------------------------------------------- /examples/README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | welltestpy Tutorial 3 | =================== 4 | 5 | In the following you will find several Tutorials on how to use welltestpy to 6 | explore its whole beauty and power. 7 | 8 | Gallery 9 | ======= 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "hatchling>=1.8.0", 4 | "hatch-vcs", 5 | ] 6 | build-backend = "hatchling.build" 7 | 8 | [project] 9 | requires-python = ">=3.7" 10 | name = "welltestpy" 11 | description = "welltestpy - package to handle well-based Field-campaigns." 12 | authors = [{name = "Sebastian Müller, Jarno Herrmann", email = "info@geostat-framework.org"}] 13 | readme = "README.md" 14 | license = "MIT" 15 | dynamic = ["version"] 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "Intended Audience :: End Users/Desktop", 20 | "Intended Audience :: Science/Research", 21 | "License :: OSI Approved :: MIT License", 22 | "Natural Language :: English", 23 | "Operating System :: MacOS", 24 | "Operating System :: MacOS :: MacOS X", 25 | "Operating System :: Microsoft", 26 | "Operating System :: Microsoft :: Windows", 27 | "Operating System :: POSIX", 28 | "Operating System :: Unix", 29 | "Programming Language :: Python", 30 | "Programming Language :: Python :: 3", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Programming Language :: Python :: 3.6", 33 | "Programming Language :: Python :: 3.7", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3.9", 36 | "Topic :: Scientific/Engineering", 37 | "Topic :: Software Development", 38 | "Topic :: Utilities", 39 | ] 40 | dependencies = [ 41 | "anaflow>=1.0.0", 42 | "matplotlib>=3.0.0", 43 | "numpy>=1.14.5", 44 | "scipy>=1.1.0", 45 | "spotpy>=1.5.0", 46 | "packaging>=20", 47 | ] 48 | 49 | [project.optional-dependencies] 50 | doc = [ 51 | "m2r2>=0.2.8", 52 | "numpydoc>=1.1", 53 | "sphinx>=4", 54 | "sphinx-gallery>=0.8", 55 | "sphinx-rtd-theme>=1,<1.1", 56 | ] 57 | test = ["pytest-cov>=3"] 58 | check = [ 59 | "black>=23,<24", 60 | "isort[colors]<6", 61 | "pylint<3", 62 | ] 63 | 64 | [project.urls] 65 | Homepage = "https://github.com/GeoStat-Framework/welltestpy" 66 | Documentation = "https://welltestpy.readthedocs.io" 67 | Source = "https://github.com/GeoStat-Framework/welltestpy" 68 | Tracker = "https://github.com/GeoStat-Framework/welltestpy/issues" 69 | Changelog = "https://github.com/GeoStat-Framework/welltestpy/blob/main/CHANGELOG.md" 70 | Conda-Forge = "https://anaconda.org/conda-forge/welltestpy" 71 | 72 | [tool.hatch.version] 73 | source = "vcs" 74 | fallback_version = "0.0.0.dev0" 75 | 76 | [tool.hatch.version.raw-options] 77 | local_scheme = "no-local-version" 78 | 79 | [tool.hatch.build.hooks.vcs] 80 | version-file = "src/welltestpy/_version.py" 81 | template = "__version__ = '{version}'" 82 | 83 | [tool.hatch.build.targets.sdist] 84 | include = [ 85 | "/src", 86 | "/tests", 87 | ] 88 | 89 | [tool.hatch.build.targets.wheel] 90 | packages = ["src/welltestpy"] 91 | 92 | [tool.isort] 93 | profile = "black" 94 | multi_line_output = 3 95 | line_length = 79 96 | 97 | [tool.black] 98 | exclude = "_version.py" 99 | line-length = 79 100 | target-version = ["py37"] 101 | 102 | [tool.coverage] 103 | [tool.coverage.run] 104 | source = ["welltestpy"] 105 | omit = [ 106 | "*docs*", 107 | "*examples*", 108 | "*tests*", 109 | ] 110 | 111 | [tool.coverage.report] 112 | exclude_lines = [ 113 | "pragma: no cover", 114 | "if __name__ == '__main__':", 115 | "def __repr__", 116 | "def __str__", 117 | ] 118 | -------------------------------------------------------------------------------- /src/welltestpy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | welltestpy - a Python package to handle well-based Field-campaigns. 3 | 4 | welltestpy provides a framework to handle and plot data from well based 5 | field campaigns as well as a parameter estimation module. 6 | 7 | Subpackages 8 | ^^^^^^^^^^^ 9 | 10 | .. autosummary:: 11 | :toctree: api 12 | 13 | data 14 | estimate 15 | process 16 | tools 17 | 18 | Classes 19 | ^^^^^^^ 20 | 21 | Campaign classes 22 | ~~~~~~~~~~~~~~~~ 23 | 24 | .. currentmodule:: welltestpy.data 25 | 26 | The following classes can be used to handle field campaigns. 27 | 28 | .. autosummary:: 29 | Campaign 30 | FieldSite 31 | 32 | Field Test classes 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | The following classes can be used to handle field test within a campaign. 36 | 37 | .. autosummary:: 38 | PumpingTest 39 | 40 | Loading routines 41 | ^^^^^^^^^^^^^^^^ 42 | 43 | Campaign related loading routines 44 | 45 | .. autosummary:: 46 | load_campaign 47 | """ 48 | from . import data, estimate, process, tools 49 | 50 | try: 51 | from ._version import __version__ 52 | except ImportError: # pragma: nocover 53 | # package is not installed 54 | __version__ = "0.0.0.dev0" 55 | 56 | from .data.campaignlib import Campaign, FieldSite 57 | from .data.data_io import load_campaign 58 | from .data.testslib import PumpingTest 59 | 60 | __all__ = ["__version__"] 61 | __all__ += ["data", "estimate", "process", "tools"] 62 | __all__ += ["Campaign", "FieldSite", "PumpingTest", "load_campaign"] 63 | -------------------------------------------------------------------------------- /src/welltestpy/data/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | welltestpy subpackage providing datastructures. 3 | 4 | Campaign classes 5 | ~~~~~~~~~~~~~~~~ 6 | 7 | The following classes can be used to handle field campaigns. 8 | 9 | .. autosummary:: 10 | :toctree: 11 | 12 | Campaign 13 | FieldSite 14 | 15 | Field Test classes 16 | ~~~~~~~~~~~~~~~~~~ 17 | 18 | The following classes can be used to handle field test within a campaign. 19 | 20 | .. autosummary:: 21 | :toctree: 22 | 23 | PumpingTest 24 | Test 25 | 26 | Variable classes 27 | ~~~~~~~~~~~~~~~~ 28 | 29 | .. autosummary:: 30 | :toctree: 31 | 32 | Variable 33 | TimeVar 34 | HeadVar 35 | TemporalVar 36 | CoordinatesVar 37 | Observation 38 | StdyObs 39 | DrawdownObs 40 | StdyHeadObs 41 | Well 42 | 43 | Routines 44 | ^^^^^^^^ 45 | 46 | Loading routines 47 | ~~~~~~~~~~~~~~~~ 48 | 49 | Campaign related loading routines 50 | 51 | .. autosummary:: 52 | :toctree: 53 | 54 | load_campaign 55 | load_fieldsite 56 | 57 | Field test related loading routines 58 | 59 | .. autosummary:: 60 | :toctree: 61 | 62 | load_test 63 | 64 | Variable related loading routines 65 | 66 | .. autosummary:: 67 | :toctree: 68 | 69 | load_var 70 | load_obs 71 | load_well 72 | """ 73 | from . import campaignlib, data_io, testslib, varlib 74 | from .campaignlib import Campaign, FieldSite 75 | from .data_io import ( 76 | load_campaign, 77 | load_fieldsite, 78 | load_obs, 79 | load_test, 80 | load_var, 81 | load_well, 82 | ) 83 | from .testslib import PumpingTest, Test 84 | from .varlib import ( 85 | CoordinatesVar, 86 | DrawdownObs, 87 | HeadVar, 88 | Observation, 89 | StdyHeadObs, 90 | StdyObs, 91 | TemporalVar, 92 | TimeVar, 93 | Variable, 94 | Well, 95 | ) 96 | 97 | __all__ = [ 98 | "Variable", 99 | "TimeVar", 100 | "HeadVar", 101 | "TemporalVar", 102 | "CoordinatesVar", 103 | "Observation", 104 | "StdyObs", 105 | "DrawdownObs", 106 | "StdyHeadObs", 107 | "Well", 108 | ] 109 | __all__ += [ 110 | "PumpingTest", 111 | "Test", 112 | ] 113 | __all__ += [ 114 | "FieldSite", 115 | "Campaign", 116 | ] 117 | __all__ += [ 118 | "load_var", 119 | "load_obs", 120 | "load_well", 121 | "load_test", 122 | "load_fieldsite", 123 | "load_campaign", 124 | ] 125 | __all__ += [ 126 | "varlib", 127 | "testslib", 128 | "campaignlib", 129 | "data_io", 130 | ] 131 | -------------------------------------------------------------------------------- /src/welltestpy/data/campaignlib.py: -------------------------------------------------------------------------------- 1 | """Welltestpy subpackage providing flow datastructures for field-campaigns.""" 2 | from copy import deepcopy as dcopy 3 | 4 | from ..tools import plotter 5 | from . import data_io, testslib, varlib 6 | 7 | __all__ = ["FieldSite", "Campaign"] 8 | 9 | 10 | class FieldSite: 11 | """Class for a field site. 12 | 13 | This is a class for a field site. 14 | It has a name and a description. 15 | 16 | Parameters 17 | ---------- 18 | name : :class:`str` 19 | Name of the field site. 20 | description : :class:`str`, optional 21 | Description of the field site. 22 | Default: ``"no description"`` 23 | coordinates : :class:`Variable`, optional 24 | Coordinates of the field site (lat, lon). 25 | Default: ``None`` 26 | """ 27 | 28 | def __init__(self, name, description="Field site", coordinates=None): 29 | self.name = data_io._formstr(name) 30 | self.description = str(description) 31 | self._coordinates = None 32 | self.coordinates = coordinates 33 | 34 | @property 35 | def info(self): 36 | """:class:`str`: Info about the field site.""" 37 | info = "" 38 | info += "----" + "\n" 39 | info += "Field-site: " + str(self.name) + "\n" 40 | info += "Description: " + str(self.description) + "\n" 41 | info += "--" + "\n" 42 | if self._coordinates is not None: 43 | info += self._coordinates.info + "\n" 44 | info += "----" + "\n" 45 | return info 46 | 47 | @property 48 | def pos(self): 49 | """:class:`numpy.ndarray`: Position of the field site.""" 50 | if self._coordinates is not None: 51 | return self._coordinates.value 52 | return None 53 | 54 | @property 55 | def coordinates(self): 56 | """:class:`numpy.ndarray`: Coordinates of the field site.""" 57 | if self._coordinates is not None: 58 | return self._coordinates 59 | return None 60 | 61 | @coordinates.setter 62 | def coordinates(self, coordinates): 63 | if coordinates is not None: 64 | if isinstance(coordinates, varlib.Variable): 65 | self._coordinates = dcopy(coordinates) 66 | else: 67 | self._coordinates = varlib.CoordinatesVar( 68 | coordinates[0], coordinates[1] 69 | ) 70 | else: 71 | self._coordinates = None 72 | 73 | def __repr__(self): 74 | """Representation.""" 75 | return self.name 76 | 77 | def save(self, path="", name=None): 78 | """Save a field site to file. 79 | 80 | This writes the field site to a csv file. 81 | 82 | Parameters 83 | ---------- 84 | path : :class:`str`, optional 85 | Path where the variable should be saved. Default: ``""`` 86 | name : :class:`str`, optional 87 | Name of the file. If ``None``, the name will be generated by 88 | ``"Field_"+name``. Default: ``None`` 89 | 90 | Notes 91 | ----- 92 | The file will get the suffix ``".fds"``. 93 | """ 94 | return data_io.save_fieldsite(self, path, name) 95 | 96 | 97 | class Campaign: 98 | """Class for a well based campaign. 99 | 100 | This is a class for a well based test campaign on a field site. 101 | It has a name, a description and a timeframe. 102 | 103 | Parameters 104 | ---------- 105 | name : :class:`str` 106 | Name of the campaign. 107 | fieldsite : :class:`str` or :class:`Variable`, optional 108 | The field site. 109 | Default: ``"Fieldsite"`` 110 | wells : :class:`dict`, optional 111 | The wells within the field site. Keys are the well names and values 112 | are an instance of :class:`Well`. 113 | Default: ``None`` 114 | wells : :class:`dict`, optional 115 | The tests within the campaign. Keys are the test names and values 116 | are an instance of :class:`Test`. 117 | Default: ``None`` 118 | timeframe : :class:`str`, optional 119 | Timeframe of the campaign. 120 | Default: ``None`` 121 | description : :class:`str`, optional 122 | Description of the field site. 123 | Default: ``"Welltest campaign"`` 124 | """ 125 | 126 | def __init__( 127 | self, 128 | name, 129 | fieldsite="Fieldsite", 130 | wells=None, 131 | tests=None, 132 | timeframe=None, 133 | description="Welltest campaign", 134 | ): 135 | self.name = data_io._formstr(name) 136 | self.description = str(description) 137 | self._fieldsite = None 138 | self.fieldsite = fieldsite 139 | self.__wells = {} 140 | self.wells = wells 141 | self.__tests = {} 142 | self.tests = tests 143 | self.timeframe = str(timeframe) 144 | 145 | @property 146 | def fieldsite(self): 147 | """:class:`FieldSite`: Field site where the campaign was realised.""" 148 | return self._fieldsite 149 | 150 | @fieldsite.setter 151 | def fieldsite(self, fieldsite): 152 | if fieldsite is not None: 153 | if isinstance(fieldsite, FieldSite): 154 | self._fieldsite = dcopy(fieldsite) 155 | else: 156 | self._fieldsite = FieldSite(str(fieldsite)) 157 | else: 158 | self._fieldsite = None 159 | 160 | @property 161 | def wells(self): 162 | """:class:`dict`: Wells within the campaign.""" 163 | return self.__wells 164 | 165 | @wells.setter 166 | def wells(self, wells): 167 | if wells is not None: 168 | if isinstance(wells, dict): 169 | for k in wells.keys(): 170 | if not isinstance(wells[k], varlib.Well): 171 | raise ValueError( 172 | "Campaign: some 'wells' are not of " + "type Well" 173 | ) 174 | if not k == wells[k].name: 175 | raise ValueError( 176 | "Campaign: 'well'-keys should be " 177 | + "the Well name" 178 | ) 179 | self.__wells = dcopy(wells) 180 | elif isinstance(wells, (list, tuple)): 181 | for wel in wells: 182 | if not isinstance(wel, varlib.Well): 183 | raise ValueError( 184 | "Campaign: some 'wells' " + "are not of type u" 185 | ) 186 | self.__wells = {} 187 | for wel in wells: 188 | self.__wells[wel.name] = dcopy(wel) 189 | else: 190 | raise ValueError( 191 | "Campaign: 'wells' should be given " 192 | + "as dictionary or list" 193 | ) 194 | else: 195 | self.__wells = {} 196 | self.__updatewells() 197 | 198 | def add_well( 199 | self, name, radius, coordinates, welldepth=1.0, aquiferdepth=None 200 | ): 201 | """Add a single well to the campaign. 202 | 203 | Parameters 204 | ---------- 205 | name : :class:`str` 206 | Name of the Variable. 207 | radius : :class:`Variable` or :class:`float` 208 | Value of the Variable. 209 | coordinates : :class:`Variable` or :class:`numpy.ndarray` 210 | Value of the Variable. 211 | welldepth : :class:`Variable` or :class:`float`, optional 212 | Depth of the well. Default: 1.0 213 | aquiferdepth : :class:`Variable` or :class:`float`, optional 214 | Depth of the aquifer at the well. Default: ``"None"`` 215 | """ 216 | well = varlib.Well(name, radius, coordinates, welldepth, aquiferdepth) 217 | self.addwells(well) 218 | 219 | def addwells(self, wells): 220 | """Add some specified wells. 221 | 222 | This will add wells to the campaign. 223 | 224 | Parameters 225 | ---------- 226 | wells : :class:`dict` 227 | Wells to be added. 228 | """ 229 | if isinstance(wells, dict): 230 | for k in wells.keys(): 231 | if not isinstance(wells[k], varlib.Well): 232 | raise ValueError( 233 | "Campaign_addwells: some 'wells' " 234 | + "are not of type Well" 235 | ) 236 | if k in tuple(self.__wells.keys()): 237 | raise ValueError( 238 | "Campaign_addwells: some 'wells' " 239 | + "are already present" 240 | ) 241 | if not k == wells[k].name: 242 | raise ValueError( 243 | "Campaign_addwells: 'well'-keys " 244 | + "should be the Well name" 245 | ) 246 | for k in wells.keys(): 247 | self.__wells[k] = dcopy(wells[k]) 248 | elif isinstance(wells, (list, tuple)): 249 | for wel in wells: 250 | if not isinstance(wel, varlib.Well): 251 | raise ValueError( 252 | "Campaign_addwells: some 'wells' " 253 | + "are not of type Well" 254 | ) 255 | if wel.name in tuple(self.__wells.keys()): 256 | raise ValueError( 257 | "Campaign_addwells: some 'wells' " 258 | + "are already present" 259 | ) 260 | for wel in wells: 261 | self.__wells[wel.name] = dcopy(wel) 262 | elif isinstance(wells, varlib.Well): 263 | self.__wells[wells.name] = dcopy(wells) 264 | else: 265 | raise ValueError( 266 | "Campaign_addwells: 'wells' should be " 267 | + "given as dictionary, list or single 'Well'" 268 | ) 269 | 270 | def delwells(self, wells): 271 | """Delete some specified wells. 272 | 273 | This will delete wells from the campaign. You can give a 274 | list of wells or a single well by name. 275 | 276 | Parameters 277 | ---------- 278 | wells : :class:`list` of :class:`str` or :class:`str` 279 | Wells to be deleted. 280 | """ 281 | if isinstance(wells, (list, tuple)): 282 | for wel in wells: 283 | if wel in tuple(self.__wells.keys()): 284 | del self.__wells[wel] 285 | else: 286 | if wells in tuple(self.__wells.keys()): 287 | del self.__wells[wells] 288 | 289 | @property 290 | def tests(self): 291 | """:class:`dict`: Tests within the campaign.""" 292 | return self.__tests 293 | 294 | @tests.setter 295 | def tests(self, tests): 296 | if tests is not None: 297 | if isinstance(tests, dict): 298 | for k in tests.keys(): 299 | if not isinstance(tests[k], testslib.Test): 300 | raise ValueError( 301 | "Campaign: 'tests' are not of " + "type Test" 302 | ) 303 | if not k == tests[k].name: 304 | raise ValueError( 305 | "Campaign: 'tests'-keys " 306 | + "should be the Test name" 307 | ) 308 | self.__tests = dcopy(tests) 309 | elif isinstance(tests, (list, tuple)): 310 | for tes in tests: 311 | if not isinstance(tes, testslib.Test): 312 | raise ValueError( 313 | "Campaign: some 'tests' are not of " + "type Test" 314 | ) 315 | self.__tests = {} 316 | for tes in tests: 317 | self.__tests[tes.name] = dcopy(tes) 318 | elif isinstance(tests, testslib.Test): 319 | self.__tests[tests.name] = dcopy(tests) 320 | else: 321 | raise ValueError( 322 | "Campaign: 'tests' should be given " 323 | + "as dictionary, list or 'Test'" 324 | ) 325 | else: 326 | self.__tests = {} 327 | 328 | def addtests(self, tests): 329 | """Add some specified tests. 330 | 331 | This will add tests to the campaign. 332 | 333 | Parameters 334 | ---------- 335 | tests : :class:`dict` 336 | Tests to be added. 337 | """ 338 | if isinstance(tests, dict): 339 | for k in tests.keys(): 340 | if not isinstance(tests[k], testslib.Test): 341 | raise ValueError( 342 | "Campaign_addtests: some 'tests' " 343 | + "are not of type Test" 344 | ) 345 | if k in tuple(self.__tests.keys()): 346 | raise ValueError( 347 | "Campaign_addtests: some 'tests' " 348 | + "are already present" 349 | ) 350 | if not k == tests[k].name: 351 | raise ValueError( 352 | "Campaign_addtests: 'tests'-keys " 353 | + "should be the Test name" 354 | ) 355 | for k in tests.keys(): 356 | self.__tests[k] = dcopy(tests[k]) 357 | elif isinstance(tests, (list, tuple)): 358 | for tes in tests: 359 | if not isinstance(tes, testslib.Test): 360 | raise ValueError( 361 | "Campaign_addtests: some 'tests' " 362 | + "are not of type Test" 363 | ) 364 | if tes.name in tuple(self.__tests.keys()): 365 | raise ValueError( 366 | "Campaign_addtests: some 'tests' " 367 | + "are already present" 368 | ) 369 | for tes in tests: 370 | self.__tests[tes.name] = dcopy(tes) 371 | elif isinstance(tests, testslib.Test): 372 | if tests.name in tuple(self.__tests.keys()): 373 | raise ValueError("Campaign.addtests: 'test' already present") 374 | self.__tests[tests.name] = dcopy(tests) 375 | else: 376 | raise ValueError( 377 | "Campaign_addtests: 'tests' should be " 378 | + "given as dictionary, list or single 'Test'" 379 | ) 380 | 381 | def deltests(self, tests): 382 | """Delete some specified tests. 383 | 384 | This will delete tests from the campaign. You can give a 385 | list of tests or a single test by name. 386 | 387 | Parameters 388 | ---------- 389 | tests : :class:`list` of :class:`str` or :class:`str` 390 | Tests to be deleted. 391 | """ 392 | if isinstance(tests, (list, tuple)): 393 | for tes in tests: 394 | if tes in tuple(self.__tests.keys()): 395 | del self.__tests[tes] 396 | else: 397 | if tests in tuple(self.__tests.keys()): 398 | del self.__tests[tests] 399 | 400 | def __updatewells(self): 401 | pass 402 | 403 | def plot(self, select_tests=None, **kwargs): 404 | """Generate a plot of the tests within the campaign. 405 | 406 | This will plot an overview of the tests within the campaign. 407 | 408 | Parameters 409 | ---------- 410 | select_tests : :class:`list`, optional 411 | Tests that should be plotted. If None, all will be displayed. 412 | Default: ``None`` 413 | **kwargs 414 | Keyword-arguments forwarded to :py:func:`~welltestpy.tools.campaign_plot` 415 | """ 416 | return plotter.campaign_plot(self, select_tests, **kwargs) 417 | 418 | def plot_wells(self, **kwargs): 419 | """Generate a plot of the wells within the campaign. 420 | 421 | This will plot an overview of the wells within the campaign. 422 | 423 | Parameters 424 | ---------- 425 | **kwargs 426 | Keyword-arguments forwarded to :py:func:`~welltestpy.tools.campaign_well_plot`. 427 | """ 428 | return plotter.campaign_well_plot(self, **kwargs) 429 | 430 | def diagnostic_plot(self, pumping_test, observation_well, **kwargs): 431 | """Generate a diagnostic plot. 432 | 433 | Parameters 434 | ---------- 435 | pumping_test : :class:`str` 436 | The pumping well that is saved in the campaign. 437 | 438 | observation_well : :class:`str` 439 | Observation point to make the diagnostic plot. 440 | 441 | **kwargs 442 | Keyword-arguments forwarded to :py:func:`~welltestpy.tools.campaign_well_plot`. 443 | """ 444 | # check if this is a pumping test 445 | if pumping_test in self.tests: 446 | if not isinstance(self.tests[pumping_test], testslib.PumpingTest): 447 | raise ValueError( 448 | f"diagnostic_plot: test '{pumping_test}' is not of instance PumpingTest!" 449 | ) 450 | # check if the well is present 451 | if observation_well in self.wells: 452 | return self.tests[pumping_test].diagnostic_plot( 453 | observation_well=observation_well, **kwargs 454 | ) 455 | else: 456 | raise ValueError( 457 | f"diagnostic_plot: well '{observation_well}' could not be found!" 458 | ) 459 | else: 460 | raise ValueError( 461 | f"diagnostic_plot: test '{pumping_test}' could not be found!" 462 | ) 463 | 464 | def __repr__(self): 465 | """Representation.""" 466 | return ( 467 | f"Campaign '{self.name}' at '{self.fieldsite}' with " 468 | f"{len(self.wells)} wells and " 469 | f"{len(self.tests)} tests" 470 | ) 471 | 472 | def save(self, path="", name=None): 473 | """Save the campaign to file. 474 | 475 | This writes the campaign to a csv file. 476 | 477 | Parameters 478 | ---------- 479 | path : :class:`str`, optional 480 | Path where the variable should be saved. Default: ``""`` 481 | name : :class:`str`, optional 482 | Name of the file. If ``None``, the name will be generated by 483 | ``"Cmp_"+name``. Default: ``None`` 484 | 485 | Notes 486 | ----- 487 | The file will get the suffix ``".cmp"``. 488 | """ 489 | return data_io.save_campaign(self, path, name) 490 | -------------------------------------------------------------------------------- /src/welltestpy/data/testslib.py: -------------------------------------------------------------------------------- 1 | """welltestpy subpackage providing flow datastructures for tests on a fieldsite.""" 2 | from copy import deepcopy as dcopy 3 | 4 | import numpy as np 5 | 6 | from ..process import processlib 7 | from ..tools import diagnostic_plots, plotter 8 | from . import data_io, varlib 9 | 10 | __all__ = ["Test", "PumpingTest"] 11 | 12 | 13 | class Test: 14 | """General class for a well based test. 15 | 16 | This is a class for a well based test on a field site. 17 | It has a name, a description and a timeframe string. 18 | 19 | Parameters 20 | ---------- 21 | name : :class:`str` 22 | Name of the test. 23 | description : :class:`str`, optional 24 | Description of the test. 25 | Default: ``"no description"`` 26 | timeframe : :class:`str`, optional 27 | Timeframe of the test. 28 | Default: ``None`` 29 | """ 30 | 31 | def __init__(self, name, description="no description", timeframe=None): 32 | self.name = data_io._formstr(name) 33 | self.description = str(description) 34 | self.timeframe = str(timeframe) 35 | self._testtype = "Test" 36 | 37 | def __repr__(self): 38 | """Representation.""" 39 | return ( 40 | self.testtype + " '" + self.name + "', Info: " + self.description 41 | ) 42 | 43 | @property 44 | def testtype(self): 45 | """:class:`str`: String containing the test type.""" 46 | return self._testtype 47 | 48 | def plot(self, wells, exclude=None, fig=None, ax=None, **kwargs): 49 | """Generate a plot of the pumping test. 50 | 51 | This will plot the test on the given figure axes. 52 | 53 | Parameters 54 | ---------- 55 | ax : :class:`Axes` 56 | Axes where the plot should be done. 57 | wells : :class:`dict` 58 | Dictionary containing the well classes sorted by name. 59 | exclude: :class:`list`, optional 60 | List of wells that should be excluded from the plot. 61 | Default: ``None`` 62 | 63 | Notes 64 | ----- 65 | This will be used by the Campaign class. 66 | """ 67 | # update ax (or create it if None) and return it 68 | return ax 69 | 70 | 71 | class PumpingTest(Test): 72 | """Class for a pumping test. 73 | 74 | This is a class for a pumping test on a field site. 75 | It has a name, a description, a timeframe and a pumpingwell string. 76 | 77 | Parameters 78 | ---------- 79 | name : :class:`str` 80 | Name of the test. 81 | pumpingwell : :class:`str` 82 | Pumping well of the test. 83 | pumpingrate : :class:`float` or :class:`Variable` 84 | Pumping rate of at the pumping well. If a `float` is given, 85 | it is assumed to be given in ``m^3/s``. 86 | observations : :class:`dict`, optional 87 | Observations made within the pumping test. The dict-keys are the 88 | well names of the observation wells or the pumpingwell. Values 89 | need to be an instance of :class:`Observation` 90 | Default: ``None`` 91 | aquiferdepth : :class:`float` or :class:`Variable`, optional 92 | Aquifer depth at the field site. 93 | Can also be used to store the saturated thickness of the aquifer. 94 | If a `float` is given, it is assumed to be given in ``m``. 95 | Default: ``1.0`` 96 | aquiferradius : :class:`float` or :class:`Variable`, optional 97 | Aquifer radius ot the field site. If a `float` is given, 98 | it is assumed to be given in ``m``. 99 | Default: ``inf`` 100 | description : :class:`str`, optional 101 | Description of the test. 102 | Default: ``"Pumpingtest"`` 103 | timeframe : :class:`str`, optional 104 | Timeframe of the test. 105 | Default: ``None`` 106 | """ 107 | 108 | def __init__( 109 | self, 110 | name, 111 | pumpingwell, 112 | pumpingrate, 113 | observations=None, 114 | aquiferdepth=1.0, 115 | aquiferradius=np.inf, 116 | description="Pumpingtest", 117 | timeframe=None, 118 | ): 119 | super().__init__(name, description, timeframe) 120 | 121 | self._pumpingrate = None 122 | self._aquiferdepth = None 123 | self._aquiferradius = None 124 | self.__observations = {} 125 | self._testtype = "PumpingTest" 126 | 127 | self.pumpingwell = str(pumpingwell) 128 | self.pumpingrate = pumpingrate 129 | self.aquiferdepth = aquiferdepth 130 | self.aquiferradius = aquiferradius 131 | self.observations = observations 132 | 133 | def make_steady(self, time="latest"): 134 | """ 135 | Convert the pumping test to a steady state test. 136 | 137 | Parameters 138 | ---------- 139 | time : :class:`str` or :class:`float`, optional 140 | Selected time point for steady state. 141 | If "latest", the latest common time point is used. 142 | If None, it takes the last observation per well. 143 | If float, it will be interpolated. 144 | Default: "latest" 145 | """ 146 | if time == "latest": 147 | tout = np.inf 148 | for obs in self.observations: 149 | if self.observations[obs].state == "transient": 150 | tout = min(tout, np.max(self.observations[obs].time)) 151 | elif time is None: 152 | tout = 0.0 153 | for obs in self.observations: 154 | if self.observations[obs].state == "transient": 155 | tout = max(tout, np.max(self.observations[obs].time)) 156 | else: 157 | tout = float(time) 158 | for obs in self.observations: 159 | if self.observations[obs].state == "transient": 160 | processlib.filterdrawdown(self.observations[obs], tout=tout) 161 | del self.observations[obs].time 162 | if ( 163 | isinstance(self._pumpingrate, varlib.Observation) 164 | and self._pumpingrate.state == "transient" 165 | ): 166 | processlib.filterdrawdown(self._pumpingrate, tout=tout) 167 | del self._pumpingrate.time 168 | 169 | def correct_observations( 170 | self, aquiferdepth=None, wells=None, method="cooper_jacob" 171 | ): 172 | """ 173 | Correct observations with the selected method. 174 | 175 | Parameters 176 | ---------- 177 | aquiferdepth : :class:`float`, optional 178 | Aquifer depth at the field site. 179 | Default: PumpingTest.depth 180 | wells : :class:`list`, optional 181 | List of wells, to check the observation state at. 182 | Default: all 183 | method : :class: 'str', optional 184 | Method to correct the drawdown data. 185 | Default: ''cooper_jacob'' 186 | 187 | Notes 188 | ----- 189 | This will be used by the Campaign class. 190 | 191 | """ 192 | if aquiferdepth is None: 193 | aquiferdepth = self.depth 194 | wells = self.observationwells if wells is None else list(wells) 195 | if method == "cooper_jacob": 196 | for obs in wells: 197 | self.observations[obs] = processlib.cooper_jacob_correction( 198 | observation=self.observations[obs], 199 | sat_thickness=aquiferdepth, 200 | ) 201 | 202 | else: 203 | return ValueError( 204 | f"correct_observations: method '{method}' is unknown!" 205 | ) 206 | 207 | def state(self, wells=None): 208 | """ 209 | Get the state of observation. 210 | 211 | Either None, "steady", "transient" or "mixed". 212 | 213 | Parameters 214 | ---------- 215 | wells : :class:`list`, optional 216 | List of wells, to check the observation state at. Default: all 217 | """ 218 | wells = self.observationwells if wells is None else list(wells) 219 | states = set() 220 | for obs in wells: 221 | if obs not in self.observations: 222 | raise ValueError(obs + " is an unknown well.") 223 | states.add(self.observations[obs].state) 224 | if len(states) == 1: 225 | return states.pop() 226 | if len(states) > 1: 227 | return "mixed" 228 | return None 229 | 230 | @property 231 | def wells(self): 232 | """:class:`tuple` of :class:`str`: all well names.""" 233 | tmp = list(self.__observations.keys()) 234 | tmp.append(self.pumpingwell) 235 | wells = list(set(tmp)) 236 | wells.sort() 237 | return wells 238 | 239 | @property 240 | def observationwells(self): 241 | """:class:`tuple` of :class:`str`: all well names.""" 242 | tmp = list(self.__observations.keys()) 243 | wells = list(set(tmp)) 244 | wells.sort() 245 | return wells 246 | 247 | @property 248 | def constant_rate(self): 249 | """:class:`bool`: state if this is a constant rate test.""" 250 | return np.isscalar(self.rate) 251 | 252 | @property 253 | def rate(self): 254 | """:class:`float`: pumping rate at the pumping well.""" 255 | return self._pumpingrate.value 256 | 257 | @property 258 | def pumpingrate(self): 259 | """:class:`float`: pumping rate variable at the pumping well.""" 260 | return self._pumpingrate 261 | 262 | @pumpingrate.setter 263 | def pumpingrate(self, pumpingrate): 264 | if isinstance(pumpingrate, (varlib.Variable, varlib.Observation)): 265 | self._pumpingrate = dcopy(pumpingrate) 266 | elif self._pumpingrate is None: 267 | self._pumpingrate = varlib.Variable( 268 | "pumpingrate", 269 | pumpingrate, 270 | "Q", 271 | "m^3/s", 272 | "Pumpingrate at test '" + self.name + "'", 273 | ) 274 | else: 275 | self._pumpingrate(pumpingrate) 276 | if ( 277 | isinstance(self._pumpingrate, varlib.Variable) 278 | and not self.constant_rate 279 | ): 280 | raise ValueError("PumpingTest: 'pumpingrate' not scalar") 281 | if ( 282 | isinstance(self._pumpingrate, varlib.Observation) 283 | and self._pumpingrate.state == "steady" 284 | and not self.constant_rate 285 | ): 286 | raise ValueError("PumpingTest: 'pumpingrate' not scalar") 287 | 288 | @property 289 | def depth(self): 290 | """:class:`float`: aquifer depth or saturated thickness.""" 291 | return self._aquiferdepth.value 292 | 293 | @property 294 | def aquiferdepth(self): 295 | """:any:`Variable`: aquifer depth or saturated thickness.""" 296 | return self._aquiferdepth 297 | 298 | @aquiferdepth.setter 299 | def aquiferdepth(self, aquiferdepth): 300 | if isinstance(aquiferdepth, varlib.Variable): 301 | self._aquiferdepth = dcopy(aquiferdepth) 302 | elif self._aquiferdepth is None: 303 | self._aquiferdepth = varlib.Variable( 304 | "aquiferdepth", 305 | aquiferdepth, 306 | "L_a", 307 | "m", 308 | "mean aquiferdepth for test '" + str(self.name) + "'", 309 | ) 310 | else: 311 | self._aquiferdepth(aquiferdepth) 312 | if not self._aquiferdepth.scalar: 313 | raise ValueError("PumpingTest: 'aquiferdepth' needs to be scalar") 314 | if self.depth <= 0.0: 315 | raise ValueError( 316 | "PumpingTest: 'aquiferdepth' needs to be positive" 317 | ) 318 | 319 | @property 320 | def radius(self): 321 | """:class:`float`: aquifer radius at the field site.""" 322 | return self._aquiferradius.value 323 | 324 | @property 325 | def aquiferradius(self): 326 | """:class:`float`: aquifer radius at the field site.""" 327 | return self._aquiferradius 328 | 329 | @aquiferradius.setter 330 | def aquiferradius(self, aquiferradius): 331 | if isinstance(aquiferradius, varlib.Variable): 332 | self._aquiferradius = dcopy(aquiferradius) 333 | elif self._aquiferradius is None: 334 | self._aquiferradius = varlib.Variable( 335 | "aquiferradius", 336 | aquiferradius, 337 | "R", 338 | "m", 339 | "mean aquiferradius for test '" + str(self.name) + "'", 340 | ) 341 | else: 342 | self._aquiferradius(aquiferradius) 343 | if not self._aquiferradius.scalar: 344 | raise ValueError("PumpingTest: 'aquiferradius' needs to be scalar") 345 | if self.radius <= 0.0: 346 | raise ValueError( 347 | "PumpingTest: 'aquiferradius' " + "needs to be positive" 348 | ) 349 | 350 | @property 351 | def observations(self): 352 | """:class:`dict`: observations made at the field site.""" 353 | return self.__observations 354 | 355 | @observations.setter 356 | def observations(self, obs): 357 | self.__observations = {} 358 | if obs is not None: 359 | self.add_observations(obs) 360 | 361 | def add_steady_obs( 362 | self, 363 | well, 364 | observation, 365 | description="Steady State Drawdown observation", 366 | ): 367 | """ 368 | Add steady drawdown observations. 369 | 370 | Parameters 371 | ---------- 372 | well : :class:`str` 373 | well where the observation is made. 374 | observation : :class:`Variable` 375 | Observation. 376 | description : :class:`str`, optional 377 | Description of the Variable. Default: ``"Steady observation"`` 378 | """ 379 | obs = varlib.StdyHeadObs(well, observation, description) 380 | self.add_observations(obs) 381 | 382 | def add_transient_obs( 383 | self, 384 | well, 385 | time, 386 | observation, 387 | description="Transient Drawdown observation", 388 | ): 389 | """ 390 | Add transient drawdown observations. 391 | 392 | Parameters 393 | ---------- 394 | well : :class:`str` 395 | well where the observation is made. 396 | time : :class:`Variable` 397 | Time points of observation. 398 | observation : :class:`Variable` 399 | Observation. 400 | description : :class:`str`, optional 401 | Description of the Variable. Default: ``"Drawdown observation"`` 402 | """ 403 | obs = varlib.DrawdownObs(well, observation, time, description) 404 | self.add_observations(obs) 405 | 406 | def add_observations(self, obs): 407 | """Add some specified observations. 408 | 409 | Parameters 410 | ---------- 411 | obs : :class:`dict`, :class:`list`, :class:`Observation` 412 | Observations to be added. 413 | """ 414 | if isinstance(obs, dict): 415 | for k in obs: 416 | if not isinstance(obs[k], varlib.Observation): 417 | raise ValueError( 418 | "PumpingTest_add_observations: some " 419 | + "'observations' are not " 420 | + "of type Observation" 421 | ) 422 | if k in self.observations: 423 | raise ValueError( 424 | "PumpingTest_add_observations: some " 425 | + "'observations' are already present" 426 | ) 427 | for k in obs: 428 | self.__observations[k] = dcopy(obs[k]) 429 | elif isinstance(obs, varlib.Observation): 430 | if obs in self.observations: 431 | raise ValueError( 432 | "PumpingTest_add_observations: " 433 | + "'observation' are already present" 434 | ) 435 | self.__observations[obs.name] = dcopy(obs) 436 | else: 437 | try: 438 | iter(obs) 439 | except TypeError: 440 | raise ValueError( 441 | "PumpingTest_add_observations: 'obs' can't be read." 442 | ) 443 | else: 444 | for ob in obs: 445 | if not isinstance(ob, varlib.Observation): 446 | raise ValueError( 447 | "PumpingTest_add_observations: some " 448 | + "'observations' are not " 449 | + "of type Observation" 450 | ) 451 | self.__observations[ob.name] = dcopy(ob) 452 | 453 | def del_observations(self, obs): 454 | """Delete some specified observations. 455 | 456 | This will delete observations from the pumping test. You can give a 457 | list of observations or a single observation by name. 458 | 459 | Parameters 460 | ---------- 461 | obs : :class:`list` of :class:`str` or :class:`str` 462 | Observations to be deleted. 463 | """ 464 | if isinstance(obs, (list, tuple)): 465 | for k in obs: 466 | if k in self.observations: 467 | del self.__observations[k] 468 | else: 469 | if obs in self.observations: 470 | del self.__observations[obs] 471 | 472 | def plot(self, wells, exclude=None, fig=None, ax=None, **kwargs): 473 | """Generate a plot of the pumping test. 474 | 475 | This will plot the pumping test on the given figure axes. 476 | 477 | Parameters 478 | ---------- 479 | ax : :class:`Axes` 480 | Axes where the plot should be done. 481 | wells : :class:`dict` 482 | Dictionary containing the well classes sorted by name. 483 | exclude: :class:`list`, optional 484 | List of wells that should be excluded from the plot. 485 | Default: ``None`` 486 | 487 | Notes 488 | ----- 489 | This will be used by the Campaign class. 490 | """ 491 | return plotter.plot_pump_test( 492 | pump_test=self, 493 | wells=wells, 494 | exclude=exclude, 495 | fig=fig, 496 | ax=ax, 497 | **kwargs, 498 | ) 499 | 500 | def diagnostic_plot(self, observation_well, **kwargs): 501 | """ 502 | Make a diagnostic plot. 503 | 504 | Parameters 505 | ---------- 506 | observation_well : :class:`str` 507 | The observation well for the data to make the diagnostic plot. 508 | 509 | Notes 510 | ----- 511 | This will be used by the Campaign class. 512 | """ 513 | if observation_well in self.observations: 514 | observation = self.observations[observation_well] 515 | rate = self.pumpingrate() 516 | return diagnostic_plots.diagnostic_plot_pump_test( 517 | observation=observation, rate=rate, **kwargs 518 | ) 519 | else: 520 | raise ValueError( 521 | f"diagnostic_plot: well '{observation_well}' not found!" 522 | ) 523 | 524 | def save(self, path="", name=None): 525 | """Save a pumping test to file. 526 | 527 | This writes the variable to a csv file. 528 | 529 | Parameters 530 | ---------- 531 | path : :class:`str`, optional 532 | Path where the variable should be saved. Default: ``""`` 533 | name : :class:`str`, optional 534 | Name of the file. If ``None``, the name will be generated by 535 | ``"Test_"+name``. Default: ``None`` 536 | 537 | Notes 538 | ----- 539 | The file will get the suffix ``".tst"``. 540 | """ 541 | return data_io.save_pumping_test(self, path, name) 542 | -------------------------------------------------------------------------------- /src/welltestpy/data/varlib.py: -------------------------------------------------------------------------------- 1 | """welltestpy subpackage providing flow datastructures for variables.""" 2 | import numbers 3 | from copy import deepcopy as dcopy 4 | 5 | import numpy as np 6 | 7 | from . import data_io 8 | 9 | __all__ = [ 10 | "Variable", 11 | "TimeVar", 12 | "HeadVar", 13 | "TemporalVar", 14 | "CoordinatesVar", 15 | "Observation", 16 | "StdyObs", 17 | "DrawdownObs", 18 | "StdyHeadObs", 19 | "TimeSeries", 20 | "Well", 21 | ] 22 | 23 | 24 | class Variable: 25 | """Class for a variable. 26 | 27 | This is a class for a physical variable which is either a scalar or an 28 | array. 29 | 30 | It has a name, a value, a symbol, a unit and a descrition string. 31 | 32 | Parameters 33 | ---------- 34 | name : :class:`str` 35 | Name of the Variable. 36 | value : :class:`int` or :class:`float` or :class:`numpy.ndarray` 37 | Value of the Variable. 38 | symbole : :class:`str`, optional 39 | Name of the Variable. Default: ``"x"`` 40 | units : :class:`str`, optional 41 | Units of the Variable. Default: ``"-"`` 42 | description : :class:`str`, optional 43 | Description of the Variable. Default: ``"no description"`` 44 | """ 45 | 46 | def __init__( 47 | self, name, value, symbol="x", units="-", description="no description" 48 | ): 49 | self.name = data_io._formstr(name) 50 | self.__value = None 51 | self.value = value 52 | self.symbol = str(symbol) 53 | self.units = str(units) 54 | self.description = str(description) 55 | 56 | def __call__(self, value=None): 57 | """Call a variable. 58 | 59 | Here you can set a new value or you can get the value of the variable. 60 | 61 | Parameters 62 | ---------- 63 | value : :class:`int` or :class:`float` or :class:`numpy.ndarray`, 64 | optional 65 | Value of the Variable. Default: ``None`` 66 | 67 | Returns 68 | ------- 69 | value : :class:`int` or :class:`float` or :class:`numpy.ndarray` 70 | Value of the Variable. 71 | """ 72 | if value is not None: 73 | self.value = value 74 | return self.value 75 | 76 | @property 77 | def info(self): 78 | """:class:`str`: Info about the Variable.""" 79 | info = "" 80 | info += " Variable-name: " + str(self.name) + "\n" 81 | info += " -Value: " + str(self.value) + "\n" 82 | info += " -Symbol: " + str(self.symbol) + "\n" 83 | info += " -Units: " + str(self.units) + "\n" 84 | info += " -Description: " + str(self.description) + "\n" 85 | return info 86 | 87 | @property 88 | def scalar(self): 89 | """:class:`bool`: State if the variable is of scalar type.""" 90 | return np.isscalar(self.__value) 91 | 92 | @property 93 | def label(self): 94 | """:class:`str`: String containing: ``symbol in units``.""" 95 | return f"{self.symbol} in {self.units}" 96 | 97 | @property 98 | def value(self): 99 | """:class:`int` or :class:`float` or :class:`numpy.ndarray`: Value.""" 100 | return self.__value 101 | 102 | @value.setter 103 | def value(self, value): 104 | if issubclass(np.asanyarray(value).dtype.type, numbers.Real): 105 | if np.ndim(np.squeeze(value)) == 0: 106 | self.__value = float(np.squeeze(value)) 107 | else: 108 | self.__value = np.squeeze(np.array(value, dtype=float)) 109 | elif issubclass(np.asanyarray(value).dtype.type, numbers.Integral): 110 | if np.ndim(np.squeeze(value)) == 0: 111 | self.__value = int(np.squeeze(value)) 112 | else: 113 | self.__value = np.squeeze(np.array(value, dtype=int)) 114 | else: 115 | raise ValueError("Variable: 'value' is neither integer nor float") 116 | 117 | def __repr__(self): 118 | """Representation.""" 119 | return f"{self.name} {self.symbol}: {self.value} {self.units}" 120 | 121 | def __str__(self): 122 | """Representation.""" 123 | return f"{self.name} {self.label}" 124 | 125 | def save(self, path="", name=None): 126 | """Save a variable to file. 127 | 128 | This writes the variable to a csv file. 129 | 130 | Parameters 131 | ---------- 132 | path : :class:`str`, optional 133 | Path where the variable should be saved. Default: ``""`` 134 | name : :class:`str`, optional 135 | Name of the file. If ``None``, the name will be generated by 136 | ``"Var_"+name``. Default: ``None`` 137 | 138 | Notes 139 | ----- 140 | The file will get the suffix ``".var"``. 141 | """ 142 | return data_io.save_var(self, path, name) 143 | 144 | 145 | class TimeVar(Variable): 146 | """Variable class special for time series. 147 | 148 | Parameters 149 | ---------- 150 | value : :class:`int` or :class:`float` or :class:`numpy.ndarray` 151 | Value of the Variable. 152 | symbole : :class:`str`, optional 153 | Name of the Variable. Default: ``"t"`` 154 | units : :class:`str`, optional 155 | Units of the Variable. Default: ``"s"`` 156 | description : :class:`str`, optional 157 | Description of the Variable. Default: ``"time given in seconds"`` 158 | 159 | Notes 160 | ----- 161 | Here the variable should be at most 1 dimensional and the name is fix set 162 | to ``"time"``. 163 | """ 164 | 165 | def __init__( 166 | self, value, symbol="t", units="s", description="time given in seconds" 167 | ): 168 | super().__init__("time", value, symbol, units, description) 169 | if np.ndim(self.value) > 1: 170 | raise ValueError( 171 | "TimeVar: 'time' should have at most one dimension" 172 | ) 173 | 174 | 175 | class HeadVar(Variable): 176 | """ 177 | Variable class special for groundwater head. 178 | 179 | Parameters 180 | ---------- 181 | value : :class:`int` or :class:`float` or :class:`numpy.ndarray` 182 | Value of the Variable. 183 | symbole : :class:`str`, optional 184 | Name of the Variable. Default: ``"h"`` 185 | units : :class:`str`, optional 186 | Units of the Variable. Default: ``"m"`` 187 | description : :class:`str`, optional 188 | Description of the Variable. Default: ``"head given in meters"`` 189 | 190 | Notes 191 | ----- 192 | Here the variable name is fix set to ``"head"``. 193 | """ 194 | 195 | def __init__( 196 | self, value, symbol="h", units="m", description="head given in meters" 197 | ): 198 | super().__init__("head", value, symbol, units, description) 199 | 200 | 201 | class TemporalVar(Variable): 202 | """ 203 | Variable class for a temporal variable. 204 | 205 | Parameters 206 | ---------- 207 | value : :class:`int` or :class:`float` or :class:`numpy.ndarray`, 208 | optional 209 | Value of the Variable. Default: ``0.0`` 210 | """ 211 | 212 | def __init__(self, value=0.0): 213 | super().__init__("temporal", value, description="temporal variable") 214 | 215 | 216 | class CoordinatesVar(Variable): 217 | """Variable class special for coordinates. 218 | 219 | Parameters 220 | ---------- 221 | lat : :class:`int` or :class:`float` or :class:`numpy.ndarray` 222 | Lateral values of the coordinates. 223 | lon : :class:`int` or :class:`float` or :class:`numpy.ndarray` 224 | Longitutional values of the coordinates. 225 | symbole : :class:`str`, optional 226 | Name of the Variable. Default: ``"[Lat,Lon]"`` 227 | units : :class:`str`, optional 228 | Units of the Variable. Default: ``"[deg,deg]"`` 229 | description : :class:`str`, optional 230 | Description of the Variable. Default: ``"Coordinates given in 231 | degree-North and degree-East"`` 232 | 233 | Notes 234 | ----- 235 | Here the variable name is fix set to ``"coordinates"``. 236 | 237 | ``lat`` and ``lon`` should have the same shape. 238 | """ 239 | 240 | def __init__( 241 | self, 242 | lat, 243 | lon, 244 | symbol="[Lat,Lon]", 245 | units="[deg,deg]", 246 | description="Coordinates given in degree-North and degree-East", 247 | ): 248 | ilat = np.array(np.squeeze(lat), ndmin=1) 249 | ilon = np.array(np.squeeze(lon), ndmin=1) 250 | 251 | if ( 252 | len(ilat.shape) != 1 253 | or len(ilon.shape) != 1 254 | or ilat.shape != ilon.shape 255 | ): 256 | raise ValueError( 257 | "CoordinatesVar: 'lat' and 'lon' should have " 258 | "same quantity and should be given as lists" 259 | ) 260 | 261 | value = np.array([ilat, ilon]).T 262 | 263 | super().__init__("coordinates", value, symbol, units, description) 264 | 265 | 266 | class Observation: 267 | """ 268 | Class for a observation. 269 | 270 | This is a class for time-dependent observations. 271 | It has a name and a description. 272 | 273 | Parameters 274 | ---------- 275 | name : :class:`str` 276 | Name of the Variable. 277 | observation : :class:`Variable` 278 | Name of the Variable. Default: ``"x"`` 279 | time : :class:`Variable` 280 | Value of the Variable. 281 | description : :class:`str`, optional 282 | Description of the Variable. Default: ``"Observation"`` 283 | """ 284 | 285 | def __init__( 286 | self, name, observation, time=None, description="Observation" 287 | ): 288 | self.__it = None 289 | self.__itfinished = None 290 | self._time = None 291 | self._observation = None 292 | self.name = data_io._formstr(name) 293 | self.description = str(description) 294 | 295 | self._setobservation(observation) 296 | self._settime(time) 297 | self._checkshape() 298 | 299 | def __call__(self, observation=None, time=None): 300 | """Call a variable. 301 | 302 | Here you can set a new value or you can get the value of the variable. 303 | 304 | Parameters 305 | ---------- 306 | observation : scalar, :class:`numpy.ndarray`, :class:`Variable`, optional 307 | New Value for observation. 308 | Default: ``"None"`` 309 | time : scalar, :class:`numpy.ndarray`, :class:`Variable`, optional 310 | New Value for time. 311 | Default: ``"None"`` 312 | 313 | Returns 314 | ------- 315 | [:class:`tuple` of] :class:`int` or :class:`float` 316 | or :class:`numpy.ndarray` 317 | ``(time, observation)`` or ``observation``. 318 | """ 319 | if observation is not None: 320 | self._setobservation(observation) 321 | if time is not None: 322 | self._settime(time) 323 | if observation is not None or time is not None: 324 | self._checkshape() 325 | return self.value 326 | 327 | def __repr__(self): 328 | """Representation.""" 329 | return f"Observation '{self.name}' {self.label}" 330 | 331 | def __str__(self): 332 | """Representation.""" 333 | return self.__repr__() 334 | 335 | @property 336 | def labels(self): 337 | """[:class:`tuple` of] :class:`str`: ``symbol in units``.""" 338 | if self.state == "transient": 339 | return self._time.label, self._observation.label 340 | return self._observation.label 341 | 342 | @property 343 | def label(self): 344 | """[:class:`tuple` of] :class:`str`: ``symbol in units``.""" 345 | return self.labels 346 | 347 | @property 348 | def info(self): 349 | """Get information about the observation. 350 | 351 | Here you can display information about the observation. 352 | """ 353 | info = "" 354 | info += "Observation-name: " + str(self.name) + "\n" 355 | info += " -Description: " + str(self.description) + "\n" 356 | info += " -Kind: " + str(self.kind) + "\n" 357 | info += " -State: " + str(self.state) + "\n" 358 | if self.state == "transient": 359 | info += " --- \n" 360 | info += self._time.info + "\n" 361 | info += " --- \n" 362 | info += self._observation.info + "\n" 363 | return info 364 | 365 | @property 366 | def value(self): 367 | """ 368 | Value of the Observation. 369 | 370 | [:class:`tuple` of] :class:`int` or :class:`float` 371 | or :class:`numpy.ndarray` 372 | """ 373 | if self.state == "transient": 374 | return self.observation, self.time 375 | return self.observation 376 | 377 | @property 378 | def state(self): 379 | """ 380 | :class:`str`: String containing state of the observation. 381 | 382 | Either ``"steady"`` or ``"transient"``. 383 | """ 384 | return "steady" if self._time is None else "transient" 385 | 386 | @property 387 | def kind(self): 388 | """:class:`str`: name of the observation variable.""" 389 | return self._observation.name 390 | 391 | @property 392 | def time(self): 393 | """ 394 | Time values of the observation. 395 | 396 | :class:`int` or :class:`float` or :class:`numpy.ndarray` 397 | """ 398 | return self._time.value if self.state == "transient" else None 399 | 400 | @time.setter 401 | def time(self, time): 402 | self._settime(time) 403 | self._checkshape() 404 | 405 | @time.deleter 406 | def time(self): 407 | self._time = None 408 | 409 | @property 410 | def observation(self): 411 | """ 412 | Observed values of the observation. 413 | 414 | :class:`int` or :class:`float` or :class:`numpy.ndarray` 415 | """ 416 | return self._observation.value 417 | 418 | @observation.setter 419 | def observation(self, observation): 420 | self._setobservation(observation) 421 | self._checkshape() 422 | 423 | @property 424 | def units(self): 425 | """[:class:`tuple` of] :class:`str`: units of the observation.""" 426 | if self.state == "steady": 427 | return self._observation.units 428 | return f"{self._time.units}, {self._observation.units}" 429 | 430 | def reshape(self): 431 | """Reshape observations to flat array.""" 432 | if self.state == "transient": 433 | tmp = len(np.shape(self.time)) 434 | self._settime(np.reshape(self.time, -1)) 435 | shp = np.shape(self.time) + np.shape(self.observation)[tmp:] 436 | self._setobservation(np.reshape(self.observation, shp)) 437 | 438 | def _settime(self, time): 439 | if isinstance(time, Variable): 440 | self._time = dcopy(time) 441 | elif time is None: 442 | self._time = None 443 | elif self._time is None: 444 | self._time = TimeVar(time) 445 | else: 446 | self._time(time) 447 | 448 | def _setobservation(self, observation): 449 | if isinstance(observation, Variable): 450 | self._observation = dcopy(observation) 451 | elif observation is None: 452 | self._observation = None 453 | else: 454 | self._observation(observation) 455 | 456 | def _checkshape(self): 457 | if self.state == "transient" and ( 458 | np.shape(self.time) 459 | != np.shape(self.observation)[: len(np.shape(self.time))] 460 | ): 461 | raise ValueError( 462 | "Observation: 'observation' has a shape-mismatch with 'time'" 463 | ) 464 | 465 | def __iter__(self): 466 | """Iterate over Observations.""" 467 | if self.state == "transient": 468 | self.__it = np.nditer(self.time, flags=["multi_index"]) 469 | else: 470 | self.__itfinished = False 471 | return self 472 | 473 | def __next__(self): 474 | """Iterate through observations.""" 475 | if self.state == "transient": 476 | if self.__it.finished: 477 | raise StopIteration 478 | ret = ( 479 | self.__it[0].item(), 480 | self.observation[self.__it.multi_index], 481 | ) 482 | self.__it.iternext() 483 | else: 484 | if self.__itfinished: 485 | raise StopIteration 486 | ret = self.observation 487 | self.__itfinished = True 488 | return ret 489 | 490 | def save(self, path="", name=None): 491 | """Save an observation to file. 492 | 493 | This writes the observation to a csv file. 494 | 495 | Parameters 496 | ---------- 497 | path : :class:`str`, optional 498 | Path where the variable should be saved. Default: ``""`` 499 | name : :class:`str`, optional 500 | Name of the file. If ``None``, the name will be generated by 501 | ``"Obs_"+name``. Default: ``None`` 502 | 503 | Notes 504 | ----- 505 | The file will get the suffix ``".obs"``. 506 | """ 507 | return data_io.save_obs(self, path, name) 508 | 509 | 510 | class StdyObs(Observation): 511 | """ 512 | Observation class special for steady observations. 513 | 514 | Parameters 515 | ---------- 516 | name : :class:`str` 517 | Name of the Variable. 518 | observation : :class:`Variable` 519 | Name of the Variable. Default: ``"x"`` 520 | description : :class:`str`, optional 521 | Description of the Variable. Default: ``"Steady observation"`` 522 | """ 523 | 524 | def __init__(self, name, observation, description="Steady observation"): 525 | super().__init__(name, observation, None, description) 526 | 527 | def _settime(self, time): 528 | """For steady observations, this raises a ``ValueError``.""" 529 | if time is not None: 530 | raise ValueError("Observation: 'time' not allowed in steady-state") 531 | 532 | 533 | class TimeSeries(Observation): 534 | """ 535 | Time series observation. 536 | 537 | Parameters 538 | ---------- 539 | name : :class:`str` 540 | Name of the Variable. 541 | values : :class:`Variable` 542 | Values of the time-series. 543 | time : :class:`Variable` 544 | Time points of the time-series. 545 | description : :class:`str`, optional 546 | Description of the Variable. Default: ``"Timeseries."`` 547 | """ 548 | 549 | def __init__(self, name, values, time, description="Timeseries."): 550 | if not isinstance(time, Variable): 551 | time = TimeVar(time) 552 | if not isinstance(values, Variable): 553 | values = Variable(name, values, description=description) 554 | super().__init__(name, values, time, description) 555 | 556 | 557 | class DrawdownObs(Observation): 558 | """ 559 | Observation class special for drawdown observations. 560 | 561 | Parameters 562 | ---------- 563 | name : :class:`str` 564 | Name of the Variable. 565 | observation : :class:`Variable` 566 | Observation. 567 | time : :class:`Variable` 568 | Time points of observation. 569 | description : :class:`str`, optional 570 | Description of the Variable. Default: ``"Drawdown observation"`` 571 | """ 572 | 573 | def __init__( 574 | self, name, observation, time, description="Drawdown observation" 575 | ): 576 | if not isinstance(time, Variable): 577 | time = TimeVar(time) 578 | if not isinstance(observation, Variable): 579 | observation = HeadVar(observation) 580 | super().__init__(name, observation, time, description) 581 | 582 | 583 | class StdyHeadObs(Observation): 584 | """ 585 | Observation class special for steady drawdown observations. 586 | 587 | Parameters 588 | ---------- 589 | name : :class:`str` 590 | Name of the Variable. 591 | observation : :class:`Variable` 592 | Observation. 593 | description : :class:`str`, optional 594 | Description of the Variable. Default: ``"Steady observation"`` 595 | """ 596 | 597 | def __init__( 598 | self, 599 | name, 600 | observation, 601 | description="Steady State Drawdown observation", 602 | ): 603 | if not isinstance(observation, Variable): 604 | observation = HeadVar(observation) 605 | super().__init__(name, observation, None, description) 606 | 607 | def _settime(self, time): 608 | """For steady observations, this raises a ``ValueError``.""" 609 | if time is not None: 610 | raise ValueError("Observation: 'time' not allowed in steady-state") 611 | 612 | 613 | class Well: 614 | """Class for a pumping-/observation-well. 615 | 616 | This is a class for a well within a aquifer-testing campaign. 617 | 618 | It has a name, a radius, coordinates and a depth. 619 | 620 | Parameters 621 | ---------- 622 | name : :class:`str` 623 | Name of the Variable. 624 | radius : :class:`Variable` or :class:`float` 625 | Value of the Variable. 626 | coordinates : :class:`Variable` or :class:`numpy.ndarray` 627 | Value of the Variable. 628 | welldepth : :class:`Variable` or :class:`float`, optional 629 | Depth of the well (in saturated zone). Default: 1.0 630 | aquiferdepth : :class:`Variable` or :class:`float`, optional 631 | Aquifer depth at the well (saturated zone). Defaults to welldepth. 632 | Default: ``"None"`` 633 | screensize : :class:`Variable` or :class:`float`, optional 634 | Size of the screen at the well. Defaults to 0.0. 635 | Default: ``"None"`` 636 | 637 | Notes 638 | ----- 639 | You can calculate the distance between two wells ``w1`` and ``w2`` by 640 | simply calculating the difference ``w1 - w2``. 641 | """ 642 | 643 | def __init__( 644 | self, 645 | name, 646 | radius, 647 | coordinates, 648 | welldepth=1.0, 649 | aquiferdepth=None, 650 | screensize=None, 651 | ): 652 | self._radius = None 653 | self._coordinates = None 654 | self._welldepth = None 655 | self._aquiferdepth = None 656 | self._screensize = None 657 | 658 | self.name = data_io._formstr(name) 659 | self.wellradius = radius 660 | self.coordinates = coordinates 661 | self.welldepth = welldepth 662 | self.aquiferdepth = aquiferdepth 663 | self.screensize = screensize 664 | 665 | @property 666 | def info(self): 667 | """Get information about the variable. 668 | 669 | Here you can display information about the variable. 670 | """ 671 | info = "" 672 | info += "----\n" 673 | info += "Well-name: " + str(self.name) + "\n" 674 | info += "--\n" 675 | info += self._radius.info + "\n" 676 | info += self.coordinates.info + "\n" 677 | info += self._welldepth.info + "\n" 678 | info += self._aquiferdepth.info + "\n" 679 | info += self._screensize.info + "\n" 680 | info += "----\n" 681 | return info 682 | 683 | @property 684 | def radius(self): 685 | """:class:`float`: Radius of the well.""" 686 | return self._radius.value 687 | 688 | @property 689 | def wellradius(self): 690 | """:class:`Variable`: Radius variable of the well.""" 691 | return self._radius 692 | 693 | @wellradius.setter 694 | def wellradius(self, radius): 695 | if isinstance(radius, Variable): 696 | self._radius = dcopy(radius) 697 | elif self._radius is None: 698 | self._radius = Variable( 699 | "radius", 700 | float(radius), 701 | "r", 702 | "m", 703 | f"Inner radius of well '{self.name}'", 704 | ) 705 | else: 706 | self._radius(radius) 707 | if not self._radius.scalar: 708 | raise ValueError("Well: 'radius' needs to be scalar") 709 | if not self.radius > 0.0: 710 | raise ValueError("Well: 'radius' needs to be positive") 711 | 712 | @property 713 | def pos(self): 714 | """:class:`numpy.ndarray`: Position of the well.""" 715 | return self._coordinates.value 716 | 717 | @property 718 | def coordinates(self): 719 | """:class:`Variable`: Coordinates variable of the well.""" 720 | return self._coordinates 721 | 722 | @coordinates.setter 723 | def coordinates(self, coordinates): 724 | if isinstance(coordinates, Variable): 725 | self._coordinates = dcopy(coordinates) 726 | elif self._coordinates is None: 727 | self._coordinates = Variable( 728 | "coordinates", 729 | coordinates, 730 | "XY", 731 | "m", 732 | f"coordinates of well '{self.name}'", 733 | ) 734 | else: 735 | self._coordinates(coordinates) 736 | if np.shape(self.pos) != (2,) and not np.isscalar(self.pos): 737 | raise ValueError( 738 | "Well: 'coordinates' should be given as " 739 | "[x,y] values or one single distance value" 740 | ) 741 | 742 | @property 743 | def depth(self): 744 | """:class:`float`: Depth of the well.""" 745 | return self._welldepth.value 746 | 747 | @property 748 | def welldepth(self): 749 | """:class:`Variable`: Depth variable of the well.""" 750 | return self._welldepth 751 | 752 | @welldepth.setter 753 | def welldepth(self, welldepth): 754 | if isinstance(welldepth, Variable): 755 | self._welldepth = dcopy(welldepth) 756 | elif self._welldepth is None: 757 | self._welldepth = Variable( 758 | "welldepth", 759 | float(welldepth), 760 | "L_w", 761 | "m", 762 | f"depth of well '{self.name}'", 763 | ) 764 | else: 765 | self._welldepth(welldepth) 766 | if not self._welldepth.scalar: 767 | raise ValueError("Well: 'welldepth' needs to be scalar") 768 | if not self.depth > 0.0: 769 | raise ValueError("Well: 'welldepth' needs to be positive") 770 | 771 | @property 772 | def aquifer(self): 773 | """:class:`float`: Aquifer depth at the well.""" 774 | return self._aquiferdepth.value 775 | 776 | @property 777 | def aquiferdepth(self): 778 | """:class:`Variable`: Aquifer depth at the well.""" 779 | return self._aquiferdepth 780 | 781 | @aquiferdepth.setter 782 | def aquiferdepth(self, aquiferdepth): 783 | if isinstance(aquiferdepth, Variable): 784 | self._aquiferdepth = dcopy(aquiferdepth) 785 | elif self._aquiferdepth is None: 786 | self._aquiferdepth = Variable( 787 | "aquiferdepth", 788 | self.depth if aquiferdepth is None else float(aquiferdepth), 789 | "L_a", 790 | self.welldepth.units, 791 | f"aquifer depth at well '{self.name}'", 792 | ) 793 | else: 794 | self._aquiferdepth(aquiferdepth) 795 | if not self._aquiferdepth.scalar: 796 | raise ValueError("Well: 'aquiferdepth' needs to be scalar") 797 | if not self.aquifer > 0.0: 798 | raise ValueError("Well: 'aquiferdepth' needs to be positive") 799 | 800 | @property 801 | def is_piezometer(self): 802 | """:class:`bool`: Whether the well is only a standpipe piezometer.""" 803 | return np.isclose(self.screen, 0) 804 | 805 | @property 806 | def screen(self): 807 | """:class:`float`: Screen size at the well.""" 808 | return self._screensize.value 809 | 810 | @property 811 | def screensize(self): 812 | """:class:`Variable`: Screen size at the well.""" 813 | return self._screensize 814 | 815 | @screensize.setter 816 | def screensize(self, screensize): 817 | if isinstance(screensize, Variable): 818 | self._screensize = dcopy(screensize) 819 | elif self._screensize is None: 820 | self._screensize = Variable( 821 | "screensize", 822 | 0.0 if screensize is None else float(screensize), 823 | "L_s", 824 | self.welldepth.units, 825 | f"screen size at well '{self.name}'", 826 | ) 827 | else: 828 | self._screensize(screensize) 829 | if not self._screensize.scalar: 830 | raise ValueError("Well: 'screensize' needs to be scalar") 831 | if self.screen < 0.0: 832 | raise ValueError("Well: 'screensize' needs to be non-negative") 833 | 834 | def distance(self, well): 835 | """Calculate distance to the well. 836 | 837 | Parameters 838 | ---------- 839 | well : :class:`Well` or :class:`tuple` of :class:`float` 840 | Coordinates to calculate the distance to or another well. 841 | """ 842 | if isinstance(well, Well): 843 | return np.linalg.norm(self.pos - well.pos) 844 | try: 845 | return np.linalg.norm(self.pos - well) 846 | except ValueError: 847 | raise ValueError( 848 | "Well: the distant-well needs to be an " 849 | "instance of Well-class " 850 | "or a tuple of x-y coordinates " 851 | "or a single distance value " 852 | "and of same coordinates-type." 853 | ) 854 | 855 | def __repr__(self): 856 | """Representation.""" 857 | return f"{self.name} r={self.radius} at {self._coordinates}" 858 | 859 | def __sub__(self, well): 860 | """Distance between wells.""" 861 | return self.distance(well) 862 | 863 | def __add__(self, well): 864 | """Distance between wells.""" 865 | return self.distance(well) 866 | 867 | def __and__(self, well): 868 | """Distance between wells.""" 869 | return self.distance(well) 870 | 871 | def __abs__(self): 872 | """Distance to origin.""" 873 | return np.linalg.norm(self.pos) 874 | 875 | def save(self, path="", name=None): 876 | """Save a well to file. 877 | 878 | This writes the variable to a csv file. 879 | 880 | Parameters 881 | ---------- 882 | path : :class:`str`, optional 883 | Path where the variable should be saved. Default: ``""`` 884 | name : :class:`str`, optional 885 | Name of the file. If ``None``, the name will be generated by 886 | ``"Well_"+name``. Default: ``None`` 887 | 888 | Notes 889 | ----- 890 | The file will get the suffix ``".wel"``. 891 | """ 892 | return data_io.save_well(self, path, name) 893 | -------------------------------------------------------------------------------- /src/welltestpy/estimate/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | welltestpy subpackage providing routines to estimate pump test parameters. 3 | 4 | .. currentmodule:: welltestpy.estimate 5 | 6 | Estimators 7 | ^^^^^^^^^^ 8 | 9 | The following estimators are provided 10 | 11 | .. autosummary:: 12 | :toctree: 13 | 14 | ExtTheis3D 15 | ExtTheis2D 16 | Neuman2004 17 | Theis 18 | ExtThiem3D 19 | ExtThiem2D 20 | Neuman2004Steady 21 | Thiem 22 | 23 | Base Classes 24 | ^^^^^^^^^^^^ 25 | 26 | Transient 27 | ~~~~~~~~~ 28 | 29 | All transient estimators are derived from the following class 30 | 31 | .. autosummary:: 32 | :toctree: 33 | 34 | TransientPumping 35 | 36 | Steady Pumping 37 | ~~~~~~~~~~~~~~ 38 | 39 | All steady estimators are derived from the following class 40 | 41 | .. autosummary:: 42 | :toctree: 43 | 44 | SteadyPumping 45 | 46 | Helper 47 | ^^^^^^ 48 | 49 | .. autosummary:: 50 | :toctree: 51 | 52 | fast_rep 53 | """ 54 | from . import estimators, spotpylib, steady_lib, transient_lib 55 | from .estimators import ( 56 | ExtTheis2D, 57 | ExtTheis3D, 58 | ExtThiem2D, 59 | ExtThiem3D, 60 | Neuman2004, 61 | Neuman2004Steady, 62 | Theis, 63 | Thiem, 64 | ) 65 | from .spotpylib import fast_rep 66 | from .steady_lib import SteadyPumping 67 | from .transient_lib import TransientPumping 68 | 69 | __all__ = ["estimators", "spotpylib", "steady_lib", "transient_lib"] 70 | __all__ += [ 71 | "ExtTheis3D", 72 | "ExtTheis2D", 73 | "Neuman2004", 74 | "Theis", 75 | "ExtThiem3D", 76 | "ExtThiem2D", 77 | "Neuman2004Steady", 78 | "Thiem", 79 | ] 80 | __all__ += ["TransientPumping", "SteadyPumping", "fast_rep"] 81 | -------------------------------------------------------------------------------- /src/welltestpy/estimate/spotpylib.py: -------------------------------------------------------------------------------- 1 | """ 2 | welltestpy subpackage providing Spotpy classes for the estimating. 3 | 4 | .. currentmodule:: welltestpy.estimate.spotpylib 5 | 6 | The following functions and classes are provided 7 | 8 | .. autosummary:: 9 | TypeCurve 10 | fast_rep 11 | """ 12 | import functools as ft 13 | 14 | import numpy as np 15 | import spotpy 16 | 17 | __all__ = ["TypeCurve", "fast_rep"] 18 | 19 | 20 | def _quad(x): 21 | return np.power(x, 2) 22 | 23 | 24 | def _inv(x): 25 | return 1.0 / x 26 | 27 | 28 | def _lin(x): 29 | return x 30 | 31 | 32 | FIT = { 33 | "lin": (_lin, _lin), 34 | "log": (np.log, np.exp), 35 | "exp": (np.exp, np.log), 36 | "sqrt": (np.sqrt, _quad), 37 | "quad": (_quad, np.sqrt), 38 | "inv": (_inv, _inv), 39 | } 40 | """dict: all predefined fitting transformations and their inverse.""" 41 | 42 | 43 | def _is_callable_tuple(input): 44 | result = False 45 | length = 0 46 | try: 47 | length = len(input) 48 | except TypeError: 49 | length = -1 50 | finally: 51 | if length == 2: 52 | result = all(map(callable, input)) 53 | return result 54 | 55 | 56 | def fast_rep(para_no, infer_fac=4, freq_step=2): 57 | """Get number of iterations needed for the FAST algorithm. 58 | 59 | Parameters 60 | ---------- 61 | para_no : :class:`int` 62 | Number of parameters in the model. 63 | infer_fac : :class:`int`, optional 64 | The inference factor. Default: 4 65 | freq_step : :class:`int`, optional 66 | The frequency step. Default: 2 67 | """ 68 | return 2 * int( 69 | para_no * (1 + 4 * infer_fac**2 * (1 + (para_no - 2) * freq_step)) 70 | ) 71 | 72 | 73 | class TypeCurve: 74 | r"""Spotpy class for an estimation of subsurface parameters. 75 | 76 | This class fits a given Type Curve to given data. 77 | Values will be sampled uniformly in given ranges and with given transformation. 78 | 79 | Parameters 80 | ---------- 81 | type_curve : :any:`callable` 82 | The given type-curve. Output will be reshaped to flat array. 83 | data : :class:`numpy.ndarray` 84 | Observed data as array. Will be reshaped to flat array. 85 | val_ranges : :class:`dict` 86 | Dictionary containing the fit-ranges for each value in the type-curve. 87 | Names should be as in the type-curve signature. 88 | All values to be estimated should be present here. 89 | Ranges should be a tuple containing min and max value: ``(min, max)``. 90 | val_fix : :class:`dict` or :any:`None` 91 | Dictionary containing fixed values for the type-curve. 92 | Names should be as in the type-curve signature. 93 | Default: None 94 | val_fit_type : :class:`dict` or :any:`None` 95 | Dictionary containing fitting transformation type for each value. 96 | Names should be as in the type-curve signature. 97 | val_fit_type can be "lin", "log", "exp", "sqrt", "quad", "inv" 98 | or a tuple of two callable functions where the 99 | first is the transformation and the second is its inverse. 100 | "log" is for example equivalent to ``(np.log, np.exp)``. 101 | By default, values will be fitted linear. 102 | Default: None 103 | val_fit_name : :class:`dict` or :any:`None` 104 | Display name of the fitting transformation. 105 | Will be the val_fit_type string if it is a predefined one, 106 | or ``f`` if it is a given callable as default for each value. 107 | Default: None 108 | val_plot_names : :class:`dict` or :any:`None` 109 | Dictionary containing plotable strings for the parameters. 110 | 111 | {value-name: plotting-string} 112 | 113 | Default: None 114 | dummy : :class:`bool`, optional 115 | Add a dummy parameter to the model. This could be used to equalize 116 | sensitivity analysis. 117 | Default: False 118 | """ 119 | 120 | def __init__( 121 | self, 122 | type_curve, 123 | data, 124 | val_ranges, 125 | val_fix=None, 126 | val_fit_type=None, 127 | val_fit_name=None, 128 | val_plot_names=None, 129 | dummy=False, 130 | ): 131 | self.func = type_curve 132 | if not callable(self.func): 133 | raise ValueError("type_curve not callable") 134 | self.val_fit_type = val_fit_type or {} 135 | self.val_plot_names = val_plot_names or {} 136 | if not isinstance(val_ranges, dict) or not val_ranges: 137 | raise ValueError("No ranges given") 138 | self.val_ranges = val_ranges.copy() 139 | self.val_fix = val_fix or {} 140 | # if values haven given ranges but should be fixed, remove ranges 141 | for inter in set(self.val_ranges) & set(self.val_fix): 142 | del self.val_ranges[inter] 143 | 144 | self.para_names = list(self.val_ranges) 145 | self.para_dist = [] 146 | self.data = np.array(data, dtype=float).reshape(-1) 147 | self.sim_kwargs = {} 148 | self.fit_func = {} 149 | self.val_fit_name = val_fit_name or {} 150 | for val in self.para_names: 151 | # linear fitting by default 152 | fit_t = self.val_fit_type.get(val, "lin") 153 | fit_n = fit_t if fit_t in FIT else "f" 154 | self.val_fit_name.setdefault( 155 | val, fit_n if fit_n != "lin" else None 156 | ) 157 | self.fit_func[val] = ( 158 | fit_t if _is_callable_tuple(fit_t) else FIT.get(fit_t, None) 159 | ) 160 | if not self.fit_func[val]: 161 | raise ValueError(f"Fitting transformation for '{val}' missing") 162 | # apply fitting transformation to ranges 163 | self.para_dist.append( 164 | spotpy.parameter.Uniform( 165 | val, *map(self.fit_func[val][0], self.val_ranges[val]) 166 | ) 167 | ) 168 | self.val_plot_names.setdefault(val, val) 169 | 170 | self.dummy = dummy 171 | if self.dummy: 172 | self.para_dist.append(spotpy.parameter.Uniform("dummy", 0, 1)) 173 | 174 | self.sim = ft.partial(self.func, **self.val_fix) 175 | 176 | def get_sim_kwargs(self, vector): 177 | """Generate keyword-args for simulation.""" 178 | # if there is a dummy parameter it will be skipped automatically 179 | for i, para in enumerate(self.para_names): 180 | self.sim_kwargs[para] = self.fit_func[para][1](vector[i]) 181 | return self.sim_kwargs 182 | 183 | def parameters(self): 184 | """Generate a set of parameters.""" 185 | return spotpy.parameter.generate(self.para_dist) 186 | 187 | def simulation(self, vector): 188 | """Run a simulation with the given parameters.""" 189 | self.get_sim_kwargs(vector) 190 | return self.sim(**self.sim_kwargs).reshape(-1) 191 | 192 | def evaluation(self): 193 | """Access the observation data.""" 194 | return self.data 195 | 196 | def objectivefunction(self, simulation, evaluation): 197 | """Calculate RMSE between observation and simulation.""" 198 | return spotpy.objectivefunctions.rmse( 199 | evaluation=evaluation, simulation=simulation 200 | ) 201 | -------------------------------------------------------------------------------- /src/welltestpy/estimate/steady_lib.py: -------------------------------------------------------------------------------- 1 | """welltestpy subpackage providing base classes for steady state estimations.""" 2 | import os 3 | import time as timemodule 4 | from copy import deepcopy as dcopy 5 | 6 | import numpy as np 7 | import spotpy 8 | 9 | from ..data import testslib 10 | from ..process import processlib 11 | from ..tools import plotter 12 | from . import spotpylib 13 | 14 | __all__ = [ 15 | "SteadyPumping", 16 | ] 17 | 18 | 19 | class SteadyPumping: 20 | """Class to estimate steady Type-Curve parameters. 21 | 22 | Parameters 23 | ---------- 24 | name : :class:`str` 25 | Name of the Estimation. 26 | campaign : :class:`welltestpy.data.Campaign` 27 | The pumping test campaign which should be used to estimate the 28 | parameters 29 | type_curve : :any:`callable` 30 | The given type-curve. Output will be reshaped to flat array. 31 | val_ranges : :class:`dict` 32 | Dictionary containing the fit-ranges for each value in the type-curve. 33 | Names should be as in the type-curve signature. 34 | Ranges should be a tuple containing min and max value. 35 | make_steady : :class:`bool`, optional 36 | State if the tests should be converted to steady observations. 37 | See: :any:`PumpingTest.make_steady`. 38 | Default: True 39 | val_fix : :class:`dict` or :any:`None` 40 | Dictionary containing fixed values for the type-curve. 41 | Names should be as in the type-curve signature. 42 | Default: None 43 | val_fit_type : :class:`dict` or :any:`None` 44 | Dictionary containing fitting transformation type for each value. 45 | Names should be as in the type-curve signature. 46 | val_fit_type can be "lin", "log", "exp", "sqrt", "quad", "inv" 47 | or a tuple of two callable functions where the 48 | first is the transformation and the second is its inverse. 49 | "log" is for example equivalent to ``(np.log, np.exp)``. 50 | By default, values will be fitted linear. 51 | Default: None 52 | val_fit_name : :class:`dict` or :any:`None` 53 | Display name of the fitting transformation. 54 | Will be the val_fit_type string if it is a predefined one, 55 | or ``f`` if it is a given callable as default for each value. 56 | Default: None 57 | val_plot_names : :class:`dict` or :any:`None` 58 | Dictionary containing keyword names in the type-curve for each value. 59 | 60 | {value-name: string for plot legend} 61 | 62 | This is useful to get better plots. 63 | By default, parameter names will be value names. 64 | Default: None 65 | testinclude : :class:`dict`, optional 66 | Dictionary of which tests should be included. If ``None`` is given, 67 | all available tests are included. 68 | Default: ``None`` 69 | generate : :class:`bool`, optional 70 | State if time stepping, processed observation data and estimation 71 | setup should be generated with default values. 72 | Default: ``False`` 73 | """ 74 | 75 | def __init__( 76 | self, 77 | name, 78 | campaign, 79 | type_curve, 80 | val_ranges, 81 | make_steady=True, 82 | val_fix=None, 83 | val_fit_type=None, 84 | val_fit_name=None, 85 | val_plot_names=None, 86 | testinclude=None, 87 | generate=False, 88 | ): 89 | val_fix = {} if val_fix is None else val_fix 90 | self.setup_kw = { 91 | "type_curve": type_curve, 92 | "val_ranges": val_ranges, 93 | "val_fix": val_fix, 94 | "val_fit_type": val_fit_type, 95 | "val_fit_name": val_fit_name, 96 | "val_plot_names": val_plot_names, 97 | } 98 | """:class:`dict`: TypeCurve Spotpy Setup definition""" 99 | self.name = name 100 | """:class:`str`: Name of the Estimation""" 101 | self.campaign_raw = dcopy(campaign) 102 | """:class:`welltestpy.data.Campaign`:\ 103 | Copy of the original input campaign""" 104 | self.campaign = dcopy(campaign) 105 | """:class:`welltestpy.data.Campaign`:\ 106 | Copy of the input campaign to be modified""" 107 | 108 | self.prate = None 109 | """:class:`float`: Pumpingrate at the pumping well""" 110 | 111 | self.rad = None 112 | """:class:`numpy.ndarray`: array of the radii from the wells""" 113 | self.data = None 114 | """:class:`numpy.ndarray`: observation data""" 115 | self.radnames = None 116 | """:class:`numpy.ndarray`: names of the radii well combination""" 117 | self.r_ref = None 118 | """:class:`float`: reference radius of the biggest distance""" 119 | self.h_ref = None 120 | """:class:`float`: reference head at the biggest distance""" 121 | 122 | self.estimated_para = {} 123 | """:class:`dict`: estimated parameters by name""" 124 | self.result = None 125 | """:class:`list`: result of the spotpy estimation""" 126 | self.sens = None 127 | """:class:`dict`: result of the spotpy sensitivity analysis""" 128 | self.testinclude = {} 129 | """:class:`dict`: dictionary of which tests should be included""" 130 | 131 | if testinclude is None: 132 | tests = list(self.campaign.tests.keys()) 133 | self.testinclude = {} 134 | for test in tests: 135 | self.testinclude[test] = self.campaign.tests[ 136 | test 137 | ].observationwells 138 | elif not isinstance(testinclude, dict): 139 | self.testinclude = {} 140 | for test in testinclude: 141 | self.testinclude[test] = self.campaign.tests[ 142 | test 143 | ].observationwells 144 | else: 145 | self.testinclude = testinclude 146 | 147 | for test in self.testinclude: 148 | if not isinstance(self.campaign.tests[test], testslib.PumpingTest): 149 | raise ValueError(test + " is not a pumping test.") 150 | if make_steady is not False: 151 | if make_steady is True: 152 | make_steady = "latest" 153 | self.campaign.tests[test].make_steady(make_steady) 154 | if not self.campaign.tests[test].constant_rate: 155 | raise ValueError(test + " is not a constant rate test.") 156 | if ( 157 | not self.campaign.tests[test].state( 158 | wells=self.testinclude[test] 159 | ) 160 | == "steady" 161 | ): 162 | raise ValueError(test + ": selection is not steady.") 163 | 164 | rwell_list = [] 165 | rinf_list = [] 166 | for test in self.testinclude: 167 | pwell = self.campaign.tests[test].pumpingwell 168 | rwell_list.append(self.campaign.wells[pwell].radius) 169 | rinf_list.append(self.campaign.tests[test].radius) 170 | self.rwell = min(rwell_list) 171 | """:class:`float`: radius of the pumping wells""" 172 | self.rinf = max(rinf_list) 173 | """:class:`float`: radius of the furthest wells""" 174 | 175 | if generate: 176 | self.setpumprate() 177 | self.gen_data() 178 | self.gen_setup() 179 | 180 | def setpumprate(self, prate=-1.0): 181 | """Set a uniform pumping rate at all pumpingwells wells. 182 | 183 | We assume linear scaling by the pumpingrate. 184 | 185 | Parameters 186 | ---------- 187 | prate : :class:`float`, optional 188 | Pumping rate. Default: ``-1.0`` 189 | """ 190 | for test in self.testinclude: 191 | processlib.normpumptest( 192 | self.campaign.tests[test], pumpingrate=prate 193 | ) 194 | self.prate = prate 195 | 196 | def gen_data(self): 197 | """Generate the observed drawdown. 198 | 199 | It will also generate an array containing all radii of all well 200 | combinations. 201 | """ 202 | rad = np.array([]) 203 | data = np.array([]) 204 | 205 | radnames = [] 206 | 207 | for test in self.testinclude: 208 | pwell = self.campaign.wells[self.campaign.tests[test].pumpingwell] 209 | for obs in self.testinclude[test]: 210 | temphead = self.campaign.tests[test].observations[obs]() 211 | data = np.hstack((data, temphead)) 212 | 213 | owell = self.campaign.wells[obs] 214 | if pwell == owell: 215 | temprad = pwell.radius 216 | else: 217 | temprad = pwell - owell 218 | rad = np.hstack((rad, temprad)) 219 | 220 | tempname = (self.campaign.tests[test].pumpingwell, obs) 221 | radnames.append(tempname) 222 | 223 | # sort everything by the radii 224 | idx = rad.argsort() 225 | radnames = np.array(radnames) 226 | self.rad = rad[idx] 227 | self.data = data[idx] 228 | self.radnames = radnames[idx] 229 | self.r_ref = self.rad[-1] 230 | self.h_ref = self.data[-1] 231 | 232 | def gen_setup( 233 | self, 234 | prate_kw="rate", 235 | rad_kw="rad", 236 | r_ref_kw="r_ref", 237 | h_ref_kw="h_ref", 238 | dummy=False, 239 | ): 240 | """Generate the Spotpy Setup. 241 | 242 | Parameters 243 | ---------- 244 | prate_kw : :class:`str`, optional 245 | Keyword name for the pumping rate in the used type curve. 246 | Default: "rate" 247 | rad_kw : :class:`str`, optional 248 | Keyword name for the radius in the used type curve. 249 | Default: "rad" 250 | r_ref_kw : :class:`str`, optional 251 | Keyword name for the reference radius in the used type curve. 252 | Default: "r_ref" 253 | h_ref_kw : :class:`str`, optional 254 | Keyword name for the reference head in the used type curve. 255 | Default: "h_ref" 256 | dummy : :class:`bool`, optional 257 | Add a dummy parameter to the model. This could be used to equalize 258 | sensitivity analysis. 259 | Default: False 260 | """ 261 | self.extra_kw_names = {"rad": rad_kw} 262 | setup_kw = dcopy(self.setup_kw) # create a copy here 263 | setup_kw["val_fix"].setdefault(prate_kw, self.prate) 264 | setup_kw["val_fix"].setdefault(rad_kw, self.rad) 265 | setup_kw["val_fix"].setdefault(r_ref_kw, self.r_ref) 266 | setup_kw["val_fix"].setdefault(h_ref_kw, self.h_ref) 267 | setup_kw.setdefault("data", self.data) 268 | setup_kw["dummy"] = dummy 269 | self.setup = spotpylib.TypeCurve(**setup_kw) 270 | 271 | def run( 272 | self, 273 | rep=5000, 274 | parallel="seq", 275 | run=True, 276 | folder=None, 277 | dbname=None, 278 | traceplotname=None, 279 | fittingplotname=None, 280 | interactplotname=None, 281 | estname=None, 282 | plot_style="WTP", 283 | ): 284 | """Run the estimation. 285 | 286 | Parameters 287 | ---------- 288 | rep : :class:`int`, optional 289 | The number of repetitions within the SCEua algorithm in spotpy. 290 | Default: ``5000`` 291 | parallel : :class:`str`, optional 292 | State if the estimation should be run in parallel or not. Options: 293 | 294 | * ``"seq"``: sequential on one CPU 295 | * ``"mpi"``: use the mpi4py package 296 | 297 | Default: ``"seq"`` 298 | run : :class:`bool`, optional 299 | State if the estimation should be executed. Otherwise all plots 300 | will be done with the previous results. 301 | Default: ``True`` 302 | folder : :class:`str`, optional 303 | Path to the output folder. If ``None`` the CWD is used. 304 | Default: ``None`` 305 | dbname : :class:`str`, optional 306 | File-name of the database of the spotpy estimation. 307 | If ``None``, it will be the current time + 308 | ``"_db"``. 309 | Default: ``None`` 310 | traceplotname : :class:`str`, optional 311 | File-name of the parameter trace plot of the spotpy estimation. 312 | If ``None``, it will be the current time + 313 | ``"_paratrace.pdf"``. 314 | Default: ``None`` 315 | fittingplotname : :class:`str`, optional 316 | File-name of the fitting plot of the estimation. 317 | If ``None``, it will be the current time + 318 | ``"_fit.pdf"``. 319 | Default: ``None`` 320 | interactplotname : :class:`str`, optional 321 | File-name of the parameter interaction plot 322 | of the spotpy estimation. 323 | If ``None``, it will be the current time + 324 | ``"_parainteract.pdf"``. 325 | Default: ``None`` 326 | estname : :class:`str`, optional 327 | File-name of the results of the spotpy estimation. 328 | If ``None``, it will be the current time + 329 | ``"_estimate"``. 330 | Default: ``None`` 331 | plot_style : str, optional 332 | Plot style. The default is "WTP". 333 | """ 334 | if self.setup.dummy: 335 | raise ValueError( 336 | "Estimate: for parameter estimation" 337 | " you can't use a dummy parameter." 338 | ) 339 | act_time = timemodule.strftime("%Y-%m-%d_%H-%M-%S") 340 | 341 | # generate the filenames 342 | if folder is None: 343 | folder = os.path.join(os.getcwd(), self.name) 344 | folder = os.path.abspath(folder) 345 | if not os.path.exists(folder): 346 | os.makedirs(folder) 347 | 348 | if dbname is None: 349 | dbname = os.path.join(folder, act_time + "_db") 350 | elif not os.path.isabs(dbname): 351 | dbname = os.path.join(folder, dbname) 352 | if traceplotname is None: 353 | traceplotname = os.path.join(folder, act_time + "_paratrace.pdf") 354 | elif not os.path.isabs(traceplotname): 355 | traceplotname = os.path.join(folder, traceplotname) 356 | if fittingplotname is None: 357 | fittingplotname = os.path.join(folder, act_time + "_fit.pdf") 358 | elif not os.path.isabs(fittingplotname): 359 | fittingplotname = os.path.join(folder, fittingplotname) 360 | if interactplotname is None: 361 | interactplotname = os.path.join(folder, act_time + "_interact.pdf") 362 | elif not os.path.isabs(interactplotname): 363 | interactplotname = os.path.join(folder, interactplotname) 364 | if estname is None: 365 | paraname = os.path.join(folder, act_time + "_estimate.txt") 366 | elif not os.path.isabs(estname): 367 | paraname = os.path.join(folder, estname) 368 | 369 | # generate the parameter-names for plotting 370 | paranames = dcopy(self.setup.para_names) 371 | paralabels = [] 372 | for name in paranames: 373 | p_label = self.setup.val_plot_names[name] 374 | fit_n = self.setup.val_fit_name[name] 375 | paralabels.append(f"{fit_n}({p_label})" if fit_n else p_label) 376 | 377 | if parallel == "mpi": 378 | # send the dbname of rank0 379 | from mpi4py import MPI 380 | 381 | comm = MPI.COMM_WORLD 382 | rank = comm.Get_rank() 383 | size = comm.Get_size() 384 | if rank == 0: 385 | print(rank, "send dbname:", dbname) 386 | for i in range(1, size): 387 | comm.send(dbname, dest=i, tag=0) 388 | else: 389 | dbname = comm.recv(source=0, tag=0) 390 | print(rank, "got dbname:", dbname) 391 | else: 392 | rank = 0 393 | 394 | # initialize the sampler 395 | sampler = spotpy.algorithms.sceua( 396 | self.setup, 397 | dbname=dbname, 398 | dbformat="csv", 399 | parallel=parallel, 400 | save_sim=True, 401 | db_precision=np.float64, 402 | ) 403 | # start the estimation with the sce-ua algorithm 404 | if run: 405 | sampler.sample(rep, ngs=10, kstop=100, pcento=1e-4, peps=1e-3) 406 | 407 | if rank == 0: 408 | if run: 409 | self.result = sampler.getdata() 410 | else: 411 | self.result = np.genfromtxt( 412 | dbname + ".csv", delimiter=",", names=True 413 | ) 414 | para_opt = spotpy.analyser.get_best_parameterset( 415 | self.result, maximize=False 416 | ) 417 | void_names = para_opt.dtype.names 418 | para = [] 419 | header = [] 420 | for name in void_names: 421 | para.append(para_opt[0][name]) 422 | fit_n = self.setup.val_fit_name[name[3:]] 423 | header.append(f"{fit_n}-{name[3:]}" if fit_n else name[3:]) 424 | self.estimated_para[name[3:]] = para[-1] 425 | np.savetxt(paraname, para, header=" ".join(header)) 426 | # plot the estimation-results 427 | plotter.plotparatrace( 428 | result=self.result, 429 | parameternames=paranames, 430 | parameterlabels=paralabels, 431 | stdvalues=self.estimated_para, 432 | plotname=traceplotname, 433 | style=plot_style, 434 | ) 435 | plotter.plotfit_steady( 436 | setup=self.setup, 437 | data=self.data, 438 | para=self.estimated_para, 439 | rad=self.rad, 440 | radnames=self.radnames, 441 | extra=self.extra_kw_names, 442 | plotname=fittingplotname, 443 | style=plot_style, 444 | ) 445 | plotter.plotparainteract( 446 | self.result, paralabels, interactplotname, style=plot_style 447 | ) 448 | 449 | def sensitivity( 450 | self, 451 | rep=None, 452 | parallel="seq", 453 | folder=None, 454 | dbname=None, 455 | plotname=None, 456 | traceplotname=None, 457 | sensname=None, 458 | plot_style="WTP", 459 | ): 460 | """Run the sensitivity analysis. 461 | 462 | Parameters 463 | ---------- 464 | rep : :class:`int`, optional 465 | The number of repetitions within the FAST algorithm in spotpy. 466 | Default: estimated 467 | parallel : :class:`str`, optional 468 | State if the estimation should be run in parallel or not. Options: 469 | 470 | * ``"seq"``: sequential on one CPU 471 | * ``"mpi"``: use the mpi4py package 472 | 473 | Default: ``"seq"`` 474 | folder : :class:`str`, optional 475 | Path to the output folder. If ``None`` the CWD is used. 476 | Default: ``None`` 477 | dbname : :class:`str`, optional 478 | File-name of the database of the spotpy estimation. 479 | If ``None``, it will be the current time + 480 | ``"_sensitivity_db"``. 481 | Default: ``None`` 482 | plotname : :class:`str`, optional 483 | File-name of the result plot of the sensitivity analysis. 484 | If ``None``, it will be the current time + 485 | ``"_sensitivity.pdf"``. 486 | Default: ``None`` 487 | traceplotname : :class:`str`, optional 488 | File-name of the parameter trace plot of the spotpy sensitivity 489 | analysis. 490 | If ``None``, it will be the current time + 491 | ``"_senstrace.pdf"``. 492 | Default: ``None`` 493 | sensname : :class:`str`, optional 494 | File-name of the results of the FAST estimation. 495 | If ``None``, it will be the current time + 496 | ``"_estimate"``. 497 | Default: ``None`` 498 | plot_style : str, optional 499 | Plot style. The default is "WTP". 500 | """ 501 | if len(self.setup.para_names) == 1 and not self.setup.dummy: 502 | raise ValueError( 503 | "Sensitivity: for estimation with only one parameter" 504 | " you have to use a dummy parameter." 505 | ) 506 | if rep is None: 507 | rep = spotpylib.fast_rep( 508 | len(self.setup.para_names) + int(self.setup.dummy) 509 | ) 510 | 511 | act_time = timemodule.strftime("%Y-%m-%d_%H-%M-%S") 512 | # generate the filenames 513 | if folder is None: 514 | folder = os.path.join(os.getcwd(), self.name) 515 | folder = os.path.abspath(folder) 516 | if not os.path.exists(folder): 517 | os.makedirs(folder) 518 | 519 | if dbname is None: 520 | dbname = os.path.join(folder, act_time + "_sensitivity_db") 521 | elif not os.path.isabs(dbname): 522 | dbname = os.path.join(folder, dbname) 523 | if plotname is None: 524 | plotname = os.path.join(folder, act_time + "_sensitivity.pdf") 525 | elif not os.path.isabs(plotname): 526 | plotname = os.path.join(folder, plotname) 527 | if traceplotname is None: 528 | traceplotname = os.path.join(folder, act_time + "_senstrace.pdf") 529 | elif not os.path.isabs(traceplotname): 530 | traceplotname = os.path.join(folder, traceplotname) 531 | if sensname is None: 532 | sensname = os.path.join(folder, act_time + "_FAST_estimate.txt") 533 | elif not os.path.isabs(sensname): 534 | sensname = os.path.join(folder, sensname) 535 | 536 | sens_base, sens_ext = os.path.splitext(sensname) 537 | sensname1 = sens_base + "_S1" + sens_ext 538 | 539 | # generate the parameter-names for plotting 540 | paranames = dcopy(self.setup.para_names) 541 | paralabels = [] 542 | for name in paranames: 543 | p_label = self.setup.val_plot_names[name] 544 | fit_n = self.setup.val_fit_name[name] 545 | paralabels.append(f"{fit_n}({p_label})" if fit_n else p_label) 546 | 547 | if self.setup.dummy: 548 | paranames.append("dummy") 549 | paralabels.append("dummy") 550 | 551 | if parallel == "mpi": 552 | # send the dbname of rank0 553 | from mpi4py import MPI 554 | 555 | comm = MPI.COMM_WORLD 556 | rank = comm.Get_rank() 557 | size = comm.Get_size() 558 | if rank == 0: 559 | print(rank, "send dbname:", dbname) 560 | for i in range(1, size): 561 | comm.send(dbname, dest=i, tag=0) 562 | else: 563 | dbname = comm.recv(source=0, tag=0) 564 | print(rank, "got dbname:", dbname) 565 | else: 566 | rank = 0 567 | 568 | # initialize the sampler 569 | sampler = spotpy.algorithms.fast( 570 | self.setup, 571 | dbname=dbname, 572 | dbformat="csv", 573 | parallel=parallel, 574 | save_sim=True, 575 | db_precision=np.float64, 576 | ) 577 | sampler.sample(rep) 578 | 579 | if rank == 0: 580 | data = sampler.getdata() 581 | parmin = sampler.parameter()["minbound"] 582 | parmax = sampler.parameter()["maxbound"] 583 | bounds = list(zip(parmin, parmax)) 584 | sens_est = sampler.analyze( 585 | bounds, np.nan_to_num(data["like1"]), len(paranames), paranames 586 | ) 587 | self.sens = {} 588 | for sen_typ in sens_est: 589 | self.sens[sen_typ] = { 590 | par: sen for par, sen in zip(paranames, sens_est[sen_typ]) 591 | } 592 | header = " ".join(paranames) 593 | np.savetxt(sensname, sens_est["ST"], header=header) 594 | np.savetxt(sensname1, sens_est["S1"], header=header) 595 | plotter.plotsensitivity( 596 | paralabels, sens_est, plotname, style=plot_style 597 | ) 598 | plotter.plotparatrace( 599 | data, 600 | parameternames=paranames, 601 | parameterlabels=paralabels, 602 | stdvalues=None, 603 | plotname=traceplotname, 604 | style=plot_style, 605 | ) 606 | -------------------------------------------------------------------------------- /src/welltestpy/estimate/transient_lib.py: -------------------------------------------------------------------------------- 1 | """welltestpy subpackage providing base classes for transient estimations.""" 2 | import os 3 | import time as timemodule 4 | from copy import deepcopy as dcopy 5 | 6 | import anaflow as ana 7 | import numpy as np 8 | import spotpy 9 | 10 | from ..data import testslib 11 | from ..process import processlib 12 | from ..tools import plotter 13 | from . import spotpylib 14 | 15 | __all__ = [ 16 | "TransientPumping", 17 | ] 18 | 19 | 20 | class TransientPumping: 21 | """Class to estimate transient Type-Curve parameters. 22 | 23 | Parameters 24 | ---------- 25 | name : :class:`str` 26 | Name of the Estimation. 27 | campaign : :class:`welltestpy.data.Campaign` 28 | The pumping test campaign which should be used to estimate the 29 | parameters 30 | type_curve : :any:`callable` 31 | The given type-curve. Output will be reshaped to flat array. 32 | val_ranges : :class:`dict` 33 | Dictionary containing the fit-ranges for each value in the type-curve. 34 | Names should be as in the type-curve signature. 35 | Ranges should be a tuple containing min and max value. 36 | val_fix : :class:`dict` or :any:`None` 37 | Dictionary containing fixed values for the type-curve. 38 | Names should be as in the type-curve signature. 39 | Default: None 40 | val_fit_type : :class:`dict` or :any:`None` 41 | Dictionary containing fitting transformation type for each value. 42 | Names should be as in the type-curve signature. 43 | val_fit_type can be "lin", "log", "exp", "sqrt", "quad", "inv" 44 | or a tuple of two callable functions where the 45 | first is the transformation and the second is its inverse. 46 | "log" is for example equivalent to ``(np.log, np.exp)``. 47 | By default, values will be fitted linear. 48 | Default: None 49 | val_fit_name : :class:`dict` or :any:`None` 50 | Display name of the fitting transformation. 51 | Will be the val_fit_type string if it is a predefined one, 52 | or ``f`` if it is a given callable as default for each value. 53 | Default: None 54 | val_plot_names : :class:`dict` or :any:`None` 55 | Dictionary containing keyword names in the type-curve for each value. 56 | 57 | {value-name: string for plot legend} 58 | 59 | This is useful to get better plots. 60 | By default, parameter names will be value names. 61 | Default: None 62 | testinclude : :class:`dict`, optional 63 | Dictionary of which tests should be included. If ``None`` is given, 64 | all available tests are included. 65 | Default: ``None`` 66 | generate : :class:`bool`, optional 67 | State if time stepping, processed observation data and estimation 68 | setup should be generated with default values. 69 | Default: ``False`` 70 | """ 71 | 72 | def __init__( 73 | self, 74 | name, 75 | campaign, 76 | type_curve, 77 | val_ranges, 78 | val_fix=None, 79 | val_fit_type=None, 80 | val_fit_name=None, 81 | val_plot_names=None, 82 | testinclude=None, 83 | generate=False, 84 | ): 85 | val_fix = val_fix or {} 86 | self.setup_kw = { 87 | "type_curve": type_curve, 88 | "val_ranges": val_ranges, 89 | "val_fix": val_fix, 90 | "val_fit_type": val_fit_type, 91 | "val_fit_name": val_fit_name, 92 | "val_plot_names": val_plot_names, 93 | } 94 | """:class:`dict`: TypeCurve Spotpy Setup definition""" 95 | self.name = name 96 | """:class:`str`: Name of the Estimation""" 97 | self.campaign_raw = dcopy(campaign) 98 | """:class:`welltestpy.data.Campaign`:\ 99 | Copy of the original input campaign""" 100 | self.campaign = dcopy(campaign) 101 | """:class:`welltestpy.data.Campaign`:\ 102 | Copy of the input campaign to be modified""" 103 | 104 | self.prate = None 105 | """:class:`float`: Pumpingrate at the pumping well""" 106 | 107 | self.time = None 108 | """:class:`numpy.ndarray`: time points of the observation""" 109 | self.rad = None 110 | """:class:`numpy.ndarray`: array of the radii from the wells""" 111 | self.data = None 112 | """:class:`numpy.ndarray`: observation data""" 113 | self.radnames = None 114 | """:class:`numpy.ndarray`: names of the radii well combination""" 115 | 116 | self.estimated_para = {} 117 | """:class:`dict`: estimated parameters by name""" 118 | self.result = None 119 | """:class:`list`: result of the spotpy estimation""" 120 | self.sens = None 121 | """:class:`dict`: result of the spotpy sensitivity analysis""" 122 | self.testinclude = {} 123 | """:class:`dict`: dictionary of which tests should be included""" 124 | 125 | if testinclude is None: 126 | tests = list(self.campaign.tests.keys()) 127 | self.testinclude = {} 128 | for test in tests: 129 | self.testinclude[test] = self.campaign.tests[ 130 | test 131 | ].observationwells 132 | elif not isinstance(testinclude, dict): 133 | self.testinclude = {} 134 | for test in testinclude: 135 | self.testinclude[test] = self.campaign.tests[ 136 | test 137 | ].observationwells 138 | else: 139 | self.testinclude = testinclude 140 | 141 | for test in self.testinclude: 142 | if not isinstance(self.campaign.tests[test], testslib.PumpingTest): 143 | raise ValueError(test + " is not a pumping test.") 144 | if not self.campaign.tests[test].constant_rate: 145 | raise ValueError(test + " is not a constant rate test.") 146 | if ( 147 | not self.campaign.tests[test].state( 148 | wells=self.testinclude[test] 149 | ) 150 | == "transient" 151 | ): 152 | raise ValueError(test + ": selection is not transient.") 153 | 154 | rwell_list = [] 155 | rinf_list = [] 156 | for test in self.testinclude: 157 | pwell = self.campaign.tests[test].pumpingwell 158 | rwell_list.append(self.campaign.wells[pwell].radius) 159 | rinf_list.append(self.campaign.tests[test].radius) 160 | self.rwell = min(rwell_list) 161 | """:class:`float`: radius of the pumping wells""" 162 | self.rinf = max(rinf_list) 163 | """:class:`float`: radius of the furthest wells""" 164 | 165 | if generate: 166 | self.setpumprate() 167 | self.settime() 168 | self.gen_data() 169 | self.gen_setup() 170 | 171 | def setpumprate(self, prate=-1.0): 172 | """Set a uniform pumping rate at all pumpingwells wells. 173 | 174 | We assume linear scaling by the pumpingrate. 175 | 176 | Parameters 177 | ---------- 178 | prate : :class:`float`, optional 179 | Pumping rate. Default: ``-1.0`` 180 | """ 181 | for test in self.testinclude: 182 | processlib.normpumptest( 183 | self.campaign.tests[test], pumpingrate=prate 184 | ) 185 | self.prate = prate 186 | 187 | def settime(self, time=None, tmin=10.0, tmax=np.inf, typ="quad", steps=10): 188 | """Set uniform time points for the observations. 189 | 190 | Parameters 191 | ---------- 192 | time : :class:`numpy.ndarray`, optional 193 | Array of specified time points. If ``None`` is given, they will 194 | be determined by the observation data. 195 | Default: ``None`` 196 | tmin : :class:`float`, optional 197 | Minimal time value. It will set a minimal value of 10s. 198 | Default: ``10`` 199 | tmax : :class:`float`, optional 200 | Maximal time value. 201 | Default: ``inf`` 202 | typ : :class:`str` or :class:`float`, optional 203 | Typ of the time selection. You can select from: 204 | 205 | * ``"exp"``: for exponential behavior 206 | * ``"log"``: for logarithmic behavior 207 | * ``"geo"``: for geometric behavior 208 | * ``"lin"``: for linear behavior 209 | * ``"quad"``: for quadratic behavior 210 | * ``"cub"``: for cubic behavior 211 | * :class:`float`: here you can specifi any exponent 212 | ("quad" would be equivalent to 2) 213 | 214 | Default: "quad" 215 | 216 | steps : :class:`int`, optional 217 | Number of generated time steps. Default: 10 218 | """ 219 | if time is None: 220 | for test in self.testinclude: 221 | for obs in self.testinclude[test]: 222 | _, temptime = self.campaign.tests[test].observations[obs]() 223 | tmin = max(tmin, temptime.min()) 224 | tmax = min(tmax, temptime.max()) 225 | tmin = tmax if tmin > tmax else tmin 226 | time = ana.specialrange(tmin, tmax, steps, typ) 227 | 228 | for test in self.testinclude: 229 | for obs in self.testinclude[test]: 230 | processlib.filterdrawdown( 231 | self.campaign.tests[test].observations[obs], tout=time 232 | ) 233 | 234 | self.time = time 235 | 236 | def gen_data(self): 237 | """Generate the observed drawdown at given time points. 238 | 239 | It will also generate an array containing all radii of all well 240 | combinations. 241 | """ 242 | rad = np.array([]) 243 | data = None 244 | 245 | radnames = [] 246 | 247 | for test in self.testinclude: 248 | pwell = self.campaign.wells[self.campaign.tests[test].pumpingwell] 249 | for obs in self.testinclude[test]: 250 | temphead, _ = self.campaign.tests[test].observations[obs]() 251 | temphead = np.array(temphead).reshape(-1)[np.newaxis].T 252 | 253 | if data is None: 254 | data = dcopy(temphead) 255 | else: 256 | data = np.hstack((data, temphead)) 257 | 258 | owell = self.campaign.wells[obs] 259 | 260 | if pwell == owell: 261 | temprad = pwell.radius 262 | else: 263 | temprad = pwell - owell 264 | rad = np.hstack((rad, temprad)) 265 | 266 | tempname = (self.campaign.tests[test].pumpingwell, obs) 267 | radnames.append(tempname) 268 | 269 | # sort everything by the radii 270 | idx = rad.argsort() 271 | radnames = np.array(radnames) 272 | self.rad = rad[idx] 273 | self.data = data[:, idx] 274 | self.radnames = radnames[idx] 275 | 276 | def gen_setup( 277 | self, prate_kw="rate", rad_kw="rad", time_kw="time", dummy=False 278 | ): 279 | """Generate the Spotpy Setup. 280 | 281 | Parameters 282 | ---------- 283 | prate_kw : :class:`str`, optional 284 | Keyword name for the pumping rate in the used type curve. 285 | Default: "rate" 286 | rad_kw : :class:`str`, optional 287 | Keyword name for the radius in the used type curve. 288 | Default: "rad" 289 | time_kw : :class:`str`, optional 290 | Keyword name for the time in the used type curve. 291 | Default: "time" 292 | dummy : :class:`bool`, optional 293 | Add a dummy parameter to the model. This could be used to equalize 294 | sensitivity analysis. 295 | Default: False 296 | """ 297 | self.extra_kw_names = {"rad": rad_kw, "time": time_kw} 298 | setup_kw = dcopy(self.setup_kw) 299 | setup_kw["val_fix"].setdefault(prate_kw, self.prate) 300 | setup_kw["val_fix"].setdefault(rad_kw, self.rad) 301 | setup_kw["val_fix"].setdefault(time_kw, self.time) 302 | setup_kw.setdefault("data", self.data) 303 | setup_kw["dummy"] = dummy 304 | self.setup = spotpylib.TypeCurve(**setup_kw) 305 | 306 | def run( 307 | self, 308 | rep=5000, 309 | parallel="seq", 310 | run=True, 311 | folder=None, 312 | dbname=None, 313 | traceplotname=None, 314 | fittingplotname=None, 315 | interactplotname=None, 316 | estname=None, 317 | plot_style="WTP", 318 | ): 319 | """Run the estimation. 320 | 321 | Parameters 322 | ---------- 323 | rep : :class:`int`, optional 324 | The number of repetitions within the SCEua algorithm in spotpy. 325 | Default: ``5000`` 326 | parallel : :class:`str`, optional 327 | State if the estimation should be run in parallel or not. Options: 328 | 329 | * ``"seq"``: sequential on one CPU 330 | * ``"mpi"``: use the mpi4py package 331 | 332 | Default: ``"seq"`` 333 | run : :class:`bool`, optional 334 | State if the estimation should be executed. Otherwise all plots 335 | will be done with the previous results. 336 | Default: ``True`` 337 | folder : :class:`str`, optional 338 | Path to the output folder. If ``None`` the CWD is used. 339 | Default: ``None`` 340 | dbname : :class:`str`, optional 341 | File-name of the database of the spotpy estimation. 342 | If ``None``, it will be the current time + 343 | ``"_db"``. 344 | Default: ``None`` 345 | traceplotname : :class:`str`, optional 346 | File-name of the parameter trace plot of the spotpy estimation. 347 | If ``None``, it will be the current time + 348 | ``"_paratrace.pdf"``. 349 | Default: ``None`` 350 | fittingplotname : :class:`str`, optional 351 | File-name of the fitting plot of the estimation. 352 | If ``None``, it will be the current time + 353 | ``"_fit.pdf"``. 354 | Default: ``None`` 355 | interactplotname : :class:`str`, optional 356 | File-name of the parameter interaction plot 357 | of the spotpy estimation. 358 | If ``None``, it will be the current time + 359 | ``"_parainteract.pdf"``. 360 | Default: ``None`` 361 | estname : :class:`str`, optional 362 | File-name of the results of the spotpy estimation. 363 | If ``None``, it will be the current time + 364 | ``"_estimate"``. 365 | Default: ``None`` 366 | plot_style : str, optional 367 | Plot style. The default is "WTP". 368 | """ 369 | if self.setup.dummy: 370 | raise ValueError( 371 | "Estimate: for parameter estimation" 372 | " you can't use a dummy paramter." 373 | ) 374 | act_time = timemodule.strftime("%Y-%m-%d_%H-%M-%S") 375 | 376 | # generate the filenames 377 | if folder is None: 378 | folder = os.path.join(os.getcwd(), self.name) 379 | folder = os.path.abspath(folder) 380 | if not os.path.exists(folder): 381 | os.makedirs(folder) 382 | 383 | if dbname is None: 384 | dbname = os.path.join(folder, act_time + "_db") 385 | elif not os.path.isabs(dbname): 386 | dbname = os.path.join(folder, dbname) 387 | if traceplotname is None: 388 | traceplotname = os.path.join(folder, act_time + "_paratrace.pdf") 389 | elif not os.path.isabs(traceplotname): 390 | traceplotname = os.path.join(folder, traceplotname) 391 | if fittingplotname is None: 392 | fittingplotname = os.path.join(folder, act_time + "_fit.pdf") 393 | elif not os.path.isabs(fittingplotname): 394 | fittingplotname = os.path.join(folder, fittingplotname) 395 | if interactplotname is None: 396 | interactplotname = os.path.join(folder, act_time + "_interact.pdf") 397 | elif not os.path.isabs(interactplotname): 398 | interactplotname = os.path.join(folder, interactplotname) 399 | if estname is None: 400 | paraname = os.path.join(folder, act_time + "_estimate.txt") 401 | elif not os.path.isabs(estname): 402 | paraname = os.path.join(folder, estname) 403 | 404 | # generate the parameter-names for plotting 405 | paranames = dcopy(self.setup.para_names) 406 | paralabels = [] 407 | for name in paranames: 408 | p_label = self.setup.val_plot_names[name] 409 | fit_n = self.setup.val_fit_name[name] 410 | paralabels.append(f"{fit_n}({p_label})" if fit_n else p_label) 411 | 412 | if parallel == "mpi": 413 | # send the dbname of rank0 414 | from mpi4py import MPI 415 | 416 | comm = MPI.COMM_WORLD 417 | rank = comm.Get_rank() 418 | size = comm.Get_size() 419 | if rank == 0: 420 | print(rank, "send dbname:", dbname) 421 | for i in range(1, size): 422 | comm.send(dbname, dest=i, tag=0) 423 | else: 424 | dbname = comm.recv(source=0, tag=0) 425 | print(rank, "got dbname:", dbname) 426 | else: 427 | rank = 0 428 | 429 | # initialize the sampler 430 | sampler = spotpy.algorithms.sceua( 431 | self.setup, 432 | dbname=dbname, 433 | dbformat="csv", 434 | parallel=parallel, 435 | save_sim=True, 436 | db_precision=np.float64, 437 | ) 438 | # start the estimation with the sce-ua algorithm 439 | if run: 440 | sampler.sample(rep, ngs=10, kstop=100, pcento=1e-4, peps=1e-3) 441 | 442 | if rank == 0: 443 | # save best parameter-set 444 | if run: 445 | self.result = sampler.getdata() 446 | else: 447 | self.result = np.genfromtxt( 448 | dbname + ".csv", delimiter=",", names=True 449 | ) 450 | para_opt = spotpy.analyser.get_best_parameterset( 451 | self.result, maximize=False 452 | ) 453 | void_names = para_opt.dtype.names 454 | para = [] 455 | header = [] 456 | for name in void_names: 457 | para.append(para_opt[0][name]) 458 | fit_n = self.setup.val_fit_name[name[3:]] 459 | header.append(f"{fit_n}-{name[3:]}" if fit_n else name[3:]) 460 | self.estimated_para[name[3:]] = para[-1] 461 | np.savetxt(paraname, para, header=" ".join(header)) 462 | # plot the estimation-results 463 | plotter.plotparatrace( 464 | self.result, 465 | parameternames=paranames, 466 | parameterlabels=paralabels, 467 | stdvalues=self.estimated_para, 468 | plotname=traceplotname, 469 | style=plot_style, 470 | ) 471 | plotter.plotfit_transient( 472 | setup=self.setup, 473 | data=self.data, 474 | para=self.estimated_para, 475 | rad=self.rad, 476 | time=self.time, 477 | radnames=self.radnames, 478 | extra=self.extra_kw_names, 479 | plotname=fittingplotname, 480 | style=plot_style, 481 | ) 482 | plotter.plotparainteract( 483 | self.result, paralabels, interactplotname, style=plot_style 484 | ) 485 | 486 | def sensitivity( 487 | self, 488 | rep=None, 489 | parallel="seq", 490 | folder=None, 491 | dbname=None, 492 | plotname=None, 493 | traceplotname=None, 494 | sensname=None, 495 | plot_style="WTP", 496 | ): 497 | """Run the sensitivity analysis. 498 | 499 | Parameters 500 | ---------- 501 | rep : :class:`int`, optional 502 | The number of repetitions within the FAST algorithm in spotpy. 503 | Default: estimated 504 | parallel : :class:`str`, optional 505 | State if the estimation should be run in parallel or not. Options: 506 | 507 | * ``"seq"``: sequential on one CPU 508 | * ``"mpi"``: use the mpi4py package 509 | 510 | Default: ``"seq"`` 511 | folder : :class:`str`, optional 512 | Path to the output folder. If ``None`` the CWD is used. 513 | Default: ``None`` 514 | dbname : :class:`str`, optional 515 | File-name of the database of the spotpy estimation. 516 | If ``None``, it will be the current time + 517 | ``"_sensitivity_db"``. 518 | Default: ``None`` 519 | plotname : :class:`str`, optional 520 | File-name of the result plot of the sensitivity analysis. 521 | If ``None``, it will be the current time + 522 | ``"_sensitivity.pdf"``. 523 | Default: ``None`` 524 | traceplotname : :class:`str`, optional 525 | File-name of the parameter trace plot of the spotpy sensitivity 526 | analysis. 527 | If ``None``, it will be the current time + 528 | ``"_senstrace.pdf"``. 529 | Default: ``None`` 530 | sensname : :class:`str`, optional 531 | File-name of the results of the FAST estimation. 532 | If ``None``, it will be the current time + 533 | ``"_estimate"``. 534 | Default: ``None`` 535 | plot_style : str, optional 536 | Plot style. The default is "WTP". 537 | """ 538 | if len(self.setup.para_names) == 1 and not self.setup.dummy: 539 | raise ValueError( 540 | "Sensitivity: for estimation with only one parameter" 541 | " you have to use a dummy parameter." 542 | ) 543 | if rep is None: 544 | rep = spotpylib.fast_rep( 545 | len(self.setup.para_names) + int(self.setup.dummy) 546 | ) 547 | 548 | act_time = timemodule.strftime("%Y-%m-%d_%H-%M-%S") 549 | # generate the filenames 550 | if folder is None: 551 | folder = os.path.join(os.getcwd(), self.name) 552 | folder = os.path.abspath(folder) 553 | if not os.path.exists(folder): 554 | os.makedirs(folder) 555 | 556 | if dbname is None: 557 | dbname = os.path.join(folder, act_time + "_sensitivity_db") 558 | elif not os.path.isabs(dbname): 559 | dbname = os.path.join(folder, dbname) 560 | if plotname is None: 561 | plotname = os.path.join(folder, act_time + "_sensitivity.pdf") 562 | elif not os.path.isabs(plotname): 563 | plotname = os.path.join(folder, plotname) 564 | if traceplotname is None: 565 | traceplotname = os.path.join(folder, act_time + "_senstrace.pdf") 566 | elif not os.path.isabs(traceplotname): 567 | traceplotname = os.path.join(folder, traceplotname) 568 | if sensname is None: 569 | sensname = os.path.join(folder, act_time + "_FAST_estimate.txt") 570 | elif not os.path.isabs(sensname): 571 | sensname = os.path.join(folder, sensname) 572 | 573 | sens_base, sens_ext = os.path.splitext(sensname) 574 | sensname1 = sens_base + "_S1" + sens_ext 575 | 576 | # generate the parameter-names for plotting 577 | paranames = dcopy(self.setup.para_names) 578 | paralabels = [] 579 | for name in paranames: 580 | p_label = self.setup.val_plot_names[name] 581 | fit_n = self.setup.val_fit_name[name] 582 | paralabels.append(f"{fit_n}({p_label})" if fit_n else p_label) 583 | 584 | if self.setup.dummy: 585 | paranames.append("dummy") 586 | paralabels.append("dummy") 587 | 588 | if parallel == "mpi": 589 | # send the dbname of rank0 590 | from mpi4py import MPI 591 | 592 | comm = MPI.COMM_WORLD 593 | rank = comm.Get_rank() 594 | size = comm.Get_size() 595 | if rank == 0: 596 | print(rank, "send dbname:", dbname) 597 | for i in range(1, size): 598 | comm.send(dbname, dest=i, tag=0) 599 | else: 600 | dbname = comm.recv(source=0, tag=0) 601 | print(rank, "got dbname:", dbname) 602 | else: 603 | rank = 0 604 | 605 | # initialize the sampler 606 | sampler = spotpy.algorithms.fast( 607 | self.setup, 608 | dbname=dbname, 609 | dbformat="csv", 610 | parallel=parallel, 611 | save_sim=True, 612 | db_precision=np.float64, 613 | ) 614 | sampler.sample(rep) 615 | 616 | if rank == 0: 617 | data = sampler.getdata() 618 | parmin = sampler.parameter()["minbound"] 619 | parmax = sampler.parameter()["maxbound"] 620 | bounds = list(zip(parmin, parmax)) 621 | sens_est = sampler.analyze( 622 | bounds, np.nan_to_num(data["like1"]), len(paranames), paranames 623 | ) 624 | self.sens = {} 625 | for sen_typ in sens_est: 626 | self.sens[sen_typ] = { 627 | par: sen for par, sen in zip(paranames, sens_est[sen_typ]) 628 | } 629 | header = " ".join(paranames) 630 | np.savetxt(sensname, sens_est["ST"], header=header) 631 | np.savetxt(sensname1, sens_est["S1"], header=header) 632 | plotter.plotsensitivity( 633 | paralabels, sens_est, plotname, style=plot_style 634 | ) 635 | plotter.plotparatrace( 636 | data, 637 | parameternames=paranames, 638 | parameterlabels=paralabels, 639 | stdvalues=None, 640 | plotname=traceplotname, 641 | style=plot_style, 642 | ) 643 | -------------------------------------------------------------------------------- /src/welltestpy/process/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | welltestpy subpackage providing routines to pre process test data. 3 | 4 | .. currentmodule:: welltestpy.process 5 | 6 | Included functions 7 | ^^^^^^^^^^^^^^^^^^ 8 | 9 | The following classes and functions are provided 10 | 11 | .. autosummary:: 12 | :toctree: 13 | 14 | normpumptest 15 | combinepumptest 16 | filterdrawdown 17 | cooper_jacob_correction 18 | smoothing_derivative 19 | """ 20 | from .processlib import ( 21 | combinepumptest, 22 | cooper_jacob_correction, 23 | filterdrawdown, 24 | normpumptest, 25 | smoothing_derivative, 26 | ) 27 | 28 | __all__ = [ 29 | "normpumptest", 30 | "combinepumptest", 31 | "filterdrawdown", 32 | "cooper_jacob_correction", 33 | "smoothing_derivative", 34 | ] 35 | -------------------------------------------------------------------------------- /src/welltestpy/process/processlib.py: -------------------------------------------------------------------------------- 1 | """welltestpy subpackage providing functions to pre process data.""" 2 | from copy import deepcopy as dcopy 3 | 4 | import numpy as np 5 | from scipy import signal 6 | 7 | from ..data import testslib 8 | 9 | __all__ = [ 10 | "normpumptest", 11 | "combinepumptest", 12 | "filterdrawdown", 13 | "cooper_jacob_correction", 14 | "smoothing_derivative", 15 | ] 16 | 17 | 18 | def normpumptest(pumptest, pumpingrate=-1.0, factor=1.0): 19 | """Normalize the pumping rate of a pumping test. 20 | 21 | Parameters 22 | ---------- 23 | pumpingrate : :class:`float`, optional 24 | Pumping rate. Default: ``-1.0`` 25 | factor : :class:`float`, optional 26 | Scaling factor that can be used for unit conversion. Default: ``1.0`` 27 | """ 28 | if not isinstance(pumptest, testslib.PumpingTest): 29 | raise ValueError(str(pumptest) + " is no pumping test") 30 | 31 | if not pumptest.constant_rate: 32 | raise ValueError(str(pumptest) + " is no constant rate pumping test") 33 | 34 | oldprate = dcopy(pumptest.rate) 35 | pumptest.pumpingrate = pumpingrate 36 | 37 | for obs in pumptest.observations: 38 | pumptest.observations[obs].observation *= ( 39 | factor * pumptest.rate / oldprate 40 | ) 41 | 42 | 43 | def combinepumptest( 44 | campaign, 45 | test1, 46 | test2, 47 | pumpingrate=None, 48 | finalname=None, 49 | factor1=1.0, 50 | factor2=1.0, 51 | infooftest1=True, 52 | replace=True, 53 | ): 54 | """Combine two pumping tests to one. 55 | 56 | They need to have the same pumping well. 57 | 58 | Parameters 59 | ---------- 60 | campaign : :class:`welltestpy.data.Campaign` 61 | The pumping test campaign which should be used. 62 | test1 : :class:`str` 63 | Name of test 1. 64 | test2 : :class:`str` 65 | Name of test 2. 66 | pumpingrate : :class:`float`, optional 67 | Pumping rate. Default: ``-1.0`` 68 | finalname : :class:`str`, optional 69 | Name of the final test. If `replace` is `True` and `finalname` is 70 | `None`, it will get the name of test 1. Else it will get a combined 71 | name of test 1 and test 2. 72 | Default: ``None`` 73 | factor1 : :class:`float`, optional 74 | Scaling factor for test 1 that can be used for unit conversion. 75 | Default: ``1.0`` 76 | factor2 : :class:`float`, optional 77 | Scaling factor for test 2 that can be used for unit conversion. 78 | Default: ``1.0`` 79 | infooftest1 : :class:`bool`, optional 80 | State if the final test should take the information from test 1. 81 | Default: ``True`` 82 | replace : :class:`bool`, optional 83 | State if the original tests should be erased. 84 | Default: ``True`` 85 | """ 86 | if test1 not in campaign.tests: 87 | raise ValueError( 88 | "combinepumptest: " 89 | + str(test1) 90 | + " not a test in " 91 | + "campaign " 92 | + str(campaign.name) 93 | ) 94 | if test2 not in campaign.tests: 95 | raise ValueError( 96 | "combinepumptest: " 97 | + str(test2) 98 | + " not a test in " 99 | + "campaign " 100 | + str(campaign.name) 101 | ) 102 | 103 | if finalname is None: 104 | if replace: 105 | finalname = test1 106 | else: 107 | finalname = test1 + "+" + test2 108 | 109 | if campaign.tests[test1].testtype != "PumpingTest": 110 | raise ValueError( 111 | "combinepumptest:" + str(test1) + " is no pumpingtest" 112 | ) 113 | if campaign.tests[test2].testtype != "PumpingTest": 114 | raise ValueError( 115 | "combinepumptest:" + str(test2) + " is no pumpingtest" 116 | ) 117 | 118 | if campaign.tests[test1].pumpingwell != campaign.tests[test2].pumpingwell: 119 | raise ValueError( 120 | "combinepumptest: The Pumpingtests do not have the " 121 | + "same pumping-well" 122 | ) 123 | 124 | pwell = campaign.tests[test1].pumpingwell 125 | 126 | wellset1 = set(campaign.tests[test1].wells) 127 | wellset2 = set(campaign.tests[test2].wells) 128 | 129 | commonwells = wellset1 & wellset2 130 | 131 | if commonwells != {pwell} and commonwells != set(): 132 | raise ValueError( 133 | "combinepumptest: The Pumpingtests shouldn't have " 134 | + "common observation-wells" 135 | ) 136 | 137 | temptest1 = dcopy(campaign.tests[test1]) 138 | temptest2 = dcopy(campaign.tests[test2]) 139 | 140 | if pumpingrate is None: 141 | if infooftest1: 142 | pumpingrate = temptest1.rate 143 | else: 144 | pumpingrate = temptest2.rate 145 | 146 | normpumptest(temptest1, pumpingrate, factor1) 147 | normpumptest(temptest2, pumpingrate, factor2) 148 | 149 | prate = temptest1.rate 150 | 151 | if infooftest1: 152 | if pwell in temptest1.observations and pwell in temptest2.observations: 153 | temptest2.del_observations(pwell) 154 | aquiferdepth = temptest1.depth 155 | aquiferradius = temptest1.radius 156 | description = temptest1.description 157 | timeframe = temptest1.timeframe 158 | else: 159 | if pwell in temptest1.observations and pwell in temptest2.observations: 160 | temptest1.del_observations(pwell) 161 | aquiferdepth = temptest2.depth 162 | aquiferradius = temptest2.radius 163 | description = temptest2.description 164 | timeframe = temptest2.timeframe 165 | 166 | observations = dcopy(temptest1.observations) 167 | observations.update(temptest2.observations) 168 | 169 | if infooftest1: 170 | aquiferdepth = temptest1.depth 171 | aquiferradius = temptest1.radius 172 | description = temptest1.description 173 | timeframe = temptest1.timeframe 174 | else: 175 | aquiferdepth = temptest2.depth 176 | aquiferradius = temptest2.radius 177 | description = temptest2.description 178 | timeframe = temptest2.timeframe 179 | 180 | finalpt = testslib.PumpingTest( 181 | finalname, 182 | pwell, 183 | prate, 184 | observations, 185 | aquiferdepth, 186 | aquiferradius, 187 | description, 188 | timeframe, 189 | ) 190 | 191 | campaign.addtests(finalpt) 192 | 193 | if replace: 194 | campaign.deltests([test1, test2]) 195 | 196 | 197 | def filterdrawdown(observation, tout=None, dxscale=2): 198 | """Smooth the drawdown data of an observation well. 199 | 200 | Parameters 201 | ---------- 202 | observation : :class:`welltestpy.data.Observation` 203 | The observation to be smoothed. 204 | tout : :class:`numpy.ndarray`, optional 205 | Time points to evaluate the smoothed observation at. If ``None``, 206 | the original time points of the observation are taken. 207 | Default: ``None`` 208 | dxscale : :class:`int`, optional 209 | Scale of time-steps used for smoothing. 210 | Default: ``2`` 211 | """ 212 | head, time = observation() 213 | head = np.array(head, dtype=float).reshape(-1) 214 | time = np.array(time, dtype=float).reshape(-1) 215 | 216 | if tout is None: 217 | tout = dcopy(time) 218 | tout = np.array(tout, dtype=float).reshape(-1) 219 | 220 | if len(time) == 1: 221 | return observation(time=tout, observation=np.full_like(tout, head[0])) 222 | 223 | # make the data equal-spaced to use filter with 224 | # a fraction of the minimal timestep 225 | dxv = dxscale * int((time[-1] - time[0]) / max(np.diff(time).min(), 1.0)) 226 | tequal = np.linspace(time[0], time[-1], dxv) 227 | hequal = np.interp(tequal, time, head) 228 | # size = h.max() - h.min() 229 | 230 | try: 231 | para1, para2 = signal.butter(1, 0.025) # size/10.) 232 | hfilt = signal.filtfilt(para1, para2, hequal, padlen=150) 233 | hout = np.interp(tout, tequal, hfilt) 234 | except ValueError: # in this case there are to few data points 235 | hout = np.interp(tout, time, head) 236 | 237 | return observation(time=tout, observation=hout) 238 | 239 | 240 | def cooper_jacob_correction(observation, sat_thickness): 241 | """ 242 | Correction method for observed drawdown for unconfined aquifers. 243 | 244 | Parameters 245 | ---------- 246 | observation : :class:`welltestpy.data.Observation` 247 | The observation to be corrected. 248 | sat_thickness : :class:`float` 249 | Vertical length of the aquifer in which its pores are filled with water. 250 | 251 | Returns 252 | ------- 253 | The corrected drawdown 254 | 255 | """ 256 | # split the observations into array for head. 257 | head = observation.observation 258 | 259 | # cooper and jacob correction 260 | head = head - (head**2) / (2 * sat_thickness) 261 | 262 | # return new observation 263 | observation(observation=head) 264 | 265 | return observation 266 | 267 | 268 | def smoothing_derivative(head, time, method="bourdet"): 269 | """Calculate the derivative of the drawdown curve. 270 | 271 | Parameters 272 | ---------- 273 | head : :class: 'array' 274 | An array with the observed head values. 275 | time: :class: 'array' 276 | An array with the time values for the observed head values. 277 | method : :class:`str`, optional 278 | Method to calculate the time derivative. 279 | Default: "bourdet" 280 | 281 | Returns 282 | ------- 283 | The derivative of the observed heads. 284 | 285 | """ 286 | # create arrays for the input of head and time. 287 | derhead = np.zeros(len(head)) 288 | t = np.arange(len(time)) 289 | if method == "bourdet": 290 | for i in t[1:-1]: 291 | # derivative approximation by Bourdet (1989) 292 | dh = ( 293 | ( 294 | (head[i] - head[i - 1]) 295 | / (np.log(time[i]) - np.log(time[i - 1])) 296 | * (np.log(time[i + 1]) - np.log(time[i])) 297 | ) 298 | + ( 299 | (head[i + 1] - head[i]) 300 | / (np.log(time[i + 1]) - np.log(time[i])) 301 | * (np.log(time[i]) - np.log(time[i - 1])) 302 | ) 303 | ) / ( 304 | (np.log(time[i]) - np.log(time[i - 1])) 305 | + (np.log(time[i + 1]) - np.log(time[i])) 306 | ) 307 | derhead[i] = dh 308 | return derhead 309 | else: 310 | raise ValueError( 311 | f"smoothing_derivative: method '{method}' is unknown!" 312 | ) 313 | -------------------------------------------------------------------------------- /src/welltestpy/tools/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | welltestpy subpackage providing miscellaneous tools. 3 | 4 | .. currentmodule:: welltestpy.tools 5 | 6 | Included functions 7 | ^^^^^^^^^^^^^^^^^^ 8 | 9 | The following functions are provided for point triangulation 10 | 11 | .. autosummary:: 12 | :toctree: 13 | 14 | triangulate 15 | sym 16 | 17 | The following plotting routines are provided 18 | 19 | .. autosummary:: 20 | :toctree: 21 | 22 | campaign_plot 23 | fadeline 24 | plot_well_pos 25 | campaign_well_plot 26 | plotfit_transient 27 | plotfit_steady 28 | plotparainteract 29 | plotparatrace 30 | plotsensitivity 31 | diagnostic_plot_pump_test 32 | """ 33 | from . import diagnostic_plots, plotter, trilib 34 | from .diagnostic_plots import diagnostic_plot_pump_test 35 | from .plotter import ( 36 | campaign_plot, 37 | campaign_well_plot, 38 | fadeline, 39 | plot_well_pos, 40 | plotfit_steady, 41 | plotfit_transient, 42 | plotparainteract, 43 | plotparatrace, 44 | plotsensitivity, 45 | ) 46 | from .trilib import sym, triangulate 47 | 48 | __all__ = [ 49 | "triangulate", 50 | "sym", 51 | "campaign_plot", 52 | "fadeline", 53 | "plot_well_pos", 54 | "campaign_well_plot", 55 | "plotfit_transient", 56 | "plotfit_steady", 57 | "plotparainteract", 58 | "plotparatrace", 59 | "plotsensitivity", 60 | "diagnostic_plot_pump_test", 61 | ] 62 | __all__ += ["plotter", "trilib", "diagnostic_plots"] 63 | -------------------------------------------------------------------------------- /src/welltestpy/tools/diagnostic_plots.py: -------------------------------------------------------------------------------- 1 | """welltestpy subpackage to make diagnostic plots.""" 2 | # pylint: disable=C0103 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | 6 | from ..process import processlib 7 | from . import plotter 8 | 9 | 10 | def diagnostic_plot_pump_test( 11 | observation, 12 | rate, 13 | method="bourdet", 14 | linthresh_time=1.0, 15 | linthresh_head=1e-5, 16 | fig=None, 17 | ax=None, 18 | plotname=None, 19 | style="WTP", 20 | ): 21 | """ 22 | Plot the derivative with the original data. 23 | 24 | Parameters 25 | ---------- 26 | observation : :class:`welltestpy.data.Observation` 27 | The observation to calculate the derivative. 28 | rate : :class:`float` 29 | Pumping rate. 30 | method : :class:`str`, optional 31 | Method to calculate the time derivative. 32 | Default: "bourdet" 33 | linthresh_time : :class: 'float' 34 | Range of time around 0 that behaves linear. 35 | Default: 1 36 | linthresh_head : :class: 'float' 37 | Range of head values around 0 that behaves linear. 38 | Default: 1e-5 39 | fig : Figure, optional 40 | Matplotlib figure to plot on. 41 | Default: None. 42 | ax : :class:`Axes` 43 | Matplotlib axes to plot on. 44 | Default: None. 45 | plotname : str, optional 46 | Plot name if the result should be saved. 47 | Default: None. 48 | style : str, optional 49 | Plot style. 50 | Default: "WTP". 51 | 52 | Returns 53 | ------- 54 | Diagnostic plot 55 | """ 56 | head, time = observation() 57 | head = np.array(head, dtype=float).reshape(-1) 58 | time = np.array(time, dtype=float).reshape(-1) 59 | if rate < 0: 60 | head = head * -1 61 | derivative = processlib.smoothing_derivative( 62 | head=head, time=time, method=method 63 | ) 64 | # setting variables 65 | dx = time[1:-1] 66 | dy = derivative[1:-1] 67 | 68 | # plotting 69 | if style == "WTP": 70 | style = "ggplot" 71 | font_size = plt.rcParams.get("font.size", 10.0) 72 | keep_fs = True 73 | with plt.style.context(style): 74 | if keep_fs: 75 | plt.rcParams.update({"font.size": font_size}) 76 | fig, ax = plotter._get_fig_ax(fig, ax) 77 | ax.scatter(time, head, color="C0", label="drawdown") 78 | ax.plot(dx, dy, color="C1", label="time derivative") 79 | ax.set_xscale("symlog", linthresh=linthresh_time) 80 | ax.set_yscale("symlog", linthresh=linthresh_head) 81 | ax.set_xlabel("$t$ in [s]", fontsize=16) 82 | ax.set_ylabel("$h$ and $dh/dx$ in [m]", fontsize=16) 83 | lgd = ax.legend(loc="upper left", facecolor="w") 84 | min_v = min(np.min(head), np.min(dy)) 85 | max_v = max(np.max(head), np.max(dy)) 86 | max_e = int(np.ceil(np.log10(max_v))) 87 | if min_v < linthresh_head: 88 | min_e = -np.inf 89 | else: 90 | min_e = int(np.floor(np.log10(min_v))) 91 | ax.set_ylim(10.0**min_e, 10.0**max_e) 92 | yticks = [0 if min_v < linthresh_head else 10.0**min_e] 93 | thresh_e = int(np.floor(np.log10(linthresh_head))) 94 | first_e = thresh_e if min_v < linthresh_head else (min_e + 1) 95 | yticks += list(10.0 ** np.arange(first_e, max_e + 1)) 96 | ax.set_yticks(yticks) 97 | fig.tight_layout() 98 | if plotname is not None: 99 | fig.savefig( 100 | plotname, 101 | format="pdf", 102 | bbox_extra_artists=(lgd,), 103 | bbox_inches="tight", 104 | ) 105 | return ax 106 | -------------------------------------------------------------------------------- /src/welltestpy/tools/trilib.py: -------------------------------------------------------------------------------- 1 | """welltestpy subpackage providing routines for triangulation.""" 2 | # pylint: disable=C0103 3 | from copy import deepcopy as dcopy 4 | 5 | import numpy as np 6 | 7 | __all__ = ["triangulate", "sym"] 8 | 9 | 10 | def triangulate(distances, prec, all_pos=False): 11 | """Triangulate points by given distances. 12 | 13 | try to triangulate points by given distances within a symmetric matrix 14 | 'distances' with ``distances[i,j] = |pi-pj|`` 15 | 16 | thereby ``p0`` will be set to the origin ``(0,0)`` and ``p1`` to 17 | ``(|p0-p1|,0)`` 18 | 19 | Parameters 20 | ---------- 21 | distances : :class:`numpy.ndarray` 22 | Given distances among the point to be triangulated. 23 | It hat to be a symmetric matrix with a vanishing diagonal and 24 | 25 | ``distances[i,j] = |pi-pj|`` 26 | 27 | If a distance is unknown, you can set it to ``-1``. 28 | prec : :class:`float` 29 | Given Precision to be used within the algorithm. This can be used to 30 | smooth away measure errors 31 | all_pos : :class:`bool`, optional 32 | If `True` all possible constellations will be calculated. Otherwise, 33 | the first possibility will be returned. 34 | Default: False 35 | """ 36 | if not _distvalid(distances, prec / 3.0): 37 | raise ValueError("Given distances are not valid") 38 | 39 | pntscount = np.shape(distances)[0] 40 | 41 | res = [] 42 | 43 | # try to triangulate with all posible starting-point constellations 44 | for n in range(pntscount - 1): 45 | for m in range(n + 1, pntscount): 46 | print("") 47 | print("Startingconstelation {} {}".format(n, m)) 48 | tmpres = _triangulatesgl(distances, n, m, prec) 49 | 50 | for i in tmpres: 51 | res.append(i) 52 | 53 | if res and not all_pos: 54 | break 55 | if res and not all_pos: 56 | break 57 | 58 | if res == []: 59 | print("no possible or unique constellation") 60 | return [] 61 | 62 | res = _rotate(res) 63 | 64 | print("number of overall results: {}".format(len(res))) 65 | sol = [res[0]] 66 | 67 | for i in range(1, len(res)): 68 | addable = True 69 | for j in sol: 70 | addable &= not _solequal(res[i], j, prec) 71 | if addable: 72 | sol.append(res[i]) 73 | 74 | return sol 75 | 76 | 77 | def _triangulatesgl(distances, sp1, sp2, prec): 78 | """ 79 | Try to triangulate points. 80 | 81 | With starting points sp1 and sp2 and a given precision. 82 | Thereby sp1 will be at the origin (0,0) and sp2 will be at (|sp2-sp1|,0). 83 | """ 84 | res = [] 85 | 86 | if distances[sp1, sp2] < -0.5: 87 | return res 88 | 89 | pntscount = np.shape(distances)[0] 90 | 91 | res = [pntscount * [0]] 92 | 93 | dis = distances[sp1, sp2] 94 | 95 | res[0][sp1] = np.array([0.0, 0.0]) 96 | res[0][sp2] = np.array([dis, 0.0]) 97 | 98 | for i in range(pntscount - 2): 99 | print("add point {}".format(i)) 100 | iterres = [] 101 | for sglres in res: 102 | tmpres, state = _addpoints(sglres, distances, prec) 103 | 104 | if state == 0: 105 | for k in tmpres: 106 | iterres.append(dcopy(k)) 107 | 108 | if iterres == []: 109 | return [] 110 | res = dcopy(iterres) 111 | 112 | print("number of temporal results: {}".format(len(res))) 113 | 114 | return res 115 | 116 | 117 | def _addpoints(sol, distances, prec): 118 | """ 119 | Try for each point to add it to a given solution-approach. 120 | 121 | gives all possibilities and a status about the solution: 122 | state = 0: possibilities found 123 | state = 1: no possibilities 124 | state = 2: solution-approach has a contradiction with a point 125 | """ 126 | res = [] 127 | 128 | posfound = False 129 | 130 | # generate all missing points in the solution approach 131 | pleft = [] 132 | for n, m in enumerate(sol): 133 | if np.ndim(m) == 0: 134 | pleft.append(n) 135 | 136 | # try each point to add to the given solution-approach 137 | for i in pleft: 138 | ires, state = _addpoint(sol, i, distances, prec) 139 | 140 | # if a point is addable, add new solution-approach to the result 141 | if state == 0: 142 | posfound = True 143 | for j in ires: 144 | res.append(dcopy(j)) 145 | # if one point gives a contradiction, return empty result and state 2 146 | elif state == 2: 147 | return [], 2 148 | 149 | # if no point is addable, return empty result and state 1 150 | if posfound: 151 | return res, 0 152 | 153 | return res, 1 154 | 155 | 156 | def _addpoint(sol, i, distances, prec): 157 | """ 158 | Try to add point i to a given solution-approach. 159 | 160 | gives all possibilities and a status about the solution: 161 | state = 0: possibilities found 162 | state = 1: no possibilities but no contradiction 163 | state = 2: solution-approach has a contradiction with point i 164 | """ 165 | res = [] 166 | 167 | # if i is already part of the solution return it 168 | if np.ndim(sol[i]) != 0: 169 | return [sol], 0 170 | 171 | # collect the points already present in the solution 172 | solpnts = [] 173 | for n, m in enumerate(sol): 174 | if np.ndim(m) != 0: 175 | solpnts.append(n) 176 | 177 | # number of present points 178 | pntscount = len(solpnts) 179 | 180 | # try to add the point in all possible constellations 181 | for n in range(pntscount - 1): 182 | for m in range(n + 1, pntscount): 183 | tmppnt, state = _pntcoord( 184 | sol, i, solpnts[n], solpnts[m], distances, prec 185 | ) 186 | 187 | # if possiblities are found, add them (at most 2! (think about)) 188 | if state == 0: 189 | for pnt in tmppnt: 190 | res.append(dcopy(sol)) 191 | res[-1][i] = pnt 192 | 193 | # if one possiblity or a contradiction is found, return the result 194 | if state != 1: 195 | return res, state 196 | 197 | # if the state remaind 1, return empty result and no contradiction 198 | return res, state 199 | 200 | 201 | def _pntcoord(sol, i, n, m, distances, prec): 202 | """ 203 | Generate coordinates for point i in constellation to points m and n. 204 | 205 | Check if these coordinates are valid with all other points in the solution. 206 | """ 207 | tmppnt = [] 208 | 209 | state = 1 210 | 211 | pntscount = len(sol) 212 | 213 | # if no distances known, return empty result and the unknown-state 214 | if distances[i, n] < -0.5 or distances[i, m] < -0.5: 215 | return tmppnt, state 216 | 217 | # if the Triangle inequality is not fulfilled give a contradiction 218 | if distances[i, n] + distances[i, m] < _dist(sol[n], sol[m]): 219 | state = 2 220 | return tmppnt, state 221 | 222 | # generate the affine rotation to bring the points in the right place 223 | g = _affinef(*_invtranmat(*_tranmat(sol[n], sol[m]))) 224 | 225 | # generate the coordinates 226 | x = _xvalue(distances[i, n], distances[i, m], _dist(sol[n], sol[m])) 227 | y1, y2 = _yvalue(distances[i, n], distances[i, m], _dist(sol[n], sol[m])) 228 | 229 | # generate the possible positions 230 | pos1 = g(np.array([x, y1])) 231 | pos2 = g(np.array([x, y2])) 232 | 233 | valid1 = True 234 | valid2 = True 235 | 236 | # check if the possible positions are valid 237 | for k in range(pntscount): 238 | if np.ndim(sol[k]) != 0 and distances[i, k] > -0.5: 239 | valid1 &= abs(_dist(sol[k], pos1) - distances[i, k]) < prec 240 | valid2 &= abs(_dist(sol[k], pos2) - distances[i, k]) < prec 241 | 242 | # if any position is valid, add it to the result 243 | if valid1 or valid2: 244 | state = 0 245 | same = abs(y1 - y2) < prec / 4.0 246 | if valid1: 247 | tmppnt.append(dcopy(pos1)) 248 | if valid2 and not same: 249 | tmppnt.append(dcopy(pos2)) 250 | # if the positions are not valid, give a contradiction 251 | else: 252 | state = 2 253 | 254 | return tmppnt, state 255 | 256 | 257 | def _solequal(sol1, sol2, prec): 258 | """ 259 | Compare two different solutions with a given precision. 260 | 261 | Return True if they equal. 262 | """ 263 | res = True 264 | 265 | for sol_1, sol_2 in zip(sol1, sol2): 266 | if np.ndim(sol_1) != 0 and np.ndim(sol_2) != 0: 267 | res &= _dist(sol_1, sol_2) < prec 268 | elif np.ndim(sol_1) != 0 and np.ndim(sol_2) == 0: 269 | return False 270 | elif np.ndim(sol_1) == 0 and np.ndim(sol_2) != 0: 271 | return False 272 | 273 | return res 274 | 275 | 276 | def _distvalid(dis, err=0.0, verbose=True): 277 | """ 278 | Check if the given distances between the points are valid. 279 | 280 | I.e. if they fulfill the triangle-equation. 281 | """ 282 | valid = True 283 | valid &= np.all(dis == dis.T) 284 | valid &= np.all(dis.diagonal() == 0.0) 285 | 286 | pntscount = np.shape(dis)[0] 287 | 288 | for i in range(pntscount - 2): 289 | for j in range(i + 1, pntscount - 1): 290 | for k in range(j + 1, pntscount): 291 | if dis[i, j] > -0.5 and dis[i, k] > -0.5 and dis[j, k] > -0.5: 292 | valid &= dis[i, j] + dis[j, k] >= dis[i, k] - abs(err) 293 | valid &= dis[i, k] + dis[k, j] >= dis[i, j] - abs(err) 294 | valid &= dis[j, i] + dis[i, k] >= dis[j, k] - abs(err) 295 | 296 | if verbose and not dis[i, j] + dis[j, k] >= dis[i, k]: 297 | print("{} {} {} for {}{}".format(i, j, k, i, k)) 298 | if verbose and not dis[i, k] + dis[k, j] >= dis[i, j]: 299 | print("{} {} {} for {}{}".format(i, j, k, i, j)) 300 | if verbose and not dis[j, i] + dis[i, k] >= dis[j, k]: 301 | print("{} {} {} for {}{}".format(i, j, k, j, k)) 302 | 303 | return valid 304 | 305 | 306 | def _xvalue(a, b, c): 307 | """ 308 | Get the x-value for the upper point of a triangle. 309 | 310 | where c is the length of the down side starting in the origin and 311 | lying on the x-axes, a is the distance of the unknown point to the origen 312 | and b is the distance of the unknown point to the righter given point 313 | """ 314 | return (a**2 + c**2 - b**2) / (2 * c) 315 | 316 | 317 | def _yvalue(b, a, c): 318 | """ 319 | Get the two possible y-values for the upper point of a triangle. 320 | 321 | where c is the length of the down side starting in the origin and 322 | lying on the x-axes, a is the distance of the unknown point to the origen 323 | and b is the distance of the unknown point to the righter given point 324 | """ 325 | # check flatness to eliminate numerical errors when the triangle is flat 326 | if a + b <= c or a + c <= b or b + c <= a: 327 | return 0.0, -0.0 328 | 329 | res = 2 * ((a * b) ** 2 + (a * c) ** 2 + (b * c) ** 2) 330 | res -= a**4 + b**4 + c**4 331 | # in case of numerical errors set res to 0 (hope you check validity before) 332 | res = max(res, 0.0) 333 | res = np.sqrt(res) 334 | res /= 2 * c 335 | return res, -res 336 | 337 | 338 | def _rotate(res): 339 | """ 340 | Rotate all solutions in res. 341 | 342 | So that p0 is at the origin and p1 is on the positive x-axes. 343 | """ 344 | rotres = dcopy(res) 345 | 346 | for rot_i, rot_e in enumerate(rotres): 347 | g = _affinef(*_tranmat(rot_e[0], rot_e[1])) 348 | for rot_e_j, rot_e_e in enumerate(rot_e): 349 | rotres[rot_i][rot_e_j] = g(rot_e_e) 350 | 351 | return rotres 352 | 353 | 354 | def _tranmat(a, b): 355 | """ 356 | Get the coefficients for the affine-linear function f(x)=Ax+s. 357 | 358 | Which fulfills that A is a rotation-matrix, 359 | f(a) = [0,0] and f(b) = [|b-a|,0]. 360 | """ 361 | A = np.zeros((2, 2)) 362 | A[0, 0] = b[0] - a[0] 363 | A[1, 1] = b[0] - a[0] 364 | A[1, 0] = -(b[1] - a[1]) 365 | A[0, 1] = +(b[1] - a[1]) 366 | A /= _dist(a, b) 367 | s = -np.dot(A, a) 368 | return A, s 369 | 370 | 371 | def _invtranmat(A, s): 372 | """ 373 | Get the coefficients for the affine-linear function g(x)=Bx+t. 374 | 375 | which is inverse to f(x)=Ax+s 376 | """ 377 | B = np.linalg.inv(A) 378 | t = -np.dot(B, s) 379 | return B, t 380 | 381 | 382 | def _affinef(A, s): 383 | """Get an affine-linear function f(x) = Ax+s.""" 384 | 385 | def func(x): 386 | """Affine-linear function func(x) = Ax+s.""" 387 | return np.dot(A, x) + s 388 | 389 | return func 390 | 391 | 392 | def _affinef_pnt(a1, a2, b1, b2, prec=0.01): 393 | """ 394 | Get an affine-linear function that maps f(ai) = bi. 395 | 396 | if |a2-a1| == |b2-b1| with respect to the given precision 397 | """ 398 | if not abs(_dist(a1, a2) - _dist(b1, b2)) < prec: 399 | raise ValueError("Points are not in isometric relation") 400 | 401 | func_a = _affinef(*_tranmat(a1, a2)) 402 | func_b = _affinef(*_invtranmat(*_tranmat(b1, b2))) 403 | 404 | def func(x): 405 | """Affine-linear function func(ai) = bi.""" 406 | return func_b(func_a(x)) 407 | 408 | return func 409 | 410 | 411 | def _dist(v, w): 412 | """Get the distance between two given point vectors v and w.""" 413 | return np.linalg.norm(np.array(v) - np.array(w)) 414 | 415 | 416 | def sym(A): 417 | """Get the symmetrized version of a lower or upper triangle-matrix A.""" 418 | return A + A.T - np.diag(A.diagonal()) 419 | -------------------------------------------------------------------------------- /tests/test_process.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the unittest of welltestpy.process. 3 | """ 4 | import copy 5 | import unittest 6 | 7 | import numpy as np 8 | 9 | import welltestpy as wtp 10 | 11 | 12 | class TestProcess(unittest.TestCase): 13 | def setUp(self): 14 | # generate artificial data 15 | self.trns_obs = wtp.data.DrawdownObs("trans", [1, 2, 3], [4, 5, 6]) 16 | self.stdy_obs = wtp.data.StdyHeadObs("steady", [1, 2, 3]) 17 | 18 | def test_cooper_jacob(self): 19 | # create copies to not alter data of setUp 20 | trns_copy = copy.deepcopy(self.trns_obs) 21 | stdy_copy = copy.deepcopy(self.stdy_obs) 22 | # apply correction 23 | wtp.process.cooper_jacob_correction(trns_copy, sat_thickness=4) 24 | wtp.process.cooper_jacob_correction(stdy_copy, sat_thickness=4) 25 | # reference values 26 | ref = [0.875, 1.5, 1.875] 27 | # check if correct 28 | self.assertTrue(np.all(np.isclose(ref, trns_copy.observation))) 29 | self.assertTrue(np.all(np.isclose(ref, stdy_copy.observation))) 30 | 31 | 32 | if __name__ == "__main__": 33 | unittest.main() 34 | -------------------------------------------------------------------------------- /tests/test_welltestpy.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is the unittest of AnaFlow. 3 | """ 4 | 5 | import unittest 6 | 7 | import matplotlib as mpl 8 | import numpy as np 9 | 10 | mpl.use("Agg") 11 | 12 | import anaflow as ana 13 | 14 | import welltestpy as wtp 15 | from welltestpy.tools import plot_well_pos, sym, triangulate 16 | 17 | 18 | class TestWTP(unittest.TestCase): 19 | def setUp(self): 20 | self.rate = -1e-4 21 | self.time = np.geomspace(10, 7200, 10) 22 | self.transmissivity = 1e-4 23 | self.storage = 1e-4 24 | self.s_types = ["ST", "S1"] 25 | 26 | def test_create(self): 27 | # create the field-site and the campaign 28 | field = wtp.FieldSite(name="UFZ", coordinates=[51.3538, 12.4313]) 29 | campaign = wtp.Campaign(name="UFZ-campaign", fieldsite=field) 30 | 31 | # add 4 wells to the campaign 32 | campaign.add_well(name="well_0", radius=0.1, coordinates=(0.0, 0.0)) 33 | campaign.add_well(name="well_1", radius=0.1, coordinates=(1.0, -1.0)) 34 | campaign.add_well(name="well_2", radius=0.1, coordinates=(2.0, 2.0)) 35 | campaign.add_well(name="well_3", radius=0.1, coordinates=(-2.0, -1.0)) 36 | 37 | # generate artificial drawdown data with the Theis solution 38 | self.rad = [ 39 | campaign.wells["well_0"].radius, # well radius of well_0 40 | campaign.wells["well_0"] - campaign.wells["well_1"], # dist. 0-1 41 | campaign.wells["well_0"] - campaign.wells["well_2"], # dist. 0-2 42 | campaign.wells["well_0"] - campaign.wells["well_3"], # dist. 0-3 43 | ] 44 | drawdown = ana.theis( 45 | time=self.time, 46 | rad=self.rad, 47 | storage=self.storage, 48 | transmissivity=self.transmissivity, 49 | rate=self.rate, 50 | ) 51 | 52 | # create a pumping test at well_0 53 | pumptest = wtp.PumpingTest( 54 | name="well_0", 55 | pumpingwell="well_0", 56 | pumpingrate=self.rate, 57 | description="Artificial pump test with Theis", 58 | ) 59 | 60 | # add the drawdown observation at the 4 wells 61 | pumptest.add_transient_obs("well_0", self.time, drawdown[:, 0]) 62 | pumptest.add_transient_obs("well_1", self.time, drawdown[:, 1]) 63 | pumptest.add_transient_obs("well_2", self.time, drawdown[:, 2]) 64 | pumptest.add_transient_obs("well_3", self.time, drawdown[:, 3]) 65 | 66 | # add the pumping test to the campaign 67 | campaign.addtests(pumptest) 68 | # plot the well constellation and a test overview 69 | campaign.plot_wells() 70 | campaign.plot() 71 | # save the whole campaign 72 | campaign.save() 73 | # test making steady 74 | campaign.tests["well_0"].make_steady() 75 | 76 | def test_est_theis(self): 77 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 78 | estimation = wtp.estimate.Theis("est_theis", campaign, generate=True) 79 | estimation.run() 80 | res = estimation.estimated_para 81 | estimation.sensitivity() 82 | self.assertAlmostEqual( 83 | np.exp(res["transmissivity"]), self.transmissivity, 2 84 | ) 85 | self.assertAlmostEqual(np.exp(res["storage"]), self.storage, 2) 86 | sens = estimation.sens 87 | for s_typ in self.s_types: 88 | self.assertTrue( 89 | sens[s_typ]["transmissivity"] > sens[s_typ]["storage"] 90 | ) 91 | 92 | def test_est_thiem(self): 93 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 94 | estimation = wtp.estimate.Thiem("est_thiem", campaign, generate=True) 95 | estimation.run() 96 | res = estimation.estimated_para 97 | # since we only have one parameter, 98 | # we need a dummy parameter to estimate sensitivity 99 | estimation.gen_setup(dummy=True) 100 | estimation.sensitivity() 101 | self.assertAlmostEqual( 102 | np.exp(res["transmissivity"]), self.transmissivity, 2 103 | ) 104 | sens = estimation.sens 105 | for s_typ in self.s_types: 106 | self.assertTrue( 107 | sens[s_typ]["transmissivity"] > sens[s_typ]["dummy"] 108 | ) 109 | 110 | def test_est_ext_thiem2D(self): 111 | campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 112 | estimation = wtp.estimate.ExtThiem2D( 113 | "est_ext_thiem2D", campaign, generate=True 114 | ) 115 | estimation.run() 116 | res = estimation.estimated_para 117 | estimation.sensitivity() 118 | self.assertAlmostEqual( 119 | np.exp(res["trans_gmean"]), self.transmissivity, 2 120 | ) 121 | self.assertAlmostEqual(res["var"], 0.0, 0) 122 | sens = estimation.sens 123 | for s_typ in self.s_types: 124 | self.assertTrue(sens[s_typ]["trans_gmean"] > sens[s_typ]["var"]) 125 | self.assertTrue(sens[s_typ]["var"] > sens[s_typ]["len_scale"]) 126 | 127 | # def test_est_ext_thiem3D(self): 128 | # campaign = wtp.load_campaign("Cmp_UFZ-campaign.cmp") 129 | # estimation = wtp.estimate.ExtThiem3D( 130 | # "est_ext_thiem3D", campaign, generate=True 131 | # ) 132 | # estimation.run() 133 | # res = estimation.estimated_para 134 | # estimation.sensitivity() 135 | # self.assertAlmostEqual(np.exp(res["cond_gmean"]), self.transmissivity, 2) 136 | # self.assertAlmostEqual(res["var"], 0.0, 0) 137 | 138 | def test_triangulate(self): 139 | dist_mat = np.zeros((4, 4), dtype=float) 140 | dist_mat[0, 1] = 3 # distance between well 0 and 1 141 | dist_mat[0, 2] = 4 # distance between well 0 and 2 142 | dist_mat[1, 2] = 2 # distance between well 1 and 2 143 | dist_mat[0, 3] = 1 # distance between well 0 and 3 144 | dist_mat[1, 3] = 3 # distance between well 1 and 3 145 | dist_mat[2, 3] = -1 # unknown distance between well 2 and 3 146 | dist_mat = sym(dist_mat) # make the distance matrix symmetric 147 | well_const = triangulate(dist_mat, prec=0.1) 148 | self.assertEqual(len(well_const), 4) 149 | # plot all possible well constellations 150 | plot_well_pos(well_const) 151 | 152 | 153 | if __name__ == "__main__": 154 | unittest.main() 155 | --------------------------------------------------------------------------------