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/// Whether a ladder leaves a speed uncovered between its slowest and fastest bands (MBA-1601).
102///
103/// True exactly when some speed above the slowest band's `velocity_min` and below the fastest
104/// band's `velocity_max` lies in no band, which is the only place [`raw_velocity_segment_bc`]
105/// returns the caller's fallback BC. Below and above the ladder the edge bands clamp, so a
106/// ladder without an interior gap never flies the fallback. Order-independent; overlapping
107/// bands cover their union, and touching bands (half-open) leave no gap.
108pub(crate) fn has_interior_gap(segments: &[BCSegmentData]) -> bool {
109    let mut sorted: Vec<&BCSegmentData> = segments.iter().collect();
110    sorted.sort_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
111    let mut covered_to = f64::NEG_INFINITY;
112    for segment in sorted {
113        if covered_to.is_finite() && segment.velocity_min > covered_to {
114            return true;
115        }
116        covered_to = covered_to.max(segment.velocity_max);
117    }
118    false
119}
120
121/// The pre-MBA-1404 step/clamp lookup, unchanged. Also used by [`velocity_segment_bc`] to
122/// sample the value on each side of a boundary when building a blend.
123fn raw_velocity_segment_bc(velocity_fps: f64, segments: &[BCSegmentData], fallback_bc: f64) -> f64 {
124    if let Some(segment) = segments
125        .iter()
126        .find(|segment| velocity_fps >= segment.velocity_min && velocity_fps < segment.velocity_max)
127    {
128        return segment.bc_value;
129    }
130
131    let lowest = segments
132        .iter()
133        .min_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
134    if let Some(segment) = lowest {
135        if velocity_fps < segment.velocity_min {
136            return segment.bc_value;
137        }
138    }
139
140    let highest = segments
141        .iter()
142        .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max));
143    if let Some(segment) = highest {
144        if velocity_fps >= segment.velocity_max {
145            return segment.bc_value;
146        }
147    }
148
149    fallback_bc
150}
151
152/// Every velocity at which [`raw_velocity_segment_bc`] can jump, paired with the margin to
153/// blend across (`0.0` means "no blend": a degenerate/zero-width adjacent band). Order of
154/// `segments` does not matter -- the boundaries are derived from a `velocity_min`-sorted
155/// copy, so ascending- and descending-stored tables produce identical boundaries.
156///
157/// Discontinuity classes (see `velocity_segment_bc`'s doc comment): interior band-to-band
158/// boundaries, the two edges of every interior band-to-fallback gap, and the two global
159/// coverage entry/exit edges.
160fn transition_boundaries(segments: &[BCSegmentData]) -> Vec<(f64, f64)> {
161    let width = |s: &BCSegmentData| (s.velocity_max - s.velocity_min).max(0.0);
162
163    let mut sorted: Vec<&BCSegmentData> = segments.iter().collect();
164    sorted.sort_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
165
166    let mut boundaries = Vec::with_capacity(sorted.len() * 2 + 2);
167
168    // Coverage entry: below the lowest-velocity_min band. The clamp region above has no
169    // finite width, so the margin is driven entirely by the lowest band's own width.
170    if let Some(first) = sorted.first() {
171        boundaries.push((first.velocity_min, smoothing_margin(width(first))));
172    }
173    // Coverage exit: above the highest-velocity_max band. Mirrors raw()'s own "highest"
174    // selection (max by `velocity_max`), which need not be `sorted.last()` for a
175    // malformed/overlapping table.
176    if let Some(highest) = segments
177        .iter()
178        .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max))
179    {
180        boundaries.push((highest.velocity_max, smoothing_margin(width(highest))));
181    }
182
183    for pair in sorted.windows(2) {
184        let (left, right) = (pair[0], pair[1]);
185        if right.velocity_min > left.velocity_max {
186            // Interior band-to-fallback gap: two edges, each capped by the gap's own width
187            // in addition to its bordering band's width, so a narrow gap never lets the
188            // blend eat into the band on the far side of it.
189            let gap_width = right.velocity_min - left.velocity_max;
190            boundaries.push((
191                left.velocity_max,
192                smoothing_margin(width(left).min(gap_width)),
193            ));
194            boundaries.push((
195                right.velocity_min,
196                smoothing_margin(gap_width.min(width(right))),
197            ));
198        } else if right.velocity_min == left.velocity_max {
199            // Contiguous band-to-band boundary.
200            boundaries.push((
201                left.velocity_max,
202                smoothing_margin(width(left).min(width(right))),
203            ));
204        }
205        // Overlapping bands (right.velocity_min < left.velocity_max) are an ambiguous shape
206        // for raw()'s first-match lookup. solve-json's `projectile.bc_segments` (MBA-1598) and
207        // `card.*`'s `bc_segments` (MBA-1600) refuse them, but the CLI's `--bc-segment` (native
208        // and the WASM terminal) and library callers that set `BallisticInputs::bc_segments_data`
209        // directly (the Python binding, for one) can still pass them: there the first band in
210        // slice order that contains the speed wins, and no blend is built across the overlap.
211    }
212
213    boundaries
214}
215
216/// `min(50.0 fps, 0.25 * narrower_adjacent_width)`; `0.0` (no blend) for a zero/negative
217/// width, which is how a degenerate adjacent band disables blending at its boundaries.
218fn smoothing_margin(narrower_adjacent_width: f64) -> f64 {
219    if narrower_adjacent_width <= 0.0 {
220        return 0.0;
221    }
222    (0.25 * narrower_adjacent_width).min(50.0)
223}
224
225/// Hermite smoothstep, `3t^2 - 2t^3`, clamped to `[0, 1]`.
226fn smoothstep(t: f64) -> f64 {
227    let t = t.clamp(0.0, 1.0);
228    t * t * (3.0 - 2.0 * t)
229}
230
231/// Bullet type classification based on model name
232#[derive(Debug, Clone, Copy, PartialEq)]
233pub enum BulletType {
234    MatchBoatTail,
235    MatchFlatBase,
236    HuntingBoatTail,
237    HuntingFlatBase,
238    VldHighBc,
239    Hybrid,
240    FMJ,
241    RoundNose,
242    Unknown,
243}
244
245/// BC degradation factors for different bullet types
246pub struct BulletTypeFactors {
247    pub drop: f64,
248    pub transition_curve: f64,
249}
250
251impl BulletType {
252    /// Get degradation factors for this bullet type
253    pub fn get_factors(&self) -> BulletTypeFactors {
254        match self {
255            BulletType::MatchBoatTail => BulletTypeFactors {
256                drop: 0.075, // 7.5% total drop for match boat tail
257                transition_curve: 0.3,
258            },
259            BulletType::MatchFlatBase => BulletTypeFactors {
260                drop: 0.10, // 10% for match flat base
261                transition_curve: 0.35,
262            },
263            BulletType::HuntingBoatTail => BulletTypeFactors {
264                drop: 0.15, // 15% for hunting boat tail
265                transition_curve: 0.45,
266            },
267            BulletType::HuntingFlatBase => BulletTypeFactors {
268                drop: 0.20, // 20% for hunting flat base
269                transition_curve: 0.5,
270            },
271            BulletType::VldHighBc => BulletTypeFactors {
272                drop: 0.05, // 5% for VLD (very low drag)
273                transition_curve: 0.25,
274            },
275            BulletType::Hybrid => BulletTypeFactors {
276                drop: 0.06, // 6% for hybrid designs
277                transition_curve: 0.28,
278            },
279            BulletType::FMJ => BulletTypeFactors {
280                drop: 0.12, // 12% for military ball
281                transition_curve: 0.4,
282            },
283            BulletType::RoundNose => BulletTypeFactors {
284                drop: 0.35, // 35% for round nose
285                transition_curve: 0.7,
286            },
287            BulletType::Unknown => BulletTypeFactors {
288                drop: 0.15, // Conservative 15%
289                transition_curve: 0.5,
290            },
291        }
292    }
293}
294
295/// BC segment estimator based on physics and known patterns
296pub struct BCSegmentEstimator;
297
298impl BCSegmentEstimator {
299    /// Identify bullet type from model name and characteristics.
300    ///
301    /// This compatibility entry point interprets `bc_value` as a G1 BC. Call
302    /// [`Self::identify_bullet_type_for_drag_model`] when the reference drag
303    /// model is known.
304    pub fn identify_bullet_type(
305        model: &str,
306        weight: f64,
307        caliber: f64,
308        bc_value: Option<f64>,
309    ) -> BulletType {
310        Self::identify_bullet_type_for_drag_model(model, weight, caliber, bc_value, "G1")
311    }
312
313    /// Identify bullet type while interpreting the BC in its reference-model space.
314    pub fn identify_bullet_type_for_drag_model(
315        model: &str,
316        weight: f64,
317        caliber: f64,
318        bc_value: Option<f64>,
319        drag_model: &str,
320    ) -> BulletType {
321        let model_lower = model.to_lowercase();
322
323        // VLD/High BC bullets
324        if model_lower.contains("vld")
325            || model_lower.contains("berger")
326            || model_lower.contains("hybrid")
327            || model_lower.contains("elite")
328        {
329            if model_lower.contains("hybrid") {
330                return BulletType::Hybrid;
331            }
332            return BulletType::VldHighBc;
333        }
334
335        // Match bullets (competition/target)
336        if model_lower.contains("smk")
337            || model_lower.contains("matchking")
338            || model_lower.contains("match")
339            || model_lower.contains("bthp")
340            || model_lower.contains("competition")
341            || model_lower.contains("target")
342            || model_lower.contains("a-max")
343            || model_lower.contains("eld-m")
344            || model_lower.contains("scenar")
345            || model_lower.contains("x-ring")
346        {
347            // Check for boat tail
348            if model_lower.contains("bt") || model_lower.contains("boat") {
349                return BulletType::MatchBoatTail;
350            }
351            // Check if high BC indicates boat tail (guard sd>0: calculate_sectional_density
352            // returns 0 for non-positive caliber, which would make bc/sd == +Inf).
353            if let Some(bc) = bc_value {
354                let sd = Self::calculate_sectional_density(weight, caliber);
355                if sd > 0.0 && Self::classification_bc_sd_ratio(bc, sd, drag_model) > 1.6 {
356                    return BulletType::MatchBoatTail;
357                }
358            }
359            return BulletType::MatchFlatBase;
360        }
361
362        // Hunting bullets (expanding)
363        if model_lower.contains("gameking")
364            || model_lower.contains("hunting")
365            || model_lower.contains("sst")
366            || model_lower.contains("eld-x")
367            || model_lower.contains("partition")
368            || model_lower.contains("accubond")
369            || model_lower.contains("core-lokt")
370            || model_lower.contains("ballistic tip")
371            || model_lower.contains("v-max")
372            || model_lower.contains("hornady sp")
373            || model_lower.contains("interlock")
374            || model_lower.contains("tsx")
375        {
376            // Check for boat tail
377            if model_lower.contains("bt")
378                || model_lower.contains("boat")
379                || model_lower.contains("sst")
380                || model_lower.contains("accubond")
381            {
382                return BulletType::HuntingBoatTail;
383            }
384            return BulletType::HuntingFlatBase;
385        }
386
387        // FMJ/Military
388        if model_lower.contains("fmj")
389            || model_lower.contains("ball")
390            || model_lower.contains("m80")
391            || model_lower.contains("m855")
392            || model_lower.contains("tracer")
393        {
394            return BulletType::FMJ;
395        }
396
397        // Round nose
398        if model_lower.contains("rn")
399            || model_lower.contains("round nose")
400            || model_lower.contains("rnsp")
401        {
402            return BulletType::RoundNose;
403        }
404
405        // Use BC value as hint if available. Guard sd>0 (zero for non-positive caliber)
406        // so a degenerate input falls through to Unknown instead of dividing by zero
407        // (bc/0 == +Inf, which would silently classify as VldHighBc).
408        if let Some(bc) = bc_value {
409            let sd = Self::calculate_sectional_density(weight, caliber);
410            if sd > 0.0 {
411                let bc_to_sd_ratio = Self::classification_bc_sd_ratio(bc, sd, drag_model);
412
413                if bc_to_sd_ratio > 1.8 {
414                    return BulletType::VldHighBc;
415                } else if bc_to_sd_ratio > 1.5 {
416                    return BulletType::MatchBoatTail;
417                } else if bc_to_sd_ratio < 1.2 {
418                    return BulletType::HuntingFlatBase;
419                }
420            }
421        }
422
423        BulletType::Unknown
424    }
425
426    /// Convert the reference-model-dependent BC/SD ratio into the G1 space used
427    /// by the legacy coarse classification thresholds above. Typical boat-tail
428    /// G1 BCs are approximately twice their G7 BCs; this normalization prevents
429    /// ordinary G7 match bullets from looking like low-BC G1 flat-base bullets.
430    fn classification_bc_sd_ratio(bc: f64, sd: f64, drag_model: &str) -> f64 {
431        let g1_equivalent_bc = if drag_model.eq_ignore_ascii_case("G7") {
432            bc * 2.0
433        } else {
434            bc
435        };
436        g1_equivalent_bc / sd
437    }
438
439    /// Calculate sectional density (SD) from weight and caliber
440    pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
441        // SD = weight / (7000 * caliber^2)
442        // Protect against division by zero or negative caliber
443        if caliber_inches <= 0.0 {
444            return 0.0;
445        }
446        weight_grains / (7000.0 * caliber_inches * caliber_inches)
447    }
448
449    /// Estimate BC segments based on bullet characteristics
450    #[allow(clippy::manual_clamp)] // max/min intentionally maps a NaN SD to the lower fallback.
451    pub fn estimate_bc_segments(
452        base_bc: f64,
453        caliber: f64,
454        weight: f64,
455        model: &str,
456        drag_model: &str,
457    ) -> Vec<BCSegmentData> {
458        // Identify bullet type
459        let bullet_type = Self::identify_bullet_type_for_drag_model(
460            model,
461            weight,
462            caliber,
463            Some(base_bc),
464            drag_model,
465        );
466        let type_factors = bullet_type.get_factors();
467
468        // Calculate sectional density
469        let sd = Self::calculate_sectional_density(weight, caliber);
470
471        // Adjust BC drop based on sectional density
472        // Higher SD = more stable BC
473        let sd_factor = (sd / 0.25).max(0.7).min(1.3);
474        let nominal_drop = type_factors.drop;
475
476        // Generate segments based on bullet type
477        let mut segments = Vec::new();
478
479        // Determine velocity ranges and BC retention factors
480        match bullet_type {
481            BulletType::MatchBoatTail => {
482                // Match boat tail - minimal BC degradation
483                segments.push(BCSegmentData {
484                    velocity_min: 2800.0,
485                    velocity_max: 5000.0,
486                    bc_value: base_bc * 1.000,
487                });
488                segments.push(BCSegmentData {
489                    velocity_min: 2400.0,
490                    velocity_max: 2800.0,
491                    bc_value: base_bc * 0.985,
492                });
493                segments.push(BCSegmentData {
494                    velocity_min: 2000.0,
495                    velocity_max: 2400.0,
496                    bc_value: base_bc * 0.965,
497                });
498                segments.push(BCSegmentData {
499                    velocity_min: 1600.0,
500                    velocity_max: 2000.0,
501                    bc_value: base_bc * 0.945,
502                });
503                segments.push(BCSegmentData {
504                    velocity_min: 0.0,
505                    velocity_max: 1600.0,
506                    bc_value: base_bc * 0.925,
507                });
508            }
509            BulletType::VldHighBc | BulletType::Hybrid => {
510                // VLD/Hybrid - very stable BC
511                segments.push(BCSegmentData {
512                    velocity_min: 2800.0,
513                    velocity_max: 5000.0,
514                    bc_value: base_bc * 1.000,
515                });
516                segments.push(BCSegmentData {
517                    velocity_min: 2200.0,
518                    velocity_max: 2800.0,
519                    bc_value: base_bc * 0.990,
520                });
521                segments.push(BCSegmentData {
522                    velocity_min: 1600.0,
523                    velocity_max: 2200.0,
524                    bc_value: base_bc * 0.970,
525                });
526                segments.push(BCSegmentData {
527                    velocity_min: 0.0,
528                    velocity_max: 1600.0,
529                    bc_value: base_bc * 0.950,
530                });
531            }
532            BulletType::HuntingBoatTail => {
533                // Hunting boat tail - moderate degradation
534                segments.push(BCSegmentData {
535                    velocity_min: 2600.0,
536                    velocity_max: 5000.0,
537                    bc_value: base_bc * 1.000,
538                });
539                segments.push(BCSegmentData {
540                    velocity_min: 2200.0,
541                    velocity_max: 2600.0,
542                    bc_value: base_bc * 0.960,
543                });
544                segments.push(BCSegmentData {
545                    velocity_min: 1800.0,
546                    velocity_max: 2200.0,
547                    bc_value: base_bc * 0.900,
548                });
549                segments.push(BCSegmentData {
550                    velocity_min: 0.0,
551                    velocity_max: 1800.0,
552                    bc_value: base_bc * 0.850,
553                });
554            }
555            _ => {
556                // Default degradation profile
557                segments.push(BCSegmentData {
558                    velocity_min: 2800.0,
559                    velocity_max: 5000.0,
560                    bc_value: base_bc,
561                });
562
563                let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
564                segments.push(BCSegmentData {
565                    velocity_min: 1800.0,
566                    velocity_max: 2800.0,
567                    bc_value: transonic_bc,
568                });
569
570                let subsonic_bc = base_bc * (1.0 - nominal_drop);
571                segments.push(BCSegmentData {
572                    velocity_min: 0.0,
573                    velocity_max: 1800.0,
574                    bc_value: subsonic_bc,
575                });
576            }
577        }
578
579        // G7 reference drag follows modern boat-tail projectiles more closely,
580        // so their banded BC varies less than the G1-shaped ladders above. Scale
581        // each loss from nominal rather than the BC itself: this leaves the muzzle
582        // band unchanged by this adjustment and makes the model effective for every
583        // named and default profile. Do not run the identity algebra for G1, so
584        // its established floating-point outputs remain bit-for-bit unchanged.
585        if drag_model.eq_ignore_ascii_case("G7") {
586            const G7_DROP_SCALE: f64 = 0.8;
587            for segment in &mut segments {
588                let drop_from_nominal = base_bc - segment.bc_value;
589                segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
590            }
591        }
592
593        // Sectional density shapes only the degradation depth. Scaling the BC
594        // itself would alter the user's published muzzle value and would apply SD
595        // twice in the default profile. Skip identity algebra so SD=0.25 keeps
596        // established output bits unchanged.
597        if sd_factor != 1.0 {
598            for segment in &mut segments {
599                let drop_from_nominal = base_bc - segment.bc_value;
600                segment.bc_value = base_bc - drop_from_nominal / sd_factor;
601            }
602        }
603
604        segments
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn test_bullet_type_identification() {
614        assert_eq!(
615            BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
616            BulletType::MatchFlatBase
617        );
618        assert_eq!(
619            BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
620            BulletType::MatchBoatTail
621        );
622        assert_eq!(
623            BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
624            BulletType::HuntingBoatTail
625        );
626        assert_eq!(
627            BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
628            BulletType::FMJ
629        );
630        assert_eq!(
631            BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
632            BulletType::RoundNose
633        );
634        assert_eq!(
635            BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
636            BulletType::VldHighBc
637        );
638        assert_eq!(
639            BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
640            BulletType::Unknown
641        );
642    }
643
644    #[test]
645    fn test_sectional_density() {
646        let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
647        assert!((sd - 0.253).abs() < 0.001);
648    }
649
650    #[test]
651    fn test_bc_estimation() {
652        let segments =
653            BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
654
655        // Match rifles typically have 4 segments
656        assert!(segments.len() >= 3);
657        // First segment should be close to base BC
658        assert!((segments[0].bc_value - 0.450).abs() < 0.05);
659        // BC should degrade at lower velocities
660        assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
661    }
662
663    #[test]
664    fn g7_transition_adjustment_softens_each_band_drop() {
665        let base_bc = 0.5;
666        // SD = 0.25 exactly, so the independent sectional-density adjustment is neutral.
667        let caliber = 1.0;
668        let weight = 1750.0;
669
670        for model in ["SMK BT", "FMJ"] {
671            let g1 =
672                BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
673            let g7 =
674                BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
675            let lowercase_g7 =
676                BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
677            assert_eq!(g7.len(), g1.len());
678            assert_eq!(lowercase_g7.len(), g7.len());
679
680            for ((g1_band, g7_band), lowercase_band) in g1.iter().zip(&g7).zip(&lowercase_g7) {
681                assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
682                assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
683                assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
684                assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
685                assert_eq!(
686                    lowercase_band.bc_value.to_bits(),
687                    g7_band.bc_value.to_bits()
688                );
689                let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
690                assert!(
691                    (g7_band.bc_value - expected_g7).abs() < 1e-12,
692                    "{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
693                    g1_band.velocity_min,
694                    g1_band.velocity_max,
695                    g1_band.bc_value,
696                    g7_band.bc_value
697                );
698            }
699        }
700    }
701
702    #[test]
703    fn sectional_density_shapes_only_band_degradation() {
704        let base_bc = 0.5;
705        let caliber = 0.224;
706        let weight = 77.0;
707        let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
708        let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
709
710        for drag_model in ["G1", "G7"] {
711            let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
712            for (model, raw_retentions) in [
713                ("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
714                ("FMJ", &[1.0, 0.964, 0.88][..]),
715            ] {
716                let segments = BCSegmentEstimator::estimate_bc_segments(
717                    base_bc, caliber, weight, model, drag_model,
718                );
719                assert_eq!(segments.len(), raw_retentions.len());
720
721                for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
722                    let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
723                    let expected = base_bc - raw_drop / sd_factor;
724                    assert!(
725                        (segment.bc_value - expected).abs() < 1e-12,
726                        "{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
727                        segment.velocity_min,
728                        segment.velocity_max,
729                        segment.bc_value
730                    );
731                }
732                assert_eq!(
733                    segments[0].bc_value.to_bits(),
734                    base_bc.to_bits(),
735                    "published muzzle BC must remain exact for {drag_model} {model}"
736                );
737            }
738        }
739
740        // High SD used to multiply every band above nominal and then cap them
741        // all to base_bc, erasing the degradation ladder.
742        let high_sd_base_bc = 0.3;
743        let high_sd_segments =
744            BCSegmentEstimator::estimate_bc_segments(high_sd_base_bc, 0.308, 220.0, "SMK BT", "G7");
745        assert_eq!(
746            high_sd_segments[0].bc_value.to_bits(),
747            high_sd_base_bc.to_bits()
748        );
749        assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
750        assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
751    }
752
753    #[test]
754    fn generic_g7_bc_uses_g7_classification_space() {
755        // A representative 175 gr .308 match bullet. Its G7 BC is ordinary for a
756        // boat-tail projectile, but the same numeric value looks like a blunt
757        // flat-base bullet when interpreted with the G1 BC/SD thresholds.
758        let base_bc = 0.243;
759        let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
760
761        assert!(
762            segments.len() >= 4,
763            "G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
764        );
765        let subsonic_bc = segments.last().unwrap().bc_value;
766        assert!(
767            subsonic_bc >= base_bc * 0.92,
768            "G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
769        );
770
771        // The normalization is deliberately equivalent to classifying the
772        // approximate G1 BC, while the G7 transition adjustment then softens
773        // the loss within that same ladder and the legacy entry point remains
774        // G1-compatible.
775        let g1_segments =
776            BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
777        assert_eq!(segments.len(), g1_segments.len());
778        let mut saw_g7_softening = false;
779        for (g7, g1) in segments.iter().zip(&g1_segments) {
780            assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
781            assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
782            let g7_retention = g7.bc_value / base_bc;
783            let g1_retention = g1.bc_value / (base_bc * 2.0);
784            assert!(g7_retention + 1e-12 >= g1_retention);
785            saw_g7_softening |= g7_retention > g1_retention + 1e-12;
786        }
787        assert!(saw_g7_softening);
788
789        let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
790        assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
791        assert_eq!(
792            legacy_g1,
793            BCSegmentEstimator::identify_bullet_type_for_drag_model(
794                "",
795                175.0,
796                0.308,
797                Some(base_bc),
798                "G1",
799            )
800        );
801        assert_eq!(
802            BCSegmentEstimator::identify_bullet_type_for_drag_model(
803                "175gr SMK",
804                175.0,
805                0.308,
806                Some(base_bc),
807                "g7",
808            ),
809            BulletType::MatchBoatTail
810        );
811        assert_eq!(
812            BCSegmentEstimator::identify_bullet_type_for_drag_model(
813                "",
814                175.0,
815                0.0,
816                Some(base_bc),
817                "G7",
818            ),
819            BulletType::Unknown
820        );
821    }
822
823    // MBA-1404: smoothstep continuity battery for `velocity_segment_bc`. Bands used here
824    // pick bc_values that are exact binary fractions (0.25/0.5/0.75/etc.) wherever a test
825    // hand-derives the blended midpoint, so assertions can use `assert_eq!` rather than an
826    // epsilon tolerance.
827
828    #[test]
829    fn margin_caps_at_50_fps_for_wide_adjacent_bands() {
830        // 0.25 * 1000 = 250, well above the 50 fps cap.
831        assert_eq!(smoothing_margin(1000.0), 50.0);
832        assert_eq!(smoothing_margin(200.0), 50.0); // 0.25*200 == 50, right at the cap edge
833    }
834
835    #[test]
836    fn margin_uses_25_percent_rule_for_narrow_adjacent_bands() {
837        // 0.25 * 40 = 10, under the 50 fps cap, so the 25% rule governs.
838        assert_eq!(smoothing_margin(40.0), 10.0);
839        assert_eq!(smoothing_margin(4.0), 1.0);
840    }
841
842    #[test]
843    fn margin_is_zero_for_degenerate_widths() {
844        assert_eq!(smoothing_margin(0.0), 0.0);
845        assert_eq!(smoothing_margin(-5.0), 0.0); // defensive: malformed negative width
846    }
847
848    #[test]
849    fn smoothstep_is_centered_and_matches_hermite_formula() {
850        assert_eq!(smoothstep(0.0), 0.0);
851        assert_eq!(smoothstep(1.0), 1.0);
852        assert_eq!(smoothstep(0.5), 0.5); // 3*0.25 - 2*0.125 = 0.75 - 0.25 = 0.5
853                                          // Out-of-range t is clamped rather than extrapolated.
854        assert_eq!(smoothstep(-1.0), 0.0);
855        assert_eq!(smoothstep(2.0), 1.0);
856    }
857
858    #[test]
859    fn ascending_band_boundary_blends_symmetrically_around_the_boundary() {
860        // Two contiguous 1000 fps-wide bands: margin = min(50, 0.25*1000) = 50, half = 25.
861        let segments = vec![
862            BCSegmentData {
863                velocity_min: 0.0,
864                velocity_max: 1000.0,
865                bc_value: 0.25,
866            },
867            BCSegmentData {
868                velocity_min: 1000.0,
869                velocity_max: 2000.0,
870                bc_value: 0.75,
871            },
872        ];
873
874        // Deep mid-band: exactly flat, bit-identical to the plain band value.
875        assert_eq!(
876            velocity_segment_bc(200.0, &segments, 0.9).to_bits(),
877            0.25f64.to_bits()
878        );
879        assert_eq!(
880            velocity_segment_bc(1800.0, &segments, 0.9).to_bits(),
881            0.75f64.to_bits()
882        );
883
884        // Exactly at the boundary (t = 0.5): the midpoint of the two band values.
885        assert_eq!(velocity_segment_bc(1000.0, &segments, 0.9), 0.5);
886
887        // At the low edge of the margin window (t = 0): matches the lower band exactly.
888        assert_eq!(
889            velocity_segment_bc(975.0, &segments, 0.9).to_bits(),
890            0.25f64.to_bits()
891        );
892        // At the high edge of the margin window (t = 1): matches the upper band exactly.
893        assert_eq!(
894            velocity_segment_bc(1025.0, &segments, 0.9).to_bits(),
895            0.75f64.to_bits()
896        );
897
898        // Strictly inside the window: strictly between the two band values (monotonic).
899        let just_below = velocity_segment_bc(999.0, &segments, 0.9);
900        let just_above = velocity_segment_bc(1001.0, &segments, 0.9);
901        assert!(just_below > 0.25 && just_below < 0.5);
902        assert!(just_above > 0.5 && just_above < 0.75);
903    }
904
905    #[test]
906    fn descending_stored_order_matches_ascending_boundary_blend() {
907        // Same table as above, stored high-to-low: the helper's semantics are documented as
908        // order-independent, and MBA-1404 must not break that for the new blend either.
909        let ascending = vec![
910            BCSegmentData {
911                velocity_min: 0.0,
912                velocity_max: 1000.0,
913                bc_value: 0.25,
914            },
915            BCSegmentData {
916                velocity_min: 1000.0,
917                velocity_max: 2000.0,
918                bc_value: 0.75,
919            },
920        ];
921        let mut descending = ascending.clone();
922        descending.reverse();
923
924        for v in [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0] {
925            assert_eq!(
926                velocity_segment_bc(v, &ascending, 0.9),
927                velocity_segment_bc(v, &descending, 0.9),
928                "order must not affect the blended result at v={v}"
929            );
930        }
931    }
932
933    /// 0.28.1 sweep: `velocity_segment_bc` now caches `transition_boundaries` per thread
934    /// (keyed on `segments`' content) instead of rebuilding it every call, since it sits in
935    /// the RK4 derivative hot path and previously reallocated on every single evaluation.
936    /// Interleaving calls against two DIFFERENT tables on the same thread (as a caller
937    /// alternating trajectories, or a Monte Carlo run redrawing BC, would do) must still
938    /// give each table its own byte-identical answer -- proving the cache actually
939    /// invalidates on a table change rather than serving a stale boundary set.
940    #[test]
941    fn cache_gives_each_distinct_table_its_own_byte_identical_answer_when_interleaved() {
942        let table_a = vec![
943            BCSegmentData {
944                velocity_min: 0.0,
945                velocity_max: 1000.0,
946                bc_value: 0.25,
947            },
948            BCSegmentData {
949                velocity_min: 1000.0,
950                velocity_max: 2000.0,
951                bc_value: 0.75,
952            },
953        ];
954        let table_b = vec![
955            BCSegmentData {
956                velocity_min: 0.0,
957                velocity_max: 900.0,
958                bc_value: 0.40,
959            },
960            BCSegmentData {
961                velocity_min: 900.0,
962                velocity_max: 1800.0,
963                bc_value: 0.60,
964            },
965        ];
966
967        // Reference: each table queried in isolation (fresh cache state going in).
968        let probes = [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0];
969        let expected_a: Vec<u64> = probes
970            .iter()
971            .map(|&v| velocity_segment_bc(v, &table_a, 0.9).to_bits())
972            .collect();
973        let expected_b: Vec<u64> = probes
974            .iter()
975            .map(|&v| velocity_segment_bc(v, &table_b, 0.9).to_bits())
976            .collect();
977
978        // Now interleave: A, B, A, B, ... forcing a cache invalidation on every call.
979        for (i, &v) in probes.iter().enumerate() {
980            assert_eq!(
981                velocity_segment_bc(v, &table_a, 0.9).to_bits(),
982                expected_a[i],
983                "table A must be unaffected by interleaved table B calls (v={v})"
984            );
985            assert_eq!(
986                velocity_segment_bc(v, &table_b, 0.9).to_bits(),
987                expected_b[i],
988                "table B must be unaffected by interleaved table A calls (v={v})"
989            );
990        }
991    }
992
993    #[test]
994    fn gapped_table_blends_both_edges_of_the_fallback_gap_and_stays_flat_mid_gap() {
995        // Band widths 1000 each; gap from 1000 to 1200 (width 200). Exit-edge margin =
996        // min(50, 0.25*min(1000,200)) = 50; entry-edge margin = min(50, 0.25*min(200,1000)) = 50.
997        let segments = vec![
998            BCSegmentData {
999                velocity_min: 0.0,
1000                velocity_max: 1000.0,
1001                bc_value: 0.25,
1002            },
1003            BCSegmentData {
1004                velocity_min: 1200.0,
1005                velocity_max: 2200.0,
1006                bc_value: 0.75,
1007            },
1008        ];
1009        let fallback = 0.5; // exact binary fraction: keeps the blend math exact here too.
1010
1011        // Deep mid-gap: exactly the fallback, untouched by either edge's margin.
1012        assert_eq!(
1013            velocity_segment_bc(1100.0, &segments, fallback).to_bits(),
1014            fallback.to_bits()
1015        );
1016
1017        // Exit edge (band -> gap) at v=1000: t=0.5 blend between the band value and fallback.
1018        assert_eq!(velocity_segment_bc(1000.0, &segments, fallback), 0.375); // (0.25+0.5)/2
1019
1020        // Entry edge (gap -> band) at v=1200: t=0.5 blend between fallback and the band value.
1021        assert_eq!(velocity_segment_bc(1200.0, &segments, fallback), 0.625); // (0.5+0.75)/2
1022
1023        // Just outside each margin window: exactly flat again.
1024        assert_eq!(
1025            velocity_segment_bc(974.0, &segments, fallback).to_bits(),
1026            0.25f64.to_bits()
1027        );
1028        assert_eq!(
1029            velocity_segment_bc(1226.0, &segments, fallback).to_bits(),
1030            0.75f64.to_bits()
1031        );
1032    }
1033
1034    #[test]
1035    fn single_segment_table_never_blends_and_is_byte_identical_to_the_raw_lookup() {
1036        let single = vec![BCSegmentData {
1037            velocity_min: 1000.0,
1038            velocity_max: 2000.0,
1039            bc_value: 0.5,
1040        }];
1041
1042        for v in [-1e6, 500.0, 1000.0, 1500.0, 1999.999, 2000.0, 1e6] {
1043            assert_eq!(
1044                velocity_segment_bc(v, &single, 0.9).to_bits(),
1045                raw_velocity_segment_bc(v, &single, 0.9).to_bits(),
1046                "single-band tables must never blend (v={v})"
1047            );
1048        }
1049    }
1050
1051    #[test]
1052    fn empty_table_never_blends_and_always_returns_the_fallback_exactly() {
1053        let empty: Vec<BCSegmentData> = vec![];
1054        for v in [-1e6, 0.0, 500.0, 1e6] {
1055            assert_eq!(
1056                velocity_segment_bc(v, &empty, 0.73).to_bits(),
1057                0.73f64.to_bits()
1058            );
1059        }
1060    }
1061
1062    #[test]
1063    fn zero_width_adjacent_band_disables_blending_at_its_boundaries() {
1064        // The middle "band" is degenerate (velocity_min == velocity_max), so it can never be
1065        // matched directly, but it still touches both of its neighbors' boundaries -- both of
1066        // those boundaries must fall back to the hard step (margin 0), matching the pre-MBA-1404
1067        // raw lookup exactly, rather than average toward the unmatchable degenerate value.
1068        let segments = vec![
1069            BCSegmentData {
1070                velocity_min: 0.0,
1071                velocity_max: 1000.0,
1072                bc_value: 0.5,
1073            },
1074            BCSegmentData {
1075                velocity_min: 1000.0,
1076                velocity_max: 1000.0,
1077                bc_value: 0.9,
1078            },
1079            BCSegmentData {
1080                velocity_min: 1000.0,
1081                velocity_max: 2000.0,
1082                bc_value: 0.6,
1083            },
1084        ];
1085
1086        for v in [999.0, 999.99, 1000.0, 1000.01, 1001.0] {
1087            assert_eq!(
1088                velocity_segment_bc(v, &segments, 0.99).to_bits(),
1089                raw_velocity_segment_bc(v, &segments, 0.99).to_bits(),
1090                "a degenerate adjacent band must disable blending, not average toward it (v={v})"
1091            );
1092        }
1093    }
1094
1095    #[test]
1096    fn coverage_entry_and_exit_edges_stay_flat_since_the_clamp_matches_the_bordering_band() {
1097        // Below the lowest band and above the highest band, the raw lookup clamps to that
1098        // same band's own value, so the "coverage entry/exit" boundary is a no-op blend
1099        // (lo == hi): it must be exactly flat, not just close, all the way up to (and past)
1100        // the boundary's own margin window.
1101        let segments = vec![
1102            BCSegmentData {
1103                velocity_min: 1000.0,
1104                velocity_max: 2000.0,
1105                bc_value: 0.25,
1106            },
1107            BCSegmentData {
1108                velocity_min: 2000.0,
1109                velocity_max: 3000.0,
1110                bc_value: 0.75,
1111            },
1112        ];
1113
1114        for v in [-1e6, -100.0, 999.0, 1000.0, 1001.0] {
1115            assert_eq!(
1116                velocity_segment_bc(v, &segments, 0.5).to_bits(),
1117                0.25f64.to_bits(),
1118                "below-coverage clamp must stay exactly flat (v={v})"
1119            );
1120        }
1121        for v in [2999.0, 3000.0, 3001.0, 1e6] {
1122            assert_eq!(
1123                velocity_segment_bc(v, &segments, 0.5).to_bits(),
1124                0.75f64.to_bits(),
1125                "above-coverage clamp must stay exactly flat (v={v})"
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn has_interior_gap_is_exactly_where_the_lookup_returns_the_fallback() {
1132        let band = |velocity_min: f64, velocity_max: f64, bc_value: f64| BCSegmentData {
1133            velocity_min,
1134            velocity_max,
1135            bc_value,
1136        };
1137        let cases = [
1138            (
1139                "touching",
1140                vec![band(500.0, 2000.0, 0.42), band(2000.0, 4000.0, 0.45)],
1141                false,
1142            ),
1143            (
1144                "gapped",
1145                vec![band(500.0, 1800.0, 0.42), band(2300.0, 4000.0, 0.45)],
1146                true,
1147            ),
1148            (
1149                "gapped, descending",
1150                vec![band(2300.0, 4000.0, 0.45), band(500.0, 1800.0, 0.42)],
1151                true,
1152            ),
1153            (
1154                "overlaps covering the span",
1155                vec![
1156                    band(500.0, 3000.0, 0.40),
1157                    band(1000.0, 1500.0, 0.42),
1158                    band(1600.0, 2000.0, 0.44),
1159                ],
1160                false,
1161            ),
1162            ("single", vec![band(500.0, 4000.0, 0.45)], false),
1163            (
1164                "three bands, one gap",
1165                vec![
1166                    band(2500.0, 4000.0, 0.46),
1167                    band(1500.0, 2500.0, 0.44),
1168                    band(500.0, 1200.0, 0.40),
1169                ],
1170                true,
1171            ),
1172        ];
1173        for (label, ladder, expected) in cases {
1174            let lookup_falls_back =
1175                (0..5000).any(|v| raw_velocity_segment_bc(f64::from(v), &ladder, -1.0) == -1.0);
1176            assert_eq!(lookup_falls_back, expected, "{label}: the case itself");
1177            assert_eq!(has_interior_gap(&ladder), expected, "{label}");
1178        }
1179    }
1180}