prbs-eos 0.1.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 (38) hide show
  1. prbs_eos-0.1.0/MANIFEST.in +5 -0
  2. prbs_eos-0.1.0/PKG-INFO +67 -0
  3. prbs_eos-0.1.0/prbs_eos/__init__.py +6 -0
  4. prbs_eos-0.1.0/prbs_eos/__main__.py +4 -0
  5. prbs_eos-0.1.0/prbs_eos/cli.py +308 -0
  6. prbs_eos-0.1.0/prbs_eos/constants.py +1 -0
  7. prbs_eos-0.1.0/prbs_eos/core/__init__.py +4 -0
  8. prbs_eos-0.1.0/prbs_eos/core/cubic_solver.py +30 -0
  9. prbs_eos-0.1.0/prbs_eos/core/eos_base.py +84 -0
  10. prbs_eos-0.1.0/prbs_eos/core/pr_eos.py +66 -0
  11. prbs_eos-0.1.0/prbs_eos/core/prbs_eos.py +100 -0
  12. prbs_eos-0.1.0/prbs_eos/data/__init__.py +3 -0
  13. prbs_eos-0.1.0/prbs_eos/data/component.py +25 -0
  14. prbs_eos-0.1.0/prbs_eos/data/component_db.py +59 -0
  15. prbs_eos-0.1.0/prbs_eos/data/mixture.py +43 -0
  16. prbs_eos-0.1.0/prbs_eos/equilibrium/__init__.py +3 -0
  17. prbs_eos-0.1.0/prbs_eos/equilibrium/flash.py +83 -0
  18. prbs_eos-0.1.0/prbs_eos/equilibrium/phase_envelope.py +55 -0
  19. prbs_eos-0.1.0/prbs_eos/equilibrium/stability.py +36 -0
  20. prbs_eos-0.1.0/prbs_eos/pvt/__init__.py +0 -0
  21. prbs_eos-0.1.0/prbs_eos/pvt/export.py +0 -0
  22. prbs_eos-0.1.0/prbs_eos/pvt/pvt_tables.py +0 -0
  23. prbs_eos-0.1.0/prbs_eos/pvt/separator.py +0 -0
  24. prbs_eos-0.1.0/prbs_eos/regression/__init__.py +2 -0
  25. prbs_eos-0.1.0/prbs_eos/regression/eos_regression.py +39 -0
  26. prbs_eos-0.1.0/prbs_eos/regression/vpaa_regressor.py +34 -0
  27. prbs_eos-0.1.0/prbs_eos/thermodynamics/__init__.py +1 -0
  28. prbs_eos-0.1.0/prbs_eos/thermodynamics/departure.py +61 -0
  29. prbs_eos-0.1.0/prbs_eos.egg-info/PKG-INFO +67 -0
  30. prbs_eos-0.1.0/prbs_eos.egg-info/SOURCES.txt +38 -0
  31. prbs_eos-0.1.0/prbs_eos.egg-info/dependency_links.txt +1 -0
  32. prbs_eos-0.1.0/prbs_eos.egg-info/entry_points.txt +2 -0
  33. prbs_eos-0.1.0/prbs_eos.egg-info/requires.txt +10 -0
  34. prbs_eos-0.1.0/prbs_eos.egg-info/top_level.txt +1 -0
  35. prbs_eos-0.1.0/pyproject.toml +112 -0
  36. prbs_eos-0.1.0/requirements-dev.txt +12 -0
  37. prbs_eos-0.1.0/requirements.txt +6 -0
  38. prbs_eos-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,5 @@
1
+ include pyproject.toml
2
+ include requirements.txt
3
+ include requirements-dev.txt
4
+ recursive-include prbs_eos *.py
5
+ prune prbs_eos/tests
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: prbs-eos
3
+ Version: 0.1.0
4
+ Summary: PRBS Equation of State — Peng-Robinson with volume-shift and CPA association for petroleum thermodynamics
5
+ Author: PRBS-EOS Contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/your-org/prbs-eos
8
+ Project-URL: Repository, https://github.com/your-org/prbs-eos
9
+ Project-URL: Issues, https://github.com/your-org/prbs-eos/issues
10
+ Keywords: thermodynamics,equation-of-state,peng-robinson,petroleum,flash,VLE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
18
+ Classifier: Topic :: Scientific/Engineering :: Physics
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: numpy>=1.24
22
+ Requires-Dist: scipy>=1.10
23
+ Requires-Dist: pandas>=2.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.4; extra == "dev"
26
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
27
+ Requires-Dist: build>=1.0; extra == "dev"
28
+ Requires-Dist: twine>=4.0; extra == "dev"
29
+ Requires-Dist: pyinstaller>=6.0; extra == "dev"
30
+
31
+ # prbs-eos
32
+
33
+ **PRBS Equation of State** — a Python library for petroleum thermodynamics.
34
+
35
+ ## Features
36
+
37
+ - Peng-Robinson EOS (PR) with van der Waals mixing rules and binary interaction parameters (kij)
38
+ - PRBS-EOS: PR + Peneloux volume-shift + CPA-style association (e.g. water, H₂S)
39
+ - Two-phase PT flash (Rachford-Rice + successive substitution)
40
+ - Tangent-plane stability analysis (Michelsen TPD)
41
+ - Bubble-point / dew-point pressure solvers (Wilson K-factor)
42
+ - Enthalpy and entropy departure functions
43
+ - kij regression from VLE data; vapour-pressure / acentric-factor regression
44
+ - Built-in database: methane, ethane, propane, n-butane … n-octane, N₂, CO₂, water, H₂S
45
+ - Command-line interface: `prbs-eos flash`, `prbs-eos bubble`, `prbs-eos properties`, …
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ import numpy as np
51
+ import prbs_eos as eos
52
+
53
+ mix = eos.Mixture([eos.get_component("methane"), eos.get_component("propane")])
54
+ pr = eos.PREOS(mix)
55
+
56
+ res = eos.flash_pt(pr, T=250.0, P=1e6, z=np.array([0.6, 0.4]))
57
+ print(f"β = {res.beta:.3f} x = {res.x} y = {res.y}")
58
+ ```
59
+
60
+ ## CLI
61
+
62
+ ```
63
+ prbs-eos flash --T 250 --P 1e6 --mix methane:0.6 propane:0.4
64
+ prbs-eos bubble --T 250 --mix methane:0.5 propane:0.5
65
+ prbs-eos properties methane
66
+ prbs-eos components
67
+ ```
@@ -0,0 +1,6 @@
1
+ from .constants import R
2
+ from .data import Component, Mixture, get_component, available_components
3
+ from .core import PREOS, PRBSEOS, solve_cubic, select_z
4
+ from .equilibrium import flash_pt, FlashResult, is_stable
5
+ from .thermodynamics import enthalpy_departure, entropy_departure
6
+ from .regression import regress_kij, regress_vpaa
@@ -0,0 +1,4 @@
1
+ """Allow running the CLI via ``python -m prbs_eos``."""
2
+ from .cli import main
3
+
4
+ main()
@@ -0,0 +1,308 @@
1
+ """
2
+ prbs-eos command-line interface.
3
+
4
+ Usage examples
5
+ --------------
6
+ prbs-eos components
7
+ prbs-eos properties methane
8
+ prbs-eos flash --T 250 --P 1e6 --mix methane:0.6 propane:0.4
9
+ prbs-eos zfactor --T 300 --P 2e6 --mix methane:0.7 propane:0.3 --phase vapor
10
+ prbs-eos fugacity --T 300 --P 2e6 --mix methane:0.7 propane:0.3 --phase vapor
11
+ prbs-eos bubble --T 250 --mix methane:0.5 propane:0.5 --P-guess 2e6
12
+ prbs-eos dew --T 250 --mix methane:0.5 propane:0.5 --P-guess 2e6
13
+ prbs-eos enthalpy --T 300 --P 2e6 --mix methane:0.7 propane:0.3 --phase vapor
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ import numpy as np
20
+
21
+ from . import (
22
+ Mixture, PREOS, get_component, available_components,
23
+ flash_pt, enthalpy_departure, entropy_departure,
24
+ )
25
+ from .equilibrium.phase_envelope import bubble_point_P, dew_point_P
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Helpers
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def _parse_mix(mix_tokens: list[str]) -> tuple[Mixture, np.ndarray]:
33
+ """
34
+ Parse ``['methane:0.6', 'propane:0.4']`` into a (Mixture, z) pair.
35
+ Mole fractions are normalised to sum to 1.
36
+ """
37
+ comps, fracs = [], []
38
+ for token in mix_tokens:
39
+ if ":" not in token:
40
+ _die(f"Bad mixture token '{token}'. Expected format: name:fraction")
41
+ name, frac_str = token.rsplit(":", 1)
42
+ try:
43
+ frac = float(frac_str)
44
+ except ValueError:
45
+ _die(f"Cannot parse fraction '{frac_str}' in token '{token}'")
46
+ if frac < 0:
47
+ _die(f"Negative mole fraction in token '{token}'")
48
+ comps.append(get_component(name.strip()))
49
+ fracs.append(frac)
50
+ z = np.array(fracs, dtype=float)
51
+ total = z.sum()
52
+ if total <= 0:
53
+ _die("Mole fractions sum to zero.")
54
+ z /= total
55
+ return Mixture(comps), z
56
+
57
+
58
+ def _die(msg: str) -> None:
59
+ print(f"ERROR: {msg}", file=sys.stderr)
60
+ sys.exit(1)
61
+
62
+
63
+ def _out(data: dict, as_json: bool) -> None:
64
+ """Print result as a formatted table or JSON."""
65
+ if as_json:
66
+ print(json.dumps(data, indent=2))
67
+ else:
68
+ for key, val in data.items():
69
+ if isinstance(val, dict):
70
+ print(f" {key}:")
71
+ for k2, v2 in val.items():
72
+ print(f" {k2:<20s} {v2}")
73
+ else:
74
+ print(f" {key:<26s} {val}")
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Sub-command handlers
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def cmd_components(_args) -> None:
82
+ print("Built-in components:")
83
+ for name in available_components():
84
+ print(f" {name}")
85
+
86
+
87
+ def cmd_properties(args) -> None:
88
+ try:
89
+ c = get_component(args.name)
90
+ except KeyError as e:
91
+ _die(str(e))
92
+ data = {
93
+ "name": c.name,
94
+ "Tc (K)": f"{c.Tc:.2f}",
95
+ "Pc (MPa)": f"{c.Pc / 1e6:.4f}",
96
+ "omega": f"{c.omega:.4f}",
97
+ "Mw (g/mol)": f"{c.Mw:.3f}",
98
+ "c (m3/mol)": f"{c.c:.3e}",
99
+ "epsilon (J/mol)": f"{c.epsilon:.2f}",
100
+ "kappa": f"{c.kappa:.4f}",
101
+ "assoc_sites": str(c.assoc_sites),
102
+ }
103
+ _out(data, args.json)
104
+
105
+
106
+ def cmd_flash(args) -> None:
107
+ try:
108
+ mix, z = _parse_mix(args.mix)
109
+ except KeyError as e:
110
+ _die(str(e))
111
+ eos = PREOS(mix)
112
+ res = flash_pt(eos, T=args.T, P=args.P, z=z)
113
+ names = [c.name for c in mix.components]
114
+ data = {
115
+ "T (K)": f"{args.T:.2f}",
116
+ "P (MPa)": f"{args.P / 1e6:.4f}",
117
+ "vapour fraction beta":f"{res.beta:.6f}",
118
+ "converged": str(res.converged),
119
+ "iterations": str(res.iterations),
120
+ "liquid x": {n: f"{v:.6f}" for n, v in zip(names, res.x)},
121
+ "vapour y": {n: f"{v:.6f}" for n, v in zip(names, res.y)},
122
+ "K-factors": {n: f"{v:.6f}" for n, v in zip(names, res.K)},
123
+ }
124
+ _out(data, args.json)
125
+
126
+
127
+ def cmd_zfactor(args) -> None:
128
+ try:
129
+ mix, z = _parse_mix(args.mix)
130
+ except KeyError as e:
131
+ _die(str(e))
132
+ eos = PREOS(mix)
133
+ Z = eos.Z_factor(T=args.T, P=args.P, z=z, phase=args.phase)
134
+ data = {
135
+ "T (K)": f"{args.T:.2f}",
136
+ "P (MPa)": f"{args.P / 1e6:.4f}",
137
+ "phase": args.phase,
138
+ "Z-factor": f"{Z:.6f}",
139
+ }
140
+ _out(data, args.json)
141
+
142
+
143
+ def cmd_fugacity(args) -> None:
144
+ try:
145
+ mix, z = _parse_mix(args.mix)
146
+ except KeyError as e:
147
+ _die(str(e))
148
+ eos = PREOS(mix)
149
+ phi = eos.fugacity_coeff(T=args.T, P=args.P, z=z, phase=args.phase)
150
+ f = eos.fugacity(T=args.T, P=args.P, z=z, phase=args.phase)
151
+ names = [c.name for c in mix.components]
152
+ data = {
153
+ "T (K)": f"{args.T:.2f}",
154
+ "P (MPa)": f"{args.P / 1e6:.4f}",
155
+ "phase": args.phase,
156
+ "fugacity coefficients phi_i": {n: f"{v:.6f}" for n, v in zip(names, phi)},
157
+ "fugacities f_i (Pa)": {n: f"{v:.4e}" for n, v in zip(names, f)},
158
+ }
159
+ _out(data, args.json)
160
+
161
+
162
+ def cmd_bubble(args) -> None:
163
+ try:
164
+ mix, z = _parse_mix(args.mix)
165
+ except KeyError as e:
166
+ _die(str(e))
167
+ eos = PREOS(mix)
168
+ Pb = bubble_point_P(eos, T=args.T, z=z, P_guess=args.P_guess)
169
+ data = {
170
+ "T (K)": f"{args.T:.2f}",
171
+ "bubble-point P (Pa)": "nan (supercritical or out of range)" if np.isnan(Pb) else f"{Pb:.2f}",
172
+ "bubble-point P (MPa)":"nan" if np.isnan(Pb) else f"{Pb / 1e6:.4f}",
173
+ }
174
+ _out(data, args.json)
175
+
176
+
177
+ def cmd_dew(args) -> None:
178
+ try:
179
+ mix, z = _parse_mix(args.mix)
180
+ except KeyError as e:
181
+ _die(str(e))
182
+ eos = PREOS(mix)
183
+ Pd = dew_point_P(eos, T=args.T, z=z, P_guess=args.P_guess)
184
+ data = {
185
+ "T (K)": f"{args.T:.2f}",
186
+ "dew-point P (Pa)": "nan (supercritical or out of range)" if np.isnan(Pd) else f"{Pd:.2f}",
187
+ "dew-point P (MPa)": "nan" if np.isnan(Pd) else f"{Pd / 1e6:.4f}",
188
+ }
189
+ _out(data, args.json)
190
+
191
+
192
+ def cmd_enthalpy(args) -> None:
193
+ try:
194
+ mix, z = _parse_mix(args.mix)
195
+ except KeyError as e:
196
+ _die(str(e))
197
+ eos = PREOS(mix)
198
+ H = enthalpy_departure(eos, T=args.T, P=args.P, z=z, phase=args.phase)
199
+ S = entropy_departure(eos, T=args.T, P=args.P, z=z, phase=args.phase)
200
+ data = {
201
+ "T (K)": f"{args.T:.2f}",
202
+ "P (MPa)": f"{args.P / 1e6:.4f}",
203
+ "phase": args.phase,
204
+ "H_dep (J/mol)": f"{H:.4f}",
205
+ "S_dep (J/mol/K)": f"{S:.6f}",
206
+ "G_dep = H-TS (J/mol)": f"{H - args.T * S:.4f}",
207
+ }
208
+ _out(data, args.json)
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Argument parser
213
+ # ---------------------------------------------------------------------------
214
+
215
+ def _build_parser() -> argparse.ArgumentParser:
216
+ parser = argparse.ArgumentParser(
217
+ prog="prbs-eos",
218
+ description=(
219
+ "PRBS Equation of State — petroleum thermodynamics toolkit.\n"
220
+ "Run 'prbs-eos <command> --help' for per-command options."
221
+ ),
222
+ formatter_class=argparse.RawDescriptionHelpFormatter,
223
+ )
224
+ parser.add_argument("--version", action="version", version="prbs-eos 0.1.0")
225
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
226
+ sub.required = True
227
+
228
+ # ---- shared options ------------------------------------------------
229
+ def add_mix(p):
230
+ p.add_argument(
231
+ "--mix", nargs="+", required=True, metavar="NAME:FRAC",
232
+ help="Mixture components, e.g. methane:0.6 propane:0.4"
233
+ )
234
+ def add_TP(p):
235
+ p.add_argument("--T", type=float, required=True, metavar="K",
236
+ help="Temperature [K]")
237
+ p.add_argument("--P", type=float, required=True, metavar="Pa",
238
+ help="Pressure [Pa] (e.g. 2e6 = 2 MPa)")
239
+ def add_phase(p):
240
+ p.add_argument("--phase", choices=["vapor", "liquid"], default="vapor",
241
+ help="Phase selection (default: vapor)")
242
+ def add_json(p):
243
+ p.add_argument("--json", action="store_true",
244
+ help="Output as JSON")
245
+ def add_P_guess(p):
246
+ p.add_argument("--P-guess", type=float, default=1e6, dest="P_guess",
247
+ metavar="Pa",
248
+ help="Initial pressure guess for bracketing [Pa] (default: 1e6)")
249
+
250
+ # ---- components ----------------------------------------------------
251
+ p = sub.add_parser("components", help="List all built-in components")
252
+ add_json(p)
253
+ p.set_defaults(func=cmd_components)
254
+
255
+ # ---- properties ----------------------------------------------------
256
+ p = sub.add_parser("properties", help="Show critical properties of a component")
257
+ p.add_argument("name", help="Component name (e.g. methane, co2, water)")
258
+ add_json(p)
259
+ p.set_defaults(func=cmd_properties)
260
+
261
+ # ---- flash ---------------------------------------------------------
262
+ p = sub.add_parser("flash", help="Two-phase PT flash (Rachford-Rice)")
263
+ add_TP(p); add_mix(p); add_json(p)
264
+ p.set_defaults(func=cmd_flash)
265
+
266
+ # ---- zfactor -------------------------------------------------------
267
+ p = sub.add_parser("zfactor", help="Compressibility factor Z")
268
+ add_TP(p); add_mix(p); add_phase(p); add_json(p)
269
+ p.set_defaults(func=cmd_zfactor)
270
+
271
+ # ---- fugacity ------------------------------------------------------
272
+ p = sub.add_parser("fugacity", help="Fugacity coefficients and fugacities")
273
+ add_TP(p); add_mix(p); add_phase(p); add_json(p)
274
+ p.set_defaults(func=cmd_fugacity)
275
+
276
+ # ---- bubble --------------------------------------------------------
277
+ p = sub.add_parser("bubble", help="Bubble-point pressure at fixed T")
278
+ p.add_argument("--T", type=float, required=True, metavar="K")
279
+ add_mix(p); add_P_guess(p); add_json(p)
280
+ p.set_defaults(func=cmd_bubble)
281
+
282
+ # ---- dew -----------------------------------------------------------
283
+ p = sub.add_parser("dew", help="Dew-point pressure at fixed T")
284
+ p.add_argument("--T", type=float, required=True, metavar="K")
285
+ add_mix(p); add_P_guess(p); add_json(p)
286
+ p.set_defaults(func=cmd_dew)
287
+
288
+ # ---- enthalpy ------------------------------------------------------
289
+ p = sub.add_parser("enthalpy",
290
+ help="Enthalpy/entropy departure functions (H_dep, S_dep, G_dep)")
291
+ add_TP(p); add_mix(p); add_phase(p); add_json(p)
292
+ p.set_defaults(func=cmd_enthalpy)
293
+
294
+ return parser
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Entry point
299
+ # ---------------------------------------------------------------------------
300
+
301
+ def main(argv: list[str] | None = None) -> None:
302
+ parser = _build_parser()
303
+ args = parser.parse_args(argv)
304
+ args.func(args)
305
+
306
+
307
+ if __name__ == "__main__":
308
+ main()
@@ -0,0 +1 @@
1
+ R = 8.314462618 # J/(mol·K)
@@ -0,0 +1,4 @@
1
+ from .cubic_solver import solve_cubic, select_z
2
+ from .eos_base import CubicEOS
3
+ from .pr_eos import PREOS
4
+ from .prbs_eos import PRBSEOS
@@ -0,0 +1,30 @@
1
+ """
2
+ Analytical and numerical roots of the cubic EOS in Z-factor form:
3
+ Z³ + p·Z² + q·Z + r = 0
4
+ """
5
+ import numpy as np
6
+
7
+
8
+ def solve_cubic(p: float, q: float, r: float) -> np.ndarray:
9
+ """Return all real roots of Z³ + p·Z² + q·Z + r = 0, sorted ascending."""
10
+ coeffs = [1.0, p, q, r]
11
+ roots = np.roots(coeffs)
12
+ real_roots = roots[np.abs(roots.imag) < 1e-8].real
13
+ return np.sort(real_roots)
14
+
15
+
16
+ def select_z(roots: np.ndarray, phase: str) -> float:
17
+ """
18
+ From the array of real positive Z roots select the thermodynamically
19
+ correct one for the requested phase ('vapor' or 'liquid').
20
+ """
21
+ pos = roots[roots > 0]
22
+ if len(pos) == 0:
23
+ raise ValueError("No positive Z root found.")
24
+ if len(pos) == 1:
25
+ return float(pos[0])
26
+ if phase == "vapor":
27
+ return float(pos.max())
28
+ if phase == "liquid":
29
+ return float(pos.min())
30
+ raise ValueError(f"Unknown phase '{phase}'. Use 'vapor' or 'liquid'.")
@@ -0,0 +1,84 @@
1
+ """Abstract base for cubic EOS implementations."""
2
+ from abc import ABC, abstractmethod
3
+ import numpy as np
4
+ from ..data.mixture import Mixture
5
+
6
+
7
+ class CubicEOS(ABC):
8
+ """
9
+ Subclasses must implement `_ab_pure` and `_mix_rule`.
10
+ Public API: `Z_factor`, `fugacity_coeff`.
11
+ """
12
+
13
+ def __init__(self, mixture: Mixture):
14
+ self.mix = mixture
15
+
16
+ # ------------------------------------------------------------------
17
+ # Abstract
18
+ # ------------------------------------------------------------------
19
+ @abstractmethod
20
+ def _ab_pure(self, T: float) -> tuple[np.ndarray, np.ndarray]:
21
+ """Return arrays (a_i, b_i) for each component at temperature T."""
22
+
23
+ @abstractmethod
24
+ def _mix_rule(
25
+ self, T: float, z: np.ndarray
26
+ ) -> tuple[float, float, np.ndarray]:
27
+ """
28
+ Return (a_mix, b_mix, (da/dni)_T) using the van der Waals mixing rule
29
+ with kij corrections.
30
+ """
31
+
32
+ @abstractmethod
33
+ def _cubic_coeffs(
34
+ self, T: float, P: float, a: float, b: float
35
+ ) -> tuple[float, float, float]:
36
+ """Return (p, q, r) for Z³ + p·Z² + q·Z + r = 0."""
37
+
38
+ @abstractmethod
39
+ def _ln_phi_i(
40
+ self,
41
+ i: int,
42
+ Z: float,
43
+ T: float,
44
+ P: float,
45
+ z: np.ndarray,
46
+ a: float,
47
+ b: float,
48
+ dai_dn: np.ndarray,
49
+ ) -> float:
50
+ """Return ln φ_i for component i."""
51
+
52
+ # ------------------------------------------------------------------
53
+ # Public
54
+ # ------------------------------------------------------------------
55
+ def Z_factor(
56
+ self, T: float, P: float, z: np.ndarray, phase: str = "vapor"
57
+ ) -> float:
58
+ from .cubic_solver import solve_cubic, select_z
59
+ z = np.asarray(z, dtype=float)
60
+ a, b, _ = self._mix_rule(T, z)
61
+ p, q, r = self._cubic_coeffs(T, P, a, b)
62
+ roots = solve_cubic(p, q, r)
63
+ return select_z(roots, phase)
64
+
65
+ def fugacity_coeff(
66
+ self, T: float, P: float, z: np.ndarray, phase: str = "vapor"
67
+ ) -> np.ndarray:
68
+ z = np.asarray(z, dtype=float)
69
+ a, b, dai_dn = self._mix_rule(T, z)
70
+ p, q, r = self._cubic_coeffs(T, P, a, b)
71
+ from .cubic_solver import solve_cubic, select_z
72
+ roots = solve_cubic(p, q, r)
73
+ Z = select_z(roots, phase)
74
+ nc = self.mix.nc
75
+ ln_phi = np.array(
76
+ [self._ln_phi_i(i, Z, T, P, z, a, b, dai_dn) for i in range(nc)]
77
+ )
78
+ return np.exp(ln_phi)
79
+
80
+ def fugacity(
81
+ self, T: float, P: float, z: np.ndarray, phase: str = "vapor"
82
+ ) -> np.ndarray:
83
+ z = np.asarray(z, dtype=float)
84
+ return z * P * self.fugacity_coeff(T, P, z, phase)
@@ -0,0 +1,66 @@
1
+ """
2
+ Peng-Robinson EOS (1976).
3
+ P = RT/(V-b) - a(T)/[V(V+b)+b(V-b)]
4
+ """
5
+ import numpy as np
6
+ from ..constants import R
7
+ from ..data.mixture import Mixture
8
+ from .eos_base import CubicEOS
9
+
10
+
11
+ class PREOS(CubicEOS):
12
+ # PR universal constants
13
+ _OmegaA = 0.45723553
14
+ _OmegaB = 0.07779607
15
+
16
+ def __init__(self, mixture: Mixture):
17
+ super().__init__(mixture)
18
+
19
+ # ------------------------------------------------------------------
20
+ def _kappa(self) -> np.ndarray:
21
+ w = self.mix.omega
22
+ return 0.37464 + 1.54226 * w - 0.26992 * w ** 2
23
+
24
+ def _ab_pure(self, T: float):
25
+ Tc = self.mix.Tc
26
+ Pc = self.mix.Pc
27
+ kappa = self._kappa()
28
+ Tr = T / Tc
29
+ alpha = (1.0 + kappa * (1.0 - np.sqrt(Tr))) ** 2
30
+ a = self._OmegaA * (R * Tc) ** 2 / Pc * alpha
31
+ b = self._OmegaB * R * Tc / Pc
32
+ return a, b
33
+
34
+ def _mix_rule(self, T: float, z: np.ndarray):
35
+ a_i, b_i = self._ab_pure(T)
36
+ kij = self.mix.kij
37
+ nc = self.mix.nc
38
+ # a_ij = sqrt(a_i * a_j) * (1 - kij)
39
+ a_ij = np.sqrt(np.outer(a_i, a_i)) * (1.0 - kij)
40
+ a_mix = float(z @ a_ij @ z)
41
+ b_mix = float(z @ b_i)
42
+ # da_mix/dn_i (at constant n_j≠i, normalised mole fraction)
43
+ dai_dn = 2.0 * (a_ij @ z)
44
+ return a_mix, b_mix, dai_dn
45
+
46
+ def _cubic_coeffs(self, T: float, P: float, a: float, b: float):
47
+ A = a * P / (R * T) ** 2
48
+ B = b * P / (R * T)
49
+ p = B - 1.0
50
+ q = A - 3.0 * B ** 2 - 2.0 * B
51
+ r = B ** 3 + B ** 2 - A * B
52
+ return p, q, r
53
+
54
+ def _ln_phi_i(self, i, Z, T, P, z, a, b, dai_dn):
55
+ a_i, b_i = self._ab_pure(T)
56
+ A = a * P / (R * T) ** 2
57
+ B = b * P / (R * T)
58
+ bi = b_i[i]
59
+ ln_phi = (
60
+ bi / b * (Z - 1.0)
61
+ - np.log(Z - B)
62
+ - A / (2.0 * np.sqrt(2.0) * B)
63
+ * (dai_dn[i] / a - bi / b)
64
+ * np.log((Z + (1.0 + np.sqrt(2.0)) * B) / (Z + (1.0 - np.sqrt(2.0)) * B))
65
+ )
66
+ return ln_phi
@@ -0,0 +1,100 @@
1
+ """
2
+ PRBS-EOS: Peng-Robinson with Boublik-Mansoori-Carnahan-Starling (BMCS)
3
+ hard-sphere repulsion term and a simplified CPA-style association
4
+ contribution, combined with a Peneloux volume shift.
5
+
6
+ For non-associating mixtures this reduces to standard PR with a
7
+ volume shift, so it is backward-compatible with PREOS.
8
+ """
9
+ import numpy as np
10
+ from scipy.optimize import brentq
11
+ from ..constants import R
12
+ from ..data.mixture import Mixture
13
+ from .pr_eos import PREOS
14
+
15
+
16
+ class PRBSEOS(PREOS):
17
+ """
18
+ PRBS = PR + volume-shift (Peneloux) + optional association (CPA-style).
19
+
20
+ Association contribution uses a 4-site model for self-associating
21
+ components (e.g. water). Cross-association is neglected here.
22
+ """
23
+
24
+ def __init__(self, mixture: Mixture):
25
+ super().__init__(mixture)
26
+ self._c = np.array([comp.c for comp in mixture.components])
27
+ self._eps = np.array([comp.epsilon for comp in mixture.components])
28
+ self._kap = np.array([comp.kappa for comp in mixture.components])
29
+ self._sites = np.array([comp.assoc_sites for comp in mixture.components], dtype=int)
30
+
31
+ # ------------------------------------------------------------------
32
+ # Volume shift: shift molar volume after Z-factor calculation
33
+ # ------------------------------------------------------------------
34
+ def _volume_shift(self, z: np.ndarray) -> float:
35
+ """Return molar volume shift c_mix = Σ z_i c_i [m³/mol]."""
36
+ return float(z @ self._c)
37
+
38
+ def molar_volume(
39
+ self, T: float, P: float, z: np.ndarray, phase: str = "vapor"
40
+ ) -> float:
41
+ """Volume-shifted molar volume [m³/mol]."""
42
+ z = np.asarray(z, dtype=float)
43
+ Z = self.Z_factor(T, P, z, phase)
44
+ V_raw = Z * R * T / P
45
+ return V_raw - self._volume_shift(z)
46
+
47
+ # ------------------------------------------------------------------
48
+ # Association (simplified 4-site CPA)
49
+ # ------------------------------------------------------------------
50
+ def _assoc_X(self, T: float, rho_mol: float, z: np.ndarray) -> np.ndarray:
51
+ """
52
+ Solve for fraction of unbonded sites X_i ∈ (0,1] for each component.
53
+ Uses the Michelsen-Hendriks iterative scheme.
54
+
55
+ Association strength: Δ_i = b_i * κ_i * (exp(ε_i / RT) - 1)
56
+ At low density rho_mol → 0, the site-occupancy denominator vanishes
57
+ and X_i → 1 (all sites free), which is the correct ideal-gas limit.
58
+ """
59
+ nc = self.mix.nc
60
+ _, b_i = self._ab_pure(T) # co-volume b_i [m³/mol] for each component
61
+ X = np.ones(nc)
62
+ for _ in range(200):
63
+ X_old = X.copy()
64
+ for i in range(nc):
65
+ if self._sites[i] == 0:
66
+ continue
67
+ # Δ = b_i * κ_i * (exp(ε_i / RT) - 1) [m³/mol]
68
+ delta = b_i[i] * self._kap[i] * (np.exp(self._eps[i] / (R * T)) - 1.0)
69
+ X[i] = 1.0 / (1.0 + self._sites[i] * z[i] * rho_mol * delta * X[i])
70
+ if np.max(np.abs(X - X_old)) < 1e-10:
71
+ break
72
+ return X
73
+
74
+ def _assoc_lnphi_correction(
75
+ self, T: float, P: float, z: np.ndarray, phase: str
76
+ ) -> np.ndarray:
77
+ """ln φ correction from association for each component."""
78
+ nc = self.mix.nc
79
+ if not np.any(self._sites > 0):
80
+ return np.zeros(nc)
81
+ V = self.molar_volume(T, P, z, phase)
82
+ rho_mol = 1.0 / V # mol/m³
83
+ X = self._assoc_X(T, rho_mol, z)
84
+ ln_corr = np.zeros(nc)
85
+ for i in range(nc):
86
+ M = self._sites[i]
87
+ if M == 0:
88
+ continue
89
+ ln_corr[i] = M * (np.log(X[i]) - X[i] / 2.0 + 0.5)
90
+ return ln_corr
91
+
92
+ # ------------------------------------------------------------------
93
+ # Override fugacity_coeff to add association
94
+ # ------------------------------------------------------------------
95
+ def fugacity_coeff(
96
+ self, T: float, P: float, z: np.ndarray, phase: str = "vapor"
97
+ ) -> np.ndarray:
98
+ phi_pr = super().fugacity_coeff(T, P, z, phase)
99
+ ln_assoc = self._assoc_lnphi_correction(T, P, z, phase)
100
+ return phi_pr * np.exp(ln_assoc)