thermo-utils 0.2.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,118 @@
1
+ """Excess Gibbs energy models."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ from thermo import NRTL, UNIQUAC, IdealSolution
7
+ from thermo.interaction_parameters import IPDB
8
+ from thermo.unifac import DOUFIP2016, DOUFSG, UFIP, UFSG, UNIFAC
9
+
10
+ from thermo_utils.units import R
11
+
12
+ MODELS = {
13
+ "unifac": "UNIFAC (predictive, original)",
14
+ "unifac-do": "Modified UNIFAC (Dortmund)",
15
+ "nrtl": "NRTL (ChemSep parameters or yours)",
16
+ "uniquac": "UNIQUAC (ChemSep parameters)",
17
+ "wilson": "Wilson (binary, your Λ12 and Λ21)",
18
+ "margules": "Margules two-parameter (binary, your A12 and A21)",
19
+ "vanlaar": "van Laar (binary, your A'12 and A'21)",
20
+ "ideal": "Ideal solution (γ = 1, Raoult)",
21
+ }
22
+
23
+
24
+ class GEModel:
25
+ """Activity coefficients for a fixed set of components."""
26
+
27
+ def __init__(self, subs, name="unifac", params=None):
28
+ self.subs = subs
29
+ self.name = name
30
+ self.params = list(params or [])
31
+ self.notes = []
32
+ n = len(subs)
33
+ if name in ("margules", "vanlaar", "wilson") and n != 2:
34
+ raise ValueError(f"{name} is implemented for binary mixtures.")
35
+ if name in ("margules", "vanlaar", "wilson") and len(self.params) != 2:
36
+ raise ValueError(f"{name} needs two parameters, e.g. params=1.2,0.8")
37
+ if name == "nrtl" and not self.params:
38
+ cas = [s.cas for s in subs]
39
+ self._b = IPDB.get_ip_asymmetric_matrix("ChemSep NRTL", cas, "bij")
40
+ self._alpha = IPDB.get_ip_asymmetric_matrix("ChemSep NRTL", cas, "alphaij")
41
+ if not any(any(r) for r in self._b):
42
+ raise ValueError("No NRTL parameters in the databank for this mixture; give b12,b21,alpha or use UNIFAC.")
43
+ self.notes.append("NRTL parameters from the ChemSep databank (τij = bij/T).")
44
+ elif name == "nrtl":
45
+ if n != 2 or len(self.params) not in (2, 3):
46
+ raise ValueError("Give NRTL params as b12,b21[,alpha] in K (binary).")
47
+ b12, b21 = self.params[:2]
48
+ a = self.params[2] if len(self.params) == 3 else 0.3
49
+ self._b = [[0, b12], [b21, 0]]
50
+ self._alpha = [[0, a], [a, 0]]
51
+ if name == "uniquac":
52
+ cas = [s.cas for s in subs]
53
+ self._b = IPDB.get_ip_asymmetric_matrix("ChemSep UNIQUAC", cas, "bij")
54
+ if not any(any(r) for r in self._b):
55
+ raise ValueError("No UNIQUAC parameters in the databank for this mixture; use UNIFAC.")
56
+ self.notes.append("UNIQUAC parameters from the ChemSep databank.")
57
+ if name.startswith("unifac"):
58
+ for s in subs:
59
+ if not s.unifac_groups:
60
+ raise ValueError(f"No UNIFAC groups for {s.name}.")
61
+
62
+ def _thermo(self, T, xs):
63
+ n = len(xs)
64
+ if self.name == "nrtl":
65
+ return NRTL(T=T, xs=xs, tau_bs=self._b, alpha_cs=self._alpha)
66
+ if self.name == "uniquac":
67
+ return UNIQUAC(T=T, xs=xs, rs=[s.r for s in self.subs], qs=[s.q for s in self.subs], tau_bs=self._b)
68
+ if self.name == "unifac":
69
+ return UNIFAC.from_subgroups(T=T, xs=xs, chemgroups=[s.unifac_groups for s in self.subs],
70
+ version=0, interaction_data=UFIP, subgroups=UFSG)
71
+ if self.name == "unifac-do":
72
+ groups = [s._constants.UNIFAC_Dortmund_groups[0] for s in self.subs]
73
+ return UNIFAC.from_subgroups(T=T, xs=xs, chemgroups=groups, version=1,
74
+ interaction_data=DOUFIP2016, subgroups=DOUFSG)
75
+ return IdealSolution(T=T, xs=xs) if n else None
76
+
77
+ def ln_gammas(self, T, xs):
78
+ xs = [max(float(x), 0.0) for x in xs]
79
+ if self.name == "margules":
80
+ A12, A21 = self.params
81
+ x1, x2 = xs
82
+ return [x2**2 * (A12 + 2 * (A21 - A12) * x1), x1**2 * (A21 + 2 * (A12 - A21) * x2)]
83
+ if self.name == "vanlaar":
84
+ A12, A21 = self.params
85
+ x1, x2 = xs
86
+ d = A12 * x1 + A21 * x2
87
+ return [A12 * (A21 * x2 / d) ** 2, A21 * (A12 * x1 / d) ** 2]
88
+ if self.name == "wilson":
89
+ L12, L21 = self.params
90
+ x1, x2 = xs
91
+ s = L12 / (x1 + x2 * L12) - L21 / (x2 + x1 * L21)
92
+ return [-math.log(x1 + x2 * L12) + x2 * s, -math.log(x2 + x1 * L21) - x1 * s]
93
+ return [math.log(g) for g in self._thermo(T, _safe(xs)).gammas()]
94
+
95
+ def gammas(self, T, xs):
96
+ return [math.exp(v) for v in self.ln_gammas(T, xs)]
97
+
98
+ def GE(self, T, xs):
99
+ return R * T * sum(x * lg for x, lg in zip(xs, self.ln_gammas(T, xs)))
100
+
101
+ def HE(self, T, xs, dT=0.01):
102
+ g = lambda t: self.GE(t, xs) / t
103
+ return -T**2 * (g(T + dT) - g(T - dT)) / (2 * dT)
104
+
105
+ def SE(self, T, xs):
106
+ return (self.HE(T, xs) - self.GE(T, xs)) / T
107
+
108
+
109
+ def _safe(xs):
110
+ xs = [max(x, 1e-12) for x in xs]
111
+ s = sum(xs)
112
+ return [x / s for x in xs]
113
+
114
+
115
+ def binary_curve(model, T, n=101):
116
+ x1 = np.linspace(0, 1, n)
117
+ ln_g = np.array([model.ln_gammas(T, [max(x, 1e-9), max(1 - x, 1e-9)]) for x in x1])
118
+ return x1, ln_g
thermo_utils/calcs.py ADDED
@@ -0,0 +1,269 @@
1
+ """Registry of calculations: one entry drives both the menu and the command line."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Callable
5
+
6
+ from thermo_utils import (
7
+ activity, chemical_equilibrium, devices, heat, mixtures, properties, substances, surface, vle,
8
+ )
9
+ from thermo_utils.pure import MODELS
10
+ from thermo_utils.units import (
11
+ CONCENTRATION, LENGTH, MASS_FLOW, MOLAR_FLOW, PRESSURE, TEMPERATURE, MOLAR_VOLUME,
12
+ parse_quantity,
13
+ )
14
+
15
+ SUBSTANCE = "substance"
16
+ SUBSTANCES = "substances"
17
+ QUANTITY = "quantity"
18
+ NUMBER = "number"
19
+ INTEGER = "integer"
20
+ CHOICE = "choice"
21
+ TEXT = "text"
22
+ LIST = "list"
23
+ FLOW = "flow"
24
+
25
+
26
+ @dataclass
27
+ class Field:
28
+ name: str
29
+ label: str
30
+ type: str
31
+ qkind: str = None
32
+ optional: bool = False
33
+ default: object = None
34
+ choices: dict = None
35
+ help: str = ""
36
+
37
+
38
+ @dataclass
39
+ class Calc:
40
+ id: str
41
+ group: str
42
+ title: str
43
+ fn: Callable
44
+ fields: list = field(default_factory=list)
45
+ help: str = ""
46
+
47
+
48
+ def parse_value(f, text):
49
+ text = text.strip()
50
+ if text == "":
51
+ if f.optional or f.default is not None:
52
+ return f.default
53
+ raise ValueError(f"{f.label} is required")
54
+ if f.type == SUBSTANCE:
55
+ return substances.get(text)
56
+ if f.type == SUBSTANCES:
57
+ return [substances.get(t) for t in _split(text)]
58
+ if f.type == QUANTITY:
59
+ return parse_quantity(text, f.qkind)
60
+ if f.type == NUMBER:
61
+ return float(text.replace(",", ".")) if "/" not in text else _ratio(text)
62
+ if f.type == INTEGER:
63
+ return int(text)
64
+ if f.type == LIST:
65
+ return [float(t.replace(",", ".")) for t in _split(text)]
66
+ if f.type == CHOICE:
67
+ key = text.lower()
68
+ if key not in f.choices:
69
+ raise ValueError(f"Options for {f.label}: {', '.join(f.choices)}")
70
+ return key
71
+ if f.type == FLOW:
72
+ return _parse_flow(text)
73
+ return text
74
+
75
+
76
+ def _split(text):
77
+ sep = ";" if ";" in text else ","
78
+ return [t.strip() for t in text.split(sep) if t.strip()]
79
+
80
+
81
+ def _ratio(text):
82
+ a, b = text.split("/")
83
+ return float(a) / float(b)
84
+
85
+
86
+ def _parse_flow(text):
87
+ from thermo_utils.units import _NUMBER, _norm, _FACTORS, to_si
88
+ m = _NUMBER.match(text.replace(",", "."))
89
+ if not m:
90
+ raise ValueError("Flow must look like '10 kg/s' or '5 kmol/h'")
91
+ value, unit = float(m.group(1)), _norm(m.group(2) or "kg/s")
92
+ if unit in _FACTORS[MASS_FLOW]:
93
+ return ("mass", to_si(value, unit, MASS_FLOW))
94
+ if unit in _FACTORS[MOLAR_FLOW]:
95
+ return ("molar", to_si(value, unit, MOLAR_FLOW))
96
+ raise ValueError(f"Unknown flow unit '{unit}'")
97
+
98
+
99
+ MODEL_FIELD = Field("model", "Model", CHOICE, default="auto", choices=MODELS)
100
+ FLOW_FIELD = Field("flow", "Flow rate (e.g. 2 kg/s, 10 kmol/h)", FLOW, optional=True,
101
+ help="Optional. Gives the power in kW.")
102
+
103
+
104
+ def _q(name, label, qkind, optional=False, default=None, help=""):
105
+ return Field(name, label, QUANTITY, qkind, optional, default, help=help)
106
+
107
+
108
+ def _sub(name="sub", label="Substance"):
109
+ return Field(name, label, SUBSTANCE, help="Name, formula or CAS")
110
+
111
+
112
+ PURE = "Pure fluids"
113
+ HEAT = "Heat effects"
114
+ DEVICES = "Flow devices"
115
+ MIX = "Mixtures"
116
+ PHASE = "Phase equilibrium"
117
+ REACT = "Chemical equilibrium"
118
+ SURF = "Surface phenomena"
119
+
120
+ CALCS = [
121
+ Calc("info", PURE, "Substance data (Tc, Pc, ω, Cp, ΔHf...)", properties.substance_info, [_sub()]),
122
+ Calc("pvt", PURE, "PVT: solve P, V or T (any EOS) + model comparison", properties.pvt, [
123
+ _sub(), _q("T_", "T", TEMPERATURE, True), _q("P_", "P", PRESSURE, True),
124
+ _q("V_", "V (molar)", MOLAR_VOLUME, True), MODEL_FIELD,
125
+ ], "Leave empty the variable you want to calculate."),
126
+ Calc("state", PURE, "State properties (V, Z, H, S, U, G, A, Cp, φ...)", properties.state_properties, [
127
+ _sub(), _q("T_", "T", TEMPERATURE, True), _q("P_", "P", PRESSURE, True),
128
+ Field("x", "Vapor fraction (two-phase only)", NUMBER, optional=True), MODEL_FIELD,
129
+ ], "Give T and P, or a vapor fraction with T or P."),
130
+ Calc("delta", PURE, "Property change ΔH, ΔS, ΔU, ΔG, ΔA between two states", properties.property_change, [
131
+ _sub(), _q("T1", "T1", TEMPERATURE, True), _q("P1", "P1", PRESSURE, True),
132
+ Field("x1", "Vapor fraction 1 (two-phase only)", NUMBER, optional=True),
133
+ _q("T2", "T2", TEMPERATURE, True), _q("P2", "P2", PRESSURE, True),
134
+ Field("x2", "Vapor fraction 2 (two-phase only)", NUMBER, optional=True), MODEL_FIELD,
135
+ ], "Each state: T and P, or a vapor fraction with T or P. Phase changes are handled."),
136
+ Calc("saturation", PURE, "Saturation: Psat/Tsat, ΔHvap, saturated volumes", properties.saturation, [
137
+ _sub(), _q("T_", "T", TEMPERATURE, True), _q("P_", "P", PRESSURE, True), MODEL_FIELD,
138
+ ], "Give T or P."),
139
+ Calc("sensible", HEAT, "Sensible heat ∫Cp dT and mean heat capacities", heat.sensible_heat, [
140
+ _sub(), _q("T1", "T1", TEMPERATURE), _q("T2", "T2", TEMPERATURE),
141
+ Field("phase", "Phase", CHOICE, default="gas", choices={"gas": "ideal gas", "liquid": "liquid"}),
142
+ ]),
143
+ Calc("latent", HEAT, "Latent heats (databank, Riedel, Watson, fusion)", heat.latent_heat, [
144
+ _sub(), _q("T_", "T (default: normal boiling point)", TEMPERATURE, True),
145
+ ]),
146
+ Calc("flame", HEAT, "Adiabatic flame temperature", heat.adiabatic_flame, [
147
+ _sub("fuel", "Fuel"), Field("excess_air", "Excess air fraction (0.2 = 20 %)", NUMBER, default=0.0),
148
+ _q("T_in", "Inlet temperature", TEMPERATURE, default=298.15),
149
+ ]),
150
+ Calc("device", DEVICES, "Compressor, pump, turbine or valve (one step)", devices.flow_device, [
151
+ _sub(), Field("kind", "Device", CHOICE, choices={
152
+ "compressor": "compressor", "pump": "pump", "turbine": "turbine/expander", "valve": "throttle valve"}),
153
+ _q("T1", "Inlet T", TEMPERATURE), _q("P1", "Inlet P", PRESSURE), _q("P2", "Outlet P", PRESSURE),
154
+ Field("efficiency", "Isentropic efficiency (0-1)", NUMBER, default=1.0), MODEL_FIELD, FLOW_FIELD,
155
+ ]),
156
+ ]
157
+
158
+
159
+ GE_FIELD = Field("model", "Activity model", CHOICE, default="unifac", choices=activity.MODELS)
160
+ PARAMS_FIELD = Field("params", "Model parameters (Margules A12,A21 / van Laar / Wilson Λ12,Λ21 / NRTL b12,b21,α)",
161
+ LIST, optional=True, help="Only for models that need them.")
162
+ SUBS_FIELD = Field("subs", "Substances (comma separated)", SUBSTANCES)
163
+ COMP_HELP = "Mole fractions in the same order; the last one may be omitted."
164
+
165
+
166
+ def _comp(name="zs", label="Mole fractions"):
167
+ return Field(name, f"{label} (e.g. 0.3,0.7)", LIST, help=COMP_HELP)
168
+
169
+
170
+ CALCS += [
171
+ Calc("fugacity-mix", MIX, "Fugacity and φ̂ of species in a mixture (PR, SRK or virial)", mixtures.mixture_fugacity, [
172
+ SUBS_FIELD, _q("T_", "T", TEMPERATURE), _q("P_", "P", PRESSURE), _comp("ys"),
173
+ Field("model", "Model", CHOICE, default="pr", choices={"pr": "Peng-Robinson", "srk": "SRK", "virial": "virial"}),
174
+ ]),
175
+ Calc("activity", MIX, "Activity coefficients, G^E, H^E, mixing properties", mixtures.activity_coefficients, [
176
+ SUBS_FIELD, _q("T_", "T", TEMPERATURE), _comp("xs"), GE_FIELD, PARAMS_FIELD,
177
+ ]),
178
+ Calc("vle", PHASE, "VLE: bubble/dew P or T, flash (γ-φ, modified Raoult, φ-φ)", mixtures.vle_calc, [
179
+ SUBS_FIELD, Field("kind", "Calculation", CHOICE, choices={
180
+ "bubble-p": "bubble P (give T, x)", "dew-p": "dew P (give T, y)", "bubble-t": "bubble T (give P, x)",
181
+ "dew-t": "dew T (give P, y)", "flash": "flash (give T, P, z)"}),
182
+ _q("T_", "T", TEMPERATURE, True), _q("P_", "P", PRESSURE, True), _comp("zs", "Composition x, y or z"),
183
+ Field("method", "Method", CHOICE, default="gamma-phi", choices=vle.METHODS), GE_FIELD, PARAMS_FIELD,
184
+ ]),
185
+ Calc("diagram", PHASE, "Binary Pxy / Txy / xy diagram and azeotrope", mixtures.binary_diagram, [
186
+ SUBS_FIELD, _q("T_", "T (for Pxy)", TEMPERATURE, True), _q("P_", "P (for Txy)", PRESSURE, True),
187
+ Field("method", "Method", CHOICE, default="gamma-phi", choices=vle.METHODS), GE_FIELD, PARAMS_FIELD,
188
+ ], "Give T for a Pxy diagram or P for a Txy diagram."),
189
+ Calc("fit", MIX, "Fit Margules / van Laar to P-x-y data (CSV)", mixtures.fit_parameters, [
190
+ SUBS_FIELD, _q("T_", "T of the data", TEMPERATURE),
191
+ Field("path", "CSV file with columns P, x1, y1", TEXT),
192
+ Field("model", "Model", CHOICE, default="margules", choices={"margules": "Margules", "vanlaar": "van Laar"}),
193
+ ], "Header like 'P (kPa),x1,y1' sets the pressure unit."),
194
+ Calc("lle", PHASE, "Liquid-liquid equilibrium (binary) + binodal curve", mixtures.lle, [
195
+ SUBS_FIELD, _q("T_", "T", TEMPERATURE), GE_FIELD, PARAMS_FIELD,
196
+ ]),
197
+ Calc("sle", PHASE, "Solid-liquid equilibrium: eutectic diagram (binary)", mixtures.sle, [
198
+ SUBS_FIELD, Field("model", "Activity model", CHOICE, default="ideal", choices=activity.MODELS), PARAMS_FIELD,
199
+ ]),
200
+ Calc("solubility", PHASE, "Solubility of a solid in a liquid", mixtures.solubility, [
201
+ _sub("solute", "Solid solute"), _q("T_", "T", TEMPERATURE),
202
+ Field("solvent", "Solvent (optional)", SUBSTANCE, optional=True),
203
+ Field("model", "Activity model", CHOICE, default="ideal", choices=activity.MODELS),
204
+ ]),
205
+ Calc("osmotic", PHASE, "Osmotic pressure", mixtures.osmotic, [
206
+ _sub("solvent", "Solvent"), _sub("solute", "Solute"), _q("T_", "T", TEMPERATURE),
207
+ Field("x_solute", "Solute mole fraction", NUMBER),
208
+ Field("model", "Activity model", CHOICE, default="ideal", choices=activity.MODELS),
209
+ ]),
210
+ Calc("k", REACT, "Equilibrium constant K(T), ΔG°, ΔH° of a reaction", chemical_equilibrium.equilibrium_constant, [
211
+ Field("reaction", "Reaction (e.g. N2 + 3 H2 -> 2 NH3)", TEXT), _q("T_", "T", TEMPERATURE),
212
+ ]),
213
+ Calc("equilibrium", REACT, "Equilibrium conversion of one reaction", chemical_equilibrium.single_reaction, [
214
+ Field("reaction", "Reaction (e.g. CO + H2O -> CO2 + H2)", TEXT), _q("T_", "T", TEMPERATURE),
215
+ _q("P_", "P", PRESSURE, default=1e5), Field("feed", "Feed in mol (e.g. CO=1, H2O=2, N2=0.5)", TEXT),
216
+ Field("phase_model", "Phase model", CHOICE, default="ideal", choices={
217
+ "ideal": "ideal gas", "virial": "gas with virial φ̂", "ideal-liquid": "ideal liquid",
218
+ "unifac": "liquid with UNIFAC γ"}),
219
+ ], "Species in the feed that are not in the reaction are inerts."),
220
+ Calc("gibbs", REACT, "Multi-reaction equilibrium (Gibbs energy minimization)", chemical_equilibrium.gibbs_minimization, [
221
+ Field("species", "All species that may be present (comma separated)", SUBSTANCES),
222
+ _q("T_", "T", TEMPERATURE), _q("P_", "P", PRESSURE, default=1e5),
223
+ Field("feed", "Feed in mol (e.g. CH4=2, H2O=3)", TEXT),
224
+ ], "Ideal-gas mixture. No need to write the reactions."),
225
+ ]
226
+
227
+ CALCS += [
228
+ Calc("surface-tension", SURF, "Surface tension σ(T), surface entropy and enthalpy", surface.surface_tension, [
229
+ _sub(), _q("T_", "T", TEMPERATURE),
230
+ ]),
231
+ Calc("kelvin", SURF, "Kelvin equation: vapor pressure over a curved surface", surface.kelvin, [
232
+ _sub(), _q("T_", "T", TEMPERATURE), _q("r", "Radius of curvature", LENGTH),
233
+ Field("kind", "Surface", CHOICE, default="drop", choices={
234
+ "drop": "drop (convex)", "bubble": "bubble or capillary (concave)"}),
235
+ ]),
236
+ Calc("adsorption", SURF, "Adsorption isotherms: Langmuir, Freundlich, BET (CSV)", surface.adsorption, [
237
+ Field("path", "CSV file with columns P, q", TEXT),
238
+ Field("model", "Model", CHOICE, default="all", choices={
239
+ "all": "all", "langmuir": "Langmuir", "freundlich": "Freundlich", "bet": "BET"}),
240
+ Field("adsorbate", "Adsorbate (for BET P0 and area)", SUBSTANCE, optional=True),
241
+ _q("T_", "T (for BET)", TEMPERATURE, True), _q("P0", "P0 (optional, BET)", PRESSURE, True),
242
+ Field("cross_section", "Area per molecule in nm² (optional, BET)", NUMBER, optional=True),
243
+ ], "Header like 'P (kPa),q (mmol/g)' sets the units."),
244
+ Calc("gibbs-adsorption", SURF, "Gibbs adsorption isotherm (surface excess from σ vs c)", surface.gibbs_adsorption, [
245
+ Field("path", "CSV file with columns c, σ", TEXT), _q("T_", "T", TEMPERATURE),
246
+ Field("n", "n (1 nonionic, 2 ionic 1:1 without salt)", INTEGER, default=1),
247
+ ], "Header like 'c (mmol/L),sigma (mN/m)' sets the units."),
248
+ Calc("micelles", SURF, "Micellization: CMC and ΔG°mic", surface.micelles, [
249
+ _q("T_", "T", TEMPERATURE), _q("cmc", "CMC (or leave empty and give a CSV)", CONCENTRATION, True),
250
+ Field("beta", "Counterion binding β (0 for nonionic)", NUMBER, default=0.0),
251
+ _q("T2", "Second T (optional, for ΔH)", TEMPERATURE, True),
252
+ _q("cmc2", "CMC at the second T (optional)", CONCENTRATION, True),
253
+ Field("path", "CSV file with columns c, σ (optional)", TEXT, optional=True),
254
+ ]),
255
+ ]
256
+
257
+
258
+ def by_id(calc_id):
259
+ for c in CALCS:
260
+ if c.id == calc_id:
261
+ return c
262
+ raise KeyError(calc_id)
263
+
264
+
265
+ def groups():
266
+ out = {g: [] for g in (PURE, HEAT, DEVICES, MIX, PHASE, REACT, SURF)}
267
+ for c in CALCS:
268
+ out.setdefault(c.group, []).append(c)
269
+ return out
@@ -0,0 +1,218 @@
1
+ """Chemical reaction equilibrium."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ from chemicals.elements import simple_formula_parser
7
+ from scipy.optimize import brentq, minimize
8
+
9
+ from thermo_utils import activity, reactions, substances, vle
10
+ from thermo_utils.result import Result, row
11
+ from thermo_utils.units import (
12
+ MOLAR_ENERGY as E, MOLAR_ENTROPY as S, PRESSURE as P, TEMPERATURE as T, R, display, fmt, from_si,
13
+ )
14
+
15
+ P0 = 1e5
16
+
17
+
18
+ def equilibrium_constant(reaction, T_):
19
+ rx = reactions.parse(reaction)
20
+ dG, dH = rx.dG(T_), rx.dH(T_)
21
+ K = math.exp(-dG / (R * T_))
22
+ K_vh = math.exp(-rx.dG0() / (R * 298.15) - rx.dH0() / R * (1 / T_ - 1 / 298.15))
23
+ res = Result("Equilibrium constant")
24
+ res.section(rx.pretty(), [
25
+ row("ΔG° at 298.15 K", rx.dG0(), E), row("ΔH° at 298.15 K", rx.dH0(), E),
26
+ row(f"ΔG° at {fmt(T_, T)}", dG, E), row(f"ΔH° at {fmt(T_, T)}", dH, E),
27
+ row(f"ΔS° at {fmt(T_, T)}", rx.dS(T_), S),
28
+ row("K (rigorous, ΔCp included)", f"{K:.6g}"),
29
+ row("ln K", f"{math.log(K):.6g}"),
30
+ row("K (short-cut van 't Hoff, ΔH constant)", f"{K_vh:.6g}"),
31
+ ])
32
+ res.notes.append("Standard state: ideal gas at 1 bar (g) or pure liquid at 1 bar (l).")
33
+
34
+ def plot():
35
+ import matplotlib.pyplot as plt
36
+ Ts = np.linspace(max(250, T_ - 400), T_ + 400, 120)
37
+ lnK = [-rx.dG(t) / (R * t) for t in Ts]
38
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
39
+ ax1.plot(1 / Ts, lnK)
40
+ ax1.plot(1 / T_, math.log(K), "ko")
41
+ ax1.set_xlabel("1/T (1/K)"); ax1.set_ylabel("ln K"); ax1.set_title("van 't Hoff plot")
42
+ ax2.plot([from_si(t, display.temperature, T) for t in Ts], [rx.dG(t) / 1000 for t in Ts])
43
+ ax2.axhline(0, color="k", lw=0.8)
44
+ ax2.set_xlabel(f"T ({display.temperature})"); ax2.set_ylabel("ΔG° (kJ/mol)"); ax2.set_title("ΔG° vs T")
45
+ for ax in (ax1, ax2):
46
+ ax.grid(alpha=0.3)
47
+ fig.tight_layout()
48
+ return fig
49
+
50
+ res.plot = plot
51
+ res.plot_name = "lnK"
52
+ return res
53
+
54
+
55
+ # Single reaction
56
+
57
+ def _parse_feed(text):
58
+ feed = {}
59
+ if not text:
60
+ return feed
61
+ for part in text.replace(";", ",").split(","):
62
+ if "=" not in part:
63
+ raise ValueError("Feed must look like 'CH4=1, H2O=2, N2=0.5' (moles).")
64
+ k, v = part.split("=", 1)
65
+ feed[substances.get(k.strip())] = float(v)
66
+ return feed
67
+
68
+
69
+ def _setup(rx, feed):
70
+ names = [sp.sub for sp in rx.species]
71
+ n0 = [feed.pop(s, 0.0) for s in names]
72
+ nu = [sp.nu for sp in rx.species]
73
+ inert = list(feed.items())
74
+ return names, n0, nu, inert
75
+
76
+
77
+ def single_reaction(reaction, T_, P_, feed, phase_model="ideal"):
78
+ """Equilibrium extent of one reaction."""
79
+ rx = reactions.parse(reaction, "l" if phase_model in ("ideal-liquid", "unifac") else "g")
80
+ feed = _parse_feed(feed)
81
+ subs, n0, nu, inert = _setup(rx, dict(feed))
82
+ if sum(n0) <= 0:
83
+ raise ValueError("Give the feed in moles of at least one reactant.")
84
+ K = rx.K(T_)
85
+ liquid = all(sp.phase == "l" for sp in rx.species)
86
+ lo = max([-n / v for n, v in zip(n0, nu) if v > 0] + [-1e300])
87
+ hi = min([n / -v for n, v in zip(n0, nu) if v < 0] + [1e300])
88
+ if hi > 1e299 or lo < -1e299:
89
+ raise ValueError("The feed must allow the reaction to proceed in both directions or one with limits.")
90
+ span = hi - lo
91
+ eps_lo, eps_hi = lo + 1e-12 * span, hi - 1e-12 * span
92
+ ge = activity.GEModel(subs + [s for s, _ in inert], "unifac") if phase_model == "unifac" else None
93
+
94
+ def comp(e):
95
+ n = [a + v * e for a, v in zip(n0, nu)] + [v for _, v in inert]
96
+ tot = sum(n)
97
+ return [x / tot for x in n]
98
+
99
+ def ln_activity(e):
100
+ ys = comp(e)
101
+ if liquid:
102
+ g = ge.gammas(T_, ys) if ge else [1.0] * len(ys)
103
+ return [math.log(max(y, 1e-300) * gi) for y, gi in zip(ys, g)][: len(subs)]
104
+ phis = [1.0] * len(ys)
105
+ if phase_model == "virial":
106
+ phis = vle.virial_phis(subs + [s for s, _ in inert], T_, P_, ys)[0]
107
+ return [math.log(max(y, 1e-300) * f * P_ / P0) for y, f in zip(ys, phis)][: len(subs)]
108
+
109
+ f = lambda e: sum(v * la for v, la in zip(nu, ln_activity(e))) - math.log(K)
110
+ f_lo, f_hi = f(eps_lo), f(eps_hi)
111
+ if f_lo * f_hi > 0:
112
+ eps = eps_hi if f_hi < 0 else eps_lo
113
+ complete = True
114
+ else:
115
+ eps = brentq(f, eps_lo, eps_hi, xtol=1e-14, maxiter=500)
116
+ complete = False
117
+ ys = comp(eps)
118
+ res = Result("Single-reaction equilibrium")
119
+ res.section(rx.pretty(), [
120
+ row("T", T_, T), row("P", P_, P), row("K", f"{K:.6g}"), row("ΔG°(T)", rx.dG(T_), E),
121
+ row("Equilibrium extent ε", f"{eps:.6g} mol"),
122
+ ])
123
+ key = next(i for i, v in enumerate(nu) if v < 0 and n0[i] > 0)
124
+ conv = -nu[key] * eps / n0[key]
125
+ res.section("Equilibrium", [row(f"Conversion of {subs[key].name}", f"{conv * 100:.5g} %")] + [
126
+ row(s.name, f"n = {n0[i] + nu[i] * eps:.6g} mol {'x' if liquid else 'y'} = {ys[i]:.6f}")
127
+ for i, s in enumerate(subs)
128
+ ] + [row(f"{s.name} (inert)", f"n = {v:.6g} mol y = {ys[len(subs) + j]:.6f}") for j, (s, v) in enumerate(inert)])
129
+ if complete:
130
+ res.notes.append("K is so far from 1 that the reaction goes to the limit of the feed.")
131
+ res.notes.append({"ideal": "Ideal-gas mixture: Π(yi P/P°)^νi = K.",
132
+ "virial": "Gas with virial fugacity coefficients: Π(yi φ̂i P/P°)^νi = K.",
133
+ "ideal-liquid": "Ideal liquid solution: Π(xi)^νi = K.",
134
+ "unifac": "Liquid with UNIFAC activity coefficients: Π(xi γi)^νi = K."}[phase_model])
135
+
136
+ def plot():
137
+ import matplotlib.pyplot as plt
138
+ Ts = np.linspace(max(250, T_ - 300), T_ + 300, 60)
139
+ xs = []
140
+ for t in Ts:
141
+ try:
142
+ xs.append((from_si(t, display.temperature, T), single_reaction_conv(reaction, t, P_, feed, phase_model)))
143
+ except Exception:
144
+ pass
145
+ fig, ax = plt.subplots(figsize=(8, 5))
146
+ if xs:
147
+ ax.plot(*zip(*xs))
148
+ ax.plot(from_si(T_, display.temperature, T), conv * 100, "ko")
149
+ ax.set_xlabel(f"T ({display.temperature})"); ax.set_ylabel(f"Conversion of {subs[key].name} (%)")
150
+ ax.set_title(f"Equilibrium conversion at {fmt(P_, P)}"); ax.grid(alpha=0.3)
151
+ fig.tight_layout()
152
+ return fig
153
+
154
+ res.plot = plot
155
+ res.plot_name = "conversion"
156
+ res.data = {"extent": eps, "conversion": conv}
157
+ return res
158
+
159
+
160
+ def single_reaction_conv(text, T_, P_, feed_dict, phase_model):
161
+ feed = ", ".join(f"{s.cas}={v}" for s, v in feed_dict.items())
162
+ return single_reaction(text, T_, P_, feed, phase_model).data["conversion"] * 100
163
+
164
+
165
+ # Multi-reaction (Gibbs energy minimization)
166
+
167
+ def _g0(sub, T_):
168
+ """Molar Gibbs energy of the ideal gas at T and 1 bar, elements in standard state at 298.15 K."""
169
+ substances.require(sub, "Hf", "S0")
170
+ H = sub.Hf + sub.H_ig(298.15, T_)
171
+ S_ = sub.S0 + sub.S_ig(298.15, T_)
172
+ return H - T_ * S_
173
+
174
+
175
+ def gibbs_minimization(species, T_, P_, feed):
176
+ """Equilibrium of a gas mixture by minimizing G with element balances."""
177
+ feed_d = _parse_feed(feed)
178
+ for s in feed_d:
179
+ if s not in species:
180
+ species = species + [s]
181
+ elements = sorted({el for s in species for el in simple_formula_parser(s.formula)})
182
+ A = np.array([[simple_formula_parser(s.formula).get(el, 0) for s in species] for el in elements], float)
183
+ n0 = np.array([feed_d.get(s, 0.0) for s in species])
184
+ b = A @ n0
185
+ g = np.array([_g0(s, T_) / (R * T_) for s in species])
186
+ lnP = math.log(P_ / P0)
187
+ scale = max(n0.sum(), 1e-12)
188
+
189
+ def G(n):
190
+ n = np.maximum(n, 1e-30)
191
+ return float(np.sum(n * (g + np.log(n / n.sum()) + lnP))) / scale
192
+
193
+ def grad(n):
194
+ n = np.maximum(n, 1e-30)
195
+ return (g + np.log(n / n.sum()) + lnP) / scale
196
+
197
+ start = np.linalg.lstsq(A, b, rcond=None)[0]
198
+ start = np.maximum(start, 0) + 0.1 * scale / len(species)
199
+ start = start * 0.5 + n0 * 0.5 + 1e-6
200
+ sol = minimize(G, start, jac=grad, method="SLSQP", bounds=[(1e-20, None)] * len(species),
201
+ constraints=[{"type": "eq", "fun": lambda n: (A @ n - b) / scale, "jac": lambda n: A / scale}],
202
+ options={"ftol": 1e-15, "maxiter": 1000})
203
+ if not sol.success:
204
+ raise ValueError(f"Gibbs minimization did not converge: {sol.message}")
205
+ n = sol.x
206
+ y = n / n.sum()
207
+ res = Result("Multi-reaction equilibrium (Gibbs energy minimization)")
208
+ res.section("Conditions", [row("T", T_, T), row("P", P_, P),
209
+ row("Elements", ", ".join(elements)),
210
+ row("Total moles at equilibrium", f"{n.sum():.6g} mol")])
211
+ res.section("Equilibrium composition", [
212
+ row(s.name, f"n = {ni:.6g} mol y = {yi:.6f} (fed {n0i:g})") for s, ni, yi, n0i in zip(species, n, y, n0)
213
+ ])
214
+ lam = g + np.log(y) + lnP
215
+ res.notes.append("Ideal-gas mixture; Lagrange multipliers satisfied when Σ λk aik = Gi/RT for every species.")
216
+ res.notes.append(f"Max element-balance error: {np.max(np.abs(A @ n - b)):.2e} mol.")
217
+ res.data = {"n": dict(zip([s.name for s in species], n)), "mu": lam}
218
+ return res