pyrestoolbox 3.1.4__tar.gz → 3.2.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 (122) hide show
  1. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/PKG-INFO +1 -1
  2. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyproject.toml +1 -1
  3. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/brine/_lib_vle_engine.py +6 -3
  4. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/brine/brine.py +15 -17
  5. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/dca/dca.py +47 -2
  6. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/changelist.rst +39 -0
  7. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/layer/layer.py +48 -63
  8. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/nodal/nodal.py +20 -7
  9. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_correlations.py +4 -0
  10. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_separator.py +1 -0
  11. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/recommend/recommend.py +8 -2
  12. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/sensitivity/sensitivity.py +11 -0
  13. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/simtools/simtools.py +44 -99
  14. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/validate/validate.py +21 -5
  15. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/mod.rs +23 -14
  16. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/.github/workflows/build-wheels.yml +0 -0
  17. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/.gitignore +0 -0
  18. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/Cargo.lock +0 -0
  19. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/Cargo.toml +0 -0
  20. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/LICENSE +0 -0
  21. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/MANIFEST.in +0 -0
  22. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/README.rst +0 -0
  23. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/ResToolbox/privacy_policy.md +0 -0
  24. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/benchmark_rust_vs_python.py +0 -0
  25. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/build_pure_python.py +0 -0
  26. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/__init__.py +0 -0
  27. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/_accelerator.py +0 -0
  28. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/brine/__init__.py +0 -0
  29. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/brine/_lib_salting_library.py +0 -0
  30. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/classes/__init__.py +0 -0
  31. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/classes/classes.py +0 -0
  32. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/constants/__init__.py +0 -0
  33. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/constants/constants.py +0 -0
  34. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/dca/__init__.py +0 -0
  35. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/brine.rst +0 -0
  36. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/dca.rst +0 -0
  37. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/examples.ipynb +0 -0
  38. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/gas.rst +0 -0
  39. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/bot.png +0 -0
  40. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/bot_PVTO.png +0 -0
  41. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/bot_img.png +0 -0
  42. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/dry_gas.png +0 -0
  43. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/grid_sat_df.png +0 -0
  44. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/influence.png +0 -0
  45. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/properties_df.png +0 -0
  46. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/sgof.png +0 -0
  47. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/swof.png +0 -0
  48. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/layer.rst +0 -0
  49. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/library.rst +0 -0
  50. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/matbal.rst +0 -0
  51. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/nodal.rst +0 -0
  52. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/nodal_examples.ipynb +0 -0
  53. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/nodal_hydrate_demo.ipynb +0 -0
  54. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/oil.rst +0 -0
  55. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/recommend.rst +0 -0
  56. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/sensitivity.rst +0 -0
  57. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/simtools.rst +0 -0
  58. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/gas/__init__.py +0 -0
  59. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/gas/gas.py +0 -0
  60. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/layer/__init__.py +0 -0
  61. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/library/__init__.py +0 -0
  62. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/library/component_library.xlsx +0 -0
  63. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/library/library.py +0 -0
  64. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/matbal/__init__.py +0 -0
  65. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/matbal/matbal.py +0 -0
  66. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/nodal/__init__.py +0 -0
  67. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/__init__.py +0 -0
  68. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_compressibility.py +0 -0
  69. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_constants.py +0 -0
  70. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_density.py +0 -0
  71. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_harmonize.py +0 -0
  72. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_pvt_class.py +0 -0
  73. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_rate.py +0 -0
  74. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_tables.py +0 -0
  75. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_utils.py +0 -0
  76. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/plyasunov/__init__.py +0 -0
  77. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/plyasunov/iapws_if97.py +0 -0
  78. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/plyasunov/plyasunov_model.py +0 -0
  79. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/plyasunov/water_properties.py +0 -0
  80. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/recommend/__init__.py +0 -0
  81. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/sensitivity/__init__.py +0 -0
  82. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/shared_fns/__init__.py +0 -0
  83. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/shared_fns/shared_fns.py +0 -0
  84. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/simtools/__init__.py +0 -0
  85. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/pyrestoolbox/validate/__init__.py +0 -0
  86. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/setup.cfg +0 -0
  87. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/setup.py +0 -0
  88. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/bessel.rs +0 -0
  89. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/critical_properties/mod.rs +0 -0
  90. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/dca/hyperbolic.rs +0 -0
  91. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/dca/mod.rs +0 -0
  92. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/dca/ransac.rs +0 -0
  93. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/gas_viscosity/mod.rs +0 -0
  94. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/gwr.rs +0 -0
  95. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/lib.rs +0 -0
  96. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/matbal/mod.rs +0 -0
  97. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/matbal/objective.rs +0 -0
  98. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/oil/density.rs +0 -0
  99. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/oil/mod.rs +0 -0
  100. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/pseudopressure.rs +0 -0
  101. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/spycher_pruess/mod.rs +0 -0
  102. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/spycher_pruess/solubility.rs +0 -0
  103. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/alpha.rs +0 -0
  104. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/bip.rs +0 -0
  105. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/components.rs +0 -0
  106. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/eos.rs +0 -0
  107. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/flash.rs +0 -0
  108. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/fugacity.rs +0 -0
  109. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/k_init.rs +0 -0
  110. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vle/rachford_rice.rs +0 -0
  111. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/friction.rs +0 -0
  112. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/holdup_bb.rs +0 -0
  113. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/holdup_gray.rs +0 -0
  114. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/holdup_hb.rs +0 -0
  115. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/holdup_wg.rs +0 -0
  116. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/ift.rs +0 -0
  117. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/mod.rs +0 -0
  118. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/pvt_helpers.rs +0 -0
  119. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/segment_gas.rs +0 -0
  120. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/segment_oil.rs +0 -0
  121. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/vlp/static_column.rs +0 -0
  122. {pyrestoolbox-3.1.4 → pyrestoolbox-3.2.0}/src/zfactor/mod.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrestoolbox
3
- Version: 3.1.4
3
+ Version: 3.2.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.1.4"
7
+ version = "3.2.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"}]
@@ -2100,9 +2100,12 @@ class SWMultiComponentFlash:
2100
2100
  z = np.asarray(z, dtype=float)
2101
2101
  z = z / np.sum(z)
2102
2102
 
2103
- # Rust acceleration path — gamma is always passed explicitly
2104
- # to prevent Rust from using its built-in ks model (which lacks
2105
- # the specialised Dubessy/Akinfiev models for CO2/H2S).
2103
+ # Rust acceleration path — gamma is always passed explicitly.
2104
+ # The Rust entry point trusts the caller-supplied gamma and never
2105
+ # substitutes its own S&W Eq 8 ks fallback. framework='proposed'
2106
+ # specialised ks models (Dubessy CO2, Akinfiev H2S, Li HC,
2107
+ # Mao-Duan N2, Duan-Sun CO2) are applied in self.calc_gamma()
2108
+ # on the Python side.
2106
2109
  if _RUST_AVAILABLE:
2107
2110
  try:
2108
2111
  gamma_arr = np.asarray(gamma, dtype=float) if gamma is not None \
@@ -456,6 +456,7 @@ class CO2_Brine_Mixture():
456
456
  .Rs : CO2 Saturated Brine solution gas ratio (sm3/sm3 or scf/stb), relative to standard conditions
457
457
  .Cf_usat : Brine undersaturated compressibility (1/Bar or 1/psi). The compressibility with constant Rs
458
458
  .Cf_sat : Brine saturated compressibility (1/Bar or 1/psi). The compressibility with reducing Rs under depletion
459
+ .converged: True if the Spycher-Pruess fugacity iteration reached tolerance, False otherwise
459
460
 
460
461
  Usage example for 5000 psia x 275 deg F and 3% NaCl brine:
461
462
  mix = brine.CO2_Brine_Mixture(pres = 5000, temp = 275, ppm = 30000, metric = False)
@@ -523,6 +524,7 @@ class CO2_Brine_Mixture():
523
524
  self.Cf_sat = None # Saturated brine compressibility (1/Bar or 1/psi). Compressibility with changing Rs
524
525
  self.CO2_sat = False # Flag to determine if in P-T range for saturated liquid CO2 K values
525
526
  self.repeat = False # Flag to trigger repeat of calculations depending on whether CO2 is saturated liquid phase
527
+ self.converged = True # Spycher-Pruess fugacity iteration convergence flag
526
528
  #self.EzrokhiDenA = None # Ezrokhi coefficient array for density (to be calculated with ezrokhi() function)
527
529
  #self.EzrokhiVisB = None # Ezrokhi coefficient array for viscosity (to be calculated with ezrokhi() function)
528
530
  self.Rs_STD = None # Dissolved CO2 remaining at standard conditions (sm3/sm3)
@@ -1125,6 +1127,7 @@ class CO2_Brine_Mixture():
1125
1127
  iternum += 1
1126
1128
 
1127
1129
  if err > EPS:
1130
+ self.converged = False
1128
1131
  import warnings
1129
1132
  warnings.warn(
1130
1133
  f"Spycher CO2-brine iteration did not converge in {iternum} iterations "
@@ -1266,11 +1269,14 @@ class CO2_Brine_Mixture():
1266
1269
 
1267
1270
  # -- Correcting brine density for dissolved CO2, JE Garcia, LBNL Report# 49023, Oct 2011, "Density of Aqueous Solutions of CO2"
1268
1271
  def garciaDensity(rhoBRnoCO2, tKel, pBar, ppm, xCO2, MwB, MwG):
1272
+ # Algebraic reformulation of Garcia Eq 18: original form
1273
+ # (1 + mRat*xRat) / (vPhi*xRat/MwB + 1/rhoBRnoCO2) with xRat = xCO2/(1-xCO2)
1274
+ # has a singularity at xCO2 -> 1. Multiplying numerator and denominator by
1275
+ # (1-xCO2)*MwB removes xRat entirely and gives the same result.
1269
1276
  xNotCO2 = 1.0 - xCO2 #--Brine (H20+Salt) Mole Fraction
1270
- xRat = xCO2 / xNotCO2 #--Mole Fraction Ratio, Gas/Brine
1271
1277
  mRat = MwG / MwB #--Mole Weight Ratio , Gas/Brine
1272
1278
  vPhi = partMolVol(tKel) #--Apparent Molar Volume of Dissolved CO2
1273
- return (1.0 + mRat * xRat) / (vPhi * xRat / MwB + 1.0 / rhoBRnoCO2) # --Equation 18 of Garcia paper
1279
+ return MwB * (xNotCO2 + mRat * xCO2) / (vPhi * xCO2 + MwB * xNotCO2 / rhoBRnoCO2)
1274
1280
 
1275
1281
  # Correct CO2 free brine viscosity for dissolved CO2
1276
1282
  # Using approach from "Viscosity Models and Effects of Dissolved CO2", Akand W. Islam and Eric S. Carlson (Jul 2012), Energy Fuels 2012, 26, 8, 5330�5336, https://doi.org/10.1021/ef3006228
@@ -1798,23 +1804,15 @@ class SoreideWhitson:
1798
1804
  vphi_eff += yi * _plyasunov_V_phi(gas_ply, tKel, Mpa)
1799
1805
  mw_eff += yi * _plyasunov_gas_mw(gas_ply)
1800
1806
 
1801
- # Garcia Eq. 18 in g/cm3:
1802
- # rho = (1 + x2*M2/(M1*x1)) / (x2*V_phi/(M1*x1) + 1/rho1)
1807
+ # Garcia Eq. 18 in g/cm3, algebraically reformulated to remove the
1808
+ # x1 -> 0 singularity. Multiplying top/bot of
1809
+ # (1 + x2*M2/(M1*x1)) / (x2*V_phi/(M1*x1) + 1/rho1)
1810
+ # by M1*x1 gives the equivalent, non-singular form below.
1803
1811
  x1 = 1.0 - self.x_total
1804
1812
  M1 = MWWAT
1805
- if x1 < 1e-6:
1806
- # Near-pure gas phase: Garcia mixing rule breaks down
1807
- import warnings
1808
- warnings.warn(
1809
- f"Gas mole fraction x_total={self.x_total:.4f} is too high for "
1810
- "Garcia density mixing rule. Returning unsaturated brine density.",
1811
- RuntimeWarning, stacklevel=2
1812
- )
1813
- rho_gas_brine_gcc = rho_brine_gcc
1814
- else:
1815
- numerator = 1.0 + self.x_total * mw_eff / (M1 * x1)
1816
- denominator = self.x_total * vphi_eff / (M1 * x1) + 1.0 / rho_brine_gcc
1817
- rho_gas_brine_gcc = numerator / denominator
1813
+ numerator = M1 * x1 + self.x_total * mw_eff
1814
+ denominator = self.x_total * vphi_eff + M1 * x1 / rho_brine_gcc
1815
+ rho_gas_brine_gcc = numerator / denominator
1818
1816
  else:
1819
1817
  rho_gas_brine_gcc = rho_brine_gcc
1820
1818
 
@@ -59,6 +59,13 @@ if _RUST_AVAILABLE:
59
59
  from pyrestoolbox import _native as _rust
60
60
 
61
61
 
62
+ def _fmt_array(a, name):
63
+ if a is None:
64
+ return f"{name}=None"
65
+ arr = np.asarray(a)
66
+ return f"{name}=ndarray(shape={arr.shape}, dtype={arr.dtype})"
67
+
68
+
62
69
  @dataclass
63
70
  class DeclineResult:
64
71
  """Result from decline curve fitting.
@@ -97,6 +104,18 @@ class DeclineResult:
97
104
  uptime_mean: Optional[float] = None
98
105
  uptime_history: Optional[np.ndarray] = None
99
106
 
107
+ def __repr__(self):
108
+ scalars = (
109
+ f"method={self.method!r}, qi={self.qi}, di={self.di}, b={self.b}, "
110
+ f"a={self.a}, m={self.m}, r_squared={self.r_squared}, "
111
+ f"uptime_mean={self.uptime_mean}"
112
+ )
113
+ return (
114
+ f"DeclineResult({scalars}, "
115
+ f"{_fmt_array(self.residuals, 'residuals')}, "
116
+ f"{_fmt_array(self.uptime_history, 'uptime_history')})"
117
+ )
118
+
100
119
 
101
120
  @dataclass
102
121
  class ForecastResult:
@@ -122,6 +141,16 @@ class ForecastResult:
122
141
  eur: float
123
142
  secondary: Optional[dict] = None
124
143
 
144
+ def __repr__(self):
145
+ sec = 'None' if self.secondary is None else f"{{{', '.join(self.secondary.keys())}}}"
146
+ return (
147
+ f"ForecastResult("
148
+ f"{_fmt_array(self.t, 't')}, "
149
+ f"{_fmt_array(self.q, 'q')}, "
150
+ f"{_fmt_array(self.Qcum, 'Qcum')}, "
151
+ f"eur={self.eur}, secondary={sec})"
152
+ )
153
+
125
154
 
126
155
  @dataclass
127
156
  class RatioResult:
@@ -152,6 +181,13 @@ class RatioResult:
152
181
  r_squared: float = 0.0
153
182
  residuals: np.ndarray = field(default_factory=lambda: np.array([]))
154
183
 
184
+ def __repr__(self):
185
+ return (
186
+ f"RatioResult(method={self.method!r}, a={self.a}, b={self.b}, "
187
+ f"c={self.c}, domain={self.domain!r}, r_squared={self.r_squared}, "
188
+ f"{_fmt_array(self.residuals, 'residuals')})"
189
+ )
190
+
155
191
 
156
192
  def arps_rate(qi, di, b, t):
157
193
  """Arps decline rate.
@@ -287,10 +323,13 @@ def duong_cum(qi, a, m, t):
287
323
  if np.any(t <= 0):
288
324
  raise ValueError("Time must be positive for Duong model")
289
325
 
290
- # Generate fine time grid for integration
326
+ # Generate fine time grid for integration. Lower bound must be strictly
327
+ # less than ti to give np.linspace an ascending range, otherwise trapezoid
328
+ # integrates over a descending axis and returns negative cumulative.
291
329
  results = np.zeros_like(t, dtype=float)
292
330
  for i, ti in enumerate(t):
293
- t_fine = np.linspace(0.001, ti, max(500, int(ti * 10)))
331
+ lower = min(0.001, ti * 0.001)
332
+ t_fine = np.linspace(lower, ti, max(500, int(ti * 10)))
294
333
  q_fine = qi * t_fine ** (-m) * np.exp(a / (1.0 - m) * (t_fine ** (1.0 - m) - 1.0))
295
334
  results[i] = np.trapezoid(q_fine, t_fine)
296
335
 
@@ -922,6 +961,12 @@ def forecast(result, t_end, dt=1.0, q_min=0.0, uptime=1.0, ratios=None):
922
961
  -------
923
962
  ForecastResult
924
963
  """
964
+ if dt <= 0:
965
+ raise ValueError(f"dt must be positive, got {dt}")
966
+ if t_end <= 0:
967
+ raise ValueError(f"t_end must be positive, got {t_end}")
968
+ if not (0.0 < uptime <= 1.0):
969
+ raise ValueError(f"uptime must be in (0, 1], got {uptime}")
925
970
  t = np.arange(dt, t_end + dt / 2, dt)
926
971
 
927
972
  if result.method == 'duong':
@@ -1,3 +1,42 @@
1
+ Changelist in 3.2.0:
2
+
3
+ - **DCA bug fixes**:
4
+
5
+ - ``dca.duong_cum`` — fixed linspace-inversion bug where cumulative volume integrated over a descending axis for ``t < 0.001`` (small-t inputs) and returned a negative value. Lower bound of the integration is now ``min(0.001, t * 0.001)`` so the grid is always ascending.
6
+ - ``dca.forecast`` — now validates ``dt > 0``, ``t_end > 0`` and ``uptime`` in ``(0, 1]`` at entry with clear ``ValueError`` messages. Previously ``dt = 0`` raised an opaque ``ZeroDivisionError`` and out-of-range ``uptime`` was silently accepted.
7
+
8
+ - **Rachford-Rice solver consolidation**: ``simtools.rr_solver`` is now a thin wrapper that delegates to the canonical ``pyrestoolbox.brine._lib_vle_engine.rr_solver`` (Nielsen & Lia 2022). Removes ~100 lines of duplicated iterative code plus the dead ``ensure_numpy_array`` helper. Inputs validated for length/sum before delegation; behaviour is preserved (``EPS_T=1e-15``, ``max_iter=100``).
9
+
10
+ - **Rust Sechenov fallback guard**: ``src/vle/mod.rs::flash_tp_rust`` no longer silently substitutes its S&W Eq 8 ``ks`` fallback when a caller passes all-ones ``gamma`` with ``salinity > 0``. The caller-supplied ``gamma`` is now always trusted, eliminating any risk that Python ``framework='proposed'`` calls bypass the specialised Dubessy/Akinfiev/Li/Mao-Duan/Duan-Sun ``ks`` models. ``calc_equilibrium_rust`` retains the S&W Eq 8 path but now carries a prominent doc warning. Python path unchanged (Python always passes the correct ``gamma``).
11
+
12
+ - **Convergence flag on ``CO2_Brine_Mixture``**: New ``.converged`` attribute on the class. ``True`` after a successful Spycher-Pruess fugacity iteration, ``False`` when the 100-iteration limit is hit (matching the existing ``RuntimeWarning``). Lets downstream callers detect non-convergence programmatically.
13
+
14
+ - **``sensitivity.tornado`` robustness**: Raises ``ValueError`` if ``base_result`` is not finite (NaN/Inf), and if any ``ranges[param]`` has ``lo > hi``. Previously returned ``nan`` or ``inf`` sensitivities that silently corrupted tornado plots.
15
+
16
+ - **``layer`` module dedup**: Five copies of the EXP/LANG dispatch (B-clamp, flow-fraction evaluation) consolidated into three private helpers (``_clamp_b``, ``_b_max``, ``_flow_fraction_at_x``). No behaviour change.
17
+
18
+ - **``recommend`` module docstrings**: ``sg`` on ``recommend_gas_methods`` and ``well_type`` on ``recommend_vlp_method`` are currently unused by the decision logic. Docstrings now flag them as reserved for future rules. Signatures preserved for backward compatibility.
19
+
20
+ - 701 validation tests (up from 696 in 3.1.5).
21
+
22
+ Changelist in 3.1.5:
23
+
24
+ - **Agent-friendly UX**:
25
+
26
+ - ``validate_methods`` invalid-method errors now list valid options (e.g. ``Invalid zmethod: 'NOSUCH'. Valid options: ['DAK', 'HY', 'WYW', 'BNS', 'BUR']``). New ``validate_choice`` helper used by all ``nodal`` public entry points (``fbhp``, ``outflow_curve``, ``ipr_curve``, ``operating_point``) to validate ``well_type``.
27
+ - ``simtools.zip_check_sim_deck`` and ``simtools.ix_extract_problem_cells`` accept a ``non_interactive=True`` kwarg that raises a ``ValueError`` instead of prompting on ``input()``. Safe for scripts and agents without stdin.
28
+ - ``simtools.make_vfpinj``/``make_vfpprod`` BHP-failure warnings now use ``warnings.warn`` instead of ``print``.
29
+ - ``DeclineResult``, ``ForecastResult``, ``RatioResult`` ``__repr__`` now summarise array fields as ``ndarray(shape=..., dtype=...)`` so printing a result doesn't flood agent transcripts.
30
+ - ``nodal`` unit-validation errors (``Reservoir.__init__``, ``WellSegment.__init__``) echo the user's original value and unit (e.g. ``got -1 m``) rather than the post-conversion internal number.
31
+
32
+ - **Release-blocking numerical fixes**:
33
+
34
+ - **Garcia CO2-brine density**: Algebraic reformulation of Eq 18 (``brine.garciaDensity`` and ``SoreideWhitson._calc_properties``) removes the ``xCO2 → 1`` singularity. Finite rho at ``xCO2 = 1`` equals ``MwG / vPhi``. Mathematically identical to the old formula for ``xCO2 < 1`` (regression: 1e-12).
35
+ - **``oil.Rs_velarde`` at atmospheric ``pb``**: Now returns ``0.0`` when ``pb <= psc`` instead of emitting NaN from a ``0/0`` division.
36
+ - **``oil.sg_evolved_gas`` silent NaN**: Now calls ``validate_pe_inputs`` at entry; zero pressure or zero ``sg_sp`` raises ``ValueError`` instead of returning NaN.
37
+
38
+ - 696 validation tests (up from 691 in 3.1.4).
39
+
1
40
  Changelist in 3.1.4:
2
41
 
3
42
  - **Tier 4 brine improvements**: Adaptive VLE damping in ``flash_tp`` and ``calc_water_content_with_kij`` (replaces fixed 0.7/0.9 factors), ``V2_inf`` cached via ``functools.lru_cache(256)`` in Plyasunov model, ``build_kij_matrix`` cached per ``(T_K, mode)`` on VLE engine instance. Rust VLE flash updated with matching adaptive damping. All three brine models (``CH4_Brine``, ``CO2_Brine_Mixture``, ``SoreideWhitson``) now accept ``p``/``degf``/``wt`` and ``pres``/``temp``/``ppm`` parameter aliases.
@@ -41,6 +41,35 @@ import numpy.typing as npt
41
41
 
42
42
  from pyrestoolbox.shared_fns import bisect_solve
43
43
 
44
+
45
+ _B_MIN = 1e-6
46
+ _B_MAX_EXP = 709
47
+ _B_MAX_LANG = 25000
48
+
49
+
50
+ def _b_max(method: str) -> float:
51
+ """Upper bookend for B in bisection for the given Lorenz method."""
52
+ return _B_MAX_LANG if method == "LANG" else _B_MAX_EXP
53
+
54
+
55
+ def _clamp_b(B: float, method: str) -> float:
56
+ """Clamp B to [_B_MIN, _b_max(method)]."""
57
+ return min(max(B, _B_MIN), _b_max(method))
58
+
59
+
60
+ def _flow_fraction_at_x(B: float, x: float, method: str) -> float:
61
+ """Flow fraction from best phi_h fraction x, given B and method.
62
+
63
+ EXP: (1 - exp(-B*x)) / (1 - exp(-B))
64
+ LANG: (VL * x) / (PL + x) with PL = 1/B, VL = PL + 1
65
+ """
66
+ if method == "LANG":
67
+ PL = 1 / B
68
+ VL = PL + 1
69
+ return (VL * x) / (PL + x)
70
+ return (1 - np.exp(-B * x)) / (1 - np.exp(-B))
71
+
72
+
44
73
  def lorenz2b(lorenz: float, lrnz_method: str = "EXP") -> float:
45
74
  """ Returns B-factor that characterizes the Lorenz function
46
75
  Lorenz: Lorenz coefficient (0-1)
@@ -63,37 +92,22 @@ def lorenz2b(lorenz: float, lrnz_method: str = "EXP") -> float:
63
92
  return 0.0 # Homogeneous — no heterogeneity
64
93
 
65
94
  if lorenz < 0.000333:
66
- B = 2 / 1000
67
- if method == "LANG":
68
- B = 1 / 1000
69
- return B
95
+ return 1 / 1000 if method == "LANG" else 2 / 1000
70
96
  if lorenz > 0.997179125528914:
71
- B = 709
72
- if method == "LANG":
73
- B = 25000
74
- return B
97
+ return _b_max(method)
75
98
 
76
- # Set bookends for B
77
- hi = 709
78
- if method == "LANG":
79
- hi = 25000
80
- lo = 0.000001
99
+ hi = _b_max(method)
100
+ lo = _B_MIN
81
101
  args = (lorenz, method)
82
102
 
83
103
  def LorenzErr(args, B):
84
104
  lorenz, method = args
85
- B = max(B, 0.000001)
86
- if method == "EXP":
87
- B = min(B, 709)
88
- err = 2 * ((1 / (np.exp(B) - 1)) - (1 / B)) + 1 - lorenz
89
- else:
90
- B = min(B, 25000)
105
+ B = _clamp_b(B, method)
106
+ if method == "LANG":
91
107
  PL = 1 / B
92
108
  VL = PL + 1
93
- err = (
94
- VL - PL * VL * np.log(VL) + PL * VL * np.log(PL) - 0.5
95
- ) * 2 - lorenz
96
- return err
109
+ return (VL - PL * VL * np.log(VL) + PL * VL * np.log(PL) - 0.5) * 2 - lorenz
110
+ return 2 * ((1 / (np.exp(B) - 1)) - (1 / B)) + 1 - lorenz
97
111
 
98
112
  rtol = 0.0000001
99
113
  return bisect_solve(args, LorenzErr, lo, hi, rtol)
@@ -113,16 +127,12 @@ def lorenzfromb(B: float, lrnz_method: str = "EXP") -> float:
113
127
  method = lrnz_method.upper()
114
128
  if B <= 0:
115
129
  return 0.0 # B=0 means homogeneous (Lc=0)
116
- B = max(B, 0.000001)
130
+ B = _clamp_b(B, method)
117
131
  if method == "LANG":
118
- B = min(B, 25000)
119
132
  PL = 1 / B
120
133
  VL = PL + 1
121
- L = (VL - PL * VL * np.log(VL) + PL * VL * np.log(PL) - 0.5) * 2
122
- else:
123
- B = min(B, 709)
124
- L = 2 * (1 / (np.exp(B) - 1) - (1 / B)) + 1
125
- return L
134
+ return (VL - PL * VL * np.log(VL) + PL * VL * np.log(PL) - 0.5) * 2
135
+ return 2 * (1 / (np.exp(B) - 1) - (1 / B)) + 1
126
136
 
127
137
 
128
138
  def lorenz_from_flow_fraction(
@@ -155,23 +165,15 @@ def lorenz_from_flow_fraction(
155
165
  return lorenzfromb(B, method)
156
166
 
157
167
  # Set bookends and first guess of B
158
- hi = 709
159
- lo = 0.000001
168
+ hi = _B_MAX_EXP
169
+ lo = _B_MIN
160
170
  args = (kh_frac, phih_frac, method)
161
171
 
162
172
  def BErr(args, B):
163
173
  kh_frac, phih_frac, method = args
164
174
  method = method.upper()
165
- B = max(B, 0.000001)
166
- if method == "EXP":
167
- B = min(B, 709)
168
- err = (1 - np.exp(-B * phih_frac)) / (1 - np.exp(-B)) - kh_frac
169
- else:
170
- B = min(B, 25000)
171
- PL = 1 / B
172
- VL = PL + 1
173
- err = (VL * phih_frac) / (PL + phih_frac) - kh_frac
174
- return err
175
+ B = _clamp_b(B, method)
176
+ return _flow_fraction_at_x(B, phih_frac, method) - kh_frac
175
177
 
176
178
  rtol = 0.0000001
177
179
  B = bisect_solve(args, BErr, lo, hi, rtol)
@@ -203,16 +205,8 @@ def lorenz_2_flow_frac(
203
205
  if B < 0: # Need to calculate B
204
206
  B = lorenz2b(lorenz=lorenz, lrnz_method=lrnz_method)
205
207
 
206
- B = max(B, 0.000001)
207
- if method == "EXP":
208
- B = min(B, 709)
209
- fraction = (1 - np.exp(-B * phih_frac)) / (1 - np.exp(-B))
210
- else:
211
- B = min(B, 25000)
212
- PL = 1 / B
213
- VL = PL + 1
214
- fraction = (VL * phih_frac) / (PL + phih_frac)
215
- return fraction
208
+ B = _clamp_b(B, method)
209
+ return _flow_fraction_at_x(B, phih_frac, method)
216
210
 
217
211
 
218
212
  def lorenz_2_layers(
@@ -255,11 +249,7 @@ def lorenz_2_layers(
255
249
  if B < 0: # Need to calculate B
256
250
  B = lorenz2b(lorenz=lorenz, lrnz_method=lrnz_method)
257
251
 
258
- B = max(B, 0.000001)
259
- if method == "EXP":
260
- B = min(B, 709)
261
- else:
262
- B = min(B, 25000)
252
+ B = _clamp_b(B, method)
263
253
 
264
254
  user_layers = False
265
255
  if len(phi_h_fracs) > 1:
@@ -280,12 +270,7 @@ def lorenz_2_layers(
280
270
  sumkh = []
281
271
 
282
272
  for layer in phih:
283
- if method == "EXP":
284
- sumkh.append((1 - np.exp(-B * layer)) / (1 - np.exp(-B)))
285
- else:
286
- PL = 1 / B
287
- VL = PL + 1
288
- sumkh.append((VL * layer) / (PL + layer))
273
+ sumkh.append(_flow_fraction_at_x(B, layer, method))
289
274
 
290
275
  kh = (
291
276
  np.array([sumkh[i] - sumkh[i - 1] for i in range(1, len(sumkh))])
@@ -47,7 +47,7 @@ import warnings
47
47
  import numpy as np
48
48
 
49
49
  from pyrestoolbox.classes import vlp_method, class_dic
50
- from pyrestoolbox.validate import validate_methods
50
+ from pyrestoolbox.validate import validate_methods, validate_choice
51
51
  from pyrestoolbox.shared_fns import bisect_solve, validate_pe_inputs
52
52
  from pyrestoolbox.constants import (BAR_TO_PSI, PSI_TO_BAR, degc_to_degf, degf_to_degc,
53
53
  M_TO_FT, FT_TO_M, MM_TO_IN, IN_TO_MM,
@@ -306,16 +306,19 @@ class WellSegment:
306
306
  def __init__(self, md, id, deviation=0, roughness=None, metric=False):
307
307
  if roughness is None:
308
308
  roughness = 0.01524 if metric else 0.0006 # 0.0006 in = 0.01524 mm
309
+ _md_in, _id_in, _rough_in = md, id, roughness
310
+ _unit_len = 'm' if metric else 'ft'
311
+ _unit_dia = 'mm' if metric else 'in'
309
312
  if metric:
310
313
  md = md * M_TO_FT
311
314
  id = id * MM_TO_IN
312
315
  roughness = roughness * MM_TO_IN
313
316
  if md <= 0:
314
- raise ValueError(f"Measured depth md must be positive, got {md}")
317
+ raise ValueError(f"Measured depth md must be positive, got {_md_in} {_unit_len}")
315
318
  if id <= 0:
316
- raise ValueError(f"Internal diameter id must be positive, got {id}")
319
+ raise ValueError(f"Internal diameter id must be positive, got {_id_in} {_unit_dia}")
317
320
  if roughness < 0:
318
- raise ValueError(f"Roughness must be non-negative, got {roughness}")
321
+ raise ValueError(f"Roughness must be non-negative, got {_rough_in} {_unit_dia}")
319
322
  if not (0 <= deviation <= 90):
320
323
  raise ValueError(f"Deviation must be between 0 and 90 degrees, got {deviation}")
321
324
  self.md = md # stored in ft
@@ -573,6 +576,9 @@ class Reservoir:
573
576
  metric: If True, inputs in metric units (barsa, degC, m, day/sm3). Default False.
574
577
  """
575
578
  def __init__(self, pr, degf, k, h, re, rw, S=0, D=0, metric=False):
579
+ # Preserve original user-facing values for error messages
580
+ _pr_in, _degf_in, _h_in, _re_in, _rw_in = pr, degf, h, re, rw
581
+ _unit_len = 'm' if metric else 'ft'
576
582
  if metric:
577
583
  pr = pr * BAR_TO_PSI
578
584
  degf = degc_to_degf(degf)
@@ -585,11 +591,14 @@ class Reservoir:
585
591
  if k <= 0:
586
592
  raise ValueError(f"Permeability k must be positive, got {k}")
587
593
  if h <= 0:
588
- raise ValueError(f"Net pay thickness h must be positive, got {h}")
594
+ raise ValueError(f"Net pay thickness h must be positive, got {_h_in} {_unit_len}")
589
595
  if rw <= 0:
590
- raise ValueError(f"Wellbore radius rw must be positive, got {rw}")
596
+ raise ValueError(f"Wellbore radius rw must be positive, got {_rw_in} {_unit_len}")
591
597
  if re <= rw:
592
- raise ValueError(f"Drainage radius re ({re}) must be greater than wellbore radius rw ({rw})")
598
+ raise ValueError(
599
+ f"Drainage radius re ({_re_in} {_unit_len}) must be greater than "
600
+ f"wellbore radius rw ({_rw_in} {_unit_len})"
601
+ )
593
602
  self.pr = pr # stored in psia
594
603
  self.degf = degf # stored in degF
595
604
  self.k = k # mD (same in both systems)
@@ -1705,6 +1714,7 @@ def fbhp(thp, completion, vlpmethod='WG', well_type='gas',
1705
1714
  rsb = rsb * SM3_PER_SM3_TO_SCF_PER_STB
1706
1715
 
1707
1716
  validate_pe_inputs(p=thp)
1717
+ validate_choice(well_type, ('gas', 'oil'), 'well_type')
1708
1718
  vlpmethod = validate_methods(["vlpmethod"], [vlpmethod])
1709
1719
 
1710
1720
  # Extract oil PVT parameters if provided (already in oilfield units from OilPVT)
@@ -1791,6 +1801,7 @@ def outflow_curve(thp, completion, vlpmethod='WG', well_type='gas',
1791
1801
  'rates': list of flow rates (MMscf/d for gas, STB/d for oil; sm3/d if metric)
1792
1802
  'bhp': list of flowing BHP values (psia; barsa if metric) at each rate
1793
1803
  """
1804
+ validate_choice(well_type, ('gas', 'oil'), 'well_type')
1794
1805
  # Convert metric inputs to oilfield at the boundary
1795
1806
  if metric:
1796
1807
  thp = thp * BAR_TO_PSI
@@ -1877,6 +1888,7 @@ def ipr_curve(reservoir, well_type='gas', gas_pvt=None, oil_pvt=None,
1877
1888
  gsg: Gas specific gravity. Used if gas_pvt not provided
1878
1889
  metric: If True, inputs/outputs in Eclipse METRIC units. Default False.
1879
1890
  """
1891
+ validate_choice(well_type, ('gas', 'oil', 'water'), 'well_type')
1880
1892
  if min_pwf is None:
1881
1893
  min_pwf = 1.01325 if metric else 14.7
1882
1894
  if metric:
@@ -1991,6 +2003,7 @@ def operating_point(thp, completion, reservoir,
1991
2003
  vlp: VLP outflow curve dict
1992
2004
  ipr: IPR inflow curve dict
1993
2005
  """
2006
+ validate_choice(well_type, ('gas', 'oil'), 'well_type')
1994
2007
  # Convert metric inputs to oilfield at the boundary
1995
2008
  if metric:
1996
2009
  thp = thp * BAR_TO_PSI
@@ -324,6 +324,10 @@ def oil_rs(
324
324
  raise ValueError(
325
325
  "Missing one of the required inputs: sg_sp, api, rsb, for the Velarde, Blasingame & McCain Rs calculation"
326
326
  )
327
+ # Degenerate: pb at (or below) atmospheric means no dissolved gas can evolve.
328
+ # Skip the correlation; the 0/0 in pr would otherwise return NaN silently.
329
+ if pb - psc <= 0:
330
+ return 0.0
327
331
  xs = [_VEL_RS_A, _VEL_RS_B, _VEL_RS_C]
328
332
  a = [
329
333
  x[0]
@@ -19,6 +19,7 @@ def sg_evolved_gas(
19
19
  api: Stock tank oil density (API)
20
20
  sg_sp: Specific gravity of separator gas (relative to air)
21
21
  """
22
+ validate_pe_inputs(p=p, degf=degf, sg=sg_sp)
22
23
 
23
24
  if (
24
25
  p > _SGEVOL_THRESHOLD
@@ -75,7 +75,9 @@ def recommend_gas_methods(sg: float = 0.65, co2: float = 0, h2s: float = 0,
75
75
  Parameters
76
76
  ----------
77
77
  sg : float
78
- Gas specific gravity (default 0.65).
78
+ Gas specific gravity (default 0.65). Currently unused by the decision
79
+ logic — accepted for API consistency and reserved for future rules
80
+ (e.g. heavy gas / condensate-laden streams). Breaking removal deferred.
79
81
  co2 : float
80
82
  CO2 mole fraction (default 0).
81
83
  h2s : float
@@ -90,6 +92,7 @@ def recommend_gas_methods(sg: float = 0.65, co2: float = 0, h2s: float = 0,
90
92
  dict of str to MethodRecommendation
91
93
  Keys: 'zmethod', 'cmethod'.
92
94
  """
95
+ _ = sg # reserved, see docstring
93
96
  inerts = co2 + h2s + n2 + h2
94
97
  recs = {}
95
98
 
@@ -204,13 +207,16 @@ def recommend_vlp_method(deviation: float = 0,
204
207
  deviation : float
205
208
  Maximum wellbore deviation from vertical in degrees (default 0).
206
209
  well_type : str
207
- 'gas' or 'oil' (default 'gas').
210
+ 'gas' or 'oil' (default 'gas'). Currently unused by the decision logic
211
+ — accepted for API consistency and reserved for future fluid-specific
212
+ recommendations. Breaking removal deferred.
208
213
 
209
214
  Returns
210
215
  -------
211
216
  dict of str to MethodRecommendation
212
217
  Key: 'vlp_method'.
213
218
  """
219
+ _ = well_type # reserved, see docstring
214
220
  recs = {}
215
221
 
216
222
  if deviation <= 30:
@@ -39,6 +39,7 @@ __all__ = [
39
39
  'SweepResult', 'TornadoEntry', 'TornadoResult',
40
40
  ]
41
41
 
42
+ import math
42
43
  from dataclasses import dataclass, field
43
44
  from typing import Callable, Dict, Any, List, Optional
44
45
 
@@ -172,11 +173,21 @@ def tornado(func: Callable, base_kwargs: Dict[str, Any],
172
173
  base_raw = func(**base_kwargs)
173
174
  base_val = _extract_result(base_raw, result_key)
174
175
  base_result = float(base_val)
176
+ if not math.isfinite(base_result):
177
+ raise ValueError(
178
+ f"base_result is not finite ({base_result}); "
179
+ f"tornado sensitivities would be undefined. "
180
+ f"Check base_kwargs and result_key."
181
+ )
175
182
 
176
183
  entries = []
177
184
  for param, (lo, hi) in ranges.items():
178
185
  if param not in base_kwargs:
179
186
  raise ValueError(f"Parameter '{param}' not found in base_kwargs")
187
+ if lo > hi:
188
+ raise ValueError(
189
+ f"ranges['{param}'] = ({lo}, {hi}): low must be <= high"
190
+ )
180
191
 
181
192
  kwargs_lo = dict(base_kwargs)
182
193
  kwargs_lo[param] = lo