1use crate::drag::{get_drag_coefficient, reference_drag_table, DragTable};
9use crate::{BCSegmentData, DragModel};
10use serde::{Deserialize, Serialize};
11use std::fmt::Write as _;
12
13pub const BC_CONVERSION_SCHEMA_VERSION_V1: u32 = 1;
15
16pub const MIN_BC_CONVERSION_TABLE_POINTS: usize = 7;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum BcConversionFormat {
27 Table,
28 Csv,
29 Json,
30}
31
32#[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 pub source_bc: f64,
40 pub target_bc: f64,
42 pub mach: f64,
43 #[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 pub conversion_ratio: f64,
50}
51
52#[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 pub relative_rms: f64,
61}
62
63#[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 pub segments: Vec<BcSegmentConversionV1>,
74 pub relative_rms: f64,
76 pub integration_evaluations: usize,
77}
78
79impl BandedBcConversionV1 {
80 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95pub struct BcFamilyFitV1 {
96 pub candidate_drag_model: String,
97 pub fitted_bc: f64,
98 pub relative_rms: f64,
100 pub integration_evaluations: usize,
101}
102
103#[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 pub fits: Vec<BcFamilyFitV1>,
113 pub recommended: BcFamilyFitV1,
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct BcSegmentAnalysisV1 {
119 pub conversion: BandedBcConversionV1,
120 pub recommendation: BcFamilyRecommendationV1,
121}
122
123pub type BcBandedAnalysisV1 = BcSegmentAnalysisV1;
125
126#[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#[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
190pub 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
200pub 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
219pub 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 (
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
290pub 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
301pub 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 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 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
351pub 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
365pub 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 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 original_index: usize,
530 velocity_min_fps: f64,
531 velocity_max_fps: f64,
532 bc: f64,
533}
534
535struct ValidatedSegments {
536 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 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 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
689const 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 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 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 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}