Skip to main content

hpr_validate/
rocketpy.rs

1//! Reading RocketPy's generator output into the harness's shapes.
2//!
3//! The fixtures under `validation/fixtures/` are written by the scripts under
4//! `validation/oracles/rocketpy/`, in each script's own shape. This module is the only place that
5//! knows those shapes: it turns one of the generator's cases into a [`Reference`] (what the oracle
6//! said, with a source for every value) and the inputs hpr must fly, a [`DescentSetup`] for
7//! `recovery.py`'s descents or a [`WholeFlightSetup`] for `flight.py`'s flights from the pad.
8//!
9//! [Loft lesson L75][l75]: the inputs come from **the reference's own record of what it flew**,
10//! never from an hpr output, so a case cannot quietly compare hpr against itself. That includes
11//! which rocket it flew and what that rocket weighed, which the harness checks rather than trusts.
12//!
13//! [l75]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l75
14
15use serde_json::Value;
16
17use crate::metrics::{Reference, ReferenceValue};
18use crate::run::{
19    DescentDevice, DescentSetup, FlightDevice, FlightMotor, SeriesRow, WholeFlightSetup,
20};
21
22/// The case named `case` of a recovery-descent fixture, as the oracle's answers and the inputs it
23/// flew. `None` if the document has no such case, does not name the run that produced it, or is
24/// not that fixture's shape.
25///
26/// `file` is the reference's path from the repository root and `sha256` its hash; both go into the
27/// report, so a hand-edited reference changes the report rather than only the file.
28#[must_use]
29pub fn descent_case(
30    document: &Value,
31    case: &str,
32    file: &str,
33    sha256: &str,
34) -> Option<(Reference, DescentSetup)> {
35    let (reference, found) = reference_of(document, case, file, sha256)?;
36    let environment = found.get("environment")?;
37    let elevation_m = number(environment.get("elevation_m"))?;
38    let start = found.get("start")?;
39    let position = start.get("position_msl_m")?.as_array()?;
40    let velocity = start.get("velocity_m_s")?.as_array()?;
41    let devices = found
42        .get("devices")?
43        .as_array()?
44        .iter()
45        .map(|device| {
46            Some(DescentDevice {
47                name: device.get("name")?.as_str()?.to_owned(),
48                cd_s_m2: number(device.get("cd_s_m2"))?,
49                lag_s: number(device.get("lag_s"))?,
50                height_above_ground_m: match device.get("trigger")?.get("kind")?.as_str()? {
51                    "apogee" => None,
52                    "descending_below_height_agl" => {
53                        Some(number(device.get("trigger")?.get("height_m"))?)
54                    }
55                    _ => return None,
56                },
57            })
58        })
59        .collect::<Option<Vec<_>>>()?;
60
61    let setup = DescentSetup {
62        design: text(found.get("design"))?,
63        dry_mass_kg: positive(number(found.get("dry_mass_kg"))?)?,
64        start_height_above_ground_m: number(start.get("height_above_ground_m"))?,
65        latitude_deg: number(environment.get("latitude_deg"))?,
66        longitude_deg: number(environment.get("longitude_deg"))?,
67        elevation_m,
68        wind: wind_levels(environment, elevation_m)?,
69        start_time_s: number(start.get("time_s"))?,
70        start_position_m: (
71            number(position.first())?,
72            number(position.get(1))?,
73            number(position.get(2))?,
74        ),
75        start_velocity_m_s: (
76            number(velocity.first())?,
77            number(velocity.get(1))?,
78            number(velocity.get(2))?,
79        ),
80        devices,
81    };
82    Some((reference, setup))
83}
84
85/// The case named `case` of a whole-flight fixture (`flight.py`'s), as the oracle's answers and
86/// the inputs it flew: the site and wind, the rail, the declared drag table (or, in an own-drag
87/// fixture, where the example's own drag came from) with the reference
88/// area it was flown on, the dry mass, and the parachutes in the order they open. `None` if the
89/// document has no such case, does not name the run that produced it, or is not that shape.
90///
91/// Every input comes from the case's own record, which is what the oracle flew
92/// ([Loft lesson L75][l75]); none is filled in from hpr's design, which the harness instead checks
93/// against it. `file` and `sha256` are as for [`descent_case`].
94///
95/// [l75]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l75
96#[must_use]
97pub fn whole_flight_case(
98    document: &Value,
99    case: &str,
100    file: &str,
101    sha256: &str,
102) -> Option<(Reference, WholeFlightSetup)> {
103    let (reference, found) = reference_of(document, case, file, sha256)?;
104    let environment = found.get("environment")?;
105    let elevation_m = number(environment.get("elevation_m"))?;
106    let rail = found.get("rail")?;
107    let drag = found.get("drag")?;
108    let motor = found.get("motor")?;
109    let devices = found
110        .get("devices")?
111        .as_array()?
112        .iter()
113        .map(|device| {
114            let trigger = device.get("trigger")?;
115            Some(FlightDevice {
116                name: text(device.get("name"))?,
117                cd_s_m2: positive(number(device.get("cd_s_m2"))?)?,
118                lag_s: number(device.get("lag_s"))?,
119                height_above_ground_m: match trigger.get("kind")?.as_str()? {
120                    "apogee" => None,
121                    "descending_below_height_agl" => Some(number(trigger.get("height_m"))?),
122                    _ => return None,
123                },
124            })
125        })
126        .collect::<Option<Vec<_>>>()?;
127    let setup = WholeFlightSetup {
128        design: text(found.get("design"))?,
129        dry_mass_kg: positive(number(found.get("dry_mass_kg"))?)?,
130        latitude_deg: number(environment.get("latitude_deg"))?,
131        longitude_deg: number(environment.get("longitude_deg"))?,
132        elevation_m,
133        wind: wind_levels(environment, elevation_m)?,
134        rail_length_m: positive(number(rail.get("rail_length_m"))?)?,
135        inclination_deg: number(rail.get("inclination_deg"))?,
136        heading_deg: number(rail.get("heading_deg"))?,
137        effective_1rl_m: positive(number(rail.get("effective_1rl_m"))?)?,
138        motor: FlightMotor {
139            total_impulse_ns: positive(number(motor.get("total_impulse_ns"))?)?,
140            burn_out_time_s: positive(number(motor.get("burn_out_time_s"))?)?,
141            propellant_initial_mass_kg: positive(number(motor.get("propellant_initial_mass_kg"))?)?,
142            // RocketPy's None is JSON's null: no correction, which is a value, not a gap.
143            reference_pressure_pa: match motor.get("reference_pressure_pa")? {
144                Value::Null => None,
145                value => Some(number(Some(value))?),
146            },
147        },
148        // A same-drag reference records the table its case flew and the one its generator
149        // declares; an own-drag reference records where each example's drag came from instead.
150        cd0_vs_mach: match drag.get("own") {
151            Some(_) => None,
152            None => Some(pairs(drag.get("cd0_vs_mach"))?),
153        },
154        declared_cd0_vs_mach: match drag.get("own") {
155            Some(_) => None,
156            None => Some(pairs(document.get("declared_drag")?.get("cd0_vs_mach"))?),
157        },
158        own_drag_source: match drag.get("own") {
159            Some(_) => Some(text(drag.get("source"))?),
160            None => None,
161        },
162        reference_radius_m: positive(number(drag.get("reference_radius_m"))?)?,
163        reference_area_m2: positive(number(drag.get("reference_area_m2"))?)?,
164        devices,
165        series: series(found.get("series"))?,
166    };
167    let mut reference = reference;
168    for name in ["series_height_rms_m", "series_speed_rms_m_s"] {
169        reference.values.insert(
170            name.to_owned(),
171            ReferenceValue {
172                value: 0.0,
173                source: format!(
174                    "0, exact agreement with {}, case {case}, series: the RMS of hpr's value less \
175                     the reference's at its times",
176                    reference.generator
177                ),
178            },
179        );
180    }
181    Some((reference, setup))
182}
183
184/// The provenance a fixture's header gives and the values its case `case` publishes, with that
185/// case's own record for the caller to read its inputs from.
186fn reference_of<'a>(
187    document: &'a Value,
188    case: &str,
189    file: &str,
190    sha256: &str,
191) -> Option<(Reference, &'a Value)> {
192    let oracle = text(document.get("oracle"))?;
193    let generator = text(document.get("generator"))?;
194    let command = text(document.get("command"))?;
195    let model = text(document.get("model"))?;
196    let overrides = text(document.get("overrides"))?;
197    let found = document
198        .get("cases")?
199        .as_array()?
200        .iter()
201        .find(|entry| entry.get("name").and_then(Value::as_str) == Some(case))?;
202
203    let metrics = found.get("metrics")?.as_object()?;
204    let mut values = std::collections::BTreeMap::new();
205    for (name, value) in metrics {
206        values.insert(
207            name.clone(),
208            ReferenceValue {
209                value: value.as_f64()?,
210                source: format!("{oracle}, {generator}, case {case}, metrics.{name}"),
211            },
212        );
213    }
214    let reference = Reference {
215        oracle,
216        generator,
217        command,
218        model,
219        overrides,
220        case: case.to_owned(),
221        file: file.to_owned(),
222        sha256: sha256.to_owned(),
223        values,
224    };
225    Some((reference, found))
226}
227
228/// A table of `[x, y]` rows.
229fn pairs(value: Option<&Value>) -> Option<Vec<(f64, f64)>> {
230    value?
231        .as_array()?
232        .iter()
233        .map(|row| {
234            let row = row.as_array()?;
235            (row.len() == 2).then_some(())?;
236            Some((number(row.first())?, number(row.get(1))?))
237        })
238        .collect()
239}
240
241/// The `(time, height, speed)` rows of a whole flight's `series`, if its columns are those, in that
242/// order, and its times rise from 0.
243fn series(value: Option<&Value>) -> Option<Vec<SeriesRow>> {
244    let value = value?;
245    let columns: Vec<&str> = value
246        .get("columns")?
247        .as_array()?
248        .iter()
249        .map(Value::as_str)
250        .collect::<Option<_>>()?;
251    (columns == ["time_s", "height_above_ground_m", "speed_m_s"]).then_some(())?;
252    let rows = value
253        .get("rows")?
254        .as_array()?
255        .iter()
256        .map(|row| {
257            let row = row.as_array()?;
258            (row.len() == 3).then_some(())?;
259            Some((
260                number(row.first())?,
261                number(row.get(1))?,
262                number(row.get(2))?,
263            ))
264        })
265        .collect::<Option<Vec<_>>>()?;
266    let starts_at_ignition = rows.first()?.0 == 0.0;
267    let rising = rows.windows(2).all(|pair| pair[1].0 > pair[0].0);
268    (starts_at_ignition && rising).then_some(rows)
269}
270
271/// The wind the oracle flew, as `(height above sea level, east, north)` levels. A scalar is one
272/// level at the site; a profile is the pairs the generator recorded.
273fn wind_levels(environment: &Value, elevation_m: f64) -> Option<Vec<(f64, f64, f64)>> {
274    let east = environment.get("wind_u")?;
275    let north = environment.get("wind_v")?;
276    match (east.as_array(), north.as_array()) {
277        (Some(east), Some(north)) if east.len() == north.len() => east
278            .iter()
279            .zip(north)
280            .map(|(east, north)| {
281                let east = east.as_array()?;
282                let north = north.as_array()?;
283                let height = number(east.first())?;
284                if (height - number(north.first())?).abs() > 0.0 {
285                    return None;
286                }
287                Some((height, number(east.get(1))?, number(north.get(1))?))
288            })
289            .collect::<Option<Vec<_>>>(),
290        (None, None) => Some(vec![(
291            elevation_m,
292            number(Some(east))?,
293            number(Some(north))?,
294        )]),
295        _ => None,
296    }
297}
298
299/// A JSON string that says something.
300fn text(value: Option<&Value>) -> Option<String> {
301    let text = value?.as_str()?.trim();
302    (!text.is_empty()).then(|| text.to_owned())
303}
304
305/// A JSON number, however it is written.
306fn number(value: Option<&Value>) -> Option<f64> {
307    value?.as_f64()
308}
309
310/// A number that can be a mass.
311fn positive(value: f64) -> Option<f64> {
312    (value.is_finite() && value > 0.0).then_some(value)
313}
314
315#[cfg(test)]
316mod tests;