Skip to main content

hpr_aero/
table.rs

1//! Override tables: coefficients from another tool or a measurement in place of hpr's own.
2//!
3//! An override lets the flight engine fly with an oracle's aerodynamics, so that a comparison
4//! isolates the dynamics, the environment and the motor from the aerodynamic prediction
5//! (`docs/VALIDATION.md`, the same-drag mode of [M2.1][m2-1], the comparisons with RocketPy).
6//!
7//! - A [`DragTable`] gives the zero-lift drag coefficient `C_D0(M)` on the rocket's reference area;
8//!   [`crate::AeroModel::drag`] applies the same angle-of-attack scaling to it as to the buildup.
9//! - A [`NormalForceTable`] gives the normal force and its center of pressure against Mach number
10//!   and angle of attack, read from RASAero II's aerodynamic export
11//!   ([`NormalForceTable::from_rasaero_csv`]); [`crate::AeroModel::normal_force`] returns it in
12//!   place of the Barrowman sum. The export carries no damping, so a flight keeps hpr's own
13//!   (`docs/physics/flight.md`).
14//!
15//! [m2-1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-1
16//!
17//! [`parse_mach_csv`] reads CSV text for a drag table (no I/O: the caller supplies the text):
18//!
19//! - **Two columns**: Mach number and `C_D`, optionally under one header row, as in the drag
20//!   curves of RocketPy's Calisto, Juno III and Valetudo examples.
21//! - **A header row**: the Mach column is the first whose name contains `mach`, and the value
22//!   column is the one named by the caller (compared without case or surrounding space). Rows
23//!   with an angle-of-attack column (`alpha`) other than zero are skipped, which reads RASAero II's
24//!   aerodynamic exports.
25//!
26//! Tables interpolate linearly and hold their end values outside their range; every lookup says
27//! whether it extrapolated. See `docs/physics/aero.md`.
28
29use std::f64::consts::{FRAC_PI_2, PI};
30
31use hpr_core::interp::{Extrapolation, Interpolation, Lookup, Side, Table1D};
32use serde::{Deserialize, Serialize};
33
34use crate::error::AeroError;
35
36/// Drag coefficient against Mach number, power-off and optionally power-on.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39#[non_exhaustive]
40pub struct DragTable {
41    /// `C_D0(M)` with no motor thrusting.
42    pub power_off: Table1D,
43    /// `C_D0(M)` while a motor thrusts; the power-off table applies when this is `None`.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub power_on: Option<Table1D>,
46    /// The reference diameter the coefficients are on, m. When it differs from the rocket's,
47    /// [`crate::AeroModel::drag`] rescales by the ratio of the reference areas; `None` takes the
48    /// coefficients as on the rocket's reference area.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub reference_diameter_m: Option<f64>,
51}
52
53impl DragTable {
54    /// A table with a power-off curve and an optional power-on curve, on the rocket's reference
55    /// area.
56    pub fn new(power_off: Table1D, power_on: Option<Table1D>) -> Self {
57        Self {
58            power_off,
59            power_on,
60            reference_diameter_m: None,
61        }
62    }
63
64    /// This table with its coefficients on a reference diameter of `diameter_m`, checked here
65    /// as [`NormalForceTable::with_reference_diameter_m`] checks its own, so that a bad diameter
66    /// is named when the table is made rather than at a flight's first lookup (#79). A table
67    /// built or read with the field set directly is still checked at its first lookup.
68    ///
69    /// # Errors
70    ///
71    /// [`AeroError::Domain`] unless `diameter_m` is finite and positive.
72    pub fn with_reference_diameter_m(mut self, diameter_m: f64) -> Result<Self, AeroError> {
73        crate::error::check_dimension("drag table reference diameter", diameter_m, false)?;
74        self.reference_diameter_m = Some(diameter_m);
75        Ok(self)
76    }
77
78    /// Reads a power-off curve and an optional power-on curve from two-column CSV text
79    /// ([`parse_mach_csv`] with no column name).
80    ///
81    /// # Errors
82    ///
83    /// As [`parse_mach_csv`].
84    pub fn from_csv(power_off: &str, power_on: Option<&str>) -> Result<Self, AeroError> {
85        Ok(Self::new(
86            parse_mach_csv(power_off, None)?,
87            power_on
88                .map(|text| parse_mach_csv(text, None))
89                .transpose()?,
90        ))
91    }
92
93    /// `C_D0` at `mach` on the table's own reference area, from the power-on curve when
94    /// `thrusting` and it exists.
95    ///
96    /// # Errors
97    ///
98    /// [`AeroError::Domain`] for a negative or non-finite Mach number, and table errors.
99    pub fn lookup(&self, mach: f64, thrusting: bool) -> Result<Lookup, AeroError> {
100        crate::drag::check_mach_any(mach)?;
101        let table = match (&self.power_on, thrusting) {
102            (Some(on), true) => on,
103            _ => &self.power_off,
104        };
105        Ok(table.lookup(mach)?)
106    }
107}
108
109/// Splits a CSV line into trimmed fields. A field in double quotes may contain commas; `""`
110/// inside quotes is a quote. Empty fields at the end of the line (a trailing comma) are dropped.
111fn split_fields(line: &str) -> Vec<String> {
112    let mut fields = Vec::new();
113    let mut field = String::new();
114    let mut quoted = false;
115    let mut chars = line.chars().peekable();
116    while let Some(c) = chars.next() {
117        match c {
118            '"' if quoted && chars.peek() == Some(&'"') => {
119                field.push('"');
120                chars.next();
121            }
122            '"' => quoted = !quoted,
123            ',' if !quoted => fields.push(std::mem::take(&mut field).trim().to_owned()),
124            _ => field.push(c),
125        }
126    }
127    fields.push(field.trim().to_owned());
128    while fields.len() > 1 && fields.last().is_some_and(String::is_empty) {
129        fields.pop();
130    }
131    fields
132}
133
134/// Reads a table of a value against Mach number from CSV text.
135///
136/// With `column` `None`, the text has two numeric columns (Mach, value), optionally under one
137/// header row. With `column` `Some(name)`, the first non-blank row must be a header; the Mach
138/// column is the first whose name contains `mach` and the value column the one named `name` (both
139/// without regard to case or surrounding space). A column whose name starts with `alpha` (the
140/// angle of attack) selects the rows where it is zero.
141///
142/// A row is a header only if none of its fields is a number. A leading byte-order mark, blank
143/// lines, `\r\n` line ends, quoted fields, trailing commas and leading zeros (`01.05`) are
144/// accepted. A row identical to the one before it is skipped. Otherwise the Mach numbers must be
145/// finite and strictly increase: a Mach number repeated with another value, or out of order, is
146/// refused, not sorted. The table interpolates linearly and holds its end values outside its range.
147///
148/// # Errors
149///
150/// - [`AeroError::Csv`] naming the 1-based line for a row that doesn't parse, a missing column or
151///   header, a non-finite value, a Mach number that doesn't increase, or no rows (line 0).
152/// - [`AeroError::Table`] for fewer than two rows.
153pub fn parse_mach_csv(text: &str, column: Option<&str>) -> Result<Table1D, AeroError> {
154    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
155    let mut lines = text
156        .lines()
157        .enumerate()
158        .map(|(i, line)| (i + 1, line.trim()))
159        .filter(|(_, line)| !line.is_empty())
160        .peekable();
161    let csv = |line: usize, message: String| AeroError::Csv { line, message };
162    let number = |f: &String| f.parse::<f64>().ok();
163
164    let (first_line, first) = lines
165        .peek()
166        .map(|&(n, line)| (n, split_fields(line)))
167        .ok_or_else(|| csv(0, "no rows".to_owned()))?;
168    let header = first.iter().all(|f| number(f).is_none());
169    let (mach_col, value_col, alpha_col, expected) = match column {
170        None => {
171            if header {
172                lines.next();
173            }
174            (0, 1, None, Some(2))
175        }
176        Some(name) => {
177            if !header {
178                return Err(csv(
179                    first_line,
180                    format!("expected a header naming `{name}`"),
181                ));
182            }
183            lines.next();
184            let names: Vec<String> = first.iter().map(|f| f.to_lowercase()).collect();
185            let mach = names
186                .iter()
187                .position(|f| f.contains("mach"))
188                .ok_or_else(|| csv(first_line, "no Mach column".to_owned()))?;
189            let wanted = name.trim().to_lowercase();
190            let value = names
191                .iter()
192                .position(|f| *f == wanted)
193                .ok_or_else(|| csv(first_line, format!("no column named `{name}`")))?;
194            let alpha = names.iter().position(|f| f.starts_with("alpha"));
195            (mach, value, alpha, None)
196        }
197    };
198
199    let (mut xs, mut ys): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
200    for (n, line) in lines {
201        let values: Vec<f64> = split_fields(line)
202            .iter()
203            .map(number)
204            .collect::<Option<_>>()
205            .ok_or_else(|| csv(n, format!("not a row of numbers: `{line}`")))?;
206        if let Some(expected) = expected
207            && values.len() != expected
208        {
209            return Err(csv(
210                n,
211                format!("expected {expected} columns, found {}", values.len()),
212            ));
213        }
214        let get = |index: usize| {
215            values
216                .get(index)
217                .copied()
218                .ok_or_else(|| csv(n, format!("missing column {}", index + 1)))
219        };
220        if let Some(alpha) = alpha_col
221            && get(alpha)? != 0.0
222        {
223            continue;
224        }
225        let (mach, value) = (get(mach_col)?, get(value_col)?);
226        if !(mach.is_finite() && value.is_finite()) {
227            return Err(csv(n, format!("not finite: Mach {mach}, value {value}")));
228        }
229        if let (Some(&last), Some(&last_value)) = (xs.last(), ys.last()) {
230            // An identical repeated row is harmless (RocketPy's Cavour curve has several).
231            if mach == last && value == last_value {
232                continue;
233            }
234            if mach <= last {
235                return Err(csv(
236                    n,
237                    format!("Mach {mach} doesn't increase from the previous row's {last}"),
238                ));
239            }
240        }
241        xs.push(mach);
242        ys.push(value);
243    }
244    if xs.is_empty() {
245        return Err(csv(0, "no rows".to_owned()));
246    }
247    Ok(Table1D::new(
248        xs,
249        ys,
250        Interpolation::Linear,
251        Extrapolation::Clamp,
252    )?)
253}
254
255/// The inch in meters, exactly. RASAero II takes its dimensions in inches (RASAero II Users
256/// Manual, 2019, p. 13), and its export gives the center of pressure in them.
257const INCH_M: f64 = 0.0254;
258
259/// One angle of attack's column of a [`NormalForceTable`]: the normal force's slope and center of
260/// pressure against Mach number.
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262#[serde(deny_unknown_fields)]
263#[non_exhaustive]
264pub struct NormalForceColumn {
265    /// The angle of attack, rad, in `[0, π/2)`.
266    pub alpha_rad: f64,
267    /// `C_N/α` against Mach number, per radian, on the table's reference area. In a column at
268    /// `α = 0` it is the slope `∂C_N/∂α` there.
269    pub slope_per_rad: Table1D,
270    /// The center of pressure against Mach number, m aft of the nose tip.
271    pub cp_station_m: Table1D,
272}
273
274impl NormalForceColumn {
275    /// The column at `alpha_rad`: `slope_per_rad` (`C_N/α`, per radian) and `cp_station_m` (m aft
276    /// of the nose tip), each against Mach number. [`NormalForceTable::new`] checks the angle.
277    pub fn new(alpha_rad: f64, slope_per_rad: Table1D, cp_station_m: Table1D) -> Self {
278        Self {
279            alpha_rad,
280            slope_per_rad,
281            cp_station_m,
282        }
283    }
284}
285
286/// The normal force and its center of pressure against Mach number and angle of attack, from
287/// another tool, in place of hpr's own ([`crate::AeroModel::with_normal_force_table`]).
288///
289/// A table is a set of columns, one per angle of attack, each holding `C_N/α` and the center of
290/// pressure against Mach number. A lookup at Mach `M` and angle `α`:
291///
292/// - reads each column at `M` (linear, holding the end values outside its Mach range, as the
293///   column's own tables say), then interpolates linearly in `α` between the two columns around
294///   it. Below the first column's angle it holds that column. So `C_N = (C_N/α)·α` gives the
295///   columns' normal force back at their own angles, and between them a quadratic in `α`: a
296///   potential-flow term linear in `α` plus a viscous cross-flow term in `α²`. RASAero II's
297///   viscous part is `sin² α` from Mach 0.91 to 1.3 in the Calisto export, which `α²` matches
298///   within 0.2% to 4°; faster, it grows more slowly, and the quadratic between the columns is an
299///   assumption.
300/// - Past the last column's angle `α_n`, with `s = sin α / sin α_n`, the force at `α_n` grows
301///   as `s` at the last column's center of pressure, as hpr's own fins follow `sin α` (the
302///   decision record on flight, [ADR-011][adr-011]). When the table starts at 0°, the part of
303///   that force beyond the 0° slope's linear share (`(C_N/α)(0) · α_n`), the rest `R`, grows
304///   faster, as `s²`, the cross flow's form (Galejs; Niskanen 2009 eq. 3.26), which RASAero II's
305///   viscous part takes from Jorgensen (RASAero II Users Manual, 2019, p. 55): the force is
306///   `C_N(α_n) s + R (s² − s)`, with the extra term at the station where the rest acts in the
307///   split (the one that gives the moment at `α_n` with the linear share at the 0° center of
308///   pressure). That is exactly the linear share growing as `s` and the rest as `s²`, each at its
309///   own center of pressure; the station is held within the rocket (or the table's own range of
310///   centers of pressure, [`NormalForceTable::lookup`]), and the linear share within the force.
311///   So the force never turns round and is zero tail first, the center of pressure lies between
312///   the last column's and that station while `s ≥ 1` (from `α_n` to `π − α_n`), and everything
313///   is continuous at `α_n` and in the table's values. This part is an assumption, not the other
314///   tool's result; the lookup reports it.
315///
316/// The table serializes as its columns and its [`TableReference`], and re-checks them when
317/// read. The decisions are in the record on normal-force overrides, [ADR-032][adr-032].
318///
319/// [adr-011]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-011-rigid-body-flight-equations-of-motion-aerodynamic-coupling-rail-phases-and-termination-2026-09-17
320/// [adr-032]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-032-normal-force-overrides-from-rasaero-ii-the-static-force-replaced-hprs-damping-kept-2026-09-19
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
322#[serde(try_from = "NormalForceTableData", into = "NormalForceTableData")]
323pub struct NormalForceTable {
324    columns: Vec<NormalForceColumn>,
325    reference: TableReference,
326}
327
328/// The area a [`NormalForceTable`]'s coefficients are on. [`crate::AeroModel::normal_force`]
329/// rescales them to the rocket's reference area.
330#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
331#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
332#[non_exhaustive]
333pub enum TableReference {
334    /// The rocket's own reference area.
335    #[default]
336    Rocket,
337    /// The largest cross-section of the rocket's bodies, as RASAero II's (RASAero II Users
338    /// Manual, 2019, p. 72).
339    LargestBody,
340    /// A circle of this diameter.
341    Diameter {
342        /// The diameter, m.
343        diameter_m: f64,
344    },
345}
346
347/// The serialized form of a [`NormalForceTable`].
348#[derive(Serialize, Deserialize)]
349#[serde(deny_unknown_fields)]
350struct NormalForceTableData {
351    columns: Vec<NormalForceColumn>,
352    #[serde(default)]
353    reference: TableReference,
354}
355
356impl TryFrom<NormalForceTableData> for NormalForceTable {
357    type Error = AeroError;
358
359    fn try_from(data: NormalForceTableData) -> Result<Self, AeroError> {
360        NormalForceTable::new(data.columns)?.with_reference(data.reference)
361    }
362}
363
364impl From<NormalForceTable> for NormalForceTableData {
365    fn from(table: NormalForceTable) -> Self {
366        NormalForceTableData {
367            columns: table.columns,
368            reference: table.reference,
369        }
370    }
371}
372
373/// A [`NormalForceTable`]'s normal force at one Mach number and angle of attack, on the table's
374/// reference area.
375#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
376#[non_exhaustive]
377pub struct NormalForceLookup {
378    /// The normal-force coefficient `C_N`.
379    pub coefficient: f64,
380    /// `C_N/α` per radian; at `α = 0`, the slope.
381    pub slope_per_rad: f64,
382    /// The center of pressure, m aft of the nose tip.
383    pub cp_station_m: f64,
384    /// `Some` when the Mach number was outside a column's range and its end value was held.
385    pub mach_extrapolated: Option<Side>,
386    /// Whether the angle of attack was past the last column's, where the cross-flow continuation
387    /// applies.
388    pub beyond_alpha: bool,
389}
390
391impl NormalForceTable {
392    /// A table from its columns, in increasing angle of attack, on the rocket's reference area.
393    ///
394    /// # Errors
395    ///
396    /// [`AeroError::Domain`] for no columns, an angle outside `[0, π/2)`, or angles that don't
397    /// strictly increase.
398    pub fn new(columns: Vec<NormalForceColumn>) -> Result<Self, AeroError> {
399        let domain = |value| AeroError::Domain {
400            what: "normal-force table angle of attack",
401            value,
402        };
403        if columns.is_empty() {
404            return Err(AeroError::Domain {
405                what: "normal-force table column count",
406                value: 0.0,
407            });
408        }
409        let mut previous: Option<f64> = None;
410        for column in &columns {
411            let alpha = column.alpha_rad;
412            if !(0.0..FRAC_PI_2).contains(&alpha) || previous.is_some_and(|p| alpha <= p) {
413                return Err(domain(alpha));
414            }
415            previous = Some(alpha);
416        }
417        Ok(Self {
418            columns,
419            reference: TableReference::Rocket,
420        })
421    }
422
423    /// This table with its coefficients on `reference`. When it differs from the rocket's,
424    /// [`crate::AeroModel::normal_force`] rescales by the ratio of the areas.
425    ///
426    /// # Errors
427    ///
428    /// [`AeroError::Domain`] for a diameter that isn't finite and positive.
429    pub fn with_reference(mut self, reference: TableReference) -> Result<Self, AeroError> {
430        if let TableReference::Diameter { diameter_m } = reference {
431            crate::error::check_dimension(
432                "normal-force table reference diameter",
433                diameter_m,
434                false,
435            )?;
436        }
437        self.reference = reference;
438        Ok(self)
439    }
440
441    /// This table with its coefficients on a circle of diameter `diameter_m`
442    /// ([`NormalForceTable::with_reference`]).
443    ///
444    /// # Errors
445    ///
446    /// [`AeroError::Domain`] unless `diameter_m` is finite and positive.
447    pub fn with_reference_diameter_m(self, diameter_m: f64) -> Result<Self, AeroError> {
448        self.with_reference(TableReference::Diameter { diameter_m })
449    }
450
451    /// The columns, in increasing angle of attack.
452    pub fn columns(&self) -> &[NormalForceColumn] {
453        &self.columns
454    }
455
456    /// The area the coefficients are on.
457    pub fn reference(&self) -> TableReference {
458        self.reference
459    }
460
461    /// The normal force at `mach` and angle of attack `alpha_rad`, on the table's reference area,
462    /// as the type's documentation describes, with the growing share's center of pressure past
463    /// the last column held from the nose tip to twice the table's largest center of pressure: a
464    /// stand-in for the rocket, which the table doesn't know ([`NormalForceTable::lookup_within`];
465    /// a flight passes the rocket's own length).
466    ///
467    /// # Errors
468    ///
469    /// As [`NormalForceTable::lookup_within`].
470    pub fn lookup(&self, mach: f64, alpha_rad: f64) -> Result<NormalForceLookup, AeroError> {
471        let largest = self
472            .columns
473            .iter()
474            .flat_map(|c| c.cp_station_m.ys())
475            .fold(0.0_f64, |largest, &cp| largest.max(cp));
476        self.lookup_within(mach, alpha_rad, (0.0, 2.0 * largest))
477    }
478
479    /// As [`NormalForceTable::lookup`], with the growing share's center of pressure past the last
480    /// column held within `stations_m`, m aft of the nose tip: [`crate::AeroModel`] passes the
481    /// rocket, from its nose tip to its aft end.
482    ///
483    /// # Errors
484    ///
485    /// [`AeroError::Domain`] for a negative or non-finite Mach number, an angle outside `[0, π]`,
486    /// or stations that aren't finite and in order; and table errors from a column whose Mach
487    /// range refuses to extrapolate.
488    pub fn lookup_within(
489        &self,
490        mach: f64,
491        alpha_rad: f64,
492        stations_m: (f64, f64),
493    ) -> Result<NormalForceLookup, AeroError> {
494        crate::drag::check_mach_any(mach)?;
495        let (fore, aft) = stations_m;
496        if !(fore.is_finite() && aft.is_finite() && fore <= aft) {
497            return Err(AeroError::Domain {
498                what: "normal-force table's stations, fore end",
499                value: fore,
500            });
501        }
502        if !(0.0..=PI).contains(&alpha_rad) {
503            return Err(AeroError::Domain {
504                what: "angle of attack",
505                value: alpha_rad,
506            });
507        }
508        let mut mach_extrapolated = None;
509        let mut read = |column: &NormalForceColumn| -> Result<(f64, f64), AeroError> {
510            let slope = column.slope_per_rad.lookup(mach)?;
511            let cp = column.cp_station_m.lookup(mach)?;
512            mach_extrapolated = mach_extrapolated.or(slope.extrapolated).or(cp.extrapolated);
513            Ok((slope.value, cp.value))
514        };
515        // The constructor refuses an empty table, so there is a last column.
516        let last = self.columns.len() - 1;
517        let alpha_n = self.columns[last].alpha_rad;
518        let held = alpha_rad.min(alpha_n);
519        // The first column at or above `held`: there is one, since `held` is at most `α_n`.
520        let upper = self
521            .columns
522            .partition_point(|c| c.alpha_rad < held)
523            .min(last);
524        let above = &self.columns[upper];
525        let (slope, cp) = if above.alpha_rad == held || upper == 0 {
526            // At a column's own angle, or below the first: that column alone.
527            read(above)?
528        } else {
529            let below = &self.columns[upper - 1];
530            let t = (held - below.alpha_rad) / (above.alpha_rad - below.alpha_rad);
531            let (s0, x0) = read(below)?;
532            let (s1, x1) = read(above)?;
533            (s0 + t * (s1 - s0), x0 + t * (x1 - x0))
534        };
535        let beyond_alpha = alpha_rad > alpha_n;
536        let (coefficient, cp) = if !beyond_alpha {
537            (slope * alpha_rad, cp)
538        } else if alpha_n == 0.0 {
539            // One column, at 0°: its slope, following the cross flow.
540            (slope * alpha_rad.sin(), cp)
541        } else {
542            // The whole force at `α_n` grows as the cross flow, at its own center of pressure;
543            // the rest beyond the 0° slope's linear share grows faster, as its square, at the
544            // station that keeps the moment of the split, held within `stations_m`. Its extra
545            // term is zero at `α_n` whatever that station, so the force and its center of
546            // pressure are continuous there and in the table's values.
547            let s1 = alpha_rad.sin() / alpha_n.sin();
548            let force_n = slope * alpha_n;
549            let rest = match self.columns.first() {
550                Some(first) if first.alpha_rad == 0.0 && force_n > 0.0 => {
551                    let (linear_slope, linear_cp) = read(first)?;
552                    let linear = (linear_slope * alpha_n).clamp(0.0, force_n);
553                    let rest = force_n - linear;
554                    (rest > 0.0).then(|| {
555                        let station = (force_n * cp - linear * linear_cp) / rest;
556                        (rest, station.clamp(stations_m.0, stations_m.1))
557                    })
558                }
559                _ => None,
560            };
561            match rest {
562                Some((rest, station)) => {
563                    let extra = rest * (s1 * s1 - s1);
564                    let force = force_n * s1 + extra;
565                    let moment = force_n * s1 * cp + extra * station;
566                    (force, if force != 0.0 { moment / force } else { cp })
567                }
568                None => (force_n * s1, cp),
569            }
570        };
571        Ok(NormalForceLookup {
572            coefficient,
573            slope_per_rad: if alpha_rad > 0.0 {
574                coefficient / alpha_rad
575            } else {
576                slope
577            },
578            cp_station_m: cp,
579            mach_extrapolated,
580            beyond_alpha,
581        })
582    }
583
584    /// Reads RASAero II's aerodynamic export (its Aero Plots screen, File, Export, To CSV File:
585    /// RASAero II Users Manual, 2019, p. 76), one column per angle of attack in it.
586    ///
587    /// The first non-blank row is the header. The columns used are named `Mach`, `Alpha` (degrees),
588    /// `CN`, `CN Potential` and `CP` (compared without case or surrounding space); the export's
589    /// others, such as `CNalpha (0 to 4 deg) (per rad)`, a secant slope to 4°, are not read.
590    ///
591    /// - At an angle `α > 0`, the column's slope is `CN/α` and its center of pressure is `CP`.
592    /// - At `α = 0` the export's `CN` is zero, so the slope is its potential-flow normal force at
593    ///   the smallest positive angle `α₁` and the same Mach number over that angle,
594    ///   `CN Potential(α₁)/α₁`: the potential part is linear in `α`, and the viscous cross-flow
595    ///   part, `sin² α` through Mach 1.3 in the Calisto export, has no slope at zero (faster, how
596    ///   it starts from 0° isn't in the export, and leaving it out is an assumption). The center of pressure is the export's at
597    ///   `α = 0`.
598    /// - `CP` is in inches (the manual, p. 13) from the nose tip ("distance measured from the
599    ///   nose", p. 114), converted at 0.0254 m to the inch. hpr's stations are also aft of the
600    ///   nose tip, so the design must start at the same nose tip as RASAero II's.
601    /// - The coefficients are on RASAero II's reference area, the largest cross-section of the
602    ///   body (p. 72): the table's reference is [`TableReference::LargestBody`], which
603    ///   [`crate::AeroModel::normal_force`] rescales to the rocket's reference area.
604    ///
605    /// The five columns read must hold a number in every row; the others are not read. Within one
606    /// angle of attack an identical repeated row is skipped, and otherwise the Mach numbers must strictly increase: a Mach number
607    /// repeated with other values, or out of order, is refused with its line, not sorted. The
608    /// angles need not all have the same Mach numbers (the export behind RocketPy's Calisto ends
609    /// its 4° rows a row early).
610    ///
611    /// # Errors
612    ///
613    /// - [`AeroError::Csv`] naming the 1-based line for a missing header column, a field read that
614    ///   isn't a number, a non-finite value, an angle outside `[0°, 90°)`, a Mach number that
615    ///   doesn't increase within its angle, a row at `α = 0` whose Mach number has no row at `α₁`,
616    ///   an angle with one Mach number (its row's line), or no rows at a positive angle (line 0).
617    ///
618    /// # Examples
619    ///
620    /// Two Mach numbers at 0° and 2°, with only the columns the reader uses (an export has more):
621    ///
622    /// ```
623    /// use hpr_aero::NormalForceTable;
624    ///
625    /// let export = "Mach,Alpha,CN,CN Potential,CP\n\
626    ///               0.3,0,0,0,40\n\
627    ///               0.5,0,0,0,40\n\
628    ///               0.3,2,0.35,0.35,40\n\
629    ///               0.5,2,0.35,0.35,40\n";
630    /// let table = NormalForceTable::from_rasaero_csv(export)?;
631    /// // At 1°, halfway between the columns at 0° and 2°, which here agree.
632    /// let at = table.lookup(0.4, 1_f64.to_radians())?;
633    /// assert!((at.slope_per_rad - 0.35 / 2_f64.to_radians()).abs() < 1e-12);
634    /// // 40 inches aft of the nose tip, in meters.
635    /// assert!((at.cp_station_m - 1.016).abs() < 1e-12);
636    /// # Ok::<(), hpr_aero::AeroError>(())
637    /// ```
638    pub fn from_rasaero_csv(text: &str) -> Result<Self, AeroError> {
639        let text = text.strip_prefix('\u{feff}').unwrap_or(text);
640        let mut lines = text
641            .lines()
642            .enumerate()
643            .map(|(i, line)| (i + 1, line.trim()))
644            .filter(|(_, line)| !line.is_empty());
645        let csv = |line: usize, message: String| AeroError::Csv { line, message };
646        let (header_line, header) = lines.next().ok_or_else(|| csv(0, "no rows".to_owned()))?;
647        let names: Vec<String> = split_fields(header)
648            .iter()
649            .map(|f| f.to_lowercase())
650            .collect();
651        let column = |name: &str| {
652            names
653                .iter()
654                .position(|f| f == name)
655                .ok_or_else(|| csv(header_line, format!("no column named `{name}`")))
656        };
657        let (mach_col, alpha_col, cn_col, potential_col, cp_col) = (
658            column("mach")?,
659            column("alpha")?,
660            column("cn")?,
661            column("cn potential")?,
662            column("cp")?,
663        );
664
665        // The rows of each angle of attack (degrees, as written), in the order they come.
666        let mut angles: Vec<(f64, Vec<RasaeroRow>)> = Vec::new();
667        for (n, line) in lines {
668            // Only the columns read must be numbers; the export's others may hold anything.
669            let fields = split_fields(line);
670            let get = |index: usize| {
671                let field = fields
672                    .get(index)
673                    .ok_or_else(|| csv(n, format!("missing column {}", index + 1)))?;
674                field.parse::<f64>().map_err(|_| {
675                    csv(
676                        n,
677                        format!("column {} is not a number: `{field}`", index + 1),
678                    )
679                })
680            };
681            let alpha_deg = get(alpha_col)?;
682            let row = RasaeroRow {
683                line: n,
684                mach: get(mach_col)?,
685                cn: get(cn_col)?,
686                potential: get(potential_col)?,
687                cp_in: get(cp_col)?,
688            };
689            if ![alpha_deg, row.mach, row.cn, row.potential, row.cp_in]
690                .iter()
691                .all(|v| v.is_finite())
692            {
693                return Err(csv(n, format!("not finite: `{line}`")));
694            }
695            if !(0.0..90.0).contains(&alpha_deg) {
696                return Err(csv(
697                    n,
698                    format!("angle of attack {alpha_deg}° is outside [0°, 90°)"),
699                ));
700            }
701            let index = match angles.iter().position(|(a, _)| *a == alpha_deg) {
702                Some(index) => index,
703                None => {
704                    angles.push((alpha_deg, Vec::new()));
705                    angles.len() - 1
706                }
707            };
708            let rows = &mut angles[index].1;
709            if let Some(last) = rows.last() {
710                // An identical repeated row is harmless, as in `parse_mach_csv`.
711                if row.same_values(last) {
712                    continue;
713                }
714                if row.mach <= last.mach {
715                    return Err(csv(
716                        n,
717                        format!(
718                            "Mach {} doesn't increase from line {}'s {} at {alpha_deg}°",
719                            row.mach, last.line, last.mach
720                        ),
721                    ));
722                }
723            }
724            rows.push(row);
725        }
726        angles.sort_by(|a, b| a.0.total_cmp(&b.0));
727        let (first_positive_deg, first_positive) = angles
728            .iter()
729            .find(|(a, _)| *a > 0.0)
730            .ok_or_else(|| csv(0, "no rows at a positive angle of attack".to_owned()))?;
731        let first_positive_rad = first_positive_deg.to_radians();
732
733        let table = |xs: Vec<f64>, ys: Vec<f64>| {
734            Table1D::new(xs, ys, Interpolation::Linear, Extrapolation::Clamp)
735        };
736        let mut columns = Vec::with_capacity(angles.len());
737        for (alpha_deg, rows) in &angles {
738            let alpha_rad = alpha_deg.to_radians();
739            let mut slopes = Vec::with_capacity(rows.len());
740            for row in rows {
741                slopes.push(if *alpha_deg > 0.0 {
742                    row.cn / alpha_rad
743                } else {
744                    // Both sets of rows increase in Mach, so a binary search finds the match.
745                    let at = first_positive.partition_point(|r| r.mach < row.mach);
746                    match first_positive.get(at) {
747                        Some(r) if r.mach == row.mach => r.potential / first_positive_rad,
748                        _ => {
749                            return Err(csv(
750                                row.line,
751                                format!(
752                                    "no row at Mach {} and {first_positive_deg}° to give the \
753                                     slope at 0°",
754                                    row.mach
755                                ),
756                            ));
757                        }
758                    }
759                });
760            }
761            if rows.len() < Table1D::MIN_KNOTS {
762                let line = rows.first().map_or(0, |r| r.line);
763                return Err(csv(
764                    line,
765                    format!("{alpha_deg}° has one Mach number; a column needs two"),
766                ));
767            }
768            let machs: Vec<f64> = rows.iter().map(|r| r.mach).collect();
769            let cps = rows.iter().map(|r| r.cp_in * INCH_M).collect();
770            columns.push(NormalForceColumn::new(
771                alpha_rad,
772                table(machs.clone(), slopes)?,
773                table(machs, cps)?,
774            ));
775        }
776        Self::new(columns)?.with_reference(TableReference::LargestBody)
777    }
778}
779
780/// One row of a RASAero II export, as [`NormalForceTable::from_rasaero_csv`] uses it.
781struct RasaeroRow {
782    line: usize,
783    mach: f64,
784    cn: f64,
785    potential: f64,
786    cp_in: f64,
787}
788
789impl RasaeroRow {
790    fn same_values(&self, other: &RasaeroRow) -> bool {
791        (self.mach, self.cn, self.potential, self.cp_in)
792            == (other.mach, other.cn, other.potential, other.cp_in)
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    #[test]
801    fn reads_two_column_curves_with_their_quirks() {
802        // CRLF line ends, a leading zero, a trailing blank line.
803        let text = "0.01,0.949\r\n0.02,01.05\r\n0.30,0.40\r\n\r\n";
804        let table = parse_mach_csv(text, None).unwrap();
805        assert_eq!(table.xs(), [0.01, 0.02, 0.30]);
806        assert_eq!(table.ys(), [0.949, 1.05, 0.40]);
807        let with_header = parse_mach_csv("Mach,Cd\n0.1,0.5\n0.2,0.6\n", None).unwrap();
808        assert_eq!(with_header.ys(), [0.5, 0.6]);
809    }
810
811    /// RASAero II's aerodynamic export: its header row (as in the export behind RocketPy's Calisto
812    /// curve), one row per Mach number and angle of attack.
813    #[test]
814    fn named_columns_and_zero_alpha_rows() {
815        let header = "Mach,Alpha,CD,CD Power-Off,CD Power-On,CA Power-Off,CA Power-On,CL,CN,\
816                      CN Potential,CN Viscous,CNalpha (0 to 4 deg) (per rad),CP,CP (0 to 4 deg),\
817                      Reynolds Number";
818        let text = format!(
819            "{header}\n\
820             0.1,0,0.51,0.50,0.40,0.50,0.40,0,0,0,0,6.1,66.2,66.2,5931000\n\
821             0.1,2,0.56,0.55,0.45,0.55,0.45,0.1,0.2,0.1,0.1,6.1,66.0,66.2,5931000\n\
822             0.2,0,0.53,0.52,0.42,0.52,0.42,0,0,0,0,6.1,66.2,66.2,11862000\n\
823             0.2,4,0.58,0.57,0.47,0.57,0.47,0.4,0.4,0.2,0.2,6.1,65.8,66.2,11862000\n"
824        );
825        let text = text.as_str();
826        let off = parse_mach_csv(text, Some("cd power-off")).unwrap();
827        let on = parse_mach_csv(text, Some(" CD Power-On ")).unwrap();
828        assert_eq!(
829            (off.xs(), off.ys()),
830            ([0.1, 0.2].as_slice(), [0.50, 0.52].as_slice())
831        );
832        assert_eq!(on.ys(), [0.40, 0.42]);
833        let table = DragTable::new(off, Some(on));
834        let mid = table.lookup(0.15, true).unwrap();
835        assert!((mid.value - 0.41).abs() < 1e-15);
836        assert_eq!(mid.extrapolated, None);
837        assert_eq!(table.lookup(0.15, false).unwrap().value, 0.51);
838        let high = table.lookup(3.0, false).unwrap();
839        assert_eq!((high.value, high.extrapolated), (0.52, Some(Side::Above)));
840    }
841
842    #[test]
843    fn malformed_text_names_the_line() {
844        let err = |text: &str, column| parse_mach_csv(text, column).unwrap_err();
845        let line = |text: &str, column| match err(text, column) {
846            AeroError::Csv { line, .. } => line,
847            other => panic!("expected a CSV error, got {other:?}"),
848        };
849        assert_eq!(line("", None), 0);
850        assert_eq!(line("0.1,0.5\n0.2,x\n", None), 2);
851        assert_eq!(line("0.1,0.5,0.4\n", None), 1);
852        assert_eq!(line("0.1,0.5\n", Some("cd")), 1);
853        assert_eq!(line("Mach,CD\n0.1,0.5\n", Some("CA")), 1);
854        // A first row with a number in it is a bad row, not a header.
855        assert_eq!(line("0.1,0.5x\n0.2,0.6\n0.3,0.7\n", None), 1);
856        // Duplicate, unsorted and non-finite rows name their line, counting skipped lines.
857        assert_eq!(line("0.2,0.5\n\n0.1,0.5\n", None), 3);
858        assert_eq!(line("0.1,0.5\n0.1,0.6\n", None), 2);
859        // An identical repeated row is skipped.
860        let repeated = parse_mach_csv("0.1,0.5\n0.1,0.5\n0.1,0.5\n0.2,0.6\n", None).unwrap();
861        assert_eq!(repeated.xs(), [0.1, 0.2]);
862        assert_eq!(line("0.1,0.5\n0.2,nan\n", None), 2);
863        assert_eq!(line("0.1,0.5\n0.2,inf\n", None), 2);
864        let alpha = "Mach,Alpha,CD\n0.1,0,0.5\n0.1,2,0.6\n0.1,0,0.7\n";
865        assert_eq!(line(alpha, Some("CD")), 4);
866        assert!(matches!(err("0.2,0.5\n", None), AeroError::Table(_)));
867        let table = DragTable::from_csv("0.1,0.5\n0.2,0.6\n", None).unwrap();
868        assert!(matches!(
869            table.lookup(-0.1, false),
870            Err(AeroError::Domain { .. })
871        ));
872        assert!(matches!(
873            table.lookup(f64::NAN, false),
874            Err(AeroError::Domain { .. })
875        ));
876        // Mach 3 is fine for a table.
877        assert_eq!(table.lookup(3.0, false).unwrap().value, 0.6);
878        // No power-on curve: the power-off one applies while thrusting.
879        assert_eq!(table.lookup(0.1, true).unwrap().value, 0.5);
880    }
881
882    /// A byte-order mark, quoted fields with commas, and trailing commas.
883    #[test]
884    fn byte_order_marks_quotes_and_trailing_commas() {
885        let table = parse_mach_csv("\u{feff}0.01,0.949\n0.02,1.05\n", None).unwrap();
886        assert_eq!(table.xs(), [0.01, 0.02]);
887        let text = "\"Mach\",\"CN (0,4)\",\"CD\",\n0.1,6.1,0.5,\n0.2,6.2,0.6,\n";
888        let table = parse_mach_csv(text, Some("cd")).unwrap();
889        assert_eq!(table.ys(), [0.5, 0.6]);
890        assert_eq!(split_fields("a,\"b,\"\"c\"\"\",d,,"), ["a", "b,\"c\"", "d"]);
891    }
892
893    /// RASAero II's header, as in the export behind RocketPy's Calisto curve.
894    const RASAERO_HEADER: &str = "Mach,Alpha,CD,CD Power-Off,CD Power-On,CA Power-Off,\
895                                  CA Power-On,CL,CN,CN Potential,CN Viscous,\
896                                  CNalpha (0 to 4 deg) (per rad),CP,CP (0 to 4 deg),\
897                                  Reynolds Number";
898
899    /// One export row: Mach, the angle in degrees, `CN Potential`, `CN Viscous` and `CP` in
900    /// inches; `CN` is their sum, and the columns the reader doesn't use are filler.
901    fn rasaero_row(mach: f64, alpha_deg: f64, potential: f64, viscous: f64, cp_in: f64) -> String {
902        format!(
903            "{mach},{alpha_deg},0.5,0.5,0.4,0.5,0.4,0,{},{potential},{viscous},9.9,{cp_in},55.5,1e6",
904            potential + viscous
905        )
906    }
907
908    /// A small export in RASAero II's layout, with invented numbers: all of 0°'s rows, then 2°'s,
909    /// then 4°'s, whose last Mach number is missing as in the Calisto export. At Mach 0.5 the
910    /// potential slope is 7 per radian with no viscous part; at Mach 1 and 1.5 a viscous part
911    /// grows as `α²` and the CP moves forward with the angle.
912    fn small_export() -> String {
913        let r2 = 2.0_f64.to_radians();
914        let r4 = 4.0_f64.to_radians();
915        let mut rows = vec![RASAERO_HEADER.to_owned()];
916        for (mach, cp_in) in [(0.5, 44.0), (1.0, 50.0), (1.5, 47.0)] {
917            rows.push(rasaero_row(mach, 0.0, 0.0, 0.0, cp_in));
918        }
919        for (mach, slope, viscous, cp_in) in [
920            (0.5, 7.0, 0.0, 44.0),
921            (1.0, 10.0, 0.03, 49.0),
922            (1.5, 9.0, 0.04, 46.0),
923        ] {
924            rows.push(rasaero_row(mach, 2.0, slope * r2, viscous, cp_in));
925        }
926        for (mach, slope, viscous, cp_in) in [(0.5, 7.0, 0.0, 44.0), (1.0, 10.0, 0.12, 48.0)] {
927            rows.push(rasaero_row(mach, 4.0, slope * r4, viscous, cp_in));
928        }
929        rows.join("\r\n")
930    }
931
932    fn close(got: f64, want: f64, rel: f64, what: &str) {
933        assert!(
934            (got - want).abs() <= rel * want.abs().max(1e-300),
935            "{what}: {got} against {want}"
936        );
937    }
938
939    #[test]
940    fn reads_a_rasaero_export_by_angle_of_attack() {
941        let table = NormalForceTable::from_rasaero_csv(&small_export()).unwrap();
942        let (r2, r4) = (2.0_f64.to_radians(), 4.0_f64.to_radians());
943        let alphas: Vec<f64> = table.columns().iter().map(|c| c.alpha_rad).collect();
944        assert_eq!(alphas, [0.0, r2, r4]);
945        assert_eq!(table.columns()[2].slope_per_rad.xs(), [0.5, 1.0]);
946        assert_eq!(table.reference(), TableReference::LargestBody);
947        // At 0°, the potential slope at 2°; at 2° and 4°, `CN/α` with the viscous part in it.
948        close(
949            table.columns()[0].slope_per_rad.ys()[1],
950            10.0,
951            1e-15,
952            "0°, Mach 1",
953        );
954        close(
955            table.columns()[1].slope_per_rad.ys()[1],
956            10.0 + 0.03 / r2,
957            1e-15,
958            "2°, Mach 1",
959        );
960        // Inches from the nose tip, converted exactly.
961        let inch = 0.0254;
962        assert_eq!(table.columns()[0].cp_station_m.ys()[1], 50.0 * inch);
963
964        let at = |mach, alpha: f64| table.lookup(mach, alpha).unwrap();
965        // At a column's own angle and Mach number the export's `CN` and `CP` come back.
966        let knot = at(1.0, r4);
967        close(knot.coefficient, 10.0 * r4 + 0.12, 1e-15, "CN at 4°");
968        assert_eq!(knot.cp_station_m, 48.0 * inch);
969        assert_eq!((knot.mach_extrapolated, knot.beyond_alpha), (None, false));
970        // Between the columns `C_N/α` and the CP are linear in the angle; with a viscous part in
971        // `α²`, `C_N/α` is `10 + 0.03 α/α₂²`, which that reproduces between the columns.
972        let s = |alpha: f64| 10.0 + 0.03 * alpha / (r2 * r2);
973        let three = at(1.0, 3.0_f64.to_radians());
974        close(
975            three.slope_per_rad,
976            s(3.0_f64.to_radians()),
977            1e-14,
978            "C_N/α at 3°",
979        );
980        close(three.cp_station_m, 48.5 * inch, 1e-14, "CP at 3°");
981        let one = at(1.0, 1.0_f64.to_radians());
982        close(
983            one.slope_per_rad,
984            s(1.0_f64.to_radians()),
985            1e-14,
986            "C_N/α at 1°",
987        );
988        close(one.cp_station_m, 49.5 * inch, 1e-14, "CP at 1°");
989        // At zero the slope is the 0° column's.
990        let zero = at(1.0, 0.0);
991        assert_eq!((zero.coefficient, zero.slope_per_rad), (0.0, 10.0));
992        // Between Mach numbers each column is linear in Mach.
993        close(at(0.75, 0.0).slope_per_rad, 8.5, 1e-15, "Mach 0.75");
994
995        // Past the last angle: the linear share, 10 per radian at 50 in, grows as sin α; the
996        // rest of the 4° column's force (0.12) and moment grows as sin² α.
997        let ten_rad = 10.0_f64.to_radians();
998        let s1 = ten_rad.sin() / r4.sin();
999        let (linear, rest) = (10.0 * r4, 0.12);
1000        let rest_moment = (linear + rest) * 48.0 * inch - linear * 50.0 * inch;
1001        let ten = at(1.0, ten_rad);
1002        let force = linear * s1 + rest * s1 * s1;
1003        close(ten.coefficient, force, 1e-14, "C_N at 10°");
1004        close(
1005            ten.cp_station_m,
1006            (linear * 50.0 * inch * s1 + rest_moment * s1 * s1) / force,
1007            1e-14,
1008            "CP at 10°",
1009        );
1010        assert!(ten.beyond_alpha);
1011        // The viscous share sits forward (36.4 in), so the CP moves forward with the angle.
1012        assert!(ten.cp_station_m < 48.0 * inch);
1013        // Continuous where the table ends.
1014        let just = at(1.0, r4 * (1.0 + 1e-9));
1015        close(just.coefficient, knot.coefficient, 1e-8, "C_N just past 4°");
1016        close(
1017            just.cp_station_m,
1018            knot.cp_station_m,
1019            1e-8,
1020            "CP just past 4°",
1021        );
1022        // Tail first there is none: `sin π` rounds to 1.2e-16, times forces of about 1.
1023        assert!(at(1.0, PI).coefficient.abs() < 1e-14);
1024        // Past the 4° column's last Mach number its end value holds, and the lookup says so.
1025        let fast = at(3.0, r4);
1026        close(fast.coefficient, 10.0 * r4 + 0.12, 1e-15, "Mach 3 at 4°");
1027        assert_eq!(fast.mach_extrapolated, Some(Side::Above));
1028        assert_eq!(at(3.0, r2).cp_station_m, 46.0 * inch);
1029        // Subsonic, where every column is the same, the continuation is the linear share alone.
1030        let sub = at(0.5, ten_rad);
1031        close(sub.coefficient, 7.0 * r4 * s1, 1e-14, "Mach 0.5 at 10°");
1032        close(sub.cp_station_m, 44.0 * inch, 1e-14, "Mach 0.5 CP at 10°");
1033    }
1034
1035    #[test]
1036    fn rasaero_errors_name_the_line() {
1037        let line = |text: &str| match NormalForceTable::from_rasaero_csv(text) {
1038            Err(AeroError::Csv { line, .. }) => line,
1039            other => panic!("expected a CSV error, got {other:?}"),
1040        };
1041        let with = |rows: &[String]| format!("{RASAERO_HEADER}\n{}", rows.join("\n"));
1042        let row = |mach, alpha| rasaero_row(mach, alpha, 0.1 * alpha, 0.0, 60.0);
1043        assert_eq!(line(""), 0);
1044        assert_eq!(line("Mach,Alpha,CN,CP\n0.1,2,0.2,60\n"), 1);
1045        assert_eq!(line(&with(&[row(0.1, 0.0), row(0.2, 0.0)])), 0);
1046        // A 0° row needs the smallest positive angle's row at its Mach number.
1047        let missing = [row(0.1, 0.0), row(0.2, 0.0), row(0.1, 2.0), row(0.3, 2.0)];
1048        assert_eq!(line(&with(&missing)), 3);
1049        assert_eq!(line(&with(&[row(0.2, 2.0), row(0.1, 2.0)])), 3);
1050        assert_eq!(line(&with(&[row(0.1, 2.0), row(0.1, 90.0)])), 3);
1051        assert_eq!(line(&with(&[row(0.1, -2.0)])), 2);
1052        assert_eq!(line(&with(&[row(0.1, 2.0), "0.2,2,x".to_owned()])), 3);
1053        // A column the reader doesn't use may hold anything.
1054        let text_in_unused = row(0.2, 2.0).replacen(",9.9,", ",n/a,", 1);
1055        let table =
1056            NormalForceTable::from_rasaero_csv(&with(&[row(0.1, 2.0), text_in_unused])).unwrap();
1057        assert_eq!(table.columns()[0].slope_per_rad.xs(), [0.1, 0.2]);
1058        let nan = rasaero_row(0.2, 2.0, f64::NAN, 0.0, 60.0);
1059        assert_eq!(line(&with(&[row(0.1, 2.0), nan])), 3);
1060        // An identical repeated row is skipped; one Mach number is too few.
1061        let table = NormalForceTable::from_rasaero_csv(&with(&[
1062            row(0.1, 2.0),
1063            row(0.1, 2.0),
1064            row(0.2, 2.0),
1065        ]))
1066        .unwrap();
1067        assert_eq!(table.columns()[0].slope_per_rad.xs(), [0.1, 0.2]);
1068        assert_eq!(
1069            line(&with(&[row(0.1, 2.0), row(0.2, 2.0), row(0.1, 4.0)])),
1070            4
1071        );
1072    }
1073
1074    #[test]
1075    fn normal_force_tables_check_their_columns_and_round_trip() {
1076        let flat = |value| {
1077            Table1D::new(
1078                vec![0.0, 2.0],
1079                vec![value, value],
1080                Interpolation::Linear,
1081                Extrapolation::Clamp,
1082            )
1083            .unwrap()
1084        };
1085        let column = |alpha| NormalForceColumn::new(alpha, flat(6.0), flat(1.5));
1086        let domain = |result: Result<NormalForceTable, AeroError>| {
1087            assert!(
1088                matches!(result, Err(AeroError::Domain { .. })),
1089                "{result:?}"
1090            );
1091        };
1092        domain(NormalForceTable::new(Vec::new()));
1093        domain(NormalForceTable::new(vec![column(0.1), column(0.1)]));
1094        domain(NormalForceTable::new(vec![column(0.1), column(0.05)]));
1095        domain(NormalForceTable::new(vec![column(-0.1)]));
1096        domain(NormalForceTable::new(vec![column(FRAC_PI_2)]));
1097        domain(NormalForceTable::new(vec![column(f64::NAN)]));
1098        let table = NormalForceTable::new(vec![column(0.0), column(0.1)]).unwrap();
1099        domain(table.clone().with_reference_diameter_m(0.0));
1100        domain(table.clone().with_reference_diameter_m(f64::INFINITY));
1101        let table = table.with_reference_diameter_m(0.1).unwrap();
1102        assert_eq!(
1103            table.reference(),
1104            TableReference::Diameter { diameter_m: 0.1 }
1105        );
1106        let json = serde_json::to_string(&table).unwrap();
1107        assert_eq!(
1108            serde_json::from_str::<NormalForceTable>(&json).unwrap(),
1109            table
1110        );
1111        // Reading re-checks the columns.
1112        let reversed = json.replacen("\"alpha_rad\":0.1", "\"alpha_rad\":-0.1", 1);
1113        assert!(serde_json::from_str::<NormalForceTable>(&reversed).is_err());
1114        for (mach, alpha) in [(-0.1, 0.0), (f64::NAN, 0.0), (0.5, -0.01), (0.5, 3.2)] {
1115            assert!(matches!(
1116                table.lookup(mach, alpha),
1117                Err(AeroError::Domain { .. })
1118            ));
1119        }
1120        // A single column at 0° follows `sin α` at every angle.
1121        let single = NormalForceTable::new(vec![column(0.0)]).unwrap();
1122        let lookup = single.lookup(1.0, 0.3).unwrap();
1123        assert_eq!(lookup.coefficient, 6.0 * 0.3_f64.sin());
1124        assert!(lookup.beyond_alpha);
1125        // Mach 7 is fine for a table.
1126        assert_eq!(single.lookup(7.0, 0.0).unwrap().slope_per_rad, 6.0);
1127    }
1128
1129    fn flat(value: f64) -> Table1D {
1130        Table1D::new(
1131            vec![0.0, 2.0],
1132            vec![value, value],
1133            Interpolation::Linear,
1134            Extrapolation::Clamp,
1135        )
1136        .unwrap()
1137    }
1138
1139    /// A table shaped as RASAero II's export is (Calisto's: the potential part exactly linear in
1140    /// the angle, the viscous part exactly as `sin² α`), sampled at 0°, 2° and 4°: past 4° the
1141    /// viscous part continues exactly, and the potential part as `α₄ sin α / sin α₄`.
1142    #[test]
1143    fn a_rasaero_shaped_table_continues_its_crossflow_exactly() {
1144        let (a, x_a, b, x_b) = (9.0, 1.6, 4.0, 1.1);
1145        let (r2, r4) = (2.0_f64.to_radians(), 4.0_f64.to_radians());
1146        let force = |alpha: f64| a * alpha + b * alpha.sin().powi(2);
1147        let cp = |alpha: f64| (a * alpha * x_a + b * alpha.sin().powi(2) * x_b) / force(alpha);
1148        let column =
1149            |alpha: f64, slope, center| NormalForceColumn::new(alpha, flat(slope), flat(center));
1150        let table = NormalForceTable::new(vec![
1151            column(0.0, a, x_a),
1152            column(r2, force(r2) / r2, cp(r2)),
1153            column(r4, force(r4) / r4, cp(r4)),
1154        ])
1155        .unwrap();
1156        for alpha_deg in [10.0_f64, 30.0, 60.0, 90.0, 150.0] {
1157            let alpha = alpha_deg.to_radians();
1158            let s1 = alpha.sin() / r4.sin();
1159            let (linear, viscous) = (a * r4 * s1, b * alpha.sin().powi(2));
1160            let lookup = table.lookup(1.0, alpha).unwrap();
1161            close(lookup.coefficient, linear + viscous, 1e-12, "C_N");
1162            close(
1163                lookup.cp_station_m,
1164                (linear * x_a + viscous * x_b) / (linear + viscous),
1165                1e-12,
1166                "CP",
1167            );
1168        }
1169    }
1170
1171    /// The case physics review found jumping in the first guarded split: as the 4° column's
1172    /// center of pressure moves past the one that makes the rest's moment zero, near Mach 0.51,
1173    /// `C_N` at 30° fell from 8.59 to 5.50. Now it moves smoothly.
1174    #[test]
1175    fn the_continuation_does_not_jump_where_the_rest_s_moment_changes_sign() {
1176        let r4 = 4.0_f64.to_radians();
1177        let line = |a: f64, b: f64| {
1178            Table1D::new(
1179                vec![0.0, 1.0],
1180                vec![a, b],
1181                Interpolation::Linear,
1182                Extrapolation::Clamp,
1183            )
1184            .unwrap()
1185        };
1186        let table = NormalForceTable::new(vec![
1187            NormalForceColumn::new(0.0, flat(10.0), flat(1.0)),
1188            NormalForceColumn::new(r4, flat(11.0), line(0.92, 0.90)),
1189        ])
1190        .unwrap();
1191        let alpha = 30.0_f64.to_radians();
1192        let at = |mach: f64| table.lookup_within(mach, alpha, (0.0, 1.3)).unwrap();
1193        let mut previous = at(0.4);
1194        for step in 1..=2000 {
1195            let next = at(0.4 + f64::from(step) * 1e-4);
1196            assert!(
1197                (next.coefficient - previous.coefficient).abs() < 1e-3,
1198                "{previous:?} {next:?}"
1199            );
1200            let moment = |l: &NormalForceLookup| l.coefficient * l.cp_station_m;
1201            assert!((moment(&next) - moment(&previous)).abs() < 1e-3);
1202            previous = next;
1203        }
1204        // Stations out of order, or not finite, are refused rather than panicking.
1205        for stations in [(1.3, 0.0), (f64::NAN, 1.3), (0.0, f64::INFINITY)] {
1206            assert!(matches!(
1207                table.lookup_within(0.5, alpha, stations),
1208                Err(AeroError::Domain { .. })
1209            ));
1210        }
1211    }
1212
1213    proptest::proptest! {
1214        /// Past the last column the normal force never turns round, and while `s ≥ 1` its center
1215        /// of pressure stays within the stations given, whatever the table: slopes that grow or
1216        /// fall with the angle, and centers of pressure that move either way.
1217        #[test]
1218        fn the_continuation_keeps_its_sign_and_center(
1219            slope_0 in 0.5..20.0_f64,
1220            slope_n in 0.5..20.0_f64,
1221            cp_0 in 0.1..3.0_f64,
1222            cp_n in 0.1..3.0_f64,
1223            alpha_n_deg in 1.0..40.0_f64,
1224            beyond in 0.0..1.0_f64,
1225        ) {
1226            let alpha_n = alpha_n_deg.to_radians();
1227            let table = NormalForceTable::new(vec![
1228                NormalForceColumn::new(0.0, flat(slope_0), flat(cp_0)),
1229                NormalForceColumn::new(alpha_n, flat(slope_n), flat(cp_n)),
1230            ])
1231            .unwrap();
1232            let alpha = alpha_n + beyond * (PI - alpha_n);
1233            let lookup = table.lookup_within(1.0, alpha, (0.0, 3.0)).unwrap();
1234            // `sin π` rounds to 1.2e-16.
1235            proptest::prop_assert!(lookup.coefficient >= -1e-13, "{lookup:?}");
1236            if alpha.sin() >= alpha_n.sin() {
1237                proptest::prop_assert!(
1238                    (-1e-9..=3.0 + 1e-9).contains(&lookup.cp_station_m),
1239                    "{lookup:?}"
1240                );
1241            }
1242        }
1243
1244        /// The continuation is continuous in the table's values: as a column's slope or center
1245        /// of pressure moves with Mach number, through the points where the rest beyond the
1246        /// linear share, or its moment, changes sign, the force and its moment move smoothly.
1247        #[test]
1248        fn the_continuation_is_continuous_in_mach(
1249            slope_0 in 5.0..15.0_f64,
1250            cp_0 in 0.8..1.2_f64,
1251            slope_change in -0.2..0.2_f64,
1252            cp_change in -0.1..0.1_f64,
1253            cp_offset in -0.1..0.1_f64,
1254            alpha_deg in 5.0..170.0_f64,
1255        ) {
1256            let r4 = 4.0_f64.to_radians();
1257            let line = |a: f64, b: f64| {
1258                Table1D::new(vec![0.0, 1.0], vec![a, b], Interpolation::Linear, Extrapolation::Clamp)
1259                    .unwrap()
1260            };
1261            let table = NormalForceTable::new(vec![
1262                NormalForceColumn::new(0.0, flat(slope_0), flat(cp_0)),
1263                NormalForceColumn::new(
1264                    r4,
1265                    line(slope_0 - slope_change, slope_0 + slope_change),
1266                    line(cp_0 + cp_offset - cp_change, cp_0 + cp_offset + cp_change),
1267                ),
1268            ])
1269            .unwrap();
1270            let alpha = alpha_deg.to_radians();
1271            let at = |mach: f64| {
1272                let l = table.lookup_within(mach, alpha, (0.0, 1.5)).unwrap();
1273                (l.coefficient, l.coefficient * l.cp_station_m)
1274            };
1275            let mut previous = at(0.0);
1276            for step in 1..=1000 {
1277                let next = at(f64::from(step) * 1e-3);
1278                // The force is at most about 1/sin²(4°) ≈ 200 times a column's; a step of 1e-3
1279                // in Mach moves it by far less than 1% of that.
1280                proptest::prop_assert!((next.0 - previous.0).abs() < 0.05, "{previous:?} {next:?}");
1281                proptest::prop_assert!((next.1 - previous.1).abs() < 0.1, "{previous:?} {next:?}");
1282                previous = next;
1283            }
1284        }
1285    }
1286}