1use crate::BCSegmentData;
2use std::cell::RefCell;
3
4thread_local! {
5 static BOUNDARY_CACHE: RefCell<Option<CachedBoundaries>> = const { RefCell::new(None) };
18}
19
20type CachedBoundaries = (Vec<BCSegmentData>, Vec<(f64, f64)>);
23
24fn segments_bitwise_eq(a: &[BCSegmentData], b: &[BCSegmentData]) -> bool {
28 a.len() == b.len()
29 && a.iter().zip(b).all(|(x, y)| {
30 x.velocity_min.to_bits() == y.velocity_min.to_bits()
31 && x.velocity_max.to_bits() == y.velocity_max.to_bits()
32 && x.bc_value.to_bits() == y.bc_value.to_bits()
33 })
34}
35
36pub(crate) fn velocity_segment_bc(
52 velocity_fps: f64,
53 segments: &[BCSegmentData],
54 fallback_bc: f64,
55) -> f64 {
56 if segments.len() < 2 {
57 return raw_velocity_segment_bc(velocity_fps, segments, fallback_bc);
60 }
61
62 BOUNDARY_CACHE.with(|cache| {
63 let mut cache = cache.borrow_mut();
64 let stale = match &*cache {
65 Some((cached_segments, _)) => !segments_bitwise_eq(cached_segments, segments),
66 None => true,
67 };
68 if stale {
69 *cache = Some((segments.to_vec(), transition_boundaries(segments)));
70 }
71 let boundaries = &cache.as_ref().unwrap().1;
72
73 for &(boundary, margin) in boundaries {
74 if margin <= 0.0 {
75 continue;
77 }
78 let half = margin / 2.0;
79 let lo_v = boundary - half;
80 let hi_v = boundary + half;
81 if velocity_fps < lo_v || velocity_fps > hi_v {
82 continue;
83 }
84
85 let lo = raw_velocity_segment_bc(lo_v, segments, fallback_bc);
86 let hi = raw_velocity_segment_bc(hi_v, segments, fallback_bc);
87 if lo == hi {
88 return lo;
91 }
92
93 let t = ((velocity_fps - lo_v) / margin).clamp(0.0, 1.0);
94 return lo + smoothstep(t) * (hi - lo);
95 }
96
97 raw_velocity_segment_bc(velocity_fps, segments, fallback_bc)
98 })
99}
100
101fn raw_velocity_segment_bc(velocity_fps: f64, segments: &[BCSegmentData], fallback_bc: f64) -> f64 {
104 if let Some(segment) = segments
105 .iter()
106 .find(|segment| velocity_fps >= segment.velocity_min && velocity_fps < segment.velocity_max)
107 {
108 return segment.bc_value;
109 }
110
111 let lowest = segments
112 .iter()
113 .min_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
114 if let Some(segment) = lowest {
115 if velocity_fps < segment.velocity_min {
116 return segment.bc_value;
117 }
118 }
119
120 let highest = segments
121 .iter()
122 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max));
123 if let Some(segment) = highest {
124 if velocity_fps >= segment.velocity_max {
125 return segment.bc_value;
126 }
127 }
128
129 fallback_bc
130}
131
132fn transition_boundaries(segments: &[BCSegmentData]) -> Vec<(f64, f64)> {
141 let width = |s: &BCSegmentData| (s.velocity_max - s.velocity_min).max(0.0);
142
143 let mut sorted: Vec<&BCSegmentData> = segments.iter().collect();
144 sorted.sort_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
145
146 let mut boundaries = Vec::with_capacity(sorted.len() * 2 + 2);
147
148 if let Some(first) = sorted.first() {
151 boundaries.push((first.velocity_min, smoothing_margin(width(first))));
152 }
153 if let Some(highest) = segments
157 .iter()
158 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max))
159 {
160 boundaries.push((highest.velocity_max, smoothing_margin(width(highest))));
161 }
162
163 for pair in sorted.windows(2) {
164 let (left, right) = (pair[0], pair[1]);
165 if right.velocity_min > left.velocity_max {
166 let gap_width = right.velocity_min - left.velocity_max;
170 boundaries.push((
171 left.velocity_max,
172 smoothing_margin(width(left).min(gap_width)),
173 ));
174 boundaries.push((
175 right.velocity_min,
176 smoothing_margin(gap_width.min(width(right))),
177 ));
178 } else if right.velocity_min == left.velocity_max {
179 boundaries.push((
181 left.velocity_max,
182 smoothing_margin(width(left).min(width(right))),
183 ));
184 }
185 }
189
190 boundaries
191}
192
193fn smoothing_margin(narrower_adjacent_width: f64) -> f64 {
196 if narrower_adjacent_width <= 0.0 {
197 return 0.0;
198 }
199 (0.25 * narrower_adjacent_width).min(50.0)
200}
201
202fn smoothstep(t: f64) -> f64 {
204 let t = t.clamp(0.0, 1.0);
205 t * t * (3.0 - 2.0 * t)
206}
207
208#[derive(Debug, Clone, Copy, PartialEq)]
210pub enum BulletType {
211 MatchBoatTail,
212 MatchFlatBase,
213 HuntingBoatTail,
214 HuntingFlatBase,
215 VldHighBc,
216 Hybrid,
217 FMJ,
218 RoundNose,
219 Unknown,
220}
221
222pub struct BulletTypeFactors {
224 pub drop: f64,
225 pub transition_curve: f64,
226}
227
228impl BulletType {
229 pub fn get_factors(&self) -> BulletTypeFactors {
231 match self {
232 BulletType::MatchBoatTail => BulletTypeFactors {
233 drop: 0.075, transition_curve: 0.3,
235 },
236 BulletType::MatchFlatBase => BulletTypeFactors {
237 drop: 0.10, transition_curve: 0.35,
239 },
240 BulletType::HuntingBoatTail => BulletTypeFactors {
241 drop: 0.15, transition_curve: 0.45,
243 },
244 BulletType::HuntingFlatBase => BulletTypeFactors {
245 drop: 0.20, transition_curve: 0.5,
247 },
248 BulletType::VldHighBc => BulletTypeFactors {
249 drop: 0.05, transition_curve: 0.25,
251 },
252 BulletType::Hybrid => BulletTypeFactors {
253 drop: 0.06, transition_curve: 0.28,
255 },
256 BulletType::FMJ => BulletTypeFactors {
257 drop: 0.12, transition_curve: 0.4,
259 },
260 BulletType::RoundNose => BulletTypeFactors {
261 drop: 0.35, transition_curve: 0.7,
263 },
264 BulletType::Unknown => BulletTypeFactors {
265 drop: 0.15, transition_curve: 0.5,
267 },
268 }
269 }
270}
271
272pub struct BCSegmentEstimator;
274
275impl BCSegmentEstimator {
276 pub fn identify_bullet_type(
282 model: &str,
283 weight: f64,
284 caliber: f64,
285 bc_value: Option<f64>,
286 ) -> BulletType {
287 Self::identify_bullet_type_for_drag_model(model, weight, caliber, bc_value, "G1")
288 }
289
290 pub fn identify_bullet_type_for_drag_model(
292 model: &str,
293 weight: f64,
294 caliber: f64,
295 bc_value: Option<f64>,
296 drag_model: &str,
297 ) -> BulletType {
298 let model_lower = model.to_lowercase();
299
300 if model_lower.contains("vld")
302 || model_lower.contains("berger")
303 || model_lower.contains("hybrid")
304 || model_lower.contains("elite")
305 {
306 if model_lower.contains("hybrid") {
307 return BulletType::Hybrid;
308 }
309 return BulletType::VldHighBc;
310 }
311
312 if model_lower.contains("smk")
314 || model_lower.contains("matchking")
315 || model_lower.contains("match")
316 || model_lower.contains("bthp")
317 || model_lower.contains("competition")
318 || model_lower.contains("target")
319 || model_lower.contains("a-max")
320 || model_lower.contains("eld-m")
321 || model_lower.contains("scenar")
322 || model_lower.contains("x-ring")
323 {
324 if model_lower.contains("bt") || model_lower.contains("boat") {
326 return BulletType::MatchBoatTail;
327 }
328 if let Some(bc) = bc_value {
331 let sd = Self::calculate_sectional_density(weight, caliber);
332 if sd > 0.0 && Self::classification_bc_sd_ratio(bc, sd, drag_model) > 1.6 {
333 return BulletType::MatchBoatTail;
334 }
335 }
336 return BulletType::MatchFlatBase;
337 }
338
339 if model_lower.contains("gameking")
341 || model_lower.contains("hunting")
342 || model_lower.contains("sst")
343 || model_lower.contains("eld-x")
344 || model_lower.contains("partition")
345 || model_lower.contains("accubond")
346 || model_lower.contains("core-lokt")
347 || model_lower.contains("ballistic tip")
348 || model_lower.contains("v-max")
349 || model_lower.contains("hornady sp")
350 || model_lower.contains("interlock")
351 || model_lower.contains("tsx")
352 {
353 if model_lower.contains("bt")
355 || model_lower.contains("boat")
356 || model_lower.contains("sst")
357 || model_lower.contains("accubond")
358 {
359 return BulletType::HuntingBoatTail;
360 }
361 return BulletType::HuntingFlatBase;
362 }
363
364 if model_lower.contains("fmj")
366 || model_lower.contains("ball")
367 || model_lower.contains("m80")
368 || model_lower.contains("m855")
369 || model_lower.contains("tracer")
370 {
371 return BulletType::FMJ;
372 }
373
374 if model_lower.contains("rn")
376 || model_lower.contains("round nose")
377 || model_lower.contains("rnsp")
378 {
379 return BulletType::RoundNose;
380 }
381
382 if let Some(bc) = bc_value {
386 let sd = Self::calculate_sectional_density(weight, caliber);
387 if sd > 0.0 {
388 let bc_to_sd_ratio = Self::classification_bc_sd_ratio(bc, sd, drag_model);
389
390 if bc_to_sd_ratio > 1.8 {
391 return BulletType::VldHighBc;
392 } else if bc_to_sd_ratio > 1.5 {
393 return BulletType::MatchBoatTail;
394 } else if bc_to_sd_ratio < 1.2 {
395 return BulletType::HuntingFlatBase;
396 }
397 }
398 }
399
400 BulletType::Unknown
401 }
402
403 fn classification_bc_sd_ratio(bc: f64, sd: f64, drag_model: &str) -> f64 {
408 let g1_equivalent_bc = if drag_model.eq_ignore_ascii_case("G7") {
409 bc * 2.0
410 } else {
411 bc
412 };
413 g1_equivalent_bc / sd
414 }
415
416 pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
418 if caliber_inches <= 0.0 {
421 return 0.0;
422 }
423 weight_grains / (7000.0 * caliber_inches * caliber_inches)
424 }
425
426 #[allow(clippy::manual_clamp)] pub fn estimate_bc_segments(
429 base_bc: f64,
430 caliber: f64,
431 weight: f64,
432 model: &str,
433 drag_model: &str,
434 ) -> Vec<BCSegmentData> {
435 let bullet_type = Self::identify_bullet_type_for_drag_model(
437 model,
438 weight,
439 caliber,
440 Some(base_bc),
441 drag_model,
442 );
443 let type_factors = bullet_type.get_factors();
444
445 let sd = Self::calculate_sectional_density(weight, caliber);
447
448 let sd_factor = (sd / 0.25).max(0.7).min(1.3);
451 let nominal_drop = type_factors.drop;
452
453 let mut segments = Vec::new();
455
456 match bullet_type {
458 BulletType::MatchBoatTail => {
459 segments.push(BCSegmentData {
461 velocity_min: 2800.0,
462 velocity_max: 5000.0,
463 bc_value: base_bc * 1.000,
464 });
465 segments.push(BCSegmentData {
466 velocity_min: 2400.0,
467 velocity_max: 2800.0,
468 bc_value: base_bc * 0.985,
469 });
470 segments.push(BCSegmentData {
471 velocity_min: 2000.0,
472 velocity_max: 2400.0,
473 bc_value: base_bc * 0.965,
474 });
475 segments.push(BCSegmentData {
476 velocity_min: 1600.0,
477 velocity_max: 2000.0,
478 bc_value: base_bc * 0.945,
479 });
480 segments.push(BCSegmentData {
481 velocity_min: 0.0,
482 velocity_max: 1600.0,
483 bc_value: base_bc * 0.925,
484 });
485 }
486 BulletType::VldHighBc | BulletType::Hybrid => {
487 segments.push(BCSegmentData {
489 velocity_min: 2800.0,
490 velocity_max: 5000.0,
491 bc_value: base_bc * 1.000,
492 });
493 segments.push(BCSegmentData {
494 velocity_min: 2200.0,
495 velocity_max: 2800.0,
496 bc_value: base_bc * 0.990,
497 });
498 segments.push(BCSegmentData {
499 velocity_min: 1600.0,
500 velocity_max: 2200.0,
501 bc_value: base_bc * 0.970,
502 });
503 segments.push(BCSegmentData {
504 velocity_min: 0.0,
505 velocity_max: 1600.0,
506 bc_value: base_bc * 0.950,
507 });
508 }
509 BulletType::HuntingBoatTail => {
510 segments.push(BCSegmentData {
512 velocity_min: 2600.0,
513 velocity_max: 5000.0,
514 bc_value: base_bc * 1.000,
515 });
516 segments.push(BCSegmentData {
517 velocity_min: 2200.0,
518 velocity_max: 2600.0,
519 bc_value: base_bc * 0.960,
520 });
521 segments.push(BCSegmentData {
522 velocity_min: 1800.0,
523 velocity_max: 2200.0,
524 bc_value: base_bc * 0.900,
525 });
526 segments.push(BCSegmentData {
527 velocity_min: 0.0,
528 velocity_max: 1800.0,
529 bc_value: base_bc * 0.850,
530 });
531 }
532 _ => {
533 segments.push(BCSegmentData {
535 velocity_min: 2800.0,
536 velocity_max: 5000.0,
537 bc_value: base_bc,
538 });
539
540 let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
541 segments.push(BCSegmentData {
542 velocity_min: 1800.0,
543 velocity_max: 2800.0,
544 bc_value: transonic_bc,
545 });
546
547 let subsonic_bc = base_bc * (1.0 - nominal_drop);
548 segments.push(BCSegmentData {
549 velocity_min: 0.0,
550 velocity_max: 1800.0,
551 bc_value: subsonic_bc,
552 });
553 }
554 }
555
556 if drag_model.eq_ignore_ascii_case("G7") {
563 const G7_DROP_SCALE: f64 = 0.8;
564 for segment in &mut segments {
565 let drop_from_nominal = base_bc - segment.bc_value;
566 segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
567 }
568 }
569
570 if sd_factor != 1.0 {
575 for segment in &mut segments {
576 let drop_from_nominal = base_bc - segment.bc_value;
577 segment.bc_value = base_bc - drop_from_nominal / sd_factor;
578 }
579 }
580
581 segments
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn test_bullet_type_identification() {
591 assert_eq!(
592 BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
593 BulletType::MatchFlatBase
594 );
595 assert_eq!(
596 BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
597 BulletType::MatchBoatTail
598 );
599 assert_eq!(
600 BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
601 BulletType::HuntingBoatTail
602 );
603 assert_eq!(
604 BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
605 BulletType::FMJ
606 );
607 assert_eq!(
608 BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
609 BulletType::RoundNose
610 );
611 assert_eq!(
612 BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
613 BulletType::VldHighBc
614 );
615 assert_eq!(
616 BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
617 BulletType::Unknown
618 );
619 }
620
621 #[test]
622 fn test_sectional_density() {
623 let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
624 assert!((sd - 0.253).abs() < 0.001);
625 }
626
627 #[test]
628 fn test_bc_estimation() {
629 let segments =
630 BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
631
632 assert!(segments.len() >= 3);
634 assert!((segments[0].bc_value - 0.450).abs() < 0.05);
636 assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
638 }
639
640 #[test]
641 fn g7_transition_adjustment_softens_each_band_drop() {
642 let base_bc = 0.5;
643 let caliber = 1.0;
645 let weight = 1750.0;
646
647 for model in ["SMK BT", "FMJ"] {
648 let g1 =
649 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
650 let g7 =
651 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
652 let lowercase_g7 =
653 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
654 assert_eq!(g7.len(), g1.len());
655 assert_eq!(lowercase_g7.len(), g7.len());
656
657 for ((g1_band, g7_band), lowercase_band) in g1.iter().zip(&g7).zip(&lowercase_g7) {
658 assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
659 assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
660 assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
661 assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
662 assert_eq!(
663 lowercase_band.bc_value.to_bits(),
664 g7_band.bc_value.to_bits()
665 );
666 let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
667 assert!(
668 (g7_band.bc_value - expected_g7).abs() < 1e-12,
669 "{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
670 g1_band.velocity_min,
671 g1_band.velocity_max,
672 g1_band.bc_value,
673 g7_band.bc_value
674 );
675 }
676 }
677 }
678
679 #[test]
680 fn sectional_density_shapes_only_band_degradation() {
681 let base_bc = 0.5;
682 let caliber = 0.224;
683 let weight = 77.0;
684 let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
685 let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
686
687 for drag_model in ["G1", "G7"] {
688 let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
689 for (model, raw_retentions) in [
690 ("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
691 ("FMJ", &[1.0, 0.964, 0.88][..]),
692 ] {
693 let segments = BCSegmentEstimator::estimate_bc_segments(
694 base_bc, caliber, weight, model, drag_model,
695 );
696 assert_eq!(segments.len(), raw_retentions.len());
697
698 for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
699 let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
700 let expected = base_bc - raw_drop / sd_factor;
701 assert!(
702 (segment.bc_value - expected).abs() < 1e-12,
703 "{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
704 segment.velocity_min,
705 segment.velocity_max,
706 segment.bc_value
707 );
708 }
709 assert_eq!(
710 segments[0].bc_value.to_bits(),
711 base_bc.to_bits(),
712 "published muzzle BC must remain exact for {drag_model} {model}"
713 );
714 }
715 }
716
717 let high_sd_base_bc = 0.3;
720 let high_sd_segments =
721 BCSegmentEstimator::estimate_bc_segments(high_sd_base_bc, 0.308, 220.0, "SMK BT", "G7");
722 assert_eq!(
723 high_sd_segments[0].bc_value.to_bits(),
724 high_sd_base_bc.to_bits()
725 );
726 assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
727 assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
728 }
729
730 #[test]
731 fn generic_g7_bc_uses_g7_classification_space() {
732 let base_bc = 0.243;
736 let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
737
738 assert!(
739 segments.len() >= 4,
740 "G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
741 );
742 let subsonic_bc = segments.last().unwrap().bc_value;
743 assert!(
744 subsonic_bc >= base_bc * 0.92,
745 "G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
746 );
747
748 let g1_segments =
753 BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
754 assert_eq!(segments.len(), g1_segments.len());
755 let mut saw_g7_softening = false;
756 for (g7, g1) in segments.iter().zip(&g1_segments) {
757 assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
758 assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
759 let g7_retention = g7.bc_value / base_bc;
760 let g1_retention = g1.bc_value / (base_bc * 2.0);
761 assert!(g7_retention + 1e-12 >= g1_retention);
762 saw_g7_softening |= g7_retention > g1_retention + 1e-12;
763 }
764 assert!(saw_g7_softening);
765
766 let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
767 assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
768 assert_eq!(
769 legacy_g1,
770 BCSegmentEstimator::identify_bullet_type_for_drag_model(
771 "",
772 175.0,
773 0.308,
774 Some(base_bc),
775 "G1",
776 )
777 );
778 assert_eq!(
779 BCSegmentEstimator::identify_bullet_type_for_drag_model(
780 "175gr SMK",
781 175.0,
782 0.308,
783 Some(base_bc),
784 "g7",
785 ),
786 BulletType::MatchBoatTail
787 );
788 assert_eq!(
789 BCSegmentEstimator::identify_bullet_type_for_drag_model(
790 "",
791 175.0,
792 0.0,
793 Some(base_bc),
794 "G7",
795 ),
796 BulletType::Unknown
797 );
798 }
799
800 #[test]
806 fn margin_caps_at_50_fps_for_wide_adjacent_bands() {
807 assert_eq!(smoothing_margin(1000.0), 50.0);
809 assert_eq!(smoothing_margin(200.0), 50.0); }
811
812 #[test]
813 fn margin_uses_25_percent_rule_for_narrow_adjacent_bands() {
814 assert_eq!(smoothing_margin(40.0), 10.0);
816 assert_eq!(smoothing_margin(4.0), 1.0);
817 }
818
819 #[test]
820 fn margin_is_zero_for_degenerate_widths() {
821 assert_eq!(smoothing_margin(0.0), 0.0);
822 assert_eq!(smoothing_margin(-5.0), 0.0); }
824
825 #[test]
826 fn smoothstep_is_centered_and_matches_hermite_formula() {
827 assert_eq!(smoothstep(0.0), 0.0);
828 assert_eq!(smoothstep(1.0), 1.0);
829 assert_eq!(smoothstep(0.5), 0.5); assert_eq!(smoothstep(-1.0), 0.0);
832 assert_eq!(smoothstep(2.0), 1.0);
833 }
834
835 #[test]
836 fn ascending_band_boundary_blends_symmetrically_around_the_boundary() {
837 let segments = vec![
839 BCSegmentData {
840 velocity_min: 0.0,
841 velocity_max: 1000.0,
842 bc_value: 0.25,
843 },
844 BCSegmentData {
845 velocity_min: 1000.0,
846 velocity_max: 2000.0,
847 bc_value: 0.75,
848 },
849 ];
850
851 assert_eq!(
853 velocity_segment_bc(200.0, &segments, 0.9).to_bits(),
854 0.25f64.to_bits()
855 );
856 assert_eq!(
857 velocity_segment_bc(1800.0, &segments, 0.9).to_bits(),
858 0.75f64.to_bits()
859 );
860
861 assert_eq!(velocity_segment_bc(1000.0, &segments, 0.9), 0.5);
863
864 assert_eq!(
866 velocity_segment_bc(975.0, &segments, 0.9).to_bits(),
867 0.25f64.to_bits()
868 );
869 assert_eq!(
871 velocity_segment_bc(1025.0, &segments, 0.9).to_bits(),
872 0.75f64.to_bits()
873 );
874
875 let just_below = velocity_segment_bc(999.0, &segments, 0.9);
877 let just_above = velocity_segment_bc(1001.0, &segments, 0.9);
878 assert!(just_below > 0.25 && just_below < 0.5);
879 assert!(just_above > 0.5 && just_above < 0.75);
880 }
881
882 #[test]
883 fn descending_stored_order_matches_ascending_boundary_blend() {
884 let ascending = vec![
887 BCSegmentData {
888 velocity_min: 0.0,
889 velocity_max: 1000.0,
890 bc_value: 0.25,
891 },
892 BCSegmentData {
893 velocity_min: 1000.0,
894 velocity_max: 2000.0,
895 bc_value: 0.75,
896 },
897 ];
898 let mut descending = ascending.clone();
899 descending.reverse();
900
901 for v in [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0] {
902 assert_eq!(
903 velocity_segment_bc(v, &ascending, 0.9),
904 velocity_segment_bc(v, &descending, 0.9),
905 "order must not affect the blended result at v={v}"
906 );
907 }
908 }
909
910 #[test]
918 fn cache_gives_each_distinct_table_its_own_byte_identical_answer_when_interleaved() {
919 let table_a = vec![
920 BCSegmentData {
921 velocity_min: 0.0,
922 velocity_max: 1000.0,
923 bc_value: 0.25,
924 },
925 BCSegmentData {
926 velocity_min: 1000.0,
927 velocity_max: 2000.0,
928 bc_value: 0.75,
929 },
930 ];
931 let table_b = vec![
932 BCSegmentData {
933 velocity_min: 0.0,
934 velocity_max: 900.0,
935 bc_value: 0.40,
936 },
937 BCSegmentData {
938 velocity_min: 900.0,
939 velocity_max: 1800.0,
940 bc_value: 0.60,
941 },
942 ];
943
944 let probes = [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0];
946 let expected_a: Vec<u64> = probes
947 .iter()
948 .map(|&v| velocity_segment_bc(v, &table_a, 0.9).to_bits())
949 .collect();
950 let expected_b: Vec<u64> = probes
951 .iter()
952 .map(|&v| velocity_segment_bc(v, &table_b, 0.9).to_bits())
953 .collect();
954
955 for (i, &v) in probes.iter().enumerate() {
957 assert_eq!(
958 velocity_segment_bc(v, &table_a, 0.9).to_bits(),
959 expected_a[i],
960 "table A must be unaffected by interleaved table B calls (v={v})"
961 );
962 assert_eq!(
963 velocity_segment_bc(v, &table_b, 0.9).to_bits(),
964 expected_b[i],
965 "table B must be unaffected by interleaved table A calls (v={v})"
966 );
967 }
968 }
969
970 #[test]
971 fn gapped_table_blends_both_edges_of_the_fallback_gap_and_stays_flat_mid_gap() {
972 let segments = vec![
975 BCSegmentData {
976 velocity_min: 0.0,
977 velocity_max: 1000.0,
978 bc_value: 0.25,
979 },
980 BCSegmentData {
981 velocity_min: 1200.0,
982 velocity_max: 2200.0,
983 bc_value: 0.75,
984 },
985 ];
986 let fallback = 0.5; assert_eq!(
990 velocity_segment_bc(1100.0, &segments, fallback).to_bits(),
991 fallback.to_bits()
992 );
993
994 assert_eq!(velocity_segment_bc(1000.0, &segments, fallback), 0.375); assert_eq!(velocity_segment_bc(1200.0, &segments, fallback), 0.625); assert_eq!(
1002 velocity_segment_bc(974.0, &segments, fallback).to_bits(),
1003 0.25f64.to_bits()
1004 );
1005 assert_eq!(
1006 velocity_segment_bc(1226.0, &segments, fallback).to_bits(),
1007 0.75f64.to_bits()
1008 );
1009 }
1010
1011 #[test]
1012 fn single_segment_table_never_blends_and_is_byte_identical_to_the_raw_lookup() {
1013 let single = vec![BCSegmentData {
1014 velocity_min: 1000.0,
1015 velocity_max: 2000.0,
1016 bc_value: 0.5,
1017 }];
1018
1019 for v in [-1e6, 500.0, 1000.0, 1500.0, 1999.999, 2000.0, 1e6] {
1020 assert_eq!(
1021 velocity_segment_bc(v, &single, 0.9).to_bits(),
1022 raw_velocity_segment_bc(v, &single, 0.9).to_bits(),
1023 "single-band tables must never blend (v={v})"
1024 );
1025 }
1026 }
1027
1028 #[test]
1029 fn empty_table_never_blends_and_always_returns_the_fallback_exactly() {
1030 let empty: Vec<BCSegmentData> = vec![];
1031 for v in [-1e6, 0.0, 500.0, 1e6] {
1032 assert_eq!(
1033 velocity_segment_bc(v, &empty, 0.73).to_bits(),
1034 0.73f64.to_bits()
1035 );
1036 }
1037 }
1038
1039 #[test]
1040 fn zero_width_adjacent_band_disables_blending_at_its_boundaries() {
1041 let segments = vec![
1046 BCSegmentData {
1047 velocity_min: 0.0,
1048 velocity_max: 1000.0,
1049 bc_value: 0.5,
1050 },
1051 BCSegmentData {
1052 velocity_min: 1000.0,
1053 velocity_max: 1000.0,
1054 bc_value: 0.9,
1055 },
1056 BCSegmentData {
1057 velocity_min: 1000.0,
1058 velocity_max: 2000.0,
1059 bc_value: 0.6,
1060 },
1061 ];
1062
1063 for v in [999.0, 999.99, 1000.0, 1000.01, 1001.0] {
1064 assert_eq!(
1065 velocity_segment_bc(v, &segments, 0.99).to_bits(),
1066 raw_velocity_segment_bc(v, &segments, 0.99).to_bits(),
1067 "a degenerate adjacent band must disable blending, not average toward it (v={v})"
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn coverage_entry_and_exit_edges_stay_flat_since_the_clamp_matches_the_bordering_band() {
1074 let segments = vec![
1079 BCSegmentData {
1080 velocity_min: 1000.0,
1081 velocity_max: 2000.0,
1082 bc_value: 0.25,
1083 },
1084 BCSegmentData {
1085 velocity_min: 2000.0,
1086 velocity_max: 3000.0,
1087 bc_value: 0.75,
1088 },
1089 ];
1090
1091 for v in [-1e6, -100.0, 999.0, 1000.0, 1001.0] {
1092 assert_eq!(
1093 velocity_segment_bc(v, &segments, 0.5).to_bits(),
1094 0.25f64.to_bits(),
1095 "below-coverage clamp must stay exactly flat (v={v})"
1096 );
1097 }
1098 for v in [2999.0, 3000.0, 3001.0, 1e6] {
1099 assert_eq!(
1100 velocity_segment_bc(v, &segments, 0.5).to_bits(),
1101 0.75f64.to_bits(),
1102 "above-coverage clamp must stay exactly flat (v={v})"
1103 );
1104 }
1105 }
1106}