Skip to main content

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}