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.
- prbs_eos-0.1.0/MANIFEST.in +5 -0
- prbs_eos-0.1.0/PKG-INFO +67 -0
- prbs_eos-0.1.0/prbs_eos/__init__.py +6 -0
- prbs_eos-0.1.0/prbs_eos/__main__.py +4 -0
- prbs_eos-0.1.0/prbs_eos/cli.py +308 -0
- prbs_eos-0.1.0/prbs_eos/constants.py +1 -0
- prbs_eos-0.1.0/prbs_eos/core/__init__.py +4 -0
- prbs_eos-0.1.0/prbs_eos/core/cubic_solver.py +30 -0
- prbs_eos-0.1.0/prbs_eos/core/eos_base.py +84 -0
- prbs_eos-0.1.0/prbs_eos/core/pr_eos.py +66 -0
- prbs_eos-0.1.0/prbs_eos/core/prbs_eos.py +100 -0
- prbs_eos-0.1.0/prbs_eos/data/__init__.py +3 -0
- prbs_eos-0.1.0/prbs_eos/data/component.py +25 -0
- prbs_eos-0.1.0/prbs_eos/data/component_db.py +59 -0
- prbs_eos-0.1.0/prbs_eos/data/mixture.py +43 -0
- prbs_eos-0.1.0/prbs_eos/equilibrium/__init__.py +3 -0
- prbs_eos-0.1.0/prbs_eos/equilibrium/flash.py +83 -0
- prbs_eos-0.1.0/prbs_eos/equilibrium/phase_envelope.py +55 -0
- prbs_eos-0.1.0/prbs_eos/equilibrium/stability.py +36 -0
- prbs_eos-0.1.0/prbs_eos/pvt/__init__.py +0 -0
- prbs_eos-0.1.0/prbs_eos/pvt/export.py +0 -0
- prbs_eos-0.1.0/prbs_eos/pvt/pvt_tables.py +0 -0
- prbs_eos-0.1.0/prbs_eos/pvt/separator.py +0 -0
- prbs_eos-0.1.0/prbs_eos/regression/__init__.py +2 -0
- prbs_eos-0.1.0/prbs_eos/regression/eos_regression.py +39 -0
- prbs_eos-0.1.0/prbs_eos/regression/vpaa_regressor.py +34 -0
- prbs_eos-0.1.0/prbs_eos/thermodynamics/__init__.py +1 -0
- prbs_eos-0.1.0/prbs_eos/thermodynamics/departure.py +61 -0
- prbs_eos-0.1.0/prbs_eos.egg-info/PKG-INFO +67 -0
- prbs_eos-0.1.0/prbs_eos.egg-info/SOURCES.txt +38 -0
- prbs_eos-0.1.0/prbs_eos.egg-info/dependency_links.txt +1 -0
- prbs_eos-0.1.0/prbs_eos.egg-info/entry_points.txt +2 -0
- prbs_eos-0.1.0/prbs_eos.egg-info/requires.txt +10 -0
- prbs_eos-0.1.0/prbs_eos.egg-info/top_level.txt +1 -0
- prbs_eos-0.1.0/pyproject.toml +112 -0
- prbs_eos-0.1.0/requirements-dev.txt +12 -0
- prbs_eos-0.1.0/requirements.txt +6 -0
- prbs_eos-0.1.0/setup.cfg +4 -0
prbs_eos-0.1.0/PKG-INFO
ADDED
|
@@ -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,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,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)
|