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