pyrestoolbox 2.2__tar.gz → 2.2.1__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 (58) hide show
  1. {pyrestoolbox-2.2/pyrestoolbox.egg-info → pyrestoolbox-2.2.1}/PKG-INFO +1 -1
  2. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/brine/brine.py +136 -13
  3. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/brine.rst +102 -1
  4. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/changelist.rst +5 -0
  5. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/gas/gas.py +216 -174
  6. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/shared_fns/shared_fns.py +77 -0
  7. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/tests/test_brine.py +57 -0
  8. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1/pyrestoolbox.egg-info}/PKG-INFO +1 -1
  9. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/setup.cfg +1 -1
  10. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/setup.py +1 -1
  11. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/LICENSE +0 -0
  12. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/MANIFEST.in +0 -0
  13. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/README.md +0 -0
  14. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/README.rst +0 -0
  15. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyproject.toml +0 -0
  16. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/__init__.py +0 -0
  17. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/brine/__init__.py +0 -0
  18. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/classes/__init__.py +0 -0
  19. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/classes/classes.py +0 -0
  20. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/constants/__init__.py +0 -0
  21. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/constants/constants.py +0 -0
  22. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/gas.rst +0 -0
  23. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/bot.png +0 -0
  24. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/bot_PVTO.png +0 -0
  25. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/bot_img.png +0 -0
  26. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/dry_gas.png +0 -0
  27. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/grid_sat_df.png +0 -0
  28. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/influence.png +0 -0
  29. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/properties_df.png +0 -0
  30. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/sgof.png +0 -0
  31. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/img/swof.png +0 -0
  32. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/layer.rst +0 -0
  33. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/library.rst +0 -0
  34. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/oil.rst +0 -0
  35. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/docs/simtools.rst +0 -0
  36. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/gas/__init__.py +0 -0
  37. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/layer/__init__.py +0 -0
  38. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/layer/layer.py +0 -0
  39. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/library/__init__.py +0 -0
  40. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/library/component_library.xlsx +0 -0
  41. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/library/library.py +0 -0
  42. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/oil/__init__.py +0 -0
  43. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/oil/oil.py +0 -0
  44. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/shared_fns/__init__.py +0 -0
  45. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/simtools/__init__.py +0 -0
  46. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/simtools/simtools.py +0 -0
  47. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/tests/__init__.py +0 -0
  48. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/tests/run_all_tests.py +0 -0
  49. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/tests/test_gas.py +0 -0
  50. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/tests/test_layer.py +0 -0
  51. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/tests/test_oil.py +0 -0
  52. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/tests/test_simtools.py +0 -0
  53. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/validate/__init__.py +0 -0
  54. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox/validate/validate.py +0 -0
  55. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox.egg-info/SOURCES.txt +0 -0
  56. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox.egg-info/dependency_links.txt +0 -0
  57. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox.egg-info/requires.txt +0 -0
  58. {pyrestoolbox-2.2 → pyrestoolbox-2.2.1}/pyrestoolbox.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrestoolbox
3
- Version: 2.2
3
+ Version: 2.2.1
4
4
  Summary: pyResToolbox - A collection of Reservoir Engineering Utilities
5
5
  Home-page: https://github.com/mwburgoyne/pyResToolbox
6
6
  Author: Mark W. Burgoyne
@@ -23,12 +23,14 @@
23
23
 
24
24
  import numpy as np
25
25
  import numpy.typing as npt
26
+ import pandas as pd
26
27
 
27
28
  from typing import Tuple
29
+ from tabulate import tabulate
28
30
 
29
31
  import pyrestoolbox.gas as gas # Needed for Z-Factor
30
32
  from pyrestoolbox.classes import z_method, c_method, pb_method, rs_method, bo_method, uo_method, deno_method, co_method, kr_family, kr_table, class_dic
31
- from pyrestoolbox.shared_fns import convert_to_numpy, process_output
33
+ from pyrestoolbox.shared_fns import convert_to_numpy, process_output, halley_solve_cubic
32
34
  from pyrestoolbox.validate import validate_methods
33
35
  from pyrestoolbox.constants import R, psc, tsc, degF2R, tscr, scf_per_mol, CUFTperBBL, WDEN, MW_CO2, MW_H2S, MW_N2, MW_AIR, MW_H2
34
36
 
@@ -719,31 +721,40 @@ class CO2_Brine_Mixture():
719
721
  # Cubic Polynomial Solver: f(Z) = Z**3 + E2*Z**2 + E1*Z + E1 = 0
720
722
  #=======================================================================
721
723
  self.repeat = False
722
- Z = np.roots(np.array([1.0, e2, e1, e0]))
723
- Z = np.array([x for x in Z if np.isreal(x)]) # Keep only real results
724
- if len(Z) > 1: # Evaluate which root to use per Eqs 25 and 26 in Spycher & Pruess (2003)
725
- vgas, vliq = max(Z), min(Z)
726
-
724
+
725
+ # Try Halley for all roots
726
+ roots = halley_solve_cubic(e2, e1, e0, flag=0)
727
+
728
+ # Fallback to np.roots if Halley returned None
729
+ if roots is None:
730
+ Z = np.roots(np.array([1.0, e2, e1, e0]))
731
+ Z = np.array([x for x in Z if np.isreal(x)]) # Keep only real results
732
+ roots = np.real(Z)
733
+
734
+ if len(roots) > 1: # Evaluate which root to use per Eqs 25 and 26 in Spycher & Pruess (2003)
735
+ vgas, vliq = max(roots), min(roots)
736
+
727
737
  w1 = self.pBar*(vgas - vliq)
728
738
  w2 = RGASCON * self.tKel * np.log((vgas - self.bMix)/(vliq - self.bMix)) + self.aMix/(self.tKel**0.5 * self.bMix) * np.log((vgas + self.bMix) * vliq / ((vliq + self.bMix) * vgas))
729
-
739
+
730
740
  if w2 - w1 > 0:
731
- Z[0] = max(Z)
741
+ result = max(roots)
732
742
  if self.CO2_sat: # CO2 was saturated in previous iteration, but now its not
733
743
  self.CO2_sat = False
734
744
  self.repeat = True
735
745
  else:
736
- Z[0] = min(Z)
746
+ result = min(roots)
737
747
  if not self.CO2_sat:
738
748
  self.CO2_sat = True
739
749
  self.repeat = True
740
750
  else:
751
+ result = roots[0]
741
752
  if self.CO2_sat: # CO2 was saturated in previous iteration, but now its not
742
753
  self.CO2_sat = False
743
754
  self.repeat = True
744
-
745
-
746
- return np.real(Z[0])
755
+
756
+
757
+ return np.real(result)
747
758
 
748
759
 
749
760
  def MolarVolume(self):
@@ -1236,4 +1247,116 @@ class CO2_Brine_Mixture():
1236
1247
  # Undersaturated compressibility = 1/V dV/dP
1237
1248
  c_usat = 1 - sg_CO2_Brine / sg_CO2_Brine_ # 1/Bar
1238
1249
 
1239
- return ([sg_CO2_Brine, sg_brine, rhowtp], [cP_CO2_brine, cP_brine, cP_freshwater], viscosblty, [bw, brine_res_vol, bw_freshwater], rs, c_usat)
1250
+ return ([sg_CO2_Brine, sg_brine, rhowtp], [cP_CO2_brine, cP_brine, cP_freshwater], viscosblty, [bw, brine_res_vol, bw_freshwater], rs, c_usat)
1251
+
1252
+
1253
+ def make_pvtw_table(
1254
+ pi: float,
1255
+ degf: float,
1256
+ wt: float = 0,
1257
+ ch4_sat: float = 0,
1258
+ pmin: float = 500,
1259
+ pmax: float = 10000,
1260
+ nrows: int = 20,
1261
+ export: bool = False,
1262
+ ) -> dict:
1263
+ """ Generates a PVTW (water PVT) table over a pressure range using brine_props (Spivey correlation).
1264
+ Follows the pattern of make_bot_og from the oil module.
1265
+
1266
+ pi: Initial (reference) pressure (psia)
1267
+ degf: Temperature (deg F)
1268
+ wt: Salt wt% (0-100), default 0
1269
+ ch4_sat: Degree of methane saturation (0 - 1), default 0
1270
+ pmin: Minimum pressure for table (psia), default 500
1271
+ pmax: Maximum pressure for table (psia), default 10000
1272
+ nrows: Number of rows in table, default 20
1273
+ export: If True, writes PVTW.INC (ECLIPSE keyword) and pvtw_table.xlsx
1274
+
1275
+ Returns dict with keys:
1276
+ table: pandas DataFrame with columns Pressure, Bw, Density, Viscosity, Cw, Rsw
1277
+ pref: Reference pressure (psia)
1278
+ bw_ref: Bw at reference pressure (rb/stb)
1279
+ cw_ref: Compressibility at reference pressure (1/psi)
1280
+ visw_ref: Viscosity at reference pressure (cP)
1281
+ rsw_ref: Rsw at reference pressure (scf/stb)
1282
+ den_ref: Density (sg) at reference pressure
1283
+ """
1284
+ # Build pressure grid, ensuring pi is included
1285
+ pressures = list(np.linspace(pmin, pmax, nrows))
1286
+ if pi not in pressures:
1287
+ pressures.append(pi)
1288
+ pressures = sorted(set(pressures))
1289
+
1290
+ bws, dens, visws, cws, rsws = [], [], [], [], []
1291
+ for p in pressures:
1292
+ bw, lden, visw, cw, rsw = brine_props(p=p, degf=degf, wt=wt, ch4_sat=ch4_sat)
1293
+ bws.append(bw)
1294
+ dens.append(lden)
1295
+ visws.append(visw)
1296
+ cws.append(cw)
1297
+ rsws.append(rsw)
1298
+
1299
+ df = pd.DataFrame()
1300
+ df["Pressure (psia)"] = pressures
1301
+ df["Bw (rb/stb)"] = bws
1302
+ df["Density (sg)"] = dens
1303
+ df["Viscosity (cP)"] = visws
1304
+ df["Cw (1/psi)"] = cws
1305
+ df["Rsw (scf/stb)"] = rsws
1306
+
1307
+ # Reference properties at pi
1308
+ bw_ref, den_ref, visw_ref, cw_ref, rsw_ref = brine_props(
1309
+ p=pi, degf=degf, wt=wt, ch4_sat=ch4_sat
1310
+ )
1311
+
1312
+ if export:
1313
+ # Write ECLIPSE PVTW keyword
1314
+ # PVTW format: Pref Bw Cw Visw Viscosibility
1315
+ # Viscosibility set to 0 (constant viscosity assumption)
1316
+ pvtw_line = f" {pi:.1f} {bw_ref:.6f} {cw_ref:.6e} {visw_ref:.4f} 0.0"
1317
+ fileout = f"-- Generated by pyResToolbox make_pvtw_table\n"
1318
+ fileout += f"-- Temperature: {degf:.1f} deg F, Salt: {wt:.1f} wt%, CH4 sat: {ch4_sat:.2f}\n"
1319
+ fileout += f"PVTW\n{pvtw_line} /\n"
1320
+ with open("PVTW.INC", "w") as f:
1321
+ f.write(fileout)
1322
+
1323
+ # Write full table to Excel
1324
+ df.to_excel("pvtw_table.xlsx", index=False)
1325
+
1326
+ return {
1327
+ "table": df,
1328
+ "pref": pi,
1329
+ "bw_ref": bw_ref,
1330
+ "cw_ref": cw_ref,
1331
+ "visw_ref": visw_ref,
1332
+ "rsw_ref": rsw_ref,
1333
+ "den_ref": den_ref,
1334
+ }
1335
+
1336
+
1337
+ class SoreideWhitson:
1338
+ """ Soreide-Whitson (1992) model for gas solubility in water/brine.
1339
+
1340
+ Planned support for multicomponent gas mixtures containing:
1341
+ C1, C2, C3, nC4, CO2, H2S, N2, H2
1342
+
1343
+ Will calculate:
1344
+ - Mole fraction of dissolved gas components in aqueous phase
1345
+ - Mole fraction of vaporised water in gas phase
1346
+ - Water content of gas (stb/mmscf)
1347
+ - Gas solubility in water (scf/stb)
1348
+
1349
+ Supports fresh and saline water (NaCl equivalent).
1350
+
1351
+ Reference:
1352
+ Soreide, I. and Whitson, C.H., "Peng-Robinson Predictions for Hydrocarbons,
1353
+ CO2, N2, and H2S with Pure Water and NaCl Brine", Fluid Phase Equilibria,
1354
+ 77, 217-240, 1992.
1355
+ """
1356
+
1357
+ def __init__(self, **kwargs):
1358
+ raise NotImplementedError(
1359
+ "SoreideWhitson model is not yet implemented. "
1360
+ "Planned support includes multicomponent gas (C1, C2, C3, nC4, CO2, H2S, N2, H2) "
1361
+ "solubility in fresh/saline water using the Soreide-Whitson (1992) PR-EOS approach."
1362
+ )
@@ -161,5 +161,106 @@ Usage example for 175 Bara x 85 degC and 0% NaCl brine:
161
161
 
162
162
  >>> mix = brine.CO2_Brine_Mixture(pres = 175, temp = 85)
163
163
  >>> mix.Rs # Returns sm3 dissolved CO2 / sm3 Brine
164
- 24.742923469934272
164
+ 24.742923469934272
165
+
166
+ pyrestoolbox.brine.make_pvtw_table
167
+ ======================
168
+
169
+ .. code-block:: python
170
+
171
+ make_pvtw_table(pi, degf, wt=0, ch4_sat=0, pmin=500, pmax=10000, nrows=20, export=False) -> dict
172
+
173
+ Generates a PVTW (water PVT) table over a pressure range using the Spivey correlation (brine_props).
174
+ Optionally exports ECLIPSE PVTW keyword file and Excel spreadsheet.
175
+
176
+ .. list-table:: Inputs
177
+ :widths: 10 15 40
178
+ :header-rows: 1
179
+
180
+ * - Parameter
181
+ - Type
182
+ - Description
183
+ * - pi
184
+ - float
185
+ - Initial (reference) pressure (psia)
186
+ * - degf
187
+ - float
188
+ - Temperature (deg F)
189
+ * - wt
190
+ - float
191
+ - Salt weight% in the brine (0 - 100). Default 0
192
+ * - ch4_sat
193
+ - float
194
+ - Degree of methane saturation (0 - 1). Default 0
195
+ * - pmin
196
+ - float
197
+ - Minimum table pressure (psia). Default 500
198
+ * - pmax
199
+ - float
200
+ - Maximum table pressure (psia). Default 10000
201
+ * - nrows
202
+ - int
203
+ - Number of table rows. Default 20
204
+ * - export
205
+ - bool
206
+ - If True, writes PVTW.INC and pvtw_table.xlsx. Default False
207
+
208
+ .. list-table:: Return dict keys
209
+ :widths: 10 15 40
210
+ :header-rows: 1
211
+
212
+ * - Key
213
+ - Type
214
+ - Description
215
+ * - table
216
+ - DataFrame
217
+ - Pressure, Bw, Density, Viscosity, Cw, Rsw
218
+ * - pref
219
+ - float
220
+ - Reference pressure (psia)
221
+ * - bw_ref
222
+ - float
223
+ - Bw at reference pressure (rb/stb)
224
+ * - cw_ref
225
+ - float
226
+ - Compressibility at reference pressure (1/psi)
227
+ * - visw_ref
228
+ - float
229
+ - Viscosity at reference pressure (cP)
230
+ * - rsw_ref
231
+ - float
232
+ - Rsw at reference pressure (scf/stb)
233
+ * - den_ref
234
+ - float
235
+ - Density (sg) at reference pressure
236
+
237
+ Examples:
238
+
239
+ .. code-block:: python
240
+
241
+ >>> from pyrestoolbox import brine
242
+ >>> result = brine.make_pvtw_table(pi=3000, degf=200, wt=0, ch4_sat=0)
243
+ >>> print(result['bw_ref'])
244
+ >>> print(result['table'].head())
245
+
246
+ pyrestoolbox.brine.SoreideWhitson
247
+ ======================
248
+
249
+ .. code-block:: python
250
+
251
+ SoreideWhitson(**kwargs) -> raises NotImplementedError
252
+
253
+ Placeholder for the Soreide-Whitson (1992) PR-EOS model for gas solubility in water/brine.
254
+
255
+ Planned support includes multicomponent gas mixtures (C1, C2, C3, nC4, CO2, H2S, N2, H2)
256
+ solubility in fresh and saline water.
257
+
258
+ Planned return values:
259
+ - Mole fraction of dissolved gas components in aqueous phase
260
+ - Mole fraction of vaporised water in gas phase
261
+ - Water content of gas (stb/mmscf)
262
+ - Gas solubility in water (scf/stb)
263
+
264
+ Reference: Soreide, I. and Whitson, C.H., "Peng-Robinson Predictions for Hydrocarbons,
265
+ CO2, N2, and H2S with Pure Water and NaCl Brine", Fluid Phase Equilibria, 77, 217-240, 1992.
165
266
 
@@ -1,3 +1,8 @@
1
+ Changelist in 2.2:
2
+
3
+ - Bugfixes.
4
+
5
+
1
6
  Changelist in 2.1.3:
2
7
 
3
8
  - Updated viscosity parameters for BUR method.
@@ -23,7 +23,6 @@
23
23
 
24
24
  import numpy as np
25
25
  import numpy.typing as npt
26
- from scipy.integrate import quad
27
26
  from typing import Tuple
28
27
 
29
28
  import pandas as pd
@@ -32,6 +31,10 @@ from pyrestoolbox.shared_fns import convert_to_numpy, process_output, check_2_in
32
31
  from pyrestoolbox.validate import validate_methods
33
32
  from pyrestoolbox.constants import R, psc, tsc, degF2R, tscr, scf_per_mol, CUFTperBBL, WDEN, MW_CO2, MW_H2S, MW_N2, MW_AIR, MW_H2
34
33
 
34
+ # Precomputed Gauss-Legendre nodes/weights for pseudopressure integration
35
+ _GL7_NODES, _GL7_WEIGHTS = np.polynomial.legendre.leggauss(7)
36
+ _GL10_NODES, _GL10_WEIGHTS = np.polynomial.legendre.leggauss(10)
37
+
35
38
  def gas_rate_radial(
36
39
  k: npt.ArrayLike,
37
40
  h: npt.ArrayLike,
@@ -463,6 +466,99 @@ _BNS_OMEGAA = np.array([0.427671, 0.436725, 0.457236, 0.457236, 0.457236])
463
466
  _BNS_OMEGAB = np.array([0.0696397, 0.0724345, 0.0777961, 0.0777961, 0.0777961])
464
467
  _BNS_VCVIS = np.array([1.46352, 1.46808, 1.35526, 0.68473, 0.0]) # cuft/lbmol
465
468
 
469
+ # BIP precomputed matrices: kij[i,j] = _BIP_CONST[i,j] + _BIP_SLOPE_TC[i,j] / degR
470
+ # Component order: [CO2=0, H2S=1, N2=2, H2=3, Gas=4]
471
+ # Gas column/row uses tpc_hc at runtime; stored slopes in _BIP_GAS_SLOPES
472
+ _BIP_CONST = np.array([
473
+ [ 0. , 0.248638, -0.25 , -0.247153, -0.145561],
474
+ [ 0.248638, 0. , -0.204414, 0. , 0.16852 ],
475
+ [-0.25 , -0.204414, 0. , -0.166253, -0.108 ],
476
+ [-0.247153, 0. , -0.166253, 0. , -0.0620119],
477
+ [-0.145561, 0.16852 , -0.108 , -0.0620119, 0. ]])
478
+
479
+ _BIP_SLOPE_TC = np.array([
480
+ [ 0. , -75.64467996, 63.51120432, 89.65031832, 0.],
481
+ [-75.64467996, 0. , 157.55635404, 0. , 0.],
482
+ [ 63.51120432, 157.55635404, 0. , 17.90313836, 0.],
483
+ [ 89.65031832, 0. , 17.90313836, 0. , 0.],
484
+ [ 0. , 0. , 0. , 0. , 0.]])
485
+
486
+ _BIP_GAS_SLOPES = np.array([0.276572, -0.122378, 0.0605506, 0.0427873])
487
+
488
+ def _calc_bips_fast(degR, tpc_hc):
489
+ """Compute 5x5 BIP matrix using precomputed constants."""
490
+ slope_tc = _BIP_SLOPE_TC.copy()
491
+ slope_tc[4, :4] = _BIP_GAS_SLOPES * tpc_hc
492
+ slope_tc[:4, 4] = slope_tc[4, :4]
493
+ return _BIP_CONST + slope_tc / degR
494
+
495
+ def _cardano_cubic(c2, c1, c0, flag=0):
496
+ """Analytic Cardano solver for monic cubic Z^3 + c2*Z^2 + c1*Z + c0 = 0.
497
+ flag=1: max root, flag=-1: min root, flag=0: all real roots."""
498
+ p = (3 * c1 - c2**2) / 3
499
+ q = (2 * c2**3 - 9 * c2 * c1 + 27 * c0) / 27
500
+ root_diagnostic = q**2 / 4 + p**3 / 27
501
+
502
+ if root_diagnostic < 0:
503
+ m = 2 * np.sqrt(-p / 3)
504
+ qpm = 3 * q / p / m
505
+ theta1 = np.arccos(qpm) / 3
506
+ roots = np.array([m * np.cos(theta1),
507
+ m * np.cos(theta1 + 4 * np.pi / 3),
508
+ m * np.cos(theta1 + 2 * np.pi / 3)])
509
+ Zs = roots - c2 / 3
510
+ else:
511
+ P = (-q / 2 + np.sqrt(root_diagnostic))
512
+ if P >= 0:
513
+ P = P ** (1 / 3)
514
+ else:
515
+ P = -(-P) ** (1 / 3)
516
+ Q = (-q / 2 - np.sqrt(root_diagnostic))
517
+ if Q >= 0:
518
+ Q = Q ** (1 / 3)
519
+ else:
520
+ Q = -(-Q) ** (1 / 3)
521
+ Zs = np.array([P + Q]) - c2 / 3
522
+
523
+ if flag == -1:
524
+ return min(Zs)
525
+ if flag == 1:
526
+ return max(Zs)
527
+ return Zs
528
+
529
+ def _halley_cubic_vec(c2, c1, c0, max_iter=50, tol=1e-12):
530
+ """Vectorized Halley solver: solve Z^3+c2*Z^2+c1*Z+c0=0 for max root (vapor Z).
531
+ c2, c1, c0 are 1D arrays of length N. Returns 1D array of Z values.
532
+ Falls back to _cardano_cubic for any non-converged elements."""
533
+ N = len(c2)
534
+ Z = -c2 / 3.0
535
+ f0 = Z**3 + c2 * Z**2 + c1 * Z + c0
536
+ Z = np.where(f0 < 0, Z + 1.0, Z)
537
+
538
+ for _ in range(max_iter):
539
+ f = Z**3 + c2 * Z**2 + c1 * Z + c0
540
+ fp = 3.0 * Z**2 + 2.0 * c2 * Z + c1
541
+ fpp = 6.0 * Z + 2.0 * c2
542
+ # Protect against zero derivatives
543
+ safe_fp = np.where(np.abs(fp) < 1e-30, 1e-30, fp)
544
+ dZ = f / safe_fp
545
+ denom = safe_fp - 0.5 * dZ * fpp
546
+ denom = np.where(np.abs(denom) < 1e-30, 1e-30, denom)
547
+ dZ = f / denom
548
+ Z -= dZ
549
+ if np.max(np.abs(dZ)) < tol:
550
+ break
551
+
552
+ # Check residuals and fall back to Cardano for any bad elements
553
+ f = Z**3 + c2 * Z**2 + c1 * Z + c0
554
+ bad = np.abs(f) > 1e-6
555
+ if np.any(bad):
556
+ bad_idx = np.where(bad)[0]
557
+ for idx in bad_idx:
558
+ Z[idx] = _cardano_cubic(c2[idx], c1[idx], c0[idx], flag=1)
559
+
560
+ return Z
561
+
466
562
  def gas_z(
467
563
  p: npt.ArrayLike,
468
564
  sg: float,
@@ -621,103 +717,43 @@ def gas_z(
621
717
  def z_bur(psias, degf):
622
718
  degR = degf + degF2R
623
719
 
624
- # Analytic solution for real root(s) of cubic polynomial
625
- # a[0] * Z**3 + a[1]*Z**2 + a[2]*Z + a[3] = 0
626
- # Flag = 1 return Max root, = -1 returns minimum root, = 0 returns all real roots
627
- def cubic_root(a, flag = 0):
628
- if a[0] != 1:
629
- a = np.array(a) / a[0] # Normalize to unity exponent for Z^3
630
- p = (3 * a[2]- a[1]**2)/3
631
- q = (2 * a[1]**3 - 9 * a[1] * a[2] + 27 * a[3])/27
632
- root_diagnostic = q**2/4 + p**3/27
633
-
634
- if root_diagnostic < 0:
635
- m = 2*np.sqrt(-p/3)
636
- qpm = 3*q/p/m
637
- theta1 = np.arccos(qpm)/3
638
- roots = np.array([m*np.cos(theta1), m*np.cos(theta1+4*np.pi/3), m*np.cos(theta1+2*np.pi/3)])
639
- Zs = roots - a[1] / 3
640
- else:
641
- P = (-q/2 + np.sqrt(root_diagnostic))
642
- if P >= 0:
643
- P = P **(1/3)
644
- else:
645
- P = -(-P)**(1/3)
646
- Q = (-q/2 - np.sqrt(root_diagnostic))
647
- if Q >=0:
648
- Q = Q **(1/3)
649
- else:
650
- Q = -(-Q)**(1/3)
651
- Zs = np.array([P + Q]) - a[1] / 3
652
-
653
- if flag == -1: # Return minimum root
654
- return min(Zs)
655
- if flag == 1: # Return maximum root
656
- return max(Zs)
657
- return Zs # Return all roots
658
-
659
- def calc_bips(degR, tpc_hc):
660
- """
661
- Temperature-dependent Binary Interaction Parameters (BIPs) for all pairs,
662
- with analytic first and second T-derivatives for use in PR analytic derivatives.
663
- - Fitted forms: constant + slope/Tr, Tr based on component crit temp.
664
- - Returns: kij, dkij_dT, d2kij_dT2 (all NxN)
665
- - Tuned to experimental VLE data Burgoyne, 2025
666
- """
667
- components = ['CO2', 'H2S', 'N2', 'H2', 'Gas']
668
- bip_parameters = {
669
- ("Gas", "CO2"): {"constant": -0.145561 , "Tr_slope": 0.276572 , "tc": tpc_hc },
670
- ("Gas", "H2S"): {"constant": 0.16852 , "Tr_slope": -0.122378, "tc": tpc_hc },
671
- ("Gas", "N2"): {"constant": -0.108 , "Tr_slope": 0.0605506, "tc": tpc_hc },
672
- ("Gas", "H2"): {"constant": -0.0620119, "Tr_slope": 0.0427873, "tc": tpc_hc },
673
- ("CO2", "H2S"): {"constant": 0.248638 , "Tr_slope": -0.138185, "tc": 547.416 },
674
- ("CO2", "N2"): {"constant": -0.25 , "Tr_slope": 0.11602 , "tc": 547.416 },
675
- ("CO2", "H2"): {"constant": -0.247153 , "Tr_slope": 0.16377 , "tc": 547.416 },
676
- ("H2S", "N2"): {"constant": -0.204414 , "Tr_slope": 0.234417 , "tc": 672.12 },
677
- ("H2S", "H2"): {"constant": 0 , "Tr_slope": 0 , "tc": 672.12 },
678
- ("N2", "H2"): {"constant": -0.166253 , "Tr_slope": 0.0788129, "tc": 227.16 },
679
- }
680
- def lookup_key(i, j):
681
- return (i, j) if (i, j) in bip_parameters else (j, i)
682
- n = len(components)
683
- kij = np.zeros((n, n))
684
- for i, ci in enumerate(components):
685
- for j, cj in enumerate(components):
686
- if ci == cj:
687
- kij[i, j] = 0.0
688
- else:
689
- params = bip_parameters[lookup_key(ci, cj)]
690
- tc = params["tc"]
691
- const = params["constant"]
692
- slope = params["Tr_slope"]
693
- Tr = degR / tc
694
- kij[i, j] = const + slope / Tr
695
- return kij
696
-
697
720
  z = np.array([co2, h2s, n2, h2, 1 - co2 - h2s - n2 - h2])
698
-
699
- #if tc * pc == 0: # Critical properties have not been user specified
700
- # tc_peng, pc_peng = gas_tc_pc(sg, co2, h2s, n2, h2, cmethod = 'BNS')
701
-
702
- tcs[-1], pcs[-1] = tc, pc # Hydrocarbon Tc and Pc from SG using BNS correlation
721
+
722
+ tcs[-1], pcs[-1] = tc, pc # Hydrocarbon Tc and Pc from SG using BNS correlation
703
723
  trs = degR / tcs
704
-
724
+
705
725
  m = 0.37464 + 1.54226 * ACF - 0.26992 * ACF**2
706
- alpha = (1 + m * (1 - np.sqrt(trs)))**2
707
-
708
- kij = calc_bips(degR, tc)
709
-
710
- zout = []
711
- for psia in psias:
712
- prs = psia / pcs
713
- Ai, Bi = OmegaA * alpha * prs / trs**2, OmegaB * prs / trs
714
- A, B = np.sum(z[:, None] * z * np.sqrt(np.outer(Ai, Ai)) * (1 - kij)), np.sum(z * Bi)
715
-
716
- # Coefficients of Cubic: a[0] * Z**3 + a[1]*Z**2 + a[2]*Z + a[3] = 0
717
- a = [1, -(1 - B), A - 3 * B**2 - 2 * B, -(A * B - B**2 - B**3)]
718
- zout.append(cubic_root(a, flag = 1) - np.sum(z * VSHIFT * Bi)) # Volume translated Z
719
-
720
- return process_output(zout, is_list)
726
+ alpha = (1 + m * (1 - np.sqrt(trs)))**2
727
+
728
+ kij = _calc_bips_fast(degR, tc)
729
+
730
+ # Vectorized across all pressures (N = len(psias))
731
+ prs = psias[:, None] / pcs[None, :] # (N, 5)
732
+ Ai = OmegaA * alpha * prs / trs**2 # (N, 5)
733
+ Bi = OmegaB * prs / trs # (N, 5)
734
+
735
+ # Mixing rule A: A = sum_ij z_i*z_j*sqrt(Ai_i*Ai_j)*(1-kij)
736
+ sqrt_Ai = np.sqrt(Ai) # (N, 5)
737
+ w = z * sqrt_Ai # (N, 5)
738
+ onemk = 1.0 - kij # (5, 5)
739
+ A = np.sum((w @ onemk) * w, axis=1) # (N,)
740
+
741
+ # Mixing rule B
742
+ B = Bi @ z # (N,)
743
+
744
+ # Cubic coefficients: Z^3 + c2*Z^2 + c1*Z + c0 = 0
745
+ c2 = -(1.0 - B)
746
+ c1_coeff = A - 3.0 * B**2 - 2.0 * B
747
+ c0 = -(A * B - B**2 - B**3)
748
+
749
+ # Solve all cubics at once
750
+ Z_raw = _halley_cubic_vec(c2, c1_coeff, c0) # (N,)
751
+
752
+ # Volume translation
753
+ vshift = np.sum(z * VSHIFT * Bi, axis=1) # (N,)
754
+ zout = Z_raw - vshift
755
+
756
+ return process_output(zout, is_list)
721
757
 
722
758
  zfuncs = {"DAK": zdak, "HY": z_hy, "WYW": z_wyw, "BUR": z_bur}
723
759
 
@@ -790,78 +826,56 @@ def gas_ug(
790
826
 
791
827
  rho = m * p / (t * zee * R * 62.37)
792
828
 
793
- mws, tcs, pcs = _BNS_MWS.copy(), _BNS_TCS.copy(), _BNS_PCS.copy()
794
- ACF, VSHIFT = _BNS_ACF, _BNS_VSHIFT
795
- OmegaA, OmegaB, VCVIS = _BNS_OMEGAA, _BNS_OMEGAB, _BNS_VCVIS
796
-
797
- # From https://wiki.whitson.com/bopvt/visc_correlations/
798
- def lbc(Z, degf, psia, sg, co2=0.0, h2s=0.0, n2=0.0, h2 = 0.0):
799
- if co2 + h2s + n2 + h2 > 1 or co2 < 0 or h2s < 0 or n2 < 0 or h2 < 0:
800
- return None
801
- degR = degf + degF2R
802
- zi = np.array([co2, h2s, n2, h2, 1 - co2 - h2s - n2 - h2])
803
- if n2 + co2 + h2s + h2 < 1:
804
- sg_hc = (sg - (co2 * mws[0] + h2s * mws[1] + n2 * mws[2] + h2 * mws[3]) / MW_AIR) / (1 - co2 - h2s - n2 - h2)
805
- else:
806
- sg_hc = 0.75 # Irrelevant, since hydrocarbon fraction = 0
807
-
808
- sg_hc = max(sg_hc, 0.553779772) # Methane is lower limit
809
-
810
- hc_gas_mw = sg_hc * MW_AIR
811
-
812
- def vcvis_hc(mw): # Returns hydrocarbon gas VcVis for LBC viscosity calculations
813
- return 0.0576710 * (mw - 16.0425) + 1.44383 # ft3/lbmol
814
-
815
-
816
- mws[-1] = hc_gas_mw
817
- tcs[-1], pcs[-1] = gas_tc_pc(hc_gas_mw/MW_AIR, cmethod = 'BNS')
818
-
819
- VCVIS[-1] = vcvis_hc(hc_gas_mw)
820
- degR = degf + degF2R
821
-
822
- def stiel_thodos(degR, mws):
823
- #Calculate the viscosity of a pure component using the Stiel-Thodos correlation.
824
- Tr = degR / tcs
825
- ui = []
826
- Tc = tcs * 5/9 # (deg K)
827
- Pc = pcs / 14.696
828
- eta = Tc**(1/6) / (mws**(1/2) * Pc**(2/3)) # Tc and Pc must be in degK and Atm respectively
829
-
830
- for i in range(len(Tr)):
831
- if Tr[i] <= 1.5:
832
- ui.append(34e-5 * Tr[i]**0.94 / eta[i])
833
- else:
834
- ui.append(17.78e-5 * (4.58 * Tr[i] - 1.67)**(5/8) / eta[i])
835
- return np.array(ui)
836
-
837
- def u0(zi, ui, mws, Z): # dilute gas mixture viscosity from Herning and Zippener
838
- sqrt_mws = np.sqrt(mws)
839
- return np.sum(zi * ui * sqrt_mws)/np.sum(zi * sqrt_mws)
840
-
841
- a = [0.1023, 0.023364, 0.058533, -0.0392852, 0.00926279] # P3 and P4 have been modified
842
- # Calculate the viscosity of the mixture using the Lorenz-Bray-Clark method.
843
- rhoc = 1/np.sum(VCVIS*zi)
844
- Tc = tcs * 5/9 # (deg K)
845
- Pc = pcs / 14.696 # (Atm)
846
-
847
- eta = np.abs(np.sum(zi*Tc)**(1/6)) / (np.abs(np.sum(zi * mws))**0.5 * np.abs(np.sum(zi * Pc))**(2/3)) # Note 0.5 exponent from original paper
848
- mw = np.sum(zi * mws)
849
- rhor = psia / (Z * R * degR) / rhoc
850
- lhs = a[0] + a[1]*rhor + a[2]*rhor**2 + a[3]*rhor**3 + a[4]*rhor**4
851
- ui = stiel_thodos(degR, mws)
852
- vis = (lhs**4 - 1e-4)/eta + u0(zi, ui, mws, Z)
853
- return process_output(vis, is_list)
854
-
855
829
  if zmethod.name not in ('BNS', 'BUR'):
856
830
  b = 3.448 + (986.4 / t) + (0.01009 * m) # 2.16
857
831
  c = 2.447 - (0.2224 * b) # 2.17
858
832
  a = ((9.379 + (0.01607 * m)) * np.power(t, 1.5) / (209.2 + (19.26 * m) + t)) # 2.15
859
833
  ug = process_output(a * 0.0001 * np.exp(b * np.power(rho, c)), is_list) # 2.14
860
834
  else:
861
- ug = []
862
- for i, psia in enumerate(p):
863
- ug.append(lbc(zee[i], degf, psia, sg, co2, h2s, n2, h2))
864
- ug = process_output(ug, is_list)
835
+ # Vectorized LBC viscosity for BNS method
836
+ # From https://wiki.whitson.com/bopvt/visc_correlations/
837
+ mws_lbc = _BNS_MWS.copy()
838
+ tcs_lbc = _BNS_TCS.copy()
839
+ pcs_lbc = _BNS_PCS.copy()
840
+ VCVIS_lbc = _BNS_VCVIS.copy() # Copy to avoid mutating module-level array
841
+
842
+ degR = degf + degF2R
843
+ zi = np.array([co2, h2s, n2, h2, 1 - co2 - h2s - n2 - h2])
844
+ if n2 + co2 + h2s + h2 < 1:
845
+ sg_hc = (sg - (co2 * mws_lbc[0] + h2s * mws_lbc[1] + n2 * mws_lbc[2] + h2 * mws_lbc[3]) / MW_AIR) / (1 - co2 - h2s - n2 - h2)
846
+ else:
847
+ sg_hc = 0.75
848
+
849
+ sg_hc = max(sg_hc, 0.553779772)
850
+ hc_gas_mw = sg_hc * MW_AIR
851
+
852
+ mws_lbc[-1] = hc_gas_mw
853
+ tcs_lbc[-1], pcs_lbc[-1] = gas_tc_pc(hc_gas_mw / MW_AIR, cmethod='BNS')
854
+ VCVIS_lbc[-1] = 0.0576710 * (hc_gas_mw - 16.0425) + 1.44383
855
+
856
+ # Vectorized Stiel-Thodos
857
+ Tr = degR / tcs_lbc
858
+ Tc_K = tcs_lbc * 5.0 / 9.0
859
+ Pc_atm = pcs_lbc / 14.696
860
+ eta_st = Tc_K**(1.0/6.0) / (mws_lbc**0.5 * Pc_atm**(2.0/3.0))
861
+ ui_low = 34e-5 * Tr**0.94 / eta_st
862
+ ui_high = 17.78e-5 * np.maximum(4.58 * Tr - 1.67, 1e-30)**(5.0/8.0) / eta_st
863
+ ui = np.where(Tr <= 1.5, ui_low, ui_high)
864
+
865
+ # Herning-Zippener dilute gas mixture viscosity
866
+ sqrt_mws = np.sqrt(mws_lbc)
867
+ u0_val = np.sum(zi * ui * sqrt_mws) / np.sum(zi * sqrt_mws)
868
+
869
+ # LBC mixture parameters
870
+ a_lbc = np.array([0.1023, 0.023364, 0.058533, -0.0392852, 0.00926279])
871
+ rhoc = 1.0 / np.sum(VCVIS_lbc * zi)
872
+ eta_mix = np.abs(np.sum(zi * Tc_K))**(1.0/6.0) / (np.abs(np.sum(zi * mws_lbc))**0.5 * np.abs(np.sum(zi * Pc_atm))**(2.0/3.0))
873
+
874
+ # Vectorized over pressures
875
+ zee_arr, _ = convert_to_numpy(zee)
876
+ rhor = p / (zee_arr * R * degR * rhoc)
877
+ lhs = a_lbc[0] + rhor * (a_lbc[1] + rhor * (a_lbc[2] + rhor * (a_lbc[3] + rhor * a_lbc[4])))
878
+ ug = process_output((lhs**4 - 1e-4) / eta_mix + u0_val, is_list)
865
879
  if ugz:
866
880
  return process_output(ug * zee, is_list)
867
881
  else:
@@ -1191,23 +1205,51 @@ def gas_dmp(
1191
1205
  tc: Critical gas temperature (deg R). Calculates using cmethod if not specified
1192
1206
  pc: Critical gas pressure (psia). Calculates using cmethod if not specified
1193
1207
  """
1208
+ if p1 == p2:
1209
+ return 0
1210
+
1194
1211
  if h2 > 0:
1195
1212
  cmethod = 'BNS' # The BNS PR EOS method is the only one that can handle Hydrogen
1196
- zmethod = 'BNS'
1197
-
1198
- zmethod, cmethod = validate_methods(["zmethod", "cmethod"], [zmethod, cmethod])
1199
-
1200
- def m_p(p, *args):
1201
- # Pseudo pressure function to be integrated
1202
- degf, sg, zmethod, cmethod, tc, pc, n2, co2, h2s, h2 = args
1203
- zee = gas_z(p=p, degf=degf, sg=sg, zmethod=zmethod, cmethod=cmethod, co2=co2, h2s=h2s, n2=n2, h2 = h2, tc=tc, pc=pc)
1204
- mugz = gas_ug(p, sg, degf, zmethod, cmethod, co2, h2s, n2, h2, tc, pc, zee, ugz=True) # Gas viscosity z-factor product using a precalculated Z factor
1205
- return 2 * p / (mugz)
1213
+ zmethod = 'BNS'
1206
1214
 
1207
- if p1 == p2:
1208
- return 0
1215
+ zmethod, cmethod = validate_methods(["zmethod", "cmethod"], [zmethod, cmethod])
1209
1216
 
1210
- return quad(m_p, p1, p2, args=(degf, sg, zmethod, cmethod, tc, pc, n2, co2, h2s, h2), limit=500)[0]
1217
+ def _gl_integrate(lo, hi, nodes, weights):
1218
+ """Batch Gauss-Legendre integration of 2p/(mu*Z) over [lo, hi]."""
1219
+ p_mid = (lo + hi) * 0.5
1220
+ p_half = (hi - lo) * 0.5
1221
+ p_eval = p_mid + p_half * nodes
1222
+ zee = gas_z(p=p_eval, degf=degf, sg=sg, zmethod=zmethod, cmethod=cmethod,
1223
+ co2=co2, h2s=h2s, n2=n2, h2=h2, tc=tc, pc=pc)
1224
+ mugz = gas_ug(p_eval, sg, degf, zmethod, cmethod, co2, h2s, n2, h2, tc, pc, zee, ugz=True)
1225
+ return p_half * np.sum(weights * 2.0 * p_eval / mugz)
1226
+
1227
+ # Two-tier integration: compute with n=7 and n=10, compare for convergence
1228
+ result_7 = _gl_integrate(p1, p2, _GL7_NODES, _GL7_WEIGHTS)
1229
+ result_10 = _gl_integrate(p1, p2, _GL10_NODES, _GL10_WEIGHTS)
1230
+
1231
+ if abs(result_10) < 1e-30 or abs(result_10 - result_7) / abs(result_10) < 1e-5:
1232
+ return result_10
1233
+
1234
+ # If not converged, split into two subintervals and integrate with n=10 each (batch)
1235
+ p_mid = (p1 + p2) * 0.5
1236
+ p_half_lo = (p_mid - p1) * 0.5
1237
+ p_half_hi = (p2 - p_mid) * 0.5
1238
+ p_center_lo = (p1 + p_mid) * 0.5
1239
+ p_center_hi = (p_mid + p2) * 0.5
1240
+
1241
+ # Build all evaluation points for both subintervals in a single array
1242
+ p_eval = np.concatenate([p_center_lo + p_half_lo * _GL10_NODES,
1243
+ p_center_hi + p_half_hi * _GL10_NODES])
1244
+
1245
+ zee = gas_z(p=p_eval, degf=degf, sg=sg, zmethod=zmethod, cmethod=cmethod,
1246
+ co2=co2, h2s=h2s, n2=n2, h2=h2, tc=tc, pc=pc)
1247
+ mugz = gas_ug(p_eval, sg, degf, zmethod, cmethod, co2, h2s, n2, h2, tc, pc, zee, ugz=True)
1248
+ integrand = 2.0 * p_eval / mugz
1249
+
1250
+ n = len(_GL10_NODES)
1251
+ return (p_half_lo * np.sum(_GL10_WEIGHTS * integrand[:n]) +
1252
+ p_half_hi * np.sum(_GL10_WEIGHTS * integrand[n:]))
1211
1253
 
1212
1254
  def gas_fws_sg(sg_g: float, cgr: float, api_st: float) -> float:
1213
1255
  """
@@ -75,6 +75,83 @@ def process_output(input_data, is_list):
75
75
  # If it's a single value (not in a list or array), return it directly
76
76
  return float(input_data)
77
77
 
78
+ def halley_solve_cubic(c2, c1, c0, flag=1, max_iter=50, tol=1e-12):
79
+ """Solve Z^3 + c2*Z^2 + c1*Z + c0 = 0 via Halley's method.
80
+
81
+ flag=1: return max real root (vapor)
82
+ flag=-1: return min real root (liquid)
83
+ flag=0: return all real roots as array
84
+ Returns None if not converged (caller should use fallback).
85
+ """
86
+ # Find one root via Halley iteration from inflection point
87
+ Z = -c2 / 3.0
88
+ f = Z**3 + c2 * Z**2 + c1 * Z + c0
89
+ if f < 0:
90
+ Z = Z + 1.0 # Start on vapor side
91
+
92
+ converged = False
93
+ for _ in range(max_iter):
94
+ f = Z**3 + c2 * Z**2 + c1 * Z + c0
95
+ fp = 3.0 * Z**2 + 2.0 * c2 * Z + c1
96
+ fpp = 6.0 * Z + 2.0 * c2
97
+ if abs(fp) < 1e-30:
98
+ break
99
+ dZ = f / fp
100
+ denom = fp - 0.5 * dZ * fpp
101
+ if abs(denom) < 1e-30:
102
+ break
103
+ dZ = f / denom
104
+ Z -= dZ
105
+ if abs(dZ) < tol:
106
+ converged = True
107
+ break
108
+
109
+ if not converged:
110
+ return None
111
+
112
+ # Check residual
113
+ f = Z**3 + c2 * Z**2 + c1 * Z + c0
114
+ if abs(f) > 1e-6:
115
+ return None
116
+
117
+ r1 = Z
118
+
119
+ # Factor out converged root via synthetic division: Z^2 + e1*Z + e0
120
+ e1 = c2 + r1
121
+ e0 = c1 + r1 * e1
122
+
123
+ roots = [r1]
124
+
125
+ # Solve depressed quadratic
126
+ disc = e1**2 - 4.0 * e0
127
+ if disc >= 0:
128
+ sqrt_disc = np.sqrt(disc)
129
+ r2 = (-e1 + sqrt_disc) / 2.0
130
+ r3 = (-e1 - sqrt_disc) / 2.0
131
+
132
+ # Refine r2 with one Halley step (Michelsen pattern)
133
+ for r in (r2, r3):
134
+ f = r**3 + c2 * r**2 + c1 * r + c0
135
+ fp = 3.0 * r**2 + 2.0 * c2 * r + c1
136
+ fpp = 6.0 * r + 2.0 * c2
137
+ if abs(fp) > 1e-30:
138
+ dZ = f / fp
139
+ denom = fp - 0.5 * dZ * fpp
140
+ if abs(denom) > 1e-30:
141
+ r -= f / denom
142
+ roots.append(r)
143
+
144
+ roots = np.array(roots)
145
+
146
+ if flag == 0:
147
+ return roots
148
+ elif flag == 1:
149
+ return float(np.max(roots))
150
+ elif flag == -1:
151
+ return float(np.min(roots))
152
+ return roots
153
+
154
+
78
155
  def check_2_inputs(x: Union[float, List[float]], y: Union[float, List[float]]) -> bool:
79
156
  """ Check inputs that need to be matched, either both float or both lists/arrays of same length"""
80
157
  # Check if both are scalars
@@ -139,6 +139,63 @@ def test_regression_co2_xco2():
139
139
  assert abs(mix.x[0] - b['co2_xco2_200_80']) < 1e-8
140
140
 
141
141
 
142
+ # =============================================================================
143
+ # make_pvtw_table Tests
144
+ # =============================================================================
145
+
146
+ def test_make_pvtw_table_basic():
147
+ """Result has expected keys and correct row count"""
148
+ result = brine.make_pvtw_table(pi=3000, degf=200, wt=0, ch4_sat=0, nrows=10)
149
+ expected_keys = {'table', 'pref', 'bw_ref', 'cw_ref', 'visw_ref', 'rsw_ref', 'den_ref'}
150
+ assert expected_keys == set(result.keys()), f"Missing keys: {expected_keys - set(result.keys())}"
151
+ # nrows=10 plus pi if not already in grid
152
+ assert len(result['table']) >= 10, f"Expected at least 10 rows, got {len(result['table'])}"
153
+ assert result['pref'] == 3000, f"pref should be 3000, got {result['pref']}"
154
+
155
+ def test_make_pvtw_table_bw_range():
156
+ """Bw values should be in reasonable range (0.9 - 1.2)"""
157
+ result = brine.make_pvtw_table(pi=3000, degf=200, wt=0, ch4_sat=0)
158
+ bw_vals = result['table']['Bw (rb/stb)'].values
159
+ assert all(0.9 < bw < 1.2 for bw in bw_vals), f"Bw values outside 0.9-1.2: {bw_vals}"
160
+
161
+ def test_make_pvtw_table_ref_vs_direct():
162
+ """Reference properties should match direct brine_props() call"""
163
+ pi, degf, wt, ch4_sat = 3000, 200, 5, 0.5
164
+ result = brine.make_pvtw_table(pi=pi, degf=degf, wt=wt, ch4_sat=ch4_sat)
165
+ bw, lden, visw, cw, rsw = brine.brine_props(p=pi, degf=degf, wt=wt, ch4_sat=ch4_sat)
166
+ assert abs(result['bw_ref'] - bw) < 1e-10, f"bw_ref mismatch: {result['bw_ref']} vs {bw}"
167
+ assert abs(result['den_ref'] - lden) < 1e-10, f"den_ref mismatch"
168
+ assert abs(result['visw_ref'] - visw) < 1e-10, f"visw_ref mismatch"
169
+ assert abs(result['cw_ref'] - cw) < 1e-10, f"cw_ref mismatch"
170
+ assert abs(result['rsw_ref'] - rsw) < 1e-10, f"rsw_ref mismatch"
171
+
172
+ def test_make_pvtw_table_pi_included():
173
+ """pi should appear in the pressure grid"""
174
+ pi = 3333
175
+ result = brine.make_pvtw_table(pi=pi, degf=200, wt=0, ch4_sat=0)
176
+ pressures = result['table']['Pressure (psia)'].values
177
+ assert pi in pressures, f"pi={pi} not found in pressure grid: {pressures}"
178
+
179
+ def test_make_pvtw_table_saline():
180
+ """Saline brine density should be greater than freshwater density"""
181
+ result_fresh = brine.make_pvtw_table(pi=3000, degf=200, wt=0, ch4_sat=0)
182
+ result_saline = brine.make_pvtw_table(pi=3000, degf=200, wt=10, ch4_sat=0)
183
+ assert result_saline['den_ref'] > result_fresh['den_ref'], \
184
+ f"Saline density ({result_saline['den_ref']}) should exceed freshwater ({result_fresh['den_ref']})"
185
+
186
+ # =============================================================================
187
+ # SoreideWhitson Tests
188
+ # =============================================================================
189
+
190
+ def test_soreide_whitson_not_implemented():
191
+ """SoreideWhitson should raise NotImplementedError"""
192
+ try:
193
+ brine.SoreideWhitson()
194
+ assert False, "Expected NotImplementedError"
195
+ except NotImplementedError as e:
196
+ assert "not yet implemented" in str(e).lower(), f"Unexpected error message: {e}"
197
+
198
+
142
199
  if __name__ == '__main__':
143
200
  print("=" * 70)
144
201
  print("BRINE MODULE VALIDATION TESTS")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyrestoolbox
3
- Version: 2.2
3
+ Version: 2.2.1
4
4
  Summary: pyResToolbox - A collection of Reservoir Engineering Utilities
5
5
  Home-page: https://github.com/mwburgoyne/pyResToolbox
6
6
  Author: Mark W. Burgoyne
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = pyrestoolbox
3
- version = 2.2
3
+ version = 2.2.1
4
4
  author = Mark W. Burgoyne
5
5
  author_email = mark.w.burgoyne@gmail.com
6
6
  description = pyResToolbox - A collection of Reservoir Engineering Utilities
@@ -12,7 +12,7 @@ with open(os.path.join(ROOT, 'README.md'), 'r', encoding='utf-8') as f:
12
12
  setup(
13
13
  name='pyrestoolbox',
14
14
  include_package_data=True,
15
- version='2.2', # Ideally should be same as your GitHub release tag version
15
+ version='2.2.1', # Ideally should be same as your GitHub release tag version
16
16
  packages=find_packages(),
17
17
  description='pyResToolbox - A collection of Reservoir Engineering Utilities',
18
18
  license="GNU General Public License v3 or later (GPLv3+)",
File without changes
File without changes
File without changes
File without changes
File without changes