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 =
301                cluster_bc.predict_cluster_for_drag_model(caliber, weight, g7_bc, DragModel::G7);
302            assert_eq!(
303                got, expected,
304                "G7 classification for ({caliber}, {weight}, {g7_bc})"
305            );
306        }
307
308        let hi = cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 2800.0, DragModel::G7);
309        assert!(
310            (hi / bc - 1.004).abs() < 1e-12,
311            "wrong cluster: {}",
312            hi / bc
313        );
314
315        let transonic =
316            cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 1100.0, DragModel::G7);
317        assert!(
318            (transonic / bc - 1.02).abs() < 1e-12,
319            "wrong transonic cluster: {}",
320            transonic / bc
321        );
322
323        // The legacy API remains byte-identical to the explicit G1 gate (both are inert because
324        // the active multiplier ladder is G7-referenced).
325        let legacy = cluster_bc.apply_correction(bc, 0.224, 77.0, 2800.0);
326        let explicit_g1 =
327            cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 2800.0, DragModel::G1);
328        assert_eq!(legacy.to_bits(), explicit_g1.to_bits());
329    }
330
331    #[test]
332    fn g7_reference_ladder_is_inert_for_other_drag_models() {
333        let cluster_bc = ClusterBCDegradation::new();
334        let bc = 0.475;
335
336        for drag_model in [
337            DragModel::G1,
338            DragModel::G2,
339            DragModel::G5,
340            DragModel::G6,
341            DragModel::G8,
342            DragModel::GI,
343            DragModel::GS,
344        ] {
345            let corrected =
346                cluster_bc.apply_correction_for_drag_model(bc, 0.308, 168.0, 1100.0, drag_model);
347            assert_eq!(
348                corrected.to_bits(),
349                bc.to_bits(),
350                "G7-reference multiplier must not shape {drag_model} BC"
351            );
352        }
353        assert_eq!(
354            cluster_bc
355                .apply_correction(bc, 0.308, 168.0, 1100.0)
356                .to_bits(),
357            bc.to_bits(),
358            "legacy G1 entry point must also remain unmodified"
359        );
360    }
361
362    /// Golden parity test — values generated directly from the Python reference
363    /// `ClusterBCDegradation` (tag 2026-07-08-radar-recal-mba1126). If this fails,
364    /// the Rust port has drifted from the source of truth; regenerate and reconcile.
365    #[test]
366    fn test_parity_with_python_reference() {
367        let m = ClusterBCDegradation::new();
368
369        // (cluster, velocity_fps, expected_multiplier)
370        let mult_goldens: &[(usize, f64, f64)] = &[
371            (0, 3600.0, 1.000000),
372            (0, 3500.0, 1.000000),
373            (0, 3250.0, 1.005000),
374            (0, 3000.0, 1.010000),
375            (0, 2800.0, 1.010000),
376            (0, 2500.0, 1.010000),
377            (0, 2250.0, 1.000000),
378            (0, 2000.0, 0.990000),
379            (0, 1750.0, 0.990000),
380            (0, 1500.0, 0.990000),
381            (0, 1350.0, 1.010000),
382            (0, 1200.0, 1.030000),
383            (0, 1100.0, 1.040000),
384            (0, 1000.0, 1.050000),
385            (0, 900.0, 1.039000),
386            (0, 800.0, 1.028000),
387            (0, 500.0, 0.995000),
388            (0, 0.0, 0.940000),
389            (1, 3600.0, 0.900000),
390            (1, 3500.0, 0.900000),
391            (1, 3250.0, 0.905000),
392            (1, 3000.0, 0.910000),
393            (1, 2800.0, 0.922000),
394            (1, 2500.0, 0.940000),
395            (1, 2250.0, 0.980000),
396            (1, 2000.0, 1.020000),
397            (1, 1750.0, 1.040000),
398            (1, 1500.0, 1.060000),
399            (1, 1350.0, 1.115000),
400            (1, 1200.0, 1.170000),
401            (1, 1100.0, 1.285000),
402            (1, 1000.0, 1.400000),
403            (1, 900.0, 1.374000),
404            (1, 800.0, 1.348000),
405            (1, 500.0, 1.270000),
406            (1, 0.0, 1.140000),
407            (2, 3600.0, 1.000000),
408            (2, 3500.0, 1.000000),
409            (2, 3250.0, 1.000000),
410            (2, 3000.0, 1.000000),
411            (2, 2800.0, 1.004000),
412            (2, 2500.0, 1.010000),
413            (2, 2250.0, 1.000000),
414            (2, 2000.0, 0.990000),
415            (2, 1750.0, 0.987500),
416            (2, 1500.0, 0.985000),
417            (2, 1350.0, 0.997500),
418            (2, 1200.0, 1.010000),
419            (2, 1100.0, 1.020000),
420            (2, 1000.0, 1.030000),
421            (2, 900.0, 1.017000),
422            (2, 800.0, 1.004000),
423            (2, 500.0, 0.965000),
424            (2, 0.0, 0.900000),
425            (3, 3600.0, 1.000000),
426            (3, 3500.0, 1.000000),
427            (3, 3250.0, 1.005000),
428            (3, 3000.0, 1.010000),
429            (3, 2800.0, 1.006000),
430            (3, 2500.0, 1.000000),
431            (3, 2250.0, 0.995000),
432            (3, 2000.0, 0.990000),
433            (3, 1750.0, 0.990000),
434            (3, 1500.0, 0.990000),
435            (3, 1350.0, 1.005000),
436            (3, 1200.0, 1.020000),
437            (3, 1100.0, 1.030000),
438            (3, 1000.0, 1.040000),
439            (3, 900.0, 1.030000),
440            (3, 800.0, 1.020000),
441            (3, 500.0, 0.990000),
442            (3, 0.0, 0.940000),
443        ];
444        for &(c, v, expected) in mult_goldens {
445            let got = m.get_bc_multiplier(v, c);
446            assert!(
447                (got - expected).abs() < 1e-6,
448                "multiplier parity: cluster {c} @{v}fps expected {expected}, got {got}"
449            );
450        }
451
452        // (caliber_in, weight_gr, bc_g1, expected_cluster)
453        let cluster_goldens: &[(f64, f64, f64, usize)] = &[
454            (0.308, 168.0, 0.475, 0),
455            (0.308, 168.0, 0.223, 0),
456            (0.224, 55.0, 0.250, 2),
457            (0.224, 77.0, 0.372, 2),
458            (0.458, 500.0, 0.295, 3),
459            (0.375, 350.0, 0.310, 0),
460            (0.264, 140.0, 0.315, 0),
461            (0.284, 180.0, 0.360, 0),
462            (0.338, 300.0, 0.400, 0),
463            (0.243, 105.0, 0.278, 0),
464            (0.20, 40.0, 0.15, 1),
465            (0.172, 20.0, 0.10, 1),
466            (0.510, 750.0, 0.500, 3),
467            (0.30, 150.0, 0.19, 1),
468            (0.36, 410.0, 0.40, 3),
469        ];
470        for &(cal, wt, bc, expected) in cluster_goldens {
471            let got = m.predict_cluster(cal, wt, bc);
472            assert_eq!(
473                got, expected,
474                "cluster parity: ({cal}, {wt}, {bc}) expected {expected}, got {got}"
475            );
476        }
477    }
478}