pyrestoolbox 3.3.0__tar.gz → 3.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/PKG-INFO +1 -1
  2. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyproject.toml +1 -1
  3. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/brine/_lib_vle_engine.py +18 -8
  4. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/brine/brine.py +49 -10
  5. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/dca/dca.py +124 -93
  6. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/brine.rst +13 -7
  7. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/changelist.rst +100 -0
  8. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/examples.ipynb +16 -19
  9. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/gas.rst +241 -0
  10. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/matbal.rst +22 -3
  11. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/nodal.rst +59 -11
  12. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/oil.rst +7 -4
  13. pyrestoolbox-3.5.0/pyrestoolbox/gas/_hydrate.py +513 -0
  14. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/gas/gas.py +2030 -2280
  15. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/matbal/matbal.py +32 -8
  16. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/nodal/nodal.py +174 -47
  17. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/__init__.py +1 -3
  18. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_compressibility.py +4 -6
  19. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_constants.py +18 -0
  20. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_correlations.py +1 -1
  21. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_density.py +1 -1
  22. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_harmonize.py +3 -3
  23. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_pvt_class.py +17 -13
  24. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_rate.py +4 -2
  25. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_separator.py +10 -15
  26. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_tables.py +24 -20
  27. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/shared_fns/shared_fns.py +11 -8
  28. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/simtools/simtools.py +27 -4
  29. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/bessel.rs +7 -3
  30. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/lib.rs +0 -2
  31. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/oil/density.rs +0 -46
  32. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/oil/mod.rs +0 -31
  33. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/spycher_pruess/mod.rs +3 -2
  34. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/spycher_pruess/solubility.rs +801 -794
  35. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/flash.rs +246 -427
  36. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/mod.rs +127 -197
  37. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/holdup_hb.rs +9 -5
  38. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/segment_gas.rs +10 -9
  39. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/segment_oil.rs +34 -9
  40. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/.github/workflows/build-wheels.yml +0 -0
  41. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/.gitignore +0 -0
  42. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/Cargo.lock +0 -0
  43. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/Cargo.toml +0 -0
  44. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/LICENSE +0 -0
  45. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/MANIFEST.in +0 -0
  46. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/README.rst +0 -0
  47. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/ResToolbox/privacy_policy.md +0 -0
  48. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/benchmark_rust_vs_python.py +0 -0
  49. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/build_pure_python.py +0 -0
  50. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/__init__.py +0 -0
  51. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/_accelerator.py +0 -0
  52. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/brine/__init__.py +0 -0
  53. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/brine/_lib_salting_library.py +0 -0
  54. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/classes/__init__.py +0 -0
  55. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/classes/classes.py +0 -0
  56. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/constants/__init__.py +0 -0
  57. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/constants/constants.py +0 -0
  58. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/dca/__init__.py +0 -0
  59. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/dca.rst +0 -0
  60. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/bot.png +0 -0
  61. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/bot_PVTO.png +0 -0
  62. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/bot_img.png +0 -0
  63. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/dry_gas.png +0 -0
  64. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/grid_sat_df.png +0 -0
  65. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/influence.png +0 -0
  66. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/properties_df.png +0 -0
  67. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/sgof.png +0 -0
  68. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/img/swof.png +0 -0
  69. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/layer.rst +0 -0
  70. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/library.rst +0 -0
  71. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/nodal_examples.ipynb +0 -0
  72. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/nodal_hydrate_demo.ipynb +0 -0
  73. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/recommend.rst +0 -0
  74. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/sensitivity.rst +0 -0
  75. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/docs/simtools.rst +0 -0
  76. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/gas/__init__.py +0 -0
  77. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/layer/__init__.py +0 -0
  78. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/layer/layer.py +0 -0
  79. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/library/__init__.py +0 -0
  80. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/library/component_library.xlsx +0 -0
  81. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/library/library.py +0 -0
  82. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/matbal/__init__.py +0 -0
  83. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/nodal/__init__.py +0 -0
  84. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/oil/_utils.py +0 -0
  85. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/plyasunov/__init__.py +0 -0
  86. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/plyasunov/iapws_if97.py +0 -0
  87. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/plyasunov/plyasunov_model.py +0 -0
  88. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/plyasunov/water_properties.py +0 -0
  89. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/recommend/__init__.py +0 -0
  90. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/recommend/recommend.py +0 -0
  91. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/sensitivity/__init__.py +0 -0
  92. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/sensitivity/sensitivity.py +0 -0
  93. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/shared_fns/__init__.py +0 -0
  94. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/simtools/__init__.py +0 -0
  95. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/validate/__init__.py +0 -0
  96. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/pyrestoolbox/validate/validate.py +0 -0
  97. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/setup.cfg +0 -0
  98. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/setup.py +0 -0
  99. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/critical_properties/mod.rs +0 -0
  100. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/dca/hyperbolic.rs +0 -0
  101. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/dca/mod.rs +0 -0
  102. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/dca/ransac.rs +0 -0
  103. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/gas_viscosity/mod.rs +0 -0
  104. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/gwr.rs +0 -0
  105. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/matbal/mod.rs +0 -0
  106. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/matbal/objective.rs +0 -0
  107. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/pseudopressure.rs +0 -0
  108. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/alpha.rs +0 -0
  109. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/bip.rs +0 -0
  110. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/components.rs +0 -0
  111. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/eos.rs +0 -0
  112. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/fugacity.rs +0 -0
  113. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/k_init.rs +0 -0
  114. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vle/rachford_rice.rs +0 -0
  115. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/friction.rs +0 -0
  116. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/holdup_bb.rs +0 -0
  117. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/holdup_gray.rs +0 -0
  118. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/holdup_wg.rs +0 -0
  119. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/ift.rs +0 -0
  120. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/mod.rs +0 -0
  121. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/pvt_helpers.rs +0 -0
  122. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/vlp/static_column.rs +0 -0
  123. {pyrestoolbox-3.3.0 → pyrestoolbox-3.5.0}/src/zfactor/mod.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrestoolbox
3
- Version: 3.3.0
3
+ Version: 3.5.0
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "pyrestoolbox"
7
- version = "3.3.0"
7
+ version = "3.5.0"
8
8
  description = "pyResToolbox - A collection of Reservoir Engineering Utilities"
9
9
  license = {text = "GPL-3.0-or-later"}
10
10
  authors = [{name = "Mark W. Burgoyne", email = "mark.w.burgoyne@gmail.com"}]
@@ -2106,15 +2106,21 @@ class SWMultiComponentFlash:
2106
2106
  # specialised ks models (Dubessy CO2, Akinfiev H2S, Li HC,
2107
2107
  # Mao-Duan N2, Duan-Sun CO2) are applied in self.calc_gamma()
2108
2108
  # on the Python side.
2109
- if _RUST_AVAILABLE:
2109
+ #
2110
+ # Restricted to framework='proposed': the Rust flash hardcodes the
2111
+ # MC-3 water alpha and the proposed-framework kij_AQ. The 'dropin'
2112
+ # and 'sw_original' frameworks use a salinity-dependent Soreide water
2113
+ # alpha and different kij_AQ correlations that Rust does not implement,
2114
+ # so they must take the Python path to avoid a silent downgrade.
2115
+ if _RUST_AVAILABLE and self.framework == 'proposed':
2110
2116
  try:
2111
2117
  gamma_arr = np.asarray(gamma, dtype=float) if gamma is not None \
2112
2118
  else np.ones(self.nc)
2113
- V, x_r, y_r = _rust.flash_tp_rust(
2119
+ V, x_r, y_r, conv_r = _rust.flash_tp_rust(
2114
2120
  T_K, P_Pa, z.tolist(), list(self.names),
2115
2121
  0.0, mode, gamma_arr.tolist(),
2116
2122
  )
2117
- return V, np.array(x_r), np.array(y_r), True
2123
+ return V, np.array(x_r), np.array(y_r), conv_r
2118
2124
  except (ImportError, AttributeError):
2119
2125
  pass
2120
2126
 
@@ -2269,8 +2275,12 @@ class SWMultiComponentFlash:
2269
2275
  salinity_method = 'explicit'
2270
2276
 
2271
2277
  # Rust acceleration path: compute gamma in Python (correct ks models),
2272
- # then use Rust flash_tp for the two flashes.
2273
- if _RUST_AVAILABLE and salinity_method == 'gamma_phi':
2278
+ # then use Rust flash_tp for the two flashes. Restricted to
2279
+ # framework='proposed' Rust only implements the proposed-framework
2280
+ # water alpha (MC-3) and kij_AQ; 'dropin'/'sw_original' would be
2281
+ # silently downgraded to 'proposed', so they take the Python path.
2282
+ if (_RUST_AVAILABLE and salinity_method == 'gamma_phi'
2283
+ and self.framework == 'proposed'):
2274
2284
  try:
2275
2285
  gamma_aq = None
2276
2286
  if self.salinity > 0:
@@ -2281,11 +2291,11 @@ class SWMultiComponentFlash:
2281
2291
  else [1.0] * self.nc
2282
2292
  names_list = list(self.names)
2283
2293
 
2284
- V_aq, x_aq_r, y_aq_r = _rust.flash_tp_rust(
2294
+ V_aq, x_aq_r, y_aq_r, conv_aq = _rust.flash_tp_rust(
2285
2295
  T_K, P_Pa, z.tolist(), names_list,
2286
2296
  self.salinity, 'AQ', gamma_list,
2287
2297
  )
2288
- V_na, x_na_r, y_na_r = _rust.flash_tp_rust(
2298
+ V_na, x_na_r, y_na_r, conv_na = _rust.flash_tp_rust(
2289
2299
  T_K, P_Pa, z.tolist(), names_list,
2290
2300
  self.salinity, 'NA', [1.0] * self.nc,
2291
2301
  )
@@ -2296,7 +2306,7 @@ class SWMultiComponentFlash:
2296
2306
  result = {
2297
2307
  'x_aq': x_aq, 'y_na': y_na, 'K_true': K_true,
2298
2308
  'V_aq': V_aq, 'V_na': V_na,
2299
- 'converged_aq': True, 'converged_na': True,
2309
+ 'converged_aq': conv_aq, 'converged_na': conv_na,
2300
2310
  'component_names': self.names,
2301
2311
  'salinity_method': salinity_method,
2302
2312
  'vlle_warning': False,
@@ -37,6 +37,8 @@ __all__ = [
37
37
  'brine_props', 'CO2_Brine_Mixture', 'SoreideWhitson', 'make_pvtw_table',
38
38
  ]
39
39
 
40
+ import warnings
41
+
40
42
  import numpy as np
41
43
  import numpy.typing as npt
42
44
  import pandas as pd
@@ -133,6 +135,8 @@ def brine_props(p: float = None, degf: float = None, wt: float = None, ch4_sat:
133
135
  p = pres
134
136
  if degf is None and temp is not None:
135
137
  degf = temp
138
+ if wt is not None and ppm is not None:
139
+ raise ValueError("Supply either wt or ppm, not both.")
136
140
  if wt is None:
137
141
  wt = ppm / 10000 if ppm is not None else 0
138
142
  if p is None or degf is None:
@@ -467,19 +471,21 @@ class CO2_Brine_Mixture():
467
471
  >> array([0.02431245, 0.95743175])
468
472
 
469
473
  Usage example for 175 Bara x 85 degC and 0% NaCl brine:
470
- mix = brine.CO2_Brine_Mixture(pres = 175, temp = 85)
474
+ mix = brine.CO2_Brine_Mixture(pres = 175, temp = 85, metric = True)
471
475
  mix.Rs # Returns sm3 dissolved CO2 / sm3 Brine
472
476
  >> 24.742923469934272
473
477
 
474
478
 
475
479
  """
476
- def __init__(self, pres=None, temp=None, ppm=None, metric=True, cw_sat=False,
480
+ def __init__(self, pres=None, temp=None, ppm=None, metric=False, cw_sat=False,
477
481
  *, p=None, degf=None, wt=None):
478
482
  # Resolve parameter aliases (p/degf/wt -> pres/temp/ppm)
479
483
  if pres is None and p is not None:
480
484
  pres = p
481
485
  if temp is None and degf is not None:
482
486
  temp = degf
487
+ if ppm is not None and wt is not None:
488
+ raise ValueError("Supply either ppm or wt, not both.")
483
489
  if ppm is None:
484
490
  ppm = wt * 10000 if wt is not None else 0
485
491
  if pres is None or temp is None:
@@ -961,9 +967,10 @@ class CO2_Brine_Mixture():
961
967
  """
962
968
  if _RUST_AVAILABLE:
963
969
  try:
964
- xco2, yco2, yh2o, rhogas, gasz = _rust.co2_brine_solubility_rust(
970
+ xco2, yco2, yh2o, rhogas, gasz, conv = _rust.co2_brine_solubility_rust(
965
971
  self.pBar, self.degC, self.ppm
966
972
  )
973
+ self.converged = conv
967
974
  self.x = np.array([xco2, 1.0 - xco2 - (self.xSalt if self.xSalt else 0.0)])
968
975
  self.y = np.array([yco2, yh2o])
969
976
  self.rhoGas = rhogas
@@ -1436,8 +1443,15 @@ class SoreideWhitson:
1436
1443
  y_N2: Mole fraction N2 in dry gas (default 0)
1437
1444
  y_H2: Mole fraction H2 in dry gas (default 0)
1438
1445
  sg: Gas specific gravity — used to estimate HC split among C1-C4 (default 0.65)
1439
- metric: Boolean for units (True=metric, False=oilfield). Default True.
1446
+ metric: Boolean for units (True=metric, False=oilfield). Default False.
1440
1447
  cw_sat: If True, also calculate saturated compressibility (default False)
1448
+ framework: VLE framework. 'proposed' (default, Soreide-Whitson 1992 re-fit),
1449
+ 'sw_original' (original 1992 published), or 'dropin' (fitted to PR-EOS with
1450
+ brine-aware water alpha). Affects kij and ks correlations.
1451
+ salinity_method: How salinity enters the flash. 'gamma_phi' (default, Sechenov
1452
+ salting-out via activity coefficient), 'embedded' (salinity inside kij —
1453
+ only for 'dropin'/'sw_original'), 'explicit' (brine treated as a component),
1454
+ 'sechenov' (legacy alias for gamma_phi), or 'auto' (pick per-gas defaults).
1441
1455
 
1442
1456
  Returns object with following calculated properties:
1443
1457
  .x : Dict of dissolved gas mole fractions, e.g. {'CO2': 0.024, 'CH4': 0.0015}
@@ -1462,7 +1476,7 @@ class SoreideWhitson:
1462
1476
  mix.Rs # Returns per-gas Rs dict, e.g. {'CO2': 15.2}
1463
1477
 
1464
1478
  # Mixed gas, metric units
1465
- mix = brine.SoreideWhitson(pres=200, temp=80, ppm=10000, y_CO2=0.1, y_H2S=0.05, sg=0.7)
1479
+ mix = brine.SoreideWhitson(pres=200, temp=80, ppm=10000, y_CO2=0.1, y_H2S=0.05, sg=0.7, metric=True)
1466
1480
  mix.bDen # Returns [gas-saturated, gas-free, freshwater] densities
1467
1481
 
1468
1482
  References:
@@ -1482,13 +1496,20 @@ class SoreideWhitson:
1482
1496
  Murphy, W.R. and Gaines, T.M. (1974), J. Chem. Eng. Data 19(4), 359-362.
1483
1497
  """
1484
1498
 
1499
+ _VALID_FRAMEWORKS = ('proposed', 'sw_original', 'dropin')
1500
+ _VALID_SALINITY_METHODS = ('gamma_phi', 'embedded', 'explicit', 'auto', 'sechenov')
1501
+
1485
1502
  def __init__(self, pres=None, temp=None, ppm=None, y_CO2=0, y_H2S=0, y_N2=0, y_H2=0,
1486
- sg=0.65, metric=True, cw_sat=False, *, p=None, degf=None, wt=None):
1503
+ sg=0.65, metric=False, cw_sat=False,
1504
+ framework='proposed', salinity_method='gamma_phi',
1505
+ *, p=None, degf=None, wt=None):
1487
1506
  # Resolve parameter aliases (p/degf/wt -> pres/temp/ppm)
1488
1507
  if pres is None and p is not None:
1489
1508
  pres = p
1490
1509
  if temp is None and degf is not None:
1491
1510
  temp = degf
1511
+ if ppm is not None and wt is not None:
1512
+ raise ValueError("Supply either ppm or wt, not both.")
1492
1513
  if ppm is None:
1493
1514
  ppm = wt * 10000 if wt is not None else 0
1494
1515
  if pres is None or temp is None:
@@ -1497,6 +1518,22 @@ class SoreideWhitson:
1497
1518
  raise ValueError(f"ppm must be non-negative, got {ppm}")
1498
1519
  if ppm >= 1e6:
1499
1520
  raise ValueError(f"ppm must be less than 1,000,000, got {ppm}")
1521
+ if framework not in self._VALID_FRAMEWORKS:
1522
+ raise ValueError(
1523
+ f"Invalid framework: {framework!r}. Valid options: {list(self._VALID_FRAMEWORKS)}"
1524
+ )
1525
+ if salinity_method not in self._VALID_SALINITY_METHODS:
1526
+ raise ValueError(
1527
+ f"Invalid salinity_method: {salinity_method!r}. Valid options: {list(self._VALID_SALINITY_METHODS)}"
1528
+ )
1529
+ if framework == 'proposed' and salinity_method == 'embedded':
1530
+ warnings.warn(
1531
+ "framework='proposed' does not define embedded-salinity kij; engine will "
1532
+ "fall back to the 'gamma_phi' behaviour.",
1533
+ stacklevel=2,
1534
+ )
1535
+ self.framework = framework
1536
+ self.salinity_method = salinity_method
1500
1537
  # Validate pressure and temperature (convert to oilfield units for validation)
1501
1538
  _p_val = pres if not metric else pres * BAR2PSI
1502
1539
  _t_val = temp if not metric else temp * 1.8 + 32
@@ -1742,8 +1779,8 @@ class SoreideWhitson:
1742
1779
  y_H2S=self.gas_comp.get('H2S', 0),
1743
1780
  y_H2=self.gas_comp.get('H2', 0),
1744
1781
  method='flash',
1745
- salinity_method='gamma_phi',
1746
- framework='proposed',
1782
+ salinity_method=self.salinity_method,
1783
+ framework=self.framework,
1747
1784
  )
1748
1785
 
1749
1786
  self.x = x_gas
@@ -1903,8 +1940,10 @@ class SoreideWhitson:
1903
1940
  gas_ply = _VLE_TO_PLYASUNOV.get(gas_vle, gas_vle.upper())
1904
1941
  vphi_eff_p1 += yi * _plyasunov_V_phi(gas_ply, tKel, Mpa_p1)
1905
1942
 
1906
- numerator_p1 = 1.0 + self.x_total * mw_eff / (MWWAT * x1)
1907
- denom_p1 = self.x_total * vphi_eff_p1 / (MWWAT * x1) + 1.0 / rho_brine_p1_gcc
1943
+ # Garcia Eq. 18 at P+1, same algebraically reformulated (non-singular)
1944
+ # form as Step 3 — multiply top/bot through by M1*x1.
1945
+ numerator_p1 = MWWAT * x1 + self.x_total * mw_eff
1946
+ denom_p1 = self.x_total * vphi_eff_p1 + MWWAT * x1 / rho_brine_p1_gcc
1908
1947
  rho_p1_gcc = numerator_p1 / denom_p1
1909
1948
  else:
1910
1949
  rho_p1_gcc = rho_brine_p1_gcc
@@ -51,7 +51,8 @@ __all__ = [
51
51
 
52
52
  import numpy as np
53
53
  from dataclasses import dataclass, field
54
- from typing import Optional
54
+ from typing import Optional, Union
55
+ from numpy.typing import ArrayLike
55
56
 
56
57
  from pyrestoolbox.shared_fns import convert_to_numpy, process_output, ransac_linreg
57
58
  from pyrestoolbox._accelerator import RUST_AVAILABLE as _RUST_AVAILABLE
@@ -59,6 +60,66 @@ if _RUST_AVAILABLE:
59
60
  from pyrestoolbox import _native as _rust
60
61
 
61
62
 
63
+ # ---------------------------------------------------------------------------
64
+ # Named constants — extracted from inline magic numbers per CLAUDE.md rules.
65
+ # ---------------------------------------------------------------------------
66
+
67
+ # Arps hyperbolic b-grid search (Arps 1945; b physically in (0, 1))
68
+ # 0.05-0.95 avoids exponential (b=0) / harmonic (b=1) degeneracies.
69
+ _B_GRID_MIN = 0.05
70
+ _B_GRID_MAX = 0.96
71
+ _B_GRID_STEP = 0.01
72
+
73
+ # Duong (2011) cumulative integration grid
74
+ _DUONG_TRAP_LB = 0.001 # Lower trap bound to avoid t=0 singularity
75
+ _DUONG_GRID_MIN = 500 # Minimum trap grid points per integration
76
+ _DUONG_GRID_DENSITY = 10 # Additional points per unit t
77
+
78
+ # Duong curve_fit bounds and initial guess (Duong 2011, Eq. 5)
79
+ _DUONG_BOUNDS_LO = (0.0, 0.01, 1.001)
80
+ _DUONG_BOUNDS_HI_QI_FACTOR = 5.0 # Upper qi bound = first q * factor
81
+ _DUONG_BOUNDS_HI_A = 10.0
82
+ _DUONG_BOUNDS_HI_M = 3.0
83
+ _DUONG_P0_A = 1.0 # Initial guess a
84
+ _DUONG_P0_M = 1.2 # Initial guess m
85
+
86
+ # scipy.curve_fit iteration cap (covers Duong + logistic fits)
87
+ _CURVE_FIT_MAXFEV = 5000
88
+
89
+ # Numerical floors / near-zero guards
90
+ _HYPER_INNER_FLOOR = 1e-10 # Floor for (1 - di*Np*exp/qi)^(1/exp) argument
91
+ _ZERO_DIV_EPS = 1e-30 # General division-by-zero guard
92
+
93
+ # Logistic ratio curve_fit (unconventional GOR/WOR trending)
94
+ _LOGISTIC_P0_RMAX_INIT = 1.5 # Initial Rmax guess = max(ratio) * factor
95
+ _LOGISTIC_P0_TC = 0.01 # Initial carryover constant
96
+ _LOGISTIC_P0_ALPHA = 10.0 # Initial curvature
97
+ _LOGISTIC_BOUNDS_LO = (1e-8, 1e-3) # Lower (tc, alpha) bounds
98
+ _LOGISTIC_BOUNDS_RMAX_FACTOR = 5.0 # Upper Rmax = max(ratio) * factor
99
+ _LOGISTIC_BOUNDS_HI = 1e6 # Upper tc / alpha bound
100
+
101
+
102
+ def _build_decline_result(method, q_obs, q_pred, **params):
103
+ """DRY helper: compute R-squared + residuals, return a DeclineResult.
104
+
105
+ Parameters
106
+ ----------
107
+ method : str
108
+ q_obs, q_pred : np.ndarray (observed and predicted rates)
109
+ **params : scalar DeclineResult fields (qi, di, b, a, m)
110
+ """
111
+ residuals = q_obs - q_pred
112
+ ss_res = np.sum(residuals ** 2)
113
+ ss_tot = np.sum((q_obs - np.mean(q_obs)) ** 2)
114
+ r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
115
+ return DeclineResult(
116
+ method=method,
117
+ r_squared=r2,
118
+ residuals=residuals,
119
+ **params,
120
+ )
121
+
122
+
62
123
  def _fmt_array(a, name):
63
124
  if a is None:
64
125
  return f"{name}=None"
@@ -189,7 +250,7 @@ class RatioResult:
189
250
  )
190
251
 
191
252
 
192
- def arps_rate(qi, di, b, t):
253
+ def arps_rate(qi: float, di: float, b: float, t: ArrayLike) -> Union[float, np.ndarray]:
193
254
  """Arps decline rate.
194
255
 
195
256
  Parameters
@@ -226,7 +287,7 @@ def arps_rate(qi, di, b, t):
226
287
  return process_output(q, is_list)
227
288
 
228
289
 
229
- def arps_cum(qi, di, b, t):
290
+ def arps_cum(qi: float, di: float, b: float, t: ArrayLike) -> Union[float, np.ndarray]:
230
291
  """Arps cumulative production.
231
292
 
232
293
  Parameters
@@ -263,7 +324,7 @@ def arps_cum(qi, di, b, t):
263
324
  return process_output(Qcum, is_list)
264
325
 
265
326
 
266
- def duong_rate(qi, a, m, t):
327
+ def duong_rate(qi: float, a: float, m: float, t: ArrayLike) -> Union[float, np.ndarray]:
267
328
  """Duong decline rate for unconventional reservoirs.
268
329
 
269
330
  q(t) = qi * t^(-m) * exp(a/(1-m) * (t^(1-m) - 1))
@@ -296,7 +357,7 @@ def duong_rate(qi, a, m, t):
296
357
  return process_output(q, is_list)
297
358
 
298
359
 
299
- def duong_cum(qi, a, m, t):
360
+ def duong_cum(qi: float, a: float, m: float, t: ArrayLike) -> Union[float, np.ndarray]:
300
361
  """Duong cumulative production via trapezoidal integration.
301
362
 
302
363
  Parameters
@@ -328,15 +389,15 @@ def duong_cum(qi, a, m, t):
328
389
  # integrates over a descending axis and returns negative cumulative.
329
390
  results = np.zeros_like(t, dtype=float)
330
391
  for i, ti in enumerate(t):
331
- lower = min(0.001, ti * 0.001)
332
- t_fine = np.linspace(lower, ti, max(500, int(ti * 10)))
392
+ lower = min(_DUONG_TRAP_LB, ti * _DUONG_TRAP_LB)
393
+ t_fine = np.linspace(lower, ti, max(_DUONG_GRID_MIN, int(ti * _DUONG_GRID_DENSITY)))
333
394
  q_fine = qi * t_fine ** (-m) * np.exp(a / (1.0 - m) * (t_fine ** (1.0 - m) - 1.0))
334
395
  results[i] = np.trapezoid(q_fine, t_fine)
335
396
 
336
397
  return process_output(results, is_list)
337
398
 
338
399
 
339
- def eur(qi, di, b, q_min):
400
+ def eur(qi: float, di: float, b: float, q_min: float) -> float:
340
401
  """Estimated ultimate recovery for Arps decline.
341
402
 
342
403
  Parameters
@@ -382,13 +443,7 @@ def _fit_exponential(t, q):
382
443
  if di <= 0:
383
444
  return None
384
445
  q_pred = qi * np.exp(-di * t)
385
- ss_res = np.sum((q - q_pred) ** 2)
386
- ss_tot = np.sum((q - np.mean(q)) ** 2)
387
- r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
388
- return DeclineResult(
389
- method='exponential', qi=qi, di=di, b=0.0,
390
- r_squared=r2, residuals=q - q_pred,
391
- )
446
+ return _build_decline_result('exponential', q, q_pred, qi=qi, di=di, b=0.0)
392
447
 
393
448
 
394
449
  def _fit_harmonic(t, q):
@@ -403,13 +458,7 @@ def _fit_harmonic(t, q):
403
458
  if di <= 0:
404
459
  return None
405
460
  q_pred = qi / (1.0 + di * t)
406
- ss_res = np.sum((q - q_pred) ** 2)
407
- ss_tot = np.sum((q - np.mean(q)) ** 2)
408
- r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
409
- return DeclineResult(
410
- method='harmonic', qi=qi, di=di, b=1.0,
411
- r_squared=r2, residuals=q - q_pred,
412
- )
461
+ return _build_decline_result('harmonic', q, q_pred, qi=qi, di=di, b=1.0)
413
462
 
414
463
 
415
464
  def _fit_hyperbolic(t, q):
@@ -423,18 +472,14 @@ def _fit_hyperbolic(t, q):
423
472
  try:
424
473
  qi, di, b, r2 = _rust.fit_hyperbolic_rust(t.tolist(), q.tolist())
425
474
  q_pred = qi / (1.0 + b * di * t) ** (1.0 / b)
426
- return DeclineResult(
427
- method='hyperbolic', qi=qi, di=di, b=b,
428
- r_squared=r2, residuals=q - q_pred,
429
- )
475
+ return _build_decline_result('hyperbolic', q, q_pred, qi=qi, di=di, b=b)
430
476
  except (ImportError, AttributeError):
431
477
  pass
432
478
 
433
479
  best_r2 = -np.inf
434
480
  best_result = None
435
- ss_tot = np.sum((q - np.mean(q)) ** 2)
436
481
 
437
- for b_trial in np.arange(0.05, 0.96, 0.01):
482
+ for b_trial in np.arange(_B_GRID_MIN, _B_GRID_MAX, _B_GRID_STEP):
438
483
  # Transform: Y = q^(-b) is linear in t
439
484
  Y = q ** (-b_trial)
440
485
  slope, intercept, _ = ransac_linreg(t, Y)
@@ -451,15 +496,10 @@ def _fit_hyperbolic(t, q):
451
496
 
452
497
  # Compute R-squared in original rate space
453
498
  q_pred = qi / (1.0 + b_trial * di * t) ** (1.0 / b_trial)
454
- ss_res = np.sum((q - q_pred) ** 2)
455
- r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
456
-
457
- if r2 > best_r2:
458
- best_r2 = r2
459
- best_result = DeclineResult(
460
- method='hyperbolic', qi=qi, di=di, b=b_trial,
461
- r_squared=r2, residuals=q - q_pred,
462
- )
499
+ result = _build_decline_result('hyperbolic', q, q_pred, qi=qi, di=di, b=b_trial)
500
+ if result.r_squared > best_r2:
501
+ best_r2 = result.r_squared
502
+ best_result = result
463
503
 
464
504
  return best_result
465
505
 
@@ -478,20 +518,19 @@ def _fit_duong(t, q):
478
518
  t_f, q_f = t[mask], q[mask]
479
519
 
480
520
  try:
481
- popt, _ = curve_fit(duong_func, t_f, q_f, p0=[q_f[0], 1.0, 1.2],
482
- bounds=([0, 0.01, 1.001], [q_f[0] * 5, 10.0, 3.0]),
483
- maxfev=5000)
484
- qi, a, m = popt
485
- q_pred_full = np.full_like(q, np.nan, dtype=float)
486
- q_pred_full[mask] = duong_func(t_f, qi, a, m)
487
- q_pred_valid = q_pred_full[mask]
488
- ss_res = np.sum((q_f - q_pred_valid) ** 2)
489
- ss_tot = np.sum((q_f - np.mean(q_f)) ** 2)
490
- r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
491
- return DeclineResult(
492
- method='duong', qi=qi, a=a, m=m,
493
- r_squared=r2, residuals=q_f - q_pred_valid,
521
+ popt, _ = curve_fit(
522
+ duong_func, t_f, q_f,
523
+ p0=[q_f[0], _DUONG_P0_A, _DUONG_P0_M],
524
+ bounds=(
525
+ list(_DUONG_BOUNDS_LO),
526
+ [q_f[0] * _DUONG_BOUNDS_HI_QI_FACTOR,
527
+ _DUONG_BOUNDS_HI_A, _DUONG_BOUNDS_HI_M],
528
+ ),
529
+ maxfev=_CURVE_FIT_MAXFEV,
494
530
  )
531
+ qi, a, m = popt
532
+ q_pred_valid = duong_func(t_f, qi, a, m)
533
+ return _build_decline_result('duong', q_f, q_pred_valid, qi=qi, a=a, m=m)
495
534
  except (RuntimeError, ValueError):
496
535
  return None
497
536
 
@@ -507,13 +546,7 @@ def _fit_exponential_cum(Np, q):
507
546
  if di <= 0 or qi <= 0:
508
547
  return None
509
548
  q_pred = qi - di * Np
510
- ss_res = np.sum((q - q_pred) ** 2)
511
- ss_tot = np.sum((q - np.mean(q)) ** 2)
512
- r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
513
- return DeclineResult(
514
- method='exponential', qi=qi, di=di, b=0.0,
515
- r_squared=r2, residuals=q - q_pred,
516
- )
549
+ return _build_decline_result('exponential', q, q_pred, qi=qi, di=di, b=0.0)
517
550
 
518
551
 
519
552
  def _fit_harmonic_cum(Np, q):
@@ -529,13 +562,7 @@ def _fit_harmonic_cum(Np, q):
529
562
  if di <= 0 or qi <= 0:
530
563
  return None
531
564
  q_pred = qi * np.exp(-di / qi * Np)
532
- ss_res = np.sum((q - q_pred) ** 2)
533
- ss_tot = np.sum((q - np.mean(q)) ** 2)
534
- r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
535
- return DeclineResult(
536
- method='harmonic', qi=qi, di=di, b=1.0,
537
- r_squared=r2, residuals=q - q_pred,
538
- )
565
+ return _build_decline_result('harmonic', q, q_pred, qi=qi, di=di, b=1.0)
539
566
 
540
567
 
541
568
  def _fit_hyperbolic_cum(Np, q):
@@ -550,20 +577,16 @@ def _fit_hyperbolic_cum(Np, q):
550
577
  try:
551
578
  qi, di, b, r2 = _rust.fit_hyperbolic_cum_rust(Np.tolist(), q.tolist())
552
579
  exp = 1.0 - b
553
- inner = np.maximum(1.0 - exp * di * Np / qi, 1e-10)
580
+ inner = np.maximum(1.0 - exp * di * Np / qi, _HYPER_INNER_FLOOR)
554
581
  q_pred = qi * inner ** (1.0 / exp)
555
- return DeclineResult(
556
- method='hyperbolic', qi=qi, di=di, b=b,
557
- r_squared=r2, residuals=q - q_pred,
558
- )
582
+ return _build_decline_result('hyperbolic', q, q_pred, qi=qi, di=di, b=b)
559
583
  except (ImportError, AttributeError):
560
584
  pass
561
585
 
562
586
  best_r2 = -np.inf
563
587
  best_result = None
564
- ss_tot = np.sum((q - np.mean(q)) ** 2)
565
588
 
566
- for b_trial in np.arange(0.05, 0.96, 0.01):
589
+ for b_trial in np.arange(_B_GRID_MIN, _B_GRID_MAX, _B_GRID_STEP):
567
590
  exp = 1.0 - b_trial
568
591
  # Transform: Np = A + B * q^(1-b)
569
592
  X = q ** exp
@@ -572,7 +595,7 @@ def _fit_hyperbolic_cum(Np, q):
572
595
  # A = intercept = qi / ((1-b)*di)
573
596
  # B = slope = -qi^b / ((1-b)*di)
574
597
  # B/A = -qi^b / qi = -qi^(b-1) = -1/qi^(1-b)
575
- if abs(slope) < 1e-30 or abs(intercept) < 1e-30:
598
+ if abs(slope) < _ZERO_DIV_EPS or abs(intercept) < _ZERO_DIV_EPS:
576
599
  continue
577
600
  ratio = slope / intercept # = -1/qi^(1-b)
578
601
  if ratio >= 0:
@@ -588,23 +611,20 @@ def _fit_hyperbolic_cum(Np, q):
588
611
  continue
589
612
 
590
613
  # Compute R-squared in original rate space
591
- inner = 1.0 - exp * di * Np / qi
592
- inner = np.maximum(inner, 1e-10)
614
+ inner = np.maximum(1.0 - exp * di * Np / qi, _HYPER_INNER_FLOOR)
593
615
  q_pred = qi * inner ** (1.0 / exp)
594
- ss_res = np.sum((q - q_pred) ** 2)
595
- r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 0.0
596
-
597
- if r2 > best_r2:
598
- best_r2 = r2
599
- best_result = DeclineResult(
600
- method='hyperbolic', qi=qi, di=di, b=b_trial,
601
- r_squared=r2, residuals=q - q_pred,
602
- )
616
+ result = _build_decline_result('hyperbolic', q, q_pred, qi=qi, di=di, b=b_trial)
617
+ if result.r_squared > best_r2:
618
+ best_r2 = result.r_squared
619
+ best_result = result
603
620
 
604
621
  return best_result
605
622
 
606
623
 
607
- def fit_decline_cum(Np, q, method='best', t_calendar=None, Np_start=None, Np_end=None):
624
+ def fit_decline_cum(Np: ArrayLike, q: ArrayLike, method: str = 'best',
625
+ t_calendar: Optional[ArrayLike] = None,
626
+ Np_start: Optional[float] = None,
627
+ Np_end: Optional[float] = None) -> 'DeclineResult':
608
628
  """Fit a decline model to rate-vs-cumulative data.
609
629
 
610
630
  Eliminates time from the Arps equations to fit q as a function of Np.
@@ -708,7 +728,9 @@ def fit_decline_cum(Np, q, method='best', t_calendar=None, Np_start=None, Np_end
708
728
  return best
709
729
 
710
730
 
711
- def fit_decline(t, q, method='best', t_start=None, t_end=None):
731
+ def fit_decline(t: ArrayLike, q: ArrayLike, method: str = 'best',
732
+ t_start: Optional[float] = None,
733
+ t_end: Optional[float] = None) -> 'DeclineResult':
712
734
  """Fit a decline model to production data.
713
735
 
714
736
  Parameters
@@ -832,11 +854,17 @@ def _fit_ratio_logistic(x, ratio):
832
854
  return Rmax / (1.0 + c * np.exp(-b * x))
833
855
 
834
856
  try:
835
- Rmax_guess = np.max(ratio) * 1.5
836
- popt, _ = curve_fit(logistic_func, x, ratio,
837
- p0=[Rmax_guess, 0.01, 10.0],
838
- bounds=([0, 1e-8, 1e-3], [Rmax_guess * 5, 10.0, 1e6]),
839
- maxfev=5000)
857
+ Rmax_guess = np.max(ratio) * _LOGISTIC_P0_RMAX_INIT
858
+ popt, _ = curve_fit(
859
+ logistic_func, x, ratio,
860
+ p0=[Rmax_guess, _LOGISTIC_P0_TC, _LOGISTIC_P0_ALPHA],
861
+ bounds=(
862
+ [0.0, _LOGISTIC_BOUNDS_LO[0], _LOGISTIC_BOUNDS_LO[1]],
863
+ [Rmax_guess * _LOGISTIC_BOUNDS_RMAX_FACTOR,
864
+ _LOGISTIC_BOUNDS_HI, _LOGISTIC_BOUNDS_HI],
865
+ ),
866
+ maxfev=_CURVE_FIT_MAXFEV,
867
+ )
840
868
  Rmax, b, c = popt
841
869
  r_pred = logistic_func(x, Rmax, b, c)
842
870
  ss_res = np.sum((ratio - r_pred) ** 2)
@@ -850,7 +878,8 @@ def _fit_ratio_logistic(x, ratio):
850
878
  return None
851
879
 
852
880
 
853
- def fit_ratio(x, ratio, method='best', domain='cum'):
881
+ def fit_ratio(x: ArrayLike, ratio: ArrayLike,
882
+ method: str = 'best', domain: str = 'cum') -> 'RatioResult':
854
883
  """Fit a ratio model (e.g. GOR, WOR) to data.
855
884
 
856
885
  Parameters
@@ -904,7 +933,7 @@ def fit_ratio(x, ratio, method='best', domain='cum'):
904
933
  return result
905
934
 
906
935
 
907
- def ratio_forecast(result, x):
936
+ def ratio_forecast(result: 'RatioResult', x: ArrayLike) -> np.ndarray:
908
937
  """Evaluate a fitted ratio model at given x values.
909
938
 
910
939
  Parameters
@@ -935,7 +964,9 @@ def ratio_forecast(result, x):
935
964
  return process_output(r, is_list)
936
965
 
937
966
 
938
- def forecast(result, t_end, dt=1.0, q_min=0.0, uptime=1.0, ratios=None):
967
+ def forecast(result: 'DeclineResult', t_end: float, dt: float = 1.0,
968
+ q_min: float = 0.0, uptime: float = 1.0,
969
+ ratios: Optional[dict] = None) -> 'ForecastResult':
939
970
  """Generate a rate and cumulative forecast from a fitted decline model.
940
971
 
941
972
  Parameters