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