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
101pub(crate) fn has_interior_gap(segments: &[BCSegmentData]) -> bool {
109 let mut sorted: Vec<&BCSegmentData> = segments.iter().collect();
110 sorted.sort_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
111 let mut covered_to = f64::NEG_INFINITY;
112 for segment in sorted {
113 if covered_to.is_finite() && segment.velocity_min > covered_to {
114 return true;
115 }
116 covered_to = covered_to.max(segment.velocity_max);
117 }
118 false
119}
120
121fn raw_velocity_segment_bc(velocity_fps: f64, segments: &[BCSegmentData], fallback_bc: f64) -> f64 {
124 if let Some(segment) = segments
125 .iter()
126 .find(|segment| velocity_fps >= segment.velocity_min && velocity_fps < segment.velocity_max)
127 {
128 return segment.bc_value;
129 }
130
131 let lowest = segments
132 .iter()
133 .min_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
134 if let Some(segment) = lowest {
135 if velocity_fps < segment.velocity_min {
136 return segment.bc_value;
137 }
138 }
139
140 let highest = segments
141 .iter()
142 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max));
143 if let Some(segment) = highest {
144 if velocity_fps >= segment.velocity_max {
145 return segment.bc_value;
146 }
147 }
148
149 fallback_bc
150}
151
152fn transition_boundaries(segments: &[BCSegmentData]) -> Vec<(f64, f64)> {
161 let width = |s: &BCSegmentData| (s.velocity_max - s.velocity_min).max(0.0);
162
163 let mut sorted: Vec<&BCSegmentData> = segments.iter().collect();
164 sorted.sort_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
165
166 let mut boundaries = Vec::with_capacity(sorted.len() * 2 + 2);
167
168 if let Some(first) = sorted.first() {
171 boundaries.push((first.velocity_min, smoothing_margin(width(first))));
172 }
173 if let Some(highest) = segments
177 .iter()
178 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max))
179 {
180 boundaries.push((highest.velocity_max, smoothing_margin(width(highest))));
181 }
182
183 for pair in sorted.windows(2) {
184 let (left, right) = (pair[0], pair[1]);
185 if right.velocity_min > left.velocity_max {
186 let gap_width = right.velocity_min - left.velocity_max;
190 boundaries.push((
191 left.velocity_max,
192 smoothing_margin(width(left).min(gap_width)),
193 ));
194 boundaries.push((
195 right.velocity_min,
196 smoothing_margin(gap_width.min(width(right))),
197 ));
198 } else if right.velocity_min == left.velocity_max {
199 boundaries.push((
201 left.velocity_max,
202 smoothing_margin(width(left).min(width(right))),
203 ));
204 }
205 }
212
213 boundaries
214}
215
216fn smoothing_margin(narrower_adjacent_width: f64) -> f64 {
219 if narrower_adjacent_width <= 0.0 {
220 return 0.0;
221 }
222 (0.25 * narrower_adjacent_width).min(50.0)
223}
224
225fn smoothstep(t: f64) -> f64 {
227 let t = t.clamp(0.0, 1.0);
228 t * t * (3.0 - 2.0 * t)
229}
230
231#[derive(Debug, Clone, Copy, PartialEq)]
233pub enum BulletType {
234 MatchBoatTail,
235 MatchFlatBase,
236 HuntingBoatTail,
237 HuntingFlatBase,
238 VldHighBc,
239 Hybrid,
240 FMJ,
241 RoundNose,
242 Unknown,
243}
244
245pub struct BulletTypeFactors {
247 pub drop: f64,
248 pub transition_curve: f64,
249}
250
251impl BulletType {
252 pub fn get_factors(&self) -> BulletTypeFactors {
254 match self {
255 BulletType::MatchBoatTail => BulletTypeFactors {
256 drop: 0.075, transition_curve: 0.3,
258 },
259 BulletType::MatchFlatBase => BulletTypeFactors {
260 drop: 0.10, transition_curve: 0.35,
262 },
263 BulletType::HuntingBoatTail => BulletTypeFactors {
264 drop: 0.15, transition_curve: 0.45,
266 },
267 BulletType::HuntingFlatBase => BulletTypeFactors {
268 drop: 0.20, transition_curve: 0.5,
270 },
271 BulletType::VldHighBc => BulletTypeFactors {
272 drop: 0.05, transition_curve: 0.25,
274 },
275 BulletType::Hybrid => BulletTypeFactors {
276 drop: 0.06, transition_curve: 0.28,
278 },
279 BulletType::FMJ => BulletTypeFactors {
280 drop: 0.12, transition_curve: 0.4,
282 },
283 BulletType::RoundNose => BulletTypeFactors {
284 drop: 0.35, transition_curve: 0.7,
286 },
287 BulletType::Unknown => BulletTypeFactors {
288 drop: 0.15, transition_curve: 0.5,
290 },
291 }
292 }
293}
294
295pub struct BCSegmentEstimator;
297
298impl BCSegmentEstimator {
299 pub fn identify_bullet_type(
305 model: &str,
306 weight: f64,
307 caliber: f64,
308 bc_value: Option<f64>,
309 ) -> BulletType {
310 Self::identify_bullet_type_for_drag_model(model, weight, caliber, bc_value, "G1")
311 }
312
313 pub fn identify_bullet_type_for_drag_model(
315 model: &str,
316 weight: f64,
317 caliber: f64,
318 bc_value: Option<f64>,
319 drag_model: &str,
320 ) -> BulletType {
321 let model_lower = model.to_lowercase();
322
323 if model_lower.contains("vld")
325 || model_lower.contains("berger")
326 || model_lower.contains("hybrid")
327 || model_lower.contains("elite")
328 {
329 if model_lower.contains("hybrid") {
330 return BulletType::Hybrid;
331 }
332 return BulletType::VldHighBc;
333 }
334
335 if model_lower.contains("smk")
337 || model_lower.contains("matchking")
338 || model_lower.contains("match")
339 || model_lower.contains("bthp")
340 || model_lower.contains("competition")
341 || model_lower.contains("target")
342 || model_lower.contains("a-max")
343 || model_lower.contains("eld-m")
344 || model_lower.contains("scenar")
345 || model_lower.contains("x-ring")
346 {
347 if model_lower.contains("bt") || model_lower.contains("boat") {
349 return BulletType::MatchBoatTail;
350 }
351 if let Some(bc) = bc_value {
354 let sd = Self::calculate_sectional_density(weight, caliber);
355 if sd > 0.0 && Self::classification_bc_sd_ratio(bc, sd, drag_model) > 1.6 {
356 return BulletType::MatchBoatTail;
357 }
358 }
359 return BulletType::MatchFlatBase;
360 }
361
362 if model_lower.contains("gameking")
364 || model_lower.contains("hunting")
365 || model_lower.contains("sst")
366 || model_lower.contains("eld-x")
367 || model_lower.contains("partition")
368 || model_lower.contains("accubond")
369 || model_lower.contains("core-lokt")
370 || model_lower.contains("ballistic tip")
371 || model_lower.contains("v-max")
372 || model_lower.contains("hornady sp")
373 || model_lower.contains("interlock")
374 || model_lower.contains("tsx")
375 {
376 if model_lower.contains("bt")
378 || model_lower.contains("boat")
379 || model_lower.contains("sst")
380 || model_lower.contains("accubond")
381 {
382 return BulletType::HuntingBoatTail;
383 }
384 return BulletType::HuntingFlatBase;
385 }
386
387 if model_lower.contains("fmj")
389 || model_lower.contains("ball")
390 || model_lower.contains("m80")
391 || model_lower.contains("m855")
392 || model_lower.contains("tracer")
393 {
394 return BulletType::FMJ;
395 }
396
397 if model_lower.contains("rn")
399 || model_lower.contains("round nose")
400 || model_lower.contains("rnsp")
401 {
402 return BulletType::RoundNose;
403 }
404
405 if let Some(bc) = bc_value {
409 let sd = Self::calculate_sectional_density(weight, caliber);
410 if sd > 0.0 {
411 let bc_to_sd_ratio = Self::classification_bc_sd_ratio(bc, sd, drag_model);
412
413 if bc_to_sd_ratio > 1.8 {
414 return BulletType::VldHighBc;
415 } else if bc_to_sd_ratio > 1.5 {
416 return BulletType::MatchBoatTail;
417 } else if bc_to_sd_ratio < 1.2 {
418 return BulletType::HuntingFlatBase;
419 }
420 }
421 }
422
423 BulletType::Unknown
424 }
425
426 fn classification_bc_sd_ratio(bc: f64, sd: f64, drag_model: &str) -> f64 {
431 let g1_equivalent_bc = if drag_model.eq_ignore_ascii_case("G7") {
432 bc * 2.0
433 } else {
434 bc
435 };
436 g1_equivalent_bc / sd
437 }
438
439 pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
441 if caliber_inches <= 0.0 {
444 return 0.0;
445 }
446 weight_grains / (7000.0 * caliber_inches * caliber_inches)
447 }
448
449 #[allow(clippy::manual_clamp)] pub fn estimate_bc_segments(
452 base_bc: f64,
453 caliber: f64,
454 weight: f64,
455 model: &str,
456 drag_model: &str,
457 ) -> Vec<BCSegmentData> {
458 let bullet_type = Self::identify_bullet_type_for_drag_model(
460 model,
461 weight,
462 caliber,
463 Some(base_bc),
464 drag_model,
465 );
466 let type_factors = bullet_type.get_factors();
467
468 let sd = Self::calculate_sectional_density(weight, caliber);
470
471 let sd_factor = (sd / 0.25).max(0.7).min(1.3);
474 let nominal_drop = type_factors.drop;
475
476 let mut segments = Vec::new();
478
479 match bullet_type {
481 BulletType::MatchBoatTail => {
482 segments.push(BCSegmentData {
484 velocity_min: 2800.0,
485 velocity_max: 5000.0,
486 bc_value: base_bc * 1.000,
487 });
488 segments.push(BCSegmentData {
489 velocity_min: 2400.0,
490 velocity_max: 2800.0,
491 bc_value: base_bc * 0.985,
492 });
493 segments.push(BCSegmentData {
494 velocity_min: 2000.0,
495 velocity_max: 2400.0,
496 bc_value: base_bc * 0.965,
497 });
498 segments.push(BCSegmentData {
499 velocity_min: 1600.0,
500 velocity_max: 2000.0,
501 bc_value: base_bc * 0.945,
502 });
503 segments.push(BCSegmentData {
504 velocity_min: 0.0,
505 velocity_max: 1600.0,
506 bc_value: base_bc * 0.925,
507 });
508 }
509 BulletType::VldHighBc | BulletType::Hybrid => {
510 segments.push(BCSegmentData {
512 velocity_min: 2800.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: 2800.0,
519 bc_value: base_bc * 0.990,
520 });
521 segments.push(BCSegmentData {
522 velocity_min: 1600.0,
523 velocity_max: 2200.0,
524 bc_value: base_bc * 0.970,
525 });
526 segments.push(BCSegmentData {
527 velocity_min: 0.0,
528 velocity_max: 1600.0,
529 bc_value: base_bc * 0.950,
530 });
531 }
532 BulletType::HuntingBoatTail => {
533 segments.push(BCSegmentData {
535 velocity_min: 2600.0,
536 velocity_max: 5000.0,
537 bc_value: base_bc * 1.000,
538 });
539 segments.push(BCSegmentData {
540 velocity_min: 2200.0,
541 velocity_max: 2600.0,
542 bc_value: base_bc * 0.960,
543 });
544 segments.push(BCSegmentData {
545 velocity_min: 1800.0,
546 velocity_max: 2200.0,
547 bc_value: base_bc * 0.900,
548 });
549 segments.push(BCSegmentData {
550 velocity_min: 0.0,
551 velocity_max: 1800.0,
552 bc_value: base_bc * 0.850,
553 });
554 }
555 _ => {
556 segments.push(BCSegmentData {
558 velocity_min: 2800.0,
559 velocity_max: 5000.0,
560 bc_value: base_bc,
561 });
562
563 let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
564 segments.push(BCSegmentData {
565 velocity_min: 1800.0,
566 velocity_max: 2800.0,
567 bc_value: transonic_bc,
568 });
569
570 let subsonic_bc = base_bc * (1.0 - nominal_drop);
571 segments.push(BCSegmentData {
572 velocity_min: 0.0,
573 velocity_max: 1800.0,
574 bc_value: subsonic_bc,
575 });
576 }
577 }
578
579 if drag_model.eq_ignore_ascii_case("G7") {
586 const G7_DROP_SCALE: f64 = 0.8;
587 for segment in &mut segments {
588 let drop_from_nominal = base_bc - segment.bc_value;
589 segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
590 }
591 }
592
593 if sd_factor != 1.0 {
598 for segment in &mut segments {
599 let drop_from_nominal = base_bc - segment.bc_value;
600 segment.bc_value = base_bc - drop_from_nominal / sd_factor;
601 }
602 }
603
604 segments
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn test_bullet_type_identification() {
614 assert_eq!(
615 BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
616 BulletType::MatchFlatBase
617 );
618 assert_eq!(
619 BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
620 BulletType::MatchBoatTail
621 );
622 assert_eq!(
623 BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
624 BulletType::HuntingBoatTail
625 );
626 assert_eq!(
627 BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
628 BulletType::FMJ
629 );
630 assert_eq!(
631 BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
632 BulletType::RoundNose
633 );
634 assert_eq!(
635 BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
636 BulletType::VldHighBc
637 );
638 assert_eq!(
639 BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
640 BulletType::Unknown
641 );
642 }
643
644 #[test]
645 fn test_sectional_density() {
646 let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
647 assert!((sd - 0.253).abs() < 0.001);
648 }
649
650 #[test]
651 fn test_bc_estimation() {
652 let segments =
653 BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
654
655 assert!(segments.len() >= 3);
657 assert!((segments[0].bc_value - 0.450).abs() < 0.05);
659 assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
661 }
662
663 #[test]
664 fn g7_transition_adjustment_softens_each_band_drop() {
665 let base_bc = 0.5;
666 let caliber = 1.0;
668 let weight = 1750.0;
669
670 for model in ["SMK BT", "FMJ"] {
671 let g1 =
672 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
673 let g7 =
674 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
675 let lowercase_g7 =
676 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
677 assert_eq!(g7.len(), g1.len());
678 assert_eq!(lowercase_g7.len(), g7.len());
679
680 for ((g1_band, g7_band), lowercase_band) in g1.iter().zip(&g7).zip(&lowercase_g7) {
681 assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
682 assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
683 assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
684 assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
685 assert_eq!(
686 lowercase_band.bc_value.to_bits(),
687 g7_band.bc_value.to_bits()
688 );
689 let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
690 assert!(
691 (g7_band.bc_value - expected_g7).abs() < 1e-12,
692 "{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
693 g1_band.velocity_min,
694 g1_band.velocity_max,
695 g1_band.bc_value,
696 g7_band.bc_value
697 );
698 }
699 }
700 }
701
702 #[test]
703 fn sectional_density_shapes_only_band_degradation() {
704 let base_bc = 0.5;
705 let caliber = 0.224;
706 let weight = 77.0;
707 let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
708 let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
709
710 for drag_model in ["G1", "G7"] {
711 let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
712 for (model, raw_retentions) in [
713 ("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
714 ("FMJ", &[1.0, 0.964, 0.88][..]),
715 ] {
716 let segments = BCSegmentEstimator::estimate_bc_segments(
717 base_bc, caliber, weight, model, drag_model,
718 );
719 assert_eq!(segments.len(), raw_retentions.len());
720
721 for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
722 let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
723 let expected = base_bc - raw_drop / sd_factor;
724 assert!(
725 (segment.bc_value - expected).abs() < 1e-12,
726 "{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
727 segment.velocity_min,
728 segment.velocity_max,
729 segment.bc_value
730 );
731 }
732 assert_eq!(
733 segments[0].bc_value.to_bits(),
734 base_bc.to_bits(),
735 "published muzzle BC must remain exact for {drag_model} {model}"
736 );
737 }
738 }
739
740 let high_sd_base_bc = 0.3;
743 let high_sd_segments =
744 BCSegmentEstimator::estimate_bc_segments(high_sd_base_bc, 0.308, 220.0, "SMK BT", "G7");
745 assert_eq!(
746 high_sd_segments[0].bc_value.to_bits(),
747 high_sd_base_bc.to_bits()
748 );
749 assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
750 assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
751 }
752
753 #[test]
754 fn generic_g7_bc_uses_g7_classification_space() {
755 let base_bc = 0.243;
759 let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
760
761 assert!(
762 segments.len() >= 4,
763 "G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
764 );
765 let subsonic_bc = segments.last().unwrap().bc_value;
766 assert!(
767 subsonic_bc >= base_bc * 0.92,
768 "G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
769 );
770
771 let g1_segments =
776 BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
777 assert_eq!(segments.len(), g1_segments.len());
778 let mut saw_g7_softening = false;
779 for (g7, g1) in segments.iter().zip(&g1_segments) {
780 assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
781 assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
782 let g7_retention = g7.bc_value / base_bc;
783 let g1_retention = g1.bc_value / (base_bc * 2.0);
784 assert!(g7_retention + 1e-12 >= g1_retention);
785 saw_g7_softening |= g7_retention > g1_retention + 1e-12;
786 }
787 assert!(saw_g7_softening);
788
789 let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
790 assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
791 assert_eq!(
792 legacy_g1,
793 BCSegmentEstimator::identify_bullet_type_for_drag_model(
794 "",
795 175.0,
796 0.308,
797 Some(base_bc),
798 "G1",
799 )
800 );
801 assert_eq!(
802 BCSegmentEstimator::identify_bullet_type_for_drag_model(
803 "175gr SMK",
804 175.0,
805 0.308,
806 Some(base_bc),
807 "g7",
808 ),
809 BulletType::MatchBoatTail
810 );
811 assert_eq!(
812 BCSegmentEstimator::identify_bullet_type_for_drag_model(
813 "",
814 175.0,
815 0.0,
816 Some(base_bc),
817 "G7",
818 ),
819 BulletType::Unknown
820 );
821 }
822
823 #[test]
829 fn margin_caps_at_50_fps_for_wide_adjacent_bands() {
830 assert_eq!(smoothing_margin(1000.0), 50.0);
832 assert_eq!(smoothing_margin(200.0), 50.0); }
834
835 #[test]
836 fn margin_uses_25_percent_rule_for_narrow_adjacent_bands() {
837 assert_eq!(smoothing_margin(40.0), 10.0);
839 assert_eq!(smoothing_margin(4.0), 1.0);
840 }
841
842 #[test]
843 fn margin_is_zero_for_degenerate_widths() {
844 assert_eq!(smoothing_margin(0.0), 0.0);
845 assert_eq!(smoothing_margin(-5.0), 0.0); }
847
848 #[test]
849 fn smoothstep_is_centered_and_matches_hermite_formula() {
850 assert_eq!(smoothstep(0.0), 0.0);
851 assert_eq!(smoothstep(1.0), 1.0);
852 assert_eq!(smoothstep(0.5), 0.5); assert_eq!(smoothstep(-1.0), 0.0);
855 assert_eq!(smoothstep(2.0), 1.0);
856 }
857
858 #[test]
859 fn ascending_band_boundary_blends_symmetrically_around_the_boundary() {
860 let segments = vec![
862 BCSegmentData {
863 velocity_min: 0.0,
864 velocity_max: 1000.0,
865 bc_value: 0.25,
866 },
867 BCSegmentData {
868 velocity_min: 1000.0,
869 velocity_max: 2000.0,
870 bc_value: 0.75,
871 },
872 ];
873
874 assert_eq!(
876 velocity_segment_bc(200.0, &segments, 0.9).to_bits(),
877 0.25f64.to_bits()
878 );
879 assert_eq!(
880 velocity_segment_bc(1800.0, &segments, 0.9).to_bits(),
881 0.75f64.to_bits()
882 );
883
884 assert_eq!(velocity_segment_bc(1000.0, &segments, 0.9), 0.5);
886
887 assert_eq!(
889 velocity_segment_bc(975.0, &segments, 0.9).to_bits(),
890 0.25f64.to_bits()
891 );
892 assert_eq!(
894 velocity_segment_bc(1025.0, &segments, 0.9).to_bits(),
895 0.75f64.to_bits()
896 );
897
898 let just_below = velocity_segment_bc(999.0, &segments, 0.9);
900 let just_above = velocity_segment_bc(1001.0, &segments, 0.9);
901 assert!(just_below > 0.25 && just_below < 0.5);
902 assert!(just_above > 0.5 && just_above < 0.75);
903 }
904
905 #[test]
906 fn descending_stored_order_matches_ascending_boundary_blend() {
907 let ascending = vec![
910 BCSegmentData {
911 velocity_min: 0.0,
912 velocity_max: 1000.0,
913 bc_value: 0.25,
914 },
915 BCSegmentData {
916 velocity_min: 1000.0,
917 velocity_max: 2000.0,
918 bc_value: 0.75,
919 },
920 ];
921 let mut descending = ascending.clone();
922 descending.reverse();
923
924 for v in [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0] {
925 assert_eq!(
926 velocity_segment_bc(v, &ascending, 0.9),
927 velocity_segment_bc(v, &descending, 0.9),
928 "order must not affect the blended result at v={v}"
929 );
930 }
931 }
932
933 #[test]
941 fn cache_gives_each_distinct_table_its_own_byte_identical_answer_when_interleaved() {
942 let table_a = vec![
943 BCSegmentData {
944 velocity_min: 0.0,
945 velocity_max: 1000.0,
946 bc_value: 0.25,
947 },
948 BCSegmentData {
949 velocity_min: 1000.0,
950 velocity_max: 2000.0,
951 bc_value: 0.75,
952 },
953 ];
954 let table_b = vec![
955 BCSegmentData {
956 velocity_min: 0.0,
957 velocity_max: 900.0,
958 bc_value: 0.40,
959 },
960 BCSegmentData {
961 velocity_min: 900.0,
962 velocity_max: 1800.0,
963 bc_value: 0.60,
964 },
965 ];
966
967 let probes = [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0];
969 let expected_a: Vec<u64> = probes
970 .iter()
971 .map(|&v| velocity_segment_bc(v, &table_a, 0.9).to_bits())
972 .collect();
973 let expected_b: Vec<u64> = probes
974 .iter()
975 .map(|&v| velocity_segment_bc(v, &table_b, 0.9).to_bits())
976 .collect();
977
978 for (i, &v) in probes.iter().enumerate() {
980 assert_eq!(
981 velocity_segment_bc(v, &table_a, 0.9).to_bits(),
982 expected_a[i],
983 "table A must be unaffected by interleaved table B calls (v={v})"
984 );
985 assert_eq!(
986 velocity_segment_bc(v, &table_b, 0.9).to_bits(),
987 expected_b[i],
988 "table B must be unaffected by interleaved table A calls (v={v})"
989 );
990 }
991 }
992
993 #[test]
994 fn gapped_table_blends_both_edges_of_the_fallback_gap_and_stays_flat_mid_gap() {
995 let segments = vec![
998 BCSegmentData {
999 velocity_min: 0.0,
1000 velocity_max: 1000.0,
1001 bc_value: 0.25,
1002 },
1003 BCSegmentData {
1004 velocity_min: 1200.0,
1005 velocity_max: 2200.0,
1006 bc_value: 0.75,
1007 },
1008 ];
1009 let fallback = 0.5; assert_eq!(
1013 velocity_segment_bc(1100.0, &segments, fallback).to_bits(),
1014 fallback.to_bits()
1015 );
1016
1017 assert_eq!(velocity_segment_bc(1000.0, &segments, fallback), 0.375); assert_eq!(velocity_segment_bc(1200.0, &segments, fallback), 0.625); assert_eq!(
1025 velocity_segment_bc(974.0, &segments, fallback).to_bits(),
1026 0.25f64.to_bits()
1027 );
1028 assert_eq!(
1029 velocity_segment_bc(1226.0, &segments, fallback).to_bits(),
1030 0.75f64.to_bits()
1031 );
1032 }
1033
1034 #[test]
1035 fn single_segment_table_never_blends_and_is_byte_identical_to_the_raw_lookup() {
1036 let single = vec![BCSegmentData {
1037 velocity_min: 1000.0,
1038 velocity_max: 2000.0,
1039 bc_value: 0.5,
1040 }];
1041
1042 for v in [-1e6, 500.0, 1000.0, 1500.0, 1999.999, 2000.0, 1e6] {
1043 assert_eq!(
1044 velocity_segment_bc(v, &single, 0.9).to_bits(),
1045 raw_velocity_segment_bc(v, &single, 0.9).to_bits(),
1046 "single-band tables must never blend (v={v})"
1047 );
1048 }
1049 }
1050
1051 #[test]
1052 fn empty_table_never_blends_and_always_returns_the_fallback_exactly() {
1053 let empty: Vec<BCSegmentData> = vec![];
1054 for v in [-1e6, 0.0, 500.0, 1e6] {
1055 assert_eq!(
1056 velocity_segment_bc(v, &empty, 0.73).to_bits(),
1057 0.73f64.to_bits()
1058 );
1059 }
1060 }
1061
1062 #[test]
1063 fn zero_width_adjacent_band_disables_blending_at_its_boundaries() {
1064 let segments = vec![
1069 BCSegmentData {
1070 velocity_min: 0.0,
1071 velocity_max: 1000.0,
1072 bc_value: 0.5,
1073 },
1074 BCSegmentData {
1075 velocity_min: 1000.0,
1076 velocity_max: 1000.0,
1077 bc_value: 0.9,
1078 },
1079 BCSegmentData {
1080 velocity_min: 1000.0,
1081 velocity_max: 2000.0,
1082 bc_value: 0.6,
1083 },
1084 ];
1085
1086 for v in [999.0, 999.99, 1000.0, 1000.01, 1001.0] {
1087 assert_eq!(
1088 velocity_segment_bc(v, &segments, 0.99).to_bits(),
1089 raw_velocity_segment_bc(v, &segments, 0.99).to_bits(),
1090 "a degenerate adjacent band must disable blending, not average toward it (v={v})"
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn coverage_entry_and_exit_edges_stay_flat_since_the_clamp_matches_the_bordering_band() {
1097 let segments = vec![
1102 BCSegmentData {
1103 velocity_min: 1000.0,
1104 velocity_max: 2000.0,
1105 bc_value: 0.25,
1106 },
1107 BCSegmentData {
1108 velocity_min: 2000.0,
1109 velocity_max: 3000.0,
1110 bc_value: 0.75,
1111 },
1112 ];
1113
1114 for v in [-1e6, -100.0, 999.0, 1000.0, 1001.0] {
1115 assert_eq!(
1116 velocity_segment_bc(v, &segments, 0.5).to_bits(),
1117 0.25f64.to_bits(),
1118 "below-coverage clamp must stay exactly flat (v={v})"
1119 );
1120 }
1121 for v in [2999.0, 3000.0, 3001.0, 1e6] {
1122 assert_eq!(
1123 velocity_segment_bc(v, &segments, 0.5).to_bits(),
1124 0.75f64.to_bits(),
1125 "above-coverage clamp must stay exactly flat (v={v})"
1126 );
1127 }
1128 }
1129
1130 #[test]
1131 fn has_interior_gap_is_exactly_where_the_lookup_returns_the_fallback() {
1132 let band = |velocity_min: f64, velocity_max: f64, bc_value: f64| BCSegmentData {
1133 velocity_min,
1134 velocity_max,
1135 bc_value,
1136 };
1137 let cases = [
1138 (
1139 "touching",
1140 vec![band(500.0, 2000.0, 0.42), band(2000.0, 4000.0, 0.45)],
1141 false,
1142 ),
1143 (
1144 "gapped",
1145 vec![band(500.0, 1800.0, 0.42), band(2300.0, 4000.0, 0.45)],
1146 true,
1147 ),
1148 (
1149 "gapped, descending",
1150 vec![band(2300.0, 4000.0, 0.45), band(500.0, 1800.0, 0.42)],
1151 true,
1152 ),
1153 (
1154 "overlaps covering the span",
1155 vec![
1156 band(500.0, 3000.0, 0.40),
1157 band(1000.0, 1500.0, 0.42),
1158 band(1600.0, 2000.0, 0.44),
1159 ],
1160 false,
1161 ),
1162 ("single", vec![band(500.0, 4000.0, 0.45)], false),
1163 (
1164 "three bands, one gap",
1165 vec![
1166 band(2500.0, 4000.0, 0.46),
1167 band(1500.0, 2500.0, 0.44),
1168 band(500.0, 1200.0, 0.40),
1169 ],
1170 true,
1171 ),
1172 ];
1173 for (label, ladder, expected) in cases {
1174 let lookup_falls_back =
1175 (0..5000).any(|v| raw_velocity_segment_bc(f64::from(v), &ladder, -1.0) == -1.0);
1176 assert_eq!(lookup_falls_back, expected, "{label}: the case itself");
1177 assert_eq!(has_interior_gap(&ladder), expected, "{label}");
1178 }
1179 }
1180}