├── .coveragerc ├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── .readthedocs.yml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── dca.png ├── dev-requirements.txt ├── docs ├── Makefile ├── _static │ └── theme_override.css ├── api.rst ├── conf.py ├── examples.rst ├── genindex.rst ├── img │ ├── diagnostics.png │ ├── model.png │ ├── sec_decline_diagnostics.png │ ├── sec_diagnostic_funs.png │ └── secondary_model.png ├── index.rst ├── make.bat ├── requirements.txt ├── testing.rst └── versions.rst ├── petbox └── dca │ ├── __init__.py │ ├── associated.py │ ├── base.py │ ├── bourdet.py │ ├── primary.py │ └── py.typed ├── setup.cfg ├── setup.py └── test ├── __init__.py ├── data.py ├── doc_examples.py ├── test.bat ├── test.sh └── test_dca.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | # branch = True 4 | 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | # Have to re-enable the standard pragma 9 | pragma: no cover 10 | 11 | # Don't complain about missing debug-only code: 12 | def __repr__ 13 | if self\.debug 14 | 15 | # Don't complain if tests don't hit defensive assertion code: 16 | raise AssertionError 17 | raise NotImplementedError 18 | 19 | # Don't complain if non-runnable code isn't run: 20 | if 0: 21 | if __name__ == .__main__.: 22 | 23 | ignore_errors = True 24 | 25 | [html] 26 | directory = test/htmlcov 27 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /test/**/*.png 2 | 3 | 4 | # Other things 5 | *.ipynb 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # VSCode 16 | .vscode/ 17 | *.code-workspace 18 | 19 | # Zips 20 | *.zip 21 | 22 | # Spotfire 23 | *.dxp 24 | 25 | # Vim 26 | *.swp 27 | *.swo 28 | 29 | # Distribution / packaging 30 | .Python 31 | build/ 32 | develop-eggs/ 33 | dist/ 34 | downloads/ 35 | eggs/ 36 | .eggs/ 37 | lib/ 38 | lib64/ 39 | parts/ 40 | sdist/ 41 | var/ 42 | wheels/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | MANIFEST 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .coverage 62 | .coverage.* 63 | .cache 64 | nosetests.xml 65 | coverage.xml 66 | *.cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | 70 | # Translations 71 | *.mo 72 | *.pot 73 | 74 | # Django stuff: 75 | *.log 76 | local_settings.py 77 | db.sqlite3 78 | 79 | # Flask stuff: 80 | instance/ 81 | .webassets-cache 82 | 83 | # Scrapy stuff: 84 | .scrapy 85 | 86 | # Sphinx documentation 87 | docs/_build/ 88 | 89 | # PyBuilder 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # celery beat schedule file 99 | celerybeat-schedule 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF 17 | formats: 18 | - pdf 19 | 20 | # Optionally set the version of Python and requirements required to build your docs 21 | python: 22 | version: 3.8 23 | system_packages: true 24 | install: 25 | - method: pip 26 | path: . 27 | - requirements: docs/requirements.txt 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: "bionic" 2 | 3 | language: "python" 4 | 5 | python: 6 | - "3.8" 7 | - "3.9" 8 | - "3.10" 9 | 10 | install: 11 | - "pip install -U \"flake8==5.0.4\" \"mypy==0.982\" \"numpy>=1.21.1\" \"scipy>=1.7.3\" \"mpmath==1.3.0\" \"pytest==7.2.0\" \"pytest-cov==4.0.0\" \"attrs==22.1.0\" \"hypothesis==6.58.0\" \"coveralls==3.3.1\" \"sphinx<7.0.0\" \"sphinx_rtd_theme==1.2.2\"" 12 | - "pip install ." 13 | 14 | script: 15 | - "flake8 petbox/dca" 16 | - "mypy petbox/dca" 17 | - "pytest" 18 | - "sphinx-build -W -b html docs docs/_build/html" 19 | 20 | notifications: 21 | - email: false 22 | 23 | after_success: 24 | - "coveralls" 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 David S. Fulford 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include docs/*.rst 4 | include docs/Makefile 5 | include docs/make.bat 6 | include docs/conf.py 7 | include docs/_static/* 8 | include docs/img/* 9 | include docs/requirements.txt 10 | include test/*.py 11 | include test/test.bat 12 | include test/test.sh 13 | include .coveragerc 14 | include dev-requirements.txt 15 | include setup.cfg 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================================== 2 | Decline Curve Models ``petbox-dca`` 3 | =================================== 4 | 5 | ----------------------------- 6 | Petroleum Engineering Toolbox 7 | ----------------------------- 8 | 9 | .. image:: https://img.shields.io/pypi/v/petbox-dca.svg 10 | :target: https://pypi.org/project/petbox-dca/ 11 | :alt: PyPi Version 12 | 13 | .. image:: https://travis-ci.org/petbox-dev/dca.svg?branch=master 14 | :target: https://travis-ci.org/github/petbox-dev/dca 15 | :alt: Build Status 16 | 17 | .. image:: https://readthedocs.org/projects/petbox-dca/badge/?version=latest 18 | :target: https://petbox-dca.readthedocs.io/en/latest/?badge=latest 19 | :alt: Documentation Status 20 | 21 | .. image:: https://coveralls.io/repos/github/petbox-dev/dca/badge.svg 22 | :target: https://coveralls.io/github/petbox-dev/dca 23 | :alt: Coverage Status 24 | 25 | .. image:: https://open.vscode.dev/badges/open-in-vscode.svg 26 | :target: https://open.vscode.dev/petbox-dev/dca 27 | :alt: Open in Visual Studio Code 28 | 29 | 30 | Empirical analysis of production data requires implementation of several decline curve models spread over years and multiple SPE publications. Additionally, comprehensive analysis requires graphical analysis among multiple diagnostics plots and their respective plotting functions. While each model's ``q(t)`` (rate) function may be simple, the ``N(t)`` (cumulative volume) may not be. For example, the hyperbolic model has three different forms (hyperbolic, harmonic, exponential), and this is complicated by potentially multiple segments, each of which must be continuous in the rate derivatives. Or, as in the case of the Power-Law Exponential model, the ``N(t)`` function must be numerically evaluated. 31 | 32 | This library defines a single interface to each of the implemented decline curve models. Each model has validation checks for parameter values and provides simple-to-use methods for evaluating arrays of ``time`` to obtain the desired function output. 33 | 34 | Additionally, we also define an interface to attach a GOR/CGR yield function to any primary phase model. We can then obtain the outputs for the secondary phase as easily as the primary phase. 35 | 36 | Analytic functions are implemented wherever possible. When not possible, numerical evaluations are performed using ``scipy.integrate.fixed_quad``. Given that most of the functions of interest that must be numerically evaluated are monotonic, this generally works well. 37 | 38 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 39 | | Primary Phase | `Transient Hyperbolic `_, | 40 | | | `Modified Hyperbolic `_, | 41 | | | `Power-Law Exponential `_, | 42 | | | `Stretched Exponential `_, | 43 | | | `Duong `_ | 44 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 45 | | Secondary Phase | `Power-Law Yield `_ | 46 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 47 | | Water Phase | `Power-Law Yield `_ | 48 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 49 | 50 | The following functions are exposed for use 51 | 52 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 53 | | Base Functions | `rate(t) `_, | 54 | | | `cum(t) `_, | 55 | | | `D(t) `_, | 56 | | | `beta(t) `_, | 57 | | | `b(t) `_, | 58 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 59 | | Interval Volumes | `interval_vol(t) `_, | 60 | | | `monthly_vol(t) `_, | 61 | | | `monthly_vol_equiv(t) `_, | 62 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 63 | | Transient Hyperbolic | `transient_rate(t) `_, | 64 | | | `transient_cum(t) `_, | 65 | | | `transient_D(t) `_, | 66 | | | `transient_beta(t) `_, | 67 | | | `transient_b(t) `_ | 68 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 69 | | Primary Phase | `add_secondary(model) `_, | 70 | | | `add_water(model) `_ | 71 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 72 | | Secondary Phase | `gor(t) `_, | 73 | | | `cgr(t) `_ | 74 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 75 | | Water Phase | `wor(t) `_, | 76 | | | `wgr(t) `_ | 77 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 78 | | Utility | `bourdet(y, x, ...) `_, | 79 | | | `get_time(...) `_, | 80 | | | `get_time_monthly_vol(...) `_ | 81 | +----------------------------+---------------------------------------------------------------------------------------------------------------------------------+ 82 | 83 | 84 | Getting Started 85 | =============== 86 | 87 | Install the library with `pip `_: 88 | 89 | .. code-block:: shell 90 | 91 | pip install petbox-dca 92 | 93 | 94 | A default time array of evenly-logspaced values over 5 log cycles is provided as a convenience. 95 | 96 | .. code-block:: python 97 | 98 | >>> from petbox import dca 99 | >>> t = dca.get_time() 100 | >>> mh = dca.MH(qi=1000.0, Di=0.8, bi=1.8, Dterm=0.08) 101 | >>> mh.rate(t) 102 | array([986.738, 982.789, 977.692, ..., 0.000]) 103 | 104 | 105 | We can also attach secondary phase and water phase models, and evaluate the rate just as easily. 106 | 107 | .. code-block:: python 108 | 109 | >>> mh.add_secondary(dca.PLYield(c=1200.0, m0=0.0, m=0.6, t0=180.0, min=None, max=20_000.0)) 110 | >>> mh.secondary.rate(t) 111 | array([1184.086, 1179.346, 1173.231, ..., 0.000]) 112 | 113 | >>> mh.add_water(dca.PLYield(c=2.0, m0=0.0, m=0.1, t0=90.0, min=None, max=10.0)) 114 | >>> mh.water.rate(t) 115 | array([1.950, 1.935, 1.917, ..., 0.000]) 116 | 117 | 118 | Once instantiated, the same functions and process for attaching a secondary phase work for any model. 119 | 120 | .. code-block:: python 121 | 122 | >>> thm = dca.THM(qi=1000.0, Di=0.8, bi=2.0, bf=0.8, telf=30.0, bterm=0.03, tterm=10.0) 123 | >>> thm.rate(t) 124 | array([968.681, 959.741, 948.451, ..., 0.000]) 125 | 126 | >>> thm.add_secondary(dca.PLYield(c=1200.0, m0=0.0, m=0.6, t0=180.0, min=None, max=20_000.0)) 127 | >>> thm.secondary.rate(t) 128 | array([1162.417, 1151.690, 1138.141, ..., 0.000]) 129 | 130 | >>> ple = dca.PLE(qi=1000.0, Di=0.1, Dinf=0.00001, n=0.5) 131 | >>> ple.rate(t) 132 | array([904.828, 892.092, 877.768, ..., 0.000]) 133 | 134 | >>> ple.add_secondary(dca.PLYield(c=1200.0, m0=0.0, m=0.6, t0=180.0, min=None, max=20_000.0)) 135 | >>> ple.secondary.rate(t) 136 | array([1085.794, 1070.510, 1053.322, ..., 0.000]) 137 | 138 | 139 | Applying the above, we can easily evaluate each model against a data set. 140 | 141 | .. code-block:: python 142 | 143 | >>> import matplotlib.pyplot as plt 144 | >>> fig = plt.figure() 145 | >>> ax1 = fig.add_subplot(121) 146 | >>> ax2 = fig.add_subplot(122) 147 | 148 | >>> ax1.plot(t_data, rate_data, 'o') 149 | >>> ax2.plot(t_data, cum_data, 'o') 150 | 151 | >>> ax1.plot(t, thm.rate(t)) 152 | >>> ax2.plot(t, thm.cum(t) * cum_data[-1] / thm.cum(t_data[-1])) # normalization 153 | 154 | >>> ax1.plot(t, ple.rate(t)) 155 | >>> ax2.plot(t, ple.cum(t) * cum_data[-1] / ple.cum(t_data[-1])) # normalization 156 | 157 | >>> ... 158 | 159 | >>> plt.show() 160 | 161 | .. image:: https://github.com/petbox-dev/dca/raw/master/docs/img/model.png 162 | :alt: model comparison 163 | 164 | 165 | See the `API documentation `_ for a complete listing, detailed use examples, and model comparison. 166 | 167 | 168 | Regression 169 | ========== 170 | No methods for regression are included in this library, as the models are simple enough to be implemented in any regression package. I recommend using `scipy.optimize.least_squares `_. 171 | 172 | For detailed derivation and argument for regression techniques, please see `SPE-201404-MS -- Optimization Methods for Time–Rate–Pressure Production Data Analysis using Automatic Outlier Filtering and Bayesian Derivative Calculations `_. 173 | Additionally, you may view my `blog post `_ on the topic. The Jupyter Notebook is available `here `_. 174 | 175 | The following is an example of how to use the `THM` model with `scipy.optimize.least_squares`. 176 | 177 | 178 | .. code-block:: python 179 | 180 | from petbox import dca 181 | import numpy as np 182 | import scipy as sc 183 | 184 | from scipy.optimize import least_squares 185 | 186 | from typing import NamedTuple 187 | from numpy.typing import NDArray 188 | 189 | 190 | class Bounds(NamedTuple): 191 | qi: tuple[float, float] 192 | Di: tuple[float, float] 193 | bf: tuple[float, float] 194 | telf: tuple[float, float] 195 | 196 | 197 | def load_data() -> tuple[NDArray[np.float64], NDArray[np.float64]]: 198 | ... # load your data here 199 | return rate, time 200 | 201 | 202 | def filter_buildup(rate: NDArray[np.float64], time: NDArray[np.float64]) -> tuple[NDArray[np.float64], NDArray[np.float64]]: 203 | """Filter out buildup data""" 204 | idx = np.argmax(rate) 205 | return rate[idx:], time[idx:] 206 | 207 | 208 | def jitter_rates(rate: NDArray[np.float64]) -> NDArray[np.float64]: 209 | """Add small jitter to rates to improve gradient descent""" 210 | # double-precion has at least 15 digits, so for rates in the 10_000s, this leaves a lot of room 211 | sd = 1e-6 212 | return rate * np.random.normal(1.0, sd, rate.shape) 213 | 214 | 215 | def forecast_thm(params: NDArray[np.float64], time: NDArray[np.float64]) -> NDArray[np.float64]: 216 | """Forecast rates using the Transient Hyperbolic Model""" 217 | thm = dca.THM( 218 | qi=params[0], 219 | Di=params[1], 220 | bi=2.0, 221 | bf=params[2], 222 | telf=params[3], 223 | bterm=0.0, 224 | tterm=0.0 225 | ) 226 | return thm.rate(time) 227 | 228 | 229 | def log1sp(x: NDArray[np.float64]) -> NDArray[np.float64]: 230 | """Add small epsilon to avoid log(0) error""" 231 | return np.log(x + 1e-6) 232 | 233 | 234 | def residuals(params: NDArray[np.float64], time: NDArray[np.float64], rate: NDArray[np.float64]) -> NDArray[np.float64]: 235 | """Residuals for scipy.optimize.least_squares""" 236 | forecast = forecast_thm(params, time) 237 | return log1sp(rate) - log1sp(forecast) 238 | 239 | 240 | rate, time = load_data() 241 | rate, time = filter_buildup(rate, time) # filter out buildup data 242 | rate = jitter_rates(rate) # add small jitter to rates to improve gradient descent 243 | bounds = Bounds( # these ***are not general***, they must be calibrated to your data 244 | qi= (10.0, 10000.0), 245 | Di= (1e-6, 0.8), 246 | bf= ( 0.5, 1.5), 247 | telf= ( 5.0, 50.0) 248 | ) 249 | opt = least_squares( 250 | fun=lambda params, time, rate: residuals(params, time, rate), # residuals function 251 | bounds=list(zip(*bounds)), # unpack bounds into list of tuples 252 | x0=[np.mean(p) for p in bounds], # initial guess, mean works well enough 253 | args=(time, rate), # additional arguments to `fun` 254 | loss='soft_l1', # robust loss function 255 | f_scale=.35 # affects outlier senstivity of the regression, larger values are more sensitive 256 | ) 257 | 258 | # no terminal segment 259 | # bterm = 0.0 260 | # tterm = 0.0 261 | 262 | # hyperbolic terminal segment 263 | bterm = 0.3 264 | tterm = 15.0 # years 265 | 266 | # exponential terminal segment 267 | # bterm = 0.06 # 6.0% secant effective decline / year 268 | # tterm = 0.0 269 | 270 | params = np.r_[np.insert(opt.x, 2, 2.0), bterm, tterm] # insert bi=2.0 and terminal parameters 271 | print(params) 272 | 273 | Which would print something like the following: 274 | 275 | ``[1177.57885, 0.793357559, 2.0, 0.666515071, 7.17744813, 0.3, 15.0]`` 276 | 277 | And passed into the ``THM`` constructor as follows: 278 | 279 | .. code-block:: python 280 | 281 | thm = dca.THM.from_params(params) 282 | 283 | 284 | 285 | Development 286 | =========== 287 | ``petbox-dca`` is maintained by David S. Fulford (`@dsfulf `_). Please post an issue or pull request in this repo for any problems or suggestions! 288 | -------------------------------------------------------------------------------- /dca.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petbox-dev/dca/d5499030dc2bb186e966624f7e0d44f989bbe653/dca.png -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | attrs>=19.2.0 2 | coverage>=5.1 3 | coveralls>=3.2.0 4 | flake8>=5.0.4 5 | hypothesis>=6.14.8 6 | mypy>=0.910 7 | numpy>=1.21.1 8 | pytest>=6.2.4 9 | pytest-cov>=2.12.1 10 | scipy>=1.7.3 11 | Sphinx<7.0.0 12 | sphinx-rtd-theme==1.2.2 13 | wheel>=0.34.2 14 | mpmath>=1.3.0 15 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 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) 21 | -------------------------------------------------------------------------------- /docs/_static/theme_override.css: -------------------------------------------------------------------------------- 1 | /* https://rackerlabs.github.io/docs-rackspace/tools/rtd-tables.html */ 2 | /* override table width restrictions */ 3 | @media screen and (min-width: 767px) { 4 | 5 | .wy-table-responsive table td { 6 | /* !important prevents the common CSS stylesheets from overriding 7 | this as on RTD they are loaded after this stylesheet */ 8 | white-space: normal !important; 9 | } 10 | 11 | .wy-table-responsive { 12 | overflow: visible !important; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | Summary 6 | ======= 7 | 8 | 9 | Primary Phase Models 10 | -------------------- 11 | 12 | .. currentmodule:: petbox.dca 13 | 14 | .. autosummary:: 15 | 16 | THM 17 | MH 18 | PLE 19 | SE 20 | Duong 21 | 22 | 23 | Associated Phase Models 24 | ----------------------- 25 | 26 | 27 | Secondary Phase Models 28 | ~~~~~~~~~~~~~~~~~~~~~~ 29 | 30 | .. currentmodule:: petbox.dca 31 | 32 | .. autosummary:: 33 | 34 | PLYield 35 | 36 | 37 | Water Phase Models 38 | ~~~~~~~~~~~~~~~~~~ 39 | 40 | .. currentmodule:: petbox.dca 41 | 42 | .. autosummary:: 43 | 44 | PLYield 45 | 46 | 47 | Model Functions 48 | =============== 49 | 50 | All Models 51 | ---------- 52 | 53 | .. currentmodule:: petbox.dca.DeclineCurve 54 | 55 | .. autosummary:: 56 | rate 57 | cum 58 | interval_vol 59 | monthly_vol 60 | monthly_vol_equiv 61 | D 62 | beta 63 | b 64 | get_param_desc 65 | get_param_descs 66 | from_params 67 | 68 | 69 | Primary Phase Models 70 | -------------------- 71 | 72 | .. currentmodule:: petbox.dca.PrimaryPhase 73 | 74 | .. autosummary:: 75 | add_secondary 76 | add_water 77 | 78 | 79 | Associated Phase Models 80 | ----------------------- 81 | 82 | Secondary Phase Models 83 | ~~~~~~~~~~~~~~~~~~~~~~ 84 | 85 | .. currentmodule:: petbox.dca.SecondaryPhase 86 | 87 | .. autosummary:: 88 | gor 89 | cgr 90 | 91 | 92 | Water Phase Models 93 | ~~~~~~~~~~~~~~~~~~ 94 | 95 | .. currentmodule:: petbox.dca.WaterPhase 96 | 97 | .. autosummary:: 98 | wor 99 | wgr 100 | 101 | 102 | Transient Hyperbolic Specific 103 | ----------------------------- 104 | 105 | .. currentmodule:: petbox.dca.THM 106 | 107 | .. autosummary:: 108 | transient_rate 109 | transient_cum 110 | transient_D 111 | transient_beta 112 | transient_b 113 | 114 | 115 | Utility Functions 116 | ----------------- 117 | 118 | .. currentmodule:: petbox.dca 119 | 120 | .. autosummary:: 121 | 122 | bourdet 123 | get_time 124 | get_time_monthly_vol 125 | 126 | 127 | Utility Constants 128 | ----------------- 129 | 130 | +--------------------------+------------------------+ 131 | | :const:`DAYS_PER_MONTH` | 365.25 / 12 = 30.4375 | 132 | +--------------------------+------------------------+ 133 | | :const:`DAYS_PER_YEAR` | 365.25 | 134 | +--------------------------+------------------------+ 135 | 136 | 137 | Detailed Reference 138 | ================== 139 | 140 | .. currentmodule:: petbox.dca 141 | 142 | Base Classes 143 | ------------ 144 | 145 | These classes define the basic functions that are exposed by all decline curve models. 146 | 147 | .. autoclass:: DeclineCurve 148 | 149 | .. automethod:: rate 150 | .. automethod:: cum 151 | .. automethod:: interval_vol 152 | .. automethod:: monthly_vol 153 | .. automethod:: monthly_vol_equiv 154 | .. automethod:: D 155 | .. automethod:: beta 156 | .. automethod:: b 157 | .. automethod:: get_param_desc 158 | .. automethod:: get_param_descs 159 | .. automethod:: from_params 160 | 161 | 162 | .. autoclass:: PrimaryPhase 163 | 164 | .. automethod:: add_secondary 165 | .. automethod:: add_water 166 | 167 | 168 | .. autoclass:: SecondaryPhase 169 | 170 | .. automethod:: gor 171 | .. automethod:: cgr 172 | 173 | 174 | .. autoclass:: WaterPhase 175 | 176 | .. automethod:: wor 177 | .. automethod:: wgr 178 | 179 | 180 | Primary Phase Models 181 | -------------------- 182 | 183 | Implementations of primary phase decline curve models 184 | 185 | .. autoclass:: THM 186 | 187 | .. automethod:: rate 188 | .. automethod:: cum 189 | .. automethod:: D 190 | .. automethod:: beta 191 | .. automethod:: b 192 | .. automethod:: transient_rate 193 | .. automethod:: transient_cum 194 | .. automethod:: transient_D 195 | .. automethod:: transient_beta 196 | .. automethod:: transient_b 197 | 198 | 199 | .. autoclass:: MH 200 | 201 | .. automethod:: rate 202 | .. automethod:: cum 203 | .. automethod:: D 204 | .. automethod:: beta 205 | .. automethod:: b 206 | .. automethod:: get_param_desc 207 | .. automethod:: get_param_descs 208 | .. automethod:: from_params 209 | 210 | 211 | .. autoclass:: PLE 212 | 213 | .. automethod:: rate 214 | .. automethod:: cum 215 | .. automethod:: D 216 | .. automethod:: beta 217 | .. automethod:: b 218 | .. automethod:: get_param_desc 219 | .. automethod:: get_param_descs 220 | .. automethod:: from_params 221 | 222 | 223 | .. autoclass:: SE 224 | 225 | .. automethod:: rate 226 | .. automethod:: cum 227 | .. automethod:: D 228 | .. automethod:: beta 229 | .. automethod:: b 230 | .. automethod:: get_param_desc 231 | .. automethod:: get_param_descs 232 | .. automethod:: from_params 233 | 234 | 235 | .. autoclass:: Duong 236 | 237 | .. automethod:: rate 238 | .. automethod:: cum 239 | .. automethod:: D 240 | .. automethod:: beta 241 | .. automethod:: b 242 | .. automethod:: get_param_desc 243 | .. automethod:: get_param_descs 244 | .. automethod:: from_params 245 | 246 | 247 | Associated Phase Models 248 | ----------------------- 249 | 250 | Implementations of associated (secondary and water) phase GOR/CGR/WOR/WGR models 251 | 252 | .. autoclass:: PLYield 253 | 254 | .. automethod:: gor 255 | .. automethod:: cgr 256 | .. automethod:: rate 257 | .. automethod:: cum 258 | .. automethod:: D 259 | .. automethod:: beta 260 | .. automethod:: b 261 | .. automethod:: get_param_desc 262 | .. automethod:: get_param_descs 263 | .. automethod:: from_params 264 | 265 | 266 | Utility Functions 267 | ----------------- 268 | 269 | .. autofunction:: bourdet 270 | .. autofunction:: get_time 271 | .. autofunction:: get_time_monthly_vol 272 | 273 | 274 | Other Classes 275 | ------------- 276 | 277 | .. autoclass:: AssociatedPhase 278 | 279 | .. autoclass:: BothAssociatedPhase 280 | 281 | .. currentmodule:: petbox.dca.base 282 | 283 | .. autoclass:: ParamDesc 284 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath('..')) 17 | from petbox import dca 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'petbox-dca' 23 | copyright = '2023, David S. Fulford' 24 | author = 'David S. Fulford' 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = dca.__version__ 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.autosummary', 37 | 'sphinx.ext.viewcode', 38 | 'sphinx.ext.napoleon', 39 | 'sphinx.ext.coverage', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix of source filenames. 46 | source_suffix = '.rst' 47 | 48 | # The master toctree document. 49 | master_doc = 'index' 50 | 51 | # List of patterns, relative to source directory, that match files and 52 | # directories to ignore when looking for source files. 53 | # This pattern also affects html_static_path and html_extra_path. 54 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 55 | 56 | 57 | # -- Options for HTML output ------------------------------------------------- 58 | 59 | # The theme to use for HTML and HTML Help pages. See the documentation for 60 | # a list of builtin themes. 61 | # 62 | html_theme = 'sphinx_rtd_theme' 63 | 64 | # A list of paths that contain custom static files (such as style sheets or 65 | # script files). Relative paths are taken as relative to the configuration 66 | # directory. They are copied to the output’s _static directory after the 67 | # theme’s static files, so a file named default.css will overwrite the theme’s 68 | # default.css. 69 | html_static_path = ['_static'] 70 | 71 | # A list of CSS files. The entry must be a filename string or a tuple 72 | # containing the filename string and the attributes dictionary. The filename 73 | # must be relative to the html_static_path, or a full URI with scheme like 74 | # https://example.org/style.css. The attributes is used for attributes of 75 | # tag. It defaults to an empty list. 76 | html_css_files = ['theme_override.css'] 77 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Detailed Usage Examples 3 | ======================= 4 | 5 | 6 | Each model, including the secondary phase models, implements all diagnostic functions. The following is a set of examples to highlight functionality. 7 | 8 | 9 | .. code-block:: python 10 | 11 | from petbox import dca 12 | from data import rate as data_q, time as data_t 13 | import numpy as np 14 | import matplotlib.pyplot as plt 15 | import matplotlib as mpl 16 | 17 | plt.style.use('seaborn-v0_8-white') 18 | plt.rcParams['font.size'] = 16 19 | 20 | 21 | .. code-block:: python 22 | 23 | # Setup time series for Forecasts and calculate cumulative production of data 24 | 25 | # We have this function handy 26 | t = dca.get_time(n=1001) 27 | 28 | # Calculate cumulative volume array of data 29 | data_N = np.cumsum(data_q * np.r_[data_t[0], np.diff(data_t)]) 30 | 31 | # Calculate diagnostic functions D, beta, and b 32 | data_D = -dca.bourdet(data_q, data_t, L=0.35, xlog=False, ylog=True) 33 | data_beta = data_D * data_t 34 | data_b = dca.bourdet(1 / data_D, data_t, L=0.25, xlog=False, ylog=False) 35 | 36 | 37 | Primary Phase Decline Curve Models 38 | ================================== 39 | 40 | Modified Hyperbolic Model 41 | ------------------------- 42 | 43 | *Robertson, S. 1988. Generalized Hyperbolic Equation. Available from SPE, Richardson, Texas, USA. SPE-18731-MS.* 44 | 45 | .. code-block:: python 46 | 47 | mh = dca.MH(qi=725, Di=0.85, bi=0.6, Dterm=0.2) 48 | q_mh = mh.rate(t) 49 | N_mh = mh.cum(t) 50 | D_mh = mh.D(t) 51 | b_mh = mh.b(t) 52 | beta_mh = mh.beta(t) 53 | N_mh *= data_N[-1] / mh.cum(data_t[-1]) 54 | 55 | 56 | Transient Hyperbolic Model 57 | -------------------------- 58 | 59 | *Fulford, D. S., and Blasingame, T. A. 2013. Evaluation of Time-Rate Performance of Shale Wells using the Transient Hyperbolic Relation. Presented at SPE Unconventional Resources Conference – Canada in Calgary, Alberta, Canda, 5–7 November. SPE-167242-MS. https://doi.org/10.2118/167242-MS.* 60 | 61 | .. code-block:: python 62 | 63 | thm = dca.THM(qi=750, Di=.8, bi=2, bf=.5, telf=28) 64 | q_trans = thm.transient_rate(t) 65 | N_trans = thm.transient_cum(t) 66 | D_trans = thm.transient_D(t) 67 | b_trans = thm.transient_b(t) 68 | beta_trans = thm.transient_beta(t) 69 | N_trans *= data_N[-1] / thm.transient_cum(data_t[-1]) 70 | 71 | 72 | Transient Hyperbolic Model Analytic Approximation 73 | ------------------------------------------------- 74 | 75 | *Fulford, D.S. 2018. A Model-Based Diagnostic Workflow for Time-Rate Performance of Unconventional Wells. Presented at Unconventional Resources Conference in Houston, Texas, USA, 23–25 July. URTeC-2903036. https://doi.org/10.15530/urtec-2018-2903036.* 76 | 77 | .. code-block:: python 78 | 79 | q_thm = thm.rate(t) 80 | N_thm = thm.cum(t) 81 | D_thm = thm.D(t) 82 | b_thm = thm.b(t) 83 | beta_thm = thm.beta(t) 84 | N_thm *= data_N[-1] / thm.cum(data_t[-1]) 85 | 86 | 87 | Timing Comparison 88 | ~~~~~~~~~~~~~~~~~ 89 | 90 | If performance is a consideration, the approximation is much faster. 91 | 92 | .. code-block:: python 93 | 94 | >>> %timeit thm.transient_rate(t) 95 | 64.9 ms ± 5.81 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) 96 | 97 | 98 | .. code-block:: python 99 | 100 | >>> %timeit thm.rate(t) 101 | 86.9 µs ± 5.35 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)`` 102 | 103 | 104 | Power-Law Exponential Model 105 | --------------------------- 106 | 107 | *Ilk, D., Perego, A. D., Rushing, J. A., and Blasingame, T. A. 2008. Exponential vs. Hyperbolic Decline in Tight Gas Sands – Understanding the Origin and Implications for Reserve Estimates Using Arps Decline Curves. Presented at SPE Annual Technical Conference and Exhibition in Denver, Colorado, USA, 21–24 September. SPE-116731-MS. https://doi.org/10.2118/116731-MS.* 108 | 109 | *Ilk, D., Rushing, J. A., and Blasingame, T. A. 2009. Decline Curve Analysis for HP/HT Gas Wells: Theory and Applications. Presented at SPE Annual Technical Conference and Exhibition in New Orleands, Louisiana, USA, 4–7 October. SPE-125031-MS. https://doi.org/10.2118/125031-MS.* 110 | 111 | .. code-block:: python 112 | 113 | ple = dca.PLE(qi=750, Di=.1, Dinf=.00001, n=.5) 114 | q_ple = ple.rate(t) 115 | N_ple = ple.cum(t) 116 | D_ple = ple.D(t) 117 | b_ple = ple.b(t) 118 | beta_ple = ple.beta(t) 119 | N_ple *= data_N[-1] / ple.cum(data_t[-1]) 120 | 121 | 122 | Stretched Exponential 123 | --------------------- 124 | 125 | *Valkó, P. P. Assigning Value to Stimulation in the Barnett Shale: A Simultaneous Analysis of 7000 Plus Production Histories and Well Completion Records. 2009. Presented at SPE Hydraulic Fracturing Technology Conference in College Station, Texas, USA, 19–21 January. SPE-119369-MS. https://doi.org/10.2118/119369-MS.* 126 | 127 | .. code-block:: python 128 | 129 | se = dca.SE(qi=715, tau=90.0, n=.5) 130 | q_se = se.rate(t) 131 | N_se = se.cum(t) 132 | D_se = se.D(t) 133 | b_se = se.b(t) 134 | beta_se = se.beta(t) 135 | N_se *= data_N[-1] / se.cum(data_t[-1]) 136 | 137 | 138 | Duong Model 139 | ----------- 140 | 141 | *Duong, A. N. 2001. Rate-Decline Analysis for Fracture-Dominated Shale Reservoirs. SPE Res Eval & Eng 14 (3): 377–387. SPE-137748-PA. https://doi.org/10.2118/137748-PA.* 142 | 143 | .. code-block:: python 144 | 145 | dg = dca.Duong(qi=715, a=2.8, m=1.4) 146 | q_dg = dg.rate(t) 147 | N_dg = dg.cum(t) 148 | D_dg = dg.D(t) 149 | b_dg = dg.b(t) 150 | beta_dg = dg.beta(t) 151 | N_dg *= data_N[-1] / dg.cum(data_t[-1]) 152 | 153 | 154 | Primary Phase Diagnostic Plots 155 | ============================== 156 | 157 | Rate and Cumulative Production Plots 158 | ------------------------------------ 159 | 160 | .. code-block:: python 161 | 162 | # Rate vs Time 163 | fig = plt.figure(figsize=(15, 7.5)) 164 | ax1 = fig.add_subplot(121) 165 | ax2 = fig.add_subplot(122) 166 | 167 | ax1.plot(data_t, data_q, 'o', mfc='w', label='Data') 168 | ax1.plot(t, q_trans, label='THM Transient') 169 | ax1.plot(t, q_thm, ls='--', label='THM Approx') 170 | ax1.plot(t, q_mh, label='MH') 171 | ax1.plot(t, q_ple, label='PLE') 172 | ax1.plot(t, q_se, label='SE') 173 | ax1.plot(t, q_dg, label='Duong') 174 | 175 | ax1.set(xscale='log', yscale='log', ylabel='Rate, BPD', xlabel='Time, Days') 176 | ax1.set(ylim=(1e0, 1e4), xlim=(1e0, 1e4)) 177 | ax1.set_aspect(1) 178 | ax1.grid() 179 | ax1.legend() 180 | 181 | # Cumulative Volume vs Time 182 | ax2.plot(data_t, data_N, 'o', mfc='w', label='Data') 183 | ax2.plot(t, N_trans, label='THM Transient') 184 | ax2.plot(t, N_thm, ls='--', label='THM Approx') 185 | ax2.plot(t, N_mh, label='MH') 186 | ax2.plot(t, N_ple, label='PLE') 187 | ax2.plot(t, N_se, label='SE') 188 | ax2.plot(t, N_dg, label='Duong') 189 | 190 | ax2.set(xscale='log', yscale='log', ylim=(1e2, 1e6), xlim=(1e0, 1e4)) 191 | ax2.set(ylabel='Cumulative Volume, MBbl', xlabel='Time, Days') 192 | ax2.set_aspect(1) 193 | ax2.grid() 194 | ax2.legend() 195 | 196 | plt.savefig(img_path / 'model.png') 197 | 198 | .. image:: img/model.png 199 | 200 | Diagnostic Function Plots 201 | ------------------------- 202 | 203 | .. code-block:: python 204 | 205 | fig = plt.figure(figsize=(15, 15)) 206 | ax1 = fig.add_subplot(221) 207 | ax2 = fig.add_subplot(222) 208 | ax3 = fig.add_subplot(223) 209 | ax4 = fig.add_subplot(224) 210 | 211 | # D-parameter vs Time 212 | ax1.plot(data_t, data_D, 'o', mfc='w', label='Data') 213 | ax1.plot(t, D_trans, label='THM Transient') 214 | ax1.plot(t, D_thm, ls='--', label='THM Approx') 215 | ax1.plot(t, D_mh, label='MH') 216 | ax1.plot(t, D_ple, label='PLE') 217 | ax1.plot(t, D_se, label='SE') 218 | ax1.plot(t, D_dg, label='Duong') 219 | ax1.set(xscale='log', yscale='log', ylim=(1e-4, 1e0)) 220 | ax1.set(ylabel='$D$-parameter, Days$^{-1}$', xlabel='Time, Days') 221 | 222 | # beta-parameter vs Time 223 | ax2.plot(data_t, data_D * data_t, 'o', mfc='w', label='Data') 224 | ax2.plot(t, beta_trans, label='THM Transient') 225 | ax2.plot(t, beta_thm, ls='--', label='THM Approx') 226 | ax2.plot(t, beta_mh, label='MH') 227 | ax2.plot(t, beta_ple, label='PLE') 228 | ax2.plot(t, beta_se, label='SE') 229 | ax2.plot(t, beta_dg, label='Duong') 230 | ax2.set(xscale='log', yscale='log', ylim=(1e-2, 1e2)) 231 | ax2.set(ylabel=r'$\beta$-parameter, Dimensionless', xlabel='Time, Days') 232 | 233 | # b-parameter vs Time 234 | ax3.plot(data_t, data_b, 'o', mfc='w', label='Data') 235 | ax3.plot(t, b_trans, label='THM Transient') 236 | ax3.plot(t, b_thm, ls='--', label='THM Approx') 237 | ax3.plot(t, b_mh, label='MH') 238 | ax3.plot(t, b_ple, label='PLE') 239 | ax3.plot(t, b_se, label='SE') 240 | ax3.plot(t, b_dg, label='Duong') 241 | ax3.set(xscale='log', yscale='linear', ylim=(0., 4.)) 242 | ax3.set(ylabel='$b$-parameter, Dimensionless', xlabel='Time, Days') 243 | 244 | # q/N vs Time 245 | ax4.plot(data_t, data_q / data_N, 'o', mfc='w', label='Data') 246 | ax4.plot(t, q_trans / N_trans, label='THM Transient') 247 | ax4.plot(t, q_thm / N_thm, ls='--', label='THM Approx') 248 | ax4.plot(t, q_mh / N_mh, label='MH') 249 | ax4.plot(t, q_ple / N_ple, label='PLE') 250 | ax4.plot(t, q_se / N_se, label='SE') 251 | ax4.plot(t, q_dg / N_dg, label='Duong') 252 | ax4.set(xscale='log', yscale='log', ylim=(1e-7, 1e0), xlim=(1e0, 1e7)) 253 | ax4.set(ylabel='$q_o / N_p$, Days$^{-1}$', xlabel='Time, Days') 254 | 255 | for ax in [ax1, ax2, ax3, ax4]: 256 | if ax != ax4: 257 | ax.set(xlim=(1e0, 1e4)) 258 | if ax != ax3: 259 | ax.set_aspect(1) 260 | ax.grid() 261 | ax.legend() 262 | 263 | 264 | plt.savefig(img_path / 'diagnostics.png') 265 | 266 | 267 | .. image:: img/diagnostics.png 268 | 269 | 270 | Secondary Phase Decline Curve Models 271 | ==================================== 272 | 273 | Power-Law GOR/CGR Model 274 | ----------------------- 275 | 276 | *Fulford, D.S. 2018. A Model-Based Diagnostic Workflow for Time-Rate Performance of Unconventional Wells. Presented at Unconventional Resources Conference in Houston, Texas, USA, 23–25 July. URTeC-2903036. https://doi.org/10.15530/urtec-2018-2903036.* 277 | 278 | .. code-block:: python 279 | 280 | thm = dca.THM(qi=750, Di=.8, bi=2, bf=.5, telf=28) 281 | thm.add_secondary(dca.PLYield(c=1000, m0=-0.1, m=0.8, t0=2 * 365.25 / 12, max=10_000)) 282 | 283 | 284 | Secondary Phase Diagnostic Plots 285 | ================================ 286 | 287 | Rate and Cumluative Production Plots 288 | ------------------------------------ 289 | 290 | Numeric calculation provided to verify analytic relationships 291 | 292 | .. code-block:: python 293 | 294 | fig = plt.figure(figsize=(15, 15)) 295 | ax1 = fig.add_subplot(221) 296 | ax2 = fig.add_subplot(222) 297 | ax3 = fig.add_subplot(223) 298 | ax4 = fig.add_subplot(224) 299 | 300 | 301 | # Rate vs Time 302 | q = thm.rate(t) 303 | g = thm.secondary.rate(t) / 1000.0 304 | y = thm.secondary.gor(t) 305 | 306 | ax1.plot(t, q, c='C2', label='Oil') 307 | ax1.plot(t, g, c='C3', label='Gas') 308 | ax1.plot(t, y, c='C1', label='GOR') 309 | ax1.set(xscale='log', yscale='log', xlim=(1e0, 1e5), ylim=(1e0, 1e5)) 310 | ax1.set(ylabel='Rate or GOR, BPD, MCFD, or scf/Bbl', xlabel='Time, Days') 311 | 312 | 313 | # Cumulative Volume vs Time 314 | q_N = thm.cum(t) 315 | g_N = thm.secondary.cum(t) / 1000.0 316 | _g_N = np.cumsum(g * np.diff(t, prepend=0)) 317 | 318 | ax2.plot(t, q_N, c='C2', label='Oil') 319 | ax2.plot(t, g_N, c='C3', label='Gas') 320 | ax2.plot(t, _g_N, c='k', ls=':', label='Gas (numeric)') 321 | ax2.plot(t, y, c='C1', label='GOR') 322 | ax2.set(xscale='log', yscale='log', xlim=(1e0, 1e5), ylim=(1e2, 1e7)) 323 | ax2.set(ylabel='Rate, Dimensionless', xlabel='Time, Days') 324 | ax2.set(ylabel='Cumulative Volume or GOR, MBbl, MMcf, or scf/Bbl', xlabel='Time, Days') 325 | 326 | 327 | # Time vs Monthly Volume 328 | q_MN = thm.monthly_vol_equiv(t) 329 | g_MN = thm.secondary.monthly_vol_equiv(t) / 1000.0 330 | _g_MN = np.diff(np.cumsum(g * np.diff(t, prepend=0)), prepend=0) \ 331 | / np.diff(t, prepend=0) * dca.DAYS_PER_MONTH 332 | 333 | ax3.plot(t, q_MN, c='C2', label='Oil') 334 | ax3.plot(t, g_MN, c='C3', label='Gas') 335 | ax3.plot(t, _g_MN, c='k', ls=':', label='Gas (numeric)') 336 | ax3.plot(t, y, c='C1', label='GOR') 337 | ax3.set(xscale='log', yscale='log', xlim=(1e0, 1e5), ylim=(1e0, 1e5)) 338 | ax3.set(ylabel='Monthly Volume or GOR, MBbl, MMcf, or scf/Bbl', xlabel='Time, Days') 339 | 340 | 341 | # Time vs Interval Volume 342 | q_IN = thm.interval_vol(t, t0=0.0) 343 | g_IN = thm.secondary.interval_vol(t, t0=0.0) / 1000.0 344 | _g_IN = np.diff(np.cumsum(g * np.diff(t, prepend=0)), prepend=0) 345 | 346 | ax4.plot(t, q_IN, c='C2', label='Oil') 347 | ax4.plot(t, g_IN, c='C3', label='Gas') 348 | ax4.plot(t, _g_IN, c='k', ls=':', label='Gas (numeric)') 349 | ax4.plot(t, y, c='C1', label='GOR') 350 | ax4.set(xscale='log', yscale='log', xlim=(1e0, 1e5), ylim=(1e0, 1e5)) 351 | ax4.set(ylabel='$\Delta$Volume or GOR, MBbl, MMcf, or scf/Bbl', xlabel='Time, Days') 352 | 353 | for ax in [ax1, ax2, ax3, ax4]: 354 | ax.set_aspect(1) 355 | ax.grid() 356 | ax.legend() 357 | 358 | plt.savefig(img_path / 'secondary_model.png') 359 | 360 | 361 | .. image:: img/secondary_model.png 362 | 363 | 364 | Diagnostic Function Plots 365 | ------------------------- 366 | 367 | .. code-block:: python 368 | 369 | fig = plt.figure(figsize=(15, 15)) 370 | ax1 = fig.add_subplot(221) 371 | ax2 = fig.add_subplot(222) 372 | ax3 = fig.add_subplot(223) 373 | ax4 = fig.add_subplot(224) 374 | 375 | # D-parameter vs Time 376 | q_D = thm.D(t) 377 | g_D = thm.secondary.D(t) 378 | _g_D = -np.gradient(np.log(thm.secondary.rate(t) / 1000.0), t) 379 | 380 | ax1.plot(t, q_D, c='C2', label='Oil') 381 | ax1.plot(t, g_D, c='C3', label='Gas') 382 | ax1.plot(t, _g_D, c='k', ls=':', label='Gas (numeric)') 383 | ax1.set(xscale='log', yscale='log', xlim=(1e0, 1e4), ylim=(1e-4, 1e0)) 384 | ax1.set(ylabel='$D$-parameter, Days$^{-1}$', xlabel='Time, Days') 385 | 386 | # beta-parameter vs Time 387 | q_beta = thm.beta(t) 388 | g_beta = thm.secondary.beta(t) 389 | _g_beta = _g_D * t 390 | 391 | ax2.plot(t, q_beta, c='C2', label='Oil') 392 | ax2.plot(t, g_beta, c='C3', label='Gas') 393 | ax2.plot(t, _g_beta, c='k', ls=':', label='Gas (numeric)') 394 | ax2.set(xscale='log', yscale='log', xlim=(1e0, 1e4), ylim=(1e-2, 1e2)) 395 | ax2.set(ylabel=r'$\beta$-parameter, Dimensionless', xlabel='Time, Days') 396 | 397 | # b-parameter vs Time 398 | q_b = thm.b(t) 399 | g_b = thm.secondary.b(t) 400 | _g_b = np.gradient(1.0 / _g_D, t) 401 | 402 | ax3.plot(t, q_b, c='C2', label='Oil') 403 | ax3.plot(t, g_b, c='C3', label='Gas') 404 | ax3.plot(t, _g_b, c='k', ls=':', label='Gas (numeric)') 405 | ax3.set(xscale='log', yscale='linear', xlim=(1e0, 1e4), ylim=(-2, 4)) 406 | ax3.set(ylabel='$b$-parameter, Dimensionless', xlabel='Time, Days') 407 | 408 | # q/N vs Time 409 | q_Ng = thm.rate(t) / thm.cum(t) 410 | g_Ng = thm.secondary.rate(t) / thm.secondary.cum(t) 411 | _g_Ng = thm.secondary.rate(t) / np.cumsum(thm.secondary.rate(t) * np.diff(t, prepend=0)) 412 | 413 | ax4.plot(t, q_Ng, c='C2', label='Oil') 414 | ax4.plot(t, g_Ng, c='C3', ls='--', label='Gas') 415 | ax4.plot(t, _g_Ng, c='k', ls=':', label='Gas (numeric)') 416 | ax4.set(xscale='log', yscale='log', ylim=(1e-7, 1e0), xlim=(1e0, 1e7)) 417 | ax4.set(ylabel='$q_o / N_p$, Days$^{-1}$', xlabel='Time, Days') 418 | 419 | for ax in [ax1, ax2, ax3, ax4]: 420 | if ax != ax3: 421 | ax.set_aspect(1) 422 | ax.grid() 423 | ax.legend() 424 | 425 | plt.savefig(img_path / 'sec_diagnostic_funs.png') 426 | 427 | 428 | .. image:: img/sec_diagnostic_funs.png 429 | 430 | 431 | Additional Diagnostic Plots 432 | --------------------------- 433 | 434 | Numeric calculation provided to verify analytic relationships 435 | 436 | 437 | .. code-block:: python 438 | 439 | fig = plt.figure(figsize=(15, 15)) 440 | ax1 = fig.add_subplot(221) 441 | ax2 = fig.add_subplot(222) 442 | ax3 = fig.add_subplot(223) 443 | 444 | # D-parameter vs Time 445 | q_D = thm.D(t) 446 | g_D = thm.secondary.D(t) 447 | _g_D = -np.gradient(np.log(thm.secondary.rate(t)), t) 448 | 449 | ax1.plot(t, q_D, c='C2', label='Oil') 450 | ax1.plot(t, g_D, c='C3', label='Gas') 451 | ax1.plot(t, _g_D, c='k', ls=':', label='Gas(numeric)') 452 | ax1.set(xscale='log', yscale='linear', xlim=(1e0, 1e5), ylim=(None, None)) 453 | ax1.set(ylabel='$D$-parameter, 1 / Days', xlabel='Time, Days') 454 | 455 | # Secant Effective Decline vs Time 456 | secant_from_nominal = dca.MultisegmentHyperbolic.secant_from_nominal 457 | dpy = dca.DAYS_PER_YEAR 458 | 459 | q_Dn = [secant_from_nominal(d * dpy, b) for d, b in zip(q_D, thm.b(t))] 460 | g_Dn = [secant_from_nominal(d * dpy, b) for d, b in zip(g_D, thm.secondary.b(t))] 461 | _g_Dn = [secant_from_nominal(d * dpy, b) for d, b in zip(_g_D, np.gradient(1 / _g_D, t))] 462 | 463 | ax2.plot(t, q_Dn, c='C2', label='Oil') 464 | ax2.plot(t, g_Dn, c='C3', label='Gas') 465 | ax2.plot(t, _g_Dn, c='k', ls=':', label='Gas (numeric)') 466 | ax2.set(xscale='log', yscale='linear', xlim=(1e0, 1e5), ylim=(-.5, 1.025)) 467 | ax2.yaxis.set_major_formatter(mpl.ticker.PercentFormatter(xmax=1)) 468 | ax2.set(ylabel='Secant Effective Decline, % / Year', xlabel='Time$ Days') 469 | 470 | # Tangent Effective Decline vs Time 471 | ax3.plot(t, 1 - np.exp(-q_D * dpy), c='C2', label='Oil') 472 | ax3.plot(t, 1 - np.exp(-g_D * dpy), c='C3', label='Gas') 473 | ax3.plot(t, 1 - np.exp(-_g_D * dpy), c='k', ls=':', label='Gas (numeric)') 474 | ax3.set(xscale='log', yscale='linear', xlim=(1e0, 1e5), ylim=(-1.025, 1.025)) 475 | ax3.yaxis.set_major_formatter(mpl.ticker.PercentFormatter(xmax=1)) 476 | ax3.set(ylabel='Tangent Effective Decline, % / Day', xlabel='Time, Days') 477 | 478 | for ax in [ax1, ax2, ax3]: 479 | ax.grid() 480 | ax.legend() 481 | 482 | plt.savefig(img_path / 'sec_decline_diagnostics.png') 483 | 484 | 485 | .. image:: img/sec_decline_diagnostics.png 486 | -------------------------------------------------------------------------------- /docs/genindex.rst: -------------------------------------------------------------------------------- 1 | Index 2 | ===== 3 | -------------------------------------------------------------------------------- /docs/img/diagnostics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petbox-dev/dca/d5499030dc2bb186e966624f7e0d44f989bbe653/docs/img/diagnostics.png -------------------------------------------------------------------------------- /docs/img/model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petbox-dev/dca/d5499030dc2bb186e966624f7e0d44f989bbe653/docs/img/model.png -------------------------------------------------------------------------------- /docs/img/sec_decline_diagnostics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petbox-dev/dca/d5499030dc2bb186e966624f7e0d44f989bbe653/docs/img/sec_decline_diagnostics.png -------------------------------------------------------------------------------- /docs/img/sec_diagnostic_funs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petbox-dev/dca/d5499030dc2bb186e966624f7e0d44f989bbe653/docs/img/sec_diagnostic_funs.png -------------------------------------------------------------------------------- /docs/img/secondary_model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petbox-dev/dca/d5499030dc2bb186e966624f7e0d44f989bbe653/docs/img/secondary_model.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Contents 4 | ======== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | README 10 | api 11 | examples 12 | 13 | .. toctree:: 14 | :maxdepth: 1 15 | 16 | testing 17 | versions 18 | genindex 19 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.21.1 2 | scipy>=1.7.3 3 | Sphinx<7.0.0 4 | sphinx-rtd-theme==1.2.2 5 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Testing 3 | ======= 4 | 5 | Testing is set to evaluate: 6 | 7 | - style with `flake8 `_, 8 | - typing with `mypy `_, 9 | - valid function return values and behaviors with `hypothesis `_, and 10 | - test coverage using `coverage `_. 11 | 12 | Windows 13 | ------- 14 | 15 | Run ``test.bat`` in the ``test`` directory. 16 | 17 | 18 | Linux 19 | ----- 20 | 21 | Run ``test.sh`` in the ``test`` directory. 22 | -------------------------------------------------------------------------------- /docs/versions.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Version History 3 | =============== 4 | 5 | .. automodule:: petbox.dca 6 | :noindex: 7 | 8 | 9 | 1.1.0 10 | ----- 11 | 12 | * Bug Fix 13 | * Fix bug in sign in ``MultisegmentHyperbolic.secant_from_nominal`` 14 | 15 | * Other changes 16 | * Add `mpmath` to handle precision requires of THM transient functions (only required to use the functions) 17 | * Adjust default degree of THM transient function quadrature integration from 50 to 10 (`scipy` default is 5) 18 | * Update package versions for docs and builds 19 | * Address various floating point errors, suppress `numpy` warnings for those which are mostly unavoidable 20 | * Add test/doc_exapmles.py and update figures (not sure what happened to the old file) 21 | * Adjust range of values in tests to avoid numerical errors in `numpy` and `scipy` functions... these were near-epsilon impractical values anyway 22 | 23 | 24 | 1.0.8 25 | ----- 26 | 27 | * New functions 28 | * Added ``WaterPhase.wgr`` method 29 | 30 | * Other changes 31 | * Adjust yield model rate function to return consistent units if primary phase is oil or gas 32 | * Update to `numpy v1.20` typing 33 | 34 | 1.0.7 35 | ----- 36 | 37 | * Allow disabling of parameter checks by passing an interable of booleans, each indicating a check 38 | to each model parameter. 39 | * Explicitly handle floating point overflow errors rather than relying on `numpy`. 40 | 41 | 1.0.6 42 | ----- 43 | 44 | * New functions 45 | * Added ``WaterPhase`` class 46 | * Added ``WaterPhase.wor`` method 47 | * Added ``PrimaryPhase.add_water`` method 48 | 49 | * Other changes 50 | * A ``yield`` model may inherit both ``SecondaryPhase`` and ``WaterPhase``, with the respective methods removed upon attachment to a ``PrimaryPhase``. 51 | 52 | 1.0.5 53 | ----- 54 | 55 | * New functions 56 | * Bourdet algorithm 57 | 58 | * Other changes 59 | * Update docstrings 60 | * Add bourdet data derivatives to detailed use examples 61 | 62 | 63 | 1.0.4 64 | ----- 65 | 66 | * Fix typos in docs 67 | 68 | 69 | 1.0.3 70 | ----- 71 | 72 | * Add documentation 73 | * Genericize numerical integration 74 | * Various refactoring 75 | 76 | 77 | 0.0.1 - 1.0.2 78 | ------------- 79 | 80 | * Internal releases 81 | -------------------------------------------------------------------------------- /petbox/dca/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.1.2' 2 | 3 | from .base import (get_time, get_time_monthly_vol, 4 | DeclineCurve, PrimaryPhase, 5 | AssociatedPhase, BothAssociatedPhase, 6 | SecondaryPhase, WaterPhase, 7 | DAYS_PER_MONTH, DAYS_PER_YEAR) 8 | from .primary import NullPrimaryPhase, MultisegmentHyperbolic, MH, THM, PLE, SE, Duong 9 | from .associated import NullAssociatedPhase, PLYield 10 | from .bourdet import bourdet 11 | -------------------------------------------------------------------------------- /petbox/dca/associated.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decline Curve Models 3 | Copyright © 2020 David S. Fulford 4 | 5 | Author 6 | ------ 7 | David S. Fulford 8 | Derrick W. Turk 9 | 10 | Notes 11 | ----- 12 | Created on August 5, 2019 13 | """ 14 | 15 | import warnings 16 | 17 | import dataclasses as dc 18 | from dataclasses import dataclass 19 | 20 | import numpy as np 21 | 22 | from scipy.integrate import fixed_quad # type: ignore 23 | 24 | from typing import (TypeVar, Type, List, Dict, Tuple, Any, 25 | Sequence, Optional, Callable, ClassVar, Union) 26 | from numpy.typing import NDArray 27 | from typing import cast 28 | 29 | from .base import (DeclineCurve, PrimaryPhase, 30 | AssociatedPhase, SecondaryPhase, WaterPhase, BothAssociatedPhase, 31 | ParamDesc, DAYS_PER_MONTH, DAYS_PER_YEAR, LOG_EPSILON, MIN_EPSILON) 32 | 33 | NDFloat = NDArray[np.float64] 34 | 35 | 36 | @dataclass 37 | class NullAssociatedPhase(SecondaryPhase, WaterPhase): 38 | """ 39 | A null :class:`AssociatedPhase` that always returns zeroes. 40 | 41 | Parameters 42 | ---------- 43 | None 44 | """ 45 | 46 | def _set_defaults(self) -> None: 47 | # Do not associate with the null primary phase 48 | pass 49 | 50 | def _yieldfn(self, t: NDFloat) -> NDFloat: 51 | return np.zeros_like(t, dtype=np.float64) 52 | 53 | def _qfn(self, t: NDFloat) -> NDFloat: 54 | return np.zeros_like(t, dtype=np.float64) 55 | 56 | def _Nfn(self, t: NDFloat, **kwargs: Dict[Any, Any]) -> NDFloat: 57 | return np.zeros_like(t, dtype=np.float64) 58 | 59 | def _Dfn(self, t: NDFloat) -> NDFloat: 60 | return np.zeros_like(t, dtype=np.float64) 61 | 62 | def _Dfn2(self, t: NDFloat) -> NDFloat: 63 | return np.zeros_like(t, dtype=np.float64) 64 | 65 | def _betafn(self, t: NDFloat) -> NDFloat: 66 | return np.zeros_like(t, dtype=np.float64) 67 | 68 | def _bfn(self, t: NDFloat) -> NDFloat: 69 | return np.zeros_like(t, dtype=np.float64) 70 | 71 | @classmethod 72 | def get_param_descs(cls) -> List[ParamDesc]: 73 | return [] 74 | 75 | 76 | @dataclass(frozen=True) 77 | class PLYield(BothAssociatedPhase): 78 | """ 79 | Power-Law Associated Phase Model. 80 | 81 | Fulford, D.S. 2018. A Model-Based Diagnostic Workflow for Time-Rate 82 | Performance of Unconventional Wells. Presented at Unconventional Resources 83 | Conference in Houston, Texas, USA, 23–25 July. URTeC-2903036. 84 | https://doi.org/10.15530/urtec-2018-2903036. 85 | 86 | Has the general form of 87 | 88 | .. math:: 89 | 90 | GOR = c \\, t^m 91 | 92 | and allows independent early-time and late-time slopes ``m0`` and ``m`` respectively. 93 | 94 | Parameters 95 | ---------- 96 | c: float 97 | The value of GOR/CGR/WOR/CGR that acts as the anchor or pivot at ``t=t0``. 98 | Units should be correctly specified for the respective yield function. 99 | Assumed volumes units per phase must be ``Bbl`` for oil and water and ``Mscf`` for gas 100 | in order to resolve any inconsistencies in unit magnitude. 101 | 102 | m0: float 103 | Early-time power-law slope. 104 | 105 | m: float 106 | Late-time power-law slope. 107 | 108 | t0: float 109 | The time of the anchor or pivot value ``c``. 110 | 111 | min: Optional[float] = None 112 | The minimum allowed value. Would be used e.g. to limit minimum CGR. 113 | 114 | max: Optional[float] = None 115 | The maximum allowed value. Would be used e.g. to limit maximum GOR. 116 | """ 117 | c: float 118 | m0: float 119 | m: float 120 | t0: float 121 | min: Optional[float] = None 122 | max: Optional[float] = None 123 | 124 | # def _set_defaults(self) -> None: 125 | # object.__setattr__(self, 't0', 1.0) 126 | 127 | def _validate(self) -> None: 128 | if self.min is not None and self.max is not None and self.max < self.min: 129 | raise ValueError('max < min') 130 | super()._validate() 131 | 132 | def _yieldfn(self, t: NDFloat) -> NDFloat: 133 | c = self.c 134 | t0 = self.t0 135 | 136 | m = np.where(t < t0, self.m0, self.m) 137 | 138 | t_t0 = t / t0 139 | np.putmask(t_t0, mask=t_t0 <= 0, values=MIN_EPSILON) # type: ignore 140 | t_m = m * np.log(t_t0) 141 | np.putmask(t_m, mask=t_m > LOG_EPSILON, values=np.inf) # type: ignore 142 | np.putmask(t_m, mask=t_m < -LOG_EPSILON, values=-np.inf) # type: ignore 143 | 144 | if self.min is not None or self.max is not None: 145 | return np.where(t == 0.0, 0.0, 146 | np.clip(c * np.exp(t_m), self.min, self.max)) # type: ignore 147 | return np.where(t == 0.0, 0.0, c * np.exp(t_m)) 148 | 149 | def _qfn(self, t: NDFloat) -> NDFloat: 150 | return self._yieldfn(t) * self.primary._qfn(t) 151 | 152 | def _Nfn(self, t: NDFloat, **kwargs: Dict[Any, Any]) -> NDFloat: 153 | return self._integrate_with(self._qfn, t, **kwargs) 154 | 155 | def _Dfn(self, t: NDFloat) -> NDFloat: 156 | c = self.c 157 | t0 = self.t0 158 | m = np.where(t < t0, self.m0, self.m) 159 | y = self._yieldfn(t) 160 | 161 | if self.min is not None: 162 | m[y <= self.min] = 0.0 163 | if self.max is not None: 164 | m[y >= self.max] = 0.0 165 | return -m / t + self.primary._Dfn(t) 166 | 167 | def _Dfn2(self, t: NDFloat) -> NDFloat: 168 | c = self.c 169 | t0 = self.t0 170 | m = np.where(t < t0, self.m0, self.m) 171 | y = self._yieldfn(t) 172 | 173 | if self.min is not None: 174 | m[y <= self.min] = 0.0 175 | if self.max is not None: 176 | m[y >= self.max] = 0.0 177 | return -m / (t * t) 178 | 179 | def _betafn(self, t: NDFloat) -> NDFloat: 180 | return self._Dfn(t) * t 181 | 182 | def _bfn(self, t: NDFloat) -> NDFloat: 183 | D = self._Dfn(t) 184 | return np.where(D == 0.0, 0.0, (self._Dfn2(t) - self.primary._Dfn2(t)) / (D * D)) 185 | 186 | @classmethod 187 | def get_param_descs(cls) -> List[ParamDesc]: 188 | return [ 189 | ParamDesc( 190 | 'c', 'Pivot point of early- and late-time functions [vol/vol]', 191 | 0.0, None, 192 | lambda r, n: r.uniform(0.0, 1e6, n), 193 | exclude_lower_bound=True), 194 | ParamDesc( 195 | 'm0', 'Early-time slope before pivot point', 196 | -10.0, 10.0, 197 | lambda r, n: r.uniform(-10.0, 10.0, n)), 198 | ParamDesc( 199 | 'm', 'Late-time slope after pivot point', 200 | -1.0, 1.0, 201 | lambda r, n: r.uniform(-1.0, 1.0, n)), 202 | ParamDesc( 203 | 't0', 'Time of pivot point [days]', 204 | 0, None, 205 | lambda r, n: r.uniform(0.0, 1e5, n), 206 | exclude_lower_bound=True), 207 | ParamDesc( 208 | 'min', 'Minimum value of yield function [vol/vol]', 209 | 0, None, 210 | lambda r, n: r.uniform(0.0, 1e3, n)), 211 | ParamDesc( 212 | 'min', 'Maximum value of yield function [vol/vol]', 213 | 0, None, 214 | lambda r, n: r.uniform(0.0, 1e5, n)) 215 | ] 216 | -------------------------------------------------------------------------------- /petbox/dca/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decline Curve Models 3 | Copyright © 2020 David S. Fulford 4 | 5 | Author 6 | ------ 7 | David S. Fulford 8 | Derrick W. Turk 9 | 10 | Notes 11 | ----- 12 | Created on August 5, 2019 13 | """ 14 | 15 | import sys 16 | from math import exp, expm1, log, log10, log1p, ceil as ceiling, floor 17 | from functools import partial 18 | from itertools import starmap 19 | import warnings 20 | 21 | import dataclasses as dc 22 | from dataclasses import dataclass 23 | 24 | import numpy as np 25 | from numpy.random import RandomState 26 | 27 | from scipy.special import expi as ei, gammainc # type: ignore 28 | from scipy.integrate import fixed_quad # type: ignore 29 | 30 | from abc import ABC, abstractmethod 31 | from typing import (TypeVar, Type, List, Dict, Tuple, Any, NoReturn, 32 | Sequence, Iterable, Iterator, Optional, Callable, ClassVar, Union) 33 | from numpy.typing import NDArray 34 | from typing import cast 35 | 36 | NDFloat = NDArray[np.float64] 37 | 38 | 39 | DAYS_PER_MONTH = 365.25 / 12.0 40 | DAYS_PER_YEAR = 365.25 41 | LOG_EPSILON = log(sys.float_info.max) 42 | MIN_EPSILON = sys.float_info.min 43 | 44 | 45 | _Self = TypeVar('_Self', bound='DeclineCurve') 46 | 47 | 48 | @dataclass(frozen=True) 49 | class ParamDesc(): 50 | name: str 51 | description: str 52 | lower_bound: Optional[float] 53 | upper_bound: Optional[float] 54 | naive_gen: Callable[[RandomState, int], NDFloat] 55 | exclude_lower_bound: bool = False 56 | exclude_upper_bound: bool = False 57 | 58 | 59 | def get_time(start: float = 1.0, end: float = 1e5, n: int = 101) -> NDFloat: 60 | """ 61 | Get a time array to evaluate with. 62 | 63 | Parameters 64 | ---------- 65 | start: float 66 | The first time value of the array. 67 | 68 | end: float 69 | The last time value of the array. 70 | 71 | n: int 72 | The number of element in the array. 73 | 74 | Returns 75 | ------- 76 | time: numpy.NDFloat 77 | An evenly-logspaced time series. 78 | """ 79 | return np.logspace(start=log10(start), stop=log10(end), num=n, base=10, dtype=np.float64) 80 | 81 | 82 | def get_time_monthly_vol(start: float = 1, end: int = 10_000) -> NDFloat: 83 | """ 84 | Get a time array to evaluate with. 85 | 86 | Parameters 87 | ---------- 88 | start: float 89 | The first time value of the array. 90 | 91 | end: float 92 | The last time value of the array. 93 | 94 | Returns 95 | ------- 96 | time: numpy.NDFloat 97 | An evenly-monthly-spaced time series 98 | """ 99 | return (np.arange(start, end // DAYS_PER_MONTH) + 1) * DAYS_PER_MONTH 100 | 101 | 102 | class DeclineCurve(ABC): 103 | """ 104 | Base class for decline curve models. Each model must implement the defined 105 | abstract methods. 106 | """ 107 | validate_params: Iterable[bool] = [True] 108 | 109 | def rate(self, t: Union[float, NDFloat]) -> NDFloat: 110 | """ 111 | Defines the model rate function: 112 | 113 | .. math:: 114 | 115 | q(t) = f(t) 116 | 117 | where :math:`f(t)` is defined by each model. 118 | 119 | Parameters 120 | ---------- 121 | t: Union[float, numpy.NDFloat] 122 | An array of times at which to evaluate the function. 123 | 124 | Returns 125 | ------- 126 | rate: numpy.NDFloat 127 | """ 128 | t = self._validate_ndarray(t) 129 | return self._qfn(t) 130 | 131 | def cum(self, t: Union[float, NDFloat], **kwargs: Any) -> NDFloat: 132 | """ 133 | Defines the model cumulative volume function: 134 | 135 | .. math:: 136 | 137 | N(t) = \\int_0^t q \\, dt 138 | 139 | Parameters 140 | ---------- 141 | t: Union[float, numpy.NDFloat] 142 | An array of times at which to evaluate the function. 143 | 144 | **kwargs 145 | Additional arguments passed to :func:`scipy.integrate.fixed_quad` if needed. 146 | 147 | Returns 148 | ------- 149 | cumulative volume: numpy.NDFloat 150 | """ 151 | t = self._validate_ndarray(t) 152 | return self._Nfn(t, **kwargs) 153 | 154 | def interval_vol(self, t: Union[float, NDFloat], t0: Optional[Union[float, NDFloat]] = None, 155 | **kwargs: Any) -> NDFloat: 156 | """ 157 | Defines the model interval volume function: 158 | 159 | .. math:: 160 | 161 | N(t) = \\int_{t_{i-1}}^{t_i} q \\, dt 162 | 163 | for each element of ``t``. 164 | 165 | Parameters 166 | ---------- 167 | t: Union[float, numpy.NDFloat] 168 | An array of interval end times at which to evaluate the function. 169 | 170 | t0: Optional[Union[float, numpy.NDFloat]] 171 | A start time of the first interval. If not given, the first element 172 | of ``t`` is used. 173 | 174 | **kwargs 175 | Additional arguments passed to :func:`scipy.integrate.fixed_quad` if needed. 176 | 177 | Returns 178 | ------- 179 | interval volume: numpy.NDFloat 180 | """ 181 | t = self._validate_ndarray(t) 182 | if t0 is None: 183 | t0 = t[0] 184 | t0 = np.atleast_1d(t0).astype(np.float64) 185 | return np.diff(self._Nfn(t, **kwargs), prepend=self._Nfn(t0, **kwargs)) # type: ignore 186 | 187 | def monthly_vol(self, t: Union[float, NDFloat], **kwargs: Any) -> NDFloat: 188 | """ 189 | Defines the model fixed monthly interval volume function. If t < 1 month, the interval 190 | begin at zero: 191 | 192 | .. math:: 193 | 194 | N(t) = \\int_{t-{1 \\, month}}^{t} q \\, dt 195 | 196 | Parameters 197 | ---------- 198 | t: Union[float, numpy.NDFloat] 199 | An array of interval end times at which to evaluate the function. 200 | 201 | **kwargs 202 | Additional arguments passed to :func:`scipy.integrate.fixed_quad` if needed. 203 | 204 | Returns 205 | ------- 206 | monthly equivalent volume: numpy.NDFloat 207 | """ 208 | t = self._validate_ndarray(t) 209 | return self._Nfn(t, **kwargs) \ 210 | - np.where(t < DAYS_PER_MONTH, 0, self._Nfn(t - DAYS_PER_MONTH, **kwargs)) 211 | 212 | def monthly_vol_equiv(self, t: Union[float, NDFloat], 213 | t0: Optional[Union[float, NDFloat]] = None, **kwargs: Any) -> NDFloat: 214 | """ 215 | Defines the model equivalent monthly interval volume function: 216 | 217 | .. math:: 218 | 219 | N(t) = \\frac{\\frac{365.25}{12}}{t-(t-1 \\, month)} 220 | \\int_{t-{1 \\, month}}^{t} q \\, dt 221 | 222 | Parameters 223 | ---------- 224 | t: Union[float, numpy.NDFloat] 225 | An array of interval end times at which to evaluate the function. 226 | 227 | t0: Optional[Union[float, numpy.NDFloat]] 228 | A start time of the first interval. If not given, assumed to be zero. 229 | 230 | **kwargs 231 | Additional arguments passed to :func:`scipy.integrate.fixed_quad` if needed. 232 | 233 | Returns 234 | ------- 235 | monthly equivalent volume: numpy.NDFloat 236 | """ 237 | t = self._validate_ndarray(t) 238 | t0 = np.atleast_1d(0.0).astype(np.float64) 239 | return (np.diff(self._Nfn(t, **kwargs), prepend=self._Nfn(t0)) # type: ignore 240 | / np.diff(t, prepend=t0) * DAYS_PER_MONTH) # type: ignore 241 | 242 | def D(self, t: Union[float, NDFloat]) -> NDFloat: 243 | """ 244 | Defines the model D-parameter function: 245 | 246 | .. math:: 247 | 248 | D(t) \\equiv \\frac{d}{dt}\\textrm{ln} \\, q \\equiv \\frac{1}{q}\\frac{dq}{dt} 249 | 250 | Parameters 251 | ---------- 252 | t: Union[float, numpy.NDFloat] 253 | An array of times at which to evaluate the function. 254 | 255 | Returns 256 | ------- 257 | D-parameter: numpy.NDFloat 258 | """ 259 | t = self._validate_ndarray(t) 260 | return self._Dfn(t) 261 | 262 | def beta(self, t: Union[float, NDFloat]) -> NDFloat: 263 | """ 264 | Defines the model beta-parameter function. 265 | 266 | .. math:: 267 | 268 | \\beta(t) \\equiv \\frac{d \\, \\textrm{ln} \\, q}{d \\, \\textrm{ln} \\, t} 269 | \\equiv \\frac{t}{q}\\frac{dq}{dt} \\equiv t \\, D(t) 270 | 271 | Parameters 272 | ---------- 273 | t: Union[float, numpy.NDFloat] 274 | An array of times at which to evaluate the function. 275 | 276 | Returns 277 | ------- 278 | beta-parameter: numpy.NDFloat 279 | """ 280 | t = self._validate_ndarray(t) 281 | return self._betafn(t) 282 | 283 | def b(self, t: Union[float, NDFloat]) -> NDFloat: 284 | """ 285 | Defines the model b-parameter function: 286 | 287 | .. math:: 288 | 289 | b(t) \\equiv \\frac{d}{dt}\\frac{1}{D} 290 | 291 | Parameters 292 | ---------- 293 | t: Union[float, numpy.NDFloat] 294 | An array of times at which to evaluate the function. 295 | 296 | Returns 297 | ------- 298 | b-parameter: numpy.NDFloat 299 | """ 300 | t = self._validate_ndarray(t) 301 | return self._bfn(t) 302 | 303 | @abstractmethod 304 | def _qfn(self, t: NDFloat) -> NDFloat: 305 | raise NotImplementedError 306 | 307 | @abstractmethod 308 | def _Nfn(self, t: NDFloat, **kwargs: Any) -> NDFloat: 309 | raise NotImplementedError 310 | 311 | @abstractmethod 312 | def _Dfn(self, t: NDFloat) -> NDFloat: 313 | raise NotImplementedError 314 | 315 | @abstractmethod 316 | def _Dfn2(self, t: NDFloat) -> NDFloat: 317 | raise NotImplementedError 318 | 319 | @abstractmethod 320 | def _betafn(self, t: NDFloat) -> NDFloat: 321 | raise NotImplementedError 322 | 323 | @abstractmethod 324 | def _bfn(self, t: NDFloat) -> NDFloat: 325 | raise NotImplementedError 326 | 327 | def _validate(self) -> None: 328 | # this will be called by the __post_init__ hook - subclasses should 329 | # do any necessary additional validation or caching here 330 | pass 331 | 332 | def __post_init__(self) -> None: 333 | self._set_defaults() 334 | 335 | for desc, do_validate in zip(self.get_param_descs(), self.validate_params): 336 | if not do_validate: 337 | continue 338 | param = getattr(self, desc.name) 339 | if param is not None and desc.lower_bound is not None: 340 | if desc.exclude_lower_bound: 341 | if param <= desc.lower_bound: 342 | raise ValueError(f'{desc.name} <= {desc.lower_bound}') 343 | else: 344 | if param < desc.lower_bound: 345 | raise ValueError(f'{desc.name} < {desc.lower_bound}') 346 | if param is not None and desc.upper_bound is not None: 347 | if desc.exclude_upper_bound: 348 | if param >= desc.upper_bound: 349 | raise ValueError(f'{desc.name} >= {desc.upper_bound}') 350 | else: 351 | if param > desc.upper_bound: 352 | raise ValueError(f'{desc.name} > {desc.upper_bound}') 353 | self._validate() 354 | 355 | @abstractmethod 356 | def _set_defaults(self) -> None: 357 | raise NotImplementedError 358 | 359 | @classmethod 360 | @abstractmethod 361 | def get_param_descs(cls) -> List[ParamDesc]: 362 | """ 363 | Get the parameter descriptions. 364 | 365 | Parameters 366 | ---------- 367 | 368 | Returns 369 | ------- 370 | parameter description: List[:class:`ParamDesc`] 371 | A list of parameter descriptions. 372 | """ 373 | raise NotImplementedError 374 | 375 | # don't call this in a loop - it's a utility for e.g. test suites 376 | @classmethod 377 | def get_param_desc(cls, name: str) -> ParamDesc: 378 | """ 379 | Get a single parameter description. 380 | 381 | Parameters 382 | ---------- 383 | name: str 384 | The parameter name. 385 | 386 | Returns 387 | ------- 388 | parameter description: :class:`ParamDesc` 389 | A parameter description. 390 | """ 391 | for p in cls.get_param_descs(): 392 | if p.name == name: 393 | return p # pragma: no cover 394 | raise KeyError(name) 395 | 396 | # only exists to satisfy mypy 397 | def __init__(self, *args: float) -> None: 398 | raise NotImplementedError 399 | 400 | @classmethod 401 | def from_params(cls: Type[_Self], params: Sequence[float]) -> _Self: 402 | """ 403 | Construct a model from a sequence of parameters. 404 | 405 | Parameters 406 | ---------- 407 | 408 | Returns 409 | ------- 410 | decline curve: :class:`DeclineCurve` 411 | The constructed decline curve model class. 412 | """ 413 | if len(cls.get_param_descs()) != len(params): 414 | raise ValueError('Params sequence does not have required length') 415 | return cls(*params) 416 | 417 | @staticmethod 418 | def _validate_ndarray(x: Union[float, NDFloat]) -> NDFloat: 419 | """ 420 | Ensure the time array is a 1d arary of floats. 421 | """ 422 | return np.atleast_1d(x).astype(np.float64) 423 | 424 | @staticmethod 425 | def _iter_t(t: NDFloat) -> Iterator[Tuple[float, float]]: 426 | """ 427 | Yield a tuple of time intervals. 428 | """ 429 | t0 = 0.0 430 | for t1 in t: 431 | yield (t0, t1) 432 | t0 = t1 433 | return 434 | 435 | def _integrate_with(self, fn: Callable[[NDFloat], NDFloat], 436 | t: NDFloat, **kwargs: Any) -> NDFloat: 437 | kwargs.setdefault('n', 50) 438 | integral = np.array(list(starmap( 439 | lambda t0, t1: fixed_quad(fn, t0, t1, **kwargs)[0], 440 | self._iter_t(t) 441 | )), dtype=np.float64) 442 | integral[np.isnan(integral)] = 0.0 443 | return np.cumsum(integral) 444 | 445 | 446 | class PrimaryPhase(DeclineCurve): 447 | """ 448 | Extends :class:`DeclineCurve` for a primary phase forecast. 449 | Adds the capability to link a secondary (associated) phase model. 450 | """ 451 | secondary: 'SecondaryPhase' 452 | water: 'WaterPhase' 453 | 454 | @staticmethod 455 | def removed_method(t: Union[float, NDFloat], phase: str, method: str) -> NoReturn: 456 | raise ValueError(f'This instance is a {phase} phase and has no `{method}` method.') 457 | 458 | def _set_defaults(self) -> None: 459 | # this is a little naughty: bypass the "frozen" protection, just this once... 460 | # naturally, this should only be called during the __post_init__ process 461 | secondary = NullAssociatedPhase() 462 | object.__setattr__(secondary, 'primary', self) 463 | object.__setattr__(self, 'secondary', secondary) 464 | object.__setattr__(secondary, 'water', self) 465 | object.__setattr__(self, 'water', secondary) 466 | 467 | def add_secondary(self, secondary: 'SecondaryPhase') -> None: 468 | """ 469 | Attach a secondary phase model to this primary phase model. 470 | 471 | Parameters 472 | ---------- 473 | secondary: SecondaryPhase 474 | A model that inherits the :class:`SecondaryPhase` class. 475 | 476 | Returns 477 | ------- 478 | """ 479 | # remove WOR if it exists 480 | if hasattr(secondary, 'wor'): 481 | object.__setattr__(secondary, 'wor', partial( 482 | self.removed_method, phase='secondary', method='wor')) 483 | 484 | # remove WGR if it exists 485 | if hasattr(secondary, 'wgr'): 486 | object.__setattr__(secondary, 'wgr', partial( 487 | self.removed_method, phase='secondary', method='wgr')) 488 | 489 | # bypass the "frozen" protection to link to the secondary phase 490 | object.__setattr__(secondary, 'primary', self) 491 | object.__setattr__(self, 'secondary', secondary) 492 | 493 | def add_water(self, water: 'WaterPhase') -> None: 494 | """ 495 | Attach a water phase model to this primary phase model. 496 | 497 | Parameters 498 | ---------- 499 | water: WaterPhase 500 | A model that inherits the :class:`WaterPhase` class. 501 | 502 | Returns 503 | ------- 504 | """ 505 | # remove GOR if it exists 506 | if hasattr(water, 'gor'): 507 | object.__setattr__(water, 'gor', partial( 508 | self.removed_method, phase='water', method='gor')) 509 | 510 | # remove CGR if it exists 511 | if hasattr(water, 'cgr'): 512 | object.__setattr__(water, 'cgr', partial( 513 | self.removed_method, phase='water', method='cgr')) 514 | 515 | # bypass the "frozen" protection to link to the water phase 516 | object.__setattr__(water, 'primary', self) 517 | object.__setattr__(self, 'water', water) 518 | 519 | 520 | class AssociatedPhase(DeclineCurve): 521 | """ 522 | Extends :class:`DeclineCurve` for an associated phase forecast. 523 | Each model must implement the defined abstract :meth:`_yieldfn` method. 524 | """ 525 | primary: 'PrimaryPhase' 526 | 527 | def _set_default(self, model: 'AssociatedPhase', name: str) -> None: 528 | # this is a little naughty: bypass the "frozen" protection, just this once... 529 | # naturally, this should only be called during the __post_init__ process 530 | if hasattr(model, 'primary'): 531 | primary = getattr(model, 'primary') 532 | else: 533 | primary = NullPrimaryPhase() 534 | object.__setattr__(primary, name, model) 535 | object.__setattr__(model, 'primary', primary) 536 | 537 | @abstractmethod 538 | def _yieldfn(self, t: NDFloat) -> NDFloat: 539 | raise NotImplementedError 540 | 541 | 542 | class SecondaryPhase(AssociatedPhase): 543 | """ 544 | Extends :class:`DeclineCurve` for a secondary (associated) phase forecast. 545 | Adds the capability to link a primary phase model. 546 | Defines the :meth:`gor` and :meth:`cgr` functions. Each model must implement the 547 | defined abstract method. 548 | """ 549 | 550 | def _set_defaults(self) -> None: 551 | super()._set_default(self, 'secondary') # pragma: no cover 552 | 553 | def gor(self, t: Union[float, NDFloat]) -> NDFloat: 554 | """ 555 | Defines the model GOR function. 556 | Implementation is idential to CGR function. 557 | 558 | Parameters 559 | ---------- 560 | t: Union[float, numpy.NDFloat] 561 | An array of times at which to evaluate the function. 562 | 563 | Returns 564 | ------- 565 | GOR: numpy.NDFloat 566 | The gas-oil ratio function in units of ``Mscf / Bbl``. 567 | """ 568 | t = self._validate_ndarray(t) 569 | return self._yieldfn(t) 570 | 571 | def cgr(self, t: Union[float, NDFloat]) -> NDFloat: 572 | """ 573 | Defines the model CGR function. 574 | Implementation is identical to GOR function. 575 | 576 | Parameters 577 | ---------- 578 | t: Union[float, numpy.NDFloat] 579 | An array of times at which to evaluate the function. 580 | 581 | Returns 582 | ------- 583 | CGR: numpy.NDFloat 584 | The condensate-gas ratio in units of ``Bbl / Mscf``. 585 | """ 586 | t = self._validate_ndarray(t) 587 | return self._yieldfn(t) 588 | 589 | 590 | class WaterPhase(AssociatedPhase): 591 | """ 592 | Extends :class:`DeclineCurve` for a water (associated) phase forecast. 593 | Adds the capability to link a primary phase model. 594 | Defines the :meth:`wor` function. Each model must implement the 595 | defined abstract method. 596 | """ 597 | 598 | def _set_defaults(self) -> None: 599 | super()._set_default(self, 'water') # pragma: no cover 600 | 601 | def wor(self, t: Union[float, NDFloat]) -> NDFloat: 602 | """ 603 | Defines the model WOR function. 604 | 605 | Parameters 606 | ---------- 607 | t: Union[float, numpy.NDFloat] 608 | An array of times at which to evaluate the function. 609 | 610 | Returns 611 | ------- 612 | WOR: numpy.NDFloat 613 | The water-oil ratio function in units of ``Bbl / Bbl``. 614 | """ 615 | t = self._validate_ndarray(t) 616 | return self._yieldfn(t) 617 | 618 | def wgr(self, t: Union[float, NDFloat]) -> NDFloat: 619 | """ 620 | Defines the model WGR function. 621 | 622 | Parameters 623 | ---------- 624 | t: Union[float, numpy.NDFloat] 625 | An array of times at which to evaluate the function. 626 | 627 | Returns 628 | ------- 629 | WOR: numpy.NDFloat 630 | The water-gas ratio function in units of ``Bbl / Mscf``. 631 | """ 632 | t = self._validate_ndarray(t) 633 | return self._yieldfn(t) 634 | 635 | 636 | class BothAssociatedPhase(SecondaryPhase, WaterPhase): 637 | """ 638 | Extends :class:`DeclineCurve` for a general yield model used for both secondary phase 639 | and water phase. 640 | """ 641 | 642 | def _set_defaults(self) -> None: 643 | super()._set_default(self, 'secondary') 644 | super()._set_default(self, 'water') 645 | 646 | 647 | # Must import these here to avoid circular dependency 648 | from .primary import NullPrimaryPhase 649 | from .associated import NullAssociatedPhase 650 | -------------------------------------------------------------------------------- /petbox/dca/bourdet.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decline Curve Models 3 | Copyright © 2020 David S. Fulford 4 | 5 | Author 6 | ------ 7 | David S. Fulford 8 | Derrick W. Turk 9 | 10 | Notes 11 | ----- 12 | Created on August 5, 2019 13 | """ 14 | 15 | from math import exp, log, log1p, ceil as ceiling, floor 16 | 17 | import numpy as np 18 | 19 | from typing import Tuple 20 | from numpy.typing import NDArray 21 | from typing import cast 22 | 23 | NDFloat = NDArray[np.float64] 24 | 25 | 26 | LOG10 = log(10) 27 | 28 | def _get_L_bourdet(x: NDFloat, L: float, i: int = 0) -> int: 29 | """ 30 | First left-end point for Bourdet derivative. 31 | """ 32 | if L == 0: 33 | return i + 1 34 | 35 | dx = x - x[i] 36 | k = len(dx) - 1 37 | idx = np.where((dx <= L) & (dx >= 0.))[0] 38 | if idx.size > 0: 39 | k = min(k, idx[-1] + 1) 40 | 41 | return k 42 | 43 | 44 | def _get_R_bourdet(x: NDFloat, L: float, i: int = -1) -> int: 45 | """ 46 | First right-end points for Bourdet derivative. 47 | """ 48 | if L == 0: 49 | return i - 1 50 | 51 | dx = x[i] - x 52 | k = 0 53 | idx = np.where((dx < L) & (dx >= 0.))[-1] 54 | if idx.size > 0: 55 | k = max(k, idx[0] - 1) 56 | 57 | return k 58 | 59 | 60 | def _get_L(y: NDFloat, x: NDFloat, L: float, i: int 61 | ) -> Tuple[NDFloat, NDFloat]: 62 | """ 63 | Bourdet indices for left-end points that lay inside of distance L. 64 | """ 65 | dx = x[i] - x[:i] 66 | dy = y[i] - y[:i] 67 | idx = np.where((dx <= L) & (dx >= 0.))[0] 68 | if idx.size > 0: 69 | idx = max(0, idx[0] - 1) 70 | return dx[idx], dy[idx] 71 | else: 72 | return dx[-1], dy[-1] 73 | 74 | 75 | def _get_R(y: NDFloat, x: NDFloat, L: float, i: int 76 | ) -> Tuple[NDFloat, NDFloat]: 77 | """ 78 | Bourdet indices for right-end points that lay inside of distance L. 79 | """ 80 | dx = x[i + 1:] - x[i] 81 | dy = y[i + 1:] - y[i] 82 | idx = np.where((dx <= L) & (dx >= 0.))[0] 83 | 84 | if idx.size > 0: 85 | idx = min(len(x) - 1, idx[-1] + 1) 86 | return dx[idx], dy[idx] 87 | else: 88 | return dx[0], dy[0] 89 | 90 | 91 | def _get_L_der(x: NDFloat, L: float, i: int = 0) -> int: 92 | """ 93 | Forward derivative indices for left-end points that lay outside of distance L. 94 | """ 95 | if L == 0: 96 | return i + 1 97 | 98 | dx = x - x[i] 99 | idx = np.where((dx >= L) & (dx >= 0.))[0] 100 | if idx.size > 0: 101 | return idx[0] 102 | 103 | return len(dx) - 1 104 | 105 | 106 | def _get_R_der(x: NDFloat, L: float, i: int = -1) -> int: 107 | """ 108 | Backward derivative indices for right-end points that lay outside of distance L. 109 | """ 110 | if L == 0: 111 | return i - 1 112 | 113 | dx = x[i] - x 114 | idx = np.where((dx < L) & (dx >= 0.))[0] 115 | if idx.size > 0: 116 | return idx[-1] 117 | 118 | return 0 # pragma: no cover 119 | 120 | 121 | def bourdet(y: NDFloat, x: NDFloat, L: float = 0.0, 122 | xlog: bool = True, ylog: bool = False 123 | ) -> NDFloat: 124 | """ 125 | Bourdet Derivative Smoothing 126 | 127 | Bourdet, D., Ayoub, J. A., and Pirard, Y. M. 1989. Use of Pressure Derivative in 128 | Well-Test Interpretation. SPE Form Eval 4 (2): 293–302. SPE-12777-PA. 129 | https://doi.org/10.2118/12777-PA. 130 | 131 | Parameters 132 | ---------- 133 | y: numpy.NDFloat 134 | An array of y values to compute the derivative for. 135 | 136 | x: numpy.NDFloat 137 | An array of x values. 138 | 139 | L: float = 0.0 140 | Smoothing factor in units of log-cycle fractions. A value of zero returns the 141 | point-by-point first-order difference derivative. 142 | 143 | xlog: bool = True 144 | Calculate the derivative with respect to the log of x, i.e. ``dy / d[ln x]``. 145 | 146 | ylog: bool = False 147 | Calculate the derivative with respect to the log of y, i.e. ``d[ln y] / dx``. 148 | 149 | Returns 150 | ------- 151 | der: numpy.NDFloat 152 | The calculated derivative. 153 | """ 154 | x = np.atleast_1d(x).astype(np.float64) 155 | y = np.atleast_1d(y).astype(np.float64) 156 | 157 | log_x = cast(NDFloat, np.log10(x)) 158 | 159 | if ylog: 160 | y = cast(NDFloat, np.log(y)) 161 | 162 | x_L = np.zeros_like(log_x, dtype=np.dtype(float)) 163 | x_R = np.zeros_like(log_x, dtype=np.dtype(float)) 164 | y_L = np.zeros_like(log_x, dtype=np.dtype(float)) 165 | y_R = np.zeros_like(log_x, dtype=np.dtype(float)) 166 | 167 | # get points for forward and backward derivatives 168 | k1 = _get_L_bourdet(log_x, L) 169 | k2 = _get_R_bourdet(log_x, L) 170 | 171 | # compute first & last points 172 | x_L[0] = log_x[k1] - log_x[0] 173 | y_L[0] = y[k1] - y[0] 174 | 175 | x_R[-1] = log_x[-1] - log_x[k2] 176 | y_R[-1] = y[-1] - y[k2] 177 | 178 | # compute bourdet derivative 179 | for i in range(k1, k2): 180 | x_L[i], y_L[i] = _get_L(y, log_x, L, i) 181 | x_R[i], y_R[i] = _get_R(y, log_x, L, i) 182 | 183 | x_L *= LOG10 184 | x_R *= LOG10 185 | der = (y_L / x_L * x_R + y_R / x_R * x_L) / (x_L + x_R) 186 | 187 | # compute forward difference at left edge 188 | for i in range(0, k1): 189 | idx = _get_L_der(log_x, L, i) 190 | dy = y[idx] - y[i] 191 | dx = log_x[idx] - log_x[i] 192 | dx *= LOG10 193 | der[i] = dy / dx 194 | 195 | # compute backward difference at right edge 196 | for i in range(k2, len(log_x))[::-1]: 197 | idx = _get_R_der(log_x, L, i) 198 | dy = y[i] - y[idx] 199 | dx = log_x[i] - log_x[idx] 200 | dx *= LOG10 201 | der[i] = dy / dx 202 | 203 | if not xlog: 204 | der /= x 205 | 206 | return der 207 | -------------------------------------------------------------------------------- /petbox/dca/primary.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decline Curve Models 3 | Copyright © 2020 David S. Fulford 4 | 5 | Author 6 | ------ 7 | David S. Fulford 8 | Derrick W. Turk 9 | 10 | Notes 11 | ----- 12 | Created on August 5, 2019 13 | """ 14 | 15 | import sys 16 | from math import exp, expm1, log, log1p, ceil as ceiling, floor 17 | import warnings 18 | 19 | import dataclasses as dc 20 | from dataclasses import dataclass, field 21 | 22 | import numpy as np 23 | 24 | from scipy.special import expi as ei, gammainc # type: ignore 25 | from scipy.integrate import fixed_quad # type: ignore 26 | 27 | from abc import ABC, abstractmethod 28 | from typing import (TypeVar, Type, List, Dict, Tuple, Any, 29 | Sequence, Iterable, Optional, Callable, ClassVar, Union) 30 | from numpy.typing import NDArray 31 | from typing import cast 32 | 33 | from .base import (ParamDesc, DeclineCurve, PrimaryPhase, SecondaryPhase, 34 | DAYS_PER_MONTH, DAYS_PER_YEAR, LOG_EPSILON, MIN_EPSILON) 35 | 36 | NDFloat = NDArray[np.float64] 37 | 38 | 39 | @dataclass 40 | class NullPrimaryPhase(PrimaryPhase): 41 | """ 42 | A null `PrimaryPhase` class that always returns zeroes. 43 | 44 | Parameters 45 | ---------- 46 | None 47 | """ 48 | 49 | def _set_defaults(self) -> None: 50 | # Do not associate with the null secondary phase 51 | pass 52 | 53 | def _qfn(self, t: NDFloat) -> NDFloat: 54 | return np.zeros_like(t, dtype=np.float64) 55 | 56 | def _Nfn(self, t: NDFloat, **kwargs: Any) -> NDFloat: 57 | return np.zeros_like(t, dtype=np.float64) 58 | 59 | def _Dfn(self, t: NDFloat) -> NDFloat: 60 | return np.zeros_like(t, dtype=np.float64) 61 | 62 | def _Dfn2(self, t: NDFloat) -> NDFloat: 63 | return np.zeros_like(t, dtype=np.float64) 64 | 65 | def _betafn(self, t: NDFloat) -> NDFloat: 66 | return np.zeros_like(t, dtype=np.float64) 67 | 68 | def _bfn(self, t: NDFloat) -> NDFloat: 69 | return np.zeros_like(t, dtype=np.float64) 70 | 71 | @classmethod 72 | def get_param_descs(cls) -> List[ParamDesc]: 73 | return [] 74 | 75 | 76 | class MultisegmentHyperbolic(PrimaryPhase): 77 | """ 78 | A base class for Hyperbolic Models that generalizes for any representation of 79 | hyperbolic "Arps'-type" models. Each child class must implement the `_segments` 80 | function which generates the initial parameters of an arbitary number of 81 | hyperbolic segments. 82 | """ 83 | 84 | T_IDX: ClassVar[int] = 0 85 | Q_IDX: ClassVar[int] = 1 86 | D_IDX: ClassVar[int] = 2 87 | B_IDX: ClassVar[int] = 3 88 | N_IDX: ClassVar[int] = 4 89 | B_EPSILON: ClassVar[float] = 1e-10 90 | 91 | segment_params: NDFloat 92 | 93 | @abstractmethod 94 | def _segments(self) -> NDFloat: 95 | """ 96 | Precache the initial conditions of each hyperbolic segment. Should assign a list of params 97 | for the start condition of each segment like: 98 | 99 | self.params = params = np.array([ 100 | [t_1, q_1, D_1, b_1, N_1], 101 | [t_2, q_2, D_2, b_2, N_2], 102 | [..., ..., ..., ..., ...], 103 | [t_m, q_n, D_n, b_n, N_m], 104 | ], dtype=np.float64) 105 | """ 106 | raise NotImplementedError 107 | 108 | def _validate(self) -> None: 109 | # this is a little naughty: bypass the "frozen" protection, just this once... 110 | # naturally, this should only be called during the __post_init__ process 111 | object.__setattr__(self, 'segment_params', self._segments()) 112 | 113 | @staticmethod 114 | def _qcheck(t0: float, q: float, D: float, b: float, N: float, 115 | t: Union[float, NDFloat]) -> NDFloat: 116 | """ 117 | Compute the proper Arps form of q 118 | """ 119 | dt = DeclineCurve._validate_ndarray(t - t0) 120 | 121 | if D < MIN_EPSILON: 122 | return np.full_like(t, q, dtype=np.float64) 123 | 124 | # Handle overflow for these function 125 | # q * np.exp(-D * dt) 126 | # q * np.log(1.0 + D * b * dt) ** (1.0 / b) 127 | if b <= MultisegmentHyperbolic.B_EPSILON: 128 | D_dt = D * dt 129 | else: 130 | D_dt = np.log(1.0 + D * b * dt) / b 131 | 132 | np.putmask(D_dt, mask=D_dt > LOG_EPSILON, values=np.inf) # type: ignore 133 | np.putmask(D_dt, mask=D_dt < -LOG_EPSILON, values=-np.inf) # type: ignore 134 | with np.errstate(over='ignore', under='ignore', invalid='ignore'): 135 | return q * np.exp(-D_dt) 136 | 137 | @staticmethod 138 | def _Ncheck(t0: float, q: float, D: float, b: float, N: float, 139 | t: Union[float, NDFloat]) -> NDFloat: 140 | """ 141 | Compute the proper Arps form of N 142 | """ 143 | dt = DeclineCurve._validate_ndarray(t - t0) 144 | 145 | if q < MIN_EPSILON: 146 | return cast(NDFloat, np.atleast_1d(N) + np.zeros_like(t, dtype=np.float64)) 147 | 148 | if D < MIN_EPSILON: 149 | return np.atleast_1d(N + q * dt) 150 | 151 | if abs(1.0 - b) < MIN_EPSILON: 152 | return N + q / D * np.log1p(D * dt) 153 | 154 | # Handle overflow for this function 155 | # N + q / ((1.0 - b) * D) * (1.0 - (1.0 + b * D * dt) ** (1.0 - 1.0 / b)) 156 | if b <= MultisegmentHyperbolic.B_EPSILON: 157 | D_dt = -D * dt 158 | q_b_D = q / D 159 | else: 160 | D_dt = (1.0 - 1.0 / b) * np.log(1.0 + b * D * dt) 161 | q_b_D = q / ((1.0 - b) * D) 162 | 163 | np.putmask(D_dt, mask=D_dt > LOG_EPSILON, values=np.inf) # type: ignore 164 | np.putmask(D_dt, mask=D_dt < -LOG_EPSILON, values=-np.inf) # type: ignore 165 | 166 | with np.errstate(over='ignore', under='ignore', invalid='ignore'): 167 | return N - q_b_D * np.expm1(D_dt) 168 | 169 | @staticmethod 170 | def _Dcheck(t0: float, q: float, D: float, b: float, N: float, 171 | t: Union[float, NDFloat]) -> NDFloat: 172 | """ 173 | Compute the proper Arps form of D 174 | """ 175 | dt = DeclineCurve._validate_ndarray(t - t0) 176 | 177 | if D < MIN_EPSILON: 178 | return np.full_like(t, D, dtype=np.float64) 179 | 180 | if b < MIN_EPSILON: 181 | b = 0.0 182 | 183 | with np.errstate(over='ignore', under='ignore', invalid='ignore'): 184 | return D / (1.0 + D * b * dt) 185 | 186 | @staticmethod 187 | def _Dcheck2(t0: float, q: float, D: float, b: float, N: float, 188 | t: Union[float, NDFloat]) -> NDFloat: 189 | """ 190 | Compute the derivative of the proper Arps form of D 191 | """ 192 | dt = DeclineCurve._validate_ndarray(t - t0) 193 | 194 | if D < MIN_EPSILON: 195 | return np.full_like(t, D, dtype=np.float64) 196 | 197 | Denom = 1.0 + D * b * dt 198 | return -b * D * D / (Denom * Denom) 199 | 200 | def _vectorize(self, fn: Callable[..., NDFloat], 201 | t: Union[float, NDFloat]) -> NDFloat: 202 | """ 203 | Vectorize the computation of a parameter 204 | """ 205 | t = np.atleast_1d(t) 206 | p = self.segment_params 207 | x = np.zeros_like(t, dtype=np.float64) 208 | 209 | for i in range(p.shape[0]): 210 | where_seg = t >= p[i, self.T_IDX] 211 | if i < p.shape[0] - 1: 212 | where_seg = where_seg & (t < p[i + 1, self.T_IDX]) 213 | 214 | x[where_seg] = fn(*p[i], t[where_seg]) 215 | 216 | return x 217 | 218 | def _qfn(self, t: NDFloat) -> NDFloat: 219 | return self._vectorize(self._qcheck, t) 220 | 221 | def _Nfn(self, t: NDFloat, **kwargs: Any) -> NDFloat: 222 | return self._vectorize(self._Ncheck, t) 223 | 224 | def _Dfn(self, t: NDFloat) -> NDFloat: 225 | return self._vectorize(self._Dcheck, t) 226 | 227 | def _Dfn2(self, t: NDFloat) -> NDFloat: 228 | return self._vectorize(self._Dcheck2, t) 229 | 230 | def _betafn(self, t: NDFloat) -> NDFloat: 231 | return self._vectorize(self._Dcheck, t) * t 232 | 233 | def _bfn(self, t: NDFloat) -> NDFloat: 234 | return self._vectorize(lambda *p: p[self.B_IDX], t) 235 | 236 | @classmethod 237 | def nominal_from_secant(cls, D: float, b: float) -> float: 238 | if b <= MultisegmentHyperbolic.B_EPSILON: 239 | return cls.nominal_from_tangent(D) 240 | 241 | if D < MIN_EPSILON: 242 | return 0.0 # pragma: no cover 243 | 244 | if D >= 1.0: 245 | return np.inf # pragma: no cover 246 | 247 | # D < 1 per validation, so this should never overflow 248 | return expm1(b * -log1p(-D)) / b 249 | 250 | @classmethod 251 | def secant_from_nominal(cls, D: float, b: float) -> float: 252 | if b <= MultisegmentHyperbolic.B_EPSILON: 253 | return cls.tangent_from_nominal(D) 254 | 255 | # Handle overflow for this function 256 | # Deff = 1.0 - 1.0 / (1.0 + D * b) ** (1.0 / b) 257 | 258 | if D < MIN_EPSILON: 259 | return 0.0 # pragma: no cover 260 | 261 | D_b = 1.0 + D * b 262 | if D_b < MIN_EPSILON: 263 | return -np.inf # pragma: no cover 264 | 265 | D_dt = np.log(D_b) / b 266 | if D_dt > LOG_EPSILON: 267 | # >= 100% decline is not possible 268 | return 1.0 # pragma: no cover 269 | 270 | return -expm1(-D_dt) 271 | 272 | @classmethod 273 | def nominal_from_tangent(cls, D: float) -> float: 274 | if D < MIN_EPSILON: 275 | return 0.0 # pragma: no cover 276 | 277 | if D >= 1.0: 278 | return np.inf # pragma: no cover 279 | 280 | return -log1p(-D) 281 | 282 | @classmethod 283 | def tangent_from_nominal(cls, D: float) -> float: 284 | if D < MIN_EPSILON: 285 | return 0.0 # pragma: no cover 286 | 287 | if D > LOG_EPSILON: 288 | # >= 100% decline is not possible 289 | return 1.0 # pragma: no cover 290 | 291 | return -expm1(-D) 292 | 293 | 294 | @dataclass(frozen=True) 295 | class MH(MultisegmentHyperbolic): 296 | """ 297 | Modified Hyperbolic Model 298 | 299 | Robertson, S. 1988. Generalized Hyperbolic Equation. 300 | Available from SPE, Richardson, Texas, USA. SPE-18731-MS. 301 | 302 | Parameters 303 | ---------- 304 | qi: float 305 | The initial production rate in units of ``volume / day``. 306 | 307 | Di: float 308 | The initial decline rate in secant effective decline aka annual 309 | effective percent decline, i.e. 310 | 311 | .. math:: 312 | 313 | D_i = 1 - \\frac{q(t=1 \\, year)}{qi} 314 | 315 | .. math:: 316 | 317 | D_i = 1 - (1 + 365.25 \\, D_{nom} \\, b) ^ \\frac{-1}{b} 318 | 319 | where ``Dnom`` is defined as :math:`\\frac{d}{dt}\\textrm{ln} \\, q` 320 | and has units of ``1 / day``. 321 | 322 | bi: float 323 | The (initial) hyperbolic parameter, defined as :math:`\\frac{d}{dt}\\frac{1}{D}`. 324 | This parameter is dimensionless. 325 | 326 | Dterm: float 327 | The terminal secant effective decline rate aka annual effective percent decline. 328 | """ 329 | qi: float 330 | Di: float 331 | bi: float 332 | Dterm: float = 0.0 333 | 334 | validate_params: Iterable[bool] = field(default_factory=lambda: [True] * 4) 335 | 336 | def _validate(self) -> None: 337 | if self.nominal_from_secant(self.Di, self.bi) < self.nominal_from_tangent(self.Dterm): 338 | raise ValueError('Di < Dterm') 339 | super()._validate() 340 | 341 | def _segments(self) -> NDFloat: 342 | """ 343 | Precache the initial conditions of each hyperbolic segment. 344 | """ 345 | Di_nom = self.nominal_from_secant(self.Di, self.bi) / DAYS_PER_YEAR 346 | Dterm_nom = self.nominal_from_tangent(self.Dterm) / DAYS_PER_YEAR 347 | 348 | if Di_nom < MIN_EPSILON or Dterm_nom < MIN_EPSILON or self.bi < MIN_EPSILON: 349 | return np.array([ 350 | [0.0, self.qi, Di_nom, self.bi, 0.0] 351 | ], dtype=np.float64) 352 | 353 | tterm = ((1.0 / Dterm_nom) - (1.0 / Di_nom)) / self.bi 354 | qterm = self._qcheck(0.0, self.qi, Di_nom, self.bi, 0.0, np.array(tterm)).item() 355 | Nterm = self._Ncheck(0.0, self.qi, Di_nom, self.bi, 0.0, np.array(tterm)).item() 356 | 357 | return np.array([ 358 | [0.0, self.qi, Di_nom, self.bi, 0.0], 359 | [tterm, qterm, Dterm_nom, 0.0, Nterm] 360 | ], dtype=np.float64) 361 | 362 | @classmethod 363 | def get_param_descs(cls) -> List[ParamDesc]: 364 | return [ 365 | ParamDesc( 366 | 'qi', 'Initial rate [vol/day]', 367 | 0.0, None, 368 | lambda r, n: r.uniform(1e-10, 1e6, n)), 369 | ParamDesc( # TODO 370 | 'Di', 'Initial decline [sec. eff. / yr]', 371 | 0.0, 1.0, 372 | lambda r, n: r.uniform(0.0, 1.0, n), 373 | exclude_upper_bound=True), 374 | ParamDesc( 375 | 'bi', 'Hyperbolic exponent', 376 | 0.0, 2.0, 377 | lambda r, n: r.uniform(0.0, 2.0, n)), 378 | ParamDesc( # TODO 379 | 'Dterm', 'Terminal decline [tan. eff. / yr]', 380 | 0.0, 1.0, 381 | lambda r, n: np.zeros(n, dtype=np.float64), 382 | exclude_upper_bound=True) 383 | ] 384 | 385 | 386 | @dataclass(frozen=True) 387 | class THM(MultisegmentHyperbolic): 388 | """ 389 | Transient Hyperbolic Model 390 | 391 | Fulford, D. S., and Blasingame, T. A. 2013. Evaluation of Time-Rate 392 | Performance of Shale Wells using the Transient Hyperbolic Relation. 393 | Presented at SPE Unconventional Resources Conference – Canada in Calgary, 394 | Alberta, Canda, 5–7 November. SPE-167242-MS. 395 | https://doi.org/10.2118/167242-MS. 396 | 397 | 398 | Analytic Approximation 399 | 400 | Fulford, D.S. 2018. A Model-Based Diagnostic Workflow for Time-Rate 401 | Performance of Unconventional Wells. Presented at Unconventional Resources 402 | Conference in Houston, Texas, USA, 23–25 July. URTeC-2903036. 403 | https://doi.org/10.15530/urtec-2018-2903036. 404 | 405 | Parameters 406 | ---------- 407 | qi: float 408 | The initial production rate in units of ``volume / day``. 409 | 410 | Di: float 411 | The initial decline rate in secant effective decline aka annual 412 | effective percent decline, i.e. 413 | 414 | .. math:: 415 | 416 | D_i = 1 - \\frac{q(t=1 \\, year)}{qi} 417 | 418 | .. math:: 419 | 420 | D_i = 1 - (1 + 365.25 \\, D_{nom} \\, b) ^ \\frac{-1}{b} 421 | 422 | where ``Dnom`` is defined as :math:`\\frac{d}{dt}\\textrm{ln} \\, q` 423 | and has units of ``1 / day``. 424 | 425 | bi: float 426 | The initial hyperbolic parameter, defined as :math:`\\frac{d}{dt}\\frac{1}{D}`. 427 | This parameter is dimensionless. Advised to always be set to ``2.0`` to represent 428 | transient linear flow. 429 | See literature for more details. 430 | 431 | bf: float 432 | The final hyperbolic parameter after transition. Represents the boundary-dominated or 433 | boundary-influenced flow regime. 434 | 435 | telf: float 436 | The time to end of linear flow in units of ``day``, or more specifically the time at 437 | which ``b(t) < bi``. Visual end of half slope occurs ``~2.5x`` after ``telf``. 438 | 439 | bterm: Optional[float] = None 440 | The terminal value of the hyperbolic parameter. Has two interpretations: 441 | 442 | If ``tterm > 0`` then the terminal regime is a hyperbolic regime with ``b = bterm`` 443 | and the parameter is given as the hyperbolic parameter. 444 | 445 | If ``tterm = 0`` then the terminal regime is an exponential regime with 446 | ``Dterm = bterm`` and the parameter is given as secant effective decline. 447 | 448 | tterm: Optional[float] = None 449 | The time to start of the terminal regime in years. Setting ``tterm = 0.0`` creates an 450 | exponential terminal regime, while setting ``tterm > 0.0`` creates a hyperbolic 451 | terminal regime. 452 | """ 453 | qi: float 454 | Di: float 455 | bi: float 456 | bf: float 457 | telf: float 458 | bterm: float = 0.0 459 | tterm: float = 0.0 460 | 461 | validate_params: Iterable[bool] = field(default_factory=lambda: [True] * 7) 462 | 463 | EXP_GAMMA: ClassVar[float] = exp(0.5572156) 464 | EXP_1: ClassVar[float] = exp(1.0) 465 | 466 | def _validate(self) -> None: 467 | # TODO: do we want to deal with optional params at all? 468 | if self.bi < self.bf: 469 | raise ValueError('bi < bf') 470 | if self.bf < self.bterm and self.tterm != 0.0: 471 | raise ValueError('bf < bterm and tterm != 0') 472 | # cheat to fix this 473 | # object.__setattr__(self, 'bterm', self.bf) 474 | pass 475 | if self.tterm != 0.0 and self.tterm * DAYS_PER_YEAR < self.telf: 476 | raise ValueError('tterm < telf') 477 | super()._validate() 478 | 479 | def _segments(self) -> NDFloat: 480 | 481 | t1 = 0.0 482 | t2 = self.telf * (self.EXP_1 - 1.0) 483 | t3 = self.telf * (self.EXP_1 + 1.0) 484 | tterm = self.tterm * DAYS_PER_YEAR 485 | 486 | b1 = self.bi 487 | b2 = self.bi - ((self.bi - self.bf) / self.EXP_1) 488 | b3 = self.bf 489 | bterm = self.bterm 490 | 491 | q1 = self.qi 492 | D1 = self.nominal_from_secant(self.Di, self.bi) / DAYS_PER_YEAR 493 | N1 = 0.0 494 | 495 | if tterm == 0.0 and bterm == 0.0: 496 | # no terminal segment 497 | segments = np.array( 498 | [ 499 | [t1, q1, D1, b1, N1], 500 | [t2, None, None, b2, None], 501 | [t3, None, None, b3, None] 502 | ], 503 | dtype=np.float64 504 | ) 505 | 506 | elif tterm != 0.0: 507 | # hyperbolic terminal segment 508 | t4 = tterm if tterm >= t3 else self.telf * 7.0 509 | b4 = min(bterm, b3) 510 | segments = np.array( 511 | [ 512 | [t1, q1, D1, b1, N1], 513 | [t2, None, None, b2, None], 514 | [t3, None, None, b3, None], 515 | [t4, None, None, b4, None], 516 | ], 517 | dtype=np.float64 518 | ) 519 | 520 | elif tterm == 0.0 and bterm != 0.0: 521 | # exponential terminal segment 522 | D2 = self._Dcheck(t1, q1, D1, b1, 0.0, t2).item() 523 | q2 = self._qcheck(t1, q1, D1, b1, 0.0, t2).item() 524 | D3 = self._Dcheck(t2, q2, D2, b2, 0.0, t3).item() 525 | D4 = self.nominal_from_tangent(bterm) / DAYS_PER_YEAR 526 | b4 = 0.0 527 | if b3 <= 0: 528 | t4 = t3 529 | else: 530 | t4 = max(t3, t3 + (1.0 / D4 - 1.0 / D3) / b3) 531 | 532 | if t4 == t3: 533 | segments = np.array( 534 | [ 535 | [t1, q1, D1, b1, N1], 536 | [t2, None, None, b2, None], 537 | [t4, None, None, b4, None], 538 | ], 539 | dtype=np.float64 540 | ) 541 | else: 542 | segments = np.array( 543 | [ 544 | [t1, q1, D1, b1, N1], 545 | [t2, None, None, b2, None], 546 | [t3, None, None, b3, None], 547 | [t4, None, None, b4, None], 548 | ], 549 | dtype=np.float64 550 | ) 551 | 552 | # Compute initial values for each segment after the first, from the 553 | # previous segment's values 554 | for i in range(segments.shape[0] - 1): 555 | p = [*segments[i], segments[i + 1, self.T_IDX]] 556 | segments[i + 1, self.D_IDX] = self._Dcheck(*p).item() 557 | segments[i + 1, self.Q_IDX] = self._qcheck(*p).item() 558 | segments[i + 1, self.N_IDX] = self._Ncheck(*p).item() 559 | 560 | return segments 561 | 562 | def transient_rate(self, t: Union[float, NDFloat], **kwargs: Any) -> NDFloat: 563 | """ 564 | Compute the rate function using full definition. 565 | Uses :func:`scipy.integrate.fixed_quad` to integrate :func:`transient_D`. 566 | 567 | .. math:: 568 | 569 | q(t) = e^{-\\int_0^t D(t) \\, dt} 570 | 571 | Parameters 572 | ---------- 573 | t: Union[float, numpy.NDFloat] 574 | An array of time values to evaluate. 575 | 576 | **kwargs 577 | Additional keyword arguments passed to :func:`scipy.integrate.fixed_quad`. 578 | 579 | Returns 580 | ------- 581 | numpy.NDFloat 582 | """ 583 | t = self._validate_ndarray(t) 584 | return self._transqfn(t, **kwargs) 585 | 586 | def transient_cum(self, t: Union[float, NDFloat], **kwargs: Any) -> NDFloat: 587 | """ 588 | Compute the cumulative volume function using full definition. 589 | Uses :func:`scipy.integrate.fixed_quad` to integrate :func:`transient_q`. 590 | 591 | .. math:: 592 | 593 | N(t) = \\int_0^t q(t) \\, dt 594 | 595 | Parameters 596 | ---------- 597 | t: Union[float, numpy.NDFloat] 598 | An array of time values to evaluate. 599 | 600 | **kwargs 601 | Additional keyword arguments passed to :func:`scipy.integrate.fixed_quad`. 602 | 603 | Returns 604 | ------- 605 | numpy.NDFloat 606 | """ 607 | t = self._validate_ndarray(t) 608 | return self._transNfn(t, **kwargs) 609 | 610 | def transient_D(self, t: Union[float, NDFloat]) -> NDFloat: 611 | """ 612 | Compute the D-parameter function using full definition. 613 | 614 | .. math:: 615 | 616 | D(t) = \\frac{1}{\\frac{1}{Di} + b_i t + \\frac{bi - bf}{c} 617 | (\\textrm{Ei}[-e^{-c \\, (t -t_{elf}) + e^(\\gamma)}] 618 | - \\textrm{Ei}[-e^{c \\, t_{elf} + e^(\\gamma)}])} 619 | 620 | Parameters 621 | ---------- 622 | t: Union[float, numpy.NDFloat] 623 | An array of time values to evaluate. 624 | 625 | Returns 626 | ------- 627 | numpy.NDFloat 628 | """ 629 | t = self._validate_ndarray(t) 630 | return self._transDfn(t) 631 | 632 | def transient_beta(self, t: Union[float, NDFloat]) -> NDFloat: 633 | """ 634 | Compute the beta-parameter function using full definition. 635 | 636 | .. math:: 637 | 638 | \\beta(t) = \\frac{t}{\\frac{1}{Di} + b_i t + \\frac{bi - bf}{c} 639 | (\\textrm{Ei}[-e^{-c \\, (t -t_{elf}) + e^(\\gamma)}] 640 | - \\textrm{Ei}[-e^{c \\, t_{elf} + e^(\\gamma)}])} 641 | 642 | Parameters 643 | ---------- 644 | t: Union[float, numpy.NDFloat] 645 | An array of time values to evaluate. 646 | 647 | Returns 648 | ------- 649 | numpy.NDFloat 650 | """ 651 | t = self._validate_ndarray(t) 652 | return self._transDfn(t) * t 653 | 654 | def transient_b(self, t: Union[float, NDFloat]) -> NDFloat: 655 | """ 656 | Compute the b-parameter function using full definition. 657 | 658 | .. math:: 659 | 660 | b(t) = b_i - (b_i - b_f) e^{-\\textrm{exp}[{-c * (t - t_{elf}) + e^{\\gamma}}]} 661 | 662 | where: 663 | 664 | .. math:: 665 | 666 | c & = \\frac{e^{\\gamma}}{1.5 \\, t_{elf}} \\\\ 667 | \\gamma & = 0.57721566... \\; \\textrm{(Euler-Mascheroni constant)} 668 | 669 | Parameters 670 | ---------- 671 | t: Union[float, numpy.NDFloat] 672 | An array of time values to evaluate. 673 | 674 | Returns 675 | ------- 676 | numpy.NDFloat 677 | """ 678 | t = self._validate_ndarray(t) 679 | return self._transbfn(t) 680 | 681 | def _transNfn(self, t: NDFloat, **kwargs: Any) -> NDFloat: 682 | kwargs.setdefault('n', 10) 683 | return self._integrate_with(lambda t: self._transqfn(t, **kwargs), t, **kwargs) 684 | 685 | def _transqfn(self, t: NDFloat, **kwargs: Any) -> NDFloat: 686 | kwargs.setdefault('n', 10) 687 | qi = self.qi 688 | Dnom_i = self.nominal_from_secant(self.Di, self.bi) / DAYS_PER_YEAR 689 | D_dt = Dnom_i - self._integrate_with(self._transDfn, t, **kwargs) 690 | where_eps = abs(D_dt) > LOG_EPSILON 691 | result = np.zeros_like(t, dtype=np.float64) 692 | result[where_eps] = 0.0 693 | result[~where_eps] = qi * np.exp(D_dt) 694 | return result 695 | 696 | def _transDfn(self, t: NDFloat) -> NDFloat: 697 | try: 698 | import mpmath as mp # type: ignore 699 | except ImportError: 700 | print('`mpmath` not installed, please install it compute the transient THM functions', 701 | file=sys.stderr) 702 | return np.full_like(t, np.nan, dtype=np.float64) 703 | 704 | t = np.atleast_1d(t) 705 | qi = self.qi 706 | bi = self.bi 707 | bf = self.bf 708 | telf = self.telf 709 | bterm = self.bterm 710 | tterm = self.tterm * DAYS_PER_YEAR 711 | 712 | if self.Di == 0.0: 713 | return np.full_like(t, 0.0, dtype=np.float64) 714 | 715 | Dnom_i = self.nominal_from_secant(self.Di, self.bi) / DAYS_PER_YEAR 716 | 717 | if Dnom_i < MIN_EPSILON: 718 | # no need to compute transient function 719 | return self._Dcheck(0.0, qi, Dnom_i, bi, 0.0, t) 720 | 721 | elif Dnom_i < MIN_EPSILON: 722 | raise ValueError(f'invalid Dnom in _transDfn {Dnom_i}') # pragma: no cover 723 | 724 | if telf < MIN_EPSILON: 725 | # telf is too small to compute transient function 726 | D = self._Dcheck(0.0, qi, Dnom_i, bf, 0.0, t) 727 | Dterm = self._Dcheck(0.0, qi, Dnom_i, bf, 0.0, tterm).item() 728 | 729 | else: 730 | # transient function 731 | if tterm > 0.0: 732 | where_term = t >= tterm 733 | else: 734 | # no known terminal times in this array, might be some later if exponential terminal 735 | where_term = np.full_like(t, False, dtype=np.bool_) 736 | 737 | c = self.EXP_GAMMA / (1.5 * telf) 738 | D_denom = np.full_like(t, np.nan, dtype=np.float64) 739 | D_denom[~where_term] = ( 740 | 1.0 / Dnom_i 741 | + bi * t[~where_term] 742 | - ei(-np.exp(c * telf + self.EXP_GAMMA)) 743 | ) 744 | if abs(bi - bf) >= MIN_EPSILON: 745 | for i, _t in enumerate(t): 746 | if where_term[i]: 747 | break 748 | D_denom[i] += (bi - bf) / c * mp.ei(-mp.exp(-c * (_t - telf) + self.EXP_GAMMA)) 749 | 750 | D = 1.0 / D_denom 751 | 752 | if tterm > 0.0: 753 | D_denom = ( 754 | 1.0 / Dnom_i 755 | + bi * tterm 756 | - ei(-np.exp(c * telf + self.EXP_GAMMA)) 757 | ) 758 | if abs(bi - bf) >= MIN_EPSILON: 759 | D_denom += (bi - bf) / c * mp.ei(-mp.exp(-c * (tterm - telf) + self.EXP_GAMMA)) 760 | 761 | Dterm = float(1.0 / D_denom) 762 | 763 | else: 764 | Dterm = 0.0 765 | 766 | # terminal regime 767 | if tterm != 0.0 or bterm != 0.0: 768 | if tterm > 0.0: 769 | # hyperbolic 770 | where_term = t > tterm 771 | D[where_term] = self._Dcheck(tterm, 1.0, Dterm, bterm, 0.0, t[where_term]) 772 | 773 | elif tterm == 0.0: 774 | # exponential 775 | Dterm = self.nominal_from_tangent(bterm) / DAYS_PER_YEAR 776 | where_term = Dterm >= D 777 | D[where_term] = self._Dcheck(tterm, 1.0, Dterm, 0.0, 0.0, t[where_term]) 778 | 779 | return D 780 | 781 | def _transbfn(self, t: NDFloat) -> NDFloat: 782 | 783 | t = np.atleast_1d(t) 784 | bi = self.bi 785 | bf = self.bf 786 | telf = self.telf 787 | bterm = self.bterm 788 | tterm = self.tterm * DAYS_PER_YEAR 789 | 790 | if telf >= MIN_EPSILON: 791 | c = self.EXP_GAMMA / (1.5 * telf) 792 | b = bi - (bi - bf) * np.exp(-np.exp(-c * (t - telf) + self.EXP_GAMMA)) 793 | else: 794 | b = np.full_like(t, bf, dtype=np.float64) 795 | 796 | # terminal regime 797 | if tterm != 0.0 or bterm != 0: 798 | if tterm > 0.0: 799 | # hyperbolic 800 | where_term = t > tterm 801 | b[where_term] = bterm 802 | 803 | elif tterm == 0.0: 804 | # exponential 805 | Dterm = self.nominal_from_tangent(bterm) / DAYS_PER_YEAR 806 | D = self._transDfn(t) 807 | where_term = Dterm >= D 808 | b[where_term] = 0.0 809 | 810 | return b 811 | 812 | @classmethod 813 | def get_param_descs(cls) -> List[ParamDesc]: 814 | return [ 815 | ParamDesc( 816 | 'qi', 'Initial rate [vol/day]', 817 | 0.0, None, 818 | lambda r, n: r.uniform(1.0, 2e4, n)), 819 | ParamDesc( # TODO 820 | 'Di', 'Initial decline [sec. eff. / yr]', 821 | 0.0, 1.0, 822 | lambda r, n: r.uniform(0.0, 1.0, n), 823 | exclude_upper_bound=True), 824 | ParamDesc( 825 | 'bi', 'Initial hyperbolic exponent', 826 | 0.0, 2.0, 827 | lambda r, n: np.full(n, 2.0)), 828 | ParamDesc( # TODO 829 | 'bf', 'Final hyperbolic exponent', 830 | 0.0, 2.0, 831 | lambda r, n: r.uniform(0.0, 1.0, n)), 832 | ParamDesc( # TODO 833 | 'telf', 'Time to end of linear flow [days]', 834 | None, None, 835 | lambda r, n: r.uniform(1e-10, 365.25, n)), 836 | ParamDesc( 837 | 'bterm', 'Terminal hyperbolic exponent', 838 | 0.0, 2.0, 839 | lambda r, n: np.full(n, 0.0)), 840 | ParamDesc( 841 | 'tterm', 'Terminal time [years]', 842 | 0.0, None, 843 | lambda r, n: np.full(n, 0.0)) 844 | ] 845 | 846 | 847 | @dataclass(frozen=True) 848 | class PLE(PrimaryPhase): 849 | """ 850 | Power-Law Exponential Model 851 | 852 | Ilk, D., Perego, A. D., Rushing, J. A., and Blasingame, T. A. 2008. 853 | Exponential vs. Hyperbolic Decline in Tight Gas Sands – Understanding 854 | the Origin and Implications for Reserve Estimates Using Arps Decline Curves. 855 | Presented at SPE Annual Technical Conference and Exhibition in Denver, 856 | Colorado, USA, 21–24 September. SPE-116731-MS. https://doi.org/10.2118/116731-MS. 857 | 858 | Ilk, D., Rushing, J. A., and Blasingame, T. A. 2009. 859 | Decline Curve Analysis for HP/HT Gas Wells: Theory and Applications. 860 | Presented at SPE Annual Technical Conference and Exhibition in New Orleands, 861 | Louisiana, USA, 4–7 October. SPE-125031-MS. https://doi.org/10.2118/125031-MS. 862 | 863 | Parameters 864 | ---------- 865 | qi: float 866 | The initial production rate in units of ``volume / day``. 867 | 868 | Di: float 869 | The initial decline rate in nominal decline rate defined as ``d[ln q] / dt`` 870 | and has units of ``1 / day``. 871 | 872 | Dterm: float 873 | The terminal decline rate in nominal decline rate, has units of ``1 / day``. 874 | 875 | n: float 876 | The n exponent. 877 | """ 878 | qi: float 879 | Di: float 880 | Dinf: float 881 | n: float 882 | 883 | validate_params: Iterable[bool] = field(default_factory=lambda: [True] * 4) 884 | 885 | def _validate(self) -> None: 886 | if self.Dinf > self.Di: 887 | raise ValueError('Dinf > Di') 888 | 889 | def _qfn(self, t: NDFloat) -> NDFloat: 890 | qi = self.qi 891 | Di = self.Di 892 | Dinf = self.Dinf 893 | n = self.n 894 | return qi * np.exp(-Di * t ** n - Dinf * t) 895 | 896 | def _Nfn(self, t: NDFloat, **kwargs: Any) -> NDFloat: 897 | return self._integrate_with(self._qfn, t, **kwargs) 898 | 899 | def _Dfn(self, t: NDFloat) -> NDFloat: 900 | Di = self.Di 901 | Dinf = self.Dinf 902 | n = self.n 903 | return Dinf + Di * n * t ** (n - 1.0) 904 | 905 | def _Dfn2(self, t: NDFloat) -> NDFloat: 906 | Di = self.Di 907 | Dinf = self.Dinf 908 | n = self.n 909 | return Dinf + Di * n * (n - 1.0) * t ** (n - 2.0) 910 | 911 | def _betafn(self, t: NDFloat) -> NDFloat: 912 | Di = self.Di 913 | Dinf = self.Dinf 914 | n = self.n 915 | return Dinf * t + Di * n * t ** n 916 | 917 | def _bfn(self, t: NDFloat) -> NDFloat: 918 | Di = self.Di 919 | Dinf = self.Dinf 920 | n = self.n 921 | Denom = (Dinf * t + Di * n * t ** n) 922 | return Di * (1.0 - n) * n * t ** n / (Denom * Denom) 923 | 924 | @classmethod 925 | def get_param_descs(cls) -> List[ParamDesc]: 926 | return [ 927 | ParamDesc( 928 | 'qi', 'Initial rate [vol/day]', 929 | 0, None, 930 | lambda r, n: r.uniform(1e-10, 1e6, n)), 931 | ParamDesc( 932 | 'Di', 'Initial decline rate [/day]', 933 | 0.0, None, 934 | lambda r, n: r.uniform(0.0, 1e3, n)), 935 | ParamDesc( 936 | 'Dinf', 'Terminal decline rate [/day]', 937 | 0, None, 938 | lambda r, n: r.uniform(0.0, 1e3, n)), 939 | ParamDesc( 940 | 'n', 'PLE exponent', 941 | 0.0, 1.0, 942 | lambda r, n: r.uniform(1e-6, 1.0, n), 943 | exclude_lower_bound=True, 944 | exclude_upper_bound=True), 945 | ] 946 | 947 | 948 | @dataclass(frozen=True) 949 | class SE(PrimaryPhase): 950 | """ 951 | Stretched Exponential 952 | 953 | Valkó, P. P. Assigning Value to Stimulation in the Barnett Shale: 954 | A Simultaneous Analysis of 7000 Plus Production Histories and Well 955 | Completion Records. 2009. Presented at SPE Hydraulic Fracturing 956 | Technology Conference in College Station, Texas, USA, 19–21 January. 957 | SPE-119369-MS. https://doi.org/10.2118/119369-MS. 958 | 959 | Parameters 960 | ---------- 961 | qi: float 962 | The initial production rate in units of ``volume / day``. 963 | 964 | tau: float 965 | The tau parameter in units of ``day ** n``. Equivalent to: 966 | 967 | .. math:: 968 | 969 | \\tau = D^n 970 | 971 | n: float 972 | The ``n`` exponent. 973 | """ 974 | qi: float 975 | tau: float 976 | n: float 977 | 978 | validate_params: Iterable[bool] = field(default_factory=lambda: [True] * 3) 979 | 980 | def _qfn(self, t: NDFloat) -> NDFloat: 981 | qi = self.qi 982 | tau = self.tau 983 | n = self.n 984 | return qi * np.exp(-(t / tau) ** n) 985 | 986 | def _Nfn(self, t: NDFloat, **kwargs: Any) -> NDFloat: 987 | qi = self.qi 988 | tau = self.tau 989 | n = self.n 990 | return qi * tau / n * gammainc(1.0 / n, (t / tau) ** n) 991 | 992 | def _Dfn(self, t: NDFloat) -> NDFloat: 993 | tau = self.tau 994 | n = self.n 995 | return n * tau ** -n * t ** (n - 1.0) 996 | 997 | def _Dfn2(self, t: NDFloat) -> NDFloat: 998 | tau = self.tau 999 | n = self.n 1000 | return n * (n - 1.0) * tau ** -n * t ** (n - 2.0) 1001 | 1002 | def _betafn(self, t: NDFloat) -> NDFloat: 1003 | tau = self.tau 1004 | n = self.n 1005 | return n * tau ** -n * t ** n 1006 | 1007 | def _bfn(self, t: NDFloat) -> NDFloat: 1008 | tau = self.tau 1009 | n = self.n 1010 | return (1.0 - n) / n * tau ** n * t ** -n 1011 | 1012 | @classmethod 1013 | def get_param_descs(cls) -> List[ParamDesc]: 1014 | return [ 1015 | ParamDesc( 1016 | 'qi', 'Initial rate [vol/day]', 1017 | 0.0, None, 1018 | lambda r, n: r.uniform(1e-10, 1e6, n)), 1019 | ParamDesc( 1020 | 'tau', 'tau', 1021 | 1e-10, 1e4, 1022 | lambda r, n: r.uniform(1e-10, 1e4, n)), 1023 | ParamDesc( 1024 | 'n', 'SE exponent', 1025 | 1e-10, 1.0, 1026 | lambda r, n: r.uniform(1e-10, 1.0, n), 1027 | exclude_upper_bound=True), 1028 | ] 1029 | 1030 | 1031 | @dataclass(frozen=True) 1032 | class Duong(PrimaryPhase): 1033 | """ 1034 | Duong Model 1035 | 1036 | Duong, A. N. 2001. Rate-Decline Analysis for Fracture-Dominated 1037 | Shale Reservoirs. SPE Res Eval & Eng 14 (3): 377–387. SPE-137748-PA. 1038 | https://doi.org/10.2118/137748-PA. 1039 | 1040 | Parameters 1041 | ---------- 1042 | qi: float 1043 | The initial production rate in units of ``volume / day`` *defined at ``t=1 day``*. 1044 | 1045 | a: float 1046 | The ``a`` parameter. Roughly speaking, controls slope of the :func:``q(t)`` function. 1047 | 1048 | m: float 1049 | The ``m`` parameter. Roughly speaking, controls curvature of the:func:``q(t)`` 1050 | function. 1051 | """ 1052 | qi: float 1053 | a: float 1054 | m: float 1055 | 1056 | validate_params: Iterable[bool] = field(default_factory=lambda: [True] * 3) 1057 | 1058 | def _qfn(self, t: NDFloat) -> NDFloat: 1059 | qi = self.qi 1060 | a = self.a 1061 | m = self.m 1062 | return np.where(t == 0.0, 0.0, 1063 | qi * t ** -m * np.exp(a / (1.0 - m) * (t ** (1.0 - m) - 1.0))) 1064 | 1065 | def _Nfn(self, t: NDFloat, **kwargs: Any) -> NDFloat: 1066 | qi = self.qi 1067 | a = self.a 1068 | m = self.m 1069 | return np.where(t == 0.0, 0.0, qi / a * np.exp(a / (1.0 - m) * (t ** (1.0 - m) - 1.0))) 1070 | 1071 | def _Dfn(self, t: NDFloat) -> NDFloat: 1072 | a = self.a 1073 | m = self.m 1074 | # alternative form: D = m * t ** -1.0 - a * t ** -m 1075 | return m / t - a * t ** -m 1076 | 1077 | def _Dfn2(self, t: NDFloat) -> NDFloat: 1078 | a = self.a 1079 | m = self.m 1080 | # alternative form: D = m * t ** -1.0 - a * t ** -m 1081 | return -m / (t * t) + m * a * t ** (-m - 1.0) 1082 | 1083 | def _betafn(self, t: NDFloat) -> NDFloat: 1084 | a = self.a 1085 | m = self.m 1086 | return m - a * t ** (1.0 - m) 1087 | 1088 | def _bfn(self, t: NDFloat) -> NDFloat: 1089 | a = self.a 1090 | m = self.m 1091 | Denom = a * t - m * t ** m 1092 | return np.where( 1093 | Denom == 0.0, 0.0, m * t ** m * (t ** m - a * t) / (Denom * Denom)) 1094 | 1095 | @classmethod 1096 | def get_param_descs(cls) -> List[ParamDesc]: 1097 | return [ 1098 | ParamDesc( 1099 | 'qi', 'Initial rate [vol/day]', 1100 | 0.0, None, 1101 | lambda r, n: r.uniform(1.0, 2e4, n)), 1102 | ParamDesc( 1103 | 'a', 'a', 1104 | 1.0, None, 1105 | lambda r, n: r.uniform(1.0, 10.0, n)), 1106 | ParamDesc( 1107 | 'm', 'm', 1108 | 1.0, None, 1109 | lambda r, n: r.uniform(1.0, 10.0, n), 1110 | exclude_lower_bound=True) 1111 | ] 1112 | -------------------------------------------------------------------------------- /petbox/dca/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petbox-dev/dca/d5499030dc2bb186e966624f7e0d44f989bbe653/petbox/dca/py.typed -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | ignore = 4 | F401, 5 | F841, 6 | E116, 7 | E251, 8 | E261, 9 | E265, 10 | E266, 11 | E302, 12 | E305, 13 | E402, 14 | E722, 15 | W503, 16 | W605 17 | exclude = 18 | .git, 19 | __pycache__, 20 | docs/source/conf.py, 21 | old, 22 | build, 23 | dist 24 | max-complexity = 20 25 | # output-file = src\test\flake8_run.txt 26 | 27 | [mypy] 28 | check_untyped_defs = true 29 | disallow_any_generics = true 30 | disallow_incomplete_defs = true 31 | disallow_subclassing_any = true 32 | disallow_untyped_calls = true 33 | disallow_untyped_decorators = true 34 | disallow_untyped_defs = true 35 | # ignore_missing_imports = true 36 | no_implicit_optional = true 37 | show_error_codes = true 38 | strict_equality = true 39 | warn_redundant_casts = true 40 | # warn_return_any = true 41 | warn_unreachable = true 42 | warn_unused_configs = true 43 | ; warn_unused_ignores = true 44 | 45 | plugins = numpy.typing.mypy_plugin 46 | 47 | [tool:pytest] 48 | addopts = --cov=petbox.dca --cov-report=term-missing --cov-config=.coveragerc --hypothesis-show-statistics -v test 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decline Curve Models 3 | Originally developed for David S. Fulford's thesis research 4 | 5 | Author 6 | ------ 7 | David S. Fulford 8 | Derrick W. Turk 9 | 10 | Notes 11 | ----- 12 | Created on August 5, 2019 13 | """ 14 | 15 | import os 16 | import sys 17 | import re 18 | 19 | from setuptools import setup # type: ignore 20 | 21 | 22 | def get_version() -> str: 23 | """ 24 | Extracts the __version__ from __init__.py 25 | """ 26 | with open('petbox/dca/__init__.py', 'r') as f: 27 | for line in f.readlines(): 28 | if '__version__' in line.strip(): 29 | # parse: __version__ == x.x.x and extract 30 | # only the version number after the = sign 31 | parts = line.strip().split('=') 32 | version = parts[1].strip() 33 | 34 | # we don't want a quoted string literally 35 | return version.replace("'", "").replace('"', '') 36 | 37 | raise ValueError('No version number found') 38 | 39 | 40 | def get_long_description() -> str: 41 | # Fix display issues on PyPI caused by RST markup 42 | with open('README.rst', 'r') as f: 43 | readme = f.read() 44 | 45 | replacements = [ 46 | '.. automodule:: petbox.dca', 47 | ':noindex:', 48 | ] 49 | 50 | subs = [ 51 | r':func:`([a-zA-Z0-9._]+)`', 52 | r':meth:`([a-zA-Z0-9._]+)`', 53 | ] 54 | 55 | def replace(s: str) -> str: 56 | for r in replacements: 57 | s = s.replace(r, '') 58 | return s 59 | 60 | lines = [] 61 | with open('docs/versions.rst', 'r') as f: 62 | iter_f = iter(f) 63 | _ = next(f) 64 | for line in f: 65 | if any(r in line for r in replacements): 66 | continue 67 | lines.append(line) 68 | 69 | version_history = ''.join(lines) 70 | for sub in subs: 71 | version_history = re.sub(sub, r'\1', version_history) 72 | 73 | return readme + '\n\n' + version_history 74 | 75 | 76 | if sys.argv[-1] == 'build': 77 | print('\nBuilding...\n') 78 | os.system('rm -r dist\\') # clean out dist/ 79 | os.system('python setup.py sdist bdist_wheel') 80 | sys.exit() 81 | 82 | setup( 83 | name='petbox-dca', 84 | version=get_version(), 85 | description='Decline Curve Library', 86 | long_description=get_long_description(), 87 | long_description_content_type="text/x-rst", 88 | url='https://github.com/petbox-dev/dca', 89 | author='David S. Fulford', 90 | author_email='petbox-dev@gmail.com', 91 | license='MIT', 92 | install_requires=[ 93 | 'numpy>=1.21.1', 94 | 'scipy>=1.7.1' 95 | ], 96 | zip_safe=False, 97 | packages=['petbox.dca'], 98 | package_data={ 99 | 'petbox.dca': ['py.typed'] 100 | }, 101 | include_package_data=True, 102 | python_requires='>=3.7', 103 | classifiers=[ 104 | 'Development Status :: 5 - Production/Stable', 105 | 'Intended Audience :: Science/Research', 106 | 'Intended Audience :: Education', 107 | 'Intended Audience :: Developers', 108 | 'Natural Language :: English', 109 | 'License :: OSI Approved :: MIT License', 110 | 'Programming Language :: Python :: 3.7', 111 | 'Programming Language :: Python :: 3.8', 112 | 'Programming Language :: Python :: Implementation :: CPython', 113 | 'Topic :: Scientific/Engineering', 114 | 'Topic :: Scientific/Engineering :: Mathematics', 115 | 'Topic :: Software Development :: Libraries', 116 | 'Typing :: Typed' 117 | ], 118 | keywords=[ 119 | 'petbox-dca', 'dca', 'decline curve', 'type curve', 120 | 'production forecast', 'production data analysis' 121 | ], 122 | ) 123 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/petbox-dev/dca/d5499030dc2bb186e966624f7e0d44f989bbe653/test/__init__.py -------------------------------------------------------------------------------- /test/data.py: -------------------------------------------------------------------------------- 1 | """ 2 | Demo Production Data 3 | Eagle Ford Well 4 | 5 | Author 6 | ------ 7 | David S. Fulford 8 | 9 | Notes 10 | ----- 11 | Created on August 5, 2019 12 | """ 13 | 14 | import numpy as np 15 | 16 | time = np.array( 17 | [ 18 | 15.0, 19 | 45.5, 20 | 76.5, 21 | 107.0, 22 | 137.5, 23 | 168.0, 24 | 198.5, 25 | 229.5, 26 | 259.0, 27 | 288.5, 28 | 319.0, 29 | 349.5, 30 | 380.0, 31 | 410.5, 32 | 441.5, 33 | 472.0, 34 | 502.5, 35 | 533.0, 36 | 563.5, 37 | 594.5, 38 | 624.0, 39 | 653.5, 40 | 684.0, 41 | 714.5, 42 | 745.0, 43 | 775.5, 44 | 806.5, 45 | 837.0, 46 | 867.5, 47 | 898.0, 48 | 928.5, 49 | 959.5, 50 | 989.0, 51 | 1018.5, 52 | 1049.0, 53 | 1079.5, 54 | 1110.0, 55 | 1140.5, 56 | 1171.5, 57 | 1202.0, 58 | 1232.5, 59 | ], 60 | dtype="float", 61 | ) 62 | 63 | rate = np.array( 64 | [ 65 | 180.6333333, 66 | 261.6451613, 67 | 100.8387097, 68 | 367.5666667, 69 | 264.6774194, 70 | 211.8333333, 71 | 209.3548387, 72 | 98.64516129, 73 | 139.0, 74 | 145.5483871, 75 | 86.2, 76 | 57.03225806, 77 | 96.6, 78 | 58.48387097, 79 | 109.0322581, 80 | 120.5666667, 81 | 97.5483871, 82 | 79.36666667, 83 | 66.87096774, 84 | 2.35483871, 85 | 26.75, 86 | 58.41935484, 87 | 47.9, 88 | 41.77419355, 89 | 55.73333333, 90 | 29.38709677, 91 | 33.74193548, 92 | 37.6, 93 | 31.25806452, 94 | 28.83333333, 95 | 26.09677419, 96 | 24.09677419, 97 | 17.64285714, 98 | 16.87096774, 99 | 21.53333333, 100 | 25.19354839, 101 | 26.1, 102 | 20.12903226, 103 | 21.87096774, 104 | 21.63333333, 105 | 21.41935484, 106 | ], 107 | dtype="float", 108 | ) 109 | -------------------------------------------------------------------------------- /test/doc_examples.py: -------------------------------------------------------------------------------- 1 | # ======================= 2 | # Detailed Usage Examples 3 | # ======================= 4 | 5 | 6 | # Each model, including the secondary phase models, implements all diagnostic functions. 7 | # The following is a set of examples to highlight functionality. 8 | 9 | from pathlib import Path 10 | 11 | from petbox import dca 12 | from data import rate as data_q, time as data_t 13 | import numpy as np 14 | import matplotlib.pyplot as plt 15 | import matplotlib as mpl 16 | 17 | plt.style.use('seaborn-v0_8-white') 18 | plt.rcParams['font.size'] = 16 19 | 20 | 21 | img_path = Path(__file__).parent.parent / 'docs/img' 22 | 23 | 24 | # Setup time series for Forecasts and calculate cumulative production of data 25 | 26 | # We have this function handy 27 | t = dca.get_time(n=1001) 28 | 29 | # Calculate cumulative volume array of data 30 | data_N = np.cumsum(data_q * np.r_[data_t[0], np.diff(data_t)]) 31 | 32 | # Calculate diagnostic functions D, beta, and b 33 | data_D = -dca.bourdet(data_q, data_t, L=0.35, xlog=False, ylog=True) 34 | data_beta = data_D * data_t 35 | data_b = dca.bourdet(1 / data_D, data_t, L=0.25, xlog=False, ylog=False) 36 | 37 | 38 | # Primary Phase Decline Curve Models 39 | # ================================== 40 | print('Primary Phase Decline Curve Models...') 41 | 42 | # Modified Hyperbolic Model 43 | # ------------------------- 44 | 45 | # Robertson, S. 1988. Generalized Hyperbolic Equation. Available from SPE, Richardson, Texas, USA. 46 | # SPE-18731-MS. 47 | 48 | mh = dca.MH(qi=725, Di=0.85, bi=0.6, Dterm=0.2) 49 | q_mh = mh.rate(t) 50 | N_mh = mh.cum(t) 51 | D_mh = mh.D(t) 52 | b_mh = mh.b(t) 53 | beta_mh = mh.beta(t) 54 | N_mh *= data_N[-1] / mh.cum(data_t[-1]) 55 | 56 | 57 | # Transient Hyperbolic Model 58 | # -------------------------- 59 | 60 | # Fulford, D. S., and Blasingame, T. A. 2013. Evaluation of Time-Rate Performance of Shale Wells 61 | # using the Transient Hyperbolic Relation. Presented at SPE Unconventional Resources Conference 62 | # – Canada in Calgary, Alberta, Canda, 5–7 November. SPE-167242-MS. 63 | # https://doi.org/10.2118/167242-MS. 64 | 65 | thm = dca.THM(qi=750, Di=.8, bi=2, bf=.5, telf=28) 66 | q_trans = thm.transient_rate(t) 67 | N_trans = thm.transient_cum(t) 68 | D_trans = thm.transient_D(t) 69 | b_trans = thm.transient_b(t) 70 | beta_trans = thm.transient_beta(t) 71 | N_trans *= data_N[-1] / thm.transient_cum(data_t[-1]) 72 | 73 | 74 | # Transient Hyperbolic Model Analytic Approximation 75 | # ------------------------------------------------- 76 | 77 | # Fulford, D.S. 2018. A Model-Based Diagnostic Workflow for Time-Rate Performance of 78 | # Unconventional Wells. Presented at Unconventional Resources Conference in Houston, 79 | # Texas, USA, 23–25 July. URTeC-2903036. https://doi.org/10.15530/urtec-2018-2903036. 80 | 81 | q_thm = thm.rate(t) 82 | N_thm = thm.cum(t) 83 | D_thm = thm.D(t) 84 | b_thm = thm.b(t) 85 | beta_thm = thm.beta(t) 86 | N_thm *= data_N[-1] / thm.cum(data_t[-1]) 87 | 88 | 89 | # Power-Law Exponential Model 90 | # --------------------------- 91 | 92 | # Ilk, D., Perego, A. D., Rushing, J. A., and Blasingame, T. A. 2008. Exponential vs. 93 | # Hyperbolic Decline in Tight Gas Sands – Understanding the Origin and Implications 94 | # for Reserve Estimates Using Arps Decline Curves. Presented at SPE Annual Technical 95 | # Conference and Exhibition in Denver, Colorado, USA, 21–24 September. SPE-116731-MS. 96 | # https://doi.org/10.2118/116731-MS. 97 | 98 | # Ilk, D., Rushing, J. A., and Blasingame, T. A. 2009. Decline Curve Analysis for 99 | # HP/HT Gas Wells: Theory and Applications. Presented at SPE Annual Technical 100 | # Conference and Exhibition in New Orleands, Louisiana, USA, 4–7 October. 101 | # SPE-125031-MS. https://doi.org/10.2118/125031-MS. 102 | 103 | ple = dca.PLE(qi=750, Di=.1, Dinf=.00001, n=.5) 104 | q_ple = ple.rate(t) 105 | N_ple = ple.cum(t) 106 | D_ple = ple.D(t) 107 | b_ple = ple.b(t) 108 | beta_ple = ple.beta(t) 109 | N_ple *= data_N[-1] / ple.cum(data_t[-1]) 110 | 111 | 112 | # Stretched Exponential 113 | # --------------------- 114 | 115 | # Valkó, P. P. Assigning Value to Stimulation in the Barnett Shale: A Simultaneous 116 | # Analysis of 7000 Plus Production Histories and Well Completion Records. 2009. 117 | # Presented at SPE Hydraulic Fracturing Technology Conference in College Station, 118 | # Texas, USA, 19–21 January. SPE-119369-MS. https://doi.org/10.2118/119369-MS. 119 | 120 | se = dca.SE(qi=715, tau=90.0, n=.5) 121 | q_se = se.rate(t) 122 | N_se = se.cum(t) 123 | D_se = se.D(t) 124 | b_se = se.b(t) 125 | beta_se = se.beta(t) 126 | N_se *= data_N[-1] / se.cum(data_t[-1]) 127 | 128 | 129 | # Duong Model 130 | # ----------- 131 | 132 | # Duong, A. N. 2001. Rate-Decline Analysis for Fracture-Dominated Shale Reservoirs. 133 | # SPE Res Eval & Eng 14 (3): 377–387. SPE-137748-PA. https://doi.org/10.2118/137748-PA. 134 | 135 | dg = dca.Duong(qi=715, a=2.8, m=1.4) 136 | q_dg = dg.rate(t) 137 | N_dg = dg.cum(t) 138 | D_dg = dg.D(t) 139 | b_dg = dg.b(t) 140 | beta_dg = dg.beta(t) 141 | N_dg *= data_N[-1] / dg.cum(data_t[-1]) 142 | 143 | 144 | # Primary Phase Diagnostic Plots 145 | # ============================== 146 | print('Primary Phase Model Plots...') 147 | 148 | # Rate and Cumulative Production Plots 149 | # ------------------------------------ 150 | 151 | # Rate vs Time 152 | fig = plt.figure(figsize=(15, 7.5)) 153 | ax1 = fig.add_subplot(121) 154 | ax2 = fig.add_subplot(122) 155 | 156 | ax1.plot(data_t, data_q, 'o', mfc='w', label='Data') 157 | ax1.plot(t, q_trans, label='THM Transient') 158 | ax1.plot(t, q_thm, ls='--', label='THM Approx') 159 | ax1.plot(t, q_mh, label='MH') 160 | ax1.plot(t, q_ple, label='PLE') 161 | ax1.plot(t, q_se, label='SE') 162 | ax1.plot(t, q_dg, label='Duong') 163 | 164 | ax1.set(xscale='log', yscale='log', ylabel='Rate, BPD', xlabel='Time, Days') 165 | ax1.set(ylim=(1e0, 1e4), xlim=(1e0, 1e4)) 166 | ax1.set_aspect(1) 167 | ax1.grid() 168 | ax1.legend() 169 | 170 | # Cumulative Volume vs Time 171 | ax2.plot(data_t, data_N, 'o', mfc='w', label='Data') 172 | ax2.plot(t, N_trans, label='THM Transient') 173 | ax2.plot(t, N_thm, ls='--', label='THM Approx') 174 | ax2.plot(t, N_mh, label='MH') 175 | ax2.plot(t, N_ple, label='PLE') 176 | ax2.plot(t, N_se, label='SE') 177 | ax2.plot(t, N_dg, label='Duong') 178 | 179 | ax2.set(xscale='log', yscale='log', ylim=(1e2, 1e6), xlim=(1e0, 1e4)) 180 | ax2.set(ylabel='Cumulative Volume, MBbl', xlabel='Time, Days') 181 | ax2.set_aspect(1) 182 | ax2.grid() 183 | ax2.legend() 184 | 185 | plt.savefig(img_path / 'model.png') 186 | 187 | # Diagnostic Function Plots 188 | # ------------------------- 189 | print('Primary Phase Diagnostic Function Plots...') 190 | 191 | fig = plt.figure(figsize=(15, 15)) 192 | ax1 = fig.add_subplot(221) 193 | ax2 = fig.add_subplot(222) 194 | ax3 = fig.add_subplot(223) 195 | ax4 = fig.add_subplot(224) 196 | 197 | # D-parameter vs Time 198 | ax1.plot(data_t, data_D, 'o', mfc='w', label='Data') 199 | ax1.plot(t, D_trans, label='THM Transient') 200 | ax1.plot(t, D_thm, ls='--', label='THM Approx') 201 | ax1.plot(t, D_mh, label='MH') 202 | ax1.plot(t, D_ple, label='PLE') 203 | ax1.plot(t, D_se, label='SE') 204 | ax1.plot(t, D_dg, label='Duong') 205 | ax1.set(xscale='log', yscale='log', ylim=(1e-4, 1e0)) 206 | ax1.set(ylabel='$D$-parameter, Days$^{-1}$', xlabel='Time, Days') 207 | 208 | # beta-parameter vs Time 209 | ax2.plot(data_t, data_D * data_t, 'o', mfc='w', label='Data') 210 | ax2.plot(t, beta_trans, label='THM Transient') 211 | ax2.plot(t, beta_thm, ls='--', label='THM Approx') 212 | ax2.plot(t, beta_mh, label='MH') 213 | ax2.plot(t, beta_ple, label='PLE') 214 | ax2.plot(t, beta_se, label='SE') 215 | ax2.plot(t, beta_dg, label='Duong') 216 | ax2.set(xscale='log', yscale='log', ylim=(1e-2, 1e2)) 217 | ax2.set(ylabel=r'$\beta$-parameter, Dimensionless', xlabel='Time, Days') 218 | 219 | # b-parameter vs Time 220 | ax3.plot(data_t, data_b, 'o', mfc='w', label='Data') 221 | ax3.plot(t, b_trans, label='THM Transient') 222 | ax3.plot(t, b_thm, ls='--', label='THM Approx') 223 | ax3.plot(t, b_mh, label='MH') 224 | ax3.plot(t, b_ple, label='PLE') 225 | ax3.plot(t, b_se, label='SE') 226 | ax3.plot(t, b_dg, label='Duong') 227 | ax3.set(xscale='log', yscale='linear', ylim=(0., 4.)) 228 | ax3.set(ylabel='$b$-parameter, Dimensionless', xlabel='Time, Days') 229 | 230 | # q/N vs Time 231 | ax4.plot(data_t, data_q / data_N, 'o', mfc='w', label='Data') 232 | ax4.plot(t, q_trans / N_trans, label='THM Transient') 233 | ax4.plot(t, q_thm / N_thm, ls='--', label='THM Approx') 234 | ax4.plot(t, q_mh / N_mh, label='MH') 235 | ax4.plot(t, q_ple / N_ple, label='PLE') 236 | ax4.plot(t, q_se / N_se, label='SE') 237 | ax4.plot(t, q_dg / N_dg, label='Duong') 238 | ax4.set(xscale='log', yscale='log', ylim=(1e-7, 1e0), xlim=(1e0, 1e7)) 239 | ax4.set(ylabel='$q_o / N_p$, Days$^{-1}$', xlabel='Time, Days') 240 | 241 | for ax in [ax1, ax2, ax3, ax4]: 242 | if ax != ax4: 243 | ax.set(xlim=(1e0, 1e4)) 244 | if ax != ax3: 245 | ax.set_aspect(1) 246 | ax.grid() 247 | ax.legend() 248 | 249 | 250 | plt.savefig(img_path / 'diagnostics.png') 251 | 252 | 253 | # Secondary Phase Decline Curve Models 254 | # ==================================== 255 | print('Secondary Phase Decline Curve Models...') 256 | 257 | # Power-Law GOR/CGR Model 258 | # ----------------------- 259 | 260 | # Fulford, D.S. 2018. A Model-Based Diagnostic Workflow for Time-Rate Performance 261 | # of Unconventional Wells. Presented at Unconventional Resources Conference in 262 | # Houston, Texas, USA, 23–25 July. URTeC-2903036. 263 | # https://doi.org/10.15530/urtec-2018-2903036. 264 | 265 | thm = dca.THM(qi=750, Di=.8, bi=2, bf=.5, telf=28) 266 | thm.add_secondary(dca.PLYield(c=1000, m0=-0.1, m=0.8, t0=2 * 365.25 / 12, max=10_000)) 267 | 268 | 269 | # Secondary Phase Diagnostic Plots 270 | # ================================ 271 | print('Secondary Phase Model Plots...') 272 | 273 | # Rate and Cumluative Production Plots 274 | # ------------------------------------ 275 | 276 | # Numeric calculation provided to verify analytic relationships 277 | 278 | fig = plt.figure(figsize=(15, 15)) 279 | ax1 = fig.add_subplot(221) 280 | ax2 = fig.add_subplot(222) 281 | ax3 = fig.add_subplot(223) 282 | ax4 = fig.add_subplot(224) 283 | 284 | 285 | # Rate vs Time 286 | q = thm.rate(t) 287 | g = thm.secondary.rate(t) / 1000.0 288 | y = thm.secondary.gor(t) 289 | 290 | ax1.plot(t, q, c='C2', label='Oil') 291 | ax1.plot(t, g, c='C3', label='Gas') 292 | ax1.plot(t, y, c='C1', label='GOR') 293 | ax1.set(xscale='log', yscale='log', xlim=(1e0, 1e5), ylim=(1e0, 1e5)) 294 | ax1.set(ylabel='Rate or GOR, BPD, MCFD, or scf/Bbl', xlabel='Time, Days') 295 | 296 | 297 | # Cumulative Volume vs Time 298 | q_N = thm.cum(t) 299 | g_N = thm.secondary.cum(t) / 1000.0 300 | _g_N = np.cumsum(g * np.diff(t, prepend=0)) 301 | 302 | ax2.plot(t, q_N, c='C2', label='Oil') 303 | ax2.plot(t, g_N, c='C3', label='Gas') 304 | ax2.plot(t, _g_N, c='k', ls=':', label='Gas (numeric)') 305 | ax2.plot(t, y, c='C1', label='GOR') 306 | ax2.set(xscale='log', yscale='log', xlim=(1e0, 1e5), ylim=(1e2, 1e7)) 307 | ax2.set(ylabel='Rate, Dimensionless', xlabel='Time, Days') 308 | ax2.set(ylabel='Cumulative Volume or GOR, MBbl, MMcf, or scf/Bbl', xlabel='Time, Days') 309 | 310 | 311 | # Time vs Monthly Volume 312 | q_MN = thm.monthly_vol_equiv(t) 313 | g_MN = thm.secondary.monthly_vol_equiv(t) / 1000.0 314 | _g_MN = np.diff(np.cumsum(g * np.diff(t, prepend=0)), prepend=0) \ 315 | / np.diff(t, prepend=0) * dca.DAYS_PER_MONTH 316 | 317 | ax3.plot(t, q_MN, c='C2', label='Oil') 318 | ax3.plot(t, g_MN, c='C3', label='Gas') 319 | ax3.plot(t, _g_MN, c='k', ls=':', label='Gas (numeric)') 320 | ax3.plot(t, y, c='C1', label='GOR') 321 | ax3.set(xscale='log', yscale='log', xlim=(1e0, 1e5), ylim=(1e0, 1e5)) 322 | ax3.set(ylabel='Monthly Volume or GOR, MBbl, MMcf, or scf/Bbl', xlabel='Time, Days') 323 | 324 | 325 | # Time vs Interval Volume 326 | q_IN = thm.interval_vol(t, t0=0.0) 327 | g_IN = thm.secondary.interval_vol(t, t0=0.0) / 1000.0 328 | _g_IN = np.diff(np.cumsum(g * np.diff(t, prepend=0)), prepend=0) 329 | 330 | ax4.plot(t, q_IN, c='C2', label='Oil') 331 | ax4.plot(t, g_IN, c='C3', label='Gas') 332 | ax4.plot(t, _g_IN, c='k', ls=':', label='Gas (numeric)') 333 | ax4.plot(t, y, c='C1', label='GOR') 334 | ax4.set(xscale='log', yscale='log', xlim=(1e0, 1e5), ylim=(1e0, 1e5)) 335 | ax4.set(ylabel='$\Delta$Volume or GOR, MBbl, MMcf, or scf/Bbl', xlabel='Time, Days') 336 | 337 | for ax in [ax1, ax2, ax3, ax4]: 338 | ax.set_aspect(1) 339 | ax.grid() 340 | ax.legend() 341 | 342 | plt.savefig(img_path / 'secondary_model.png') 343 | 344 | 345 | # Diagnostic Function Plots 346 | # ------------------------ 347 | print('Secondary Model Diagnostic Function Plots...') 348 | 349 | fig = plt.figure(figsize=(15, 15)) 350 | ax1 = fig.add_subplot(221) 351 | ax2 = fig.add_subplot(222) 352 | ax3 = fig.add_subplot(223) 353 | ax4 = fig.add_subplot(224) 354 | 355 | # D-parameter vs Time 356 | q_D = thm.D(t) 357 | g_D = thm.secondary.D(t) 358 | _g_D = -np.gradient(np.log(thm.secondary.rate(t) / 1000.0), t) 359 | 360 | ax1.plot(t, q_D, c='C2', label='Oil') 361 | ax1.plot(t, g_D, c='C3', label='Gas') 362 | ax1.plot(t, _g_D, c='k', ls=':', label='Gas (numeric)') 363 | ax1.set(xscale='log', yscale='log', xlim=(1e0, 1e4), ylim=(1e-4, 1e0)) 364 | ax1.set(ylabel='$D$-parameter, Days$^{-1}$', xlabel='Time, Days') 365 | 366 | # beta-parameter vs Time 367 | q_beta = thm.beta(t) 368 | g_beta = thm.secondary.beta(t) 369 | _g_beta = _g_D * t 370 | 371 | ax2.plot(t, q_beta, c='C2', label='Oil') 372 | ax2.plot(t, g_beta, c='C3', label='Gas') 373 | ax2.plot(t, _g_beta, c='k', ls=':', label='Gas (numeric)') 374 | ax2.set(xscale='log', yscale='log', xlim=(1e0, 1e4), ylim=(1e-2, 1e2)) 375 | ax2.set(ylabel=r'$\beta$-parameter, Dimensionless', xlabel='Time, Days') 376 | 377 | # b-parameter vs Time 378 | q_b = thm.b(t) 379 | g_b = thm.secondary.b(t) 380 | _g_b = np.gradient(1.0 / _g_D, t) 381 | 382 | ax3.plot(t, q_b, c='C2', label='Oil') 383 | ax3.plot(t, g_b, c='C3', label='Gas') 384 | ax3.plot(t, _g_b, c='k', ls=':', label='Gas (numeric)') 385 | ax3.set(xscale='log', yscale='linear', xlim=(1e0, 1e4), ylim=(-2, 4)) 386 | ax3.set(ylabel='$b$-parameter, Dimensionless', xlabel='Time, Days') 387 | 388 | # q/N vs Time 389 | q_Ng = thm.rate(t) / thm.cum(t) 390 | g_Ng = thm.secondary.rate(t) / thm.secondary.cum(t) 391 | _g_Ng = thm.secondary.rate(t) / np.cumsum(thm.secondary.rate(t) * np.diff(t, prepend=0)) 392 | 393 | ax4.plot(t, q_Ng, c='C2', label='Oil') 394 | ax4.plot(t, g_Ng, c='C3', ls='--', label='Gas') 395 | ax4.plot(t, _g_Ng, c='k', ls=':', label='Gas (numeric)') 396 | ax4.set(xscale='log', yscale='log', ylim=(1e-7, 1e0), xlim=(1e0, 1e7)) 397 | ax4.set(ylabel='$q_o / N_p$, Days$^{-1}$', xlabel='Time, Days') 398 | 399 | for ax in [ax1, ax2, ax3, ax4]: 400 | if ax != ax3: 401 | ax.set_aspect(1) 402 | ax.grid() 403 | ax.legend() 404 | 405 | plt.savefig(img_path / 'sec_diagnostic_funs.png') 406 | 407 | 408 | # Additional Diagnostic Plots 409 | # --------------------------- 410 | print('Additional Diagnostic Plots...') 411 | 412 | # Numeric calculation provided to verify analytic relationships 413 | 414 | 415 | fig = plt.figure(figsize=(15, 15)) 416 | ax1 = fig.add_subplot(221) 417 | ax2 = fig.add_subplot(222) 418 | ax3 = fig.add_subplot(223) 419 | 420 | # D-parameter vs Time 421 | q_D = thm.D(t) 422 | g_D = thm.secondary.D(t) 423 | _g_D = -np.gradient(np.log(thm.secondary.rate(t)), t) 424 | 425 | ax1.plot(t, q_D, c='C2', label='Oil') 426 | ax1.plot(t, g_D, c='C3', label='Gas') 427 | ax1.plot(t, _g_D, c='k', ls=':', label='Gas(numeric)') 428 | ax1.set(xscale='log', yscale='linear', xlim=(1e0, 1e5), ylim=(None, None)) 429 | ax1.set(ylabel='$D$-parameter, 1 / Days', xlabel='Time, Days') 430 | 431 | # Secant Effective Decline vs Time 432 | secant_from_nominal = dca.MultisegmentHyperbolic.secant_from_nominal 433 | dpy = dca.DAYS_PER_YEAR 434 | 435 | q_Dn = [secant_from_nominal(d * dpy, b) for d, b in zip(q_D, thm.b(t))] 436 | g_Dn = [secant_from_nominal(d * dpy, b) for d, b in zip(g_D, thm.secondary.b(t))] 437 | _g_Dn = [secant_from_nominal(d * dpy, b) for d, b in zip(_g_D, np.gradient(1 / _g_D, t))] 438 | 439 | ax2.plot(t, q_Dn, c='C2', label='Oil') 440 | ax2.plot(t, g_Dn, c='C3', label='Gas') 441 | ax2.plot(t, _g_Dn, c='k', ls=':', label='Gas (numeric)') 442 | ax2.set(xscale='log', yscale='linear', xlim=(1e0, 1e5), ylim=(-.5, 1.025)) 443 | ax2.yaxis.set_major_formatter(mpl.ticker.PercentFormatter(xmax=1)) 444 | ax2.set(ylabel='Secant Effective Decline, % / Year', xlabel='Time$ Days') 445 | 446 | # Tangent Effective Decline vs Time 447 | ax3.plot(t, 1 - np.exp(-q_D * dpy), c='C2', label='Oil') 448 | ax3.plot(t, 1 - np.exp(-g_D * dpy), c='C3', label='Gas') 449 | ax3.plot(t, 1 - np.exp(-_g_D * dpy), c='k', ls=':', label='Gas (numeric)') 450 | ax3.set(xscale='log', yscale='linear', xlim=(1e0, 1e5), ylim=(-1.025, 1.025)) 451 | ax3.yaxis.set_major_formatter(mpl.ticker.PercentFormatter(xmax=1)) 452 | ax3.set(ylabel='Tangent Effective Decline, % / Day', xlabel='Time, Days') 453 | 454 | for ax in [ax1, ax2, ax3]: 455 | ax.grid() 456 | ax.legend() 457 | 458 | plt.savefig(img_path / 'sec_decline_diagnostics.png') 459 | -------------------------------------------------------------------------------- /test/test.bat: -------------------------------------------------------------------------------- 1 | :: Run tests and generate report 2 | 3 | flake8 %~dp0..\petbox\dca 4 | mypy %~dp0..\petbox\dca 5 | 6 | pytest 7 | -------------------------------------------------------------------------------- /test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 4 | 5 | 6 | echo flake8 ../petbox/dca 7 | flake8 $DIR/../petbox/dca 8 | echo 9 | 10 | echo mypy ../petbox/dca 11 | mypy $DIR/../petbox/dca 12 | echo 13 | 14 | echo pytest --cov=petbox.dca --cov-report=term-missing --hypothesis-show-statistics -v . 15 | pytest --cov=petbox.dca --cov-report=term-missing --hypothesis-show-statistics -v . 16 | -------------------------------------------------------------------------------- /test/test_dca.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decline Curve Models 3 | Unit Testing 4 | Copyright © 2020 David S. Fulford 5 | 6 | Author 7 | ------ 8 | David S. Fulford 9 | Derrick W. Turk 10 | 11 | Notes 12 | ----- 13 | Created on August 5, 2019 14 | """ 15 | import sys 16 | import warnings 17 | from datetime import timedelta 18 | import pytest # type: ignore 19 | import hypothesis 20 | from hypothesis import assume, given, settings, note, strategies as st 21 | from typing import Any, Type, TypeVar, Union 22 | 23 | from math import isnan 24 | import numpy as np 25 | 26 | from petbox import dca 27 | 28 | # local import 29 | from .data import rate as q_data, time as t_data # noqa 30 | 31 | 32 | def signif(x: np.ndarray, p: int) -> np.ndarray: 33 | x = np.asarray(x) 34 | x_positive = np.where(np.isfinite(x) & (x != 0), np.abs(x), 10**(p - 1)) 35 | mags = 10 ** (p - 1 - np.floor(np.log10(x_positive))) 36 | return np.round(x * mags) / mags 37 | 38 | 39 | def is_float_array_like(arr: Any, like: np.ndarray) -> bool: 40 | return ( 41 | isinstance(arr, np.ndarray) 42 | and arr.dtype == np.dtype(np.float64) 43 | and arr.shape == like.shape 44 | ) 45 | 46 | 47 | def is_monotonic_nonincreasing(arr: np.ndarray) -> bool: 48 | # a = np.diff(signif(arr, 6)) 49 | a = np.diff(arr, 6) 50 | return np.all(a <= 0.0) 51 | 52 | 53 | def is_monotonic_increasing(arr: np.ndarray) -> bool: 54 | # a = np.diff(signif(arr, 6)) 55 | a = np.diff(arr, 6) 56 | return np.all(a > 0.0) 57 | 58 | 59 | def is_monotonic_nondecreasing(arr: np.ndarray) -> bool: 60 | # a = np.diff(signif(arr, 6)) 61 | a = np.diff(arr, 6) 62 | return np.all(a >= 0.0) 63 | 64 | 65 | T = TypeVar('T', bound=dca.DeclineCurve) 66 | def model_floats(model_cls: Type[T], param: str) -> st.SearchStrategy[float]: 67 | p = model_cls.get_param_desc(param) 68 | return st.floats(p.lower_bound, p.upper_bound, # type: ignore 69 | exclude_min=p.exclude_lower_bound, exclude_max=p.exclude_upper_bound) 70 | 71 | 72 | def check_model(model: dca.DeclineCurve, qi: float) -> bool: 73 | t = dca.get_time() 74 | 75 | with warnings.catch_warnings(record=True) as w: 76 | if isinstance(model, dca.Duong): 77 | t0 = 1e-3 78 | assert np.isclose(model.rate(np.array(1.0)), qi, atol=1e-10) 79 | assert np.isclose(model.cum(np.array(1.0)), qi / model.a, atol=1e-10) 80 | else: 81 | t0 = 0.0 82 | assert np.isclose(model.rate(np.array(0.0)), qi, atol=1e-10) 83 | assert np.isclose(model.cum(np.array(0.0)), 0.0, atol=1e-10) 84 | 85 | rate = model.rate(t) 86 | assert is_float_array_like(rate, t) 87 | # assert is_monotonic_nonincreasing(rate) 88 | assert np.all(np.isfinite(rate)) 89 | 90 | cum = model.cum(t) 91 | assert is_float_array_like(cum, t) 92 | # if not isinstance(model, dca.PLE): 93 | # exclude PLE as it is numerically integrated 94 | # assert is_monotonic_nondecreasing(cum) 95 | assert np.all(np.isfinite(cum)) 96 | 97 | mvolume = model.monthly_vol(t) 98 | mavg_rate = np.gradient(mvolume, t) 99 | # assert is_float_array_like(mvolume, t) 100 | # assert is_monotonic_nonincreasing(mavg_rate) 101 | assert np.all(np.isfinite(mvolume)) 102 | assert np.all(np.isfinite(mavg_rate)) 103 | 104 | ivolume = model.interval_vol(t) 105 | iavg_rate = np.gradient(ivolume, t) 106 | # assert is_float_array_like(ivolume, t) 107 | # assert is_monotonic_nonincreasing(iavg_rate) 108 | assert np.all(np.isfinite(ivolume)) 109 | assert np.all(np.isfinite(iavg_rate)) 110 | 111 | evolume = model.monthly_vol_equiv(t) 112 | mavg_rate = np.gradient(evolume, t) 113 | # assert is_float_array_like(evolume, t) 114 | # assert is_monotonic_nonincreasing(mavg_rate) 115 | assert np.all(np.isfinite(evolume)) 116 | assert np.all(np.isfinite(mavg_rate)) 117 | 118 | D = model.D(t) 119 | assert is_float_array_like(D, t) 120 | # assert is_monotonic_nonincreasing(D) 121 | assert np.all(np.isfinite(D)) 122 | 123 | D2 = model._Dfn2(t) 124 | assert is_float_array_like(D2, t) 125 | # assert is_monotonic_nonincreasing(D2) 126 | assert np.all(np.isfinite(D2)) 127 | 128 | beta = model.beta(t) 129 | assert is_float_array_like(beta, t) 130 | # TODO: what are the invariants for beta? 131 | D_inferred = beta / t 132 | # assert is_monotonic_nonincreasing(D_inferred) 133 | assert np.all(np.isfinite(beta)) 134 | 135 | b = model.b(t) 136 | assert is_float_array_like(b, t) 137 | assert np.all(np.isfinite(b)) 138 | 139 | return True 140 | 141 | 142 | def check_yield_model(model: Union[dca.SecondaryPhase, dca.WaterPhase], 143 | phase: str, qi: float) -> bool: 144 | t = dca.get_time() 145 | 146 | with warnings.catch_warnings(record=True) as w: 147 | t0 = 0.0 148 | assert np.isclose(model.cum(np.array(0.0)), 0.0, atol=1e-10) 149 | 150 | if phase == 'secondary' and isinstance(model, dca.SecondaryPhase): 151 | gor = model.gor(t) 152 | assert is_float_array_like(gor, t) 153 | assert np.all(np.isfinite(gor)) 154 | 155 | cgr = model.cgr(t) 156 | assert is_float_array_like(cgr, t) 157 | assert np.all(np.isfinite(cgr)) 158 | 159 | with pytest.raises(ValueError) as e: 160 | wor = model.wor(t) # type: ignore 161 | assert is_float_array_like(wor, t) 162 | assert np.all(np.isfinite(wor)) 163 | 164 | wgr = model.wgr(t) # type: ignore 165 | assert is_float_array_like(wgr, t) 166 | assert np.all(np.isfinite(wgr)) 167 | 168 | elif phase == 'water' and isinstance(model, dca.WaterPhase): 169 | with pytest.raises(ValueError) as e: 170 | gor = model.gor(t) # type: ignore 171 | assert is_float_array_like(gor, t) 172 | assert np.all(np.isfinite(gor)) 173 | 174 | cgr = model.cgr(t) # type: ignore 175 | assert is_float_array_like(cgr, t) 176 | assert np.all(np.isfinite(cgr)) 177 | 178 | wor = model.wor(t) 179 | assert is_float_array_like(wor, t) 180 | assert np.all(np.isfinite(wor)) 181 | 182 | wgr = model.wgr(t) 183 | assert is_float_array_like(wgr, t) 184 | assert np.all(np.isfinite(wgr)) 185 | 186 | rate = model.rate(t) 187 | assert is_float_array_like(rate, t) 188 | # assert is_monotonic_nonincreasing(rate) 189 | assert np.all(np.isfinite(rate)) 190 | 191 | cum = model.cum(t) 192 | assert is_float_array_like(cum, t) 193 | # if not isinstance(model, dca.PLE): 194 | # exclude PLE as it is numerically integrated 195 | # assert is_monotonic_nondecreasing(cum) 196 | assert np.all(np.isfinite(cum)) 197 | 198 | mvolume = model.monthly_vol(t) 199 | mavg_rate = np.gradient(mvolume, t) 200 | # assert is_float_array_like(mvolume, t) 201 | # assert is_monotonic_nonincreasing(mavg_rate) 202 | assert np.all(np.isfinite(mvolume)) 203 | assert np.all(np.isfinite(mavg_rate)) 204 | 205 | ivolume = model.interval_vol(t, t0=t0) 206 | iavg_rate = np.gradient(ivolume, t) 207 | # assert is_float_array_like(ivolume, t) 208 | # assert is_monotonic_nonincreasing(iavg_rate) 209 | assert np.all(np.isfinite(ivolume)) 210 | assert np.all(np.isfinite(iavg_rate)) 211 | 212 | evolume = model.monthly_vol_equiv(t) 213 | mavg_rate = np.gradient(evolume, t) 214 | # assert is_float_array_like(evolume, t) 215 | # assert is_monotonic_nonincreasing(mavg_rate) 216 | assert np.all(np.isfinite(evolume)) 217 | assert np.all(np.isfinite(mavg_rate)) 218 | 219 | D = model.D(t) 220 | assert is_float_array_like(D, t) 221 | # assert is_monotonic_nonincreasing(D) 222 | # assert np.all(np.isfinite(D)) 223 | 224 | D2 = model._Dfn2(t) 225 | assert is_float_array_like(D2, t) 226 | # assert is_monotonic_nonincreasing(D2) 227 | # assert np.all(np.isfinite(D2)) 228 | 229 | beta = model.beta(t) 230 | assert is_float_array_like(beta, t) 231 | # TODO: what are the invariants for beta? 232 | # D_inferred = beta / t 233 | # assert is_monotonic_nonincreasing(D_inferred) 234 | # assert np.all(np.isfinite(beta)) 235 | 236 | b = model.b(t) 237 | assert is_float_array_like(b, t) 238 | assert np.all(np.isfinite(b)) 239 | 240 | # der = model._derfn(np.array([0.0])) 241 | # NN = model._NNfn(np.array([0.0])) 242 | 243 | return True 244 | 245 | 246 | def check_transient_model(model: dca.THM) -> bool: 247 | t = dca.get_time() 248 | 249 | with warnings.catch_warnings(record=True) as w: 250 | t_D = model.transient_D(t) 251 | assert is_float_array_like(t_D, t) 252 | # assert is_monotonic_nonincreasing(t_D) 253 | assert np.all(np.isfinite(t_D)) 254 | 255 | t_beta = model.transient_beta(t) 256 | assert is_float_array_like(t_beta, t) 257 | # assert is_monotonic_nonincreasing(t_beta) 258 | assert np.all(np.isfinite(t_beta)) 259 | 260 | t_b = model.transient_b(t) 261 | assert is_float_array_like(t_b, t) 262 | # assert is_monotonic_nonincreasing(t_b) 263 | assert np.all(np.isfinite(t_b)) 264 | 265 | return True 266 | 267 | 268 | def check_transient_model_rate_cum(model: dca.THM) -> bool: 269 | # these are computationally expensive, so check separately 270 | t = dca.get_time() 271 | 272 | with warnings.catch_warnings(record=True) as w: 273 | t_N = model.transient_cum(t) 274 | assert is_float_array_like(t_N, t) 275 | # assert is_monotonic_nondecreasing(t_N) 276 | assert np.all(np.isfinite(t_N)) 277 | 278 | t_q = model.transient_rate(t) 279 | assert is_float_array_like(t_q, t) 280 | # assert is_monotonic_nonincreasing(t_q) 281 | assert np.all(np.isfinite(t_q)) 282 | 283 | return True 284 | 285 | 286 | def test_time_arrays() -> None: 287 | t = dca.get_time() 288 | assert is_monotonic_increasing(t) 289 | 290 | int_t = dca.get_time_monthly_vol() 291 | 292 | thm = dca.THM(1000, 0.5, 2.0, 1.0, 30.0) 293 | 294 | 295 | def test_nulls() -> None: 296 | t = dca.get_time() 297 | primary = dca.NullPrimaryPhase() 298 | assert np.allclose(primary.rate(t), 0.0) 299 | assert np.allclose(primary.cum(t), 0.0) 300 | assert np.allclose(primary.D(t), 0.0) 301 | assert np.allclose(primary.beta(t), 0.0) 302 | assert np.allclose(primary.b(t), 0.0) 303 | assert np.allclose(primary._Dfn2(t), 0.0) 304 | 305 | secondary = dca.NullAssociatedPhase() 306 | assert np.allclose(secondary.gor(t), 0.0) 307 | assert np.allclose(secondary.cgr(t), 0.0) 308 | assert np.allclose(secondary.wor(t), 0.0) 309 | assert np.allclose(secondary.wgr(t), 0.0) 310 | assert np.allclose(secondary.rate(t), 0.0) 311 | assert np.allclose(secondary.cum(t), 0.0) 312 | assert np.allclose(secondary.D(t), 0.0) 313 | assert np.allclose(secondary.beta(t), 0.0) 314 | assert np.allclose(secondary.b(t), 0.0) 315 | assert np.allclose(secondary._Dfn2(t), 0.0) 316 | 317 | 318 | def test_associated() -> None: 319 | with pytest.raises(TypeError) as e: 320 | sec = dca.AssociatedPhase() # type: ignore 321 | 322 | with pytest.raises(TypeError) as e: 323 | sec = dca.SecondaryPhase() # type: ignore 324 | 325 | with pytest.raises(TypeError) as e: 326 | wtr = dca.WaterPhase() # type: ignore 327 | 328 | with pytest.raises(TypeError) as e: 329 | bth = dca.BothAssociatedPhase() # type: ignore 330 | 331 | 332 | # TODO: use bounds, after we use testing to set them 333 | @given( 334 | qi=st.floats(0.0, 1e6), 335 | Di=st.floats(1e-10, 1e10), 336 | Dinf=st.floats(1e-10, 1e10), 337 | n=st.floats(1e-10, 1.0, exclude_max=True) 338 | ) 339 | def test_PLE(qi: float, Di: float, Dinf: float, n: float) -> None: 340 | assume(Dinf <= Di) 341 | ple = dca.PLE.from_params((qi, Di, Dinf, n)) 342 | ple = dca.PLE(qi, Di, Dinf, n) 343 | check_model(ple, qi) 344 | 345 | 346 | @given( 347 | qi=st.floats(0.0, 1e6), 348 | tau=st.floats(1e-10, 1e4), 349 | n=st.floats(1e-10, 1.0, exclude_max=True) 350 | ) 351 | def test_SE(qi: float, tau: float, n: float) -> None: 352 | se = dca.SE.from_params((qi, tau, n)) 353 | se = dca.SE(qi, tau, n) 354 | check_model(se, qi) 355 | 356 | 357 | @given( 358 | qi=st.floats(0.0, 1e6), 359 | a=st.floats(1.0, 10.0), 360 | m=st.floats(1.0, 10.0, exclude_min=True) 361 | ) 362 | def test_Duong(qi: float, a: float, m: float) -> None: 363 | duong = dca.Duong.from_params((qi, a, m)) 364 | duong = dca.Duong(qi, a, m) 365 | check_model(duong, qi) 366 | 367 | 368 | @given( 369 | qi=st.floats(0.0, 1e6), 370 | Di=st.floats(0.0, 1.0, exclude_max=True), 371 | bf=st.floats(0.0, 2.0), 372 | telf=st.floats(0.0, 1e6) 373 | ) 374 | def test_THM(qi: float, Di: float, bf: float, telf: float) -> None: 375 | thm = dca.THM.from_params((qi, Di, 2.0, bf, telf, 0.0, 0.0)) 376 | thm = dca.THM(qi, Di, 2.0, bf, telf, 0.0, 0.0) 377 | check_model(thm, qi) 378 | check_transient_model(thm) 379 | 380 | thm = dca.THM(qi, Di, 2.0, 0.0, telf) 381 | check_model(thm, qi) 382 | check_transient_model(thm) 383 | 384 | 385 | @given( 386 | qi=st.floats(0.0, 1e6), 387 | Di=st.floats(0.0, 1.0, exclude_max=True), 388 | bf=st.floats(0.0, 2.0), 389 | telf=st.floats(0, 1e4), 390 | bterm=st.floats(0.0, 1.0), 391 | tterm=st.floats(1e-3, 30.0), 392 | ) 393 | def test_THM_terminal(qi: float, Di: float, bf: float, telf: float, 394 | bterm: float, tterm: float) -> None: 395 | assume(tterm * dca.DAYS_PER_YEAR > telf) 396 | assume(bterm < bf) 397 | thm = dca.THM(qi, Di, 2.0, bf, telf, bterm, tterm) 398 | check_transient_model(thm) 399 | check_model(thm, qi) 400 | 401 | 402 | @given( 403 | qi=st.floats(0.0, 1e6), 404 | bf=st.floats(0.0, 2.0), 405 | telf=st.floats(1e-10, 1e4), 406 | bterm=st.floats(0.0, 1.0), 407 | tterm=st.floats(5.0, 30.0), 408 | ) 409 | def test_THM_zero_Di(qi: float, bf: float, telf: float, bterm: float, tterm: float) -> None: 410 | assume(tterm * dca.DAYS_PER_YEAR > telf) 411 | assume(bterm < bf) 412 | thm = dca.THM(qi, 0.0, 2.0, bf, telf, bterm, tterm) 413 | check_model(thm, qi) 414 | check_transient_model(thm) 415 | 416 | 417 | @given( 418 | qi=st.floats(0.0, 1e6), 419 | Di=st.floats(0.0, 1.0, exclude_max=True), 420 | telf=st.floats(1e-10, 1e4), 421 | bterm=st.floats(0.0, 0.5), 422 | tterm=st.floats(5, 30), 423 | ) 424 | def test_THM_harmonic(qi: float, Di: float, telf: float, bterm: float, tterm: float) -> None: 425 | assume(tterm * dca.DAYS_PER_YEAR > telf) 426 | thm = dca.THM(qi, Di, 2.0, 1.0, telf, bterm, tterm) 427 | check_model(thm, qi) 428 | check_transient_model(thm) 429 | 430 | 431 | def test_THM_transient_extra() -> None: 432 | thm = dca.THM(1000.0, 0.80, 2.0, 0.8, 30.0, 0.3, 5.0) 433 | check_transient_model(thm) 434 | check_transient_model_rate_cum(thm) 435 | 436 | thm = dca.THM(1000.0, 0.80, 2.0, 0.8, 30.0, 0.06, 0.0) 437 | check_transient_model(thm) 438 | check_transient_model_rate_cum(thm) 439 | 440 | thm = dca.THM(1000.0, 1e-10, 2.0, 0.0, 30.0, 0.06, 0.0) 441 | check_transient_model(thm) 442 | check_transient_model_rate_cum(thm) 443 | 444 | thm = dca.THM(1000.0, 1e-10, 2.0, 0.8, 0.0, 0.5, 0.06) 445 | check_transient_model(thm) 446 | check_transient_model_rate_cum(thm) 447 | 448 | with pytest.raises(ValueError) as e: 449 | thm = dca.THM(1000.0, 1e-10, 2.0, 0.3, 30.0, 0.5, 10.0) 450 | 451 | 452 | @given( 453 | qi=st.floats(0.0, 1e6), 454 | Di=st.floats(0.0, 1.0, exclude_max=True), 455 | bf=st.floats(0.0, 2.0), 456 | telf=st.floats(0.0, 1e6), 457 | bterm=st.floats(1e-3, 0.3) 458 | ) 459 | @settings(suppress_health_check=[hypothesis.HealthCheck.filter_too_much]) # type: ignore 460 | def test_THM_terminal_exp(qi: float, Di: float, bf: float, telf: float, bterm: float) -> None: 461 | assume(dca.THM.nominal_from_secant(Di, 2.0) >= dca.THM.nominal_from_tangent(bterm)) 462 | thm = dca.THM(qi, Di, 2.0, bf, telf, bterm, 0.0) 463 | check_model(thm, qi) 464 | check_transient_model(thm) 465 | 466 | 467 | @given( 468 | qi=st.floats(0.0, 1e6), 469 | Di=st.floats(0.0, 1.0, exclude_max=True), 470 | bi=st.floats(0.0, 2.0), 471 | Dterm=st.floats(0.0, 1.0, exclude_max=True), 472 | ) 473 | def test_MH(qi: float, Di: float, bi: float, Dterm: float) -> None: 474 | assume(dca.MH.nominal_from_secant(Di, bi) >= dca.MH.nominal_from_tangent(Dterm)) 475 | mh = dca.MH(qi, Di, bi, Dterm) 476 | check_model(mh, qi) 477 | 478 | mh = dca.MH(qi, 0.0, bi, 0.0) 479 | check_model(mh, qi) 480 | 481 | 482 | @given( 483 | qi=st.floats(0.0, 1e6), 484 | Di=st.floats(0.0, 1.0, exclude_max=True), 485 | Dterm=st.floats(0.0, 1.0, exclude_max=True), 486 | ) 487 | def test_MH_harmonic(qi: float, Di: float, Dterm: float) -> None: 488 | assume(dca.MH.nominal_from_secant(Di, 1.0) >= dca.MH.nominal_from_tangent(Dterm)) 489 | mh = dca.MH(qi, Di, 1.0, Dterm) 490 | check_model(mh, qi) 491 | 492 | 493 | @given( 494 | qi=st.floats(0.0, 1e6), 495 | Di=st.floats(0.0, 1.0, exclude_max=True), 496 | Dterm=st.floats(0.0, 1.0, exclude_max=True), 497 | ) 498 | def test_MH_no_validate(qi: float, Di: float, Dterm: float) -> None: 499 | assume(dca.MH.nominal_from_secant(Di, 1.0) >= dca.MH.nominal_from_tangent(Dterm)) 500 | with pytest.raises(ValueError) as e: 501 | mh = dca.MH(qi, Di, 2.5, Dterm) 502 | 503 | mh = dca.MH(qi, Di, 2.5, Dterm, validate_params=[True, True, False, True]) 504 | 505 | 506 | @given( 507 | D=st.floats(0.0, 1.0, exclude_max=True), 508 | b=st.floats(0.0, 2.0), 509 | ) 510 | def test_decline_conv(D: float, b: float) -> None: 511 | Dnom = dca.MultisegmentHyperbolic.nominal_from_secant(D, b) 512 | _D = dca.MultisegmentHyperbolic.secant_from_nominal(Dnom, b) 513 | 514 | def test_bound_errors() -> None: 515 | with pytest.raises(ValueError) as e: 516 | # < lower bound 517 | ple = dca.PLE(-1000, 0.8, 0.0, 0.5) 518 | 519 | with pytest.raises(ValueError) as e: 520 | # lower bound excluded 521 | ple = dca.PLE(1000, 0.8, 0.0, 0.0) 522 | 523 | with pytest.raises(ValueError) as e: 524 | # > upper bound 525 | thm = dca.THM(1000, 0.5, 2.0, 10.0, 30.0) 526 | 527 | with pytest.raises(ValueError) as e: 528 | # upper bound exluded 529 | thm = dca.THM(1000, 1.5, 2.0, 0.5, 30.0) 530 | 531 | with pytest.raises(KeyError) as e: 532 | # invalid parameter 533 | thm = dca.THM(1000, 0.5, 2.0, 0.5, 30.0) 534 | thm.get_param_desc('n') 535 | 536 | with pytest.raises(ValueError) as e: 537 | # invalid parameter sequence length 538 | thm = dca.THM.from_params([1000, 0.5, 2.0, 0.5]) 539 | 540 | 541 | def test_terminal_exceeds() -> None: 542 | with pytest.raises(ValueError) as e: 543 | # Dinf > Di 544 | ple = dca.PLE(1000, 0.8, 0.9, 0.5) 545 | 546 | with pytest.raises(ValueError) as e: 547 | # Dterm > Di 548 | mh = dca.MH(1000, 0.5, 1.0, 0.9) 549 | 550 | with pytest.raises(ValueError) as e: 551 | # bf > bi 552 | thm = dca.THM(1000, 0.8, 1.5, 1.6, 30.0) 553 | 554 | with pytest.raises(ValueError) as e: 555 | # tterm < telf 556 | thm = dca.THM(1000, 0.8, 2.0, 1.0, 200.0, 0.3, 100.0 / dca.DAYS_PER_YEAR) 557 | 558 | 559 | @given( 560 | qi=st.floats(0.0, 1e6), 561 | Di=st.floats(1e-3, 1.0, exclude_max=True), 562 | bf=st.floats(0.0, 2.0), 563 | telf=st.floats(1e-10, 1e4), 564 | bterm=st.floats(1e-3, 0.3, exclude_max=True), 565 | tterm=st.floats(5.0, 30.0), 566 | c=st.floats(1e-10, 1e10), 567 | m0=st.floats(-1.0, 1.0), 568 | m=st.floats(-1.0, 1.0), 569 | t0=st.floats(1e-10, 365.25), 570 | ) 571 | @settings(deadline=None) # type: ignore 572 | def test_yield(qi: float, Di: float, bf: float, telf: float, bterm: float, tterm: float, 573 | c: float, m0: float, m: float, t0: float) -> None: 574 | assume(tterm * dca.DAYS_PER_YEAR > telf) 575 | assume(bterm < bf) 576 | thm = dca.THM(qi, Di, 2.0, bf, telf, bterm, tterm) 577 | sec = dca.PLYield(c, m0, m, t0) 578 | thm.add_secondary(sec) 579 | check_yield_model(thm.secondary, 'secondary', qi) 580 | 581 | thm = dca.THM(qi, Di, 2.0, bf, telf, bterm, tterm) 582 | wtr = dca.PLYield(c, m0, m, t0) 583 | thm.add_water(wtr) 584 | check_yield_model(thm.water, 'water', qi) 585 | 586 | 587 | @given( 588 | qi=st.floats(0.0, 1e6), 589 | Di=st.floats(1e-3, 1.0, exclude_max=True), 590 | bf=st.floats(0.0, 2.0), 591 | telf=st.floats(1e-10, 1e4), 592 | bterm=st.floats(1e-3, 0.3, exclude_max=True), 593 | tterm=st.floats(5.0, 30.0), 594 | c=st.floats(1e-10, 1e10), 595 | m0=st.floats(-1.0, 1.0), 596 | m=st.floats(-1.0, 1.0), 597 | t0=st.floats(1e-10, 365.25), 598 | _min=st.floats(0, 100.0), 599 | _max=st.floats(1e4, 5e5) 600 | ) 601 | @settings(deadline=None) # type: ignore 602 | def test_yield_min_max(qi: float, Di: float, bf: float, telf: float, bterm: float, tterm: float, 603 | c: float, m0: float, m: float, t0: float, _min: float, _max: float) -> None: 604 | assume(tterm * dca.DAYS_PER_YEAR > telf) 605 | assume(bterm < bf) 606 | thm = dca.THM(qi, Di, 2.0, bf, telf, bterm, tterm) 607 | sec = dca.PLYield(c, m0, m, t0, _min, _max) 608 | thm.add_secondary(sec) 609 | check_yield_model(thm.secondary, 'secondary', qi) 610 | 611 | wtr = dca.PLYield(c, m0, m, t0, _min, _max) 612 | thm.add_water(wtr) 613 | check_yield_model(thm.water, 'water', qi) 614 | 615 | 616 | def test_yield_min_max_invalid() -> None: 617 | with pytest.raises(ValueError) as e: 618 | y = dca.PLYield(1000.0, 0.0, 0.0, 180.0, 10.0, 1.0) 619 | 620 | 621 | def test_yield_errors() -> None: 622 | with pytest.raises(ValueError) as e: 623 | # < lower bound 624 | ple = dca.PLE(-1000, 0.8, 0.0, 0.5) 625 | 626 | with pytest.raises(ValueError) as e: 627 | # lower bound excluded 628 | tplehm = dca.PLE(1000, 0.8, 0.0, 0.0) 629 | 630 | with pytest.raises(ValueError) as e: 631 | # > upper bound 632 | thm = dca.THM(1000, 0.5, 2.0, 10.0, 30.0) 633 | 634 | with pytest.raises(ValueError) as e: 635 | # upper bound exluded 636 | thm = dca.THM(1000, 1.5, 2.0, 0.5, 30.0) 637 | 638 | with pytest.raises(KeyError) as e: 639 | # invalid parameter 640 | thm = dca.THM(1000, 0.5, 2.0, 0.5, 30.0) 641 | thm.get_param_desc('n') 642 | 643 | with pytest.raises(ValueError) as e: 644 | # invalid parameter sequence length 645 | thm = dca.THM.from_params([1000, 0.5, 2.0, 0.5]) 646 | 647 | @given( 648 | L=st.floats(0.0, 2.0), 649 | xlog=st.booleans(), 650 | ylog=st.booleans() 651 | ) 652 | def test_bourdet(L: float, xlog: bool, ylog: bool) -> None: 653 | with warnings.catch_warnings(record=True) as w: 654 | der = dca.bourdet(q_data, t_data, L, xlog, ylog) 655 | --------------------------------------------------------------------------------