stages_thermo/thermo.rs
1//! The thermodynamics adapter — **the only module that imports `vle_thermo`**
2//! (PLAN §7).
3//!
4//! Every other module in this crate reaches thermodynamics through this
5//! boundary, never through `vle_thermo` directly. Keeping the dependency
6//! surface in one file means:
7//!
8//! - a surrogate thermo model (the inside-out inner loop, a stretch milestone)
9//! or a test mock can replace it without touching any solver, and
10//! - the exact set of vle-thermo entry points stages-thermo relies on is
11//! auditable in one place — which is what turns this crate into a
12//! pressure-test of vle-thermo's public API (PLAN §1).
13//!
14//! From M1 the adapter is a concrete [`ThermoSystem`]: an owned, reusable
15//! description of a mixture + model selection that can answer bubble-point
16//! questions. It stays a struct (not a trait) until a surrogate model or a
17//! mock genuinely needs to slot in — no premature abstraction (PLAN §7).
18//!
19//! ## Reference-state discipline
20//!
21//! One convention for the whole column, set once here, never per-stage.
22//! Enthalpy queries arrive at M2 (Ponchon–Savarit); when they do, the
23//! reference state lives in this struct.
24//!
25//! ## Units
26//!
27//! Canonical engine units throughout: temperature **K**, pressure **kPa**
28//! (absolute), mole fractions dimensionless.
29
30use vle_thermo::activity::ActivityModel;
31use vle_thermo::eos::{CubicEos, LiquidModel, PhaseId, VaporModel};
32use vle_thermo::flash::bubble::{bubble_pressure, bubble_temperature};
33use vle_thermo::flash::{SystemSpec, phase_enthalpy_entropy};
34use vle_thermo::mixing::MixingRule;
35use vle_thermo::types::Component;
36
37use crate::types::{Result, StagesError};
38
39/// Convergence tolerance passed to vle-thermo's bubble-point iterations.
40const BUBBLE_TOL: f64 = 1e-9;
41/// Iteration budget for vle-thermo's bubble-point iterations.
42const BUBBLE_MAX_ITER: usize = 200;
43
44/// Reference temperature for the enthalpy datum — **298.15 K** (25 °C).
45///
46/// One convention for the whole column (PLAN §7). Ponchon–Savarit works with
47/// enthalpy *differences*, so the datum choice cancels; 298.15 K / 101.325 kPa
48/// is the conventional thermochemical standard state and keeps the numbers
49/// readable.
50const T_REF: f64 = 298.15;
51/// Reference pressure for the enthalpy datum — **101.325 kPa** (1 atm).
52const P_REF: f64 = 101.325;
53
54/// Which phase's molar enthalpy to evaluate — the adapter's own phase tag, so
55/// no `vle_thermo` type crosses the module boundary (mirrors [`BubblePoint`]).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Phase {
58 /// Saturated (or subcooled) liquid.
59 Liquid,
60 /// Saturated (or superheated) vapor.
61 Vapor,
62}
63
64impl Phase {
65 /// Map to vle-thermo's internal phase id (kept private to this module).
66 fn to_phase_id(self) -> PhaseId {
67 match self {
68 Phase::Liquid => PhaseId::Liquid,
69 Phase::Vapor => PhaseId::Vapor,
70 }
71 }
72}
73
74/// A bubble-point evaluation result: the solved value plus the incipient
75/// vapor composition and K-values, re-exported in adapter-owned form so no
76/// vle-thermo type crosses the module boundary.
77#[derive(Debug, Clone)]
78pub struct BubblePoint {
79 /// The solved quantity — bubble temperature in **K** (for
80 /// [`ThermoSystem::bubble_temperature`]) or bubble pressure in **kPa**
81 /// (for [`ThermoSystem::bubble_pressure`]).
82 pub value: f64,
83 /// Incipient vapor mole fractions `y*` in equilibrium with the liquid.
84 pub y: Vec<f64>,
85 /// K-values `K_i = y_i / x_i` at the bubble point.
86 pub k: Vec<f64>,
87}
88
89/// Which thermodynamic route evaluates the liquid phase.
90///
91/// vle-thermo supports both classic formulations; stages-thermo exposes the
92/// two used on the M1 pedagogical ladder:
93///
94/// - **φ-φ**: one cubic EOS for both phases (benzene–toluene with
95/// Peng–Robinson — near-ideal hydrocarbon pairs).
96/// - **γ-φ**: an activity-coefficient model for the liquid + ideal-gas vapor
97/// (methanol–water with van Laar — the nonideal aqueous systems validated
98/// in vle Chapter IV).
99#[derive(Debug, Clone)]
100enum ModelKind {
101 /// Cubic EOS both phases; the inner matrix is the (symmetric) binary
102 /// interaction parameter matrix `kij` (empty ⇒ all zero).
103 PhiPhi { eos: CubicEos, kij: Vec<Vec<f64>> },
104 /// Activity-coefficient liquid + ideal-gas vapor; `aij` is the activity
105 /// model's binary parameter matrix (`aij[0][1] = A₁₂`, `aij[1][0] = A₂₁`,
106 /// zero diagonal), and `alpha` is the NRTL non-randomness matrix — **empty
107 /// for every model except NRTL**, which reads it (vle-thermo 0.11's option-B
108 /// parallel `alpha` field on `SystemSpec`).
109 GammaPhi {
110 activity: ActivityModel,
111 aij: Vec<Vec<f64>>,
112 alpha: Vec<Vec<f64>>,
113 },
114}
115
116/// An owned mixture + model selection that answers equilibrium questions.
117///
118/// `vle_thermo::flash::SystemSpec` borrows all its data, so this struct owns
119/// the components and parameter matrices and assembles a fresh (cheap, `Copy`)
120/// spec per call. Construct once per column problem, reuse for every
121/// evaluation.
122#[derive(Debug, Clone)]
123#[cfg_attr(feature = "python", pyo3::pyclass)]
124pub struct ThermoSystem {
125 components: Vec<Component>,
126 model: ModelKind,
127 /// Enthalpy-datum temperature in **K** — [`T_REF`], set once here, never
128 /// per-stage (PLAN §7 reference-state discipline).
129 t_ref: f64,
130 /// Enthalpy-datum pressure in **kPa** — [`P_REF`].
131 p_ref: f64,
132}
133
134impl ThermoSystem {
135 /// A φ-φ system: Peng–Robinson (1976) for both phases, classical mixing,
136 /// all `kij = 0`. Components are loaded from vle-thermo's built-in
137 /// database by (case-insensitive) name.
138 ///
139 /// This is the right default for near-ideal hydrocarbon pairs like
140 /// benzene–toluene.
141 ///
142 /// # Errors
143 /// [`StagesError::Thermo`] if any name is not in the database.
144 pub fn peng_robinson(names: &[&str]) -> Result<Self> {
145 Ok(Self {
146 components: load_components(names)?,
147 model: ModelKind::PhiPhi {
148 eos: CubicEos::PR1976,
149 kij: Vec::new(),
150 },
151 t_ref: T_REF,
152 p_ref: P_REF,
153 })
154 }
155
156 /// A γ-φ system: van Laar activity coefficients for the liquid, ideal-gas
157 /// vapor. Components are loaded from vle-thermo's built-in database by
158 /// (case-insensitive) name.
159 ///
160 /// # Arguments
161 /// * `a12`, `a21` — the dimensionless van Laar parameters, in the
162 /// component order given by `names` (`a12` multiplies the first
163 /// component's infinite-dilution log-γ: `ln γ₁^∞ = A₁₂`).
164 ///
165 /// For methanol(1)–water(2), vle Chapter IV (Orbey & Sandler Table 4.5)
166 /// regressed `A₁₂ = 0.5853`, `A₂₁ = 0.3458`.
167 ///
168 /// # Errors
169 /// [`StagesError::Thermo`] if any name is not in the database.
170 pub fn van_laar(names: &[&str; 2], a12: f64, a21: f64) -> Result<Self> {
171 Ok(Self {
172 components: load_components(names)?,
173 model: ModelKind::GammaPhi {
174 activity: ActivityModel::VanLaar,
175 aij: vec![vec![0.0, a12], vec![a21, 0.0]],
176 // van Laar reads no non-randomness matrix — leave it empty so
177 // `SystemSpec.alpha` is ignored.
178 alpha: Vec::new(),
179 },
180 t_ref: T_REF,
181 p_ref: P_REF,
182 })
183 }
184
185 /// A γ-φ system: **NRTL** activity coefficients (Renon & Prausnitz, *AIChE
186 /// J.* **1968**, 14, 135) for the liquid, ideal-gas vapor. Components are
187 /// loaded from vle-thermo's built-in database by (case-insensitive) name.
188 ///
189 /// NRTL is the general aqueous-organic infrastructure the ladder needs from
190 /// M2 on (Ponchon–Savarit's ammonia–water showcase, and the later
191 /// extractive/azeotropic ternaries). Its binary form has three parameters:
192 /// two interaction energies plus one shared non-randomness factor.
193 ///
194 /// # Arguments
195 /// * `a12`, `a21` — the NRTL interaction energies `gᵢⱼ − gⱼⱼ` in
196 /// **kJ/kmol**, in the component order given by `names`. vle-thermo forms
197 /// `τᵢⱼ = aᵢⱼ / (R·T)` internally (R = 8.31451 kJ/(kmol·K)), so these are
198 /// the *energy* parameters, **not** the dimensionless τ.
199 /// * `alpha12` — the (symmetric) non-randomness parameter α₁₂ = α₂₁,
200 /// dimensionless, typically 0.2–0.47.
201 ///
202 /// # Errors
203 /// [`StagesError::Thermo`] if any name is not in the database.
204 pub fn nrtl(names: &[&str; 2], a12: f64, a21: f64, alpha12: f64) -> Result<Self> {
205 Ok(Self {
206 components: load_components(names)?,
207 model: ModelKind::GammaPhi {
208 activity: ActivityModel::Nrtl,
209 // aᵢⱼ = gᵢⱼ − gⱼⱼ, zero diagonal (a component with itself).
210 aij: vec![vec![0.0, a12], vec![a21, 0.0]],
211 // Symmetric non-randomness matrix; the diagonal is unused.
212 alpha: vec![vec![0.0, alpha12], vec![alpha12, 0.0]],
213 },
214 t_ref: T_REF,
215 p_ref: P_REF,
216 })
217 }
218
219 /// The enthalpy-datum temperature in **K** ([`T_REF`]).
220 pub fn t_ref(&self) -> f64 {
221 self.t_ref
222 }
223
224 /// The enthalpy-datum pressure in **kPa** ([`P_REF`]).
225 pub fn p_ref(&self) -> f64 {
226 self.p_ref
227 }
228
229 /// Number of components in the mixture.
230 pub fn n_components(&self) -> usize {
231 self.components.len()
232 }
233
234 /// Component names, in the order compositions are indexed.
235 pub fn component_names(&self) -> Vec<String> {
236 self.components.iter().map(|c| c.name.clone()).collect()
237 }
238
239 /// Bubble temperature of a liquid of composition `x` at pressure `p`.
240 ///
241 /// # Arguments
242 /// * `p` — pressure in **kPa** (absolute)
243 /// * `x` — liquid mole fractions (must sum to 1)
244 ///
245 /// # Returns
246 /// [`BubblePoint`] with `value` = bubble temperature in **K**.
247 pub fn bubble_temperature(&self, p: f64, x: &[f64]) -> Result<BubblePoint> {
248 self.check_composition(x)?;
249 // `with_spec` hands the closure a borrowed SystemSpec assembled from
250 // this struct's owned data — the borrow can't outlive the call, which
251 // is exactly the lifetime discipline SystemSpec's design asks for.
252 self.with_spec(|spec| {
253 let r = bubble_temperature(spec, p, x, BUBBLE_TOL, BUBBLE_MAX_ITER)
254 .map_err(|e| StagesError::Thermo(e.to_string()))?;
255 Ok(BubblePoint {
256 value: r.value,
257 y: r.incipient,
258 k: r.k,
259 })
260 })
261 }
262
263 /// Bubble pressure of a liquid of composition `x` at temperature `t`.
264 ///
265 /// # Arguments
266 /// * `t` — temperature in **K**
267 /// * `x` — liquid mole fractions (must sum to 1)
268 ///
269 /// # Returns
270 /// [`BubblePoint`] with `value` = bubble pressure in **kPa** (absolute).
271 pub fn bubble_pressure(&self, t: f64, x: &[f64]) -> Result<BubblePoint> {
272 self.check_composition(x)?;
273 self.with_spec(|spec| {
274 let r = bubble_pressure(spec, t, x, BUBBLE_TOL, BUBBLE_MAX_ITER)
275 .map_err(|e| StagesError::Thermo(e.to_string()))?;
276 Ok(BubblePoint {
277 value: r.value,
278 y: r.incipient,
279 k: r.k,
280 })
281 })
282 }
283
284 /// Molar enthalpy of `comp` in the given `phase` at `(t, p)`.
285 ///
286 /// This is the energy quantity Ponchon–Savarit needs: the H–x–y diagram is
287 /// saturated-liquid and saturated-vapor enthalpy versus composition. The
288 /// call routes to vle-thermo's `phase_enthalpy_entropy`, which for the γ-φ
289 /// liquid assembles ideal-gas terms **minus** the per-component
290 /// Clausius–Clapeyron condensation enthalpy **plus** the excess enthalpy
291 /// Hᴱ — the γ-φ enthalpy route (see the M2 lesson in
292 /// `docs/theory/ponchon-savarit.md`). Entropy is computed alongside but
293 /// **deliberately discarded** — P–S never uses it; widen the return to a
294 /// pair only when a later milestone needs S.
295 ///
296 /// # Arguments
297 /// * `t` — temperature in **K**
298 /// * `p` — pressure in **kPa** (absolute)
299 /// * `comp` — phase mole fractions (must sum to 1, length = n components)
300 /// * `phase` — [`Phase::Liquid`] or [`Phase::Vapor`]
301 ///
302 /// # Returns
303 /// Molar enthalpy in **kJ/kmol**, relative to the system's `t_ref`/`p_ref`
304 /// datum with per-component reference enthalpy zero (P–S uses differences).
305 ///
306 /// # Errors
307 /// [`StagesError::Dimension`] on a length mismatch; [`StagesError::Thermo`]
308 /// if the property evaluation fails.
309 pub fn phase_enthalpy(&self, t: f64, p: f64, comp: &[f64], phase: Phase) -> Result<f64> {
310 self.check_composition(comp)?;
311 let phase_id = phase.to_phase_id();
312 self.with_spec(|spec| {
313 // `&[]` for h_ref/s_ref selects the zero datum. Upstream returns an
314 // (H, S) tuple; `.0` takes H and drops S (see the doc comment).
315 let (h, _s) = phase_enthalpy_entropy(
316 spec,
317 t,
318 p,
319 comp,
320 phase_id,
321 self.t_ref,
322 self.p_ref,
323 &[],
324 &[],
325 )
326 .map_err(|e| StagesError::Thermo(e.to_string()))?;
327 Ok(h)
328 })
329 }
330
331 /// Assemble the borrowing `SystemSpec` and run `f` against it.
332 fn with_spec<T>(&self, f: impl FnOnce(&SystemSpec<'_>) -> Result<T>) -> Result<T> {
333 let spec = match &self.model {
334 ModelKind::PhiPhi { eos, kij } => SystemSpec {
335 components: &self.components,
336 vapor: VaporModel::Cubic(*eos),
337 liquid: LiquidModel::Cubic(*eos),
338 mixing_rule: MixingRule::Classical,
339 kij,
340 aij: &[],
341 alpha: &[],
342 vl: &[],
343 delta: &[],
344 sat_models: &[],
345 ge_model: None,
346 },
347 ModelKind::GammaPhi {
348 activity,
349 aij,
350 alpha,
351 } => SystemSpec {
352 components: &self.components,
353 vapor: VaporModel::IdealGas,
354 liquid: LiquidModel::Activity(*activity),
355 mixing_rule: MixingRule::Classical,
356 kij: &[],
357 aij,
358 // Empty for every model except NRTL (van Laar leaves it `[]`).
359 alpha,
360 vl: &[],
361 delta: &[],
362 sat_models: &[],
363 // `ge_model` couples an activity model into a GE-based cubic
364 // mixing rule (Wong–Sandler etc.) — not what a plain γ-φ
365 // liquid uses. The activity model rides in `liquid` above.
366 ge_model: None,
367 },
368 };
369 f(&spec)
370 }
371
372 fn check_composition(&self, x: &[f64]) -> Result<()> {
373 if x.len() != self.components.len() {
374 return Err(StagesError::Dimension(format!(
375 "composition has {} entries but the system has {} components",
376 x.len(),
377 self.components.len()
378 )));
379 }
380 Ok(())
381 }
382}
383
384/// Load components from vle-thermo's built-in database, erroring with the
385/// list of available names on a miss.
386fn load_components(names: &[&str]) -> Result<Vec<Component>> {
387 names
388 .iter()
389 .map(|name| {
390 vle_thermo::db::component(name).ok_or_else(|| {
391 StagesError::Thermo(format!(
392 "component {name:?} not in the vle-thermo database (available: {})",
393 vle_thermo::db::available().join(", ")
394 ))
395 })
396 })
397 .collect()
398}
399
400/// The bubble temperature of an equimolar methanol(1)/water(2) mixture at
401/// 101.325 kPa, computed through vle-thermo with the Peng–Robinson EOS and
402/// classical mixing.
403///
404/// This is the M0 end-to-end smoke path, kept as the cheapest cross-FFI
405/// health check (see [`crate::py_bindings`]). Since M1 it runs through
406/// [`ThermoSystem`] with database-loaded components. It is **not** a
407/// validated property — methanol/water is strongly non-ideal and a bare
408/// cubic EOS with no activity model is only approximate here; the validated
409/// van Laar route is [`ThermoSystem::van_laar`].
410///
411/// Returns the bubble temperature in **K**.
412pub fn smoke_bubble_temperature() -> Result<f64> {
413 let system = ThermoSystem::peng_robinson(&["methanol", "water"])?;
414 Ok(system.bubble_temperature(101.325, &[0.5, 0.5])?.value)
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 /// The smoke path evaluates end-to-end and returns a physically plausible
422 /// bubble temperature. The window is deliberately wide — this asserts the
423 /// dependency works, not a validated value (see the function docs).
424 #[test]
425 fn smoke_bubble_temperature_is_physical() {
426 let t = smoke_bubble_temperature().expect("bubble_temperature should converge");
427 assert!(
428 (280.0..400.0).contains(&t),
429 "methanol/water bubble T = {t} K is outside the plausible window [280, 400)"
430 );
431 }
432
433 /// Pure-component bubble T of a φ-φ system reproduces the normal boiling
434 /// point from the database within a couple of kelvin (PR is not exact at
435 /// low pressure, but it must be close).
436 #[test]
437 fn benzene_toluene_pure_endpoints_near_boiling_points() {
438 let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
439 // Benzene boils at 353.24 K, toluene at 383.78 K (1 atm).
440 let t_b = sys.bubble_temperature(101.325, &[1.0, 0.0]).unwrap().value;
441 let t_t = sys.bubble_temperature(101.325, &[0.0, 1.0]).unwrap().value;
442 assert!((t_b - 353.24).abs() < 3.0, "benzene Tb = {t_b} K");
443 assert!((t_t - 383.78).abs() < 3.0, "toluene Tb = {t_t} K");
444 }
445
446 /// The van Laar γ-φ route reproduces the vle Chapter IV methanol–water
447 /// system: positive deviation from Raoult's law means the bubble pressure
448 /// at 298.15 K exceeds the Raoult (linear) interpolation of the pure
449 /// vapor pressures.
450 #[test]
451 fn methanol_water_van_laar_positive_deviation() {
452 let sys = ThermoSystem::van_laar(&["methanol", "water"], 0.5853, 0.3458).unwrap();
453 let p_pure_m = sys.bubble_pressure(298.15, &[1.0, 0.0]).unwrap().value;
454 let p_pure_w = sys.bubble_pressure(298.15, &[0.0, 1.0]).unwrap().value;
455 let p_mid = sys.bubble_pressure(298.15, &[0.5, 0.5]).unwrap().value;
456 let raoult = 0.5 * p_pure_m + 0.5 * p_pure_w;
457 assert!(
458 p_mid > raoult,
459 "van Laar should give positive deviation: P(0.5) = {p_mid} kPa ≤ Raoult {raoult} kPa"
460 );
461 }
462
463 /// The NRTL γ-φ route runs end-to-end (exercising vle-thermo 0.11's new
464 /// `alpha` matrix path) and gives the expected positive deviation for
465 /// ethanol–water — a strongly non-ideal, azeotrope-forming pair.
466 #[test]
467 fn ethanol_water_nrtl_positive_deviation() {
468 // Aspen-style NRTL for ethanol(1)/water(2): Δg₁₂ = −458.7,
469 // Δg₂₁ = 5574 kJ/kmol, α = 0.303 (VLE-regressed positive-deviation
470 // azeotrope). Exact reproduction isn't the point here — the sign is.
471 let sys = ThermoSystem::nrtl(&["ethanol", "water"], -458.7, 5574.0, 0.303).unwrap();
472 let p_pure_e = sys.bubble_pressure(298.15, &[1.0, 0.0]).unwrap().value;
473 let p_pure_w = sys.bubble_pressure(298.15, &[0.0, 1.0]).unwrap().value;
474 let p_mid = sys.bubble_pressure(298.15, &[0.5, 0.5]).unwrap().value;
475 let raoult = 0.5 * p_pure_e + 0.5 * p_pure_w;
476 assert!(
477 p_mid > raoult,
478 "NRTL should give positive deviation: P(0.5) = {p_mid} kPa ≤ Raoult {raoult} kPa"
479 );
480 }
481
482 /// The γ-φ enthalpy path runs and respects the sign and rough magnitude of
483 /// the latent heat: at a bubble point, saturated-vapor molar enthalpy
484 /// exceeds saturated-liquid enthalpy by tens of MJ/kmol.
485 #[test]
486 fn methanol_water_vapor_enthalpy_exceeds_liquid() {
487 let sys = ThermoSystem::van_laar(&["methanol", "water"], 0.5853, 0.3458).unwrap();
488 let p = 101.325;
489 let x = [0.4, 0.6];
490 let bp = sys.bubble_temperature(p, &x).unwrap();
491 let t = bp.value;
492 let h_liq = sys.phase_enthalpy(t, p, &x, Phase::Liquid).unwrap();
493 // Saturated vapor is the incipient vapor y*(x) at the same T, P.
494 let h_vap = sys.phase_enthalpy(t, p, &bp.y, Phase::Vapor).unwrap();
495 assert!(
496 h_vap > h_liq,
497 "latent heat must be positive: H_vap = {h_vap} ≤ H_liq = {h_liq} kJ/kmol"
498 );
499 let dh = h_vap - h_liq;
500 assert!(
501 (10_000.0..80_000.0).contains(&dh),
502 "ΔH_vap = {dh} kJ/kmol is outside the plausible latent-heat window"
503 );
504 }
505
506 /// The reference-state datum is the standard thermochemical state, set once.
507 #[test]
508 fn reference_state_is_standard() {
509 let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
510 assert_eq!(sys.t_ref(), 298.15);
511 assert_eq!(sys.p_ref(), 101.325);
512 }
513
514 /// Unknown component names produce a Thermo error naming the culprit.
515 #[test]
516 fn unknown_component_is_a_thermo_error() {
517 let err = ThermoSystem::peng_robinson(&["benzene", "unobtainium"]).unwrap_err();
518 assert!(matches!(err, StagesError::Thermo(_)));
519 assert!(err.to_string().contains("unobtainium"));
520 }
521
522 /// Composition length must match the component count.
523 #[test]
524 fn dimension_mismatch_is_caught() {
525 let sys = ThermoSystem::peng_robinson(&["benzene", "toluene"]).unwrap();
526 let err = sys.bubble_temperature(101.325, &[1.0]).unwrap_err();
527 assert!(matches!(err, StagesError::Dimension(_)));
528 }
529}