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.iter().find(|segment| {
105 velocity_fps >= segment.velocity_min && velocity_fps < segment.velocity_max
106 }) {
107 return segment.bc_value;
108 }
109
110 let lowest = segments
111 .iter()
112 .min_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
113 if let Some(segment) = lowest {
114 if velocity_fps < segment.velocity_min {
115 return segment.bc_value;
116 }
117 }
118
119 let highest = segments
120 .iter()
121 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max));
122 if let Some(segment) = highest {
123 if velocity_fps >= segment.velocity_max {
124 return segment.bc_value;
125 }
126 }
127
128 fallback_bc
129}
130
131fn transition_boundaries(segments: &[BCSegmentData]) -> Vec<(f64, f64)> {
140 let width = |s: &BCSegmentData| (s.velocity_max - s.velocity_min).max(0.0);
141
142 let mut sorted: Vec<&BCSegmentData> = segments.iter().collect();
143 sorted.sort_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
144
145 let mut boundaries = Vec::with_capacity(sorted.len() * 2 + 2);
146
147 if let Some(first) = sorted.first() {
150 boundaries.push((first.velocity_min, smoothing_margin(width(first))));
151 }
152 if let Some(highest) = segments
156 .iter()
157 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max))
158 {
159 boundaries.push((highest.velocity_max, smoothing_margin(width(highest))));
160 }
161
162 for pair in sorted.windows(2) {
163 let (left, right) = (pair[0], pair[1]);
164 if right.velocity_min > left.velocity_max {
165 let gap_width = right.velocity_min - left.velocity_max;
169 boundaries.push((
170 left.velocity_max,
171 smoothing_margin(width(left).min(gap_width)),
172 ));
173 boundaries.push((
174 right.velocity_min,
175 smoothing_margin(gap_width.min(width(right))),
176 ));
177 } else if right.velocity_min == left.velocity_max {
178 boundaries.push((
180 left.velocity_max,
181 smoothing_margin(width(left).min(width(right))),
182 ));
183 }
184 }
188
189 boundaries
190}
191
192fn smoothing_margin(narrower_adjacent_width: f64) -> f64 {
195 if narrower_adjacent_width <= 0.0 {
196 return 0.0;
197 }
198 (0.25 * narrower_adjacent_width).min(50.0)
199}
200
201fn smoothstep(t: f64) -> f64 {
203 let t = t.clamp(0.0, 1.0);
204 t * t * (3.0 - 2.0 * t)
205}
206
207#[derive(Debug, Clone, Copy, PartialEq)]
209pub enum BulletType {
210 MatchBoatTail,
211 MatchFlatBase,
212 HuntingBoatTail,
213 HuntingFlatBase,
214 VldHighBc,
215 Hybrid,
216 FMJ,
217 RoundNose,
218 Unknown,
219}
220
221pub struct BulletTypeFactors {
223 pub drop: f64,
224 pub transition_curve: f64,
225}
226
227impl BulletType {
228 pub fn get_factors(&self) -> BulletTypeFactors {
230 match self {
231 BulletType::MatchBoatTail => BulletTypeFactors {
232 drop: 0.075, transition_curve: 0.3,
234 },
235 BulletType::MatchFlatBase => BulletTypeFactors {
236 drop: 0.10, transition_curve: 0.35,
238 },
239 BulletType::HuntingBoatTail => BulletTypeFactors {
240 drop: 0.15, transition_curve: 0.45,
242 },
243 BulletType::HuntingFlatBase => BulletTypeFactors {
244 drop: 0.20, transition_curve: 0.5,
246 },
247 BulletType::VldHighBc => BulletTypeFactors {
248 drop: 0.05, transition_curve: 0.25,
250 },
251 BulletType::Hybrid => BulletTypeFactors {
252 drop: 0.06, transition_curve: 0.28,
254 },
255 BulletType::FMJ => BulletTypeFactors {
256 drop: 0.12, transition_curve: 0.4,
258 },
259 BulletType::RoundNose => BulletTypeFactors {
260 drop: 0.35, transition_curve: 0.7,
262 },
263 BulletType::Unknown => BulletTypeFactors {
264 drop: 0.15, transition_curve: 0.5,
266 },
267 }
268 }
269}
270
271pub struct BCSegmentEstimator;
273
274impl BCSegmentEstimator {
275 pub fn identify_bullet_type(
281 model: &str,
282 weight: f64,
283 caliber: f64,
284 bc_value: Option<f64>,
285 ) -> BulletType {
286 Self::identify_bullet_type_for_drag_model(model, weight, caliber, bc_value, "G1")
287 }
288
289 pub fn identify_bullet_type_for_drag_model(
291 model: &str,
292 weight: f64,
293 caliber: f64,
294 bc_value: Option<f64>,
295 drag_model: &str,
296 ) -> BulletType {
297 let model_lower = model.to_lowercase();
298
299 if model_lower.contains("vld")
301 || model_lower.contains("berger")
302 || model_lower.contains("hybrid")
303 || model_lower.contains("elite")
304 {
305 if model_lower.contains("hybrid") {
306 return BulletType::Hybrid;
307 }
308 return BulletType::VldHighBc;
309 }
310
311 if model_lower.contains("smk")
313 || model_lower.contains("matchking")
314 || model_lower.contains("match")
315 || model_lower.contains("bthp")
316 || model_lower.contains("competition")
317 || model_lower.contains("target")
318 || model_lower.contains("a-max")
319 || model_lower.contains("eld-m")
320 || model_lower.contains("scenar")
321 || model_lower.contains("x-ring")
322 {
323 if model_lower.contains("bt") || model_lower.contains("boat") {
325 return BulletType::MatchBoatTail;
326 }
327 if let Some(bc) = bc_value {
330 let sd = Self::calculate_sectional_density(weight, caliber);
331 if sd > 0.0 && Self::classification_bc_sd_ratio(bc, sd, drag_model) > 1.6 {
332 return BulletType::MatchBoatTail;
333 }
334 }
335 return BulletType::MatchFlatBase;
336 }
337
338 if model_lower.contains("gameking")
340 || model_lower.contains("hunting")
341 || model_lower.contains("sst")
342 || model_lower.contains("eld-x")
343 || model_lower.contains("partition")
344 || model_lower.contains("accubond")
345 || model_lower.contains("core-lokt")
346 || model_lower.contains("ballistic tip")
347 || model_lower.contains("v-max")
348 || model_lower.contains("hornady sp")
349 || model_lower.contains("interlock")
350 || model_lower.contains("tsx")
351 {
352 if model_lower.contains("bt")
354 || model_lower.contains("boat")
355 || model_lower.contains("sst")
356 || model_lower.contains("accubond")
357 {
358 return BulletType::HuntingBoatTail;
359 }
360 return BulletType::HuntingFlatBase;
361 }
362
363 if model_lower.contains("fmj")
365 || model_lower.contains("ball")
366 || model_lower.contains("m80")
367 || model_lower.contains("m855")
368 || model_lower.contains("tracer")
369 {
370 return BulletType::FMJ;
371 }
372
373 if model_lower.contains("rn")
375 || model_lower.contains("round nose")
376 || model_lower.contains("rnsp")
377 {
378 return BulletType::RoundNose;
379 }
380
381 if let Some(bc) = bc_value {
385 let sd = Self::calculate_sectional_density(weight, caliber);
386 if sd > 0.0 {
387 let bc_to_sd_ratio = Self::classification_bc_sd_ratio(bc, sd, drag_model);
388
389 if bc_to_sd_ratio > 1.8 {
390 return BulletType::VldHighBc;
391 } else if bc_to_sd_ratio > 1.5 {
392 return BulletType::MatchBoatTail;
393 } else if bc_to_sd_ratio < 1.2 {
394 return BulletType::HuntingFlatBase;
395 }
396 }
397 }
398
399 BulletType::Unknown
400 }
401
402 fn classification_bc_sd_ratio(bc: f64, sd: f64, drag_model: &str) -> f64 {
407 let g1_equivalent_bc = if drag_model.eq_ignore_ascii_case("G7") {
408 bc * 2.0
409 } else {
410 bc
411 };
412 g1_equivalent_bc / sd
413 }
414
415 pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
417 if caliber_inches <= 0.0 {
420 return 0.0;
421 }
422 weight_grains / (7000.0 * caliber_inches * caliber_inches)
423 }
424
425 #[allow(clippy::manual_clamp)] pub fn estimate_bc_segments(
428 base_bc: f64,
429 caliber: f64,
430 weight: f64,
431 model: &str,
432 drag_model: &str,
433 ) -> Vec<BCSegmentData> {
434 let bullet_type = Self::identify_bullet_type_for_drag_model(
436 model,
437 weight,
438 caliber,
439 Some(base_bc),
440 drag_model,
441 );
442 let type_factors = bullet_type.get_factors();
443
444 let sd = Self::calculate_sectional_density(weight, caliber);
446
447 let sd_factor = (sd / 0.25).max(0.7).min(1.3);
450 let nominal_drop = type_factors.drop;
451
452 let mut segments = Vec::new();
454
455 match bullet_type {
457 BulletType::MatchBoatTail => {
458 segments.push(BCSegmentData {
460 velocity_min: 2800.0,
461 velocity_max: 5000.0,
462 bc_value: base_bc * 1.000,
463 });
464 segments.push(BCSegmentData {
465 velocity_min: 2400.0,
466 velocity_max: 2800.0,
467 bc_value: base_bc * 0.985,
468 });
469 segments.push(BCSegmentData {
470 velocity_min: 2000.0,
471 velocity_max: 2400.0,
472 bc_value: base_bc * 0.965,
473 });
474 segments.push(BCSegmentData {
475 velocity_min: 1600.0,
476 velocity_max: 2000.0,
477 bc_value: base_bc * 0.945,
478 });
479 segments.push(BCSegmentData {
480 velocity_min: 0.0,
481 velocity_max: 1600.0,
482 bc_value: base_bc * 0.925,
483 });
484 }
485 BulletType::VldHighBc | BulletType::Hybrid => {
486 segments.push(BCSegmentData {
488 velocity_min: 2800.0,
489 velocity_max: 5000.0,
490 bc_value: base_bc * 1.000,
491 });
492 segments.push(BCSegmentData {
493 velocity_min: 2200.0,
494 velocity_max: 2800.0,
495 bc_value: base_bc * 0.990,
496 });
497 segments.push(BCSegmentData {
498 velocity_min: 1600.0,
499 velocity_max: 2200.0,
500 bc_value: base_bc * 0.970,
501 });
502 segments.push(BCSegmentData {
503 velocity_min: 0.0,
504 velocity_max: 1600.0,
505 bc_value: base_bc * 0.950,
506 });
507 }
508 BulletType::HuntingBoatTail => {
509 segments.push(BCSegmentData {
511 velocity_min: 2600.0,
512 velocity_max: 5000.0,
513 bc_value: base_bc * 1.000,
514 });
515 segments.push(BCSegmentData {
516 velocity_min: 2200.0,
517 velocity_max: 2600.0,
518 bc_value: base_bc * 0.960,
519 });
520 segments.push(BCSegmentData {
521 velocity_min: 1800.0,
522 velocity_max: 2200.0,
523 bc_value: base_bc * 0.900,
524 });
525 segments.push(BCSegmentData {
526 velocity_min: 0.0,
527 velocity_max: 1800.0,
528 bc_value: base_bc * 0.850,
529 });
530 }
531 _ => {
532 segments.push(BCSegmentData {
534 velocity_min: 2800.0,
535 velocity_max: 5000.0,
536 bc_value: base_bc,
537 });
538
539 let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
540 segments.push(BCSegmentData {
541 velocity_min: 1800.0,
542 velocity_max: 2800.0,
543 bc_value: transonic_bc,
544 });
545
546 let subsonic_bc = base_bc * (1.0 - nominal_drop);
547 segments.push(BCSegmentData {
548 velocity_min: 0.0,
549 velocity_max: 1800.0,
550 bc_value: subsonic_bc,
551 });
552 }
553 }
554
555 if drag_model.eq_ignore_ascii_case("G7") {
562 const G7_DROP_SCALE: f64 = 0.8;
563 for segment in &mut segments {
564 let drop_from_nominal = base_bc - segment.bc_value;
565 segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
566 }
567 }
568
569 if sd_factor != 1.0 {
574 for segment in &mut segments {
575 let drop_from_nominal = base_bc - segment.bc_value;
576 segment.bc_value = base_bc - drop_from_nominal / sd_factor;
577 }
578 }
579
580 segments
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn test_bullet_type_identification() {
590 assert_eq!(
591 BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
592 BulletType::MatchFlatBase
593 );
594 assert_eq!(
595 BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
596 BulletType::MatchBoatTail
597 );
598 assert_eq!(
599 BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
600 BulletType::HuntingBoatTail
601 );
602 assert_eq!(
603 BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
604 BulletType::FMJ
605 );
606 assert_eq!(
607 BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
608 BulletType::RoundNose
609 );
610 assert_eq!(
611 BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
612 BulletType::VldHighBc
613 );
614 assert_eq!(
615 BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
616 BulletType::Unknown
617 );
618 }
619
620 #[test]
621 fn test_sectional_density() {
622 let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
623 assert!((sd - 0.253).abs() < 0.001);
624 }
625
626 #[test]
627 fn test_bc_estimation() {
628 let segments =
629 BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
630
631 assert!(segments.len() >= 3);
633 assert!((segments[0].bc_value - 0.450).abs() < 0.05);
635 assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
637 }
638
639 #[test]
640 fn g7_transition_adjustment_softens_each_band_drop() {
641 let base_bc = 0.5;
642 let caliber = 1.0;
644 let weight = 1750.0;
645
646 for model in ["SMK BT", "FMJ"] {
647 let g1 =
648 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
649 let g7 =
650 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
651 let lowercase_g7 =
652 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
653 assert_eq!(g7.len(), g1.len());
654 assert_eq!(lowercase_g7.len(), g7.len());
655
656 for ((g1_band, g7_band), lowercase_band) in
657 g1.iter().zip(&g7).zip(&lowercase_g7)
658 {
659 assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
660 assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
661 assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
662 assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
663 assert_eq!(lowercase_band.bc_value.to_bits(), g7_band.bc_value.to_bits());
664 let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
665 assert!(
666 (g7_band.bc_value - expected_g7).abs() < 1e-12,
667 "{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
668 g1_band.velocity_min,
669 g1_band.velocity_max,
670 g1_band.bc_value,
671 g7_band.bc_value
672 );
673 }
674 }
675 }
676
677 #[test]
678 fn sectional_density_shapes_only_band_degradation() {
679 let base_bc = 0.5;
680 let caliber = 0.224;
681 let weight = 77.0;
682 let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
683 let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
684
685 for drag_model in ["G1", "G7"] {
686 let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
687 for (model, raw_retentions) in [
688 ("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
689 ("FMJ", &[1.0, 0.964, 0.88][..]),
690 ] {
691 let segments = BCSegmentEstimator::estimate_bc_segments(
692 base_bc,
693 caliber,
694 weight,
695 model,
696 drag_model,
697 );
698 assert_eq!(segments.len(), raw_retentions.len());
699
700 for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
701 let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
702 let expected = base_bc - raw_drop / sd_factor;
703 assert!(
704 (segment.bc_value - expected).abs() < 1e-12,
705 "{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
706 segment.velocity_min,
707 segment.velocity_max,
708 segment.bc_value
709 );
710 }
711 assert_eq!(
712 segments[0].bc_value.to_bits(),
713 base_bc.to_bits(),
714 "published muzzle BC must remain exact for {drag_model} {model}"
715 );
716 }
717 }
718
719 let high_sd_base_bc = 0.3;
722 let high_sd_segments = BCSegmentEstimator::estimate_bc_segments(
723 high_sd_base_bc,
724 0.308,
725 220.0,
726 "SMK BT",
727 "G7",
728 );
729 assert_eq!(high_sd_segments[0].bc_value.to_bits(), high_sd_base_bc.to_bits());
730 assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
731 assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
732 }
733
734 #[test]
735 fn generic_g7_bc_uses_g7_classification_space() {
736 let base_bc = 0.243;
740 let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
741
742 assert!(
743 segments.len() >= 4,
744 "G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
745 );
746 let subsonic_bc = segments.last().unwrap().bc_value;
747 assert!(
748 subsonic_bc >= base_bc * 0.92,
749 "G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
750 );
751
752 let g1_segments =
757 BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
758 assert_eq!(segments.len(), g1_segments.len());
759 let mut saw_g7_softening = false;
760 for (g7, g1) in segments.iter().zip(&g1_segments) {
761 assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
762 assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
763 let g7_retention = g7.bc_value / base_bc;
764 let g1_retention = g1.bc_value / (base_bc * 2.0);
765 assert!(g7_retention + 1e-12 >= g1_retention);
766 saw_g7_softening |= g7_retention > g1_retention + 1e-12;
767 }
768 assert!(saw_g7_softening);
769
770 let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
771 assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
772 assert_eq!(
773 legacy_g1,
774 BCSegmentEstimator::identify_bullet_type_for_drag_model(
775 "",
776 175.0,
777 0.308,
778 Some(base_bc),
779 "G1",
780 )
781 );
782 assert_eq!(
783 BCSegmentEstimator::identify_bullet_type_for_drag_model(
784 "175gr SMK",
785 175.0,
786 0.308,
787 Some(base_bc),
788 "g7",
789 ),
790 BulletType::MatchBoatTail
791 );
792 assert_eq!(
793 BCSegmentEstimator::identify_bullet_type_for_drag_model(
794 "",
795 175.0,
796 0.0,
797 Some(base_bc),
798 "G7",
799 ),
800 BulletType::Unknown
801 );
802 }
803
804 #[test]
810 fn margin_caps_at_50_fps_for_wide_adjacent_bands() {
811 assert_eq!(smoothing_margin(1000.0), 50.0);
813 assert_eq!(smoothing_margin(200.0), 50.0); }
815
816 #[test]
817 fn margin_uses_25_percent_rule_for_narrow_adjacent_bands() {
818 assert_eq!(smoothing_margin(40.0), 10.0);
820 assert_eq!(smoothing_margin(4.0), 1.0);
821 }
822
823 #[test]
824 fn margin_is_zero_for_degenerate_widths() {
825 assert_eq!(smoothing_margin(0.0), 0.0);
826 assert_eq!(smoothing_margin(-5.0), 0.0); }
828
829 #[test]
830 fn smoothstep_is_centered_and_matches_hermite_formula() {
831 assert_eq!(smoothstep(0.0), 0.0);
832 assert_eq!(smoothstep(1.0), 1.0);
833 assert_eq!(smoothstep(0.5), 0.5); assert_eq!(smoothstep(-1.0), 0.0);
836 assert_eq!(smoothstep(2.0), 1.0);
837 }
838
839 #[test]
840 fn ascending_band_boundary_blends_symmetrically_around_the_boundary() {
841 let segments = vec![
843 BCSegmentData {
844 velocity_min: 0.0,
845 velocity_max: 1000.0,
846 bc_value: 0.25,
847 },
848 BCSegmentData {
849 velocity_min: 1000.0,
850 velocity_max: 2000.0,
851 bc_value: 0.75,
852 },
853 ];
854
855 assert_eq!(velocity_segment_bc(200.0, &segments, 0.9).to_bits(), 0.25f64.to_bits());
857 assert_eq!(velocity_segment_bc(1800.0, &segments, 0.9).to_bits(), 0.75f64.to_bits());
858
859 assert_eq!(velocity_segment_bc(1000.0, &segments, 0.9), 0.5);
861
862 assert_eq!(
864 velocity_segment_bc(975.0, &segments, 0.9).to_bits(),
865 0.25f64.to_bits()
866 );
867 assert_eq!(
869 velocity_segment_bc(1025.0, &segments, 0.9).to_bits(),
870 0.75f64.to_bits()
871 );
872
873 let just_below = velocity_segment_bc(999.0, &segments, 0.9);
875 let just_above = velocity_segment_bc(1001.0, &segments, 0.9);
876 assert!(just_below > 0.25 && just_below < 0.5);
877 assert!(just_above > 0.5 && just_above < 0.75);
878 }
879
880 #[test]
881 fn descending_stored_order_matches_ascending_boundary_blend() {
882 let ascending = vec![
885 BCSegmentData {
886 velocity_min: 0.0,
887 velocity_max: 1000.0,
888 bc_value: 0.25,
889 },
890 BCSegmentData {
891 velocity_min: 1000.0,
892 velocity_max: 2000.0,
893 bc_value: 0.75,
894 },
895 ];
896 let mut descending = ascending.clone();
897 descending.reverse();
898
899 for v in [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0] {
900 assert_eq!(
901 velocity_segment_bc(v, &ascending, 0.9),
902 velocity_segment_bc(v, &descending, 0.9),
903 "order must not affect the blended result at v={v}"
904 );
905 }
906 }
907
908 #[test]
916 fn cache_gives_each_distinct_table_its_own_byte_identical_answer_when_interleaved() {
917 let table_a = vec![
918 BCSegmentData { velocity_min: 0.0, velocity_max: 1000.0, bc_value: 0.25 },
919 BCSegmentData { velocity_min: 1000.0, velocity_max: 2000.0, bc_value: 0.75 },
920 ];
921 let table_b = vec![
922 BCSegmentData { velocity_min: 0.0, velocity_max: 900.0, bc_value: 0.40 },
923 BCSegmentData { velocity_min: 900.0, velocity_max: 1800.0, bc_value: 0.60 },
924 ];
925
926 let probes = [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0];
928 let expected_a: Vec<u64> = probes
929 .iter()
930 .map(|&v| velocity_segment_bc(v, &table_a, 0.9).to_bits())
931 .collect();
932 let expected_b: Vec<u64> = probes
933 .iter()
934 .map(|&v| velocity_segment_bc(v, &table_b, 0.9).to_bits())
935 .collect();
936
937 for (i, &v) in probes.iter().enumerate() {
939 assert_eq!(
940 velocity_segment_bc(v, &table_a, 0.9).to_bits(),
941 expected_a[i],
942 "table A must be unaffected by interleaved table B calls (v={v})"
943 );
944 assert_eq!(
945 velocity_segment_bc(v, &table_b, 0.9).to_bits(),
946 expected_b[i],
947 "table B must be unaffected by interleaved table A calls (v={v})"
948 );
949 }
950 }
951
952 #[test]
953 fn gapped_table_blends_both_edges_of_the_fallback_gap_and_stays_flat_mid_gap() {
954 let segments = vec![
957 BCSegmentData {
958 velocity_min: 0.0,
959 velocity_max: 1000.0,
960 bc_value: 0.25,
961 },
962 BCSegmentData {
963 velocity_min: 1200.0,
964 velocity_max: 2200.0,
965 bc_value: 0.75,
966 },
967 ];
968 let fallback = 0.5; assert_eq!(
972 velocity_segment_bc(1100.0, &segments, fallback).to_bits(),
973 fallback.to_bits()
974 );
975
976 assert_eq!(velocity_segment_bc(1000.0, &segments, fallback), 0.375); assert_eq!(velocity_segment_bc(1200.0, &segments, fallback), 0.625); assert_eq!(
984 velocity_segment_bc(974.0, &segments, fallback).to_bits(),
985 0.25f64.to_bits()
986 );
987 assert_eq!(
988 velocity_segment_bc(1226.0, &segments, fallback).to_bits(),
989 0.75f64.to_bits()
990 );
991 }
992
993 #[test]
994 fn single_segment_table_never_blends_and_is_byte_identical_to_the_raw_lookup() {
995 let single = vec![BCSegmentData {
996 velocity_min: 1000.0,
997 velocity_max: 2000.0,
998 bc_value: 0.5,
999 }];
1000
1001 for v in [-1e6, 500.0, 1000.0, 1500.0, 1999.999, 2000.0, 1e6] {
1002 assert_eq!(
1003 velocity_segment_bc(v, &single, 0.9).to_bits(),
1004 raw_velocity_segment_bc(v, &single, 0.9).to_bits(),
1005 "single-band tables must never blend (v={v})"
1006 );
1007 }
1008 }
1009
1010 #[test]
1011 fn empty_table_never_blends_and_always_returns_the_fallback_exactly() {
1012 let empty: Vec<BCSegmentData> = vec![];
1013 for v in [-1e6, 0.0, 500.0, 1e6] {
1014 assert_eq!(velocity_segment_bc(v, &empty, 0.73).to_bits(), 0.73f64.to_bits());
1015 }
1016 }
1017
1018 #[test]
1019 fn zero_width_adjacent_band_disables_blending_at_its_boundaries() {
1020 let segments = vec![
1025 BCSegmentData {
1026 velocity_min: 0.0,
1027 velocity_max: 1000.0,
1028 bc_value: 0.5,
1029 },
1030 BCSegmentData {
1031 velocity_min: 1000.0,
1032 velocity_max: 1000.0,
1033 bc_value: 0.9,
1034 },
1035 BCSegmentData {
1036 velocity_min: 1000.0,
1037 velocity_max: 2000.0,
1038 bc_value: 0.6,
1039 },
1040 ];
1041
1042 for v in [999.0, 999.99, 1000.0, 1000.01, 1001.0] {
1043 assert_eq!(
1044 velocity_segment_bc(v, &segments, 0.99).to_bits(),
1045 raw_velocity_segment_bc(v, &segments, 0.99).to_bits(),
1046 "a degenerate adjacent band must disable blending, not average toward it (v={v})"
1047 );
1048 }
1049 }
1050
1051 #[test]
1052 fn coverage_entry_and_exit_edges_stay_flat_since_the_clamp_matches_the_bordering_band() {
1053 let segments = vec![
1058 BCSegmentData {
1059 velocity_min: 1000.0,
1060 velocity_max: 2000.0,
1061 bc_value: 0.25,
1062 },
1063 BCSegmentData {
1064 velocity_min: 2000.0,
1065 velocity_max: 3000.0,
1066 bc_value: 0.75,
1067 },
1068 ];
1069
1070 for v in [-1e6, -100.0, 999.0, 1000.0, 1001.0] {
1071 assert_eq!(
1072 velocity_segment_bc(v, &segments, 0.5).to_bits(),
1073 0.25f64.to_bits(),
1074 "below-coverage clamp must stay exactly flat (v={v})"
1075 );
1076 }
1077 for v in [2999.0, 3000.0, 3001.0, 1e6] {
1078 assert_eq!(
1079 velocity_segment_bc(v, &segments, 0.5).to_bits(),
1080 0.75f64.to_bits(),
1081 "above-coverage clamp must stay exactly flat (v={v})"
1082 );
1083 }
1084 }
1085}