├── .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 | [](https://doi.org/10.5281/zenodo.1229051)
4 | [](https://badge.fury.io/py/welltestpy)
5 | [](https://github.com/GeoStat-Framework/welltestpy/actions)
6 | [](https://coveralls.io/github/GeoStat-Framework/welltestpy?branch=main)
7 | [](https://geostat-framework.readthedocs.io/projects/welltestpy/en/latest/?badge=latest)
8 | [](https://github.com/ambv/black)
9 |
10 |
11 |
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 | Documentation
4 | {{ super() }}
5 |
6 | welltestpy Links
7 |
12 |
13 | GeoStat Framework
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 |
--------------------------------------------------------------------------------