pyrestoolbox 3.1.5__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.5 → pyrestoolbox-3.2.0}/PKG-INFO +1 -1
  2. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyproject.toml +1 -1
  3. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/brine/_lib_vle_engine.py +6 -3
  4. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/brine/brine.py +3 -0
  5. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/dca/dca.py +11 -2
  6. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/changelist.rst +21 -0
  7. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/layer/layer.py +48 -63
  8. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/recommend/recommend.py +8 -2
  9. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/sensitivity/sensitivity.py +11 -0
  10. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/simtools/simtools.py +6 -80
  11. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/mod.rs +23 -14
  12. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/.github/workflows/build-wheels.yml +0 -0
  13. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/.gitignore +0 -0
  14. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/Cargo.lock +0 -0
  15. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/Cargo.toml +0 -0
  16. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/LICENSE +0 -0
  17. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/MANIFEST.in +0 -0
  18. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/README.rst +0 -0
  19. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/ResToolbox/privacy_policy.md +0 -0
  20. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/benchmark_rust_vs_python.py +0 -0
  21. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/build_pure_python.py +0 -0
  22. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/__init__.py +0 -0
  23. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/_accelerator.py +0 -0
  24. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/brine/__init__.py +0 -0
  25. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/brine/_lib_salting_library.py +0 -0
  26. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/classes/__init__.py +0 -0
  27. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/classes/classes.py +0 -0
  28. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/constants/__init__.py +0 -0
  29. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/constants/constants.py +0 -0
  30. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/dca/__init__.py +0 -0
  31. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/brine.rst +0 -0
  32. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/dca.rst +0 -0
  33. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/examples.ipynb +0 -0
  34. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/gas.rst +0 -0
  35. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/bot.png +0 -0
  36. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/bot_PVTO.png +0 -0
  37. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/bot_img.png +0 -0
  38. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/dry_gas.png +0 -0
  39. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/grid_sat_df.png +0 -0
  40. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/influence.png +0 -0
  41. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/properties_df.png +0 -0
  42. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/sgof.png +0 -0
  43. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/img/swof.png +0 -0
  44. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/layer.rst +0 -0
  45. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/library.rst +0 -0
  46. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/matbal.rst +0 -0
  47. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/nodal.rst +0 -0
  48. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/nodal_examples.ipynb +0 -0
  49. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/nodal_hydrate_demo.ipynb +0 -0
  50. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/oil.rst +0 -0
  51. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/recommend.rst +0 -0
  52. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/sensitivity.rst +0 -0
  53. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/docs/simtools.rst +0 -0
  54. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/gas/__init__.py +0 -0
  55. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/gas/gas.py +0 -0
  56. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/layer/__init__.py +0 -0
  57. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/library/__init__.py +0 -0
  58. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/library/component_library.xlsx +0 -0
  59. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/library/library.py +0 -0
  60. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/matbal/__init__.py +0 -0
  61. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/matbal/matbal.py +0 -0
  62. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/nodal/__init__.py +0 -0
  63. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/nodal/nodal.py +0 -0
  64. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/__init__.py +0 -0
  65. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_compressibility.py +0 -0
  66. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_constants.py +0 -0
  67. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_correlations.py +0 -0
  68. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_density.py +0 -0
  69. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_harmonize.py +0 -0
  70. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_pvt_class.py +0 -0
  71. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_rate.py +0 -0
  72. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_separator.py +0 -0
  73. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_tables.py +0 -0
  74. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/oil/_utils.py +0 -0
  75. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/plyasunov/__init__.py +0 -0
  76. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/plyasunov/iapws_if97.py +0 -0
  77. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/plyasunov/plyasunov_model.py +0 -0
  78. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/plyasunov/water_properties.py +0 -0
  79. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/recommend/__init__.py +0 -0
  80. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/sensitivity/__init__.py +0 -0
  81. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/shared_fns/__init__.py +0 -0
  82. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/shared_fns/shared_fns.py +0 -0
  83. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/simtools/__init__.py +0 -0
  84. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/validate/__init__.py +0 -0
  85. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/pyrestoolbox/validate/validate.py +0 -0
  86. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/setup.cfg +0 -0
  87. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/setup.py +0 -0
  88. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/bessel.rs +0 -0
  89. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/critical_properties/mod.rs +0 -0
  90. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/dca/hyperbolic.rs +0 -0
  91. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/dca/mod.rs +0 -0
  92. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/dca/ransac.rs +0 -0
  93. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/gas_viscosity/mod.rs +0 -0
  94. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/gwr.rs +0 -0
  95. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/lib.rs +0 -0
  96. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/matbal/mod.rs +0 -0
  97. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/matbal/objective.rs +0 -0
  98. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/oil/density.rs +0 -0
  99. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/oil/mod.rs +0 -0
  100. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/pseudopressure.rs +0 -0
  101. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/spycher_pruess/mod.rs +0 -0
  102. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/spycher_pruess/solubility.rs +0 -0
  103. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/alpha.rs +0 -0
  104. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/bip.rs +0 -0
  105. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/components.rs +0 -0
  106. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/eos.rs +0 -0
  107. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/flash.rs +0 -0
  108. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/fugacity.rs +0 -0
  109. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/k_init.rs +0 -0
  110. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vle/rachford_rice.rs +0 -0
  111. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/friction.rs +0 -0
  112. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/holdup_bb.rs +0 -0
  113. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/holdup_gray.rs +0 -0
  114. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/holdup_hb.rs +0 -0
  115. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/holdup_wg.rs +0 -0
  116. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/ift.rs +0 -0
  117. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/mod.rs +0 -0
  118. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/pvt_helpers.rs +0 -0
  119. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/segment_gas.rs +0 -0
  120. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/segment_oil.rs +0 -0
  121. {pyrestoolbox-3.1.5 → pyrestoolbox-3.2.0}/src/vlp/static_column.rs +0 -0
  122. {pyrestoolbox-3.1.5 → 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.5
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.5"
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 "
@@ -323,10 +323,13 @@ def duong_cum(qi, a, m, t):
323
323
  if np.any(t <= 0):
324
324
  raise ValueError("Time must be positive for Duong model")
325
325
 
326
- # 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.
327
329
  results = np.zeros_like(t, dtype=float)
328
330
  for i, ti in enumerate(t):
329
- 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)))
330
333
  q_fine = qi * t_fine ** (-m) * np.exp(a / (1.0 - m) * (t_fine ** (1.0 - m) - 1.0))
331
334
  results[i] = np.trapezoid(q_fine, t_fine)
332
335
 
@@ -958,6 +961,12 @@ def forecast(result, t_end, dt=1.0, q_min=0.0, uptime=1.0, ratios=None):
958
961
  -------
959
962
  ForecastResult
960
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}")
961
970
  t = np.arange(dt, t_end + dt / 2, dt)
962
971
 
963
972
  if result.method == 'duong':
@@ -1,3 +1,24 @@
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
+
1
22
  Changelist in 3.1.5:
2
23
 
3
24
  - **Agent-friendly UX**:
@@ -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))])
@@ -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
@@ -1025,19 +1025,11 @@ def zip_check_sim_deck(files2scrape = [], tozip = True, console_summary = True,
1025
1025
  else:
1026
1026
  return
1027
1027
 
1028
- def ensure_numpy_array(data):
1029
- if isinstance(data, list):
1030
- return np.array(data)
1031
- elif isinstance(data, np.ndarray):
1032
- return data
1033
- else:
1034
- raise ValueError("Input is neither a list nor a numpy array.")
1035
-
1036
1028
  def rr_solver(
1037
1029
  zi: Union[npt.ArrayLike, list], ki: Union[npt.ArrayLike, list]
1038
1030
  ) -> Tuple[int, np.ndarray, np.ndarray, float, float]:
1039
1031
  """
1040
- Solves for the root of the Rachford-Rice equation using a method that
1032
+ Solves for the root of the Rachford-Rice equation using a method that
1041
1033
  gracefully handles catastrophic roundoff errors.
1042
1034
  The method is outlined in 2022 'Fluid Phase Equilibria' paper by M. Nielsen & H. Lia
1043
1035
 
@@ -1052,8 +1044,10 @@ def rr_solver(
1052
1044
  V: Vapor molar fraction (float)
1053
1045
  L: Liquid molar fraction (float)
1054
1046
  """
1055
- zi = ensure_numpy_array(zi)
1056
- ki = ensure_numpy_array(ki)
1047
+ from pyrestoolbox.brine._lib_vle_engine import rr_solver as _brine_rr_solver
1048
+
1049
+ zi = np.asarray(zi, dtype=float)
1050
+ ki = np.asarray(ki, dtype=float)
1057
1051
 
1058
1052
  if len(zi) == 0 or len(ki) == 0:
1059
1053
  raise ValueError("zi and ki arrays must not be empty")
@@ -1063,76 +1057,8 @@ def rr_solver(
1063
1057
  zi_sum = np.sum(zi)
1064
1058
  if zi_sum <= 0:
1065
1059
  raise ValueError("Sum of zi must be positive")
1066
- zi = zi / zi_sum # Normalize feed compositions
1067
-
1068
- def rr(V: float) -> float:
1069
- return np.dot(zi, (ki - 1) / (1 + V * (ki - 1)))
1070
-
1071
- def h(b: float) -> float: # Eq 12b
1072
- return np.sum(zi*b/(1+b*(phi_min - ci)))
1073
-
1074
- def dh(b: float) -> float: # Eq 16b
1075
- return np.sum(zi/(1 + b*(phi_min - ci)) ** 2)
1076
-
1077
- N_it = 0
1078
-
1079
- # Assess if solution nearest vapor or liquid solutions
1080
- # and appropriately assign pseudo phase and ki's
1081
- near_vapor = False
1082
- if rr(0.5) > 0:
1083
- near_vapor = True
1084
-
1085
- ki_hat = ki
1086
- if near_vapor:
1087
- ki_hat = 1/ki
1088
-
1089
- ci = np.where(np.abs(1 - ki_hat) > 1e-10, 1/(1-ki_hat), 0.0) # Eq 10
1090
-
1091
- # Will use the transformed solution in terms of b (Eq 14b)
1092
- phi_max = min(1/(1-min(ki_hat)), 0.5) # Eq 11a
1093
- phi_min = 1/(1-max(ki_hat)) # Eq 11b with upper 0.5 limit given we have chosen this phase
1094
- b_min = 1/(phi_max - phi_min) # Eq 15
1095
- b_max = np.inf
1096
-
1097
- b = 1/(0.25 - phi_min)
1098
- N_it = 0
1099
- h_b = np.inf
1100
-
1101
- while abs(h_b) > EPS_T:
1102
- N_it += 1
1103
1060
 
1104
- h_b = h(b)
1105
- dh_b = dh(b)
1106
-
1107
- if h_b > 0:
1108
- b_max = b
1109
- else:
1110
- b_min = b
1111
-
1112
- b = b - h_b / dh_b
1113
-
1114
- if b < b_min or b > b_max:
1115
- b = (b_min + b_max) / 2
1116
-
1117
- if N_it > MAX_ITR:
1118
- break
1119
-
1120
- ui = -zi*ci*b/(1+b*(phi_min-ci)) # Eq 27b
1121
- phi = (1+b*phi_min)/b # Rearranged Eq 14b
1122
-
1123
- if near_vapor:
1124
- L = phi
1125
- V = 1 - L
1126
- yi = ui
1127
- xi = ki_hat * ui # Eq 28
1128
-
1129
- else:
1130
- V = phi
1131
- L = 1 - V
1132
- xi = ui
1133
- yi = ki_hat * ui # Eq 28
1134
-
1135
- return N_it, yi, xi, V, L
1061
+ return _brine_rr_solver(zi, ki, tol=EPS_T, max_iter=MAX_ITR)
1136
1062
 
1137
1063
 
1138
1064
  # =============================================================================
@@ -57,10 +57,14 @@ fn resolve_comp_names(comp_names: &[String]) -> PyResult<Vec<usize>> {
57
57
  /// * `p_pa` - Pressure in Pascal
58
58
  /// * `z` - Feed composition (will be normalized)
59
59
  /// * `comp_names` - Component names (e.g., ["H2O", "CH4", "CO2"])
60
- /// * `salinity` - NaCl molality (mol/kg water)
60
+ /// * `salinity` - NaCl molality (mol/kg water). Informational only; the caller
61
+ /// is responsible for baking salinity into `gamma`.
61
62
  /// * `mode` - "AQ" for aqueous phase BIPs, "NA" for non-aqueous
62
- /// * `gamma` - Activity coefficient array (same length as z).
63
- /// Use all-ones for freshwater or when salinity is handled externally.
63
+ /// * `gamma` - Activity coefficient array (same length as z). Must be computed
64
+ /// by the caller. For framework='proposed', specialized ks models
65
+ /// (Dubessy/Akinfiev/Li/Mao-Duan/Duan-Sun) live on the Python side
66
+ /// — this Rust entry point must not substitute its own S&W Eq 8
67
+ /// fallback silently. Use `1.0` for gases in freshwater.
64
68
  ///
65
69
  /// # Returns
66
70
  /// Tuple (V, x_vec, y_vec) where V is vapor fraction, x is liquid comp, y is vapor comp.
@@ -74,6 +78,10 @@ pub fn flash_tp_rust(
74
78
  mode: String,
75
79
  gamma: Vec<f64>,
76
80
  ) -> PyResult<(f64, Vec<f64>, Vec<f64>)> {
81
+ // Salinity arg is retained for signature stability / future diagnostics;
82
+ // currently unused because gamma is always caller-supplied.
83
+ let _ = salinity;
84
+
77
85
  // Validate inputs
78
86
  if !t_k.is_finite() || t_k <= 0.0 {
79
87
  return Err(PyValueError::new_err("Temperature must be positive and finite"));
@@ -104,23 +112,13 @@ pub fn flash_tp_rust(
104
112
 
105
113
  let comp_indices = resolve_comp_names(&comp_names)?;
106
114
 
107
- // If salinity > 0 and mode is AQ and gamma is all-ones, compute gamma internally
108
- let gamma_eff = if salinity > 0.0
109
- && mode_aq
110
- && gamma.iter().all(|&g| (g - 1.0).abs() < 1e-15)
111
- {
112
- flash::calc_gamma(&comp_indices, t_k, salinity)
113
- } else {
114
- gamma
115
- };
116
-
117
115
  let (v, x, y, _converged) = flash::flash_tp(
118
116
  t_k,
119
117
  p_pa,
120
118
  &z,
121
119
  &comp_indices,
122
120
  mode_aq,
123
- &gamma_eff,
121
+ &gamma,
124
122
  200,
125
123
  1e-10,
126
124
  );
@@ -134,6 +132,17 @@ pub fn flash_tp_rust(
134
132
  /// Flash 2 (kij_NA) -> non-aqueous phase y
135
133
  /// True K-values: K_i = y_i(Flash 2) / x_i(Flash 1)
136
134
  ///
135
+ /// # Warning
136
+ /// This entry point computes Sechenov activity coefficients internally using
137
+ /// **S&W Equation 8 only**. It does NOT route CO2/H2S through the specialized
138
+ /// Dubessy/Akinfiev/Li/Mao-Duan/Duan-Sun models used by the Python
139
+ /// framework='proposed' path. Callers who need the specialized ks models must
140
+ /// compute gamma Python-side and call `flash_tp_rust` twice (AQ then NA) with
141
+ /// the pre-computed gamma, rather than using this convenience entry point.
142
+ /// Not currently called from Python `SWFlashEngine` — Python computes gamma
143
+ /// via `self.calc_gamma(...)` with the correct framework dispatch and calls
144
+ /// `flash_tp_rust` directly.
145
+ ///
137
146
  /// # Arguments
138
147
  /// * `t_k` - Temperature in Kelvin
139
148
  /// * `p_pa` - Pressure in Pascal
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes