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.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
131/// Every velocity at which [`raw_velocity_segment_bc`] can jump, paired with the margin to
132/// blend across (`0.0` means "no blend": a degenerate/zero-width adjacent band). Order of
133/// `segments` does not matter -- the boundaries are derived from a `velocity_min`-sorted
134/// copy, so ascending- and descending-stored tables produce identical boundaries.
135///
136/// Discontinuity classes (see `velocity_segment_bc`'s doc comment): interior band-to-band
137/// boundaries, the two edges of every interior band-to-fallback gap, and the two global
138/// coverage entry/exit edges.
139fn 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    // Coverage entry: below the lowest-velocity_min band. The clamp region above has no
148    // finite width, so the margin is driven entirely by the lowest band's own width.
149    if let Some(first) = sorted.first() {
150        boundaries.push((first.velocity_min, smoothing_margin(width(first))));
151    }
152    // Coverage exit: above the highest-velocity_max band. Mirrors raw()'s own "highest"
153    // selection (max by `velocity_max`), which need not be `sorted.last()` for a
154    // malformed/overlapping table.
155    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            // Interior band-to-fallback gap: two edges, each capped by the gap's own width
166            // in addition to its bordering band's width, so a narrow gap never lets the
167            // blend eat into the band on the far side of it.
168            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            // Contiguous band-to-band boundary.
179            boundaries.push((
180                left.velocity_max,
181                smoothing_margin(width(left).min(width(right))),
182            ));
183        }
184        // Overlapping bands (right.velocity_min < left.velocity_max) are already an
185        // ambiguous shape for raw()'s first-match lookup and aren't produced by any current
186        // caller; MBA-1404 does not add blending for that out-of-scope configuration.
187    }
188
189    boundaries
190}
191
192/// `min(50.0 fps, 0.25 * narrower_adjacent_width)`; `0.0` (no blend) for a zero/negative
193/// width, which is how a degenerate adjacent band disables blending at its boundaries.
194fn 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
201/// Hermite smoothstep, `3t^2 - 2t^3`, clamped to `[0, 1]`.
202fn smoothstep(t: f64) -> f64 {
203    let t = t.clamp(0.0, 1.0);
204    t * t * (3.0 - 2.0 * t)
205}
206
207/// Bullet type classification based on model name
208#[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
221/// BC degradation factors for different bullet types
222pub struct BulletTypeFactors {
223    pub drop: f64,
224    pub transition_curve: f64,
225}
226
227impl BulletType {
228    /// Get degradation factors for this bullet type
229    pub fn get_factors(&self) -> BulletTypeFactors {
230        match self {
231            BulletType::MatchBoatTail => BulletTypeFactors {
232                drop: 0.075, // 7.5% total drop for match boat tail
233                transition_curve: 0.3,
234            },
235            BulletType::MatchFlatBase => BulletTypeFactors {
236                drop: 0.10, // 10% for match flat base
237                transition_curve: 0.35,
238            },
239            BulletType::HuntingBoatTail => BulletTypeFactors {
240                drop: 0.15, // 15% for hunting boat tail
241                transition_curve: 0.45,
242            },
243            BulletType::HuntingFlatBase => BulletTypeFactors {
244                drop: 0.20, // 20% for hunting flat base
245                transition_curve: 0.5,
246            },
247            BulletType::VldHighBc => BulletTypeFactors {
248                drop: 0.05, // 5% for VLD (very low drag)
249                transition_curve: 0.25,
250            },
251            BulletType::Hybrid => BulletTypeFactors {
252                drop: 0.06, // 6% for hybrid designs
253                transition_curve: 0.28,
254            },
255            BulletType::FMJ => BulletTypeFactors {
256                drop: 0.12, // 12% for military ball
257                transition_curve: 0.4,
258            },
259            BulletType::RoundNose => BulletTypeFactors {
260                drop: 0.35, // 35% for round nose
261                transition_curve: 0.7,
262            },
263            BulletType::Unknown => BulletTypeFactors {
264                drop: 0.15, // Conservative 15%
265                transition_curve: 0.5,
266            },
267        }
268    }
269}
270
271/// BC segment estimator based on physics and known patterns
272pub struct BCSegmentEstimator;
273
274impl BCSegmentEstimator {
275    /// Identify bullet type from model name and characteristics.
276    ///
277    /// This compatibility entry point interprets `bc_value` as a G1 BC. Call
278    /// [`Self::identify_bullet_type_for_drag_model`] when the reference drag
279    /// model is known.
280    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    /// Identify bullet type while interpreting the BC in its reference-model space.
290    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        // VLD/High BC bullets
300        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        // Match bullets (competition/target)
312        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            // Check for boat tail
324            if model_lower.contains("bt") || model_lower.contains("boat") {
325                return BulletType::MatchBoatTail;
326            }
327            // Check if high BC indicates boat tail (guard sd>0: calculate_sectional_density
328            // returns 0 for non-positive caliber, which would make bc/sd == +Inf).
329            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        // Hunting bullets (expanding)
339        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            // Check for boat tail
353            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        // FMJ/Military
364        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        // Round nose
374        if model_lower.contains("rn")
375            || model_lower.contains("round nose")
376            || model_lower.contains("rnsp")
377        {
378            return BulletType::RoundNose;
379        }
380
381        // Use BC value as hint if available. Guard sd>0 (zero for non-positive caliber)
382        // so a degenerate input falls through to Unknown instead of dividing by zero
383        // (bc/0 == +Inf, which would silently classify as VldHighBc).
384        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    /// Convert the reference-model-dependent BC/SD ratio into the G1 space used
403    /// by the legacy coarse classification thresholds above. Typical boat-tail
404    /// G1 BCs are approximately twice their G7 BCs; this normalization prevents
405    /// ordinary G7 match bullets from looking like low-BC G1 flat-base bullets.
406    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    /// Calculate sectional density (SD) from weight and caliber
416    pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
417        // SD = weight / (7000 * caliber^2)
418        // Protect against division by zero or negative caliber
419        if caliber_inches <= 0.0 {
420            return 0.0;
421        }
422        weight_grains / (7000.0 * caliber_inches * caliber_inches)
423    }
424
425    /// Estimate BC segments based on bullet characteristics
426    #[allow(clippy::manual_clamp)] // max/min intentionally maps a NaN SD to the lower fallback.
427    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        // Identify bullet type
435        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        // Calculate sectional density
445        let sd = Self::calculate_sectional_density(weight, caliber);
446
447        // Adjust BC drop based on sectional density
448        // Higher SD = more stable BC
449        let sd_factor = (sd / 0.25).max(0.7).min(1.3);
450        let nominal_drop = type_factors.drop;
451
452        // Generate segments based on bullet type
453        let mut segments = Vec::new();
454
455        // Determine velocity ranges and BC retention factors
456        match bullet_type {
457            BulletType::MatchBoatTail => {
458                // Match boat tail - minimal BC degradation
459                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                // VLD/Hybrid - very stable BC
487                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                // Hunting boat tail - moderate degradation
510                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                // Default degradation profile
533                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        // G7 reference drag follows modern boat-tail projectiles more closely,
556        // so their banded BC varies less than the G1-shaped ladders above. Scale
557        // each loss from nominal rather than the BC itself: this leaves the muzzle
558        // band unchanged by this adjustment and makes the model effective for every
559        // named and default profile. Do not run the identity algebra for G1, so
560        // its established floating-point outputs remain bit-for-bit unchanged.
561        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        // Sectional density shapes only the degradation depth. Scaling the BC
570        // itself would alter the user's published muzzle value and would apply SD
571        // twice in the default profile. Skip identity algebra so SD=0.25 keeps
572        // established output bits unchanged.
573        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        // Match rifles typically have 4 segments
632        assert!(segments.len() >= 3);
633        // First segment should be close to base BC
634        assert!((segments[0].bc_value - 0.450).abs() < 0.05);
635        // BC should degrade at lower velocities
636        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        // SD = 0.25 exactly, so the independent sectional-density adjustment is neutral.
643        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        // High SD used to multiply every band above nominal and then cap them
720        // all to base_bc, erasing the degradation ladder.
721        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        // A representative 175 gr .308 match bullet. Its G7 BC is ordinary for a
737        // boat-tail projectile, but the same numeric value looks like a blunt
738        // flat-base bullet when interpreted with the G1 BC/SD thresholds.
739        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        // The normalization is deliberately equivalent to classifying the
753        // approximate G1 BC, while the G7 transition adjustment then softens
754        // the loss within that same ladder and the legacy entry point remains
755        // G1-compatible.
756        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    // MBA-1404: smoothstep continuity battery for `velocity_segment_bc`. Bands used here
805    // pick bc_values that are exact binary fractions (0.25/0.5/0.75/etc.) wherever a test
806    // hand-derives the blended midpoint, so assertions can use `assert_eq!` rather than an
807    // epsilon tolerance.
808
809    #[test]
810    fn margin_caps_at_50_fps_for_wide_adjacent_bands() {
811        // 0.25 * 1000 = 250, well above the 50 fps cap.
812        assert_eq!(smoothing_margin(1000.0), 50.0);
813        assert_eq!(smoothing_margin(200.0), 50.0); // 0.25*200 == 50, right at the cap edge
814    }
815
816    #[test]
817    fn margin_uses_25_percent_rule_for_narrow_adjacent_bands() {
818        // 0.25 * 40 = 10, under the 50 fps cap, so the 25% rule governs.
819        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); // defensive: malformed negative width
827    }
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); // 3*0.25 - 2*0.125 = 0.75 - 0.25 = 0.5
834        // Out-of-range t is clamped rather than extrapolated.
835        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        // Two contiguous 1000 fps-wide bands: margin = min(50, 0.25*1000) = 50, half = 25.
842        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        // Deep mid-band: exactly flat, bit-identical to the plain band value.
856        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        // Exactly at the boundary (t = 0.5): the midpoint of the two band values.
860        assert_eq!(velocity_segment_bc(1000.0, &segments, 0.9), 0.5);
861
862        // At the low edge of the margin window (t = 0): matches the lower band exactly.
863        assert_eq!(
864            velocity_segment_bc(975.0, &segments, 0.9).to_bits(),
865            0.25f64.to_bits()
866        );
867        // At the high edge of the margin window (t = 1): matches the upper band exactly.
868        assert_eq!(
869            velocity_segment_bc(1025.0, &segments, 0.9).to_bits(),
870            0.75f64.to_bits()
871        );
872
873        // Strictly inside the window: strictly between the two band values (monotonic).
874        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        // Same table as above, stored high-to-low: the helper's semantics are documented as
883        // order-independent, and MBA-1404 must not break that for the new blend either.
884        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    /// 0.28.1 sweep: `velocity_segment_bc` now caches `transition_boundaries` per thread
909    /// (keyed on `segments`' content) instead of rebuilding it every call, since it sits in
910    /// the RK4 derivative hot path and previously reallocated on every single evaluation.
911    /// Interleaving calls against two DIFFERENT tables on the same thread (as a caller
912    /// alternating trajectories, or a Monte Carlo run redrawing BC, would do) must still
913    /// give each table its own byte-identical answer -- proving the cache actually
914    /// invalidates on a table change rather than serving a stale boundary set.
915    #[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        // Reference: each table queried in isolation (fresh cache state going in).
927        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        // Now interleave: A, B, A, B, ... forcing a cache invalidation on every call.
938        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        // Band widths 1000 each; gap from 1000 to 1200 (width 200). Exit-edge margin =
955        // min(50, 0.25*min(1000,200)) = 50; entry-edge margin = min(50, 0.25*min(200,1000)) = 50.
956        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; // exact binary fraction: keeps the blend math exact here too.
969
970        // Deep mid-gap: exactly the fallback, untouched by either edge's margin.
971        assert_eq!(
972            velocity_segment_bc(1100.0, &segments, fallback).to_bits(),
973            fallback.to_bits()
974        );
975
976        // Exit edge (band -> gap) at v=1000: t=0.5 blend between the band value and fallback.
977        assert_eq!(velocity_segment_bc(1000.0, &segments, fallback), 0.375); // (0.25+0.5)/2
978
979        // Entry edge (gap -> band) at v=1200: t=0.5 blend between fallback and the band value.
980        assert_eq!(velocity_segment_bc(1200.0, &segments, fallback), 0.625); // (0.5+0.75)/2
981
982        // Just outside each margin window: exactly flat again.
983        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        // The middle "band" is degenerate (velocity_min == velocity_max), so it can never be
1021        // matched directly, but it still touches both of its neighbors' boundaries -- both of
1022        // those boundaries must fall back to the hard step (margin 0), matching the pre-MBA-1404
1023        // raw lookup exactly, rather than average toward the unmatchable degenerate value.
1024        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        // Below the lowest band and above the highest band, the raw lookup clamps to that
1054        // same band's own value, so the "coverage entry/exit" boundary is a no-op blend
1055        // (lo == hi): it must be exactly flat, not just close, all the way up to (and past)
1056        // the boundary's own margin window.
1057        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}