hpr_validate/case.rs
1//! Case files: what to fly, and which metrics to compare against which reference.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8/// One validation case, read from a TOML file under `validation/cases/`.
9///
10/// Every metric it reports has to name a tolerance ([Loft lesson L79][l79]) or say in writing why
11/// it is not scored, and the reference it compares against has to carry provenance ([L77][l77]).
12/// The harness checks both before it flies anything.
13///
14/// [l77]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l77
15/// [l79]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l79
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct Case {
19 /// The case's id, which is also its file name without the extension.
20 pub id: String,
21 /// What the case is for, in one line.
22 pub title: String,
23 /// What is flown and how ([`Flight`]).
24 pub flight: Flight,
25 /// The reference to compare against, relative to the repository root.
26 pub reference: PathBuf,
27 /// Which case of that reference file, when it holds several.
28 #[serde(default)]
29 pub reference_case: Option<String>,
30 /// The metrics to compare, each with its tolerance.
31 pub metrics: BTreeMap<String, Metric>,
32 /// A limit of hpr's that this case is known to reach, in writing. The case still runs, and
33 /// its metrics keep the tolerances they will be held to once the limit is lifted, but nothing
34 /// is scored: the report shows the case as a gap, with this reason and hpr's own refusal.
35 ///
36 /// The one gap the harness accepts is hpr's refusal of a Mach number past its models' range,
37 /// which ends at Mach 5 for the normal force and the drag buildup alike (until
38 /// [M1.8b1][m1-8b1] the buildup stopped at Mach 1, and Prometheus on its own drag was such a
39 /// gap). It is checked, not trusted: the reference must itself reach that Mach number, and hpr
40 /// must refuse the flight with exactly that error. A gap that starts flying fails the run, so
41 /// it cannot stay excused after it is fixed ([Loft lesson L85][l85]). No committed case
42 /// declares one.
43 ///
44 /// [m1-8b1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8b1
45 /// [l85]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l85
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub known_gap: Option<String>,
48}
49
50/// What a case flies.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields, rename_all = "snake_case")]
53#[non_exhaustive]
54pub enum Flight {
55 /// A descent from a state the reference declares, under the devices the reference declares:
56 /// the recovery comparison of [M1.7a][m1-7a] (parachutes, and the descent under them), run
57 /// through the harness.
58 ///
59 /// Everything about the flight comes from the reference's own case (the site, the wind, the
60 /// devices, the state at the first deployment), which is the point: the oracle's inputs are
61 /// the case's, not hpr's output ([Loft lesson L75][l75]).
62 ///
63 /// [m1-7a]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-7a
64 /// [l75]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l75
65 RecoveryDescent {
66 /// The design to fly, by file name under `validation/designs/`.
67 design: String,
68 /// Its configuration id.
69 configuration: String,
70 },
71 /// A flight from the pad to the ground, in either mode of [M2.1][m2-1] ([`DragMode`]). In
72 /// same-drag mode hpr flies the `C_D0(M)` table the reference declares, through
73 /// [`hpr_sim::Simulation::with_drag_table`], so a difference is in the equations of motion,
74 /// the environment or the motor, not in the drag. In predicted mode it flies the design's own
75 /// aerodynamics against a reference that flew the example's own drag.
76 ///
77 /// The site, the wind, the rail, the drag table and its reference area, and the recovery
78 /// devices all come from the reference's own record of what the oracle flew
79 /// ([Loft lesson L75][l75]). The harness checks the rest rather than trusting it: the design
80 /// must be the one the reference names, with the dry mass and reference area it recorded.
81 ///
82 /// [m2-1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-1
83 /// [l75]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l75
84 WholeFlight {
85 /// The design to fly, by file name under `validation/designs/`.
86 design: String,
87 /// Its configuration id.
88 configuration: String,
89 /// Whose drag hpr flies: the reference's declared table (the default), or its own.
90 #[serde(default, skip_serializing_if = "DragMode::is_same_drag")]
91 mode: DragMode,
92 },
93}
94
95/// Whose drag a whole flight flies, the two modes of [M2.1][m2-1].
96///
97/// [m2-1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-1
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100#[non_exhaustive]
101pub enum DragMode {
102 /// hpr flies the `C_D0(M)` table the reference declares, so a difference is in the equations
103 /// of motion, the environment or the motor. Its metrics are gated.
104 #[default]
105 SameDrag,
106 /// hpr flies its own drag against a reference in which RocketPy flies the example's own drag,
107 /// so a difference is mostly the two drags ([M2.1c2][m2-1c2]). hpr's normal force is its own
108 /// in both modes; only the zero-lift drag differs. Its metrics are
109 /// held to a target and reported, never gated ([`crate::Verdict::WithinTarget`]).
110 ///
111 /// [m2-1c2]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-1c2
112 Predicted,
113}
114
115impl DragMode {
116 /// Whether this is the default, same-drag mode.
117 #[must_use]
118 pub fn is_same_drag(&self) -> bool {
119 *self == Self::SameDrag
120 }
121}
122
123impl Flight {
124 /// The design it flies, by file name under `validation/designs/`.
125 #[must_use]
126 pub fn design(&self) -> &str {
127 match self {
128 Self::RecoveryDescent { design, .. } | Self::WholeFlight { design, .. } => design,
129 }
130 }
131}
132
133/// One metric of a case: how far hpr may be from the reference, or why it is not scored.
134///
135/// In a case file that is a table of one or both bounds:
136///
137/// ```toml
138/// [metrics.descent_time_s]
139/// relative = 0.03
140///
141/// [metrics.drift_north_m]
142/// relative = 0.03
143/// absolute = 0.002 # this component passes through zero, so a fraction alone means nothing
144/// ```
145///
146/// A metric whose table sets neither is refused, because that is the "ungated metric" of
147/// [Loft lesson L79][l79] by another name.
148///
149/// The one alternative to a gate is to say, in the case file, that the metric is not scored and
150/// why:
151///
152/// ```toml
153/// [metrics.drift_north_m]
154/// not_scored = "hpr and RocketPy differ by 28x on 0.5 mm and the cause is not established (#27)"
155/// ```
156///
157/// Such a metric is still measured and still printed, with both numbers, the difference and the
158/// reason: it is a gap on the face of the report, not a quiet omission and not a pass. Loft
159/// excused its two largest misses as "no single target" ([Loft lesson L82][l82]), so the reason
160/// has to be written down, and
161/// `hpr_validate::tests::the_metrics_that_are_not_scored_are_these_and_no_others` pins the whole
162/// set: a new excuse has to be argued in a test whose name says what it is.
163///
164/// [l79]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l79
165/// [l82]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l82
166#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct Metric {
169 /// How far hpr may be from the reference, as a fraction of it (`0.03` for 3%).
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub relative: Option<f64>,
172 /// How far hpr may be from the reference, in the metric's own unit.
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub absolute: Option<f64>,
175 /// Why the metric is measured and reported but not scored. Mutually exclusive with a bound.
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub not_scored: Option<String>,
178}
179
180impl Metric {
181 /// A relative bound alone.
182 #[must_use]
183 pub fn relative(relative: f64) -> Self {
184 Self {
185 relative: Some(relative),
186 ..Self::default()
187 }
188 }
189
190 /// The bounds it holds hpr to.
191 #[must_use]
192 pub fn tolerance(&self) -> Tolerance {
193 Tolerance {
194 relative: self.relative,
195 absolute: self.absolute,
196 }
197 }
198
199 /// The reason it is not scored, if it says one.
200 #[must_use]
201 pub fn reason(&self) -> Option<&str> {
202 self.not_scored
203 .as_deref()
204 .map(str::trim)
205 .filter(|reason| !reason.is_empty())
206 }
207
208 /// Checks that the metric is either gated or declared, and that its bounds are real numbers
209 /// that bound something.
210 ///
211 /// # Errors
212 ///
213 /// A sentence naming what is wrong, for [`crate::run::ValidateError::Case`].
214 pub fn check(&self, case: &str, name: &str) -> Result<(), String> {
215 let gated = self.tolerance().is_set();
216 match (gated, self.reason()) {
217 (false, None) => {
218 // Loft lesson L79: a tolerance that bounds nothing gates nothing. A metric is
219 // either held to a number or declared, in writing, not to be.
220 Err(format!(
221 "case {case}: {name} has a tolerance that bounds nothing, and no written \
222 reason for not scoring it"
223 ))
224 }
225 (true, Some(_)) => Err(format!(
226 "case {case}: {name} is both gated and declared not scored; it is one or the other"
227 )),
228 (false, Some(_)) => Ok(()),
229 (true, None) => {
230 for (label, bound) in [("relative", self.relative), ("absolute", self.absolute)] {
231 if let Some(bound) = bound
232 && !(bound.is_finite() && bound > 0.0)
233 {
234 // An infinite or negative bound is a gate that cannot fail.
235 return Err(format!(
236 "case {case}: {name}'s {label} bound is {bound}, which bounds nothing"
237 ));
238 }
239 }
240 Ok(())
241 }
242 }
243 }
244}
245
246/// How far a measured value may be from its reference: a fraction of it, an absolute difference,
247/// or whichever of the two is larger.
248#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct Tolerance {
251 /// A fraction of the reference value, as `0.03` for 3%.
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub relative: Option<f64>,
254 /// An absolute difference, in the metric's own unit.
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub absolute: Option<f64>,
257}
258
259impl Tolerance {
260 /// A relative bound alone.
261 #[must_use]
262 pub const fn relative(relative: f64) -> Self {
263 Self {
264 relative: Some(relative),
265 absolute: None,
266 }
267 }
268
269 /// Whether this bounds anything at all. A bound that is not a positive, finite number gates
270 /// nothing, the "ungated metric" of [Loft lesson L79][l79], so it does not count as set.
271 ///
272 /// [l79]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l79
273 #[must_use]
274 pub fn is_set(self) -> bool {
275 [self.relative, self.absolute]
276 .into_iter()
277 .flatten()
278 .any(|bound| bound.is_finite() && bound > 0.0)
279 }
280
281 /// How much `reference` may move under this tolerance, in the metric's own unit.
282 #[must_use]
283 pub fn allowed(self, reference: f64) -> f64 {
284 let relative = self
285 .relative
286 .filter(|bound| bound.is_finite() && *bound > 0.0)
287 .map_or(0.0, |relative| relative * reference.abs());
288 let absolute = self
289 .absolute
290 .filter(|bound| bound.is_finite() && *bound > 0.0)
291 .unwrap_or(0.0);
292 relative.max(absolute)
293 }
294
295 /// Whether `measured` is within this tolerance of `reference`: inside either bound that is
296 /// given. A tolerance with neither accepts nothing, so an unset one fails rather than passing
297 /// silently, and a value that is not a real number never passes.
298 #[must_use]
299 pub fn accepts(self, measured: f64, reference: f64) -> bool {
300 self.is_set()
301 && measured.is_finite()
302 && reference.is_finite()
303 && (measured - reference).abs() <= self.allowed(reference)
304 }
305
306 /// How the tolerance reads in a report.
307 #[must_use]
308 pub fn describe(self) -> String {
309 match (self.relative, self.absolute) {
310 (Some(relative), Some(absolute)) => format!("{:.3}% or {absolute}", 100.0 * relative),
311 (Some(relative), None) => format!("{:.3}%", 100.0 * relative),
312 (None, Some(absolute)) => format!("{absolute}"),
313 (None, None) => "none".to_owned(),
314 }
315 }
316}
317
318/// The cases a run must cover, read from `validation/cases/lock.toml`.
319///
320/// [Loft lesson L78][l78]: a suite that quietly skips a case and reports green is worse than a
321/// red one, so the lock names every case that has to run and the harness fails if one is missing.
322///
323/// [l78]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l78
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325#[serde(deny_unknown_fields)]
326pub struct CaseLock {
327 /// The case ids that must run, in report order.
328 pub cases: Vec<String>,
329 /// The ids `--fast` may leave out, which must all be in `cases`.
330 #[serde(default)]
331 pub slow: Vec<String>,
332}
333
334impl CaseLock {
335 /// The ids a run covers: every locked case, or the ones that are not slow.
336 #[must_use]
337 pub fn wanted(&self, fast: bool) -> Vec<String> {
338 self.cases
339 .iter()
340 .filter(|id| !(fast && self.slow.contains(id)))
341 .cloned()
342 .collect()
343 }
344
345 /// The ids a run leaves out, which is empty unless it is a fast one.
346 #[must_use]
347 pub fn skipped(&self, fast: bool) -> Vec<String> {
348 self.cases
349 .iter()
350 .filter(|id| fast && self.slow.contains(id))
351 .cloned()
352 .collect()
353 }
354
355 /// The ids named as slow that are not cases at all.
356 #[must_use]
357 pub fn unknown_slow(&self) -> Vec<String> {
358 self.slow
359 .iter()
360 .filter(|id| !self.cases.contains(id))
361 .cloned()
362 .collect()
363 }
364}
365
366/// Where the cases and their lock live, relative to the repository root.
367#[must_use]
368pub fn cases_dir(root: &Path) -> PathBuf {
369 root.join("validation/cases")
370}
371
372/// Every case id committed under `validation/cases/`, sorted.
373///
374/// A case that is committed but not locked would never run, which is the other half of
375/// [Loft lesson L78][l78]: a suite must not quietly skip a case.
376///
377/// [l78]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l78
378///
379/// # Errors
380///
381/// The directory's own error, as a sentence.
382pub fn committed_cases(root: &Path) -> Result<Vec<String>, String> {
383 let directory = cases_dir(root);
384 let mut ids = Vec::new();
385 for entry in std::fs::read_dir(&directory)
386 .map_err(|error| format!("reading {}: {error}", directory.display()))?
387 {
388 let path = entry
389 .map_err(|error| format!("reading {}: {error}", directory.display()))?
390 .path();
391 if path
392 .extension()
393 .is_some_and(|extension| extension == "toml")
394 && let Some(stem) = path
395 .file_stem()
396 .map(|stem| stem.to_string_lossy().into_owned())
397 && stem != "lock"
398 {
399 ids.push(stem);
400 }
401 }
402 ids.sort();
403 Ok(ids)
404}