Skip to main content

ballistics_engine/
bc_estimation.rs

1use crate::BCSegmentData;
2
3/// Resolve a velocity-keyed BC table without assuming segment order.
4///
5/// Bands are half-open (`velocity_min <= v < velocity_max`), so a shared boundary belongs to
6/// the upper band. Below/above global coverage, clamp to the lowest/highest-velocity band;
7/// an interior coverage gap or empty table uses the caller's projectile-specific scalar BC.
8///
9/// MBA-1404: away from a transition this is byte-identical to the original step/clamp
10/// lookup (see [`raw_velocity_segment_bc`], preserved verbatim). Near every discontinuity
11/// class the raw lookup can produce -- an interior band-to-band boundary, either edge of an
12/// interior band-to-fallback gap, or a global coverage entry/exit edge (below the lowest
13/// band's `velocity_min`, above the highest band's `velocity_max`) -- this instead blends
14/// across a small velocity margin centered on the boundary with a Hermite smoothstep
15/// (`3t^2 - 2t^3`), so an integrator sampling BC every step does not see an instantaneous
16/// drag-coefficient jolt exactly at a band edge. Tables with fewer than two segments have no
17/// adjacent band to blend against and return the raw value unchanged.
18pub(crate) fn velocity_segment_bc(
19    velocity_fps: f64,
20    segments: &[BCSegmentData],
21    fallback_bc: f64,
22) -> f64 {
23    if segments.len() < 2 {
24        // No adjacent band to blend against: single-band and empty tables stay exactly the
25        // pre-MBA-1404 behavior.
26        return raw_velocity_segment_bc(velocity_fps, segments, fallback_bc);
27    }
28
29    for (boundary, margin) in transition_boundaries(segments) {
30        if margin <= 0.0 {
31            // Degenerate (zero/negative-width) adjacent band: no blend, keep the hard step.
32            continue;
33        }
34        let half = margin / 2.0;
35        let lo_v = boundary - half;
36        let hi_v = boundary + half;
37        if velocity_fps < lo_v || velocity_fps > hi_v {
38            continue;
39        }
40
41        let lo = raw_velocity_segment_bc(lo_v, segments, fallback_bc);
42        let hi = raw_velocity_segment_bc(hi_v, segments, fallback_bc);
43        if lo == hi {
44            // Nothing actually jumps here (e.g. a continuous coverage-clamp edge): stay flat
45            // rather than run smoothstep algebra that would return the same value anyway.
46            return lo;
47        }
48
49        let t = ((velocity_fps - lo_v) / margin).clamp(0.0, 1.0);
50        return lo + smoothstep(t) * (hi - lo);
51    }
52
53    raw_velocity_segment_bc(velocity_fps, segments, fallback_bc)
54}
55
56/// The pre-MBA-1404 step/clamp lookup, unchanged. Also used by [`velocity_segment_bc`] to
57/// sample the value on each side of a boundary when building a blend.
58fn raw_velocity_segment_bc(velocity_fps: f64, segments: &[BCSegmentData], fallback_bc: f64) -> f64 {
59    if let Some(segment) = segments.iter().find(|segment| {
60        velocity_fps >= segment.velocity_min && velocity_fps < segment.velocity_max
61    }) {
62        return segment.bc_value;
63    }
64
65    let lowest = segments
66        .iter()
67        .min_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
68    if let Some(segment) = lowest {
69        if velocity_fps < segment.velocity_min {
70            return segment.bc_value;
71        }
72    }
73
74    let highest = segments
75        .iter()
76        .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max));
77    if let Some(segment) = highest {
78        if velocity_fps >= segment.velocity_max {
79            return segment.bc_value;
80        }
81    }
82
83    fallback_bc
84}
85
86/// Every velocity at which [`raw_velocity_segment_bc`] can jump, paired with the margin to
87/// blend across (`0.0` means "no blend": a degenerate/zero-width adjacent band). Order of
88/// `segments` does not matter -- the boundaries are derived from a `velocity_min`-sorted
89/// copy, so ascending- and descending-stored tables produce identical boundaries.
90///
91/// Discontinuity classes (see `velocity_segment_bc`'s doc comment): interior band-to-band
92/// boundaries, the two edges of every interior band-to-fallback gap, and the two global
93/// coverage entry/exit edges.
94fn transition_boundaries(segments: &[BCSegmentData]) -> Vec<(f64, f64)> {
95    let width = |s: &BCSegmentData| (s.velocity_max - s.velocity_min).max(0.0);
96
97    let mut sorted: Vec<&BCSegmentData> = segments.iter().collect();
98    sorted.sort_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
99
100    let mut boundaries = Vec::with_capacity(sorted.len() * 2 + 2);
101
102    // Coverage entry: below the lowest-velocity_min band. The clamp region above has no
103    // finite width, so the margin is driven entirely by the lowest band's own width.
104    if let Some(first) = sorted.first() {
105        boundaries.push((first.velocity_min, smoothing_margin(width(first))));
106    }
107    // Coverage exit: above the highest-velocity_max band. Mirrors raw()'s own "highest"
108    // selection (max by `velocity_max`), which need not be `sorted.last()` for a
109    // malformed/overlapping table.
110    if let Some(highest) = segments
111        .iter()
112        .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max))
113    {
114        boundaries.push((highest.velocity_max, smoothing_margin(width(highest))));
115    }
116
117    for pair in sorted.windows(2) {
118        let (left, right) = (pair[0], pair[1]);
119        if right.velocity_min > left.velocity_max {
120            // Interior band-to-fallback gap: two edges, each capped by the gap's own width
121            // in addition to its bordering band's width, so a narrow gap never lets the
122            // blend eat into the band on the far side of it.
123            let gap_width = right.velocity_min - left.velocity_max;
124            boundaries.push((
125                left.velocity_max,
126                smoothing_margin(width(left).min(gap_width)),
127            ));
128            boundaries.push((
129                right.velocity_min,
130                smoothing_margin(gap_width.min(width(right))),
131            ));
132        } else if right.velocity_min == left.velocity_max {
133            // Contiguous band-to-band boundary.
134            boundaries.push((
135                left.velocity_max,
136                smoothing_margin(width(left).min(width(right))),
137            ));
138        }
139        // Overlapping bands (right.velocity_min < left.velocity_max) are already an
140        // ambiguous shape for raw()'s first-match lookup and aren't produced by any current
141        // caller; MBA-1404 does not add blending for that out-of-scope configuration.
142    }
143
144    boundaries
145}
146
147/// `min(50.0 fps, 0.25 * narrower_adjacent_width)`; `0.0` (no blend) for a zero/negative
148/// width, which is how a degenerate adjacent band disables blending at its boundaries.
149fn smoothing_margin(narrower_adjacent_width: f64) -> f64 {
150    if narrower_adjacent_width <= 0.0 {
151        return 0.0;
152    }
153    (0.25 * narrower_adjacent_width).min(50.0)
154}
155
156/// Hermite smoothstep, `3t^2 - 2t^3`, clamped to `[0, 1]`.
157fn smoothstep(t: f64) -> f64 {
158    let t = t.clamp(0.0, 1.0);
159    t * t * (3.0 - 2.0 * t)
160}
161
162/// Bullet type classification based on model name
163#[derive(Debug, Clone, Copy, PartialEq)]
164pub enum BulletType {
165    MatchBoatTail,
166    MatchFlatBase,
167    HuntingBoatTail,
168    HuntingFlatBase,
169    VldHighBc,
170    Hybrid,
171    FMJ,
172    RoundNose,
173    Unknown,
174}
175
176/// BC degradation factors for different bullet types
177pub struct BulletTypeFactors {
178    pub drop: f64,
179    pub transition_curve: f64,
180}
181
182impl BulletType {
183    /// Get degradation factors for this bullet type
184    pub fn get_factors(&self) -> BulletTypeFactors {
185        match self {
186            BulletType::MatchBoatTail => BulletTypeFactors {
187                drop: 0.075, // 7.5% total drop for match boat tail
188                transition_curve: 0.3,
189            },
190            BulletType::MatchFlatBase => BulletTypeFactors {
191                drop: 0.10, // 10% for match flat base
192                transition_curve: 0.35,
193            },
194            BulletType::HuntingBoatTail => BulletTypeFactors {
195                drop: 0.15, // 15% for hunting boat tail
196                transition_curve: 0.45,
197            },
198            BulletType::HuntingFlatBase => BulletTypeFactors {
199                drop: 0.20, // 20% for hunting flat base
200                transition_curve: 0.5,
201            },
202            BulletType::VldHighBc => BulletTypeFactors {
203                drop: 0.05, // 5% for VLD (very low drag)
204                transition_curve: 0.25,
205            },
206            BulletType::Hybrid => BulletTypeFactors {
207                drop: 0.06, // 6% for hybrid designs
208                transition_curve: 0.28,
209            },
210            BulletType::FMJ => BulletTypeFactors {
211                drop: 0.12, // 12% for military ball
212                transition_curve: 0.4,
213            },
214            BulletType::RoundNose => BulletTypeFactors {
215                drop: 0.35, // 35% for round nose
216                transition_curve: 0.7,
217            },
218            BulletType::Unknown => BulletTypeFactors {
219                drop: 0.15, // Conservative 15%
220                transition_curve: 0.5,
221            },
222        }
223    }
224}
225
226/// BC segment estimator based on physics and known patterns
227pub struct BCSegmentEstimator;
228
229impl BCSegmentEstimator {
230    /// Identify bullet type from model name and characteristics.
231    ///
232    /// This compatibility entry point interprets `bc_value` as a G1 BC. Call
233    /// [`Self::identify_bullet_type_for_drag_model`] when the reference drag
234    /// model is known.
235    pub fn identify_bullet_type(
236        model: &str,
237        weight: f64,
238        caliber: f64,
239        bc_value: Option<f64>,
240    ) -> BulletType {
241        Self::identify_bullet_type_for_drag_model(model, weight, caliber, bc_value, "G1")
242    }
243
244    /// Identify bullet type while interpreting the BC in its reference-model space.
245    pub fn identify_bullet_type_for_drag_model(
246        model: &str,
247        weight: f64,
248        caliber: f64,
249        bc_value: Option<f64>,
250        drag_model: &str,
251    ) -> BulletType {
252        let model_lower = model.to_lowercase();
253
254        // VLD/High BC bullets
255        if model_lower.contains("vld")
256            || model_lower.contains("berger")
257            || model_lower.contains("hybrid")
258            || model_lower.contains("elite")
259        {
260            if model_lower.contains("hybrid") {
261                return BulletType::Hybrid;
262            }
263            return BulletType::VldHighBc;
264        }
265
266        // Match bullets (competition/target)
267        if model_lower.contains("smk")
268            || model_lower.contains("matchking")
269            || model_lower.contains("match")
270            || model_lower.contains("bthp")
271            || model_lower.contains("competition")
272            || model_lower.contains("target")
273            || model_lower.contains("a-max")
274            || model_lower.contains("eld-m")
275            || model_lower.contains("scenar")
276            || model_lower.contains("x-ring")
277        {
278            // Check for boat tail
279            if model_lower.contains("bt") || model_lower.contains("boat") {
280                return BulletType::MatchBoatTail;
281            }
282            // Check if high BC indicates boat tail (guard sd>0: calculate_sectional_density
283            // returns 0 for non-positive caliber, which would make bc/sd == +Inf).
284            if let Some(bc) = bc_value {
285                let sd = Self::calculate_sectional_density(weight, caliber);
286                if sd > 0.0 && Self::classification_bc_sd_ratio(bc, sd, drag_model) > 1.6 {
287                    return BulletType::MatchBoatTail;
288                }
289            }
290            return BulletType::MatchFlatBase;
291        }
292
293        // Hunting bullets (expanding)
294        if model_lower.contains("gameking")
295            || model_lower.contains("hunting")
296            || model_lower.contains("sst")
297            || model_lower.contains("eld-x")
298            || model_lower.contains("partition")
299            || model_lower.contains("accubond")
300            || model_lower.contains("core-lokt")
301            || model_lower.contains("ballistic tip")
302            || model_lower.contains("v-max")
303            || model_lower.contains("hornady sp")
304            || model_lower.contains("interlock")
305            || model_lower.contains("tsx")
306        {
307            // Check for boat tail
308            if model_lower.contains("bt")
309                || model_lower.contains("boat")
310                || model_lower.contains("sst")
311                || model_lower.contains("accubond")
312            {
313                return BulletType::HuntingBoatTail;
314            }
315            return BulletType::HuntingFlatBase;
316        }
317
318        // FMJ/Military
319        if model_lower.contains("fmj")
320            || model_lower.contains("ball")
321            || model_lower.contains("m80")
322            || model_lower.contains("m855")
323            || model_lower.contains("tracer")
324        {
325            return BulletType::FMJ;
326        }
327
328        // Round nose
329        if model_lower.contains("rn")
330            || model_lower.contains("round nose")
331            || model_lower.contains("rnsp")
332        {
333            return BulletType::RoundNose;
334        }
335
336        // Use BC value as hint if available. Guard sd>0 (zero for non-positive caliber)
337        // so a degenerate input falls through to Unknown instead of dividing by zero
338        // (bc/0 == +Inf, which would silently classify as VldHighBc).
339        if let Some(bc) = bc_value {
340            let sd = Self::calculate_sectional_density(weight, caliber);
341            if sd > 0.0 {
342                let bc_to_sd_ratio = Self::classification_bc_sd_ratio(bc, sd, drag_model);
343
344                if bc_to_sd_ratio > 1.8 {
345                    return BulletType::VldHighBc;
346                } else if bc_to_sd_ratio > 1.5 {
347                    return BulletType::MatchBoatTail;
348                } else if bc_to_sd_ratio < 1.2 {
349                    return BulletType::HuntingFlatBase;
350                }
351            }
352        }
353
354        BulletType::Unknown
355    }
356
357    /// Convert the reference-model-dependent BC/SD ratio into the G1 space used
358    /// by the legacy coarse classification thresholds above. Typical boat-tail
359    /// G1 BCs are approximately twice their G7 BCs; this normalization prevents
360    /// ordinary G7 match bullets from looking like low-BC G1 flat-base bullets.
361    fn classification_bc_sd_ratio(bc: f64, sd: f64, drag_model: &str) -> f64 {
362        let g1_equivalent_bc = if drag_model.eq_ignore_ascii_case("G7") {
363            bc * 2.0
364        } else {
365            bc
366        };
367        g1_equivalent_bc / sd
368    }
369
370    /// Calculate sectional density (SD) from weight and caliber
371    pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
372        // SD = weight / (7000 * caliber^2)
373        // Protect against division by zero or negative caliber
374        if caliber_inches <= 0.0 {
375            return 0.0;
376        }
377        weight_grains / (7000.0 * caliber_inches * caliber_inches)
378    }
379
380    /// Estimate BC segments based on bullet characteristics
381    #[allow(clippy::manual_clamp)] // max/min intentionally maps a NaN SD to the lower fallback.
382    pub fn estimate_bc_segments(
383        base_bc: f64,
384        caliber: f64,
385        weight: f64,
386        model: &str,
387        drag_model: &str,
388    ) -> Vec<BCSegmentData> {
389        // Identify bullet type
390        let bullet_type = Self::identify_bullet_type_for_drag_model(
391            model,
392            weight,
393            caliber,
394            Some(base_bc),
395            drag_model,
396        );
397        let type_factors = bullet_type.get_factors();
398
399        // Calculate sectional density
400        let sd = Self::calculate_sectional_density(weight, caliber);
401
402        // Adjust BC drop based on sectional density
403        // Higher SD = more stable BC
404        let sd_factor = (sd / 0.25).max(0.7).min(1.3);
405        let nominal_drop = type_factors.drop;
406
407        // Generate segments based on bullet type
408        let mut segments = Vec::new();
409
410        // Determine velocity ranges and BC retention factors
411        match bullet_type {
412            BulletType::MatchBoatTail => {
413                // Match boat tail - minimal BC degradation
414                segments.push(BCSegmentData {
415                    velocity_min: 2800.0,
416                    velocity_max: 5000.0,
417                    bc_value: base_bc * 1.000,
418                });
419                segments.push(BCSegmentData {
420                    velocity_min: 2400.0,
421                    velocity_max: 2800.0,
422                    bc_value: base_bc * 0.985,
423                });
424                segments.push(BCSegmentData {
425                    velocity_min: 2000.0,
426                    velocity_max: 2400.0,
427                    bc_value: base_bc * 0.965,
428                });
429                segments.push(BCSegmentData {
430                    velocity_min: 1600.0,
431                    velocity_max: 2000.0,
432                    bc_value: base_bc * 0.945,
433                });
434                segments.push(BCSegmentData {
435                    velocity_min: 0.0,
436                    velocity_max: 1600.0,
437                    bc_value: base_bc * 0.925,
438                });
439            }
440            BulletType::VldHighBc | BulletType::Hybrid => {
441                // VLD/Hybrid - very stable BC
442                segments.push(BCSegmentData {
443                    velocity_min: 2800.0,
444                    velocity_max: 5000.0,
445                    bc_value: base_bc * 1.000,
446                });
447                segments.push(BCSegmentData {
448                    velocity_min: 2200.0,
449                    velocity_max: 2800.0,
450                    bc_value: base_bc * 0.990,
451                });
452                segments.push(BCSegmentData {
453                    velocity_min: 1600.0,
454                    velocity_max: 2200.0,
455                    bc_value: base_bc * 0.970,
456                });
457                segments.push(BCSegmentData {
458                    velocity_min: 0.0,
459                    velocity_max: 1600.0,
460                    bc_value: base_bc * 0.950,
461                });
462            }
463            BulletType::HuntingBoatTail => {
464                // Hunting boat tail - moderate degradation
465                segments.push(BCSegmentData {
466                    velocity_min: 2600.0,
467                    velocity_max: 5000.0,
468                    bc_value: base_bc * 1.000,
469                });
470                segments.push(BCSegmentData {
471                    velocity_min: 2200.0,
472                    velocity_max: 2600.0,
473                    bc_value: base_bc * 0.960,
474                });
475                segments.push(BCSegmentData {
476                    velocity_min: 1800.0,
477                    velocity_max: 2200.0,
478                    bc_value: base_bc * 0.900,
479                });
480                segments.push(BCSegmentData {
481                    velocity_min: 0.0,
482                    velocity_max: 1800.0,
483                    bc_value: base_bc * 0.850,
484                });
485            }
486            _ => {
487                // Default degradation profile
488                segments.push(BCSegmentData {
489                    velocity_min: 2800.0,
490                    velocity_max: 5000.0,
491                    bc_value: base_bc,
492                });
493
494                let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
495                segments.push(BCSegmentData {
496                    velocity_min: 1800.0,
497                    velocity_max: 2800.0,
498                    bc_value: transonic_bc,
499                });
500
501                let subsonic_bc = base_bc * (1.0 - nominal_drop);
502                segments.push(BCSegmentData {
503                    velocity_min: 0.0,
504                    velocity_max: 1800.0,
505                    bc_value: subsonic_bc,
506                });
507            }
508        }
509
510        // G7 reference drag follows modern boat-tail projectiles more closely,
511        // so their banded BC varies less than the G1-shaped ladders above. Scale
512        // each loss from nominal rather than the BC itself: this leaves the muzzle
513        // band unchanged by this adjustment and makes the model effective for every
514        // named and default profile. Do not run the identity algebra for G1, so
515        // its established floating-point outputs remain bit-for-bit unchanged.
516        if drag_model.eq_ignore_ascii_case("G7") {
517            const G7_DROP_SCALE: f64 = 0.8;
518            for segment in &mut segments {
519                let drop_from_nominal = base_bc - segment.bc_value;
520                segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
521            }
522        }
523
524        // Sectional density shapes only the degradation depth. Scaling the BC
525        // itself would alter the user's published muzzle value and would apply SD
526        // twice in the default profile. Skip identity algebra so SD=0.25 keeps
527        // established output bits unchanged.
528        if sd_factor != 1.0 {
529            for segment in &mut segments {
530                let drop_from_nominal = base_bc - segment.bc_value;
531                segment.bc_value = base_bc - drop_from_nominal / sd_factor;
532            }
533        }
534
535        segments
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_bullet_type_identification() {
545        assert_eq!(
546            BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
547            BulletType::MatchFlatBase
548        );
549        assert_eq!(
550            BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
551            BulletType::MatchBoatTail
552        );
553        assert_eq!(
554            BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
555            BulletType::HuntingBoatTail
556        );
557        assert_eq!(
558            BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
559            BulletType::FMJ
560        );
561        assert_eq!(
562            BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
563            BulletType::RoundNose
564        );
565        assert_eq!(
566            BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
567            BulletType::VldHighBc
568        );
569        assert_eq!(
570            BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
571            BulletType::Unknown
572        );
573    }
574
575    #[test]
576    fn test_sectional_density() {
577        let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
578        assert!((sd - 0.253).abs() < 0.001);
579    }
580
581    #[test]
582    fn test_bc_estimation() {
583        let segments =
584            BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
585
586        // Match rifles typically have 4 segments
587        assert!(segments.len() >= 3);
588        // First segment should be close to base BC
589        assert!((segments[0].bc_value - 0.450).abs() < 0.05);
590        // BC should degrade at lower velocities
591        assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
592    }
593
594    #[test]
595    fn g7_transition_adjustment_softens_each_band_drop() {
596        let base_bc = 0.5;
597        // SD = 0.25 exactly, so the independent sectional-density adjustment is neutral.
598        let caliber = 1.0;
599        let weight = 1750.0;
600
601        for model in ["SMK BT", "FMJ"] {
602            let g1 =
603                BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
604            let g7 =
605                BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
606            let lowercase_g7 =
607                BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
608            assert_eq!(g7.len(), g1.len());
609            assert_eq!(lowercase_g7.len(), g7.len());
610
611            for ((g1_band, g7_band), lowercase_band) in
612                g1.iter().zip(&g7).zip(&lowercase_g7)
613            {
614                assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
615                assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
616                assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
617                assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
618                assert_eq!(lowercase_band.bc_value.to_bits(), g7_band.bc_value.to_bits());
619                let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
620                assert!(
621                    (g7_band.bc_value - expected_g7).abs() < 1e-12,
622                    "{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
623                    g1_band.velocity_min,
624                    g1_band.velocity_max,
625                    g1_band.bc_value,
626                    g7_band.bc_value
627                );
628            }
629        }
630    }
631
632    #[test]
633    fn sectional_density_shapes_only_band_degradation() {
634        let base_bc = 0.5;
635        let caliber = 0.224;
636        let weight = 77.0;
637        let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
638        let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
639
640        for drag_model in ["G1", "G7"] {
641            let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
642            for (model, raw_retentions) in [
643                ("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
644                ("FMJ", &[1.0, 0.964, 0.88][..]),
645            ] {
646                let segments = BCSegmentEstimator::estimate_bc_segments(
647                    base_bc,
648                    caliber,
649                    weight,
650                    model,
651                    drag_model,
652                );
653                assert_eq!(segments.len(), raw_retentions.len());
654
655                for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
656                    let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
657                    let expected = base_bc - raw_drop / sd_factor;
658                    assert!(
659                        (segment.bc_value - expected).abs() < 1e-12,
660                        "{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
661                        segment.velocity_min,
662                        segment.velocity_max,
663                        segment.bc_value
664                    );
665                }
666                assert_eq!(
667                    segments[0].bc_value.to_bits(),
668                    base_bc.to_bits(),
669                    "published muzzle BC must remain exact for {drag_model} {model}"
670                );
671            }
672        }
673
674        // High SD used to multiply every band above nominal and then cap them
675        // all to base_bc, erasing the degradation ladder.
676        let high_sd_base_bc = 0.3;
677        let high_sd_segments = BCSegmentEstimator::estimate_bc_segments(
678            high_sd_base_bc,
679            0.308,
680            220.0,
681            "SMK BT",
682            "G7",
683        );
684        assert_eq!(high_sd_segments[0].bc_value.to_bits(), high_sd_base_bc.to_bits());
685        assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
686        assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
687    }
688
689    #[test]
690    fn generic_g7_bc_uses_g7_classification_space() {
691        // A representative 175 gr .308 match bullet. Its G7 BC is ordinary for a
692        // boat-tail projectile, but the same numeric value looks like a blunt
693        // flat-base bullet when interpreted with the G1 BC/SD thresholds.
694        let base_bc = 0.243;
695        let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
696
697        assert!(
698            segments.len() >= 4,
699            "G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
700        );
701        let subsonic_bc = segments.last().unwrap().bc_value;
702        assert!(
703            subsonic_bc >= base_bc * 0.92,
704            "G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
705        );
706
707        // The normalization is deliberately equivalent to classifying the
708        // approximate G1 BC, while the G7 transition adjustment then softens
709        // the loss within that same ladder and the legacy entry point remains
710        // G1-compatible.
711        let g1_segments =
712            BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
713        assert_eq!(segments.len(), g1_segments.len());
714        let mut saw_g7_softening = false;
715        for (g7, g1) in segments.iter().zip(&g1_segments) {
716            assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
717            assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
718            let g7_retention = g7.bc_value / base_bc;
719            let g1_retention = g1.bc_value / (base_bc * 2.0);
720            assert!(g7_retention + 1e-12 >= g1_retention);
721            saw_g7_softening |= g7_retention > g1_retention + 1e-12;
722        }
723        assert!(saw_g7_softening);
724
725        let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
726        assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
727        assert_eq!(
728            legacy_g1,
729            BCSegmentEstimator::identify_bullet_type_for_drag_model(
730                "",
731                175.0,
732                0.308,
733                Some(base_bc),
734                "G1",
735            )
736        );
737        assert_eq!(
738            BCSegmentEstimator::identify_bullet_type_for_drag_model(
739                "175gr SMK",
740                175.0,
741                0.308,
742                Some(base_bc),
743                "g7",
744            ),
745            BulletType::MatchBoatTail
746        );
747        assert_eq!(
748            BCSegmentEstimator::identify_bullet_type_for_drag_model(
749                "",
750                175.0,
751                0.0,
752                Some(base_bc),
753                "G7",
754            ),
755            BulletType::Unknown
756        );
757    }
758
759    // MBA-1404: smoothstep continuity battery for `velocity_segment_bc`. Bands used here
760    // pick bc_values that are exact binary fractions (0.25/0.5/0.75/etc.) wherever a test
761    // hand-derives the blended midpoint, so assertions can use `assert_eq!` rather than an
762    // epsilon tolerance.
763
764    #[test]
765    fn margin_caps_at_50_fps_for_wide_adjacent_bands() {
766        // 0.25 * 1000 = 250, well above the 50 fps cap.
767        assert_eq!(smoothing_margin(1000.0), 50.0);
768        assert_eq!(smoothing_margin(200.0), 50.0); // 0.25*200 == 50, right at the cap edge
769    }
770
771    #[test]
772    fn margin_uses_25_percent_rule_for_narrow_adjacent_bands() {
773        // 0.25 * 40 = 10, under the 50 fps cap, so the 25% rule governs.
774        assert_eq!(smoothing_margin(40.0), 10.0);
775        assert_eq!(smoothing_margin(4.0), 1.0);
776    }
777
778    #[test]
779    fn margin_is_zero_for_degenerate_widths() {
780        assert_eq!(smoothing_margin(0.0), 0.0);
781        assert_eq!(smoothing_margin(-5.0), 0.0); // defensive: malformed negative width
782    }
783
784    #[test]
785    fn smoothstep_is_centered_and_matches_hermite_formula() {
786        assert_eq!(smoothstep(0.0), 0.0);
787        assert_eq!(smoothstep(1.0), 1.0);
788        assert_eq!(smoothstep(0.5), 0.5); // 3*0.25 - 2*0.125 = 0.75 - 0.25 = 0.5
789        // Out-of-range t is clamped rather than extrapolated.
790        assert_eq!(smoothstep(-1.0), 0.0);
791        assert_eq!(smoothstep(2.0), 1.0);
792    }
793
794    #[test]
795    fn ascending_band_boundary_blends_symmetrically_around_the_boundary() {
796        // Two contiguous 1000 fps-wide bands: margin = min(50, 0.25*1000) = 50, half = 25.
797        let segments = vec![
798            BCSegmentData {
799                velocity_min: 0.0,
800                velocity_max: 1000.0,
801                bc_value: 0.25,
802            },
803            BCSegmentData {
804                velocity_min: 1000.0,
805                velocity_max: 2000.0,
806                bc_value: 0.75,
807            },
808        ];
809
810        // Deep mid-band: exactly flat, bit-identical to the plain band value.
811        assert_eq!(velocity_segment_bc(200.0, &segments, 0.9).to_bits(), 0.25f64.to_bits());
812        assert_eq!(velocity_segment_bc(1800.0, &segments, 0.9).to_bits(), 0.75f64.to_bits());
813
814        // Exactly at the boundary (t = 0.5): the midpoint of the two band values.
815        assert_eq!(velocity_segment_bc(1000.0, &segments, 0.9), 0.5);
816
817        // At the low edge of the margin window (t = 0): matches the lower band exactly.
818        assert_eq!(
819            velocity_segment_bc(975.0, &segments, 0.9).to_bits(),
820            0.25f64.to_bits()
821        );
822        // At the high edge of the margin window (t = 1): matches the upper band exactly.
823        assert_eq!(
824            velocity_segment_bc(1025.0, &segments, 0.9).to_bits(),
825            0.75f64.to_bits()
826        );
827
828        // Strictly inside the window: strictly between the two band values (monotonic).
829        let just_below = velocity_segment_bc(999.0, &segments, 0.9);
830        let just_above = velocity_segment_bc(1001.0, &segments, 0.9);
831        assert!(just_below > 0.25 && just_below < 0.5);
832        assert!(just_above > 0.5 && just_above < 0.75);
833    }
834
835    #[test]
836    fn descending_stored_order_matches_ascending_boundary_blend() {
837        // Same table as above, stored high-to-low: the helper's semantics are documented as
838        // order-independent, and MBA-1404 must not break that for the new blend either.
839        let ascending = vec![
840            BCSegmentData {
841                velocity_min: 0.0,
842                velocity_max: 1000.0,
843                bc_value: 0.25,
844            },
845            BCSegmentData {
846                velocity_min: 1000.0,
847                velocity_max: 2000.0,
848                bc_value: 0.75,
849            },
850        ];
851        let mut descending = ascending.clone();
852        descending.reverse();
853
854        for v in [200.0, 975.0, 999.0, 1000.0, 1001.0, 1025.0, 1800.0] {
855            assert_eq!(
856                velocity_segment_bc(v, &ascending, 0.9),
857                velocity_segment_bc(v, &descending, 0.9),
858                "order must not affect the blended result at v={v}"
859            );
860        }
861    }
862
863    #[test]
864    fn gapped_table_blends_both_edges_of_the_fallback_gap_and_stays_flat_mid_gap() {
865        // Band widths 1000 each; gap from 1000 to 1200 (width 200). Exit-edge margin =
866        // min(50, 0.25*min(1000,200)) = 50; entry-edge margin = min(50, 0.25*min(200,1000)) = 50.
867        let segments = vec![
868            BCSegmentData {
869                velocity_min: 0.0,
870                velocity_max: 1000.0,
871                bc_value: 0.25,
872            },
873            BCSegmentData {
874                velocity_min: 1200.0,
875                velocity_max: 2200.0,
876                bc_value: 0.75,
877            },
878        ];
879        let fallback = 0.5; // exact binary fraction: keeps the blend math exact here too.
880
881        // Deep mid-gap: exactly the fallback, untouched by either edge's margin.
882        assert_eq!(
883            velocity_segment_bc(1100.0, &segments, fallback).to_bits(),
884            fallback.to_bits()
885        );
886
887        // Exit edge (band -> gap) at v=1000: t=0.5 blend between the band value and fallback.
888        assert_eq!(velocity_segment_bc(1000.0, &segments, fallback), 0.375); // (0.25+0.5)/2
889
890        // Entry edge (gap -> band) at v=1200: t=0.5 blend between fallback and the band value.
891        assert_eq!(velocity_segment_bc(1200.0, &segments, fallback), 0.625); // (0.5+0.75)/2
892
893        // Just outside each margin window: exactly flat again.
894        assert_eq!(
895            velocity_segment_bc(974.0, &segments, fallback).to_bits(),
896            0.25f64.to_bits()
897        );
898        assert_eq!(
899            velocity_segment_bc(1226.0, &segments, fallback).to_bits(),
900            0.75f64.to_bits()
901        );
902    }
903
904    #[test]
905    fn single_segment_table_never_blends_and_is_byte_identical_to_the_raw_lookup() {
906        let single = vec![BCSegmentData {
907            velocity_min: 1000.0,
908            velocity_max: 2000.0,
909            bc_value: 0.5,
910        }];
911
912        for v in [-1e6, 500.0, 1000.0, 1500.0, 1999.999, 2000.0, 1e6] {
913            assert_eq!(
914                velocity_segment_bc(v, &single, 0.9).to_bits(),
915                raw_velocity_segment_bc(v, &single, 0.9).to_bits(),
916                "single-band tables must never blend (v={v})"
917            );
918        }
919    }
920
921    #[test]
922    fn empty_table_never_blends_and_always_returns_the_fallback_exactly() {
923        let empty: Vec<BCSegmentData> = vec![];
924        for v in [-1e6, 0.0, 500.0, 1e6] {
925            assert_eq!(velocity_segment_bc(v, &empty, 0.73).to_bits(), 0.73f64.to_bits());
926        }
927    }
928
929    #[test]
930    fn zero_width_adjacent_band_disables_blending_at_its_boundaries() {
931        // The middle "band" is degenerate (velocity_min == velocity_max), so it can never be
932        // matched directly, but it still touches both of its neighbors' boundaries -- both of
933        // those boundaries must fall back to the hard step (margin 0), matching the pre-MBA-1404
934        // raw lookup exactly, rather than average toward the unmatchable degenerate value.
935        let segments = vec![
936            BCSegmentData {
937                velocity_min: 0.0,
938                velocity_max: 1000.0,
939                bc_value: 0.5,
940            },
941            BCSegmentData {
942                velocity_min: 1000.0,
943                velocity_max: 1000.0,
944                bc_value: 0.9,
945            },
946            BCSegmentData {
947                velocity_min: 1000.0,
948                velocity_max: 2000.0,
949                bc_value: 0.6,
950            },
951        ];
952
953        for v in [999.0, 999.99, 1000.0, 1000.01, 1001.0] {
954            assert_eq!(
955                velocity_segment_bc(v, &segments, 0.99).to_bits(),
956                raw_velocity_segment_bc(v, &segments, 0.99).to_bits(),
957                "a degenerate adjacent band must disable blending, not average toward it (v={v})"
958            );
959        }
960    }
961
962    #[test]
963    fn coverage_entry_and_exit_edges_stay_flat_since_the_clamp_matches_the_bordering_band() {
964        // Below the lowest band and above the highest band, the raw lookup clamps to that
965        // same band's own value, so the "coverage entry/exit" boundary is a no-op blend
966        // (lo == hi): it must be exactly flat, not just close, all the way up to (and past)
967        // the boundary's own margin window.
968        let segments = vec![
969            BCSegmentData {
970                velocity_min: 1000.0,
971                velocity_max: 2000.0,
972                bc_value: 0.25,
973            },
974            BCSegmentData {
975                velocity_min: 2000.0,
976                velocity_max: 3000.0,
977                bc_value: 0.75,
978            },
979        ];
980
981        for v in [-1e6, -100.0, 999.0, 1000.0, 1001.0] {
982            assert_eq!(
983                velocity_segment_bc(v, &segments, 0.5).to_bits(),
984                0.25f64.to_bits(),
985                "below-coverage clamp must stay exactly flat (v={v})"
986            );
987        }
988        for v in [2999.0, 3000.0, 3001.0, 1e6] {
989            assert_eq!(
990                velocity_segment_bc(v, &segments, 0.5).to_bits(),
991                0.75f64.to_bits(),
992                "above-coverage clamp must stay exactly flat (v={v})"
993            );
994        }
995    }
996}