Skip to main content

ballistics_engine/
cluster_bc.rs

1//! Cluster-based BC degradation for improved accuracy
2//!
3//! This module implements empirical BC corrections based on bullet clustering.
4//! Bullets are classified into 4 clusters based on their physical characteristics,
5//! and each cluster has unique velocity-dependent BC degradation curves derived
6//! from real-world ballistic data.
7
8#[derive(Debug, Clone, Copy)]
9pub struct ClusterBCDegradation {
10    /// Pre-calculated cluster centroids
11    centroids: [(f64, f64, f64); 4],
12}
13
14impl ClusterBCDegradation {
15    pub fn new() -> Self {
16        Self {
17            // Cluster centroids: (caliber_normalized, weight_normalized, bc_normalized)
18            centroids: [
19                (0.605, 0.415, 0.613), // Cluster 0: Standard Long-Range
20                (0.516, 0.324, 0.643), // Cluster 1: Low-Drag Specialty
21                (0.307, 0.088, 0.336), // Cluster 2: Light Varmint
22                (0.750, 0.805, 0.505), // Cluster 3: Heavy Magnums
23            ],
24        }
25    }
26
27    /// Predict which cluster a bullet belongs to
28    pub fn predict_cluster(&self, caliber: f64, weight_gr: f64, bc_g1: f64) -> usize {
29        // Normalize features to [0, 1] range
30        // Bounds derived from training data and centroid values:
31        // - Caliber: 0.172 (.17 HMR) to 0.750 (.50 BMG) - matches cluster 3 centroid
32        // - Weight: 15gr (light varmint) to 750gr (heavy magnum)
33        // - BC G1: 0.05 (low drag) to 1.2 (high BC match bullets)
34        let caliber_norm = (caliber - 0.172) / (0.750 - 0.172);
35        let weight_norm = (weight_gr - 15.0) / (750.0 - 15.0);
36        let bc_norm = (bc_g1 - 0.05) / (1.2 - 0.05);
37
38        // Find nearest centroid
39        let mut min_distance = f64::INFINITY;
40        let mut best_cluster = 0;
41
42        for (i, &(c_cal, c_wt, c_bc)) in self.centroids.iter().enumerate() {
43            let distance = ((caliber_norm - c_cal).powi(2)
44                + (weight_norm - c_wt).powi(2)
45                + (bc_norm - c_bc).powi(2))
46            .sqrt();
47
48            if distance < min_distance {
49                min_distance = distance;
50                best_cluster = i;
51            }
52        }
53
54        best_cluster
55    }
56
57    /// Get BC multiplier for a given velocity and cluster
58    ///
59    /// MBA-645: Recalibrated from 170 bullets with measured BC segments.
60    /// Previous values were too aggressive (predicted 65% at subsonic,
61    /// measured data shows 93%).
62    pub fn get_bc_multiplier(&self, velocity_fps: f64, cluster_id: usize) -> f64 {
63        match cluster_id {
64            0 => {
65                // Standard Long-Range: calibrated from measured BC segment curves
66                // Notable: transonic dip at 1200-1500 fps, recovery below 1200
67                if velocity_fps > 3500.0 {
68                    1.0
69                } else if velocity_fps > 3000.0 {
70                    1.0 - 0.01 * (3500.0 - velocity_fps) / 500.0 // 1.00 -> 0.99
71                } else if velocity_fps > 2500.0 {
72                    0.99 - 0.01 * (3000.0 - velocity_fps) / 500.0 // 0.99 -> 0.98
73                } else if velocity_fps > 2000.0 {
74                    0.98 // flat at 98% through mid velocities
75                } else if velocity_fps > 1500.0 {
76                    0.98 - 0.02 * (2000.0 - velocity_fps) / 500.0 // 0.98 -> 0.96
77                } else if velocity_fps > 1200.0 {
78                    0.96 - 0.10 * (1500.0 - velocity_fps) / 300.0 // 0.96 -> 0.86 transonic dip
79                } else if velocity_fps > 1000.0 {
80                    0.86 + 0.11 * (1200.0 - velocity_fps) / 200.0 // 0.86 -> 0.97 recovery
81                } else {
82                    0.97 - 0.04 * (1000.0 - velocity_fps) / 1000.0 // 0.97 -> 0.93 subsonic
83                }
84            }
85            1 => {
86                // Low-Drag Specialty (VLD, ELD, A-TIP): superior transonic performance
87                if velocity_fps > 3500.0 {
88                    1.0
89                } else if velocity_fps > 3000.0 {
90                    1.0 - 0.01 * (3500.0 - velocity_fps) / 500.0 // 1.00 -> 0.99
91                } else if velocity_fps > 2500.0 {
92                    0.99 - 0.01 * (3000.0 - velocity_fps) / 500.0 // 0.99 -> 0.98
93                } else if velocity_fps > 2000.0 {
94                    0.98 - 0.01 * (2500.0 - velocity_fps) / 500.0 // 0.98 -> 0.97
95                } else if velocity_fps > 1500.0 {
96                    0.97 - 0.02 * (2000.0 - velocity_fps) / 500.0 // 0.97 -> 0.95
97                } else if velocity_fps > 1200.0 {
98                    0.95 - 0.05 * (1500.0 - velocity_fps) / 300.0 // 0.95 -> 0.90 milder dip
99                } else if velocity_fps > 1000.0 {
100                    0.90 + 0.06 * (1200.0 - velocity_fps) / 200.0 // 0.90 -> 0.96
101                } else {
102                    0.96 - 0.02 * (1000.0 - velocity_fps) / 1000.0 // 0.96 -> 0.94
103                }
104            }
105            2 => {
106                // Light Varmint/Target: more degradation but not as severe as before
107                if velocity_fps > 3500.0 {
108                    1.0
109                } else if velocity_fps > 3000.0 {
110                    1.0 - 0.02 * (3500.0 - velocity_fps) / 500.0 // 1.00 -> 0.98
111                } else if velocity_fps > 2500.0 {
112                    0.98 - 0.02 * (3000.0 - velocity_fps) / 500.0 // 0.98 -> 0.96
113                } else if velocity_fps > 2000.0 {
114                    0.96 - 0.02 * (2500.0 - velocity_fps) / 500.0 // 0.96 -> 0.94
115                } else if velocity_fps > 1500.0 {
116                    0.94 - 0.04 * (2000.0 - velocity_fps) / 500.0 // 0.94 -> 0.90
117                } else if velocity_fps > 1200.0 {
118                    0.90 - 0.08 * (1500.0 - velocity_fps) / 300.0 // 0.90 -> 0.82 sharper dip
119                } else if velocity_fps > 1000.0 {
120                    0.82 + 0.06 * (1200.0 - velocity_fps) / 200.0 // 0.82 -> 0.88
121                } else {
122                    0.88 - 0.03 * (1000.0 - velocity_fps) / 1000.0 // 0.88 -> 0.85
123                }
124            }
125            3 => {
126                // Heavy Magnums: best BC retention across velocity range
127                if velocity_fps > 3500.0 {
128                    1.0
129                } else if velocity_fps > 3000.0 {
130                    1.0 - 0.01 * (3500.0 - velocity_fps) / 500.0 // 1.00 -> 0.99
131                } else if velocity_fps > 2500.0 {
132                    0.99 - 0.01 * (3000.0 - velocity_fps) / 500.0 // 0.99 -> 0.98
133                } else if velocity_fps > 2000.0 {
134                    0.98 - 0.01 * (2500.0 - velocity_fps) / 500.0 // 0.98 -> 0.97
135                } else if velocity_fps > 1500.0 {
136                    0.97 - 0.01 * (2000.0 - velocity_fps) / 500.0 // 0.97 -> 0.96
137                } else if velocity_fps > 1200.0 {
138                    0.96 - 0.04 * (1500.0 - velocity_fps) / 300.0 // 0.96 -> 0.92 mild dip
139                } else if velocity_fps > 1000.0 {
140                    0.92 + 0.05 * (1200.0 - velocity_fps) / 200.0 // 0.92 -> 0.97
141                } else {
142                    0.97 - 0.02 * (1000.0 - velocity_fps) / 1000.0 // 0.97 -> 0.95
143                }
144            }
145            _ => 1.0, // Default: no adjustment
146        }
147    }
148
149    /// Get cluster name for display
150    pub fn get_cluster_name(&self, cluster_id: usize) -> &'static str {
151        match cluster_id {
152            0 => "Standard Long-Range",
153            1 => "Low-Drag Specialty",
154            2 => "Light Varmint/Target",
155            3 => "Heavy Magnum",
156            _ => "Unknown",
157        }
158    }
159
160    /// Apply cluster-based BC correction
161    pub fn apply_correction(
162        &self,
163        bc: f64,
164        caliber: f64,
165        weight_gr: f64,
166        velocity_fps: f64,
167    ) -> f64 {
168        let cluster_id = self.predict_cluster(caliber, weight_gr, bc);
169        let multiplier = self.get_bc_multiplier(velocity_fps, cluster_id);
170        bc * multiplier
171    }
172}
173
174impl Default for ClusterBCDegradation {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_cluster_prediction() {
186        let cluster_bc = ClusterBCDegradation::new();
187
188        // Test standard long-range bullet (308 Win 168gr)
189        let cluster = cluster_bc.predict_cluster(0.308, 168.0, 0.475);
190        assert!(
191            cluster <= 3,
192            "Standard long-range should be in a valid cluster"
193        );
194
195        // Test light varmint bullet (223 Rem 55gr)
196        let cluster = cluster_bc.predict_cluster(0.224, 55.0, 0.250);
197        assert_eq!(cluster, 2);
198
199        // Test heavy magnum (458 Win Mag 500gr)
200        let cluster = cluster_bc.predict_cluster(0.458, 500.0, 0.295);
201        assert_eq!(cluster, 3);
202    }
203
204    #[test]
205    fn test_bc_multiplier() {
206        let cluster_bc = ClusterBCDegradation::new();
207
208        // Test high velocity (minimal degradation)
209        let mult = cluster_bc.get_bc_multiplier(3000.0, 0);
210        assert!(mult > 0.95 && mult <= 1.0);
211
212        // Test low velocity - MBA-645: now expects ~93% retention, not <85%
213        let mult = cluster_bc.get_bc_multiplier(1000.0, 0);
214        assert!(
215            mult > 0.90 && mult < 1.0,
216            "Subsonic should retain ~93% BC, got {}",
217            mult
218        );
219
220        // Test transonic dip (1200-1500 fps should be the minimum)
221        let mult_transonic = cluster_bc.get_bc_multiplier(1300.0, 0);
222        let mult_subsonic = cluster_bc.get_bc_multiplier(900.0, 0);
223        assert!(
224            mult_transonic < mult_subsonic,
225            "Transonic ({}) should be lower than subsonic ({})",
226            mult_transonic,
227            mult_subsonic
228        );
229    }
230
231    #[test]
232    fn test_apply_correction() {
233        let cluster_bc = ClusterBCDegradation::new();
234
235        // Test that correction reduces BC at transonic (1300 fps is the dip)
236        let bc_original = 0.475;
237        let bc_corrected = cluster_bc.apply_correction(bc_original, 0.308, 168.0, 1300.0);
238        assert!(bc_corrected < bc_original);
239
240        // Test that correction is minimal at high velocity
241        let bc_corrected_high = cluster_bc.apply_correction(bc_original, 0.308, 168.0, 2800.0);
242        assert!(
243            bc_corrected_high >= bc_original * 0.95,
244            "High velocity should have minimal BC reduction (>95%), got {}",
245            bc_corrected_high / bc_original
246        );
247
248        // Test that subsonic retains good BC (MBA-645: >80% for all clusters)
249        // Note: 308 168gr gets classified as cluster 2 (Light Varmint) due to
250        // centroid proximity, which has 85% subsonic retention
251        let bc_subsonic = cluster_bc.apply_correction(bc_original, 0.308, 168.0, 800.0);
252        assert!(
253            bc_subsonic >= bc_original * 0.80,
254            "Subsonic should retain >80% BC, got {}",
255            bc_subsonic / bc_original
256        );
257
258        // Test cluster 0 directly for higher retention
259        let mult_subsonic_c0 = cluster_bc.get_bc_multiplier(800.0, 0);
260        assert!(
261            mult_subsonic_c0 >= 0.90,
262            "Cluster 0 subsonic should retain >90% BC, got {}",
263            mult_subsonic_c0
264        );
265    }
266}