Skip to main content

dover/
deck.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//! The TOML input deck — a validated, typed document, not free text.
20//!
21//! # Shape
22//!
23//! ```toml
24//! schema_version = 1
25//!
26//! [case]
27//! name = "smr-base"
28//!
29//! [reactor]
30//! kind = "cstr"
31//! volume_m3 = 2.0
32//! temperature_k = 1100.0
33//! pressure_pa = 2.0e6
34//!
35//! [feed]
36//! methane_mol_s = 1.0
37//! steam_to_carbon = 3.0
38//!
39//! # The only fitted inputs in the whole model (see `crate::smr`).
40//! [kinetics.reforming]
41//! a_forward = 1.0e7
42//! e_forward_j_mol = 2.4e5
43//!
44//! [kinetics.water_gas_shift]
45//! a_forward = 1.0e6
46//! e_forward_j_mol = 6.7e4
47//!
48//! # Optional: sweep one variable instead of running a single point.
49//! [sweep]
50//! variable = "temperature_k"
51//! from = 800.0
52//! to = 1300.0
53//! steps = 11
54//! ```
55//!
56//! # Why validation is separate from deserialisation
57//!
58//! `serde` will happily accept `volume_m3 = -2.0` or `steam_to_carbon = 0`:
59//! both are well-formed floats. A deck that parses but describes an
60//! impossible reactor is worse than one that fails to parse, because the
61//! solver will return *a number* for it. [`Deck::validate`] is therefore run
62//! by [`Deck::from_toml`] and is not optional — there is no path that hands
63//! back an unvalidated deck.
64//!
65//! Unknown fields are **rejected** (`deny_unknown_fields`). A misspelled key
66//! that is silently ignored is how a deck ends up not describing the run
67//! someone thought it did.
68
69use serde::{Deserialize, Serialize};
70
71use crate::smr::{ForwardRate, SmrCase};
72
73/// The deck schema version this crate reads and writes.
74///
75/// Bumped whenever a change would make an older deck mean something
76/// different. A deck carrying any other value is rejected rather than
77/// interpreted, since guessing at an unknown schema is how a deck silently
78/// changes meaning between versions.
79pub const SCHEMA_VERSION: u32 = 1;
80
81/// Why a deck was rejected.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum DeckError {
84    /// The TOML did not parse, or carried an unknown key.
85    Parse(String),
86    /// It parsed, but describes something unphysical or unsupported.
87    Invalid(String),
88}
89
90impl std::fmt::Display for DeckError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            DeckError::Parse(m) => write!(f, "deck does not parse: {m}"),
94            DeckError::Invalid(m) => write!(f, "deck is invalid: {m}"),
95        }
96    }
97}
98
99impl std::error::Error for DeckError {}
100
101/// A complete DOVER input deck.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct Deck {
105    /// Must equal [`SCHEMA_VERSION`].
106    pub schema_version: u32,
107    /// Case identification.
108    pub case: CaseBlock,
109    /// Reactor geometry and operating point.
110    pub reactor: ReactorBlock,
111    /// Inlet specification.
112    pub feed: FeedBlock,
113    /// The fitted forward rates.
114    pub kinetics: KineticsBlock,
115    /// Optional single-variable sweep. Absent means one operating point.
116    #[serde(default)]
117    pub sweep: Option<SweepBlock>,
118}
119
120/// Case identification. Carries no physics.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct CaseBlock {
124    /// A short name, echoed into the output so a trace identifies itself.
125    pub name: String,
126}
127
128/// Reactor geometry and operating point.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct ReactorBlock {
132    /// Reactor model. Only `"cstr"` is implemented; naming anything else is
133    /// an error rather than a silent fallback.
134    pub kind: String,
135    /// Tank volume [m³].
136    pub volume_m3: f64,
137    /// Temperature [K], held constant.
138    pub temperature_k: f64,
139    /// Pressure [Pa].
140    pub pressure_pa: f64,
141}
142
143/// Inlet specification.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct FeedBlock {
147    /// Methane molar flow [mol/s].
148    pub methane_mol_s: f64,
149    /// Steam-to-carbon ratio [-].
150    pub steam_to_carbon: f64,
151}
152
153/// The fitted forward Arrhenius pairs — the only calibrated inputs.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct KineticsBlock {
157    /// Forward rate for `CH4 + H2O <=> CO + 3H2`.
158    pub reforming: RateBlock,
159    /// Forward rate for `CO + H2O <=> CO2 + H2`.
160    pub water_gas_shift: RateBlock,
161}
162
163/// One forward Arrhenius pair.
164#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct RateBlock {
167    /// Pre-exponential factor (units per the reaction's orders).
168    pub a_forward: f64,
169    /// Activation energy [J/mol].
170    pub e_forward_j_mol: f64,
171}
172
173/// Sweep one variable across a range instead of running a single point.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct SweepBlock {
177    /// Which variable: `"temperature_k"`, `"pressure_pa"`,
178    /// `"steam_to_carbon"` or `"volume_m3"`.
179    pub variable: String,
180    /// First value (inclusive).
181    pub from: f64,
182    /// Last value (inclusive).
183    pub to: f64,
184    /// Number of points, at least 2.
185    pub steps: usize,
186}
187
188impl SweepBlock {
189    /// The swept values, `from` to `to` inclusive.
190    #[must_use]
191    pub fn values(&self) -> Vec<f64> {
192        if self.steps < 2 {
193            return vec![self.from];
194        }
195        let n = self.steps - 1;
196        (0..self.steps)
197            .map(|i| self.from + (self.to - self.from) * (i as f64) / (n as f64))
198            .collect()
199    }
200}
201
202impl Deck {
203    /// Parse and validate a deck from TOML text.
204    ///
205    /// # Errors
206    ///
207    /// [`DeckError::Parse`] if the TOML is malformed or names an unknown key;
208    /// [`DeckError::Invalid`] if it parses but fails [`Deck::validate`].
209    pub fn from_toml(text: &str) -> Result<Self, DeckError> {
210        let deck: Deck = toml::from_str(text).map_err(|e| DeckError::Parse(e.to_string()))?;
211        deck.validate()?;
212        Ok(deck)
213    }
214
215    /// Serialise back to TOML.
216    ///
217    /// # Errors
218    ///
219    /// [`DeckError::Parse`] if serialisation fails, which in practice it does
220    /// not for this schema.
221    pub fn to_toml(&self) -> Result<String, DeckError> {
222        toml::to_string_pretty(self).map_err(|e| DeckError::Parse(e.to_string()))
223    }
224
225    /// Check the deck describes a physically meaningful, supported run.
226    ///
227    /// # Errors
228    ///
229    /// [`DeckError::Invalid`] naming the first problem found.
230    pub fn validate(&self) -> Result<(), DeckError> {
231        let bad = |m: String| Err(DeckError::Invalid(m));
232
233        if self.schema_version != SCHEMA_VERSION {
234            return bad(format!(
235                "schema_version {} is not the {SCHEMA_VERSION} this build reads",
236                self.schema_version
237            ));
238        }
239        if self.case.name.trim().is_empty() {
240            return bad("case.name is empty".into());
241        }
242        if self.reactor.kind != "cstr" {
243            return bad(format!(
244                "reactor.kind {:?} is not implemented; only \"cstr\" is",
245                self.reactor.kind
246            ));
247        }
248        for (label, v) in [
249            ("reactor.volume_m3", self.reactor.volume_m3),
250            ("reactor.temperature_k", self.reactor.temperature_k),
251            ("reactor.pressure_pa", self.reactor.pressure_pa),
252            ("feed.methane_mol_s", self.feed.methane_mol_s),
253            ("feed.steam_to_carbon", self.feed.steam_to_carbon),
254        ] {
255            if !(v > 0.0) || !v.is_finite() {
256                return bad(format!("{label} must be finite and positive, got {v}"));
257            }
258        }
259        for (label, r) in [
260            ("kinetics.reforming", self.kinetics.reforming),
261            ("kinetics.water_gas_shift", self.kinetics.water_gas_shift),
262        ] {
263            if !(r.a_forward > 0.0) || !r.a_forward.is_finite() {
264                return bad(format!(
265                    "{label}.a_forward must be finite and positive, got {}",
266                    r.a_forward
267                ));
268            }
269            if r.e_forward_j_mol < 0.0 || !r.e_forward_j_mol.is_finite() {
270                return bad(format!(
271                    "{label}.e_forward_j_mol must be finite and non-negative, got {}",
272                    r.e_forward_j_mol
273                ));
274            }
275        }
276        if let Some(s) = &self.sweep {
277            const KNOWN: [&str; 4] = [
278                "temperature_k",
279                "pressure_pa",
280                "steam_to_carbon",
281                "volume_m3",
282            ];
283            if !KNOWN.contains(&s.variable.as_str()) {
284                return bad(format!(
285                    "sweep.variable {:?} is not one of {KNOWN:?}",
286                    s.variable
287                ));
288            }
289            if s.steps < 2 {
290                return bad(format!("sweep.steps must be at least 2, got {}", s.steps));
291            }
292            if !s.from.is_finite() || !s.to.is_finite() {
293                return bad("sweep bounds must be finite".into());
294            }
295            if !(s.from > 0.0) || !(s.to > 0.0) {
296                return bad("sweep bounds must be positive for every supported variable".into());
297            }
298        }
299        Ok(())
300    }
301
302    /// The single operating point this deck describes, ignoring any sweep.
303    #[must_use]
304    pub fn base_case(&self) -> SmrCase {
305        SmrCase {
306            methane_feed: self.feed.methane_mol_s,
307            steam_to_carbon: self.feed.steam_to_carbon,
308            temperature: self.reactor.temperature_k,
309            pressure: self.reactor.pressure_pa,
310            volume: self.reactor.volume_m3,
311            forward: [
312                ForwardRate {
313                    a: self.kinetics.reforming.a_forward,
314                    e: self.kinetics.reforming.e_forward_j_mol,
315                },
316                ForwardRate {
317                    a: self.kinetics.water_gas_shift.a_forward,
318                    e: self.kinetics.water_gas_shift.e_forward_j_mol,
319                },
320            ],
321        }
322    }
323
324    /// Every case the deck describes: one, or the sweep expanded.
325    #[must_use]
326    pub fn cases(&self) -> Vec<(f64, SmrCase)> {
327        let base = self.base_case();
328        match &self.sweep {
329            None => vec![(f64::NAN, base)],
330            Some(s) => s
331                .values()
332                .into_iter()
333                .map(|v| {
334                    let mut c = base.clone();
335                    match s.variable.as_str() {
336                        "temperature_k" => c.temperature = v,
337                        "pressure_pa" => c.pressure = v,
338                        "steam_to_carbon" => c.steam_to_carbon = v,
339                        "volume_m3" => c.volume = v,
340                        // `validate` has already rejected anything else.
341                        _ => unreachable!("validate rejects unknown sweep variables"),
342                    }
343                    (v, c)
344                })
345                .collect(),
346        }
347    }
348}