Skip to main content

ballistics_engine/
bc_conversion.rs

1//! Ballistic-coefficient conversion between standard reference drag families (MBA-1375).
2//!
3//! A reference-family BC appears in the point-mass retardation as `Cd_ref(Mach) / BC`.
4//! Consequently, conversion at one Mach is an exact table-ratio operation, while fitting one
5//! scalar BC across velocity bands is a one-parameter least-squares problem in reciprocal BC.
6//! This module owns that shared, filesystem-free math so native and WASM front ends cannot drift.
7
8use crate::drag::{get_drag_coefficient, reference_drag_table, DragTable};
9use crate::{BCSegmentData, DragModel};
10use serde::{Deserialize, Serialize};
11use std::fmt::Write as _;
12
13/// Schema version carried by every serializable conversion report.
14pub const BC_CONVERSION_SCHEMA_VERSION_V1: u32 = 1;
15
16/// A conversion curve must contain more than the legacy six-row placeholder decks.
17///
18/// Point count is a capability guard, not a quality metric: the normal shape checks below still
19/// require aligned, finite, positive, strictly ascending data. Seven is deliberately the smallest
20/// value that rejects every retired six-point placeholder while leaving table-density policy to
21/// provenance review and golden tests.
22pub const MIN_BC_CONVERSION_TABLE_POINTS: usize = 7;
23
24/// The canonical output forms shared by native and WASM front ends.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum BcConversionFormat {
27    Table,
28    Csv,
29    Json,
30}
31
32/// Exact conversion of one scalar BC at one Mach number.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct ScalarBcConversionV1 {
35    pub schema_version: u32,
36    pub source_drag_model: String,
37    pub target_drag_model: String,
38    /// Conventional small-arms BC in lb/in^2.
39    pub source_bc: f64,
40    /// Conventional small-arms BC in lb/in^2.
41    pub target_bc: f64,
42    pub mach: f64,
43    /// Present when the velocity convenience API was used; canonical unit is ft/s.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub velocity_fps: Option<f64>,
46    pub source_cd: f64,
47    pub target_cd: f64,
48    /// `target_cd / source_cd`.
49    pub conversion_ratio: f64,
50}
51
52/// One input [`BCSegmentData`] band converted to a constant BC in another family.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct BcSegmentConversionV1 {
55    pub velocity_min_fps: f64,
56    pub velocity_max_fps: f64,
57    pub source_bc: f64,
58    pub target_bc: f64,
59    /// Width-normalized RMS relative retardation error inside this band.
60    pub relative_rms: f64,
61}
62
63/// A velocity-banded conversion into one requested target family.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct BandedBcConversionV1 {
66    pub schema_version: u32,
67    pub source_drag_model: String,
68    pub target_drag_model: String,
69    pub speed_of_sound_fps: f64,
70    pub mach_min: f64,
71    pub mach_max: f64,
72    /// Same order and bounds as the caller's input segments.
73    pub segments: Vec<BcSegmentConversionV1>,
74    /// Width-normalized RMS relative retardation error across all converted bands.
75    pub relative_rms: f64,
76    pub integration_evaluations: usize,
77}
78
79impl BandedBcConversionV1 {
80    /// Return the converted schedule in the engine's established velocity-band type.
81    pub fn converted_segments(&self) -> Vec<BCSegmentData> {
82        self.segments
83            .iter()
84            .map(|segment| BCSegmentData {
85                velocity_min: segment.velocity_min_fps,
86                velocity_max: segment.velocity_max_fps,
87                bc_value: segment.target_bc,
88            })
89            .collect()
90    }
91}
92
93/// One candidate family's best single scalar BC over a source band schedule.
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct BcFamilyFitV1 {
96    pub candidate_drag_model: String,
97    pub fitted_bc: f64,
98    /// Width-normalized RMS relative retardation error across the covered velocity range.
99    pub relative_rms: f64,
100    pub integration_evaluations: usize,
101}
102
103/// Ranking of candidate families by their one-scalar fit to a source band schedule.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct BcFamilyRecommendationV1 {
106    pub schema_version: u32,
107    pub source_drag_model: String,
108    pub speed_of_sound_fps: f64,
109    pub mach_min: f64,
110    pub mach_max: f64,
111    /// Ascending by residual, then by the caller's candidate order for exact ties.
112    pub fits: Vec<BcFamilyFitV1>,
113    pub recommended: BcFamilyFitV1,
114}
115
116/// One banded operation's requested conversion and family recommendation.
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct BcSegmentAnalysisV1 {
119    pub conversion: BandedBcConversionV1,
120    pub recommendation: BcFamilyRecommendationV1,
121}
122
123/// Backward-compatible descriptive alias for [`BcSegmentAnalysisV1`].
124pub type BcBandedAnalysisV1 = BcSegmentAnalysisV1;
125
126/// Type-erased report accepted by the shared renderer.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[serde(tag = "mode", rename_all = "snake_case")]
129pub enum BcConversionReportV1 {
130    Scalar { result: ScalarBcConversionV1 },
131    Banded { result: BcSegmentAnalysisV1 },
132    Recommendation { result: BcFamilyRecommendationV1 },
133}
134
135impl From<BcSegmentAnalysisV1> for BcConversionReportV1 {
136    fn from(result: BcSegmentAnalysisV1) -> Self {
137        Self::Banded { result }
138    }
139}
140
141/// Why a BC conversion or family fit was rejected.
142#[derive(Debug, Clone, PartialEq, thiserror::Error)]
143pub enum BcConversionError {
144    #[error("{field} must be finite and greater than zero, got {value}")]
145    InvalidPositiveValue { field: &'static str, value: f64 },
146    #[error("Mach must be finite and non-negative, got {mach}")]
147    InvalidMach { mach: f64 },
148    #[error("no BC segments were supplied")]
149    NoSegments,
150    #[error(
151        "BC segment #{index} has invalid bounds {velocity_min_fps}..{velocity_max_fps} ft/s; bounds must be finite, non-negative, and max must exceed min"
152    )]
153    InvalidSegmentBounds {
154        index: usize,
155        velocity_min_fps: f64,
156        velocity_max_fps: f64,
157    },
158    #[error("BC segment #{index} must have a finite positive BC, got {bc}")]
159    InvalidSegmentBc { index: usize, bc: f64 },
160    #[error(
161        "BC segments #{first_index} and #{second_index} overlap ({first_max_fps} > {second_min_fps} ft/s)"
162    )]
163    OverlappingSegments {
164        first_index: usize,
165        second_index: usize,
166        first_max_fps: f64,
167        second_min_fps: f64,
168    },
169    #[error("no candidate drag families were supplied")]
170    NoCandidates,
171    #[error("candidate drag family {drag_model} was supplied more than once")]
172    DuplicateCandidate { drag_model: String },
173    #[error("{drag_model} reference drag table is unusable: {reason}")]
174    InvalidDragTable { drag_model: String, reason: String },
175    #[error(
176        "Mach {mach} is outside the {drag_model} reference table domain [{mach_min}, {mach_max}]; conversion does not clamp or extrapolate"
177    )]
178    MachOutsideTableDomain {
179        drag_model: String,
180        mach: f64,
181        mach_min: f64,
182        mach_max: f64,
183    },
184    #[error("BC conversion produced a non-finite result while computing {context}")]
185    NonFiniteResult { context: &'static str },
186    #[error("could not serialize BC conversion report: {message}")]
187    Serialization { message: String },
188}
189
190/// Convert one scalar BC at a stated Mach number.
191pub fn convert_bc_at_mach(
192    source_bc: f64,
193    source: DragModel,
194    target: DragModel,
195    mach: f64,
196) -> Result<ScalarBcConversionV1, BcConversionError> {
197    convert_bc_at_mach_impl(source_bc, source, target, mach, None)
198}
199
200/// Convert one scalar BC at a stated velocity and speed of sound, both in ft/s.
201pub fn convert_bc_at_velocity(
202    source_bc: f64,
203    source: DragModel,
204    target: DragModel,
205    velocity_fps: f64,
206    speed_of_sound_fps: f64,
207) -> Result<ScalarBcConversionV1, BcConversionError> {
208    validate_positive("velocity_fps", velocity_fps)?;
209    validate_positive("speed_of_sound_fps", speed_of_sound_fps)?;
210    convert_bc_at_mach_impl(
211        source_bc,
212        source,
213        target,
214        velocity_fps / speed_of_sound_fps,
215        Some(velocity_fps),
216    )
217}
218
219/// Convert every source band to a constant target-family BC over the same velocity bounds.
220pub fn convert_bc_segments(
221    segments: &[BCSegmentData],
222    source: DragModel,
223    target: DragModel,
224    speed_of_sound_fps: f64,
225) -> Result<BandedBcConversionV1, BcConversionError> {
226    let validated = validate_segments_for_pair(segments, source, target, speed_of_sound_fps)?;
227    let mut converted_by_input = vec![None; segments.len()];
228    let mut total_squared_error = CompensatedSum::default();
229    let mut total_weight = CompensatedSum::default();
230    let mut integration_evaluations = 0usize;
231
232    for segment in &validated.segments {
233        let (target_bc, relative_rms, squared_error, weight, evaluations) = if source == target {
234            // Preserve both the band BC and its identity residual exactly. The domain and
235            // table were still validated above, so this does not bypass strictness.
236            (
237                segment.bc,
238                0.0,
239                0.0,
240                segment.velocity_max_fps - segment.velocity_min_fps,
241                0,
242            )
243        } else {
244            let samples = collect_relative_samples(segment, source, target, speed_of_sound_fps)?;
245            let solution = solve_relative_fit(&samples)?;
246            (
247                solution.fitted_bc,
248                solution.relative_rms,
249                solution.weighted_squared_error,
250                solution.total_weight,
251                solution.integration_evaluations,
252            )
253        };
254
255        total_squared_error.add(squared_error);
256        total_weight.add(weight);
257        integration_evaluations += evaluations;
258        converted_by_input[segment.original_index - 1] = Some(BcSegmentConversionV1 {
259            velocity_min_fps: segment.velocity_min_fps,
260            velocity_max_fps: segment.velocity_max_fps,
261            source_bc: segment.bc,
262            target_bc,
263            relative_rms,
264        });
265    }
266
267    let relative_rms = normalized_rms(total_squared_error.value(), total_weight.value())?;
268    let converted_segments = converted_by_input
269        .into_iter()
270        .map(|segment| {
271            segment.ok_or(BcConversionError::NonFiniteResult {
272                context: "converted BC segment ordering",
273            })
274        })
275        .collect::<Result<Vec<_>, _>>()?;
276
277    Ok(BandedBcConversionV1 {
278        schema_version: BC_CONVERSION_SCHEMA_VERSION_V1,
279        source_drag_model: source.to_string(),
280        target_drag_model: target.to_string(),
281        speed_of_sound_fps,
282        mach_min: validated.mach_min,
283        mach_max: validated.mach_max,
284        segments: converted_segments,
285        relative_rms,
286        integration_evaluations,
287    })
288}
289
290/// Fit one scalar BC in `candidate` across all supplied source-family BC bands.
291pub fn fit_bc_family(
292    segments: &[BCSegmentData],
293    source: DragModel,
294    candidate: DragModel,
295    speed_of_sound_fps: f64,
296) -> Result<BcFamilyFitV1, BcConversionError> {
297    let validated = validate_segments_for_pair(segments, source, candidate, speed_of_sound_fps)?;
298    fit_validated_segments(&validated.segments, source, candidate, speed_of_sound_fps)
299}
300
301/// Rank candidate families by one-scalar relative-retardation least-squares residual.
302pub fn recommend_bc_family(
303    segments: &[BCSegmentData],
304    source: DragModel,
305    candidates: &[DragModel],
306    speed_of_sound_fps: f64,
307) -> Result<BcFamilyRecommendationV1, BcConversionError> {
308    validate_positive("speed_of_sound_fps", speed_of_sound_fps)?;
309    if candidates.is_empty() {
310        return Err(BcConversionError::NoCandidates);
311    }
312    for (index, candidate) in candidates.iter().enumerate() {
313        if candidates[..index].iter().any(|seen| seen == candidate) {
314            return Err(BcConversionError::DuplicateCandidate {
315                drag_model: candidate.to_string(),
316            });
317        }
318    }
319
320    // Validate source bands once up front to establish the report range. Each candidate fit
321    // additionally validates its own table and pair domain before sampling.
322    let source_validated =
323        validate_segments_for_pair(segments, source, source, speed_of_sound_fps)?;
324    let mut fits = Vec::with_capacity(candidates.len());
325    for &candidate in candidates {
326        fits.push(fit_bc_family(
327            segments,
328            source,
329            candidate,
330            speed_of_sound_fps,
331        )?);
332    }
333    // `sort_by` is stable: an exact residual tie retains caller candidate order.
334    fits.sort_by(|left, right| left.relative_rms.total_cmp(&right.relative_rms));
335    let recommended = fits
336        .first()
337        .cloned()
338        .ok_or(BcConversionError::NoCandidates)?;
339
340    Ok(BcFamilyRecommendationV1 {
341        schema_version: BC_CONVERSION_SCHEMA_VERSION_V1,
342        source_drag_model: source.to_string(),
343        speed_of_sound_fps,
344        mach_min: source_validated.mach_min,
345        mach_max: source_validated.mach_max,
346        fits,
347        recommended,
348    })
349}
350
351/// Perform the requested band conversion and candidate-family recommendation in one call.
352pub fn analyze_bc_segments(
353    segments: &[BCSegmentData],
354    source: DragModel,
355    target: DragModel,
356    candidates: &[DragModel],
357    speed_of_sound_fps: f64,
358) -> Result<BcSegmentAnalysisV1, BcConversionError> {
359    Ok(BcSegmentAnalysisV1 {
360        conversion: convert_bc_segments(segments, source, target, speed_of_sound_fps)?,
361        recommendation: recommend_bc_family(segments, source, candidates, speed_of_sound_fps)?,
362    })
363}
364
365/// Render a report in canonical ft/s units, identically for native and WASM callers.
366pub fn format_bc_conversion_report(
367    report: &BcConversionReportV1,
368    format: BcConversionFormat,
369) -> Result<String, BcConversionError> {
370    match format {
371        BcConversionFormat::Json => {
372            let mut rendered = serde_json::to_string_pretty(report).map_err(|error| {
373                BcConversionError::Serialization {
374                    message: error.to_string(),
375                }
376            })?;
377            rendered.push('\n');
378            Ok(rendered)
379        }
380        BcConversionFormat::Table => Ok(format_report_table(report)),
381        BcConversionFormat::Csv => Ok(format_report_csv(report)),
382    }
383}
384
385fn convert_bc_at_mach_impl(
386    source_bc: f64,
387    source: DragModel,
388    target: DragModel,
389    mach: f64,
390    velocity_fps: Option<f64>,
391) -> Result<ScalarBcConversionV1, BcConversionError> {
392    validate_positive("source_bc", source_bc)?;
393    validate_mach(mach)?;
394    validate_mach_for_model(source, mach)?;
395    validate_mach_for_model(target, mach)?;
396
397    let source_cd = get_drag_coefficient(mach, &source);
398    let target_cd = if source == target {
399        source_cd
400    } else {
401        get_drag_coefficient(mach, &target)
402    };
403    validate_computed_cd(source_cd, "source drag coefficient")?;
404    validate_computed_cd(target_cd, "target drag coefficient")?;
405
406    // Special-case identity so the public invariant is bit-exact rather than merely within
407    // floating-point tolerance after multiplying by Cd/Cd.
408    let (conversion_ratio, target_bc) = if source == target {
409        (1.0, source_bc)
410    } else {
411        let ratio = target_cd / source_cd;
412        let converted = source_bc * ratio;
413        if !ratio.is_finite() || !converted.is_finite() || converted <= 0.0 {
414            return Err(BcConversionError::NonFiniteResult {
415                context: "scalar target BC",
416            });
417        }
418        (ratio, converted)
419    };
420
421    Ok(ScalarBcConversionV1 {
422        schema_version: BC_CONVERSION_SCHEMA_VERSION_V1,
423        source_drag_model: source.to_string(),
424        target_drag_model: target.to_string(),
425        source_bc,
426        target_bc,
427        mach,
428        velocity_fps,
429        source_cd,
430        target_cd,
431        conversion_ratio,
432    })
433}
434
435fn validate_positive(field: &'static str, value: f64) -> Result<(), BcConversionError> {
436    if value.is_finite() && value > 0.0 {
437        Ok(())
438    } else {
439        Err(BcConversionError::InvalidPositiveValue { field, value })
440    }
441}
442
443fn validate_mach(mach: f64) -> Result<(), BcConversionError> {
444    if mach.is_finite() && mach >= 0.0 {
445        Ok(())
446    } else {
447        Err(BcConversionError::InvalidMach { mach })
448    }
449}
450
451fn validate_computed_cd(cd: f64, context: &'static str) -> Result<(), BcConversionError> {
452    if cd.is_finite() && cd > 0.0 {
453        Ok(())
454    } else {
455        Err(BcConversionError::NonFiniteResult { context })
456    }
457}
458
459fn validate_mach_for_model(drag_model: DragModel, mach: f64) -> Result<(), BcConversionError> {
460    let (mach_min, mach_max) = validate_drag_table(drag_model)?;
461    if mach < mach_min || mach > mach_max {
462        return Err(BcConversionError::MachOutsideTableDomain {
463            drag_model: drag_model.to_string(),
464            mach,
465            mach_min,
466            mach_max,
467        });
468    }
469    Ok(())
470}
471
472fn validate_drag_table(drag_model: DragModel) -> Result<(f64, f64), BcConversionError> {
473    let table = reference_drag_table(&drag_model);
474    validate_drag_table_shape(drag_model, table)?;
475    Ok((
476        table.mach_values[0],
477        table.mach_values[table.mach_values.len() - 1],
478    ))
479}
480
481fn validate_drag_table_shape(
482    drag_model: DragModel,
483    table: &DragTable,
484) -> Result<(), BcConversionError> {
485    let invalid = |reason: String| BcConversionError::InvalidDragTable {
486        drag_model: drag_model.to_string(),
487        reason,
488    };
489    if table.mach_values.len() != table.cd_values.len() {
490        return Err(invalid(format!(
491            "{} Mach values but {} Cd values",
492            table.mach_values.len(),
493            table.cd_values.len()
494        )));
495    }
496    if table.mach_values.len() < MIN_BC_CONVERSION_TABLE_POINTS {
497        return Err(invalid(format!(
498            "needs at least {MIN_BC_CONVERSION_TABLE_POINTS} rows for conversion (legacy sparse/placeholder decks are refused), got {}",
499            table.mach_values.len()
500        )));
501    }
502    for (index, (&mach, &cd)) in table.mach_values.iter().zip(&table.cd_values).enumerate() {
503        if !mach.is_finite() || mach < 0.0 {
504            return Err(invalid(format!(
505                "row {} Mach must be finite and non-negative, got {mach}",
506                index + 1
507            )));
508        }
509        if !cd.is_finite() || cd <= 0.0 {
510            return Err(invalid(format!(
511                "row {} Cd must be finite and positive, got {cd}",
512                index + 1
513            )));
514        }
515        if index > 0 && mach <= table.mach_values[index - 1] {
516            return Err(invalid(format!(
517                "Mach values must strictly ascend; row {} ({mach}) follows {}",
518                index + 1,
519                table.mach_values[index - 1]
520            )));
521        }
522    }
523    Ok(())
524}
525
526#[derive(Debug, Clone, Copy)]
527struct ValidatedSegment {
528    /// One-based index in the caller's slice, used in public errors and output restoration.
529    original_index: usize,
530    velocity_min_fps: f64,
531    velocity_max_fps: f64,
532    bc: f64,
533}
534
535struct ValidatedSegments {
536    /// Ascending by velocity, regardless of caller order.
537    segments: Vec<ValidatedSegment>,
538    mach_min: f64,
539    mach_max: f64,
540}
541
542fn validate_segments_for_pair(
543    segments: &[BCSegmentData],
544    source: DragModel,
545    target: DragModel,
546    speed_of_sound_fps: f64,
547) -> Result<ValidatedSegments, BcConversionError> {
548    validate_positive("speed_of_sound_fps", speed_of_sound_fps)?;
549    if segments.is_empty() {
550        return Err(BcConversionError::NoSegments);
551    }
552
553    let mut validated = Vec::with_capacity(segments.len());
554    for (zero_based_index, segment) in segments.iter().enumerate() {
555        let index = zero_based_index + 1;
556        if !segment.velocity_min.is_finite()
557            || !segment.velocity_max.is_finite()
558            || segment.velocity_min < 0.0
559            || segment.velocity_max <= segment.velocity_min
560        {
561            return Err(BcConversionError::InvalidSegmentBounds {
562                index,
563                velocity_min_fps: segment.velocity_min,
564                velocity_max_fps: segment.velocity_max,
565            });
566        }
567        if !segment.bc_value.is_finite() || segment.bc_value <= 0.0 {
568            return Err(BcConversionError::InvalidSegmentBc {
569                index,
570                bc: segment.bc_value,
571            });
572        }
573        validated.push(ValidatedSegment {
574            original_index: index,
575            velocity_min_fps: segment.velocity_min,
576            velocity_max_fps: segment.velocity_max,
577            bc: segment.bc_value,
578        });
579    }
580    validated.sort_by(|left, right| {
581        left.velocity_min_fps
582            .total_cmp(&right.velocity_min_fps)
583            .then_with(|| left.velocity_max_fps.total_cmp(&right.velocity_max_fps))
584            .then_with(|| left.original_index.cmp(&right.original_index))
585    });
586    for pair in validated.windows(2) {
587        let (left, right) = (pair[0], pair[1]);
588        if right.velocity_min_fps < left.velocity_max_fps {
589            return Err(BcConversionError::OverlappingSegments {
590                first_index: left.original_index,
591                second_index: right.original_index,
592                first_max_fps: left.velocity_max_fps,
593                second_min_fps: right.velocity_min_fps,
594            });
595        }
596    }
597
598    let source_domain = validate_drag_table(source)?;
599    let target_domain = if source == target {
600        source_domain
601    } else {
602        validate_drag_table(target)?
603    };
604    for segment in &validated {
605        let low_mach = segment.velocity_min_fps / speed_of_sound_fps;
606        let high_mach = segment.velocity_max_fps / speed_of_sound_fps;
607        validate_mach_in_domain(source, low_mach, source_domain)?;
608        validate_mach_in_domain(source, high_mach, source_domain)?;
609        validate_mach_in_domain(target, low_mach, target_domain)?;
610        validate_mach_in_domain(target, high_mach, target_domain)?;
611    }
612
613    let mach_min = validated[0].velocity_min_fps / speed_of_sound_fps;
614    let mach_max = validated
615        .iter()
616        .map(|segment| segment.velocity_max_fps)
617        .max_by(f64::total_cmp)
618        .ok_or(BcConversionError::NoSegments)?
619        / speed_of_sound_fps;
620
621    Ok(ValidatedSegments {
622        segments: validated,
623        mach_min,
624        mach_max,
625    })
626}
627
628fn validate_mach_in_domain(
629    drag_model: DragModel,
630    mach: f64,
631    (mach_min, mach_max): (f64, f64),
632) -> Result<(), BcConversionError> {
633    if mach < mach_min || mach > mach_max {
634        Err(BcConversionError::MachOutsideTableDomain {
635            drag_model: drag_model.to_string(),
636            mach,
637            mach_min,
638            mach_max,
639        })
640    } else {
641        Ok(())
642    }
643}
644
645#[derive(Default)]
646struct CompensatedSum {
647    sum: f64,
648    correction: f64,
649}
650
651impl CompensatedSum {
652    fn add(&mut self, value: f64) {
653        // Neumaier summation is deterministic and remains effective when the next term is
654        // larger than the running sum (which can happen across very uneven velocity bands).
655        let next = self.sum + value;
656        if self.sum.abs() >= value.abs() {
657            self.correction += (self.sum - next) + value;
658        } else {
659            self.correction += (value - next) + self.sum;
660        }
661        self.sum = next;
662    }
663
664    fn value(&self) -> f64 {
665        self.sum + self.correction
666    }
667}
668
669#[derive(Debug, Clone, Copy)]
670struct WeightedRelativeSample {
671    weight: f64,
672    /// `candidate_cd * source_bc / source_cd`; candidate relative error is `q*z - 1`,
673    /// where `q = 1 / candidate_bc` is the sole fitted parameter.
674    z: f64,
675}
676
677struct RelativeSamples {
678    values: Vec<WeightedRelativeSample>,
679}
680
681struct RelativeFitSolution {
682    fitted_bc: f64,
683    relative_rms: f64,
684    weighted_squared_error: f64,
685    total_weight: f64,
686    integration_evaluations: usize,
687}
688
689// Eight-point Gauss-Legendre nodes and weights on [-1, 1]. The integration interval is
690// additionally split at every knot from both live drag tables, so the sharp transonic shape is
691// never averaged across a table discontinuity in interpolation slope.
692const GAUSS_NODES_8: [f64; 8] = [
693    -0.960_289_856_497_536_3,
694    -0.796_666_477_413_626_7,
695    -0.525_532_409_916_329,
696    -0.183_434_642_495_649_8,
697    0.183_434_642_495_649_8,
698    0.525_532_409_916_329,
699    0.796_666_477_413_626_7,
700    0.960_289_856_497_536_3,
701];
702const GAUSS_WEIGHTS_8: [f64; 8] = [
703    0.101_228_536_290_376_3,
704    0.222_381_034_453_374_5,
705    0.313_706_645_877_887_3,
706    0.362_683_783_378_362,
707    0.362_683_783_378_362,
708    0.313_706_645_877_887_3,
709    0.222_381_034_453_374_5,
710    0.101_228_536_290_376_3,
711];
712
713fn collect_relative_samples(
714    segment: &ValidatedSegment,
715    source: DragModel,
716    candidate: DragModel,
717    speed_of_sound_fps: f64,
718) -> Result<RelativeSamples, BcConversionError> {
719    let mut breakpoints = vec![segment.velocity_min_fps, segment.velocity_max_fps];
720    append_table_breakpoints(
721        &mut breakpoints,
722        reference_drag_table(&source),
723        speed_of_sound_fps,
724        segment.velocity_min_fps,
725        segment.velocity_max_fps,
726    );
727    if source != candidate {
728        append_table_breakpoints(
729            &mut breakpoints,
730            reference_drag_table(&candidate),
731            speed_of_sound_fps,
732            segment.velocity_min_fps,
733            segment.velocity_max_fps,
734        );
735    }
736    breakpoints.sort_by(f64::total_cmp);
737    breakpoints.dedup_by(|left, right| left.to_bits() == right.to_bits());
738
739    let mut values = Vec::with_capacity((breakpoints.len().saturating_sub(1)) * 8);
740    for interval in breakpoints.windows(2) {
741        let low = interval[0];
742        let high = interval[1];
743        if high <= low {
744            continue;
745        }
746        let midpoint = 0.5 * (low + high);
747        let half_width = 0.5 * (high - low);
748        for (&node, &base_weight) in GAUSS_NODES_8.iter().zip(&GAUSS_WEIGHTS_8) {
749            let velocity_fps = midpoint + half_width * node;
750            let mach = velocity_fps / speed_of_sound_fps;
751            let source_cd = get_drag_coefficient(mach, &source);
752            validate_computed_cd(source_cd, "band source drag coefficient")?;
753            let z = if source == candidate {
754                // Avoid Cd/Cd roundoff; this is also what makes a constant schedule's
755                // same-family fit resolve to exactly its input BC at the samples.
756                segment.bc
757            } else {
758                let candidate_cd = get_drag_coefficient(mach, &candidate);
759                validate_computed_cd(candidate_cd, "band candidate drag coefficient")?;
760                candidate_cd * segment.bc / source_cd
761            };
762            let weight = half_width * base_weight;
763            if !z.is_finite() || z <= 0.0 || !weight.is_finite() || weight <= 0.0 {
764                return Err(BcConversionError::NonFiniteResult {
765                    context: "band integration sample",
766                });
767            }
768            values.push(WeightedRelativeSample { weight, z });
769        }
770    }
771    if values.is_empty() {
772        return Err(BcConversionError::NonFiniteResult {
773            context: "band integration grid",
774        });
775    }
776    Ok(RelativeSamples { values })
777}
778
779fn append_table_breakpoints(
780    breakpoints: &mut Vec<f64>,
781    table: &DragTable,
782    speed_of_sound_fps: f64,
783    velocity_min_fps: f64,
784    velocity_max_fps: f64,
785) {
786    for &mach in &table.mach_values {
787        let velocity_fps = mach * speed_of_sound_fps;
788        if velocity_fps > velocity_min_fps && velocity_fps < velocity_max_fps {
789            breakpoints.push(velocity_fps);
790        }
791    }
792}
793
794fn solve_relative_fit(samples: &RelativeSamples) -> Result<RelativeFitSolution, BcConversionError> {
795    let mut weighted_z = CompensatedSum::default();
796    let mut weighted_z_squared = CompensatedSum::default();
797    let mut total_weight = CompensatedSum::default();
798    for sample in &samples.values {
799        weighted_z.add(sample.weight * sample.z);
800        weighted_z_squared.add(sample.weight * sample.z * sample.z);
801        total_weight.add(sample.weight);
802    }
803    let numerator = weighted_z.value();
804    let denominator = weighted_z_squared.value();
805    let total_weight = total_weight.value();
806    if !numerator.is_finite()
807        || !denominator.is_finite()
808        || !total_weight.is_finite()
809        || numerator <= 0.0
810        || denominator <= 0.0
811        || total_weight <= 0.0
812    {
813        return Err(BcConversionError::NonFiniteResult {
814            context: "least-squares moments",
815        });
816    }
817
818    let reciprocal_bc = numerator / denominator;
819    let fitted_bc = 1.0 / reciprocal_bc;
820    if !reciprocal_bc.is_finite()
821        || reciprocal_bc <= 0.0
822        || !fitted_bc.is_finite()
823        || fitted_bc <= 0.0
824    {
825        return Err(BcConversionError::NonFiniteResult {
826            context: "least-squares fitted BC",
827        });
828    }
829
830    // Compute the residual directly at the same quadrature nodes rather than subtracting two
831    // nearly equal moment products. This keeps exact-family fits near machine epsilon instead of
832    // magnifying cancellation into a misleading ~1e-8 RMS score.
833    let mut squared_error = CompensatedSum::default();
834    for sample in &samples.values {
835        let relative_error = reciprocal_bc * sample.z - 1.0;
836        squared_error.add(sample.weight * relative_error * relative_error);
837    }
838    let weighted_squared_error = squared_error.value();
839    let relative_rms = normalized_rms(weighted_squared_error, total_weight)?;
840
841    Ok(RelativeFitSolution {
842        fitted_bc,
843        relative_rms,
844        weighted_squared_error,
845        total_weight,
846        integration_evaluations: samples.values.len(),
847    })
848}
849
850fn normalized_rms(squared_error: f64, weight: f64) -> Result<f64, BcConversionError> {
851    if !squared_error.is_finite() || squared_error < 0.0 || !weight.is_finite() || weight <= 0.0 {
852        return Err(BcConversionError::NonFiniteResult {
853            context: "normalized relative RMS residual",
854        });
855    }
856    let rms = (squared_error / weight).sqrt();
857    if rms.is_finite() {
858        Ok(rms)
859    } else {
860        Err(BcConversionError::NonFiniteResult {
861            context: "normalized relative RMS residual",
862        })
863    }
864}
865
866fn fit_validated_segments(
867    segments: &[ValidatedSegment],
868    source: DragModel,
869    candidate: DragModel,
870    speed_of_sound_fps: f64,
871) -> Result<BcFamilyFitV1, BcConversionError> {
872    let mut all_samples = RelativeSamples { values: Vec::new() };
873    for segment in segments {
874        all_samples.values.extend(
875            collect_relative_samples(segment, source, candidate, speed_of_sound_fps)?.values,
876        );
877    }
878    let solution = solve_relative_fit(&all_samples)?;
879    Ok(BcFamilyFitV1 {
880        candidate_drag_model: candidate.to_string(),
881        fitted_bc: solution.fitted_bc,
882        relative_rms: solution.relative_rms,
883        integration_evaluations: solution.integration_evaluations,
884    })
885}
886
887fn format_report_table(report: &BcConversionReportV1) -> String {
888    let mut output = String::new();
889    match report {
890        BcConversionReportV1::Scalar { result } => {
891            let _ = writeln!(output, "BC drag-family conversion");
892            let _ = writeln!(
893                output,
894                "source  target  mach      velocity_fps  source_bc  source_cd  target_cd  ratio      target_bc"
895            );
896            let velocity = result
897                .velocity_fps
898                .map(|value| format!("{value:.3}"))
899                .unwrap_or_else(|| "-".to_string());
900            let _ = writeln!(
901                output,
902                "{:<6}  {:<6}  {:>8.5}  {:>12}  {:>9.6}  {:>9.6}  {:>9.6}  {:>9.6}  {:>9.6}",
903                result.source_drag_model,
904                result.target_drag_model,
905                result.mach,
906                velocity,
907                result.source_bc,
908                result.source_cd,
909                result.target_cd,
910                result.conversion_ratio,
911                result.target_bc,
912            );
913        }
914        BcConversionReportV1::Banded { result } => {
915            let conversion = &result.conversion;
916            let recommendation = &result.recommendation;
917            let _ = writeln!(
918                output,
919                "BC band conversion {} -> {} (speed_of_sound_fps={:.3}, Mach {:.5}..{:.5})",
920                conversion.source_drag_model,
921                conversion.target_drag_model,
922                conversion.speed_of_sound_fps,
923                conversion.mach_min,
924                conversion.mach_max,
925            );
926            let _ = writeln!(
927                output,
928                "velocity_min_fps  velocity_max_fps  source_bc  target_bc  relative_rms"
929            );
930            for segment in &conversion.segments {
931                let _ = writeln!(
932                    output,
933                    "{:>16.3}  {:>16.3}  {:>9.6}  {:>9.6}  {:>12.8}",
934                    segment.velocity_min_fps,
935                    segment.velocity_max_fps,
936                    segment.source_bc,
937                    segment.target_bc,
938                    segment.relative_rms,
939                );
940            }
941            let _ = writeln!(
942                output,
943                "combined_relative_rms={:.8}",
944                conversion.relative_rms
945            );
946            append_recommendation_table(&mut output, recommendation);
947        }
948        BcConversionReportV1::Recommendation { result } => {
949            append_recommendation_table(&mut output, result);
950        }
951    }
952    output
953}
954
955fn append_recommendation_table(output: &mut String, recommendation: &BcFamilyRecommendationV1) {
956    let _ = writeln!(
957        output,
958        "Family recommendation (source={}, speed_of_sound_fps={:.3}, Mach {:.5}..{:.5})",
959        recommendation.source_drag_model,
960        recommendation.speed_of_sound_fps,
961        recommendation.mach_min,
962        recommendation.mach_max,
963    );
964    let _ = writeln!(output, "candidate  fitted_bc  relative_rms  recommended");
965    for fit in &recommendation.fits {
966        let selected = fit.candidate_drag_model == recommendation.recommended.candidate_drag_model
967            && fit.fitted_bc.to_bits() == recommendation.recommended.fitted_bc.to_bits()
968            && fit.relative_rms.to_bits() == recommendation.recommended.relative_rms.to_bits();
969        let _ = writeln!(
970            output,
971            "{:<9}  {:>9.6}  {:>12.8}  {}",
972            fit.candidate_drag_model,
973            fit.fitted_bc,
974            fit.relative_rms,
975            if selected { "yes" } else { "no" },
976        );
977    }
978}
979
980fn format_report_csv(report: &BcConversionReportV1) -> String {
981    let mut output = String::new();
982    match report {
983        BcConversionReportV1::Scalar { result } => {
984            let _ = writeln!(
985                output,
986                "schema_version,source_drag_model,target_drag_model,mach,velocity_fps,source_bc,source_cd,target_cd,conversion_ratio,target_bc"
987            );
988            let velocity = result
989                .velocity_fps
990                .map(|value| format!("{value:.9}"))
991                .unwrap_or_default();
992            let _ = writeln!(
993                output,
994                "{},{},{},{:.9},{},{:.9},{:.9},{:.9},{:.9},{:.9}",
995                result.schema_version,
996                result.source_drag_model,
997                result.target_drag_model,
998                result.mach,
999                velocity,
1000                result.source_bc,
1001                result.source_cd,
1002                result.target_cd,
1003                result.conversion_ratio,
1004                result.target_bc,
1005            );
1006        }
1007        BcConversionReportV1::Banded { result } => {
1008            let conversion = &result.conversion;
1009            let recommendation = &result.recommendation;
1010            let _ = writeln!(
1011                output,
1012                "record_type,schema_version,source_drag_model,target_drag_model,speed_of_sound_fps,mach_min,mach_max,velocity_min_fps,velocity_max_fps,source_bc,target_bc,candidate_drag_model,fitted_bc,relative_rms,recommended,integration_evaluations"
1013            );
1014            for segment in &conversion.segments {
1015                let _ = writeln!(
1016                    output,
1017                    "segment,{},{},{},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9},{:.9},,,{:.12},,",
1018                    conversion.schema_version,
1019                    conversion.source_drag_model,
1020                    conversion.target_drag_model,
1021                    conversion.speed_of_sound_fps,
1022                    conversion.mach_min,
1023                    conversion.mach_max,
1024                    segment.velocity_min_fps,
1025                    segment.velocity_max_fps,
1026                    segment.source_bc,
1027                    segment.target_bc,
1028                    segment.relative_rms,
1029                );
1030            }
1031            let summary = [
1032                "conversion_summary".to_string(),
1033                conversion.schema_version.to_string(),
1034                conversion.source_drag_model.clone(),
1035                conversion.target_drag_model.clone(),
1036                format!("{:.9}", conversion.speed_of_sound_fps),
1037                format!("{:.9}", conversion.mach_min),
1038                format!("{:.9}", conversion.mach_max),
1039                String::new(),
1040                String::new(),
1041                String::new(),
1042                String::new(),
1043                String::new(),
1044                String::new(),
1045                format!("{:.12}", conversion.relative_rms),
1046                String::new(),
1047                conversion.integration_evaluations.to_string(),
1048            ];
1049            let _ = writeln!(output, "{}", summary.join(","));
1050            for fit in &recommendation.fits {
1051                let selected = fit.candidate_drag_model
1052                    == recommendation.recommended.candidate_drag_model
1053                    && fit.fitted_bc.to_bits() == recommendation.recommended.fitted_bc.to_bits()
1054                    && fit.relative_rms.to_bits()
1055                        == recommendation.recommended.relative_rms.to_bits();
1056                let _ = writeln!(
1057                    output,
1058                    "family_fit,{},{},,{:.9},{:.9},{:.9},,,,,{},{:.9},{:.12},{},{}",
1059                    recommendation.schema_version,
1060                    recommendation.source_drag_model,
1061                    recommendation.speed_of_sound_fps,
1062                    recommendation.mach_min,
1063                    recommendation.mach_max,
1064                    fit.candidate_drag_model,
1065                    fit.fitted_bc,
1066                    fit.relative_rms,
1067                    selected,
1068                    fit.integration_evaluations,
1069                );
1070            }
1071        }
1072        BcConversionReportV1::Recommendation { result } => {
1073            let _ = writeln!(
1074                output,
1075                "schema_version,source_drag_model,speed_of_sound_fps,mach_min,mach_max,candidate_drag_model,fitted_bc,relative_rms,recommended,integration_evaluations"
1076            );
1077            for fit in &result.fits {
1078                let selected = fit.candidate_drag_model == result.recommended.candidate_drag_model
1079                    && fit.fitted_bc.to_bits() == result.recommended.fitted_bc.to_bits()
1080                    && fit.relative_rms.to_bits() == result.recommended.relative_rms.to_bits();
1081                let _ = writeln!(
1082                    output,
1083                    "{},{},{:.9},{:.9},{:.9},{},{:.9},{:.12},{},{}",
1084                    result.schema_version,
1085                    result.source_drag_model,
1086                    result.speed_of_sound_fps,
1087                    result.mach_min,
1088                    result.mach_max,
1089                    fit.candidate_drag_model,
1090                    fit.fitted_bc,
1091                    fit.relative_rms,
1092                    selected,
1093                    fit.integration_evaluations,
1094                );
1095            }
1096        }
1097    }
1098    output
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104
1105    const SOS_FPS: f64 = 1_100.0;
1106
1107    fn segment(min: f64, max: f64, bc: f64) -> BCSegmentData {
1108        BCSegmentData {
1109            velocity_min: min,
1110            velocity_max: max,
1111            bc_value: bc,
1112        }
1113    }
1114
1115    fn constant_schedule(bc: f64) -> Vec<BCSegmentData> {
1116        vec![
1117            segment(660.0, 1_100.0, bc),
1118            segment(1_100.0, 1_650.0, bc),
1119            segment(1_650.0, 2_200.0, bc),
1120            segment(2_200.0, 2_750.0, bc),
1121            segment(2_750.0, 3_300.0, bc),
1122        ]
1123    }
1124
1125    #[test]
1126    fn scalar_conversion_preserves_reference_retardation() {
1127        let converted =
1128            convert_bc_at_mach(0.500, DragModel::G1, DragModel::G7, 2.0).expect("convert");
1129        let source_retardation = converted.source_cd / converted.source_bc;
1130        let target_retardation = converted.target_cd / converted.target_bc;
1131        assert!(
1132            (source_retardation - target_retardation).abs() <= 2.0 * f64::EPSILON,
1133            "{source_retardation} != {target_retardation}"
1134        );
1135        assert!((converted.target_bc - 0.251_095_382_541).abs() < 1e-12);
1136    }
1137
1138    #[test]
1139    fn jbm_g1_0492_at_3000_fps_converts_to_g7_0242() {
1140        // Widely documented JBM conversion example under standard-atmosphere speed of sound.
1141        // Pinning the unrounded engine value catches both a ratio inversion and table drift;
1142        // the public-facing three-decimal answer is G7 0.242.
1143        let converted = convert_bc_at_velocity(
1144            0.492,
1145            DragModel::G1,
1146            DragModel::G7,
1147            3_000.0,
1148            crate::constants::SPEED_OF_SOUND_MPS / crate::constants::FPS_TO_MPS,
1149        )
1150        .expect("JBM example");
1151        assert!(
1152            (converted.target_bc - 0.242_195_467_3).abs() < 5e-10,
1153            "got {} at Mach {}",
1154            converted.target_bc,
1155            converted.mach
1156        );
1157        assert_eq!((converted.target_bc * 1_000.0).round() / 1_000.0, 0.242);
1158    }
1159
1160    #[test]
1161    fn scalar_round_trip_recovers_input_at_the_same_mach() {
1162        for mach in [0.5, 0.95, 1.0, 1.25, 2.0, 3.0, 5.0] {
1163            let forward =
1164                convert_bc_at_mach(0.537, DragModel::G1, DragModel::G7, mach).expect("G1 to G7");
1165            let backward =
1166                convert_bc_at_mach(forward.target_bc, DragModel::G7, DragModel::G1, mach)
1167                    .expect("G7 to G1");
1168            assert!(
1169                (backward.target_bc - 0.537).abs() < 2e-15,
1170                "Mach {mach}: {}",
1171                backward.target_bc
1172            );
1173        }
1174    }
1175
1176    #[test]
1177    fn conversion_ratio_is_mach_dependent_not_the_cluster_shortcut() {
1178        let transonic =
1179            convert_bc_at_mach(1.0, DragModel::G1, DragModel::G7, 1.0).expect("transonic");
1180        let supersonic =
1181            convert_bc_at_mach(1.0, DragModel::G1, DragModel::G7, 2.0).expect("supersonic");
1182        assert!((transonic.conversion_ratio - supersonic.conversion_ratio).abs() > 0.25);
1183        assert!((transonic.conversion_ratio - 1.0 / 1.98).abs() > 0.25);
1184    }
1185
1186    #[test]
1187    fn scalar_and_banded_identity_preserve_bc_bits() {
1188        let source_bc = f64::from_bits(0x3f_df_7c_ed_91_68_72_b0);
1189        let scalar = convert_bc_at_mach(source_bc, DragModel::G7, DragModel::G7, 2.1)
1190            .expect("scalar identity");
1191        assert_eq!(scalar.target_bc.to_bits(), source_bc.to_bits());
1192        assert_eq!(scalar.conversion_ratio.to_bits(), 1.0f64.to_bits());
1193
1194        let bands = vec![
1195            segment(2_000.0, 2_400.0, source_bc),
1196            segment(1_500.0, 2_000.0, 0.211_111_111_111_111_1),
1197        ];
1198        let converted = convert_bc_segments(&bands, DragModel::G7, DragModel::G7, SOS_FPS)
1199            .expect("band identity");
1200        for (actual, expected) in converted.segments.iter().zip(&bands) {
1201            assert_eq!(actual.target_bc.to_bits(), expected.bc_value.to_bits());
1202            assert_eq!(actual.relative_rms.to_bits(), 0.0f64.to_bits());
1203        }
1204        assert_eq!(converted.relative_rms.to_bits(), 0.0f64.to_bits());
1205    }
1206
1207    #[test]
1208    fn conversion_rejects_endpoint_clamping_and_invalid_values() {
1209        assert!(matches!(
1210            convert_bc_at_mach(0.5, DragModel::G1, DragModel::G7, 5.000_001),
1211            Err(BcConversionError::MachOutsideTableDomain { .. })
1212        ));
1213        assert!(matches!(
1214            convert_bc_at_mach(0.5, DragModel::G1, DragModel::G7, -0.1),
1215            Err(BcConversionError::InvalidMach { .. })
1216        ));
1217        assert!(matches!(
1218            convert_bc_at_mach(f64::NAN, DragModel::G1, DragModel::G7, 2.0),
1219            Err(BcConversionError::InvalidPositiveValue {
1220                field: "source_bc",
1221                ..
1222            })
1223        ));
1224        assert!(matches!(
1225            convert_bc_at_velocity(0.5, DragModel::G1, DragModel::G7, 2_500.0, f64::INFINITY),
1226            Err(BcConversionError::InvalidPositiveValue {
1227                field: "speed_of_sound_fps",
1228                ..
1229            })
1230        ));
1231        assert!(matches!(
1232            convert_bc_segments(
1233                &[segment(0.0, f64::INFINITY, 0.5)],
1234                DragModel::G1,
1235                DragModel::G7,
1236                SOS_FPS,
1237            ),
1238            Err(BcConversionError::InvalidSegmentBounds { .. })
1239        ));
1240    }
1241
1242    #[test]
1243    fn sparse_and_placeholder_tables_are_refused() {
1244        for points in [2usize, 6] {
1245            let table = DragTable::new(
1246                (0..points).map(|index| index as f64).collect(),
1247                vec![0.2; points],
1248            );
1249            let error = validate_drag_table_shape(DragModel::G1, &table)
1250                .expect_err("sparse table must be refused");
1251            assert!(matches!(error, BcConversionError::InvalidDragTable { .. }));
1252            assert!(error.to_string().contains("placeholder"));
1253        }
1254
1255        let minimum_valid = DragTable::new(
1256            (0..MIN_BC_CONVERSION_TABLE_POINTS)
1257                .map(|index| index as f64)
1258                .collect(),
1259            vec![0.2; MIN_BC_CONVERSION_TABLE_POINTS],
1260        );
1261        validate_drag_table_shape(DragModel::G1, &minimum_valid).expect("seven rows are enough");
1262    }
1263
1264    #[test]
1265    fn unsorted_bands_preserve_output_order_and_overlaps_are_rejected() {
1266        let unsorted = vec![
1267            segment(2_200.0, 2_800.0, 0.51),
1268            segment(700.0, 1_200.0, 0.43),
1269            segment(1_400.0, 2_000.0, 0.47),
1270        ];
1271        let converted = convert_bc_segments(&unsorted, DragModel::G1, DragModel::G7, SOS_FPS)
1272            .expect("unsorted input");
1273        for (actual, expected) in converted.segments.iter().zip(&unsorted) {
1274            assert_eq!(
1275                actual.velocity_min_fps.to_bits(),
1276                expected.velocity_min.to_bits()
1277            );
1278            assert_eq!(
1279                actual.velocity_max_fps.to_bits(),
1280                expected.velocity_max.to_bits()
1281            );
1282            assert_eq!(actual.source_bc.to_bits(), expected.bc_value.to_bits());
1283        }
1284
1285        let overlap = vec![
1286            segment(1_000.0, 2_000.0, 0.5),
1287            segment(1_900.0, 2_500.0, 0.5),
1288        ];
1289        assert!(matches!(
1290            convert_bc_segments(&overlap, DragModel::G1, DragModel::G7, SOS_FPS),
1291            Err(BcConversionError::OverlappingSegments { .. })
1292        ));
1293    }
1294
1295    #[test]
1296    fn band_split_and_permutation_leave_fit_nearly_unchanged() {
1297        let one = vec![segment(660.0, 3_300.0, 0.5)];
1298        let split = vec![
1299            segment(660.0, 1_430.0, 0.5),
1300            segment(1_430.0, 2_310.0, 0.5),
1301            segment(2_310.0, 3_300.0, 0.5),
1302        ];
1303        let permuted = vec![split[2].clone(), split[0].clone(), split[1].clone()];
1304        let fit_one = fit_bc_family(&one, DragModel::G1, DragModel::G7, SOS_FPS).expect("one");
1305        let fit_split =
1306            fit_bc_family(&split, DragModel::G1, DragModel::G7, SOS_FPS).expect("split");
1307        let fit_permuted =
1308            fit_bc_family(&permuted, DragModel::G1, DragModel::G7, SOS_FPS).expect("permuted");
1309        assert!((fit_one.fitted_bc - fit_split.fitted_bc).abs() < 1e-11);
1310        assert!((fit_one.relative_rms - fit_split.relative_rms).abs() < 1e-11);
1311        assert_eq!(
1312            fit_split.fitted_bc.to_bits(),
1313            fit_permuted.fitted_bc.to_bits()
1314        );
1315        assert_eq!(
1316            fit_split.relative_rms.to_bits(),
1317            fit_permuted.relative_rms.to_bits()
1318        );
1319    }
1320
1321    #[test]
1322    fn constant_g1_schedule_recommends_g1() {
1323        let recommendation = recommend_bc_family(
1324            &constant_schedule(0.5),
1325            DragModel::G1,
1326            &[DragModel::G1, DragModel::G7],
1327            SOS_FPS,
1328        )
1329        .expect("recommend");
1330        assert_eq!(recommendation.recommended.candidate_drag_model, "G1");
1331        assert!((recommendation.recommended.fitted_bc - 0.5).abs() < 1e-15);
1332        assert!(recommendation.recommended.relative_rms < 1e-15);
1333        assert!(recommendation.fits[1].relative_rms > 0.05);
1334    }
1335
1336    #[test]
1337    fn g1_bands_synthesized_from_constant_g7_recommend_g7() {
1338        let g7_bc = 0.25;
1339        let mut g1_bands = Vec::new();
1340        let mut low_mach = 0.6;
1341        while low_mach < 3.0 {
1342            let high_mach = low_mach + 0.1;
1343            let midpoint = 0.5 * (low_mach + high_mach);
1344            let as_g1 = convert_bc_at_mach(g7_bc, DragModel::G7, DragModel::G1, midpoint)
1345                .expect("synthetic G1 point");
1346            g1_bands.push(segment(
1347                low_mach * SOS_FPS,
1348                high_mach * SOS_FPS,
1349                as_g1.target_bc,
1350            ));
1351            low_mach = high_mach;
1352        }
1353        let recommendation = recommend_bc_family(
1354            &g1_bands,
1355            DragModel::G1,
1356            &[DragModel::G1, DragModel::G7],
1357            SOS_FPS,
1358        )
1359        .expect("recommend");
1360        assert_eq!(recommendation.recommended.candidate_drag_model, "G7");
1361        assert!(
1362            (recommendation.recommended.fitted_bc - g7_bc).abs() < 0.003,
1363            "fitted {}",
1364            recommendation.recommended.fitted_bc
1365        );
1366        assert!(recommendation.recommended.relative_rms < recommendation.fits[1].relative_rms);
1367    }
1368
1369    #[test]
1370    fn recommendation_is_scale_invariant() {
1371        let original = constant_schedule(0.5);
1372        let scaled: Vec<_> = original
1373            .iter()
1374            .map(|band| segment(band.velocity_min, band.velocity_max, band.bc_value * 2.5))
1375            .collect();
1376        let candidates = [DragModel::G1, DragModel::G7];
1377        let first =
1378            recommend_bc_family(&original, DragModel::G1, &candidates, SOS_FPS).expect("original");
1379        let second =
1380            recommend_bc_family(&scaled, DragModel::G1, &candidates, SOS_FPS).expect("scaled");
1381        assert_eq!(
1382            first.recommended.candidate_drag_model,
1383            second.recommended.candidate_drag_model
1384        );
1385        for (left, right) in first.fits.iter().zip(&second.fits) {
1386            assert_eq!(left.candidate_drag_model, right.candidate_drag_model);
1387            assert!((right.fitted_bc / left.fitted_bc - 2.5).abs() < 2e-14);
1388            assert!((left.relative_rms - right.relative_rms).abs() < 2e-15);
1389        }
1390    }
1391
1392    #[test]
1393    fn combined_formatters_are_complete_newline_terminated_documents() {
1394        let analysis = analyze_bc_segments(
1395            &constant_schedule(0.5),
1396            DragModel::G1,
1397            DragModel::G7,
1398            &[DragModel::G1, DragModel::G7],
1399            SOS_FPS,
1400        )
1401        .expect("analysis");
1402        let report = BcConversionReportV1::Banded { result: analysis };
1403
1404        let json = format_bc_conversion_report(&report, BcConversionFormat::Json).expect("json");
1405        assert!(json.starts_with('{') && json.ends_with("\n"));
1406        let document: serde_json::Value = serde_json::from_str(&json).expect("pure JSON");
1407        assert_eq!(document["mode"], "banded");
1408        assert_eq!(
1409            document["result"]["conversion"]["schema_version"],
1410            BC_CONVERSION_SCHEMA_VERSION_V1
1411        );
1412        assert_eq!(
1413            document["result"]["recommendation"]["schema_version"],
1414            BC_CONVERSION_SCHEMA_VERSION_V1
1415        );
1416
1417        let table = format_bc_conversion_report(&report, BcConversionFormat::Table).expect("table");
1418        assert!(table.ends_with('\n'));
1419        assert!(table.contains("velocity_min_fps"));
1420        assert!(table.contains("Family recommendation"));
1421
1422        let csv = format_bc_conversion_report(&report, BcConversionFormat::Csv).expect("csv");
1423        assert!(csv.ends_with('\n'));
1424        assert!(csv.starts_with("record_type,schema_version,source_drag_model"));
1425        assert!(csv.contains("segment,1,G1,G7"));
1426        assert!(csv.contains("conversion_summary,1,G1,G7"));
1427        assert!(csv.contains("family_fit,1,G1"));
1428        for row in csv.lines() {
1429            assert_eq!(
1430                row.split(',').count(),
1431                16,
1432                "banded CSV row does not match its header: {row}"
1433            );
1434        }
1435        let summary = csv
1436            .lines()
1437            .find(|row| row.starts_with("conversion_summary,"))
1438            .expect("conversion summary row");
1439        let summary_fields: Vec<_> = summary.split(',').collect();
1440        let BcConversionReportV1::Banded { result } = &report else {
1441            unreachable!("test report is banded")
1442        };
1443        assert_eq!(
1444            summary_fields[13],
1445            format!("{:.12}", result.conversion.relative_rms)
1446        );
1447        assert!(!summary_fields[15].is_empty());
1448    }
1449
1450    #[test]
1451    fn candidate_validation_rejects_empty_and_duplicate_sets() {
1452        let schedule = constant_schedule(0.5);
1453        assert!(matches!(
1454            recommend_bc_family(&schedule, DragModel::G1, &[], SOS_FPS),
1455            Err(BcConversionError::NoCandidates)
1456        ));
1457        assert!(matches!(
1458            recommend_bc_family(
1459                &schedule,
1460                DragModel::G1,
1461                &[DragModel::G7, DragModel::G7],
1462                SOS_FPS,
1463            ),
1464            Err(BcConversionError::DuplicateCandidate { .. })
1465        ));
1466    }
1467}