prbs-eos 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- prbs_eos/__main__.py +4 -0
- prbs_eos/cli.py +308 -0
- prbs_eos/constants.py +1 -0
- prbs_eos/core/__init__.py +4 -0
- prbs_eos/core/cubic_solver.py +30 -0
- prbs_eos/core/eos_base.py +84 -0
- prbs_eos/core/pr_eos.py +66 -0
- prbs_eos/core/prbs_eos.py +100 -0
- prbs_eos/data/__init__.py +3 -0
- prbs_eos/data/component.py +25 -0
- prbs_eos/data/component_db.py +59 -0
- prbs_eos/data/mixture.py +43 -0
- prbs_eos/equilibrium/__init__.py +3 -0
- prbs_eos/equilibrium/flash.py +83 -0
- prbs_eos/equilibrium/phase_envelope.py +55 -0
- prbs_eos/equilibrium/stability.py +36 -0
- prbs_eos/pvt/__init__.py +0 -0
- prbs_eos/pvt/export.py +0 -0
- prbs_eos/pvt/pvt_tables.py +0 -0
- prbs_eos/pvt/separator.py +0 -0
- prbs_eos/regression/__init__.py +2 -0
- prbs_eos/regression/eos_regression.py +39 -0
- prbs_eos/regression/vpaa_regressor.py +34 -0
- prbs_eos/thermodynamics/__init__.py +1 -0
- prbs_eos/thermodynamics/departure.py +61 -0
- prbs_eos-0.1.0.dist-info/METADATA +67 -0
- prbs_eos-0.1.0.dist-info/RECORD +31 -0
- prbs_eos-0.1.0.dist-info/WHEEL +5 -0
- prbs_eos-0.1.0.dist-info/entry_points.txt +2 -0
- prbs_eos-0.1.0.dist-info/top_level.txt +1 -0
prbs_eos/__init__.py
ADDED
|
@@ -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
|
prbs_eos/__main__.py
ADDED
prbs_eos/cli.py
ADDED
|
@@ -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()
|
prbs_eos/constants.py
ADDED
|
@@ -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)
|
prbs_eos/core/pr_eos.py
ADDED
|
@@ -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)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Component:
|
|
7
|
+
"""Pure-component critical properties and acentric factor."""
|
|
8
|
+
name: str
|
|
9
|
+
Tc: float # critical temperature [K]
|
|
10
|
+
Pc: float # critical pressure [Pa]
|
|
11
|
+
omega: float # acentric factor [-]
|
|
12
|
+
Mw: float # molecular weight [g/mol]
|
|
13
|
+
# optional PRBS volume-shift and association parameters
|
|
14
|
+
c: float = 0.0 # Peneloux volume-shift [m³/mol]
|
|
15
|
+
epsilon: float = 0.0 # association energy [J/mol]
|
|
16
|
+
kappa: float = 0.0 # association volume [-]
|
|
17
|
+
assoc_sites: int = 0 # number of association sites
|
|
18
|
+
|
|
19
|
+
def __post_init__(self):
|
|
20
|
+
if self.Tc <= 0:
|
|
21
|
+
raise ValueError(f"Tc must be positive, got {self.Tc}")
|
|
22
|
+
if self.Pc <= 0:
|
|
23
|
+
raise ValueError(f"Pc must be positive, got {self.Pc}")
|
|
24
|
+
if self.Mw <= 0:
|
|
25
|
+
raise ValueError(f"Mw must be positive, got {self.Mw}")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Built-in component database. Properties from DIPPR / Poling et al. (2001).
|
|
3
|
+
Pc in Pa, Tc in K, omega dimensionless, Mw in g/mol.
|
|
4
|
+
"""
|
|
5
|
+
from .component import Component
|
|
6
|
+
|
|
7
|
+
_DB: dict[str, Component] = {
|
|
8
|
+
"methane": Component(
|
|
9
|
+
name="methane", Tc=190.56, Pc=4.599e6, omega=0.0115, Mw=16.043
|
|
10
|
+
),
|
|
11
|
+
"ethane": Component(
|
|
12
|
+
name="ethane", Tc=305.32, Pc=4.872e6, omega=0.0995, Mw=30.070
|
|
13
|
+
),
|
|
14
|
+
"propane": Component(
|
|
15
|
+
name="propane", Tc=369.83, Pc=4.248e6, omega=0.1523, Mw=44.097
|
|
16
|
+
),
|
|
17
|
+
"n-butane": Component(
|
|
18
|
+
name="n-butane", Tc=425.12, Pc=3.796e6, omega=0.2002, Mw=58.123
|
|
19
|
+
),
|
|
20
|
+
"n-pentane": Component(
|
|
21
|
+
name="n-pentane", Tc=469.70, Pc=3.370e6, omega=0.2515, Mw=72.150
|
|
22
|
+
),
|
|
23
|
+
"n-hexane": Component(
|
|
24
|
+
name="n-hexane", Tc=507.60, Pc=3.025e6, omega=0.3013, Mw=86.177
|
|
25
|
+
),
|
|
26
|
+
"n-heptane": Component(
|
|
27
|
+
name="n-heptane", Tc=540.20, Pc=2.740e6, omega=0.3495, Mw=100.204
|
|
28
|
+
),
|
|
29
|
+
"n-octane": Component(
|
|
30
|
+
name="n-octane", Tc=568.70, Pc=2.490e6, omega=0.3996, Mw=114.231
|
|
31
|
+
),
|
|
32
|
+
"nitrogen": Component(
|
|
33
|
+
name="nitrogen", Tc=126.20, Pc=3.390e6, omega=0.0372, Mw=28.014
|
|
34
|
+
),
|
|
35
|
+
"co2": Component(
|
|
36
|
+
name="co2", Tc=304.12, Pc=7.374e6, omega=0.2239, Mw=44.010
|
|
37
|
+
),
|
|
38
|
+
"water": Component(
|
|
39
|
+
name="water", Tc=647.10, Pc=22.064e6, omega=0.3449, Mw=18.015,
|
|
40
|
+
epsilon=1804.0 * 8.314462618, # 1804 K × R [J/mol]
|
|
41
|
+
kappa=0.0692, assoc_sites=4,
|
|
42
|
+
),
|
|
43
|
+
"hydrogen_sulfide": Component(
|
|
44
|
+
name="hydrogen_sulfide", Tc=373.10, Pc=8.963e6, omega=0.0942, Mw=34.081
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_component(name: str) -> Component:
|
|
50
|
+
key = name.strip().lower().replace(" ", "_").replace("-", "-")
|
|
51
|
+
if key not in _DB:
|
|
52
|
+
raise KeyError(
|
|
53
|
+
f"Component '{name}' not found. Available: {list(_DB.keys())}"
|
|
54
|
+
)
|
|
55
|
+
return _DB[key]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def available_components() -> list[str]:
|
|
59
|
+
return list(_DB.keys())
|
prbs_eos/data/mixture.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
import numpy as np
|
|
3
|
+
from typing import List
|
|
4
|
+
from .component import Component
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Mixture:
|
|
9
|
+
"""Collection of components with binary interaction parameters."""
|
|
10
|
+
components: List[Component]
|
|
11
|
+
kij: np.ndarray = field(default=None) # shape (nc, nc)
|
|
12
|
+
|
|
13
|
+
def __post_init__(self):
|
|
14
|
+
nc = len(self.components)
|
|
15
|
+
if nc == 0:
|
|
16
|
+
raise ValueError("Mixture must have at least one component.")
|
|
17
|
+
if self.kij is None:
|
|
18
|
+
self.kij = np.zeros((nc, nc))
|
|
19
|
+
self.kij = np.asarray(self.kij, dtype=float)
|
|
20
|
+
if self.kij.shape != (nc, nc):
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"kij shape {self.kij.shape} does not match nc={nc}"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def nc(self) -> int:
|
|
27
|
+
return len(self.components)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def Tc(self) -> np.ndarray:
|
|
31
|
+
return np.array([c.Tc for c in self.components])
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def Pc(self) -> np.ndarray:
|
|
35
|
+
return np.array([c.Pc for c in self.components])
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def omega(self) -> np.ndarray:
|
|
39
|
+
return np.array([c.omega for c in self.components])
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def Mw(self) -> np.ndarray:
|
|
43
|
+
return np.array([c.Mw for c in self.components])
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Two-phase PT flash using successive substitution + Newton acceleration.
|
|
3
|
+
Rachford-Rice equation for vapour fraction beta.
|
|
4
|
+
"""
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.optimize import brentq
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from ..constants import R
|
|
9
|
+
from ..core.eos_base import CubicEOS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class FlashResult:
|
|
14
|
+
beta: float # vapour mole fraction
|
|
15
|
+
x: np.ndarray # liquid mole fractions
|
|
16
|
+
y: np.ndarray # vapour mole fractions
|
|
17
|
+
K: np.ndarray # K-factors (y/x)
|
|
18
|
+
converged: bool
|
|
19
|
+
iterations: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _rachford_rice(beta: float, z: np.ndarray, K: np.ndarray) -> float:
|
|
23
|
+
return float(np.sum(z * (K - 1.0) / (1.0 + beta * (K - 1.0))))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _solve_rr(z: np.ndarray, K: np.ndarray) -> float:
|
|
27
|
+
"""Solve Rachford-Rice for beta ∈ (0,1)."""
|
|
28
|
+
f0 = _rachford_rice(0.0, z, K)
|
|
29
|
+
f1 = _rachford_rice(1.0, z, K)
|
|
30
|
+
if f0 <= 0.0:
|
|
31
|
+
return 0.0
|
|
32
|
+
if f1 >= 0.0:
|
|
33
|
+
return 1.0
|
|
34
|
+
return brentq(_rachford_rice, 0.0, 1.0, args=(z, K), xtol=1e-12)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def flash_pt(
|
|
38
|
+
eos: CubicEOS,
|
|
39
|
+
T: float,
|
|
40
|
+
P: float,
|
|
41
|
+
z: np.ndarray,
|
|
42
|
+
max_iter: int = 200,
|
|
43
|
+
tol: float = 1e-10,
|
|
44
|
+
) -> FlashResult:
|
|
45
|
+
"""Two-phase isothermal-isobaric flash."""
|
|
46
|
+
z = np.asarray(z, dtype=float)
|
|
47
|
+
nc = eos.mix.nc
|
|
48
|
+
|
|
49
|
+
# Wilson K-factor initialisation
|
|
50
|
+
Tc = eos.mix.Tc
|
|
51
|
+
Pc = eos.mix.Pc
|
|
52
|
+
omega = eos.mix.omega
|
|
53
|
+
K = (Pc / P) * np.exp(5.373 * (1.0 + omega) * (1.0 - Tc / T))
|
|
54
|
+
|
|
55
|
+
beta = _solve_rr(z, K)
|
|
56
|
+
converged = False
|
|
57
|
+
|
|
58
|
+
for it in range(max_iter):
|
|
59
|
+
beta = _solve_rr(z, K)
|
|
60
|
+
x = z / (1.0 + beta * (K - 1.0))
|
|
61
|
+
x = np.clip(x, 1e-300, 1.0)
|
|
62
|
+
x /= x.sum()
|
|
63
|
+
y = K * x
|
|
64
|
+
y = np.clip(y, 1e-300, 1.0)
|
|
65
|
+
y /= y.sum()
|
|
66
|
+
|
|
67
|
+
phi_L = eos.fugacity_coeff(T, P, x, "liquid")
|
|
68
|
+
phi_V = eos.fugacity_coeff(T, P, y, "vapor")
|
|
69
|
+
K_new = phi_L / phi_V
|
|
70
|
+
|
|
71
|
+
err = np.max(np.abs(np.log(K_new / K)))
|
|
72
|
+
K = K_new
|
|
73
|
+
if err < tol:
|
|
74
|
+
converged = True
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
beta = _solve_rr(z, K)
|
|
78
|
+
x = z / (1.0 + beta * (K - 1.0))
|
|
79
|
+
x /= x.sum()
|
|
80
|
+
y = K * x
|
|
81
|
+
y /= y.sum()
|
|
82
|
+
return FlashResult(beta=beta, x=x, y=y, K=K,
|
|
83
|
+
converged=converged, iterations=it + 1)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple PT phase-envelope tracer: bubble-point and dew-point pressure solvers.
|
|
3
|
+
|
|
4
|
+
Uses the Wilson K-factor correlation:
|
|
5
|
+
K_i = (Pc_i / P) * exp(5.373 * (1 + omega_i) * (1 - Tc_i / T))
|
|
6
|
+
|
|
7
|
+
Bubble-point condition: sum_i K_i * z_i = 1
|
|
8
|
+
- K_i strictly decreases with P → objective goes from + to - → reliable bracket.
|
|
9
|
+
|
|
10
|
+
Dew-point condition: sum_i z_i / K_i = 1
|
|
11
|
+
- 1/K_i strictly increases with P → objective goes from - to + → reliable bracket.
|
|
12
|
+
|
|
13
|
+
Returns nan when no root lies within [P_guess*0.01, P_guess*100], which naturally
|
|
14
|
+
occurs for supercritical T (where the Wilson P would be far outside the bracket).
|
|
15
|
+
"""
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy.optimize import brentq
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _wilson_K(eos, T: float, P: float) -> np.ndarray:
|
|
21
|
+
"""Wilson K-factor correlation K_i = (Pc_i/P)*exp(5.373*(1+ω_i)*(1-Tc_i/T))."""
|
|
22
|
+
Tc = eos.mix.Tc
|
|
23
|
+
Pc = eos.mix.Pc
|
|
24
|
+
omega = eos.mix.omega
|
|
25
|
+
return (Pc / P) * np.exp(5.373 * (1.0 + omega) * (1.0 - Tc / T))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bubble_point_P(eos, T, z, P_guess=1e6):
|
|
29
|
+
"""Bubble-point pressure [Pa] at temperature T for feed z."""
|
|
30
|
+
z = np.asarray(z, dtype=float)
|
|
31
|
+
|
|
32
|
+
def obj(P):
|
|
33
|
+
K = _wilson_K(eos, T, P)
|
|
34
|
+
return float(np.sum(K * z)) - 1.0
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
return brentq(obj, P_guess * 0.01, P_guess * 100.0,
|
|
38
|
+
xtol=1.0, rtol=1e-6, maxiter=100)
|
|
39
|
+
except ValueError:
|
|
40
|
+
return float("nan")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def dew_point_P(eos, T, z, P_guess=1e6):
|
|
44
|
+
"""Dew-point pressure [Pa] at temperature T for feed z."""
|
|
45
|
+
z = np.asarray(z, dtype=float)
|
|
46
|
+
|
|
47
|
+
def obj(P):
|
|
48
|
+
K = _wilson_K(eos, T, P)
|
|
49
|
+
return float(np.sum(z / K)) - 1.0
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return brentq(obj, P_guess * 0.01, P_guess * 100.0,
|
|
53
|
+
xtol=1.0, rtol=1e-6, maxiter=100)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return float("nan")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tangent-plane distance (TPD) stability test (Michelsen 1982).
|
|
3
|
+
"""
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.optimize import minimize
|
|
6
|
+
from ..constants import R
|
|
7
|
+
from ..core.eos_base import CubicEOS
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def tpd_min(eos, T, P, z, phase_feed="vapor"):
|
|
11
|
+
z = np.asarray(z, dtype=float)
|
|
12
|
+
nc = eos.mix.nc
|
|
13
|
+
ln_phi_z = np.log(eos.fugacity_coeff(T, P, z, phase_feed))
|
|
14
|
+
hi = np.log(z) + ln_phi_z
|
|
15
|
+
|
|
16
|
+
def tpd(W):
|
|
17
|
+
w = np.abs(W)
|
|
18
|
+
w_sum = w.sum()
|
|
19
|
+
if w_sum < 1e-30:
|
|
20
|
+
return 0.0
|
|
21
|
+
y = w / w_sum
|
|
22
|
+
trial_phase = "liquid" if phase_feed == "vapor" else "vapor"
|
|
23
|
+
ln_phi_y = np.log(eos.fugacity_coeff(T, P, y, trial_phase))
|
|
24
|
+
return float(w_sum * (1.0 + np.sum(w / w_sum * (np.log(w / w_sum) + ln_phi_y - hi)) - 1.0))
|
|
25
|
+
|
|
26
|
+
best = np.inf
|
|
27
|
+
for start in [z * 0.5, z * 2.0, np.ones(nc) / nc]:
|
|
28
|
+
res = minimize(tpd, start, method="L-BFGS-B",
|
|
29
|
+
bounds=[(1e-12, None)] * nc)
|
|
30
|
+
if res.fun < best:
|
|
31
|
+
best = res.fun
|
|
32
|
+
return best
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_stable(eos, T, P, z, phase_feed="vapor", tol=-1e-6):
|
|
36
|
+
return tpd_min(eos, T, P, z, phase_feed) > tol
|
prbs_eos/pvt/__init__.py
ADDED
|
File without changes
|
prbs_eos/pvt/export.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Regression of binary interaction parameters kij to VLE data."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.optimize import least_squares
|
|
4
|
+
from ..core.pr_eos import PREOS
|
|
5
|
+
from ..data.mixture import Mixture
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def regress_kij(mixture, T_data, P_data, x_data, y_data, kij_bounds=(-0.5, 0.5)):
|
|
9
|
+
from ..equilibrium.flash import flash_pt
|
|
10
|
+
nc = mixture.nc
|
|
11
|
+
n_pairs = nc * (nc - 1) // 2
|
|
12
|
+
if n_pairs == 0:
|
|
13
|
+
return np.zeros((nc, nc))
|
|
14
|
+
pair_idx = [(i, j) for i in range(nc) for j in range(i + 1, nc)]
|
|
15
|
+
|
|
16
|
+
def residuals(params):
|
|
17
|
+
kij = np.zeros((nc, nc))
|
|
18
|
+
for k, (i, j) in enumerate(pair_idx):
|
|
19
|
+
kij[i, j] = kij[j, i] = params[k]
|
|
20
|
+
mix = Mixture(mixture.components, kij=kij)
|
|
21
|
+
eos = PREOS(mix)
|
|
22
|
+
res_list = []
|
|
23
|
+
for n in range(len(T_data)):
|
|
24
|
+
z = x_data[n]
|
|
25
|
+
try:
|
|
26
|
+
fl = flash_pt(eos, T_data[n], P_data[n], z)
|
|
27
|
+
res_list.extend((fl.y - y_data[n]).tolist())
|
|
28
|
+
except Exception:
|
|
29
|
+
res_list.extend([1.0] * nc)
|
|
30
|
+
return np.array(res_list)
|
|
31
|
+
|
|
32
|
+
x0 = np.zeros(n_pairs)
|
|
33
|
+
bounds = ([kij_bounds[0]] * n_pairs, [kij_bounds[1]] * n_pairs)
|
|
34
|
+
result = least_squares(residuals, x0, bounds=bounds, method="trf",
|
|
35
|
+
ftol=1e-8, xtol=1e-8, max_nfev=500)
|
|
36
|
+
kij_out = np.zeros((nc, nc))
|
|
37
|
+
for k, (i, j) in enumerate(pair_idx):
|
|
38
|
+
kij_out[i, j] = kij_out[j, i] = result.x[k]
|
|
39
|
+
return kij_out
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vapour-pressure and acentric-factor regression from experimental Psat data.
|
|
3
|
+
"""
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.optimize import least_squares
|
|
6
|
+
from ..data.component import Component
|
|
7
|
+
from ..constants import R
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def regress_vpaa(
|
|
11
|
+
comp: Component,
|
|
12
|
+
T_data: np.ndarray,
|
|
13
|
+
Psat_data: np.ndarray,
|
|
14
|
+
) -> tuple[float, float]:
|
|
15
|
+
"""
|
|
16
|
+
Fit omega and Pc to vapour-pressure data via Wilson correlation.
|
|
17
|
+
|
|
18
|
+
Returns
|
|
19
|
+
-------
|
|
20
|
+
(omega_fit, Pc_fit)
|
|
21
|
+
"""
|
|
22
|
+
T_data = np.asarray(T_data, dtype=float)
|
|
23
|
+
Psat_data = np.asarray(Psat_data, dtype=float)
|
|
24
|
+
|
|
25
|
+
def residuals(params):
|
|
26
|
+
omega, Pc = params
|
|
27
|
+
Psat_calc = Pc * np.exp(5.373 * (1 + omega) * (1 - comp.Tc / T_data))
|
|
28
|
+
return np.log(Psat_calc) - np.log(Psat_data)
|
|
29
|
+
|
|
30
|
+
x0 = [comp.omega, comp.Pc]
|
|
31
|
+
bounds = ([0.0, 1e4], [2.0, 1e8])
|
|
32
|
+
result = least_squares(residuals, x0, bounds=bounds, method="trf",
|
|
33
|
+
ftol=1e-10, xtol=1e-10)
|
|
34
|
+
return float(result.x[0]), float(result.x[1])
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .departure import enthalpy_departure, entropy_departure
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Departure functions for PR-EOS.
|
|
3
|
+
All quantities are per mole of mixture.
|
|
4
|
+
"""
|
|
5
|
+
import numpy as np
|
|
6
|
+
from ..constants import R
|
|
7
|
+
from ..core.pr_eos import PREOS
|
|
8
|
+
from ..core.cubic_solver import solve_cubic, select_z
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _AB(eos: PREOS, T: float, P: float, z: np.ndarray):
|
|
12
|
+
a, b, _ = eos._mix_rule(T, z)
|
|
13
|
+
A = a * P / (R * T) ** 2
|
|
14
|
+
B = b * P / (R * T)
|
|
15
|
+
return a, b, A, B
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def enthalpy_departure(
|
|
19
|
+
eos: PREOS, T: float, P: float, z: np.ndarray, phase: str = "vapor"
|
|
20
|
+
) -> float:
|
|
21
|
+
"""H_dep = H - H_ig [J/mol]."""
|
|
22
|
+
z = np.asarray(z, dtype=float)
|
|
23
|
+
a, b, A, B = _AB(eos, T, P, z)
|
|
24
|
+
# da/dT
|
|
25
|
+
a_i, b_i = eos._ab_pure(T)
|
|
26
|
+
kappa = eos._kappa()
|
|
27
|
+
Tc = eos.mix.Tc
|
|
28
|
+
Pc = eos.mix.Pc
|
|
29
|
+
kij = eos.mix.kij
|
|
30
|
+
alpha_i = a_i / (eos._OmegaA * (R * Tc) ** 2 / Pc)
|
|
31
|
+
dadt_i = (
|
|
32
|
+
-eos._OmegaA * (R * Tc) ** 2 / Pc
|
|
33
|
+
* kappa
|
|
34
|
+
* np.sqrt(alpha_i / (T * Tc))
|
|
35
|
+
)
|
|
36
|
+
a_ij = np.sqrt(np.outer(a_i, a_i)) * (1.0 - kij)
|
|
37
|
+
# dadt_ij = 0.5*(dadt_i/sqrt(a_i))*sqrt(a_j)*(1-kij) + sym
|
|
38
|
+
dadt_ij = 0.5 * (
|
|
39
|
+
np.outer(dadt_i / a_i, a_i) + np.outer(a_i, dadt_i / a_i)
|
|
40
|
+
) * np.sqrt(np.outer(a_i, a_i)) * (1.0 - kij)
|
|
41
|
+
dadt_mix = float(z @ dadt_ij @ z)
|
|
42
|
+
|
|
43
|
+
Z = eos.Z_factor(T, P, z, phase)
|
|
44
|
+
s2 = np.sqrt(2.0)
|
|
45
|
+
H_dep = (
|
|
46
|
+
R * T * (Z - 1.0)
|
|
47
|
+
+ (T * dadt_mix - a) / (b * 2.0 * s2)
|
|
48
|
+
* np.log((Z + (1 + s2) * B) / (Z + (1 - s2) * B))
|
|
49
|
+
)
|
|
50
|
+
return H_dep
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def entropy_departure(
|
|
54
|
+
eos: PREOS, T: float, P: float, z: np.ndarray, phase: str = "vapor"
|
|
55
|
+
) -> float:
|
|
56
|
+
"""S_dep = S - S_ig [J/(mol·K)]."""
|
|
57
|
+
z = np.asarray(z, dtype=float)
|
|
58
|
+
H_dep = enthalpy_departure(eos, T, P, z, phase)
|
|
59
|
+
ln_phi = np.log(eos.fugacity_coeff(T, P, z, phase))
|
|
60
|
+
G_dep = R * T * float(z @ ln_phi)
|
|
61
|
+
return (H_dep - G_dep) / T
|
|
@@ -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,31 @@
|
|
|
1
|
+
prbs_eos/__init__.py,sha256=Lc3jtKz-DFYxg9qM40K2mYffLF6GbWASqScDAlXMKp4,335
|
|
2
|
+
prbs_eos/__main__.py,sha256=7ZoLnk_SqsSGgXOkx5gMAAdEm96roAQgRhF0ythsZ1I,86
|
|
3
|
+
prbs_eos/cli.py,sha256=9bWdG0qbob7P8VNV8SVWelhNJGbZ3DHzLFjQypjsEC8,10911
|
|
4
|
+
prbs_eos/constants.py,sha256=qeO6maLY3eAeqQU4dTdbby4QGY0uVSu7qf7khnGhFnk,30
|
|
5
|
+
prbs_eos/core/__init__.py,sha256=dzzwLcnUNs9yUe431DQ9Ety7VciRYKX9BsK4Zm5HqLs,135
|
|
6
|
+
prbs_eos/core/cubic_solver.py,sha256=7mkZo4i2lkXP9WvQWK2MED-IlTuUhL7YNRxwBzyqUJ8,963
|
|
7
|
+
prbs_eos/core/eos_base.py,sha256=4YrB5kKJ5m1OokXKGj3HDJVaev4CgS415_8oDFwyl9s,2677
|
|
8
|
+
prbs_eos/core/pr_eos.py,sha256=RrvvyzZSu6qAU4SvFoR0f3UHRpdOHl3OnFsd6Nt-i34,2010
|
|
9
|
+
prbs_eos/core/prbs_eos.py,sha256=cFa-kmZvfKYuA0qqsWuAP4hi17swMhQj9B-ccUoslNw,4162
|
|
10
|
+
prbs_eos/data/__init__.py,sha256=holrFRrpn-LEb3YHXpAfzV44LGTjhpuOjOcVc_fXD_A,124
|
|
11
|
+
prbs_eos/data/component.py,sha256=JPiLurLhu4-uncczLzNbsJZ8w3JGjf8LfVe_hotuXfA,967
|
|
12
|
+
prbs_eos/data/component_db.py,sha256=Rb-sqv5Iixj2sk_0b6PNi5ggqCieEpvNllF7e5OzKew,1909
|
|
13
|
+
prbs_eos/data/mixture.py,sha256=nvgtzSbxIqq6MoRj8n35Sj3kaH-qCp_EodMnlVjObuU,1241
|
|
14
|
+
prbs_eos/equilibrium/__init__.py,sha256=GR7ADq_KALSaxE0jGotMKXSTGJ-lVGI-adQqj2guL4Y,142
|
|
15
|
+
prbs_eos/equilibrium/flash.py,sha256=e0bCK3YgYO5wy9MbGCazYzyoDk0eLquF2Cm72DixbmE,2206
|
|
16
|
+
prbs_eos/equilibrium/phase_envelope.py,sha256=o2DAmiWSd6dfDM6ZY8L0of2Muo-iwNkJIXdmrGh6ufI,1830
|
|
17
|
+
prbs_eos/equilibrium/stability.py,sha256=qywjEE0AqI36ngyT2bNF4qZ_ofpf2_F5DtqGNGBi-QE,1155
|
|
18
|
+
prbs_eos/pvt/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
prbs_eos/pvt/export.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
prbs_eos/pvt/pvt_tables.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
prbs_eos/pvt/separator.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
prbs_eos/regression/__init__.py,sha256=_brdCoDhx1SD6ZAAvKpjHp0ukJVjtxoYUPB8WzgypqI,83
|
|
23
|
+
prbs_eos/regression/eos_regression.py,sha256=tH2jTHIrCu7Ba3-u3H0x2l0qEG_ecL6K23-LF60NfJg,1475
|
|
24
|
+
prbs_eos/regression/vpaa_regressor.py,sha256=lIb1ID6n4_f6diOhJaAEnsGFkuh4MkANKybOKUD9lq0,1019
|
|
25
|
+
prbs_eos/thermodynamics/__init__.py,sha256=EKLFCTDVQ1Vzk4x0sRv2k3UOT_rbefVIv3FWU6s44UM,61
|
|
26
|
+
prbs_eos/thermodynamics/departure.py,sha256=Vv-aSq3G00HUL_OWVzGutyVscZkIsnOln2ZMJYWhrvQ,1779
|
|
27
|
+
prbs_eos-0.1.0.dist-info/METADATA,sha256=l8ies3CVdc3dSmm-uVF_x3I-YrJYxPn0sRjQrTshw0g,2603
|
|
28
|
+
prbs_eos-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
29
|
+
prbs_eos-0.1.0.dist-info/entry_points.txt,sha256=I8t_Egguo1J2NeNw-GhLVLnshxFHjf1ElGZ6Di9aZNw,47
|
|
30
|
+
prbs_eos-0.1.0.dist-info/top_level.txt,sha256=KDom8s7RiWwChQoM0uB2_myb_t18-DkRYVqujfuBj3E,9
|
|
31
|
+
prbs_eos-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prbs_eos
|