Skip to main content

dover/
smr.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3//
4// This file is part of OUTRAM PARK.
5//
6// OUTRAM PARK is free software: you can redistribute it and/or modify it
7// under the terms of the GNU General Public License as published by the
8// Free Software Foundation, either version 3 of the License, or (at your
9// option) any later version.
10//
11// OUTRAM PARK is distributed in the hope that it will be useful, but
12// WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14// General Public License for more details.
15//
16// You should have received a copy of the GNU General Public License along
17// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
18
19//! Steam methane reforming in a continuous stirred-tank reactor.
20//!
21//! # What this models
22//!
23//! Methane and steam over a reforming catalyst, in a perfectly-mixed tank at
24//! steady state. Two **independent** reactions:
25//!
26//! ```text
27//!   R1  reforming   CH4 + H2O  <=>  CO  + 3 H2     strongly endothermic
28//!   R2  water-gas   CO  + H2O  <=>  CO2 +   H2     mildly exothermic
29//!       shift
30//! ```
31//!
32//! # Why two reactions and not three
33//!
34//! Textbooks usually write a third, `CH4 + 2 H2O <=> CO2 + 4 H2`. It is
35//! **R1 + R2 exactly** — same atoms, same enthalpy, same entropy — so adding
36//! it to the reaction list does not add chemistry. It adds a redundant extent:
37//! the outlet composition becomes a function of three unknowns constrained by
38//! two, the Jacobian in [`Cstr`]'s Newton solve goes singular, and the solver
39//! either wanders or fails. The set here is deliberately the independent one,
40//! and [`smr_reactions`] returns exactly two.
41//!
42//! This is checked, not asserted: `the_overall_reaction_is_the_sum_of_the_two`
43//! confirms the third reaction's enthalpy and entropy are reproduced by
44//! summing R1 and R2, which is *why* it is redundant.
45//!
46//! # The model hierarchy: what is derived and what is fitted
47//!
48//! The workspace's model-hierarchy rule requires the physics-derived layer to
49//! be built and run **uncalibrated** first, with the disagreement reported,
50//! before anything is fitted. This module is built to make that separation
51//! explicit rather than to assert it:
52//!
53//! | quantity | where it comes from | free? |
54//! |---|---|---|
55//! | `ΔH°`, `ΔS°` per reaction | summed from [`crate::species`] formation data | **no** — derived |
56//! | `K_eq(T)` | van 't Hoff on those: `K = exp(−(ΔH° − TΔS°)/RT)` | **no** — derived |
57//! | reverse rate `A_r`, `E_r` | [`consistent_reverse`], forced to satisfy `k_f/k_r = K_eq` | **no** — derived |
58//! | forward rate `A_f`, `E_f` | the deck | **yes** — the *only* fitted inputs |
59//!
60//! So the **equilibrium limit of this reactor is pure thermodynamics**: as the
61//! residence time grows, the outlet approaches the equilibrium composition
62//! regardless of what `A_f` and `E_f` are. The forward rate sets only how
63//! quickly that limit is approached. That is the uncalibrated model, and
64//! `long_residence_time_approaches_thermodynamic_equilibrium` measures it.
65//!
66//! **No published kinetic parameter set is embedded here.** The Xu–Froment
67//! (1989) Langmuir–Hinshelwood constants are the usual choice and are
68//! deliberately *not* typed in: they are fitted to one specific
69//! Ni/MgAl₂O₄ catalyst, and the workspace requires any document informing the
70//! code to be catalogued in `kovan-literature` first — which, as
71//! [`crate::species`] records, has not been done. A deck supplies its own
72//! `A_f`/`E_f` and owns that choice.
73//!
74//! # Stiffness: why the bundled deck's shift pre-exponential is 1e2
75//!
76//! The residual [`Cstr`] solves is `ζ_r − V·rate_r(C(ζ)) = 0`. It is only
77//! well-scaled while `V·rate` is comparable to the extents themselves. A
78//! reaction fast enough that `V·rate` runs many orders of magnitude above the
79//! feed rate is, physically, *at equilibrium* — and numerically it makes that
80//! residual unsolvable.
81//!
82//! This bit, and the measurement is recorded here because the symptom looks
83//! like a solver bug and is not. An earlier draft of the bundled deck used
84//! `A = 1e6` for the shift. At the base operating point that is
85//! `V·rate_shift = 1.3e6 mol/s` once CO reaches 0.1 mol/s — against a
86//! **1 mol/s** methane feed, six orders of magnitude out. Measured 2026-09-25:
87//! the solve then failed for 4 of 13 temperatures between 800 K and 1400 K and
88//! for 4 of 6 volumes between 2 m³ and 2e5 m³, and raising `max_iter` to
89//! 100 000 fixed none of them, because the failure is not a budget shortfall —
90//! `‖g‖` has a strict local minimum at `ζ ≈ 0` that is not a root, so no step
91//! in any direction reduces it.
92//!
93//! **The choice of `A` costs nothing physically, which is what licenses making
94//! it on conditioning grounds.** The shift is equilibrium-limited over the
95//! whole range, so the answer is invariant (measured 2026-09-25, base case,
96//! `max_iter = 20 000`):
97//!
98//! | `A_shift` | methane conversion | shift `Q/Kc` |
99//! |---|---|---|
100//! | 1e0 | 0.41151567 | 0.921970 |
101//! | 1e1 | 0.41172225 | 0.991650 |
102//! | **1e2** | **0.41174540** | **0.999159** |
103//! | 1e3 | 0.41174774 | 0.999916 |
104//! | 1e4 | 0.41174797 | 0.999992 |
105//! | 1e6 | 0.41174800 | 1.000000 |
106//! | 1e7 | 0.41174800 | 1.000000 |
107//!
108//! From `1e2` up the reported conversion moves by 6e-6 relative while the
109//! conditioning improves by four orders of magnitude. `1e2` is the deck's
110//! value for that reason, and `shift_kinetics_only_set_the_approach_rate`
111//! pins the invariance so the claim can fail. This is **not** a parameter
112//! tuned to reproduce a target: the target is unchanged across the range.
113//!
114//! # Honest scope — read before using a number from this
115//!
116//! - **Isothermal.** The feed temperature is held through the reactor. The
117//!   heat of reaction is *reported* ([`SmrOutcome::heat_duty`]) but not fed
118//!   back into an energy balance. Real reforming is violently endothermic and
119//!   a real reactor's temperature drops along it; this does not model that.
120//! - **Ideal gas, constant volumetric flow.** `Cᵢ = Fᵢ/Q` with `Q` fixed, the
121//!   inherited assumption from `dwsim-libs`' reactors. Reforming increases
122//!   the mole count substantially (1 + 1 → 1 + 3), so a real isobaric reactor
123//!   expands and `Q` rises. ~~Holding it fixed overstates the product
124//!   concentrations and therefore the reverse rates.~~ **CORRECTED
125//!   2026-09-25, by measurement:** holding it fixed overstates *every*
126//!   concentration, reactants included, and this reactor is forward-rate
127//!   limited, so the net effect is that conversion comes out **too high**, not
128//!   too low. Compiled upstream DWSIM, which re-flashes the tank every sweep,
129//!   gives methane conversion **0.375803** on the base deck where this model
130//!   gives **0.411745 — +9.6 %** — with upstream's `Q` growing 19.0 % through
131//!   the reactor. Handed upstream's outlet `Q`, the same solver reproduces
132//!   upstream to 6.1e-10 per species, so the whole gap is this assumption
133//!   (`outram-park-fork-dwsim-libs/tests/upstream_cstr_parity.rs`). Documented,
134//!   not modelled.
135//! - **Power-law kinetics, no adsorption.** `dwsim-libs` has
136//!   `LangmuirHinshelwood` ready for the surface-coverage denominator real
137//!   reforming needs; wiring it requires the catalyst constants above, so it
138//!   is left for when they can be catalogued.
139//! - **No catalyst, no diffusion, no pressure drop, no carbon formation.**
140//!
141//! **Untrusted AI-assisted draft, no human V&V.** Education, research and V&V
142//! only, per the workspace `RESPONSIBLE_USE.md`.
143
144use petir::mathf::RealMath;
145use outram_park_fork_dwsim_libs::reactions::{
146    EquilibriumConstant, Reaction, ReactionBasis, ReactionComponent, ReactionKind,
147};
148use outram_park_fork_dwsim_libs::reactors::{Cstr, ReactorError, ReactorFeed};
149
150use crate::species::Species;
151
152/// Molar gas constant `R` [J/(mol·K)] for **thermodynamics** — CODATA, exact
153/// by the 2019 SI redefinition.
154///
155/// Used for `ΔG° = ΔH° − TΔS°`, for `K = exp(−ΔG°/RT)`, and for the ideal-gas
156/// volumetric flow. Not used for rate constants — see [`R_KINETIC`].
157pub const R_GAS: f64 = 8.314_462_618_153_24;
158
159/// The gas constant **`dwsim-libs` evaluates Arrhenius rate constants with**,
160/// re-exported so this crate cannot drift from it.
161///
162/// # Why there are two of these
163///
164/// `outram-park-fork-dwsim-libs` carries three gas constants:
165/// `reactions::R_GAS = 8.314` (truncated, as DWSIM upstream has it) and
166/// `thermo::flash_sle::R_GAS` / `thermo::activity::R_GAS`, both the full
167/// CODATA `8.31446261815324`. `Reaction::forward_rate_constant` uses the
168/// truncated one.
169///
170/// That 5.6e-5 relative difference is invisible almost everywhere and is
171/// emphatically not invisible here: `consistent_reverse` has to predict what
172/// `forward_rate_constant` will return, and an activation energy of 240 kJ/mol
173/// at 700 K puts `E/(RT)` near 41, so a 5.6e-5 error in `R` becomes a
174/// **2e-3 relative error in `k`**. That is what the first run of
175/// `reverse_rate_is_thermodynamically_consistent` measured, to within a
176/// factor of one — the test found it immediately.
177///
178/// So: thermodynamic quantities use [`R_GAS`], and anything that has to agree
179/// with the rate law uses this. Mixing them silently breaks the consistency
180/// guarantee that is the point of the model.
181pub const R_KINETIC: f64 = outram_park_fork_dwsim_libs::reactions::R_GAS;
182
183/// Newton iteration budget handed to [`Cstr`], overriding its default of 200.
184///
185/// # Why it is raised, and why that is a budget and not a loosened criterion
186///
187/// [`Cstr`]'s tolerance is untouched — a solve still has to reach the same
188/// residual, it is merely allowed more steps to get there. The cases that need
189/// them are the genuinely harder ones: a large reactor sitting close to
190/// equilibrium, where the residual surface is flat near the root.
191///
192/// **Measured 2026-09-25** over a 936-point grid (6 volumes from 2 m³ to
193/// 2e5 m³ × 13 temperatures from 800 K to 1400 K × 4 pressures from 5 bar to
194/// 40 bar × 3 steam-to-carbon ratios), with the deck's kinetics: **0 failures**,
195/// and the worst case needed 20 000 iterations (V = 2e4 m³, 800 K, 40 bar,
196/// S/C = 1). The base case converges in 200 and takes about 0.3 ms; the
197/// slowest in the grid takes a few milliseconds. The budget is therefore set
198/// at the measured worst case rather than at a round number.
199pub const MAX_NEWTON_ITERATIONS: usize = 20_000;
200
201/// Reference temperature for the standard-state data, 298.15 K.
202pub const T_REF: f64 = 298.15;
203
204/// Which of the two independent reactions.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum SmrReaction {
207    /// `CH4 + H2O <=> CO + 3 H2`.
208    Reforming,
209    /// `CO + H2O <=> CO2 + H2`.
210    WaterGasShift,
211}
212
213impl SmrReaction {
214    /// Both, in the order [`smr_reactions`] returns them.
215    pub const BOTH: [SmrReaction; 2] = [SmrReaction::Reforming, SmrReaction::WaterGasShift];
216
217    /// Signed stoichiometry `νᵢ` — negative reactant, positive product.
218    #[must_use]
219    pub fn stoichiometry(self) -> [(Species, f64); 4] {
220        match self {
221            SmrReaction::Reforming => [
222                (Species::Methane, -1.0),
223                (Species::Steam, -1.0),
224                (Species::CarbonMonoxide, 1.0),
225                (Species::Hydrogen, 3.0),
226            ],
227            SmrReaction::WaterGasShift => [
228                (Species::CarbonMonoxide, -1.0),
229                (Species::Steam, -1.0),
230                (Species::CarbonDioxide, 1.0),
231                (Species::Hydrogen, 1.0),
232            ],
233        }
234    }
235
236    /// The reaction's base reactant — the species whose conversion normalises
237    /// the extent.
238    #[must_use]
239    pub fn base_reactant(self) -> Species {
240        match self {
241            SmrReaction::Reforming => Species::Methane,
242            SmrReaction::WaterGasShift => Species::CarbonMonoxide,
243        }
244    }
245
246    /// Standard reaction enthalpy `ΔH°` at 298.15 K [J/mol of extent],
247    /// summed from [`crate::species`] formation enthalpies.
248    ///
249    /// Positive is endothermic. Nothing here is a tabulated reaction
250    /// enthalpy — it is `Σ νᵢ ΔH°f,ᵢ`, so it cannot disagree with the species
251    /// table it is built from.
252    #[must_use]
253    pub fn delta_h(self) -> f64 {
254        self.stoichiometry()
255            .iter()
256            .map(|&(sp, nu)| nu * sp.enthalpy_formation())
257            .sum()
258    }
259
260    /// Standard reaction entropy `ΔS°` at 298.15 K [J/(mol·K)], summed from
261    /// absolute entropies as `Σ νᵢ S°ᵢ`.
262    #[must_use]
263    pub fn delta_s(self) -> f64 {
264        self.stoichiometry()
265            .iter()
266            .map(|&(sp, nu)| nu * sp.entropy())
267            .sum()
268    }
269
270    /// Standard Gibbs energy of reaction at `temperature_k` [J/mol], on the
271    /// constant-`ΔH°`/`ΔS°` (van 't Hoff) approximation: `ΔG° = ΔH° − T ΔS°`.
272    ///
273    /// **This is the model's main thermodynamic approximation.** Treating
274    /// `ΔH°` and `ΔS°` as temperature-independent ignores the `∫Cp dT` and
275    /// `∫Cp/T dT` corrections, which over 298 K → 1200 K are not negligible.
276    /// It is the approximation `EquilibriumConstant::GibbsVantHoff` embodies,
277    /// and it is kept because the alternative — integrating heat capacities —
278    /// needs `Cp(T)` polynomials this crate would have to source and
279    /// catalogue. Stated so a reader does not mistake the equilibrium curve
280    /// for an exact one.
281    #[must_use]
282    pub fn delta_g(self, temperature_k: f64) -> f64 {
283        self.delta_h() - temperature_k * self.delta_s()
284    }
285
286    /// Equilibrium constant `Kp(T)` [-] on the **standard-state partial
287    /// pressure** basis: `Kp = exp(−ΔG°(T)/(R T))`, with each partial
288    /// pressure referred to `P° = 1 bar`.
289    ///
290    /// This is the thermodynamic equilibrium constant. It is **not** the one
291    /// a concentration-basis rate law equilibrates to unless `Δn = 0` — see
292    /// [`SmrReaction::kc`].
293    #[must_use]
294    pub fn equilibrium_constant(self, temperature_k: f64) -> f64 {
295        (-self.delta_g(temperature_k) / (R_GAS * temperature_k)).r_exp()
296    }
297
298    /// Change in mole count, `Δn = Σ νᵢ` [-].
299    ///
300    /// `+2` for reforming (1 + 1 → 1 + 3) and `0` for the shift. It is the
301    /// whole reason [`kc`](Self::kc) exists.
302    #[must_use]
303    pub fn delta_n(self) -> f64 {
304        self.stoichiometry().iter().map(|&(_, nu)| nu).sum()
305    }
306
307    /// Equilibrium constant on the **molar concentration** basis,
308    /// `Kc = Π Cᵢ^νᵢ` in `(mol/m³)^Δn`.
309    ///
310    /// # Why this is not `Kp`, and why getting it wrong is expensive
311    ///
312    /// `Reaction::net_rate` evaluates `k_f·ΠC^d − k_r·ΠC^r` in
313    /// **concentrations**, so the composition its rate law settles at
314    /// satisfies `k_f/k_r = Kc`, not `Kp`. For an ideal gas `pᵢ = Cᵢ R T`,
315    /// so
316    ///
317    /// ```text
318    ///   Kp = Kc · (R T / P°)^Δn      =>      Kc = Kp · (P° / (R T))^Δn
319    /// ```
320    ///
321    /// For the shift reaction `Δn = 0` and the two coincide. For reforming
322    /// `Δn = +2`, and at 1123 K the factor `(R T / P°)²` is **about 115** —
323    /// so using `Kp` where `Kc` belongs would put the reforming equilibrium
324    /// out by two orders of magnitude, in the direction of far too little
325    /// conversion. That is not a subtle error, but it is a silent one: the
326    /// reactor still converges, and still returns a plausible-looking number.
327    ///
328    /// `P°` is 1 bar = 100 kPa, the standard state the formation data in
329    /// [`crate::species`] is tabulated at.
330    #[must_use]
331    pub fn kc(self, temperature_k: f64) -> f64 {
332        const P_STANDARD: f64 = 100_000.0;
333        self.equilibrium_constant(temperature_k)
334            * (P_STANDARD / (R_GAS * temperature_k)).r_powf(self.delta_n())
335    }
336}
337
338/// The forward Arrhenius pair a deck supplies for one reaction — the **only**
339/// fitted inputs in the model.
340///
341/// `A` carries whatever units make `k·∏Cᵈ` come out in mol/(m³·s) for that
342/// reaction's orders; with the mass-action orders used here (orders equal to
343/// `|ν|` for reactants) that is `(m³/mol)^(n−1)·s⁻¹` for an `n`-th order
344/// reaction. `E` is in J/mol.
345#[derive(Debug, Clone, Copy, PartialEq)]
346pub struct ForwardRate {
347    /// Pre-exponential factor `A_f`.
348    pub a: f64,
349    /// Activation energy `E_f` [J/mol].
350    pub e: f64,
351}
352
353/// Derive the reverse Arrhenius pair that makes a reversible rate law
354/// **thermodynamically consistent** at the reactor temperature.
355///
356/// # Why this function has to exist
357///
358/// `dwsim-libs`' `Reaction::net_rate` evaluates
359/// `rate = k_f(T)·ΠCᵈ − k_r(T)·ΠCʳ` with `k_f` and `k_r` as two *independent*
360/// Arrhenius pairs. It never consults `k_eq`. So nothing in that API stops a
361/// caller specifying a reaction whose kinetics settle at a composition its own
362/// equilibrium constant says is wrong — the rate law and the thermodynamics
363/// simply disagree, silently, and the reactor converges to the kinetic answer.
364///
365/// Mass-action orders make the equilibrium condition `k_f/k_r = Kc`, so the
366/// reverse constant is pinned once `k_f` and the thermodynamics are known:
367///
368/// ```text
369///   k_r(T) = k_f(T) / Kc(T)
370/// ```
371///
372/// # Why it is anchored at one temperature rather than derived once
373///
374/// The tempting move is to match the Arrhenius forms symbolically —
375/// `A_r = A_f·exp(−ΔS°/R)`, `E_r = E_f − ΔH°` — which makes `k_f/k_r` equal
376/// `Kp` at *every* temperature. That is exactly right, and exactly wrong
377/// here: the rate law needs `Kc`, and `Kc = Kp·(P°/RT)^Δn` carries a factor
378/// of `T^{−Δn}` that **no Arrhenius pair can reproduce**. For reforming
379/// (`Δn = +2`) that factor changes by 2.6x across 800–1300 K, so a single
380/// pair cannot be consistent over a range.
381///
382/// Since this model is isothermal — one temperature per solve — the honest
383/// resolution is to make consistency **exact at that temperature**. The
384/// activation energy still takes its physically meaningful value
385/// `E_r = E_f − ΔH°` (so an endothermic reaction gets the lower reverse
386/// barrier, as it should), and `A_r` is then whatever makes the ratio come
387/// out right:
388///
389/// ```text
390///   A_r = (k_f(T) / Kc(T)) · exp(E_r / (R T))
391/// ```
392///
393/// Verified to 1e-12 relative over 700–1400 K by
394/// `reverse_rate_is_thermodynamically_consistent`, which rebuilds the pair at
395/// each temperature exactly as a solve does.
396///
397/// **A sweep therefore rebuilds its reactions per point**, which
398/// [`smr_reactions`] takes the temperature for. Reusing one reactor across a
399/// temperature sweep would silently break consistency everywhere but the
400/// anchor point.
401#[must_use]
402pub fn consistent_reverse(
403    forward: ForwardRate,
404    delta_h: f64,
405    kc: f64,
406    temperature_k: f64,
407) -> ForwardRate {
408    let e_r = forward.e - delta_h;
409    // R_KINETIC, not R_GAS: this has to reproduce exactly what
410    // `Reaction::forward_rate_constant` will compute, and that uses
411    // dwsim-libs' own truncated constant. See R_KINETIC's docs.
412    let k_f = forward.a * (-forward.e / (R_KINETIC * temperature_k)).r_exp();
413    let k_r = k_f / kc;
414    ForwardRate {
415        a: k_r * (e_r / (R_KINETIC * temperature_k)).r_exp(),
416        e: e_r,
417    }
418}
419
420/// Build the two independent SMR reactions, ready for a
421/// [`Cstr`](outram_park_fork_dwsim_libs::reactors::Cstr).
422///
423/// `forward` supplies the fitted forward Arrhenius pair for each reaction, in
424/// [`SmrReaction::BOTH`] order. Everything else — stoichiometry, orders,
425/// `ΔH°`, `ΔS°`, `K_eq(T)` and the whole reverse pair — is derived.
426///
427/// `temperature_k` is required because the reverse pair is anchored to it;
428/// see [`consistent_reverse`]. Build the reactions at the temperature you
429/// intend to solve at.
430///
431/// Reaction orders are mass-action: each reactant's forward order is `|ν|`,
432/// each product's reverse order is `ν`. That is an assumption, not a
433/// measurement — real reforming is not mass-action, it is
434/// Langmuir–Hinshelwood — and it is the assumption that makes the rate law
435/// self-consistent without catalyst data.
436#[must_use]
437pub fn smr_reactions(forward: [ForwardRate; 2], temperature_k: f64) -> Vec<Reaction> {
438    SmrReaction::BOTH
439        .iter()
440        .zip(forward)
441        .map(|(&rxn, fwd)| {
442            let (dh, ds) = (rxn.delta_h(), rxn.delta_s());
443            let rev = consistent_reverse(fwd, dh, rxn.kc(temperature_k), temperature_k);
444            let base = rxn.base_reactant();
445
446            let components = rxn
447                .stoichiometry()
448                .iter()
449                .map(|&(sp, nu)| {
450                    // Mass-action: reactants carry the forward order, products
451                    // the reverse one.
452                    let (direct, reverse) = if nu < 0.0 { (-nu, 0.0) } else { (0.0, nu) };
453                    ReactionComponent::new(sp.index(), nu, direct, reverse, sp == base)
454                })
455                .collect();
456
457            Reaction::new(
458                ReactionKind::Kinetic,
459                ReactionBasis::MolarConcentration,
460                components,
461            )
462            .with_forward(fwd.a, fwd.e)
463            .with_reverse(rev.a, rev.e)
464            .with_k_eq(EquilibriumConstant::GibbsVantHoff {
465                delta_h: dh,
466                delta_s: ds,
467            })
468            .with_reaction_heat(dh)
469        })
470        .collect()
471}
472
473/// A fully-specified steady-state SMR CSTR case.
474#[derive(Debug, Clone, PartialEq)]
475pub struct SmrCase {
476    /// Inlet methane molar flow [mol/s].
477    pub methane_feed: f64,
478    /// Steam-to-carbon ratio `S/C` [-] — inlet steam per inlet methane.
479    /// Industrial reforming runs 2.5–3.5 to suppress carbon laydown; this
480    /// model has no carbon chemistry, so a low `S/C` here is merely
481    /// optimistic rather than visibly wrong.
482    pub steam_to_carbon: f64,
483    /// Reactor temperature [K], held constant (see the module's scope note).
484    pub temperature: f64,
485    /// Reactor pressure [Pa]. Enters only through the ideal-gas volumetric
486    /// flow.
487    pub pressure: f64,
488    /// Tank volume `V` [m³].
489    pub volume: f64,
490    /// Forward Arrhenius pairs, in [`SmrReaction::BOTH`] order.
491    pub forward: [ForwardRate; 2],
492}
493
494impl SmrCase {
495    /// Inlet molar flows, by [`Species::index`]. Only methane and steam are
496    /// fed; the rest start at zero.
497    #[must_use]
498    pub fn inlet_flows(&self) -> Vec<f64> {
499        let mut f = vec![0.0; Species::INDEX_ORDER.len()];
500        f[Species::Methane.index()] = self.methane_feed;
501        f[Species::Steam.index()] = self.methane_feed * self.steam_to_carbon;
502        f
503    }
504
505    /// Inlet volumetric flow `Q` [m³/s] from the ideal gas law,
506    /// `Q = Σ F · R T / P`.
507    ///
508    /// Held constant through the reactor, which is the inherited assumption
509    /// this model does not fix — see the module scope note.
510    #[must_use]
511    pub fn volumetric_flow(&self) -> f64 {
512        let total: f64 = self.inlet_flows().iter().sum();
513        total * R_GAS * self.temperature / self.pressure
514    }
515
516    /// Assemble the reactor feed.
517    #[must_use]
518    pub fn feed(&self) -> ReactorFeed {
519        ReactorFeed {
520            molar_flows: self.inlet_flows(),
521            temperature: self.temperature,
522            pressure: self.pressure,
523            volumetric_flow: self.volumetric_flow(),
524        }
525    }
526
527    /// Nominal residence time `τ = V/Q` [s].
528    #[must_use]
529    pub fn residence_time(&self) -> f64 {
530        self.volume / self.volumetric_flow()
531    }
532
533    /// Solve the steady-state CSTR.
534    ///
535    /// # Errors
536    ///
537    /// Propagates [`ReactorError`] from the underlying Newton solve — a
538    /// non-positive volumetric flow, or failure to converge.
539    pub fn solve(&self) -> Result<SmrOutcome, ReactorError> {
540        let mut reactor = Cstr::new(smr_reactions(self.forward, self.temperature), self.volume);
541        reactor.max_iter = MAX_NEWTON_ITERATIONS;
542        let feed = self.feed();
543        let out = reactor.solve(&feed)?;
544
545        let inlet = self.inlet_flows();
546        let ch4 = Species::Methane.index();
547        let methane_conversion = if inlet[ch4] > 0.0 {
548            (inlet[ch4] - out.molar_flows[ch4]) / inlet[ch4]
549        } else {
550            0.0
551        };
552
553        Ok(SmrOutcome {
554            outlet_flows: out.molar_flows,
555            extents: out.extents,
556            heat_duty: out.heat_of_reaction,
557            methane_conversion,
558            residence_time: self.residence_time(),
559        })
560    }
561}
562
563/// What a solved case reports.
564#[derive(Debug, Clone, PartialEq)]
565pub struct SmrOutcome {
566    /// Outlet molar flows [mol/s], by [`Species::index`].
567    pub outlet_flows: Vec<f64>,
568    /// Per-reaction extent [mol/s], in [`SmrReaction::BOTH`] order.
569    pub extents: Vec<f64>,
570    /// Net heat of reaction [W]. **Positive means heat must be supplied** to
571    /// hold the feed temperature — reforming is endothermic, so a converged
572    /// case with meaningful conversion should report a positive duty. This is
573    /// reported, not enforced: the model is isothermal and does not solve an
574    /// energy balance.
575    pub heat_duty: f64,
576    /// Fractional methane conversion `X = (F_in − F_out)/F_in` [-].
577    pub methane_conversion: f64,
578    /// Nominal residence time `τ = V/Q` [s].
579    pub residence_time: f64,
580}
581
582impl SmrOutcome {
583    /// Outlet mole fraction of `species` [-].
584    #[must_use]
585    pub fn mole_fraction(&self, species: Species) -> f64 {
586        let total: f64 = self.outlet_flows.iter().sum();
587        if total > 0.0 {
588            self.outlet_flows[species.index()] / total
589        } else {
590            0.0
591        }
592    }
593
594    /// Hydrogen yield, moles of H₂ per mole of methane fed.
595    ///
596    /// The stoichiometric ceiling is **4** (the overall reaction
597    /// `CH4 + 2H2O -> CO2 + 4H2`), reached only at complete conversion with
598    /// complete shift.
599    #[must_use]
600    pub fn hydrogen_yield(&self, methane_feed: f64) -> f64 {
601        if methane_feed > 0.0 {
602            self.outlet_flows[Species::Hydrogen.index()] / methane_feed
603        } else {
604            0.0
605        }
606    }
607}