pyrestoolbox 3.3.0__tar.gz → 3.4.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.3.0 → pyrestoolbox-3.4.0}/PKG-INFO +1 -1
  2. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyproject.toml +1 -1
  3. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/brine/brine.py +43 -7
  4. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/dca/dca.py +124 -93
  5. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/brine.rst +13 -7
  6. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/changelist.rst +61 -0
  7. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/examples.ipynb +16 -19
  8. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/nodal.rst +55 -7
  9. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/oil.rst +7 -4
  10. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/nodal/nodal.py +170 -47
  11. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/__init__.py +1 -3
  12. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_compressibility.py +4 -6
  13. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_constants.py +18 -0
  14. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_correlations.py +1 -1
  15. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_density.py +1 -1
  16. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_harmonize.py +1 -1
  17. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_pvt_class.py +17 -13
  18. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_rate.py +4 -2
  19. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_separator.py +10 -15
  20. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_tables.py +1 -3
  21. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/simtools/simtools.py +4 -4
  22. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/holdup_hb.rs +9 -5
  23. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/segment_gas.rs +1 -1
  24. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/segment_oil.rs +30 -5
  25. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/.github/workflows/build-wheels.yml +0 -0
  26. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/.gitignore +0 -0
  27. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/Cargo.lock +0 -0
  28. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/Cargo.toml +0 -0
  29. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/LICENSE +0 -0
  30. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/MANIFEST.in +0 -0
  31. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/README.rst +0 -0
  32. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/ResToolbox/privacy_policy.md +0 -0
  33. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/benchmark_rust_vs_python.py +0 -0
  34. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/build_pure_python.py +0 -0
  35. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/__init__.py +0 -0
  36. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/_accelerator.py +0 -0
  37. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/brine/__init__.py +0 -0
  38. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/brine/_lib_salting_library.py +0 -0
  39. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/brine/_lib_vle_engine.py +0 -0
  40. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/classes/__init__.py +0 -0
  41. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/classes/classes.py +0 -0
  42. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/constants/__init__.py +0 -0
  43. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/constants/constants.py +0 -0
  44. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/dca/__init__.py +0 -0
  45. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/dca.rst +0 -0
  46. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/gas.rst +0 -0
  47. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/bot.png +0 -0
  48. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/bot_PVTO.png +0 -0
  49. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/bot_img.png +0 -0
  50. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/dry_gas.png +0 -0
  51. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/grid_sat_df.png +0 -0
  52. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/influence.png +0 -0
  53. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/properties_df.png +0 -0
  54. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/sgof.png +0 -0
  55. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/img/swof.png +0 -0
  56. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/layer.rst +0 -0
  57. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/library.rst +0 -0
  58. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/matbal.rst +0 -0
  59. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/nodal_examples.ipynb +0 -0
  60. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/nodal_hydrate_demo.ipynb +0 -0
  61. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/recommend.rst +0 -0
  62. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/sensitivity.rst +0 -0
  63. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/docs/simtools.rst +0 -0
  64. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/gas/__init__.py +0 -0
  65. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/gas/gas.py +0 -0
  66. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/layer/__init__.py +0 -0
  67. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/layer/layer.py +0 -0
  68. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/library/__init__.py +0 -0
  69. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/library/component_library.xlsx +0 -0
  70. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/library/library.py +0 -0
  71. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/matbal/__init__.py +0 -0
  72. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/matbal/matbal.py +0 -0
  73. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/nodal/__init__.py +0 -0
  74. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/oil/_utils.py +0 -0
  75. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/plyasunov/__init__.py +0 -0
  76. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/plyasunov/iapws_if97.py +0 -0
  77. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/plyasunov/plyasunov_model.py +0 -0
  78. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/plyasunov/water_properties.py +0 -0
  79. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/recommend/__init__.py +0 -0
  80. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/recommend/recommend.py +0 -0
  81. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/sensitivity/__init__.py +0 -0
  82. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/sensitivity/sensitivity.py +0 -0
  83. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/shared_fns/__init__.py +0 -0
  84. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/shared_fns/shared_fns.py +0 -0
  85. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/simtools/__init__.py +0 -0
  86. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/validate/__init__.py +0 -0
  87. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/pyrestoolbox/validate/validate.py +0 -0
  88. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/setup.cfg +0 -0
  89. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/setup.py +0 -0
  90. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/bessel.rs +0 -0
  91. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/critical_properties/mod.rs +0 -0
  92. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/dca/hyperbolic.rs +0 -0
  93. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/dca/mod.rs +0 -0
  94. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/dca/ransac.rs +0 -0
  95. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/gas_viscosity/mod.rs +0 -0
  96. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/gwr.rs +0 -0
  97. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/lib.rs +0 -0
  98. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/matbal/mod.rs +0 -0
  99. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/matbal/objective.rs +0 -0
  100. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/oil/density.rs +0 -0
  101. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/oil/mod.rs +0 -0
  102. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/pseudopressure.rs +0 -0
  103. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/spycher_pruess/mod.rs +0 -0
  104. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/spycher_pruess/solubility.rs +0 -0
  105. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/alpha.rs +0 -0
  106. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/bip.rs +0 -0
  107. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/components.rs +0 -0
  108. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/eos.rs +0 -0
  109. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/flash.rs +0 -0
  110. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/fugacity.rs +0 -0
  111. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/k_init.rs +0 -0
  112. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/mod.rs +0 -0
  113. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vle/rachford_rice.rs +0 -0
  114. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/friction.rs +0 -0
  115. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/holdup_bb.rs +0 -0
  116. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/holdup_gray.rs +0 -0
  117. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/holdup_wg.rs +0 -0
  118. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/ift.rs +0 -0
  119. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/mod.rs +0 -0
  120. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/pvt_helpers.rs +0 -0
  121. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.0}/src/vlp/static_column.rs +0 -0
  122. {pyrestoolbox-3.3.0 → pyrestoolbox-3.4.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.4.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.4.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"}]
@@ -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:
@@ -1436,8 +1442,15 @@ class SoreideWhitson:
1436
1442
  y_N2: Mole fraction N2 in dry gas (default 0)
1437
1443
  y_H2: Mole fraction H2 in dry gas (default 0)
1438
1444
  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.
1445
+ metric: Boolean for units (True=metric, False=oilfield). Default False.
1440
1446
  cw_sat: If True, also calculate saturated compressibility (default False)
1447
+ framework: VLE framework. 'proposed' (default, Soreide-Whitson 1992 re-fit),
1448
+ 'sw_original' (original 1992 published), or 'dropin' (fitted to PR-EOS with
1449
+ brine-aware water alpha). Affects kij and ks correlations.
1450
+ salinity_method: How salinity enters the flash. 'gamma_phi' (default, Sechenov
1451
+ salting-out via activity coefficient), 'embedded' (salinity inside kij —
1452
+ only for 'dropin'/'sw_original'), 'explicit' (brine treated as a component),
1453
+ 'sechenov' (legacy alias for gamma_phi), or 'auto' (pick per-gas defaults).
1441
1454
 
1442
1455
  Returns object with following calculated properties:
1443
1456
  .x : Dict of dissolved gas mole fractions, e.g. {'CO2': 0.024, 'CH4': 0.0015}
@@ -1462,7 +1475,7 @@ class SoreideWhitson:
1462
1475
  mix.Rs # Returns per-gas Rs dict, e.g. {'CO2': 15.2}
1463
1476
 
1464
1477
  # 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)
1478
+ mix = brine.SoreideWhitson(pres=200, temp=80, ppm=10000, y_CO2=0.1, y_H2S=0.05, sg=0.7, metric=True)
1466
1479
  mix.bDen # Returns [gas-saturated, gas-free, freshwater] densities
1467
1480
 
1468
1481
  References:
@@ -1482,13 +1495,20 @@ class SoreideWhitson:
1482
1495
  Murphy, W.R. and Gaines, T.M. (1974), J. Chem. Eng. Data 19(4), 359-362.
1483
1496
  """
1484
1497
 
1498
+ _VALID_FRAMEWORKS = ('proposed', 'sw_original', 'dropin')
1499
+ _VALID_SALINITY_METHODS = ('gamma_phi', 'embedded', 'explicit', 'auto', 'sechenov')
1500
+
1485
1501
  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):
1502
+ sg=0.65, metric=False, cw_sat=False,
1503
+ framework='proposed', salinity_method='gamma_phi',
1504
+ *, p=None, degf=None, wt=None):
1487
1505
  # Resolve parameter aliases (p/degf/wt -> pres/temp/ppm)
1488
1506
  if pres is None and p is not None:
1489
1507
  pres = p
1490
1508
  if temp is None and degf is not None:
1491
1509
  temp = degf
1510
+ if ppm is not None and wt is not None:
1511
+ raise ValueError("Supply either ppm or wt, not both.")
1492
1512
  if ppm is None:
1493
1513
  ppm = wt * 10000 if wt is not None else 0
1494
1514
  if pres is None or temp is None:
@@ -1497,6 +1517,22 @@ class SoreideWhitson:
1497
1517
  raise ValueError(f"ppm must be non-negative, got {ppm}")
1498
1518
  if ppm >= 1e6:
1499
1519
  raise ValueError(f"ppm must be less than 1,000,000, got {ppm}")
1520
+ if framework not in self._VALID_FRAMEWORKS:
1521
+ raise ValueError(
1522
+ f"Invalid framework: {framework!r}. Valid options: {list(self._VALID_FRAMEWORKS)}"
1523
+ )
1524
+ if salinity_method not in self._VALID_SALINITY_METHODS:
1525
+ raise ValueError(
1526
+ f"Invalid salinity_method: {salinity_method!r}. Valid options: {list(self._VALID_SALINITY_METHODS)}"
1527
+ )
1528
+ if framework == 'proposed' and salinity_method == 'embedded':
1529
+ warnings.warn(
1530
+ "framework='proposed' does not define embedded-salinity kij; engine will "
1531
+ "fall back to the 'gamma_phi' behaviour.",
1532
+ stacklevel=2,
1533
+ )
1534
+ self.framework = framework
1535
+ self.salinity_method = salinity_method
1500
1536
  # Validate pressure and temperature (convert to oilfield units for validation)
1501
1537
  _p_val = pres if not metric else pres * BAR2PSI
1502
1538
  _t_val = temp if not metric else temp * 1.8 + 32
@@ -1742,8 +1778,8 @@ class SoreideWhitson:
1742
1778
  y_H2S=self.gas_comp.get('H2S', 0),
1743
1779
  y_H2=self.gas_comp.get('H2', 0),
1744
1780
  method='flash',
1745
- salinity_method='gamma_phi',
1746
- framework='proposed',
1781
+ salinity_method=self.salinity_method,
1782
+ framework=self.framework,
1747
1783
  )
1748
1784
 
1749
1785
  self.x = x_gas
@@ -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
@@ -14,11 +14,11 @@ Returns tuple of (Bw (rb/stb), Density (sg), viscosity (cP), Compressibility (1/
14
14
  Unit System Support
15
15
  ----------------------
16
16
 
17
- All three brine models support both oilfield and metric unit systems via a ``metric`` parameter:
17
+ All three brine models default to oilfield units (psia, degF, 1/psi, scf/stb) and accept ``metric=True`` to switch to Eclipse METRIC (barsa, degC, 1/bar, sm3/sm3):
18
18
 
19
- - ``brine_props``: ``metric=False`` (default). When ``metric=True``, pressure is in barsa, temperature is in degC, compressibility is in 1/barsa, and Rsw is in sm3/sm3.
20
- - ``CO2_Brine_Mixture``: ``metric=True`` (default). When ``metric=False``, pressure is in psia and temperature is in degF.
21
- - ``SoreideWhitson``: ``metric=True`` (default). When ``metric=False``, pressure is in psia and temperature is in degF.
19
+ - ``brine_props``: ``metric=False`` (default).
20
+ - ``CO2_Brine_Mixture``: ``metric=False`` (default).
21
+ - ``SoreideWhitson``: ``metric=False`` (default).
22
22
 
23
23
  .. note::
24
24
 
@@ -126,7 +126,7 @@ pyrestoolbox.brine.CO2_Brine_Mixture
126
126
 
127
127
  .. code-block:: python
128
128
 
129
- CO2_Brine_Mixture(pres, temp, ppm = 0, metric = True) -> class
129
+ CO2_Brine_Mixture(pres, temp, ppm = 0, metric = False) -> class
130
130
 
131
131
  .. list-table:: Inputs
132
132
  :widths: 10 15 40
@@ -211,7 +211,7 @@ Usage example for 175 Bara x 85 degC and 0% NaCl brine:
211
211
 
212
212
  .. code-block:: python
213
213
 
214
- >>> mix = brine.CO2_Brine_Mixture(pres = 175, temp = 85)
214
+ >>> mix = brine.CO2_Brine_Mixture(pres = 175, temp = 85, metric = True)
215
215
  >>> mix.Rs # Returns sm3 dissolved CO2 / sm3 Brine
216
216
  24.743651168969475
217
217
 
@@ -312,7 +312,7 @@ pyrestoolbox.brine.SoreideWhitson
312
312
 
313
313
  .. code-block:: python
314
314
 
315
- SoreideWhitson(pres, temp, ppm=0, y_CO2=0, y_H2S=0, y_N2=0, y_H2=0, sg=0.65, metric=True, cw_sat=False) -> class
315
+ SoreideWhitson(pres, temp, ppm=0, y_CO2=0, y_H2S=0, y_N2=0, y_H2=0, sg=0.65, metric=False, cw_sat=False, framework='proposed', salinity_method='gamma_phi') -> class
316
316
 
317
317
  Soreide-Whitson (1992) VLE model for multicomponent gas solubility in water/brine, with Garcia/Plyasunov
318
318
  density corrections and calibrated viscosity corrections. Supports gas mixtures containing any combination
@@ -377,6 +377,12 @@ based on the gas specific gravity using constrained exponential decay to match t
377
377
  * - cw_sat
378
378
  - Boolean
379
379
  - If True, will also calculate saturated brine compressibility. Default False
380
+ * - framework
381
+ - str
382
+ - VLE framework used by the S&W engine. ``'proposed'`` (default, Soreide-Whitson 1992 re-fit), ``'sw_original'`` (original 1992 published coefficients), or ``'dropin'`` (PR-EOS fitted with brine-aware water alpha). Affects kij and ks correlations.
383
+ * - salinity_method
384
+ - str
385
+ - How salinity enters the flash. ``'gamma_phi'`` (default, Sechenov salting-out via activity coefficient), ``'embedded'`` (salinity inside kij — only compatible with ``'dropin'``/``'sw_original'``), ``'explicit'`` (brine treated as a component in the flash), ``'sechenov'`` (alias for ``gamma_phi``), or ``'auto'`` (pick per-gas default). ``framework='proposed'`` + ``salinity_method='embedded'`` emits a warning and falls back to ``gamma_phi``.
380
386
 
381
387
  .. list-table:: Returns (SoreideWhitson)
382
388
  :widths: 10 15 40
@@ -1,3 +1,64 @@
1
+ Changelist in 3.4.0:
2
+
3
+ - **BREAKING — Brine metric default standardization**: ``CO2_Brine_Mixture`` and ``SoreideWhitson`` constructors now default to ``metric=False`` (oilfield units) to match ``brine_props`` and every other pyrestoolbox API. Previously they defaulted to ``metric=True``. Existing callers that relied on the old default must either pass ``metric=True`` explicitly or switch their input units to psia / degF. RST and notebook examples that relied on the default have been updated to pass ``metric=True`` explicitly.
4
+
5
+ - **nodal.outflow_curve parameter unification**: ``n_rates`` has been renamed to ``n_points`` to match ``ipr_curve`` / ``operating_point``. ``n_rates`` remains accepted as a deprecated alias (takes precedence when both are passed) — existing callers keep working without changes.
6
+
7
+ - **Type hints on public APIs**: ``nodal`` (fbhp / fthp / outflow_curve / ipr_curve / operating_point), ``oil`` (oil_co/bt return types, OilPVT constructor + methods, oil_rate_radial/linear), and ``dca`` (arps_*, duong_*, eur, fit_*, ratio_forecast, forecast) public signatures now carry type annotations. IDE autocomplete and static analysis now work out of the box for the primary user surface. ``matbal`` / ``simtools`` / ``plyasunov`` type-hint backfill deferred.
8
+
9
+ - **Rust-vs-Python parity harness**: 63 new parametrized tests added to ``test_rust_acceleration.py``. Coverage:
10
+
11
+ - Nodal gas VLP: 4 methods × 6 (THP, rate) points on a vertical well; WG/BB × 2 points on a deviated well (45-degree lower segment).
12
+ - Nodal oil VLP: 4 methods × 5 (THP, rate) points on a vertical well; WG/BB × 2 points on a deviated well.
13
+ - SoreideWhitson: pure CO2 across 4 (P, T) × 3 salinity points; sour-gas mix (CO2 + H2S + N2) at 2 points; natural-gas-only at 2 points.
14
+
15
+ Purpose is prophylactic — catches future one-sided edits between Python and Rust. Tolerance is ``RTOL_MEDIUM = 1e-4``.
16
+
17
+ - **Rust HB-oil VLP bugfix (correctness)**. Three dimensional bugs in ``src/vlp/segment_oil.rs`` (HB oil only — WG/GRAY/BB oil were coded correctly):
18
+
19
+ 1. ``min_l`` formula (``rho_g * 62.37 / (rho_g * 62.37 + lsg * 62.37)`` simplified to ``rho_g_lbft3 / (rho_g_lbft3 + lsg_SG)``) mixed lb/cuft with dimensionless SG. Forced ~35% minimum liquid holdup at low rate when Python computed ~2%. Dominant contributor to the divergence.
20
+ 2. ``rho_avg`` averaged gas vs stock-tank oil density, not the oil+water mixture density that includes live-oil McCain correction and water cut.
21
+ 3. ``ul`` superficial liquid velocity used stock-tank volumetric flow (``ql * 5.615 / 86400 / area``) instead of reservoir-conditions volumetric (``mflow_l / rho_l / area``). Missed the live-oil expansion / water-cut weighting.
22
+
23
+ Combined effect was up to ~75% over-prediction of BHP for HB oil wells at low rate / low THP. Python path was always correct; bug was isolated to the Rust accelerator. The ``hb_holdup`` helper signature changed from taking ``lsg`` (implicit ``62.4 * lsg`` internally) to taking ``rho_l`` directly; gas-segment callers updated to pass ``62.4 * lsg_loc`` to preserve prior behaviour. Parity harness drift reduced from 75% to <0.1% on the affected grid.
24
+
25
+ - **Nodal ``operating_point`` non-monotonic VLP handling**. HB oil VLP curves can be non-monotonic at very low rate (spurious near-shut-in high BHP from holdup correlations). The previous bisection bracketed ``[min_rate, AOF]`` and failed when both endpoints had the same sign despite a crossing existing in between. Replaced with a scan-then-bisect: sample 25 rates, locate all sign changes in the error function, and bisect in the highest-rate bracket — the physical operating point. Sets ``converged=False`` if no sign change is found.
26
+
27
+ - **HB oil doc-example values updated**. Three expected values changed due to the Rust HB-oil bugfix (Python values unchanged, Rust values now match Python):
28
+
29
+ - ``fbhp(thp=200, ..., vlpmethod='HB', well_type='oil', qt_stbpd=2000, ...)``: 2271.72 psi → 1771.47 psi
30
+ - ``fbhp`` with ``oil_pvt=opvt``: 2273.72 psi → 1772.24 psi
31
+ - ``operating_point`` (HB oil, thp=200, reservoir pr=3000): rate 1391.4 → 2019.0 stb/d, bhp 2206.3 → 1778.5 psi
32
+
33
+ RST and notebook examples updated to match the corrected values.
34
+
35
+ - **nodal.fthp**: New reverse-solve function. ``fthp(bhp, completion, ...)`` returns the tubing head pressure that produces the specified BHP under the given VLP correlation. Wraps ``bisect_solve`` over ``fbhp``, accepts the same flow/well parameters as ``fbhp``, and has matching units/metric handling. Users no longer need to hand-roll a bisection for wellhead back-calculation workflows.
36
+
37
+ - **nodal.fbhp ``return_profile=True``**: New kwarg. When enabled, ``fbhp`` returns a ``NodalResult`` with per-segment-boundary ``md``, ``tvd``, and ``p`` arrays (plus scalar ``bhp``) instead of just the bottom-hole scalar. Exposes the wellbore pressure traverse without forking the internal segment march. Metric-aware outputs.
38
+
39
+ - **nodal.operating_point extensions**:
40
+
41
+ - New ``injection=False`` kwarg, forwarded to internal ``fbhp`` and ``outflow_curve`` calls so injection wells can be solved directly through ``operating_point``. Previously ``injection=True`` was honoured only by direct ``fbhp`` calls.
42
+ - New ``converged`` key in the returned ``NodalResult``. ``True`` when the VLP/IPR bisection succeeded, ``False`` when it fell back to ``rate=0, bhp=pr`` (no intersection). Surfaces a condition that previously looked like a plausible zero-rate answer.
43
+
44
+ - **nodal.outflow_curve dict key**: Returned ``NodalResult`` now carries both ``'rate'`` and ``'rates'`` entries (same list). ``'rate'`` matches ``ipr_curve`` and ``operating_point``; ``'rates'`` is kept for backward compatibility.
45
+
46
+ - **SoreideWhitson framework / salinity_method exposed**: New ``framework`` (``'proposed'`` | ``'sw_original'`` | ``'dropin'``) and ``salinity_method`` (``'gamma_phi'`` | ``'embedded'`` | ``'explicit'`` | ``'sechenov'`` | ``'auto'``) constructor kwargs, forwarded to the VLE engine. Defaults unchanged (``'proposed'`` + ``'gamma_phi'``). Combining ``framework='proposed'`` with ``salinity_method='embedded'`` now emits a warning because the engine falls back to ``gamma_phi`` for the ``proposed`` kij set.
47
+
48
+ - **Brine ``ppm`` / ``wt`` conflict detection**: ``brine_props``, ``CO2_Brine_Mixture``, and ``SoreideWhitson`` now raise ``ValueError("Supply either ... not both.")`` when both aliases are passed. Previously one silently won with no feedback.
49
+
50
+ - **Oil API cleanup**:
51
+
52
+ - Removed private-helper re-exports ``_cofb_mccain``, ``_perrine_co_sat``, ``_resolve_pb_rsb``, ``_build_bot_tables``, ``_format_bot_results`` from ``pyrestoolbox.oil`` public namespace. Still reachable via ``pyrestoolbox.oil._tables`` / ``._density`` / ``._compressibility`` for advanced users; ``simtools.make_bot_og`` updated internally.
53
+ - Dead imports removed across ``_compressibility``, ``_correlations``, ``_density``, ``_harmonize``, ``_tables`` sub-files (11 symbols total).
54
+ - Valko-McCain (2003) coefficient matrices for ``sg_st_gas`` and ``oil_rs_st`` extracted to ``_constants.py`` with paper citations, per CLAUDE.md named-constants rule.
55
+
56
+ - **Oil documentation sync**: ``docs/oil.rst`` updated — ``oil_deno`` parameter listed as ``sg_o`` (matches code; was incorrectly ``sg_sto``). ``make_bot_og`` result-dict table now documents ``vis_frac`` (was previously omitted despite being returned).
57
+
58
+ - **DCA internals**: 20 previously inline magic numbers extracted to named constants at the top of ``dca.py`` (Arps b-grid 0.05/0.96/0.01, Duong trap bounds and curve_fit bounds, logistic fit bounds, hyperbolic numerical floor). ``_build_decline_result`` helper deduplicates R-squared/residual/DeclineResult construction across the six ``_fit_*`` helpers (~60 lines removed).
59
+
60
+ - 726 validation tests (up from 716 in 3.3.0). New coverage for ``fthp`` roundtrip, ``fbhp`` pressure-traverse return, ``operating_point.converged``, ``SoreideWhitson`` framework/salinity_method validation, and the three ``ppm``/``wt`` conflict raises.
61
+
1
62
  Changelist in 3.3.0:
2
63
 
3
64
  - **BNS Z-factor / critical-property coupling**: When either ``zmethod`` or ``cmethod`` is ``BNS``, both are now forced to ``BNS`` for thermodynamic consistency with a ``UserWarning`` naming the overruled counterpart. ``h2 > 0`` continues to auto-select BNS silently. Non-BNS methods (e.g. ``DAK`` + ``SUT``) remain freely mixable. Implemented via a single ``_resolve_methods`` helper applied at all gas public entry points plus ``GasPVT.__init__``.