Skip to main content

ballistics_engine/
bc_estimation.rs

1use crate::BCSegmentData;
2use std::cell::RefCell;
3
4thread_local! {
5    /// 0.28.1 sweep perf hoist: `transition_boundaries` is pure over `segments`, but is
6    /// otherwise re-derived (sorting the table and allocating both a boundaries `Vec` and a
7    /// sorted-reference `Vec`) on EVERY [`velocity_segment_bc`] call. That function sits in
8    /// the RK4/derivative hot path (`derivatives::get_bc_for_velocity`'s fast path) and is
9    /// called several times per integration step, thousands of times per trajectory, with
10    /// the exact same `segments` table for the whole solve -- MBA-955 already established
11    /// this precedent (pre-populate once, don't rebuild per step) for the sibling BC
12    /// estimation path in this same call chain. Cache the boundaries for the last-seen
13    /// `segments` content per thread and reuse them while unchanged, instead of
14    /// re-sorting/re-allocating on every call. Content (not pointer) keyed, so it stays
15    /// correct even if a `Vec`'s address happens to be reused across distinct solves; a
16    /// changed table (a new trajectory, or Monte Carlo redrawing BC) just recomputes.
17    static BOUNDARY_CACHE: RefCell<Option<CachedBoundaries>> = const { RefCell::new(None) };
18}
19
20/// One thread's memoized boundary set: the `segments` content it was computed from, and the
21/// `(boundary_velocity, margin)` pairs `velocity_segment_bc` blends across.
22type CachedBoundaries = (Vec<BCSegmentData>, Vec<(f64, f64)>);
23
24/// Bit-exact equality for the fields `transition_boundaries` depends on (order-sensitive:
25/// the cache must miss on a reordered table even though `transition_boundaries` internally
26/// sorts, since a differently-ordered `segments` slice is still a different cache key here).
27fn 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
36/// Resolve a velocity-keyed BC table without assuming segment order.
37///
38/// Bands are half-open (`velocity_min <= v < velocity_max`), so a shared boundary belongs to
39/// the upper band. Below/above global coverage, clamp to the lowest/highest-velocity band;
40/// an interior coverage gap or empty table uses the caller's projectile-specific scalar BC.
41///
42/// MBA-1404: away from a transition this is byte-identical to the original step/clamp
43/// lookup (see [`raw_velocity_segment_bc`], preserved verbatim). Near every discontinuity
44/// class the raw lookup can produce -- an interior band-to-band boundary, either edge of an
45/// interior band-to-fallback gap, or a global coverage entry/exit edge (below the lowest
46/// band's `velocity_min`, above the highest band's `velocity_max`) -- this instead blends
47/// across a small velocity margin centered on the boundary with a Hermite smoothstep
48/// (`3t^2 - 2t^3`), so an integrator sampling BC every step does not see an instantaneous
49/// drag-coefficient jolt exactly at a band edge. Tables with fewer than two segments have no
50/// adjacent band to blend against and return the raw value unchanged.
51pub(crate) fn velocity_segment_bc(
52    velocity_fps: f64,
53    segments: &[BCSegmentData],
54    fallback_bc: f64,
55) -> f64 {
56    if segments.len() < 2 {
57        // No adjacent band to blend against: single-band and empty tables stay exactly the
58        // pre-MBA-1404 behavior.
59        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                // Degenerate (zero/negative-width) adjacent band: no blend, keep the hard step.
76                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                // Nothing actually jumps here (e.g. a continuous coverage-clamp edge): stay
89                // flat rather than run smoothstep algebra that would return the same value.
90                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
101/// The pre-MBA-1404 step/clamp lookup, unchanged. Also used by [`velocity_segment_bc`] to
102/// sample the value on each side of a boundary when building a blend.
103fn 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
132/// Every velocity at which [`raw_velocity_segment_bc`] can jump, paired with the margin to
133/// blend across (`0.0` means "no blend": a degenerate/zero-width adjacent band). Order of
134/// `segments` does not matter -- the boundaries are derived from a `velocity_min`-sorted
135/// copy, so ascending- and descending-stored tables produce identical boundaries.
136///
137/// Discontinuity classes (see `velocity_segment_bc`'s doc comment): interior band-to-band
138/// boundaries, the two edges of every interior band-to-fallback gap, and the two global
139/// coverage entry/exit edges.
140fn 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    // Coverage entry: below the lowest-velocity_min band. The clamp region above has no
149    // finite width, so the margin is driven entirely by the lowest band's own width.
150    if let Some(first) = sorted.first() {
151        boundaries.push((first.velocity_min, smoothing_margin(width(first))));
152    }
153    // Coverage exit: above the highest-velocity_max band. Mirrors raw()'s own "highest"
154    // selection (max by `velocity_max`), which need not be `sorted.last()` for a
155    // malformed/overlapping table.
156    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            // Interior band-to-fallback gap: two edges, each capped by the gap's own width
167            // in addition to its bordering band's width, so a narrow gap never lets the
168            // blend eat into the band on the far side of it.
169            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            // Contiguous band-to-band boundary.
180            boundaries.push((
181                left.velocity_max,
182                smoothing_margin(width(left).min(width(right))),
183            ));
184        }
185        // Overlapping bands (right.velocity_min < left.velocity_max) are already an
186        // ambiguous shape for raw()'s first-match lookup and aren't produced by any current
187        // caller; MBA-1404 does not add blending for that out-of-scope configuration.
188    }
189
190    boundaries
191}
192
193/// `min(50.0 fps, 0.25 * narrower_adjacent_width)`; `0.0` (no blend) for a zero/negative
194/// width, which is how a degenerate adjacent band disables blending at its boundaries.
195fn 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
202/// Hermite smoothstep, `3t^2 - 2t^3`, clamped to `[0, 1]`.
203fn smoothstep(t: f64) -> f64 {
204    let t = t.clamp(0.0, 1.0);
205    t * t * (3.0 - 2.0 * t)
206}
207
208/// Bullet type classification based on model name
209#[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
222/// BC degradation factors for different bullet types
223pub struct BulletTypeFactors {
224    pub drop: f64,
225    pub transition_curve: f64,
226}
227
228impl BulletType {
229    /// Get degradation factors for this bullet type
230    pub fn get_factors(&self) -> BulletTypeFactors {
231        match self {
232            BulletType::MatchBoatTail => BulletTypeFactors {
233                drop: 0.075, // 7.5% total drop for match boat tail
234                transition_curve: 0.3,
235            },
236            BulletType::MatchFlatBase => BulletTypeFactors {
237                drop: 0.10, // 10% for match flat base
238                transition_curve: 0.35,
239            },
240            BulletType::HuntingBoatTail => BulletTypeFactors {
241                drop: 0.15, // 15% for hunting boat tail
242                transition_curve: 0.45,
243            },
244            BulletType::HuntingFlatBase => BulletTypeFactors {
245                drop: 0.20, // 20% for hunting flat base
246                transition_curve: 0.5,
247            },
248            BulletType::VldHighBc => BulletTypeFactors {
249                drop: 0.05, // 5% for VLD (very low drag)
250                transition_curve: 0.25,
251            },
252            BulletType::Hybrid => BulletTypeFactors {
253                drop: 0.06, // 6% for hybrid designs
254                transition_curve: 0.28,
255            },
256            BulletType::FMJ => BulletTypeFactors {
257                drop: 0.12, // 12% for military ball
258                transition_curve: 0.4,
259            },
260            BulletType::RoundNose => BulletTypeFactors {
261                drop: 0.35, // 35% for round nose
262                transition_curve: 0.7,
263            },
264            BulletType::Unknown => BulletTypeFactors {
265                drop: 0.15, // Conservative 15%
266                transition_curve: 0.5,
267            },
268        }
269    }
270}
271
272/// BC segment estimator based on physics and known patterns
273pub struct BCSegmentEstimator;
274
275impl BCSegmentEstimator {
276    /// Identify bullet type from model name and characteristics.
277    ///
278    /// This compatibility entry point interprets `bc_value` as a G1 BC. Call
279    /// [`Self::identify_bullet_type_for_drag_model`] when the reference drag
280    /// model is known.
281    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    /// Identify bullet type while interpreting the BC in its reference-model space.
291    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        // VLD/High BC bullets
301        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        // Match bullets (competition/target)
313        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            // Check for boat tail
325            if model_lower.contains("bt") || model_lower.contains("boat") {
326                return BulletType::MatchBoatTail;
327            }
328            // Check if high BC indicates boat tail (guard sd>0: calculate_sectional_density
329            // returns 0 for non-positive caliber, which would make bc/sd == +Inf).
330            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        // Hunting bullets (expanding)
340        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            // Check for boat tail
354            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        // FMJ/Military
365        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        // Round nose
375        if model_lower.contains("rn")
376            || model_lower.contains("round nose")
377            || model_lower.contains("rnsp")
378        {
379            return BulletType::RoundNose;
380        }
381
382        // Use BC value as hint if available. Guard sd>0 (zero for non-positive caliber)
383        // so a degenerate input falls through to Unknown instead of dividing by zero
384        // (bc/0 == +Inf, which would silently classify as VldHighBc).
385        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    /// Convert the reference-model-dependent BC/SD ratio into the G1 space used
404    /// by the legacy coarse classification thresholds above. Typical boat-tail
405    /// G1 BCs are approximately twice their G7 BCs; this normalization prevents
406    /// ordinary G7 match bullets from looking like low-BC G1 flat-base bullets.
407    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    /// Calculate sectional density (SD) from weight and caliber
417    pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
418        // SD = weight / (7000 * caliber^2)
419        // Protect against division by zero or negative caliber
420        if caliber_inches <= 0.0 {
421            return 0.0;
422        }
423        weight_grains / (7000.0 * caliber_inches * caliber_inches)
424    }
425
426    /// Estimate BC segments based on bullet characteristics
427    #[allow(clippy::manual_clamp)] // max/min intentionally maps a NaN SD to the lower fallback.
428    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        // Identify bullet type
436        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        // Calculate sectional density
446        let sd = Self::calculate_sectional_density(weight, caliber);
447
448        // Adjust BC drop based on sectional density
449        // Higher SD = more stable BC
450        let sd_factor = (sd / 0.25).max(0.7).min(1.3);
451        let nominal_drop = type_factors.drop;
452
453        // Generate segments based on bullet type
454        let mut segments = Vec::new();
455
456        // Determine velocity ranges and BC retention factors
457        match bullet_type {
458            BulletType::MatchBoatTail => {
459                // Match boat tail - minimal BC degradation
460                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                // VLD/Hybrid - very stable BC
488                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                // Hunting boat tail - moderate degradation
511                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                // Default degradation profile
534                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        // G7 reference drag follows modern boat-tail projectiles more closely,
557        // so their banded BC varies less than the G1-shaped ladders above. Scale
558        // each loss from nominal rather than the BC itself: this leaves the muzzle
559        // band unchanged by this adjustment and makes the model effective for every
560        // named and default profile. Do not run the identity algebra for G1, so
561        // its established floating-point outputs remain bit-for-bit unchanged.
562        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        // Sectional density shapes only the degradation depth. Scaling the BC
571        // itself would alter the user's published muzzle value and would apply SD
572        // twice in the default profile. Skip identity algebra so SD=0.25 keeps
573        // established output bits unchanged.
574        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        // Match rifles typically have 4 segments
633        assert!(segments.len() >= 3);
634        // First segment should be close to base BC
635        assert!((segments[0].bc_value - 0.450).abs() < 0.05);
636        // BC should degrade at lower velocities
637        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        // SD = 0.25 exactly, so the independent sectional-density adjustment is neutral.
644        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        // High SD used to multiply every band above nominal and then cap them
718        // all to base_bc, erasing the degradation ladder.
719        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        // A representative 175 gr .308 match bullet. Its G7 BC is ordinary for a
733        // boat-tail projectile, but the same numeric value looks like a blunt
734        // flat-base bullet when interpreted with the G1 BC/SD thresholds.
735        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        // The normalization is deliberately equivalent to classifying the
749        // approximate G1 BC, while the G7 transition adjustment then softens
750        // the loss within that same ladder and the legacy entry point remains
751        // G1-compatible.
752        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    // MBA-1404: smoothstep continuity battery for `velocity_segment_bc`. Bands used here
801    // pick bc_values that are exact binary fractions (0.25/0.5/0.75/etc.) wherever a test
802    // hand-derives the blended midpoint, so assertions can use `assert_eq!` rather than an
803    // epsilon tolerance.
804
805    #[test]
806    fn margin_caps_at_50_fps_for_wide_adjacent_bands() {
807        // 0.25 * 1000 = 250, well above the 50 fps cap.
808        assert_eq!(smoothing_margin(1000.0), 50.0);
809        assert_eq!(smoothing_margin(200.0), 50.0); // 0.25*200 == 50, right at the cap edge
810    }
811
812    #[test]
813    fn margin_uses_25_percent_rule_for_narrow_adjacent_bands() {
814        // 0.25 * 40 = 10, under the 50 fps cap, so the 25% rule governs.
815        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); // defensive: malformed negative width
823    }
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); // 3*0.25 - 2*0.125 = 0.75 - 0.25 = 0.5
830                                          // Out-of-range t is clamped rather than extrapolated.
831        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        // Two contiguous 1000 fps-wide bands: margin = min(50, 0.25*1000) = 50, half = 25.
838        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        // Deep mid-band: exactly flat, bit-identical to the plain band value.
852        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        // Exactly at the boundary (t = 0.5): the midpoint of the two band values.
862        assert_eq!(velocity_segment_bc(1000.0, &segments, 0.9), 0.5);
863
864        // At the low edge of the margin window (t = 0): matches the lower band exactly.
865        assert_eq!(
866            velocity_segment_bc(975.0, &segments, 0.9).to_bits(),
867            0.25f64.to_bits()
868        );
869        // At the high edge of the margin window (t = 1): matches the upper band exactly.
870        assert_eq!(
871            velocity_segment_bc(1025.0, &segments, 0.9).to_bits(),
872            0.75f64.to_bits()
873        );
874
875        // Strictly inside the window: strictly between the two band values (monotonic).
876        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        // Same table as above, stored high-to-low: the helper's semantics are documented as
885        // order-independent, and MBA-1404 must not break that for the new blend either.
886        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    /// 0.28.1 sweep: `velocity_segment_bc` now caches `transition_boundaries` per thread
911    /// (keyed on `segments`' content) instead of rebuilding it every call, since it sits in
912    /// the RK4 derivative hot path and previously reallocated on every single evaluation.
913    /// Interleaving calls against two DIFFERENT tables on the same thread (as a caller
914    /// alternating trajectories, or a Monte Carlo run redrawing BC, would do) must still
915    /// give each table its own byte-identical answer -- proving the cache actually
916    /// invalidates on a table change rather than serving a stale boundary set.
917    #[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        // Reference: each table queried in isolation (fresh cache state going in).
945        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        // Now interleave: A, B, A, B, ... forcing a cache invalidation on every call.
956        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        // Band widths 1000 each; gap from 1000 to 1200 (width 200). Exit-edge margin =
973        // min(50, 0.25*min(1000,200)) = 50; entry-edge margin = min(50, 0.25*min(200,1000)) = 50.
974        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; // exact binary fraction: keeps the blend math exact here too.
987
988        // Deep mid-gap: exactly the fallback, untouched by either edge's margin.
989        assert_eq!(
990            velocity_segment_bc(1100.0, &segments, fallback).to_bits(),
991            fallback.to_bits()
992        );
993
994        // Exit edge (band -> gap) at v=1000: t=0.5 blend between the band value and fallback.
995        assert_eq!(velocity_segment_bc(1000.0, &segments, fallback), 0.375); // (0.25+0.5)/2
996
997        // Entry edge (gap -> band) at v=1200: t=0.5 blend between fallback and the band value.
998        assert_eq!(velocity_segment_bc(1200.0, &segments, fallback), 0.625); // (0.5+0.75)/2
999
1000        // Just outside each margin window: exactly flat again.
1001        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        // The middle "band" is degenerate (velocity_min == velocity_max), so it can never be
1042        // matched directly, but it still touches both of its neighbors' boundaries -- both of
1043        // those boundaries must fall back to the hard step (margin 0), matching the pre-MBA-1404
1044        // raw lookup exactly, rather than average toward the unmatchable degenerate value.
1045        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        // Below the lowest band and above the highest band, the raw lookup clamps to that
1075        // same band's own value, so the "coverage entry/exit" boundary is a no-op blend
1076        // (lo == hi): it must be exactly flat, not just close, all the way up to (and past)
1077        // the boundary's own margin window.
1078        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}