Skip to main content

ballistics_engine/
cluster_bc.rs

1//! Cluster-based BC degradation for improved accuracy
2//!
3//! This module implements empirical velocity-dependent BC corrections based on
4//! bullet clustering. Bullets are classified into 4 clusters from their physical
5//! characteristics, and each cluster carries a velocity-keyed multiplier ladder.
6//!
7//! MBA-1127 (2026-07): this is a faithful port of the Python reference model
8//! `ballistics.ml.cluster_bc_degradation.ClusterBCDegradation`
9//! (version tag `2026-07-08-radar-recal-mba1126`), which is the single source of
10//! truth. The ladders and the classifier below MUST stay byte-for-byte equivalent
11//! to that model; `tests::test_parity_with_python_reference` locks the values with
12//! goldens generated from the Python implementation.
13//!
14//! MBA-1126 recalibration notes (why these values differ from the old MBA-645
15//! ladders): the multipliers are ratios r(v) = BC_G7(v) / BC_G7_banded_avg computed
16//! in form-factor space against the 84-point standard G7 table, from 50 Lapua radar
17//! .drg curves + 281 doppler-derived curves. Because the G7 reference curve already
18//! carries the transonic drag rise, a G7 BC is nearly FLAT through the supersonic
19//! band and actually RISES slightly (~+4-8%) around 1000-1200 fps for match bullets.
20//! The old deep transonic dips (0.82-0.90 at 1200-1500) were G1-band shapes
21//! misapplied to G7 semantics and over-degraded every transonic trajectory (and the
22//! BC5D tables sampled from this model). Multipliers may therefore exceed 1.0.
23
24/// One velocity band: (velocity_threshold_fps, multiplier). Bands are ordered by
25/// DESCENDING threshold, matching the Python reference; `get_bc_multiplier`
26/// interpolates linearly between adjacent bands.
27type VelocityBand = (f64, f64);
28
29/// Per-cluster velocity ladders (MBA-1126 radar-recalibrated, G7-referenced).
30/// Index = cluster id (0..=3). Kept identical to the Python `velocity_bands`.
31const VELOCITY_BANDS: [[VelocityBand; 8]; 4] = [
32    // Cluster 0: Standard Long-Range (Sierra MatchKing, etc.; n~248)
33    [
34        (3500.0, 1.00),
35        (3000.0, 1.01),
36        (2500.0, 1.01),
37        (2000.0, 0.99),
38        (1500.0, 0.99),
39        (1200.0, 1.03),
40        (1000.0, 1.05),
41        (0.0, 0.94),
42    ],
43    // Cluster 1: Low-Drag Specialty (blunt/monolithic + stubby varmint; n~12,
44    // shrunk ~25% toward 1.0). G7 BC runs below the banded avg high, rises
45    // steeply through transonic.
46    [
47        (3500.0, 0.90),
48        (3000.0, 0.91),
49        (2500.0, 0.94),
50        (2000.0, 1.02),
51        (1500.0, 1.06),
52        (1200.0, 1.17),
53        (1000.0, 1.40),
54        (0.0, 1.14),
55    ],
56    // Cluster 2: Light Varmint/Target (n~54)
57    [
58        (3500.0, 1.00),
59        (3000.0, 1.00),
60        (2500.0, 1.01),
61        (2000.0, 0.99),
62        (1500.0, 0.985),
63        (1200.0, 1.01),
64        (1000.0, 1.03),
65        (0.0, 0.90),
66    ],
67    // Cluster 3: Heavy Magnums (n~6, shrunk 50% toward 1.0)
68    [
69        (3500.0, 1.00),
70        (3000.0, 1.01),
71        (2500.0, 1.00),
72        (2000.0, 0.99),
73        (1500.0, 0.99),
74        (1200.0, 1.02),
75        (1000.0, 1.04),
76        (0.0, 0.94),
77    ],
78];
79
80#[derive(Debug, Clone, Copy, Default)]
81pub struct ClusterBCDegradation;
82
83impl ClusterBCDegradation {
84    pub fn new() -> Self {
85        Self
86    }
87
88    /// Predict which cluster a bullet belongs to.
89    ///
90    /// Rule-based classifier ported verbatim from the Python reference
91    /// `predict_cluster`. `caliber` must be in INCHES (the sectional-density and
92    /// form-factor thresholds are inch-referenced); passing meters misclassifies.
93    pub fn predict_cluster(&self, caliber: f64, weight_gr: f64, bc_g1: f64) -> usize {
94        // Sectional density (grains / (7000 * caliber_in^2))
95        let sd = if caliber > 0.0 {
96            weight_gr / (7000.0 * caliber * caliber)
97        } else {
98            0.0
99        };
100        // Approximate form factor
101        let form_factor = if sd > 0.0 { bc_g1 / sd } else { 1.5 };
102
103        // Heavy magnum check
104        if weight_gr > 400.0 && caliber > 0.35 {
105            return 3;
106        }
107        // Low-drag specialty check (low form factor)
108        if form_factor < 1.1 && bc_g1 < 0.2 {
109            return 1;
110        }
111        // Light varmint check
112        if weight_gr < 100.0 && caliber < 0.3 {
113            return 2;
114        }
115        // Default to standard long-range
116        0
117    }
118
119    /// Get the BC multiplier for a given velocity and cluster.
120    ///
121    /// Mirrors the Python reference: iterate the cluster's bands (descending
122    /// threshold); at the first band whose threshold `<=` velocity, return the
123    /// band multiplier if it is the top band, otherwise linearly interpolate
124    /// between it and the band above. Below the lowest threshold, return the last
125    /// band's multiplier. Unknown clusters fall back to cluster 0 (matching Python,
126    /// which coerces an unknown id to the standard profile — NOT to 1.0).
127    pub fn get_bc_multiplier(&self, velocity_fps: f64, cluster_id: usize) -> f64 {
128        let cid = if cluster_id < VELOCITY_BANDS.len() {
129            cluster_id
130        } else {
131            0
132        };
133        let bands = &VELOCITY_BANDS[cid];
134
135        for i in 0..bands.len() {
136            let (v_threshold, multiplier) = bands[i];
137            if velocity_fps >= v_threshold {
138                if i == 0 {
139                    // Above the highest threshold
140                    return multiplier;
141                }
142                // Interpolate between bands[i-1] (high) and bands[i] (low)
143                let (v_high, mult_high) = bands[i - 1];
144                let (v_low, mult_low) = bands[i];
145                let t = (velocity_fps - v_low) / (v_high - v_low);
146                return mult_low + t * (mult_high - mult_low);
147            }
148        }
149        // Below the lowest threshold
150        bands[bands.len() - 1].1
151    }
152
153    /// Get cluster name for display
154    pub fn get_cluster_name(&self, cluster_id: usize) -> &'static str {
155        match cluster_id {
156            0 => "Standard Long-Range",
157            1 => "Low-Drag Specialty",
158            2 => "Light Varmint/Target",
159            3 => "Heavy Magnum",
160            _ => "Unknown",
161        }
162    }
163
164    /// Apply cluster-based BC correction: classify, then scale by the
165    /// velocity-dependent multiplier.
166    pub fn apply_correction(
167        &self,
168        bc: f64,
169        caliber: f64,
170        weight_gr: f64,
171        velocity_fps: f64,
172    ) -> f64 {
173        let cluster_id = self.predict_cluster(caliber, weight_gr, bc);
174        let multiplier = self.get_bc_multiplier(velocity_fps, cluster_id);
175        bc * multiplier
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_cluster_prediction() {
185        let cluster_bc = ClusterBCDegradation::new();
186
187        // Standard long-range bullet (.308 Win 168gr) -> cluster 0
188        assert_eq!(cluster_bc.predict_cluster(0.308, 168.0, 0.475), 0);
189
190        // Light varmint bullet (.223 Rem 55gr) -> cluster 2
191        assert_eq!(cluster_bc.predict_cluster(0.224, 55.0, 0.250), 2);
192
193        // Heavy magnum (.458 Win Mag 500gr) -> cluster 3
194        assert_eq!(cluster_bc.predict_cluster(0.458, 500.0, 0.295), 3);
195    }
196
197    #[test]
198    fn test_caliber_must_be_inches_not_meters() {
199        // predict_cluster's SD/form-factor thresholds are inch-referenced.
200        // Passing the caliber in meters (the old cli_api bug, caliber_inches *
201        // 0.0254) collapses the sectional density and misclassifies. Guard it.
202        let cluster_bc = ClusterBCDegradation::new();
203        let inches = cluster_bc.predict_cluster(0.458, 500.0, 0.295);
204        let meters = cluster_bc.predict_cluster(0.458 * 0.0254, 500.0, 0.295);
205        assert_eq!(inches, 3, ".458/500gr in inches -> Heavy Magnum cluster");
206        assert_ne!(
207            meters, inches,
208            "caliber in meters must misclassify (caller must pass inches)"
209        );
210    }
211
212    #[test]
213    fn test_bc_multiplier_g7_semantics() {
214        let cluster_bc = ClusterBCDegradation::new();
215
216        // MBA-1126: G7-referenced multipliers hover around 1.0 and may exceed it.
217        // Cluster 0 at 3000 fps is 1.01 (slight rise), not a sub-1.0 degradation.
218        let mult_hi = cluster_bc.get_bc_multiplier(3000.0, 0);
219        assert!(
220            (mult_hi - 1.01).abs() < 1e-9,
221            "cluster 0 @3000fps should be 1.01, got {mult_hi}"
222        );
223
224        // Transonic RISE for match bullets (the old deep dip is gone): the
225        // 1000-1200 fps region is above the mid-supersonic (1500-2000) values.
226        let mult_transonic = cluster_bc.get_bc_multiplier(1100.0, 0);
227        let mult_mid = cluster_bc.get_bc_multiplier(1750.0, 0);
228        assert!(
229            mult_transonic > mult_mid,
230            "G7 transonic ({mult_transonic}) should now rise above mid-supersonic ({mult_mid})"
231        );
232    }
233
234    #[test]
235    fn test_apply_correction_g7() {
236        let cluster_bc = ClusterBCDegradation::new();
237        let bc = 0.475;
238
239        // High velocity: minimal change (>=95% retention).
240        let hi = cluster_bc.apply_correction(bc, 0.308, 168.0, 2800.0);
241        assert!(hi >= bc * 0.95, "high-v should keep >=95% BC, got {}", hi / bc);
242
243        // Transonic (1100 fps): cluster 0 G7 BC rises, so the corrected BC is
244        // ABOVE the stated BC (this is the MBA-1126 correction vs the old dip).
245        let transonic = cluster_bc.apply_correction(bc, 0.308, 168.0, 1100.0);
246        assert!(
247            transonic > bc,
248            "cluster-0 transonic G7 BC should rise above stated, got {}",
249            transonic / bc
250        );
251    }
252
253    /// Golden parity test — values generated directly from the Python reference
254    /// `ClusterBCDegradation` (tag 2026-07-08-radar-recal-mba1126). If this fails,
255    /// the Rust port has drifted from the source of truth; regenerate and reconcile.
256    #[test]
257    fn test_parity_with_python_reference() {
258        let m = ClusterBCDegradation::new();
259
260        // (cluster, velocity_fps, expected_multiplier)
261        let mult_goldens: &[(usize, f64, f64)] = &[
262            (0, 3600.0, 1.000000), (0, 3500.0, 1.000000), (0, 3250.0, 1.005000),
263            (0, 3000.0, 1.010000), (0, 2800.0, 1.010000), (0, 2500.0, 1.010000),
264            (0, 2250.0, 1.000000), (0, 2000.0, 0.990000), (0, 1750.0, 0.990000),
265            (0, 1500.0, 0.990000), (0, 1350.0, 1.010000), (0, 1200.0, 1.030000),
266            (0, 1100.0, 1.040000), (0, 1000.0, 1.050000), (0, 900.0, 1.039000),
267            (0, 800.0, 1.028000), (0, 500.0, 0.995000), (0, 0.0, 0.940000),
268            (1, 3600.0, 0.900000), (1, 3500.0, 0.900000), (1, 3250.0, 0.905000),
269            (1, 3000.0, 0.910000), (1, 2800.0, 0.922000), (1, 2500.0, 0.940000),
270            (1, 2250.0, 0.980000), (1, 2000.0, 1.020000), (1, 1750.0, 1.040000),
271            (1, 1500.0, 1.060000), (1, 1350.0, 1.115000), (1, 1200.0, 1.170000),
272            (1, 1100.0, 1.285000), (1, 1000.0, 1.400000), (1, 900.0, 1.374000),
273            (1, 800.0, 1.348000), (1, 500.0, 1.270000), (1, 0.0, 1.140000),
274            (2, 3600.0, 1.000000), (2, 3500.0, 1.000000), (2, 3250.0, 1.000000),
275            (2, 3000.0, 1.000000), (2, 2800.0, 1.004000), (2, 2500.0, 1.010000),
276            (2, 2250.0, 1.000000), (2, 2000.0, 0.990000), (2, 1750.0, 0.987500),
277            (2, 1500.0, 0.985000), (2, 1350.0, 0.997500), (2, 1200.0, 1.010000),
278            (2, 1100.0, 1.020000), (2, 1000.0, 1.030000), (2, 900.0, 1.017000),
279            (2, 800.0, 1.004000), (2, 500.0, 0.965000), (2, 0.0, 0.900000),
280            (3, 3600.0, 1.000000), (3, 3500.0, 1.000000), (3, 3250.0, 1.005000),
281            (3, 3000.0, 1.010000), (3, 2800.0, 1.006000), (3, 2500.0, 1.000000),
282            (3, 2250.0, 0.995000), (3, 2000.0, 0.990000), (3, 1750.0, 0.990000),
283            (3, 1500.0, 0.990000), (3, 1350.0, 1.005000), (3, 1200.0, 1.020000),
284            (3, 1100.0, 1.030000), (3, 1000.0, 1.040000), (3, 900.0, 1.030000),
285            (3, 800.0, 1.020000), (3, 500.0, 0.990000), (3, 0.0, 0.940000),
286        ];
287        for &(c, v, expected) in mult_goldens {
288            let got = m.get_bc_multiplier(v, c);
289            assert!(
290                (got - expected).abs() < 1e-6,
291                "multiplier parity: cluster {c} @{v}fps expected {expected}, got {got}"
292            );
293        }
294
295        // (caliber_in, weight_gr, bc_g1, expected_cluster)
296        let cluster_goldens: &[(f64, f64, f64, usize)] = &[
297            (0.308, 168.0, 0.475, 0), (0.308, 168.0, 0.223, 0),
298            (0.224, 55.0, 0.250, 2), (0.224, 77.0, 0.372, 2),
299            (0.458, 500.0, 0.295, 3), (0.375, 350.0, 0.310, 0),
300            (0.264, 140.0, 0.315, 0), (0.284, 180.0, 0.360, 0),
301            (0.338, 300.0, 0.400, 0), (0.243, 105.0, 0.278, 0),
302            (0.20, 40.0, 0.15, 1), (0.172, 20.0, 0.10, 1),
303            (0.510, 750.0, 0.500, 3), (0.30, 150.0, 0.19, 1),
304            (0.36, 410.0, 0.40, 3),
305        ];
306        for &(cal, wt, bc, expected) in cluster_goldens {
307            let got = m.predict_cluster(cal, wt, bc);
308            assert_eq!(
309                got, expected,
310                "cluster parity: ({cal}, {wt}, {bc}) expected {expected}, got {got}"
311            );
312        }
313    }
314}