dover/headless.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//! Headless runner — deck in, CSV out, no window and no event loop.
20//!
21//! DOVER has no GUI, so unlike the `dhoby-ghaut` studios there is no headless
22//! *mode* to switch into: this is the only way it runs. The workspace's
23//! requirements for a headless path are met all the same, because they are
24//! what make a result checkable rather than merely producible —
25//! **deterministic** (no clock, no RNG, no I/O in the loop), **machine
26//! readable** with a stable header, and **fixed precision** so a committed
27//! fixture diffs cleanly.
28//!
29//! An unconverged case does not abort the run: its row carries the error in
30//! the `status` column and the sweep continues. A sweep that stops at its
31//! first failure tells you less than one that shows you where the failures
32//! are.
33
34use crate::deck::Deck;
35use crate::smr::{SmrCase, SmrReaction};
36use crate::species::Species;
37
38/// The CSV header. Keep this and [`run_case`]'s row in lockstep — a fixture
39/// diffs against both.
40pub const CSV_HEADER: &str = "case,sweep_value,T_K,P_Pa,S_C,V_m3,tau_s,\
41X_CH4,y_CH4,y_H2O,y_CO,y_CO2,y_H2,H2_per_CH4,duty_W,K_reform,K_wgs,status\n";
42
43/// Run one operating point and format its CSV row.
44///
45/// `sweep_value` is the swept variable's value, or `f64::NAN` for a single
46/// point (written as an empty field).
47#[must_use]
48pub fn run_case(name: &str, sweep_value: f64, case: &SmrCase) -> String {
49 let sweep = if sweep_value.is_nan() {
50 String::new()
51 } else {
52 format!("{sweep_value:.6}")
53 };
54 let k_ref = SmrReaction::Reforming.equilibrium_constant(case.temperature);
55 let k_wgs = SmrReaction::WaterGasShift.equilibrium_constant(case.temperature);
56
57 match case.solve() {
58 Ok(o) => format!(
59 "{name},{sweep},{:.4},{:.1},{:.4},{:.6},{:.6},{:.6},\
60{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.4},{:.6e},{:.6e},ok\n",
61 case.temperature,
62 case.pressure,
63 case.steam_to_carbon,
64 case.volume,
65 o.residence_time,
66 o.methane_conversion,
67 o.mole_fraction(Species::Methane),
68 o.mole_fraction(Species::Steam),
69 o.mole_fraction(Species::CarbonMonoxide),
70 o.mole_fraction(Species::CarbonDioxide),
71 o.mole_fraction(Species::Hydrogen),
72 o.hydrogen_yield(case.methane_feed),
73 o.heat_duty,
74 k_ref,
75 k_wgs,
76 ),
77 Err(e) => format!(
78 "{name},{sweep},{:.4},{:.1},{:.4},{:.6},{:.6},,,,,,,,,{:.6e},{:.6e},ERROR:{}\n",
79 case.temperature,
80 case.pressure,
81 case.steam_to_carbon,
82 case.volume,
83 case.residence_time(),
84 k_ref,
85 k_wgs,
86 e.to_string().replace(',', ";"),
87 ),
88 }
89}
90
91/// Run every case a deck describes and return the whole CSV.
92#[must_use]
93pub fn run_deck(deck: &Deck) -> String {
94 let mut out = String::from(CSV_HEADER);
95 for (v, case) in deck.cases() {
96 out.push_str(&run_case(&deck.case.name, v, &case));
97 }
98 out
99}
100
101/// Parse a deck from TOML text and run it.
102///
103/// # Errors
104///
105/// Propagates [`crate::deck::DeckError`] from parsing or validation — a bad
106/// deck fails before any solving happens, rather than producing rows nobody
107/// should trust.
108pub fn run_toml(text: &str) -> Result<String, crate::deck::DeckError> {
109 Ok(run_deck(&Deck::from_toml(text)?))
110}