1use crate::BCSegmentData;
2
3pub(crate) fn velocity_segment_bc(
19 velocity_fps: f64,
20 segments: &[BCSegmentData],
21 fallback_bc: f64,
22) -> f64 {
23 if segments.len() < 2 {
24 return raw_velocity_segment_bc(velocity_fps, segments, fallback_bc);
27 }
28
29 for (boundary, margin) in transition_boundaries(segments) {
30 if margin <= 0.0 {
31 continue;
33 }
34 let half = margin / 2.0;
35 let lo_v = boundary - half;
36 let hi_v = boundary + half;
37 if velocity_fps < lo_v || velocity_fps > hi_v {
38 continue;
39 }
40
41 let lo = raw_velocity_segment_bc(lo_v, segments, fallback_bc);
42 let hi = raw_velocity_segment_bc(hi_v, segments, fallback_bc);
43 if lo == hi {
44 return lo;
47 }
48
49 let t = ((velocity_fps - lo_v) / margin).clamp(0.0, 1.0);
50 return lo + smoothstep(t) * (hi - lo);
51 }
52
53 raw_velocity_segment_bc(velocity_fps, segments, fallback_bc)
54}
55
56fn raw_velocity_segment_bc(velocity_fps: f64, segments: &[BCSegmentData], fallback_bc: f64) -> f64 {
59 if let Some(segment) = segments.iter().find(|segment| {
60 velocity_fps >= segment.velocity_min && velocity_fps < segment.velocity_max
61 }) {
62 return segment.bc_value;
63 }
64
65 let lowest = segments
66 .iter()
67 .min_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
68 if let Some(segment) = lowest {
69 if velocity_fps < segment.velocity_min {
70 return segment.bc_value;
71 }
72 }
73
74 let highest = segments
75 .iter()
76 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max));
77 if let Some(segment) = highest {
78 if velocity_fps >= segment.velocity_max {
79 return segment.bc_value;
80 }
81 }
82
83 fallback_bc
84}
85
86fn transition_boundaries(segments: &[BCSegmentData]) -> Vec<(f64, f64)> {
95 let width = |s: &BCSegmentData| (s.velocity_max - s.velocity_min).max(0.0);
96
97 let mut sorted: Vec<&BCSegmentData> = segments.iter().collect();
98 sorted.sort_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
99
100 let mut boundaries = Vec::with_capacity(sorted.len() * 2 + 2);
101
102 if let Some(first) = sorted.first() {
105 boundaries.push((first.velocity_min, smoothing_margin(width(first))));
106 }
107 if let Some(highest) = segments
111 .iter()
112 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max))
113 {
114 boundaries.push((highest.velocity_max, smoothing_margin(width(highest))));
115 }
116
117 for pair in sorted.windows(2) {
118 let (left, right) = (pair[0], pair[1]);
119 if right.velocity_min > left.velocity_max {
120 let gap_width = right.velocity_min - left.velocity_max;
124 boundaries.push((
125 left.velocity_max,
126 smoothing_margin(width(left).min(gap_width)),
127 ));
128 boundaries.push((
129 right.velocity_min,
130 smoothing_margin(gap_width.min(width(right))),
131 ));
132 } else if right.velocity_min == left.velocity_max {
133 boundaries.push((
135 left.velocity_max,
136 smoothing_margin(width(left).min(width(right))),
137 ));
138 }
139 }
143
144 boundaries
145}
146
147fn smoothing_margin(narrower_adjacent_width: f64) -> f64 {
150 if narrower_adjacent_width <= 0.0 {
151 return 0.0;
152 }
153 (0.25 * narrower_adjacent_width).min(50.0)
154}
155
156fn smoothstep(t: f64) -> f64 {
158 let t = t.clamp(0.0, 1.0);
159 t * t * (3.0 - 2.0 * t)
160}
161
162#[derive(Debug, Clone, Copy, PartialEq)]
164pub enum BulletType {
165 MatchBoatTail,
166 MatchFlatBase,
167 HuntingBoatTail,
168 HuntingFlatBase,
169 VldHighBc,
170 Hybrid,
171 FMJ,
172 RoundNose,
173 Unknown,
174}
175
176pub struct BulletTypeFactors {
178 pub drop: f64,
179 pub transition_curve: f64,
180}
181
182impl BulletType {
183 pub fn get_factors(&self) -> BulletTypeFactors {
185 match self {
186 BulletType::MatchBoatTail => BulletTypeFactors {
187 drop: 0.075, transition_curve: 0.3,
189 },
190 BulletType::MatchFlatBase => BulletTypeFactors {
191 drop: 0.10, transition_curve: 0.35,
193 },
194 BulletType::HuntingBoatTail => BulletTypeFactors {
195 drop: 0.15, transition_curve: 0.45,
197 },
198 BulletType::HuntingFlatBase => BulletTypeFactors {
199 drop: 0.20, transition_curve: 0.5,
201 },
202 BulletType::VldHighBc => BulletTypeFactors {
203 drop: 0.05, transition_curve: 0.25,
205 },
206 BulletType::Hybrid => BulletTypeFactors {
207 drop: 0.06, transition_curve: 0.28,
209 },
210 BulletType::FMJ => BulletTypeFactors {
211 drop: 0.12, transition_curve: 0.4,
213 },
214 BulletType::RoundNose => BulletTypeFactors {
215 drop: 0.35, transition_curve: 0.7,
217 },
218 BulletType::Unknown => BulletTypeFactors {
219 drop: 0.15, transition_curve: 0.5,
221 },
222 }
223 }
224}
225
226pub struct BCSegmentEstimator;
228
229impl BCSegmentEstimator {
230 pub fn identify_bullet_type(
236 model: &str,
237 weight: f64,
238 caliber: f64,
239 bc_value: Option<f64>,
240 ) -> BulletType {
241 Self::identify_bullet_type_for_drag_model(model, weight, caliber, bc_value, "G1")
242 }
243
244 pub fn identify_bullet_type_for_drag_model(
246 model: &str,
247 weight: f64,
248 caliber: f64,
249 bc_value: Option<f64>,
250 drag_model: &str,
251 ) -> BulletType {
252 let model_lower = model.to_lowercase();
253
254 if model_lower.contains("vld")
256 || model_lower.contains("berger")
257 || model_lower.contains("hybrid")
258 || model_lower.contains("elite")
259 {
260 if model_lower.contains("hybrid") {
261 return BulletType::Hybrid;
262 }
263 return BulletType::VldHighBc;
264 }
265
266 if model_lower.contains("smk")
268 || model_lower.contains("matchking")
269 || model_lower.contains("match")
270 || model_lower.contains("bthp")
271 || model_lower.contains("competition")
272 || model_lower.contains("target")
273 || model_lower.contains("a-max")
274 || model_lower.contains("eld-m")
275 || model_lower.contains("scenar")
276 || model_lower.contains("x-ring")
277 {
278 if model_lower.contains("bt") || model_lower.contains("boat") {
280 return BulletType::MatchBoatTail;
281 }
282 if let Some(bc) = bc_value {
285 let sd = Self::calculate_sectional_density(weight, caliber);
286 if sd > 0.0 && Self::classification_bc_sd_ratio(bc, sd, drag_model) > 1.6 {
287 return BulletType::MatchBoatTail;
288 }
289 }
290 return BulletType::MatchFlatBase;
291 }
292
293 if model_lower.contains("gameking")
295 || model_lower.contains("hunting")
296 || model_lower.contains("sst")
297 || model_lower.contains("eld-x")
298 || model_lower.contains("partition")
299 || model_lower.contains("accubond")
300 || model_lower.contains("core-lokt")
301 || model_lower.contains("ballistic tip")
302 || model_lower.contains("v-max")
303 || model_lower.contains("hornady sp")
304 || model_lower.contains("interlock")
305 || model_lower.contains("tsx")
306 {
307 if model_lower.contains("bt")
309 || model_lower.contains("boat")
310 || model_lower.contains("sst")
311 || model_lower.contains("accubond")
312 {
313 return BulletType::HuntingBoatTail;
314 }
315 return BulletType::HuntingFlatBase;
316 }
317
318 if model_lower.contains("fmj")
320 || model_lower.contains("ball")
321 || model_lower.contains("m80")
322 || model_lower.contains("m855")
323 || model_lower.contains("tracer")
324 {
325 return BulletType::FMJ;
326 }
327
328 if model_lower.contains("rn")
330 || model_lower.contains("round nose")
331 || model_lower.contains("rnsp")
332 {
333 return BulletType::RoundNose;
334 }
335
336 if let Some(bc) = bc_value {
340 let sd = Self::calculate_sectional_density(weight, caliber);
341 if sd > 0.0 {
342 let bc_to_sd_ratio = Self::classification_bc_sd_ratio(bc, sd, drag_model);
343
344 if bc_to_sd_ratio > 1.8 {
345 return BulletType::VldHighBc;
346 } else if bc_to_sd_ratio > 1.5 {
347 return BulletType::MatchBoatTail;
348 } else if bc_to_sd_ratio < 1.2 {
349 return BulletType::HuntingFlatBase;
350 }
351 }
352 }
353
354 BulletType::Unknown
355 }
356
357 fn classification_bc_sd_ratio(bc: f64, sd: f64, drag_model: &str) -> f64 {
362 let g1_equivalent_bc = if drag_model.eq_ignore_ascii_case("G7") {
363 bc * 2.0
364 } else {
365 bc
366 };
367 g1_equivalent_bc / sd
368 }
369
370 pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
372 if caliber_inches <= 0.0 {
375 return 0.0;
376 }
377 weight_grains / (7000.0 * caliber_inches * caliber_inches)
378 }
379
380 #[allow(clippy::manual_clamp)] pub fn estimate_bc_segments(
383 base_bc: f64,
384 caliber: f64,
385 weight: f64,
386 model: &str,
387 drag_model: &str,
388 ) -> Vec<BCSegmentData> {
389 let bullet_type = Self::identify_bullet_type_for_drag_model(
391 model,
392 weight,
393 caliber,
394 Some(base_bc),
395 drag_model,
396 );
397 let type_factors = bullet_type.get_factors();
398
399 let sd = Self::calculate_sectional_density(weight, caliber);
401
402 let sd_factor = (sd / 0.25).max(0.7).min(1.3);
405 let nominal_drop = type_factors.drop;
406
407 let mut segments = Vec::new();
409
410 match bullet_type {
412 BulletType::MatchBoatTail => {
413 segments.push(BCSegmentData {
415 velocity_min: 2800.0,
416 velocity_max: 5000.0,
417 bc_value: base_bc * 1.000,
418 });
419 segments.push(BCSegmentData {
420 velocity_min: 2400.0,
421 velocity_max: 2800.0,
422 bc_value: base_bc * 0.985,
423 });
424 segments.push(BCSegmentData {
425 velocity_min: 2000.0,
426 velocity_max: 2400.0,
427 bc_value: base_bc * 0.965,
428 });
429 segments.push(BCSegmentData {
430 velocity_min: 1600.0,
431 velocity_max: 2000.0,
432 bc_value: base_bc * 0.945,
433 });
434 segments.push(BCSegmentData {
435 velocity_min: 0.0,
436 velocity_max: 1600.0,
437 bc_value: base_bc * 0.925,
438 });
439 }
440 BulletType::VldHighBc | BulletType::Hybrid => {
441 segments.push(BCSegmentData {
443 velocity_min: 2800.0,
444 velocity_max: 5000.0,
445 bc_value: base_bc * 1.000,
446 });
447 segments.push(BCSegmentData {
448 velocity_min: 2200.0,
449 velocity_max: 2800.0,
450 bc_value: base_bc * 0.990,
451 });
452 segments.push(BCSegmentData {
453 velocity_min: 1600.0,
454 velocity_max: 2200.0,
455 bc_value: base_bc * 0.970,
456 });
457 segments.push(BCSegmentData {
458 velocity_min: 0.0,
459 velocity_max: 1600.0,
460 bc_value: base_bc * 0.950,
461 });
462 }
463 BulletType::HuntingBoatTail => {
464 segments.push(BCSegmentData {
466 velocity_min: 2600.0,
467 velocity_max: 5000.0,
468 bc_value: base_bc * 1.000,
469 });
470 segments.push(BCSegmentData {
471 velocity_min: 2200.0,
472 velocity_max: 2600.0,
473 bc_value: base_bc * 0.960,
474 });
475 segments.push(BCSegmentData {
476 velocity_min: 1800.0,
477 velocity_max: 2200.0,
478 bc_value: base_bc * 0.900,
479 });
480 segments.push(BCSegmentData {
481 velocity_min: 0.0,
482 velocity_max: 1800.0,
483 bc_value: base_bc * 0.850,
484 });
485 }
486 _ => {
487 segments.push(BCSegmentData {
489 velocity_min: 2800.0,
490 velocity_max: 5000.0,
491 bc_value: base_bc,
492 });
493
494 let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
495 segments.push(BCSegmentData {
496 velocity_min: 1800.0,
497 velocity_max: 2800.0,
498 bc_value: transonic_bc,
499 });
500
501 let subsonic_bc = base_bc * (1.0 - nominal_drop);
502 segments.push(BCSegmentData {
503 velocity_min: 0.0,
504 velocity_max: 1800.0,
505 bc_value: subsonic_bc,
506 });
507 }
508 }
509
510 if drag_model.eq_ignore_ascii_case("G7") {
517 const G7_DROP_SCALE: f64 = 0.8;
518 for segment in &mut segments {
519 let drop_from_nominal = base_bc - segment.bc_value;
520 segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
521 }
522 }
523
524 if sd_factor != 1.0 {
529 for segment in &mut segments {
530 let drop_from_nominal = base_bc - segment.bc_value;
531 segment.bc_value = base_bc - drop_from_nominal / sd_factor;
532 }
533 }
534
535 segments
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn test_bullet_type_identification() {
545 assert_eq!(
546 BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
547 BulletType::MatchFlatBase
548 );
549 assert_eq!(
550 BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
551 BulletType::MatchBoatTail
552 );
553 assert_eq!(
554 BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
555 BulletType::HuntingBoatTail
556 );
557 assert_eq!(
558 BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
559 BulletType::FMJ
560 );
561 assert_eq!(
562 BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
563 BulletType::RoundNose
564 );
565 assert_eq!(
566 BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
567 BulletType::VldHighBc
568 );
569 assert_eq!(
570 BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
571 BulletType::Unknown
572 );
573 }
574
575 #[test]
576 fn test_sectional_density() {
577 let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
578 assert!((sd - 0.253).abs() < 0.001);
579 }
580
581 #[test]
582 fn test_bc_estimation() {
583 let segments =
584 BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
585
586 assert!(segments.len() >= 3);
588 assert!((segments[0].bc_value - 0.450).abs() < 0.05);
590 assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
592 }
593
594 #[test]
595 fn g7_transition_adjustment_softens_each_band_drop() {
596 let base_bc = 0.5;
597 let caliber = 1.0;
599 let weight = 1750.0;
600
601 for model in ["SMK BT", "FMJ"] {
602 let g1 =
603 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
604 let g7 =
605 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
606 let lowercase_g7 =
607 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
608 assert_eq!(g7.len(), g1.len());
609 assert_eq!(lowercase_g7.len(), g7.len());
610
611 for ((g1_band, g7_band), lowercase_band) in
612 g1.iter().zip(&g7).zip(&lowercase_g7)
613 {
614 assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
615 assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
616 assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
617 assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
618 assert_eq!(lowercase_band.bc_value.to_bits(), g7_band.bc_value.to_bits());
619 let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
620 assert!(
621 (g7_band.bc_value - expected_g7).abs() < 1e-12,
622 "{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
623 g1_band.velocity_min,
624 g1_band.velocity_max,
625 g1_band.bc_value,
626 g7_band.bc_value
627 );
628 }
629 }
630 }
631
632 #[test]
633 fn sectional_density_shapes_only_band_degradation() {
634 let base_bc = 0.5;
635 let caliber = 0.224;
636 let weight = 77.0;
637 let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
638 let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
639
640 for drag_model in ["G1", "G7"] {
641 let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
642 for (model, raw_retentions) in [
643 ("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
644 ("FMJ", &[1.0, 0.964, 0.88][..]),
645 ] {
646 let segments = BCSegmentEstimator::estimate_bc_segments(
647 base_bc,
648 caliber,
649 weight,
650 model,
651 drag_model,
652 );
653 assert_eq!(segments.len(), raw_retentions.len());
654
655 for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
656 let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
657 let expected = base_bc - raw_drop / sd_factor;
658 assert!(
659 (segment.bc_value - expected).abs() < 1e-12,
660 "{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
661 segment.velocity_min,
662 segment.velocity_max,
663 segment.bc_value
664 );
665 }
666 assert_eq!(
667 segments[0].bc_value.to_bits(),
668 base_bc.to_bits(),
669 "published muzzle BC must remain exact for {drag_model} {model}"
670 );
671 }
672 }
673
674 let high_sd_base_bc = 0.3;
677 let high_sd_segments = BCSegmentEstimator::estimate_bc_segments(
678 high_sd_base_bc,
679 0.308,
680 220.0,
681 "SMK BT",
682 "G7",
683 );
684 assert_eq!(high_sd_segments[0].bc_value.to_bits(), high_sd_base_bc.to_bits());
685 assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
686 assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
687 }
688
689 #[test]
690 fn generic_g7_bc_uses_g7_classification_space() {
691 let base_bc = 0.243;
695 let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
696
697 assert!(
698 segments.len() >= 4,
699 "G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
700 );
701 let subsonic_bc = segments.last().unwrap().bc_value;
702 assert!(
703 subsonic_bc >= base_bc * 0.92,
704 "G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
705 );
706
707 let g1_segments =
712 BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
713 assert_eq!(segments.len(), g1_segments.len());
714 let mut saw_g7_softening = false;
715 for (g7, g1) in segments.iter().zip(&g1_segments) {
716 assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
717 assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
718 let g7_retention = g7.bc_value / base_bc;
719 let g1_retention = g1.bc_value / (base_bc * 2.0);
720 assert!(g7_retention + 1e-12 >= g1_retention);
721 saw_g7_softening |= g7_retention > g1_retention + 1e-12;
722 }
723 assert!(saw_g7_softening);
724
725 let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
726 assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
727 assert_eq!(
728 legacy_g1,
729 BCSegmentEstimator::identify_bullet_type_for_drag_model(
730 "",
731 175.0,
732 0.308,
733 Some(base_bc),
734 "G1",
735 )
736 );
737 assert_eq!(
738 BCSegmentEstimator::identify_bullet_type_for_drag_model(
739 "175gr SMK",
740 175.0,
741 0.308,
742 Some(base_bc),
743 "g7",
744 ),
745 BulletType::MatchBoatTail
746 );
747 assert_eq!(
748 BCSegmentEstimator::identify_bullet_type_for_drag_model(
749 "",
750 175.0,
751 0.0,
752 Some(base_bc),
753 "G7",
754 ),
755 BulletType::Unknown
756 );
757 }
758
759 #[test]
765 fn margin_caps_at_50_fps_for_wide_adjacent_bands() {
766 assert_eq!(smoothing_margin(1000.0), 50.0);
768 assert_eq!(smoothing_margin(200.0), 50.0); }
770
771 #[test]
772 fn margin_uses_25_percent_rule_for_narrow_adjacent_bands() {
773 assert_eq!(smoothing_margin(40.0), 10.0);
775 assert_eq!(smoothing_margin(4.0), 1.0);
776 }
777
778 #[test]
779 fn margin_is_zero_for_degenerate_widths() {
780 assert_eq!(smoothing_margin(0.0), 0.0);
781 assert_eq!(smoothing_margin(-5.0), 0.0); }
783
784 #[test]
785 fn smoothstep_is_centered_and_matches_hermite_formula() {
786 assert_eq!(smoothstep(0.0), 0.0);
787 assert_eq!(smoothstep(1.0), 1.0);
788 assert_eq!(smoothstep(0.5), 0.5); assert_eq!(smoothstep(-1.0), 0.0);
791 assert_eq!(smoothstep(2.0), 1.0);
792 }
793
794 #[test]
795 fn ascending_band_boundary_blends_symmetrically_around_the_boundary() {
796 let segments = vec![
798 BCSegmentData {
799 velocity_min: 0.0,
800 velocity_max: 1000.0,
801 bc_value: 0.25,
802 },
803 BCSegmentData {
804 velocity_min: 1000.0,
805 velocity_max: 2000.0,
806 bc_value: 0.75,
807 },
808 ];
809
810 assert_eq!(velocity_segment_bc(200.0, &segments, 0.9).to_bits(), 0.25f64.to_bits());
812 assert_eq!(velocity_segment_bc(1800.0, &segments, 0.9).to_bits(), 0.75f64.to_bits());
813
814 assert_eq!(velocity_segment_bc(1000.0, &segments, 0.9), 0.5);
816
817 assert_eq!(
819 velocity_segment_bc(975.0, &segments, 0.9).to_bits(),
820 0.25f64.to_bits()
821 );
822 assert_eq!(
824 velocity_segment_bc(1025.0, &segments, 0.9).to_bits(),
825 0.75f64.to_bits()
826 );
827
828 let just_below = velocity_segment_bc(999.0, &segments, 0.9);
830 let just_above = velocity_segment_bc(1001.0, &segments, 0.9);
831 assert!(just_below > 0.25 && just_below < 0.5);
832 assert!(just_above > 0.5 && just_above < 0.75);
833 }
834
835 #[test]
836 fn descending_stored_order_matches_ascending_boundary_blend() {
837 let ascending = vec![
840 BCSegmentData {
841 velocity_min: 0.0,
842 velocity_max: 1000.0,
843 bc_value: 0.25,
844 },
845 BCSegmentData {
846 velocity_min: 1000.0,
847 velocity_max: 2000.0,
848 bc_value: 0.75,
849 },
850 ];
851 let mut descending = ascending.clone();
852 descending.reverse();
853
854 for v in [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0] {
855 assert_eq!(
856 velocity_segment_bc(v, &ascending, 0.9),
857 velocity_segment_bc(v, &descending, 0.9),
858 "order must not affect the blended result at v={v}"
859 );
860 }
861 }
862
863 #[test]
864 fn gapped_table_blends_both_edges_of_the_fallback_gap_and_stays_flat_mid_gap() {
865 let segments = vec![
868 BCSegmentData {
869 velocity_min: 0.0,
870 velocity_max: 1000.0,
871 bc_value: 0.25,
872 },
873 BCSegmentData {
874 velocity_min: 1200.0,
875 velocity_max: 2200.0,
876 bc_value: 0.75,
877 },
878 ];
879 let fallback = 0.5; assert_eq!(
883 velocity_segment_bc(1100.0, &segments, fallback).to_bits(),
884 fallback.to_bits()
885 );
886
887 assert_eq!(velocity_segment_bc(1000.0, &segments, fallback), 0.375); assert_eq!(velocity_segment_bc(1200.0, &segments, fallback), 0.625); assert_eq!(
895 velocity_segment_bc(974.0, &segments, fallback).to_bits(),
896 0.25f64.to_bits()
897 );
898 assert_eq!(
899 velocity_segment_bc(1226.0, &segments, fallback).to_bits(),
900 0.75f64.to_bits()
901 );
902 }
903
904 #[test]
905 fn single_segment_table_never_blends_and_is_byte_identical_to_the_raw_lookup() {
906 let single = vec![BCSegmentData {
907 velocity_min: 1000.0,
908 velocity_max: 2000.0,
909 bc_value: 0.5,
910 }];
911
912 for v in [-1e6, 500.0, 1000.0, 1500.0, 1999.999, 2000.0, 1e6] {
913 assert_eq!(
914 velocity_segment_bc(v, &single, 0.9).to_bits(),
915 raw_velocity_segment_bc(v, &single, 0.9).to_bits(),
916 "single-band tables must never blend (v={v})"
917 );
918 }
919 }
920
921 #[test]
922 fn empty_table_never_blends_and_always_returns_the_fallback_exactly() {
923 let empty: Vec<BCSegmentData> = vec![];
924 for v in [-1e6, 0.0, 500.0, 1e6] {
925 assert_eq!(velocity_segment_bc(v, &empty, 0.73).to_bits(), 0.73f64.to_bits());
926 }
927 }
928
929 #[test]
930 fn zero_width_adjacent_band_disables_blending_at_its_boundaries() {
931 let segments = vec![
936 BCSegmentData {
937 velocity_min: 0.0,
938 velocity_max: 1000.0,
939 bc_value: 0.5,
940 },
941 BCSegmentData {
942 velocity_min: 1000.0,
943 velocity_max: 1000.0,
944 bc_value: 0.9,
945 },
946 BCSegmentData {
947 velocity_min: 1000.0,
948 velocity_max: 2000.0,
949 bc_value: 0.6,
950 },
951 ];
952
953 for v in [999.0, 999.99, 1000.0, 1000.01, 1001.0] {
954 assert_eq!(
955 velocity_segment_bc(v, &segments, 0.99).to_bits(),
956 raw_velocity_segment_bc(v, &segments, 0.99).to_bits(),
957 "a degenerate adjacent band must disable blending, not average toward it (v={v})"
958 );
959 }
960 }
961
962 #[test]
963 fn coverage_entry_and_exit_edges_stay_flat_since_the_clamp_matches_the_bordering_band() {
964 let segments = vec![
969 BCSegmentData {
970 velocity_min: 1000.0,
971 velocity_max: 2000.0,
972 bc_value: 0.25,
973 },
974 BCSegmentData {
975 velocity_min: 2000.0,
976 velocity_max: 3000.0,
977 bc_value: 0.75,
978 },
979 ];
980
981 for v in [-1e6, -100.0, 999.0, 1000.0, 1001.0] {
982 assert_eq!(
983 velocity_segment_bc(v, &segments, 0.5).to_bits(),
984 0.25f64.to_bits(),
985 "below-coverage clamp must stay exactly flat (v={v})"
986 );
987 }
988 for v in [2999.0, 3000.0, 3001.0, 1e6] {
989 assert_eq!(
990 velocity_segment_bc(v, &segments, 0.5).to_bits(),
991 0.75f64.to_bits(),
992 "above-coverage clamp must stay exactly flat (v={v})"
993 );
994 }
995 }
996}