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)).sqrt();
46            
47            if distance < min_distance {
48                min_distance = distance;
49                best_cluster = i;
50            }
51        }
52
53        best_cluster
54    }
55
56    /// Get BC multiplier for a given velocity and cluster
57    pub fn get_bc_multiplier(&self, velocity_fps: f64, cluster_id: usize) -> f64 {
58        match cluster_id {
59            0 => {
60                // Standard Long-Range: gradual degradation
61                if velocity_fps > 2800.0 {
62                    1.0
63                } else if velocity_fps > 2400.0 {
64                    0.98 - 0.03 * (2800.0 - velocity_fps) / 400.0
65                } else if velocity_fps > 1800.0 {
66                    0.95 - 0.05 * (2400.0 - velocity_fps) / 600.0
67                } else if velocity_fps > 1200.0 {
68                    0.90 - 0.10 * (1800.0 - velocity_fps) / 600.0
69                } else {
70                    0.80 - 0.05 * (1200.0 - velocity_fps) / 1200.0
71                }
72            },
73            1 => {
74                // Low-Drag Specialty: minimal degradation
75                if velocity_fps > 3000.0 {
76                    1.0
77                } else if velocity_fps > 2500.0 {
78                    0.99 - 0.02 * (3000.0 - velocity_fps) / 500.0
79                } else if velocity_fps > 2000.0 {
80                    0.97 - 0.03 * (2500.0 - velocity_fps) / 500.0
81                } else if velocity_fps > 1500.0 {
82                    0.94 - 0.06 * (2000.0 - velocity_fps) / 500.0
83                } else {
84                    0.88 - 0.08 * (1500.0 - velocity_fps) / 1500.0
85                }
86            },
87            2 => {
88                // Light Varmint/Target: significant degradation
89                if velocity_fps > 3500.0 {
90                    1.0
91                } else if velocity_fps > 3000.0 {
92                    0.96 - 0.04 * (3500.0 - velocity_fps) / 500.0
93                } else if velocity_fps > 2200.0 {
94                    0.92 - 0.08 * (3000.0 - velocity_fps) / 800.0
95                } else if velocity_fps > 1600.0 {
96                    0.84 - 0.14 * (2200.0 - velocity_fps) / 600.0
97                } else {
98                    0.70 - 0.15 * (1600.0 - velocity_fps) / 1600.0
99                }
100            },
101            3 => {
102                // Heavy Magnums: moderate degradation with steep initial drop
103                if velocity_fps > 2600.0 {
104                    1.0
105                } else if velocity_fps > 2200.0 {
106                    0.96 - 0.06 * (2600.0 - velocity_fps) / 400.0
107                } else if velocity_fps > 1700.0 {
108                    0.90 - 0.10 * (2200.0 - velocity_fps) / 500.0
109                } else if velocity_fps > 1200.0 {
110                    0.80 - 0.15 * (1700.0 - velocity_fps) / 500.0
111                } else {
112                    0.65 - 0.10 * (1200.0 - velocity_fps) / 1200.0
113                }
114            },
115            _ => 1.0, // Default: no adjustment
116        }
117    }
118
119    /// Get cluster name for display
120    pub fn get_cluster_name(&self, cluster_id: usize) -> &'static str {
121        match cluster_id {
122            0 => "Standard Long-Range",
123            1 => "Low-Drag Specialty",
124            2 => "Light Varmint/Target",
125            3 => "Heavy Magnum",
126            _ => "Unknown",
127        }
128    }
129
130    /// Apply cluster-based BC correction
131    pub fn apply_correction(&self, bc: f64, caliber: f64, weight_gr: f64, velocity_fps: f64) -> f64 {
132        let cluster_id = self.predict_cluster(caliber, weight_gr, bc);
133        let multiplier = self.get_bc_multiplier(velocity_fps, cluster_id);
134        bc * multiplier
135    }
136}
137
138impl Default for ClusterBCDegradation {
139    fn default() -> Self {
140        Self::new()
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_cluster_prediction() {
150        let cluster_bc = ClusterBCDegradation::new();
151        
152        // Test standard long-range bullet (308 Win 168gr)
153        let cluster = cluster_bc.predict_cluster(0.308, 168.0, 0.475);
154        assert!(cluster <= 3, "Standard long-range should be in a valid cluster");
155        
156        // Test light varmint bullet (223 Rem 55gr)
157        let cluster = cluster_bc.predict_cluster(0.224, 55.0, 0.250);
158        assert_eq!(cluster, 2);
159        
160        // Test heavy magnum (458 Win Mag 500gr)
161        let cluster = cluster_bc.predict_cluster(0.458, 500.0, 0.295);
162        assert_eq!(cluster, 3);
163    }
164
165    #[test]
166    fn test_bc_multiplier() {
167        let cluster_bc = ClusterBCDegradation::new();
168        
169        // Test high velocity (minimal degradation)
170        let mult = cluster_bc.get_bc_multiplier(3000.0, 0);
171        assert!(mult > 0.95 && mult <= 1.0);
172        
173        // Test low velocity (significant degradation)
174        let mult = cluster_bc.get_bc_multiplier(1000.0, 0);
175        assert!(mult < 0.85);
176        
177        // Test that multiplier decreases with velocity
178        let mult_high = cluster_bc.get_bc_multiplier(2500.0, 1);
179        let mult_low = cluster_bc.get_bc_multiplier(1500.0, 1);
180        assert!(mult_high > mult_low);
181    }
182
183    #[test]
184    fn test_apply_correction() {
185        let cluster_bc = ClusterBCDegradation::new();
186        
187        // Test that correction reduces BC at low velocity
188        let bc_original = 0.475;
189        let bc_corrected = cluster_bc.apply_correction(bc_original, 0.308, 168.0, 1500.0);
190        assert!(bc_corrected < bc_original);
191        
192        // Test that correction is minimal at high velocity
193        let bc_corrected_high = cluster_bc.apply_correction(bc_original, 0.308, 168.0, 2800.0);
194        assert!(bc_corrected_high >= bc_original * 0.90, "High velocity should have minimal BC reduction");
195    }
196}