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
24use crate::DragModel;
25
26/// One velocity band: (velocity_threshold_fps, multiplier). Bands are ordered by
27/// DESCENDING threshold, matching the Python reference; `get_bc_multiplier`
28/// interpolates linearly between adjacent bands.
29type VelocityBand = (f64, f64);
30
31/// Population-average conversion used by the Python source of truth when a G7
32/// BC must be classified by the legacy G1-keyed cluster gates. The converted BC
33/// is used only to select a cluster; correction multipliers scale the original BC.
34const G7_TO_G1_CLASSIFICATION_RATIO: f64 = 1.98;
35
36/// Per-cluster velocity ladders (MBA-1126 radar-recalibrated, G7-referenced).
37/// Index = cluster id (0..=3). Kept identical to the Python `velocity_bands`.
38const VELOCITY_BANDS: [[VelocityBand; 8]; 4] = [
39    // Cluster 0: Standard Long-Range (Sierra MatchKing, etc.; n~248)
40    [
41        (3500.0, 1.00),
42        (3000.0, 1.01),
43        (2500.0, 1.01),
44        (2000.0, 0.99),
45        (1500.0, 0.99),
46        (1200.0, 1.03),
47        (1000.0, 1.05),
48        (0.0, 0.94),
49    ],
50    // Cluster 1: Low-Drag Specialty (blunt/monolithic + stubby varmint; n~12,
51    // shrunk ~25% toward 1.0). G7 BC runs below the banded avg high, rises
52    // steeply through transonic.
53    [
54        (3500.0, 0.90),
55        (3000.0, 0.91),
56        (2500.0, 0.94),
57        (2000.0, 1.02),
58        (1500.0, 1.06),
59        (1200.0, 1.17),
60        (1000.0, 1.40),
61        (0.0, 1.14),
62    ],
63    // Cluster 2: Light Varmint/Target (n~54)
64    [
65        (3500.0, 1.00),
66        (3000.0, 1.00),
67        (2500.0, 1.01),
68        (2000.0, 0.99),
69        (1500.0, 0.985),
70        (1200.0, 1.01),
71        (1000.0, 1.03),
72        (0.0, 0.90),
73    ],
74    // Cluster 3: Heavy Magnums (n~6, shrunk 50% toward 1.0)
75    [
76        (3500.0, 1.00),
77        (3000.0, 1.01),
78        (2500.0, 1.00),
79        (2000.0, 0.99),
80        (1500.0, 0.99),
81        (1200.0, 1.02),
82        (1000.0, 1.04),
83        (0.0, 0.94),
84    ],
85];
86
87#[derive(Debug, Clone, Copy, Default)]
88pub struct ClusterBCDegradation;
89
90impl ClusterBCDegradation {
91    pub fn new() -> Self {
92        Self
93    }
94
95    /// Predict which cluster a bullet belongs to.
96    ///
97    /// Rule-based classifier ported verbatim from the Python reference
98    /// `predict_cluster`. `caliber` must be in INCHES (the sectional-density and
99    /// form-factor thresholds are inch-referenced); passing meters misclassifies.
100    pub fn predict_cluster(&self, caliber: f64, weight_gr: f64, bc_g1: f64) -> usize {
101        // Sectional density (grains / (7000 * caliber_in^2))
102        let sd = if caliber > 0.0 {
103            weight_gr / (7000.0 * caliber * caliber)
104        } else {
105            0.0
106        };
107        // Approximate form factor
108        let form_factor = if sd > 0.0 { bc_g1 / sd } else { 1.5 };
109
110        // Heavy magnum check
111        if weight_gr > 400.0 && caliber > 0.35 {
112            return 3;
113        }
114        // Low-drag specialty check (low form factor)
115        if form_factor < 1.1 && bc_g1 < 0.2 {
116            return 1;
117        }
118        // Light varmint check
119        if weight_gr < 100.0 && caliber < 0.3 {
120            return 2;
121        }
122        // Default to standard long-range
123        0
124    }
125
126    /// Predict a cluster from a BC expressed in its active reference-model space.
127    ///
128    /// The legacy classifier is G1-keyed. G7 values are converted to the same
129    /// approximate G1 space used by the Python source of truth before applying
130    /// those unchanged gates. Other drag models retain the legacy behavior.
131    pub fn predict_cluster_for_drag_model(
132        &self,
133        caliber: f64,
134        weight_gr: f64,
135        bc: f64,
136        drag_model: DragModel,
137    ) -> usize {
138        let classification_bc_g1 = if drag_model == DragModel::G7 {
139            bc * G7_TO_G1_CLASSIFICATION_RATIO
140        } else {
141            bc
142        };
143        self.predict_cluster(caliber, weight_gr, classification_bc_g1)
144    }
145
146    /// Get the BC multiplier for a given velocity and cluster.
147    ///
148    /// Mirrors the Python reference: iterate the cluster's bands (descending
149    /// threshold); at the first band whose threshold `<=` velocity, return the
150    /// band multiplier if it is the top band, otherwise linearly interpolate
151    /// between it and the band above. Below the lowest threshold, return the last
152    /// band's multiplier. Unknown clusters fall back to cluster 0 (matching Python,
153    /// which coerces an unknown id to the standard profile — NOT to 1.0).
154    pub fn get_bc_multiplier(&self, velocity_fps: f64, cluster_id: usize) -> f64 {
155        let cid = if cluster_id < VELOCITY_BANDS.len() {
156            cluster_id
157        } else {
158            0
159        };
160        let bands = &VELOCITY_BANDS[cid];
161
162        for i in 0..bands.len() {
163            let (v_threshold, multiplier) = bands[i];
164            if velocity_fps >= v_threshold {
165                if i == 0 {
166                    // Above the highest threshold
167                    return multiplier;
168                }
169                // Interpolate between bands[i-1] (high) and bands[i] (low)
170                let (v_high, mult_high) = bands[i - 1];
171                let (v_low, mult_low) = bands[i];
172                let t = (velocity_fps - v_low) / (v_high - v_low);
173                return mult_low + t * (mult_high - mult_low);
174            }
175        }
176        // Below the lowest threshold
177        bands[bands.len() - 1].1
178    }
179
180    /// Get cluster name for display
181    pub fn get_cluster_name(&self, cluster_id: usize) -> &'static str {
182        match cluster_id {
183            0 => "Standard Long-Range",
184            1 => "Low-Drag Specialty",
185            2 => "Light Varmint/Target",
186            3 => "Heavy Magnum",
187            _ => "Unknown",
188        }
189    }
190
191    /// Legacy G1 entry point retained for API compatibility.
192    ///
193    /// The active multiplier ladder is G7-referenced, so G1 correction is intentionally
194    /// an identity. Use [`Self::apply_correction_for_drag_model`] with [`DragModel::G7`]
195    /// to apply the calibrated ladder.
196    pub fn apply_correction(
197        &self,
198        bc: f64,
199        caliber: f64,
200        weight_gr: f64,
201        velocity_fps: f64,
202    ) -> f64 {
203        self.apply_correction_for_drag_model(bc, caliber, weight_gr, velocity_fps, DragModel::G1)
204    }
205
206    /// Apply cluster-based BC correction when the BC is in G7 reference-model space.
207    ///
208    /// The velocity ladders are G7-derived ratios and are not physically valid for G1/G2/etc.;
209    /// non-G7 inputs are returned byte-identically unchanged (MBA-1179). For G7, the classifier's
210    /// thresholds are G1-keyed, so the BC is converted to an approximate G1 equivalent for
211    /// classification only; the selected multiplier still scales the original G7 value.
212    pub fn apply_correction_for_drag_model(
213        &self,
214        bc: f64,
215        caliber: f64,
216        weight_gr: f64,
217        velocity_fps: f64,
218        drag_model: DragModel,
219    ) -> f64 {
220        if drag_model != DragModel::G7 {
221            return bc;
222        }
223        let cluster_id = self.predict_cluster_for_drag_model(caliber, weight_gr, bc, drag_model);
224        let multiplier = self.get_bc_multiplier(velocity_fps, cluster_id);
225        bc * multiplier
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_cluster_prediction() {
235        let cluster_bc = ClusterBCDegradation::new();
236
237        // Standard long-range bullet (.308 Win 168gr) -> cluster 0
238        assert_eq!(cluster_bc.predict_cluster(0.308, 168.0, 0.475), 0);
239
240        // Light varmint bullet (.223 Rem 55gr) -> cluster 2
241        assert_eq!(cluster_bc.predict_cluster(0.224, 55.0, 0.250), 2);
242
243        // Heavy magnum (.458 Win Mag 500gr) -> cluster 3
244        assert_eq!(cluster_bc.predict_cluster(0.458, 500.0, 0.295), 3);
245    }
246
247    #[test]
248    fn test_caliber_must_be_inches_not_meters() {
249        // predict_cluster's SD/form-factor thresholds are inch-referenced.
250        // Passing the caliber in meters (the old cli_api bug, caliber_inches *
251        // 0.0254) collapses the sectional density and misclassifies. Guard it.
252        let cluster_bc = ClusterBCDegradation::new();
253        let inches = cluster_bc.predict_cluster(0.458, 500.0, 0.295);
254        let meters = cluster_bc.predict_cluster(0.458 * 0.0254, 500.0, 0.295);
255        assert_eq!(inches, 3, ".458/500gr in inches -> Heavy Magnum cluster");
256        assert_ne!(
257            meters, inches,
258            "caliber in meters must misclassify (caller must pass inches)"
259        );
260    }
261
262    #[test]
263    fn test_bc_multiplier_g7_semantics() {
264        let cluster_bc = ClusterBCDegradation::new();
265
266        // MBA-1126: G7-referenced multipliers hover around 1.0 and may exceed it.
267        // Cluster 0 at 3000 fps is 1.01 (slight rise), not a sub-1.0 degradation.
268        let mult_hi = cluster_bc.get_bc_multiplier(3000.0, 0);
269        assert!(
270            (mult_hi - 1.01).abs() < 1e-9,
271            "cluster 0 @3000fps should be 1.01, got {mult_hi}"
272        );
273
274        // Transonic RISE for match bullets (the old deep dip is gone): the
275        // 1000-1200 fps region is above the mid-supersonic (1500-2000) values.
276        let mult_transonic = cluster_bc.get_bc_multiplier(1100.0, 0);
277        let mult_mid = cluster_bc.get_bc_multiplier(1750.0, 0);
278        assert!(
279            mult_transonic > mult_mid,
280            "G7 transonic ({mult_transonic}) should now rise above mid-supersonic ({mult_mid})"
281        );
282    }
283
284    #[test]
285    fn test_apply_correction_g7() {
286        let cluster_bc = ClusterBCDegradation::new();
287        let bc = 0.190;
288
289        // A representative .224 77gr match bullet belongs to light-target
290        // cluster 2 when its G7 BC is classified in G1-equivalent space.
291        assert_eq!(cluster_bc.predict_cluster(0.224, 77.0, bc), 1);
292        let classification_cases = [
293            (0.224, 55.0, 0.120, 2),
294            (0.224, 69.0, 0.169, 2),
295            (0.224, 77.0, bc, 2),
296            (0.243, 87.0, 0.190, 2),
297            (0.20, 40.0, 0.075, 1),
298        ];
299        for (caliber, weight, g7_bc, expected) in classification_cases {
300            let got = cluster_bc.predict_cluster_for_drag_model(
301                caliber,
302                weight,
303                g7_bc,
304                DragModel::G7,
305            );
306            assert_eq!(
307                got,
308                expected,
309                "G7 classification for ({caliber}, {weight}, {g7_bc})"
310            );
311        }
312
313        let hi = cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 2800.0, DragModel::G7);
314        assert!(
315            (hi / bc - 1.004).abs() < 1e-12,
316            "wrong cluster: {}",
317            hi / bc
318        );
319
320        let transonic =
321            cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 1100.0, DragModel::G7);
322        assert!(
323            (transonic / bc - 1.02).abs() < 1e-12,
324            "wrong transonic cluster: {}",
325            transonic / bc
326        );
327
328        // The legacy API remains byte-identical to the explicit G1 gate (both are inert because
329        // the active multiplier ladder is G7-referenced).
330        let legacy = cluster_bc.apply_correction(bc, 0.224, 77.0, 2800.0);
331        let explicit_g1 = cluster_bc.apply_correction_for_drag_model(
332            bc,
333            0.224,
334            77.0,
335            2800.0,
336            DragModel::G1,
337        );
338        assert_eq!(legacy.to_bits(), explicit_g1.to_bits());
339    }
340
341    #[test]
342    fn g7_reference_ladder_is_inert_for_other_drag_models() {
343        let cluster_bc = ClusterBCDegradation::new();
344        let bc = 0.475;
345
346        for drag_model in [
347            DragModel::G1,
348            DragModel::G2,
349            DragModel::G5,
350            DragModel::G6,
351            DragModel::G8,
352            DragModel::GI,
353            DragModel::GS,
354        ] {
355            let corrected = cluster_bc.apply_correction_for_drag_model(
356                bc,
357                0.308,
358                168.0,
359                1100.0,
360                drag_model,
361            );
362            assert_eq!(
363                corrected.to_bits(),
364                bc.to_bits(),
365                "G7-reference multiplier must not shape {drag_model} BC"
366            );
367        }
368        assert_eq!(
369            cluster_bc
370                .apply_correction(bc, 0.308, 168.0, 1100.0)
371                .to_bits(),
372            bc.to_bits(),
373            "legacy G1 entry point must also remain unmodified"
374        );
375    }
376
377    /// Golden parity test — values generated directly from the Python reference
378    /// `ClusterBCDegradation` (tag 2026-07-08-radar-recal-mba1126). If this fails,
379    /// the Rust port has drifted from the source of truth; regenerate and reconcile.
380    #[test]
381    fn test_parity_with_python_reference() {
382        let m = ClusterBCDegradation::new();
383
384        // (cluster, velocity_fps, expected_multiplier)
385        let mult_goldens: &[(usize, f64, f64)] = &[
386            (0, 3600.0, 1.000000), (0, 3500.0, 1.000000), (0, 3250.0, 1.005000),
387            (0, 3000.0, 1.010000), (0, 2800.0, 1.010000), (0, 2500.0, 1.010000),
388            (0, 2250.0, 1.000000), (0, 2000.0, 0.990000), (0, 1750.0, 0.990000),
389            (0, 1500.0, 0.990000), (0, 1350.0, 1.010000), (0, 1200.0, 1.030000),
390            (0, 1100.0, 1.040000), (0, 1000.0, 1.050000), (0, 900.0, 1.039000),
391            (0, 800.0, 1.028000), (0, 500.0, 0.995000), (0, 0.0, 0.940000),
392            (1, 3600.0, 0.900000), (1, 3500.0, 0.900000), (1, 3250.0, 0.905000),
393            (1, 3000.0, 0.910000), (1, 2800.0, 0.922000), (1, 2500.0, 0.940000),
394            (1, 2250.0, 0.980000), (1, 2000.0, 1.020000), (1, 1750.0, 1.040000),
395            (1, 1500.0, 1.060000), (1, 1350.0, 1.115000), (1, 1200.0, 1.170000),
396            (1, 1100.0, 1.285000), (1, 1000.0, 1.400000), (1, 900.0, 1.374000),
397            (1, 800.0, 1.348000), (1, 500.0, 1.270000), (1, 0.0, 1.140000),
398            (2, 3600.0, 1.000000), (2, 3500.0, 1.000000), (2, 3250.0, 1.000000),
399            (2, 3000.0, 1.000000), (2, 2800.0, 1.004000), (2, 2500.0, 1.010000),
400            (2, 2250.0, 1.000000), (2, 2000.0, 0.990000), (2, 1750.0, 0.987500),
401            (2, 1500.0, 0.985000), (2, 1350.0, 0.997500), (2, 1200.0, 1.010000),
402            (2, 1100.0, 1.020000), (2, 1000.0, 1.030000), (2, 900.0, 1.017000),
403            (2, 800.0, 1.004000), (2, 500.0, 0.965000), (2, 0.0, 0.900000),
404            (3, 3600.0, 1.000000), (3, 3500.0, 1.000000), (3, 3250.0, 1.005000),
405            (3, 3000.0, 1.010000), (3, 2800.0, 1.006000), (3, 2500.0, 1.000000),
406            (3, 2250.0, 0.995000), (3, 2000.0, 0.990000), (3, 1750.0, 0.990000),
407            (3, 1500.0, 0.990000), (3, 1350.0, 1.005000), (3, 1200.0, 1.020000),
408            (3, 1100.0, 1.030000), (3, 1000.0, 1.040000), (3, 900.0, 1.030000),
409            (3, 800.0, 1.020000), (3, 500.0, 0.990000), (3, 0.0, 0.940000),
410        ];
411        for &(c, v, expected) in mult_goldens {
412            let got = m.get_bc_multiplier(v, c);
413            assert!(
414                (got - expected).abs() < 1e-6,
415                "multiplier parity: cluster {c} @{v}fps expected {expected}, got {got}"
416            );
417        }
418
419        // (caliber_in, weight_gr, bc_g1, expected_cluster)
420        let cluster_goldens: &[(f64, f64, f64, usize)] = &[
421            (0.308, 168.0, 0.475, 0), (0.308, 168.0, 0.223, 0),
422            (0.224, 55.0, 0.250, 2), (0.224, 77.0, 0.372, 2),
423            (0.458, 500.0, 0.295, 3), (0.375, 350.0, 0.310, 0),
424            (0.264, 140.0, 0.315, 0), (0.284, 180.0, 0.360, 0),
425            (0.338, 300.0, 0.400, 0), (0.243, 105.0, 0.278, 0),
426            (0.20, 40.0, 0.15, 1), (0.172, 20.0, 0.10, 1),
427            (0.510, 750.0, 0.500, 3), (0.30, 150.0, 0.19, 1),
428            (0.36, 410.0, 0.40, 3),
429        ];
430        for &(cal, wt, bc, expected) in cluster_goldens {
431            let got = m.predict_cluster(cal, wt, bc);
432            assert_eq!(
433                got, expected,
434                "cluster parity: ({cal}, {wt}, {bc}) expected {expected}, got {got}"
435            );
436        }
437    }
438}