Skip to main content

hpr_motor/
convert.rs

1//! Converting motor files between RASP `.eng` and RockSim `.rse`.
2//!
3//! Both formats give a motor's name, maker, casing, masses, delays and thrust curve; the rules
4//! for each are in `docs/format/eng.md` and `docs/format/rse.md`. What differs is how they say
5//! it, and what `.rse` adds:
6//!
7//! | | `.eng` | `.rse` |
8//! |---|---|---|
9//! | masses | kg | g |
10//! | delays | `6-10-14`, `P` for plugged | `6,10,14`, `1000` for plugged |
11//! | the curve's `(0, 0)` origin | implicit, "should not be specified" | written out, in every observed file |
12//! | comments | `;` lines before the header | one `<comments>` text |
13//! | type, stated figures, `m` and `cg` per point | none | `Type`, `Itot`, `avgThrust`, `peakThrust`, `burn-time`, `massFrac`, `Isp`, … |
14//!
15//! [`eng_to_rse`] fills what `.eng` lacks the way the observed `.rse` files do (the rse page's
16//! writer policy): a `(0, 0)` origin when the curve starts after ignition; `Itot` by the
17//! trapezoid rule; `peakThrust`; `burn-time`, the last time; `avgThrust = Itot / burn-time`;
18//! `m`, the propellant left, `m(t) = m₀ (1 − I(t) / Itot)` with `m₀` the propellant mass and `I`
19//! the trapezoidal impulse; `cg`, half the length; both auto-calc flags `1`; `massFrac =
20//! 100 m₀ / initWt`; `Isp = Itot / (m₀ g₀)`; and `Type="unspecified"`, which RockSim's guide
21//! requires and the files use when they don't say.
22//!
23//! [`rse_to_eng`] drops what `.eng` can't hold, each named in a [`ConvertWarning`]. hpr uses
24//! none of it for a solid motor: it works the mass and center of gravity out from the curve and
25//! the masses ([`SolidMotor::from_envelope`](crate::SolidMotor::from_envelope)). It refuses a
26//! hybrid, whose `Type` a `.eng` file couldn't keep, and an engine without delays it can read,
27//! since a `.eng` header must give them. A `.eng` header is seven fields split on spaces, and
28//! OpenRocket refuses more, so a name or a maker of several words is written with `_` between
29//! them (`Estes_Industries,_Inc.`).
30//!
31//! **Round trips.** Masses move between kg and g by moving the decimal point in their shortest
32//! digits, not by multiplying, so a mass of at most 15 significant digits, in the normal range
33//! of a double, comes back bit for bit. One of 16 or 17 digits may not (2 of the 29 bundled
34//! `.eng` files write one, such as `0.0036000000000000003`), and a warning says so.
35//!
36//! The thrust curve hpr flies comes back bit for bit both ways, and so do the diameter, the
37//! length, and the masses above. The rest may come back written differently:
38//!
39//! - delays spelled with commas, spaces, `p` or `1000` come back in the other spelling, and read
40//!   as the same delays ([`DelayList`]);
41//! - a name or maker of several words comes back with `_` between them, said in a warning;
42//! - a `(0, 0)` origin is written in `.rse` and left out of `.eng`, so a curve that gives it the
43//!   other way comes back the usual way;
44//! - comments lose blank lines and the spaces ending a line, said in a warning; comments after a
45//!   `.eng` file's last motor are dropped, said in a warning;
46//! - the `.rse` figures `.eng` can't hold are dropped, said in a warning, and filled again by the
47//!   rules above.
48//!
49//! After one conversion, a file converts to the other format and back to the same bytes, but for
50//! a `.eng` motor whose delays name none (`-`): its `.rse` file leaves them out, and a `.eng`
51//! header must give them, so converting it back needs them given again.
52
53use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
54use serde::{Deserialize, Serialize};
55
56use crate::delay::DelayList;
57use crate::eng::{EngEntry, EngFile};
58use crate::error::MotorError;
59use crate::rse::{RseEngine, RseFile, RsePoint};
60use crate::text::WarningKind;
61
62/// The `Type` [`eng_to_rse`] writes: a `.eng` file doesn't say.
63pub const UNSPECIFIED_TYPE: &str = "unspecified";
64
65/// The delay a `.rse` file writes for a plugged motor, where `.eng` writes `P`.
66pub const RSE_PLUGGED: &str = "1000";
67
68/// A converted file and what the conversion said.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct Converted<T> {
71    /// The file, in the other format.
72    pub value: T,
73    /// What was dropped or changed on the way, in motor order.
74    pub warnings: Vec<ConvertWarning>,
75}
76
77/// Something the other format couldn't hold as it was.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ConvertWarning {
80    /// The motor it is about: its name or code.
81    pub motor: String,
82    /// [`WarningKind::Dropped`] for a value the other format has no place for;
83    /// [`WarningKind::Unusual`] for one written differently, such as a name with spaces.
84    pub kind: WarningKind,
85    /// What happened, for a person.
86    pub message: String,
87}
88
89impl ConvertWarning {
90    fn new(motor: &str, kind: WarningKind, message: impl Into<String>) -> Self {
91        Self {
92            motor: motor.to_owned(),
93            kind,
94            message: message.into(),
95        }
96    }
97}
98
99/// A `.eng` file as a `.rse` file, filled the way RockSim's files are (see the module's docs).
100///
101/// # Errors
102///
103/// What [`EngEntry::thrust_curve`] refuses: a curve with negative thrust, decreasing time or
104/// no impulse, whose `Itot` and `m` can't be worked out. [`MotorError::Domain`] for a mass that
105/// has no value in grams, or one that makes a filled figure infinite, such as a propellant mass
106/// so small that the specific impulse overflows.
107pub fn eng_to_rse(file: &EngFile) -> Result<Converted<RseFile>, MotorError> {
108    let mut warnings = Vec::new();
109    let engines = file
110        .entries
111        .iter()
112        .map(|entry| rse_engine(entry, &mut warnings))
113        .collect::<Result<Vec<_>, _>>()?;
114    if let Some(last) = file.entries.last()
115        && !file.trailing_comments.is_empty()
116    {
117        warnings.push(ConvertWarning::new(
118            &last.name,
119            WarningKind::Dropped,
120            format!(
121                "{} comment line(s) after the last motor: a .rse file has no place for them",
122                file.trailing_comments.len()
123            ),
124        ));
125    }
126    Ok(Converted {
127        value: RseFile { engines },
128        warnings,
129    })
130}
131
132fn rse_engine(
133    entry: &EngEntry,
134    warnings: &mut Vec<ConvertWarning>,
135) -> Result<RseEngine, MotorError> {
136    let curve = entry.thrust_curve()?;
137    let total_impulse_ns = curve.total_impulse_ns();
138    let burn_time_s = curve.end_time_s();
139    let initial_mass_g = scaled(&entry.name, entry.total_mass_kg, Unit::Kg, warnings)?;
140    let propellant_mass_g = scaled(&entry.name, entry.propellant_mass_kg, Unit::Kg, warnings)?;
141    let cg_mm = entry.length_mm / 2.0;
142    let mut points = entry.points.clone();
143    // The origin `ThrustCurve::new` adds, written out.
144    if points.first().is_some_and(|&(time_s, _)| time_s > 0.0) {
145        points.insert(0, (0.0, 0.0));
146    }
147    // The impulse summed as `ThrustCurve::new` sums it, over the same points, so the last `m`
148    // is exactly zero.
149    let mut impulse_ns = 0.0;
150    let mut previous: Option<(f64, f64)> = None;
151    let points = points
152        .into_iter()
153        .map(|(time_s, thrust_n)| {
154            if let Some((t0, f0)) = previous {
155                impulse_ns += 0.5 * (f0 + thrust_n) * (time_s - t0);
156            }
157            previous = Some((time_s, thrust_n));
158            RsePoint {
159                time_s,
160                thrust_n,
161                mass_g: Some(propellant_mass_g * (1.0 - impulse_ns / total_impulse_ns)),
162                cg_mm: Some(cg_mm),
163            }
164        })
165        .collect();
166    let positive = |value: f64| (value > 0.0).then_some(value);
167    let delays = if DelayList::parse(&entry.delays).delays.is_empty() {
168        warnings.push(ConvertWarning::new(
169            &entry.name,
170            WarningKind::Dropped,
171            format!(
172                "dropped the delays {:?}, which name no delay; a .rse file may leave them out",
173                entry.delays
174            ),
175        ));
176        None
177    } else {
178        Some(delays_to_rse(&entry.delays))
179    };
180    let engine = RseEngine {
181        manufacturer: entry.manufacturer.clone(),
182        code: entry.name.clone(),
183        motor_type: Some(UNSPECIFIED_TYPE.to_owned()),
184        diameter_mm: entry.diameter_mm,
185        length_mm: entry.length_mm,
186        initial_mass_g,
187        propellant_mass_g,
188        delays,
189        auto_calc_mass: Some(true),
190        auto_calc_cg: Some(true),
191        // `ThrustCurve::new` refuses a curve with no impulse or no burn time.
192        average_thrust_n: Some(total_impulse_ns / burn_time_s),
193        peak_thrust_n: Some(curve.peak_thrust_n()),
194        throat_diameter_mm: None,
195        exit_diameter_mm: None,
196        total_impulse_ns: Some(total_impulse_ns),
197        burn_time_s: Some(burn_time_s),
198        // A motor file may give no propellant or no mass; then there is no fraction and no
199        // specific impulse to state.
200        mass_fraction_pct: positive(initial_mass_g)
201            .map(|initial| 100.0 * propellant_mass_g / initial),
202        isp_s: positive(entry.propellant_mass_kg)
203            .map(|propellant| total_impulse_ns / (propellant * STANDARD_GRAVITY_MPS2)),
204        comments: (!entry.comments.is_empty()).then(|| entry.comments.join("\n")),
205        points,
206    };
207    // A mass near a double's limits can make a figure infinite, which no file can hold.
208    for (what, value) in [
209        ("average thrust (N)", engine.average_thrust_n),
210        ("mass fraction (%)", engine.mass_fraction_pct),
211        ("specific impulse (s)", engine.isp_s),
212    ] {
213        if let Some(value) = value
214            && !value.is_finite()
215        {
216            return Err(MotorError::Domain { what, value });
217        }
218    }
219    Ok(engine)
220}
221
222/// A `.rse` file as a `.eng` file, with what `.eng` can't hold named in the warnings.
223///
224/// # Errors
225///
226/// [`MotorError::Inconsistent`] for an engine without delays it can read (none, or none
227/// [`DelayList::parse`](crate::DelayList::parse) finds), which a `.eng` header must give, or a
228/// hybrid, which a `.eng` file couldn't say it is; [`MotorError::Domain`] for a mass that has no
229/// value in kg (see the module's docs).
230pub fn rse_to_eng(file: &RseFile) -> Result<Converted<EngFile>, MotorError> {
231    let mut warnings = Vec::new();
232    let entries = file
233        .engines
234        .iter()
235        .map(|engine| eng_entry(engine, &mut warnings))
236        .collect::<Result<Vec<_>, _>>()?;
237    Ok(Converted {
238        value: EngFile {
239            entries,
240            trailing_comments: Vec::new(),
241        },
242        warnings,
243    })
244}
245
246fn eng_entry(
247    engine: &RseEngine,
248    warnings: &mut Vec<ConvertWarning>,
249) -> Result<EngEntry, MotorError> {
250    let code = &engine.code;
251    if engine
252        .motor_type
253        .as_deref()
254        .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("hybrid"))
255    {
256        return Err(MotorError::Inconsistent(format!(
257            "{code} is a hybrid, which a .eng file can't say; hpr models solid motors only"
258        )));
259    }
260    let delays = engine
261        .delays
262        .as_deref()
263        .filter(|delays| !DelayList::parse(delays).delays.is_empty())
264        .ok_or_else(|| {
265            MotorError::Inconsistent(format!(
266                "the .rse engine {code:?} gives no delays, and a .eng header must"
267            ))
268        })?;
269    // A `.eng` header is seven fields split on spaces. hpr reads the maker as the rest of the
270    // line, but OpenRocket refuses a header of more than seven fields, so each is one word.
271    let name = code.split_whitespace().collect::<Vec<_>>().join("_");
272    if name != *code {
273        warnings.push(ConvertWarning::new(
274            code,
275            WarningKind::Unusual,
276            format!("a .eng name is one word, so {code:?} is written {name:?}"),
277        ));
278    }
279    let manufacturer = engine
280        .manufacturer
281        .split_whitespace()
282        .collect::<Vec<_>>()
283        .join("_");
284    if manufacturer != engine.manufacturer {
285        warnings.push(ConvertWarning::new(
286            code,
287            WarningKind::Unusual,
288            format!(
289                "a .eng maker is one word, so {:?} is written {manufacturer:?}",
290                engine.manufacturer
291            ),
292        ));
293    }
294    let comments = match &engine.comments {
295        Some(text) => {
296            let lines: Vec<String> = text
297                .lines()
298                .map(str::trim_end)
299                .filter(|line| !line.is_empty())
300                .map(str::to_owned)
301                .collect();
302            if lines.join("\n") != *text {
303                warnings.push(ConvertWarning::new(
304                    code,
305                    WarningKind::Dropped,
306                    "dropped the comments' blank lines and the spaces that end their lines: a \
307                     .eng comment is one line of text",
308                ));
309            }
310            lines
311        }
312        None => Vec::new(),
313    };
314    let mut points: Vec<(f64, f64)> = engine
315        .points
316        .iter()
317        .map(|point| (point.time_s, point.thrust_n))
318        .collect();
319    // `.eng` leaves the origin implicit; `ThrustCurve::new` puts it back exactly when the next
320    // point is after ignition. A `-0` stays written, as it wouldn't come back.
321    if let [(t0, f0), (t1, _), ..] = points[..]
322        && t0.to_bits() == 0
323        && f0.to_bits() == 0
324        && t1 > 0.0
325    {
326        points.remove(0);
327    }
328    let dropped = dropped(engine);
329    if !dropped.is_empty() {
330        warnings.push(ConvertWarning::new(
331            code,
332            WarningKind::Dropped,
333            format!(
334                "dropped {}: a .eng file has no place for them. hpr doesn't use them for a solid \
335                 motor: it works the mass and center of gravity out from the curve and the masses",
336                dropped.join(", ")
337            ),
338        ));
339    }
340    Ok(EngEntry {
341        comments,
342        name,
343        diameter_mm: engine.diameter_mm,
344        length_mm: engine.length_mm,
345        delays: delays_to_eng(delays),
346        propellant_mass_kg: scaled(code, engine.propellant_mass_g, Unit::G, warnings)?,
347        total_mass_kg: scaled(code, engine.initial_mass_g, Unit::G, warnings)?,
348        manufacturer,
349        points,
350    })
351}
352
353/// The `.rse` values of `engine` a `.eng` file has no place for, by their attribute names.
354fn dropped(engine: &RseEngine) -> Vec<&'static str> {
355    let flags = [
356        ("Type", engine.motor_type.is_some()),
357        ("auto-calc-mass", engine.auto_calc_mass.is_some()),
358        ("auto-calc-cg", engine.auto_calc_cg.is_some()),
359        ("avgThrust", engine.average_thrust_n.is_some()),
360        ("peakThrust", engine.peak_thrust_n.is_some()),
361        ("throatDia", engine.throat_diameter_mm.is_some()),
362        ("exitDia", engine.exit_diameter_mm.is_some()),
363        ("Itot", engine.total_impulse_ns.is_some()),
364        ("burn-time", engine.burn_time_s.is_some()),
365        ("massFrac", engine.mass_fraction_pct.is_some()),
366        ("Isp", engine.isp_s.is_some()),
367        (
368            "m",
369            engine.points.iter().any(|point| point.mass_g.is_some()),
370        ),
371        (
372            "cg",
373            engine.points.iter().any(|point| point.cg_mm.is_some()),
374        ),
375    ];
376    flags
377        .into_iter()
378        .filter_map(|(name, present)| present.then_some(name))
379        .collect()
380}
381
382/// `.eng` delays as `.rse` writes them: commas, and `1000` for plugged.
383fn delays_to_rse(delays: &str) -> String {
384    delays
385        .split(['-', ','])
386        .filter(|piece| !piece.is_empty())
387        .map(|piece| {
388            if piece.eq_ignore_ascii_case("P") {
389                RSE_PLUGGED
390            } else {
391                piece
392            }
393        })
394        .collect::<Vec<_>>()
395        .join(",")
396}
397
398/// `.rse` delays as `.eng` writes them: dashes, and `P` for plugged.
399fn delays_to_eng(delays: &str) -> String {
400    delays
401        .split([',', '-'])
402        .map(str::trim)
403        .filter(|piece| !piece.is_empty())
404        .map(|piece| if piece == RSE_PLUGGED { "P" } else { piece })
405        .collect::<Vec<_>>()
406        .join("-")
407}
408
409/// The unit a mass is given in.
410#[derive(Clone, Copy)]
411enum Unit {
412    Kg,
413    G,
414}
415
416/// A mass in the other format's unit, with a warning if it wouldn't come back bit for bit: a
417/// value of 16 or 17 digits, such as a file's `7.5120000000000005` kg, can fall between the
418/// doubles the other unit reaches.
419///
420/// # Errors
421///
422/// [`MotorError::Domain`] for a mass whose value in the other unit is infinite, or zero when the
423/// mass isn't: a double's limits, which no motor comes near.
424fn scaled(
425    motor: &str,
426    mass: f64,
427    unit: Unit,
428    warnings: &mut Vec<ConvertWarning>,
429) -> Result<f64, MotorError> {
430    let (there, back, from, to): (f64, fn(f64) -> f64, _, _) = match unit {
431        Unit::Kg => (kg_to_g(mass), g_to_kg, "kg", "g"),
432        Unit::G => (g_to_kg(mass), kg_to_g, "g", "kg"),
433    };
434    if !there.is_finite() || (there == 0.0 && mass != 0.0) {
435        return Err(MotorError::Domain {
436            what: match unit {
437                Unit::Kg => "mass (kg), which has no value in g",
438                Unit::G => "mass (g), which has no value in kg",
439            },
440            value: mass,
441        });
442    }
443    let returned = back(there);
444    if returned.to_bits() != mass.to_bits() {
445        warnings.push(ConvertWarning::new(
446            motor,
447            WarningKind::Unusual,
448            format!(
449                "{mass} {from} has more digits than a value in {to} keeps: it is written \
450                 {there} {to}, which reads back as {returned} {from}"
451            ),
452        ));
453    }
454    Ok(there)
455}
456
457/// Kilograms to grams, by moving the decimal point in the mass's shortest digits rather than
458/// multiplying: `0.0041` kg is `4.1` g, where `0.0041 × 1000` is `4.1000000000000005`.
459pub fn kg_to_g(kg: f64) -> f64 {
460    shift(kg, 3)
461}
462
463/// Grams to kilograms, by moving the decimal point as [`kg_to_g`] does.
464pub fn g_to_kg(g: f64) -> f64 {
465    shift(g, -3)
466}
467
468/// `value × 10^places`, taken by moving the decimal point in `value`'s shortest digits (Rust's
469/// `{:e}`) and reading the result.
470///
471/// Multiplying by 1000 would round in binary: `0.0041 × 1000` is `4.1000000000000005`, as it is
472/// for about a quarter of the masses with four decimals. Moving the point gives the double
473/// nearest `4.1`, which a file writes as `4.1`, and moving it back gives `0.0041` again whenever
474/// the digits number at most 15 and both values are normal doubles, since two decimals of up to
475/// 15 significant digits never share a double.
476fn shift(value: f64, places: i32) -> f64 {
477    let text = format!("{value:e}");
478    text.split_once('e')
479        .and_then(|(digits, exponent)| {
480            let exponent: i32 = exponent.parse().ok()?;
481            format!("{digits}e{}", exponent + places).parse().ok()
482        })
483        // `{:e}` of a finite f64 always reads back; a non-finite one, which no writer accepts,
484        // keeps the product so that the writer refuses it by its value.
485        .unwrap_or(value * 10f64.powi(places))
486}
487
488#[cfg(test)]
489mod tests {
490    use proptest::prelude::*;
491
492    use super::*;
493    use crate::{DelayList, eng, rse};
494
495    #[test]
496    fn a_decimal_shift_reads_as_the_file_writes() {
497        assert_eq!(0.0041_f64 * 1000.0, 4.100_000_000_000_000_5);
498        assert_eq!(kg_to_g(0.0041), 4.1);
499        assert_eq!(g_to_kg(4.1), 0.0041);
500        assert_eq!(kg_to_g(0.0773), 77.3);
501        assert_eq!(g_to_kg(77.3), 0.0773);
502        assert_eq!(kg_to_g(0.0).to_bits(), 0);
503        assert_eq!(kg_to_g(1.5e-5), 0.015);
504        assert_eq!(g_to_kg(182.5), 0.1825);
505        assert!(kg_to_g(f64::NAN).is_nan());
506        assert_eq!(kg_to_g(f64::INFINITY), f64::INFINITY);
507    }
508
509    #[test]
510    fn delays_trade_dashes_and_p_for_commas_and_1000() {
511        assert_eq!(delays_to_rse("4-5-7-8-9"), "4,5,7,8,9");
512        assert_eq!(delays_to_rse("P"), "1000");
513        assert_eq!(delays_to_rse("6-10-P"), "6,10,1000");
514        assert_eq!(delays_to_rse("5,8,11"), "5,8,11");
515        assert_eq!(delays_to_eng("10,14,1000"), "10-14-P");
516        assert_eq!(delays_to_eng("15, 12,10"), "15-12-10");
517        assert_eq!(delays_to_eng("0"), "0");
518        for eng in ["4-5-7-8-9", "P", "6-10-P", "0", "5,8,11", "1000", "100"] {
519            let there_and_back = delays_to_eng(&delays_to_rse(eng));
520            assert_eq!(
521                DelayList::parse(&there_and_back),
522                DelayList::parse(eng),
523                "{eng}"
524            );
525        }
526    }
527
528    const ENG: &str = "; a comment\n; another\nH54 38 152 6-10-P 0.0773 0.172 Test_Maker\n   0.05 80.25\n   0.4 60\n   1.2 0\n;\n";
529
530    #[test]
531    fn eng_to_rse_fills_what_rocksim_files_give() {
532        let eng = eng::parse(ENG).unwrap().value;
533        let rse = eng_to_rse(&eng).unwrap();
534        assert!(rse.warnings.is_empty(), "{:?}", rse.warnings);
535        let [engine] = &rse.value.engines[..] else {
536            panic!("one engine");
537        };
538        assert_eq!(engine.code, "H54");
539        assert_eq!(engine.manufacturer, "Test_Maker");
540        assert_eq!(engine.motor_type.as_deref(), Some("unspecified"));
541        assert_eq!(engine.initial_mass_g, 172.0);
542        assert_eq!(engine.propellant_mass_g, 77.3);
543        assert_eq!(engine.delays.as_deref(), Some("6,10,1000"));
544        assert_eq!(engine.comments.as_deref(), Some(" a comment\n another"));
545        let times: Vec<f64> = engine.points.iter().map(|p| p.time_s).collect();
546        assert_eq!(times, [0.0, 0.05, 0.4, 1.2]);
547        // By hand: 0.5·80.25·0.05 + 0.5·(80.25 + 60)·0.35 + 0.5·60·0.8.
548        let itot = 2.006_25 + 24.543_75 + 24.0;
549        assert!((engine.total_impulse_ns.unwrap() - itot).abs() < 1e-12);
550        assert_eq!(engine.peak_thrust_n, Some(80.25));
551        assert_eq!(engine.burn_time_s, Some(1.2));
552        assert!((engine.average_thrust_n.unwrap() - itot / 1.2).abs() < 1e-12);
553        let masses: Vec<f64> = engine.points.iter().map(|p| p.mass_g.unwrap()).collect();
554        assert_eq!(masses[0], 77.3);
555        assert!((masses[1] - 77.3 * (1.0 - 2.006_25 / itot)).abs() < 1e-12);
556        assert_eq!(masses[3], 0.0);
557        assert!(engine.points.iter().all(|p| p.cg_mm == Some(76.0)));
558        assert!((engine.mass_fraction_pct.unwrap() - 100.0 * 77.3 / 172.0).abs() < 1e-12);
559        assert!((engine.isp_s.unwrap() - itot / (0.0773 * 9.806_65)).abs() < 1e-9);
560        // What it writes reads back as it was written, and without the reader's complaints.
561        let text = rse::write(&rse.value).unwrap();
562        let read = rse::parse(&text).unwrap();
563        assert!(read.warnings.is_empty(), "{:?}", read.warnings);
564        assert_eq!(read.value, rse.value);
565    }
566
567    #[test]
568    fn a_round_trip_through_rse_gives_the_eng_back() {
569        let eng = eng::parse(ENG).unwrap().value;
570        let rse = eng_to_rse(&eng).unwrap().value;
571        let back = rse_to_eng(&rse).unwrap();
572        assert_eq!(back.value, eng);
573        assert_eq!(back.warnings.len(), 1);
574        assert_eq!(back.warnings[0].kind, WarningKind::Dropped);
575        assert!(
576            back.warnings[0].message.starts_with(
577                "dropped Type, auto-calc-mass, auto-calc-cg, avgThrust, peakThrust, Itot, burn-time, \
578                 massFrac, Isp, m, cg:"
579            ),
580            "{}",
581            back.warnings[0].message
582        );
583    }
584
585    const RSE: &str = r#"<engine-database>
586  <engine-list>
587    <engine mfg=" Some  Maker" code="Micro Maxx II" Type="reloadable" dia="38." len="191." initWt="330." propWt="182.5" delays="2,4,6" auto-calc-mass="1" auto-calc-cg="1" avgThrust="148.737" peakThrust="205.821" throatDia="0." exitDia="0." Itot="318." burn-time="2.14" massFrac="55.3" Isp="177.68">
588      <comments>
589      Line one
590      Line two   </comments>
591      <data>
592        <eng-data t="0." f="0." m="182.5" cg="95.5"/>
593        <eng-data t="0.1" f="205.821" m="170." cg="95.5"/>
594        <eng-data t="2.14" f="0." m="0." cg="95.5"/>
595      </data>
596    </engine>
597  </engine-list>
598</engine-database>
599"#;
600
601    #[test]
602    fn rse_to_eng_names_what_it_drops_and_changes() {
603        let rse = rse::parse(RSE).unwrap().value;
604        let eng = rse_to_eng(&rse).unwrap();
605        let [entry] = &eng.value.entries[..] else {
606            panic!("one entry");
607        };
608        assert_eq!(entry.name, "Micro_Maxx_II");
609        assert_eq!(entry.manufacturer, "Some_Maker");
610        assert_eq!(entry.delays, "2-4-6");
611        assert_eq!(entry.total_mass_kg, 0.33);
612        assert_eq!(entry.propellant_mass_kg, 0.1825);
613        assert_eq!(entry.comments, ["      Line one", "      Line two"]);
614        assert_eq!(entry.points, [(0.1, 205.821), (2.14, 0.0)]);
615        let kinds: Vec<(WarningKind, &str)> = eng
616            .warnings
617            .iter()
618            .map(|w| (w.kind, &w.message[..12]))
619            .collect();
620        assert_eq!(
621            kinds,
622            [
623                (WarningKind::Unusual, "a .eng name "),
624                (WarningKind::Unusual, "a .eng maker"),
625                (WarningKind::Dropped, "dropped the "),
626                (WarningKind::Dropped, "dropped Type"),
627            ]
628        );
629        assert!(eng.warnings[3].message.starts_with(
630            "dropped Type, auto-calc-mass, auto-calc-cg, avgThrust, peakThrust, throatDia, exitDia, Itot, \
631             burn-time, massFrac, Isp, m, cg:"
632        ));
633        // The curve hpr flies is the same.
634        assert_eq!(
635            entry.thrust_curve().unwrap(),
636            rse.engines[0].thrust_curve().unwrap()
637        );
638        // And written, it reads back unchanged.
639        let text = eng::write(&eng.value).unwrap();
640        assert_eq!(eng::parse(&text).unwrap().value, eng.value);
641    }
642
643    #[test]
644    fn a_round_trip_through_eng_keeps_what_both_formats_carry() {
645        let rse = rse::parse(RSE).unwrap().value;
646        let there = rse_to_eng(&rse).unwrap().value;
647        let back = eng_to_rse(&there).unwrap().value;
648        let (a, b) = (&rse.engines[0], &back.engines[0]);
649        assert_eq!(
650            (
651                a.diameter_mm,
652                a.length_mm,
653                a.initial_mass_g,
654                a.propellant_mass_g
655            ),
656            (
657                b.diameter_mm,
658                b.length_mm,
659                b.initial_mass_g,
660                b.propellant_mass_g
661            )
662        );
663        assert_eq!(a.delays, b.delays);
664        let curve = |e: &RseEngine| -> Vec<(f64, f64)> {
665            e.points.iter().map(|p| (p.time_s, p.thrust_n)).collect()
666        };
667        assert_eq!(curve(a), curve(b));
668        // Once converted, a file comes back bit for bit.
669        assert_eq!(rse_to_eng(&back).unwrap().value, there);
670    }
671
672    #[test]
673    fn a_hybrid_or_an_engine_without_delays_is_refused() {
674        let mut rse = rse::parse(RSE).unwrap().value;
675        rse.engines[0].motor_type = Some("Hybrid".to_owned());
676        let error = rse_to_eng(&rse).unwrap_err().to_string();
677        assert!(error.contains("is a hybrid"), "{error}");
678        rse.engines[0].motor_type = None;
679        rse.engines[0].delays = None;
680        let error = rse_to_eng(&rse).unwrap_err().to_string();
681        assert!(error.contains("gives no delays"), "{error}");
682    }
683
684    #[test]
685    fn a_written_origin_and_trailing_comments_are_said() {
686        let text = "X1 29 100 P 0.01 0.05 M\n 0 0\n 0.5 10\n 1 0\n; after\n";
687        let eng = eng::parse(text).unwrap().value;
688        assert_eq!(eng.trailing_comments, [" after"]);
689        let rse = eng_to_rse(&eng).unwrap();
690        assert_eq!(rse.value.engines[0].points.len(), 3, "no second origin");
691        assert_eq!(rse.warnings.len(), 1);
692        assert!(
693            rse.warnings[0]
694                .message
695                .starts_with("1 comment line(s) after")
696        );
697        // The origin comes back implicit: the same curve, one point fewer.
698        let back = rse_to_eng(&rse.value).unwrap().value;
699        assert_eq!(back.entries[0].points, [(0.5, 10.0), (1.0, 0.0)]);
700        assert_eq!(
701            back.entries[0].thrust_curve().unwrap(),
702            eng.entries[0].thrust_curve().unwrap()
703        );
704        // A first point at ignition with thrust, or a `-0` origin, is kept.
705        let mut rse = rse.value;
706        rse.engines[0].points[0].thrust_n = -0.0;
707        let back = rse_to_eng(&rse).unwrap().value;
708        assert_eq!(back.entries[0].points.len(), 3);
709    }
710
711    /// Every bundled curve converts, writes, reads back as written, and comes back from the other
712    /// format: a `.eng` file bit for bit, a `.rse` file in what both formats carry.
713    #[test]
714    fn every_bundled_curve_round_trips() {
715        let (mut engs, mut rses) = (0, 0);
716        let mut respelled = Vec::new();
717        let mut said = Vec::new();
718        let mut makers = Vec::new();
719        let mut moved = Vec::new();
720        for (name, text) in crate::bundled::CURVE_FILES {
721            if name.ends_with(".eng") {
722                engs += 1;
723                let eng = eng::parse(text).unwrap().value;
724                let rse = eng_to_rse(&eng).unwrap();
725                said.extend(
726                    rse.warnings
727                        .iter()
728                        .map(|w| format!("{name}: {}", w.message)),
729                );
730                // The figures filled agree with the curve; the reader may still flag the
731                // delays, as it does the source's.
732                let written = rse::parse(&rse::write(&rse.value).unwrap()).unwrap();
733                for warning in &written.warnings {
734                    assert!(warning.message.contains("delay"), "{name}: {warning:?}");
735                }
736                assert_eq!(written.value, rse.value, "{name}");
737                let mut back = rse_to_eng(&written.value).unwrap().value;
738                for (back, eng) in back.entries.iter_mut().zip(&eng.entries) {
739                    // The two files whose masses move, each said in a warning pinned below;
740                    // every other mass must come back bit for bit.
741                    if rse
742                        .warnings
743                        .iter()
744                        .any(|w| w.message.contains("more digits"))
745                    {
746                        moved.push(*name);
747                        back.propellant_mass_kg = eng.propellant_mass_kg;
748                        back.total_mass_kg = eng.total_mass_kg;
749                    }
750                    if back.delays != eng.delays {
751                        respelled.push(format!("{name}: {} as {}", eng.delays, back.delays));
752                        assert_eq!(back.delays(), eng.delays(), "{name}");
753                        back.delays.clone_from(&eng.delays);
754                    }
755                }
756                assert_eq!(back, eng, "{name}");
757            } else {
758                rses += 1;
759                let rse = rse::parse(text).unwrap().value;
760                let eng = rse_to_eng(&rse).unwrap();
761                // Read back, it may be flagged as its source was, such as for a maker of two
762                // words, but it reads as written.
763                let written = eng::parse(&eng::write(&eng.value).unwrap()).unwrap();
764                assert_eq!(written.value, eng.value, "{name}");
765                let back = eng_to_rse(&written.value).unwrap().value;
766                for (a, b) in rse.engines.iter().zip(&back.engines) {
767                    let mut b = b.clone();
768                    if b.manufacturer != a.manufacturer {
769                        makers.push(format!("{name}: {} as {}", a.manufacturer, b.manufacturer));
770                        b.manufacturer.clone_from(&a.manufacturer);
771                    }
772                    let carried = |e: &RseEngine| {
773                        let points: Vec<(u64, u64)> = e
774                            .points
775                            .iter()
776                            .map(|p| (p.time_s.to_bits(), p.thrust_n.to_bits()))
777                            .collect();
778                        (
779                            e.code.clone(),
780                            e.manufacturer.clone(),
781                            [
782                                e.diameter_mm,
783                                e.length_mm,
784                                e.initial_mass_g,
785                                e.propellant_mass_g,
786                            ]
787                            .map(f64::to_bits),
788                            e.delays.clone(),
789                            points,
790                        )
791                    };
792                    assert_eq!(carried(a), carried(&b), "{name}");
793                }
794                assert_eq!(rse_to_eng(&back).unwrap().value, written.value, "{name}");
795            }
796        }
797        assert_eq!((engs, rses), (29, 3));
798        // `.rse` writes a plugged motor `1000`, which comes back `P`, however `.eng` spelled it.
799        assert_eq!(
800            respelled,
801            [
802                "curves/5f4294d20002e90000000875.eng: p as P",
803                "curves/5f4294d20002e90000000876.eng: 1000 as P"
804            ]
805        );
806        // A maker of several words, which a .eng header joins with `_`.
807        assert_eq!(
808            makers,
809            [
810                "curves/5f923edb1bca5800041716ab.rse: Estes Industries, Inc. as Estes_Industries,_Inc."
811            ]
812        );
813        assert_eq!(
814            moved,
815            [
816                "curves/5f4294d20002e90000000884.eng",
817                "curves/5f4294d20002e9000000088e.eng"
818            ]
819        );
820        // The two masses of 17 digits, each said, and the only values that move.
821        assert_eq!(
822            said,
823            [
824                "curves/5f4294d20002e90000000884.eng: 7.5120000000000005 kg has more digits than a \
825                 value in g keeps: it is written 7512.000000000001 g, which reads back as \
826                 7.512000000000001 kg",
827                "curves/5f4294d20002e9000000088e.eng: 0.0036000000000000003 kg has more digits \
828                 than a value in g keeps: it is written 3.6 g, which reads back as 0.0036 kg"
829            ]
830        );
831    }
832
833    /// Delays that name none are as good as none: `.rse` leaves them out, and `.eng` refuses them.
834    #[test]
835    fn delays_that_name_none_are_left_out_or_refused() {
836        let mut eng = eng::parse(ENG).unwrap().value;
837        eng.entries[0].delays = "-".to_owned();
838        let rse = eng_to_rse(&eng).unwrap();
839        assert_eq!(rse.value.engines[0].delays, None);
840        assert!(
841            rse.warnings[0]
842                .message
843                .starts_with("dropped the delays \"-\"")
844        );
845        let mut rse = rse::parse(RSE).unwrap().value;
846        for delays in ["", " ", "abc"] {
847            rse.engines[0].delays = Some(delays.to_owned());
848            let error = rse_to_eng(&rse).unwrap_err().to_string();
849            assert!(error.contains("gives no delays"), "{delays:?}: {error}");
850        }
851    }
852
853    /// A mass at a double's limits has no value in the other unit, and a figure that would be
854    /// infinite is refused rather than written.
855    #[test]
856    fn a_mass_at_a_doubles_limits_is_refused() {
857        let mut rse = rse::parse(RSE).unwrap().value;
858        rse.engines[0].propellant_mass_g = 5e-324;
859        let error = rse_to_eng(&rse).unwrap_err();
860        assert!(
861            matches!(
862                error,
863                MotorError::Domain {
864                    what: "mass (g), which has no value in kg",
865                    ..
866                }
867            ),
868            "{error:?}"
869        );
870        let mut eng = eng::parse(ENG).unwrap().value;
871        eng.entries[0].total_mass_kg = 1e306;
872        let error = eng_to_rse(&eng).unwrap_err();
873        assert!(
874            matches!(
875                error,
876                MotorError::Domain {
877                    what: "mass (kg), which has no value in g",
878                    ..
879                }
880            ),
881            "{error:?}"
882        );
883        let mut eng = eng::parse(ENG).unwrap().value;
884        eng.entries[0].propellant_mass_kg = 1e-310;
885        let error = eng_to_rse(&eng).unwrap_err();
886        assert!(
887            matches!(
888                error,
889                MotorError::Domain {
890                    what: "specific impulse (s)",
891                    ..
892                }
893            ),
894            "{error:?}"
895        );
896    }
897
898    /// A mass with at most 15 significant digits, as a file writes it.
899    fn file_mass() -> impl Strategy<Value = f64> {
900        (1u64..1_000_000_000_000_000, -12i32..4)
901            .prop_map(|(digits, exponent)| format!("{digits}e{exponent}").parse().unwrap())
902    }
903
904    proptest! {
905        #[test]
906        fn a_mass_with_15_digits_survives_kg_to_g_and_back(mass in file_mass()) {
907            prop_assert_eq!(g_to_kg(kg_to_g(mass)).to_bits(), mass.to_bits());
908            prop_assert_eq!(kg_to_g(g_to_kg(mass)).to_bits(), mass.to_bits());
909        }
910    }
911}