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}