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}