1type VelocityBand = (f64, f64);
28
29const VELOCITY_BANDS: [[VelocityBand; 8]; 4] = [
32 [
34 (3500.0, 1.00),
35 (3000.0, 1.01),
36 (2500.0, 1.01),
37 (2000.0, 0.99),
38 (1500.0, 0.99),
39 (1200.0, 1.03),
40 (1000.0, 1.05),
41 (0.0, 0.94),
42 ],
43 [
47 (3500.0, 0.90),
48 (3000.0, 0.91),
49 (2500.0, 0.94),
50 (2000.0, 1.02),
51 (1500.0, 1.06),
52 (1200.0, 1.17),
53 (1000.0, 1.40),
54 (0.0, 1.14),
55 ],
56 [
58 (3500.0, 1.00),
59 (3000.0, 1.00),
60 (2500.0, 1.01),
61 (2000.0, 0.99),
62 (1500.0, 0.985),
63 (1200.0, 1.01),
64 (1000.0, 1.03),
65 (0.0, 0.90),
66 ],
67 [
69 (3500.0, 1.00),
70 (3000.0, 1.01),
71 (2500.0, 1.00),
72 (2000.0, 0.99),
73 (1500.0, 0.99),
74 (1200.0, 1.02),
75 (1000.0, 1.04),
76 (0.0, 0.94),
77 ],
78];
79
80#[derive(Debug, Clone, Copy, Default)]
81pub struct ClusterBCDegradation;
82
83impl ClusterBCDegradation {
84 pub fn new() -> Self {
85 Self
86 }
87
88 pub fn predict_cluster(&self, caliber: f64, weight_gr: f64, bc_g1: f64) -> usize {
94 let sd = if caliber > 0.0 {
96 weight_gr / (7000.0 * caliber * caliber)
97 } else {
98 0.0
99 };
100 let form_factor = if sd > 0.0 { bc_g1 / sd } else { 1.5 };
102
103 if weight_gr > 400.0 && caliber > 0.35 {
105 return 3;
106 }
107 if form_factor < 1.1 && bc_g1 < 0.2 {
109 return 1;
110 }
111 if weight_gr < 100.0 && caliber < 0.3 {
113 return 2;
114 }
115 0
117 }
118
119 pub fn get_bc_multiplier(&self, velocity_fps: f64, cluster_id: usize) -> f64 {
128 let cid = if cluster_id < VELOCITY_BANDS.len() {
129 cluster_id
130 } else {
131 0
132 };
133 let bands = &VELOCITY_BANDS[cid];
134
135 for i in 0..bands.len() {
136 let (v_threshold, multiplier) = bands[i];
137 if velocity_fps >= v_threshold {
138 if i == 0 {
139 return multiplier;
141 }
142 let (v_high, mult_high) = bands[i - 1];
144 let (v_low, mult_low) = bands[i];
145 let t = (velocity_fps - v_low) / (v_high - v_low);
146 return mult_low + t * (mult_high - mult_low);
147 }
148 }
149 bands[bands.len() - 1].1
151 }
152
153 pub fn get_cluster_name(&self, cluster_id: usize) -> &'static str {
155 match cluster_id {
156 0 => "Standard Long-Range",
157 1 => "Low-Drag Specialty",
158 2 => "Light Varmint/Target",
159 3 => "Heavy Magnum",
160 _ => "Unknown",
161 }
162 }
163
164 pub fn apply_correction(
167 &self,
168 bc: f64,
169 caliber: f64,
170 weight_gr: f64,
171 velocity_fps: f64,
172 ) -> f64 {
173 let cluster_id = self.predict_cluster(caliber, weight_gr, bc);
174 let multiplier = self.get_bc_multiplier(velocity_fps, cluster_id);
175 bc * multiplier
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn test_cluster_prediction() {
185 let cluster_bc = ClusterBCDegradation::new();
186
187 assert_eq!(cluster_bc.predict_cluster(0.308, 168.0, 0.475), 0);
189
190 assert_eq!(cluster_bc.predict_cluster(0.224, 55.0, 0.250), 2);
192
193 assert_eq!(cluster_bc.predict_cluster(0.458, 500.0, 0.295), 3);
195 }
196
197 #[test]
198 fn test_caliber_must_be_inches_not_meters() {
199 let cluster_bc = ClusterBCDegradation::new();
203 let inches = cluster_bc.predict_cluster(0.458, 500.0, 0.295);
204 let meters = cluster_bc.predict_cluster(0.458 * 0.0254, 500.0, 0.295);
205 assert_eq!(inches, 3, ".458/500gr in inches -> Heavy Magnum cluster");
206 assert_ne!(
207 meters, inches,
208 "caliber in meters must misclassify (caller must pass inches)"
209 );
210 }
211
212 #[test]
213 fn test_bc_multiplier_g7_semantics() {
214 let cluster_bc = ClusterBCDegradation::new();
215
216 let mult_hi = cluster_bc.get_bc_multiplier(3000.0, 0);
219 assert!(
220 (mult_hi - 1.01).abs() < 1e-9,
221 "cluster 0 @3000fps should be 1.01, got {mult_hi}"
222 );
223
224 let mult_transonic = cluster_bc.get_bc_multiplier(1100.0, 0);
227 let mult_mid = cluster_bc.get_bc_multiplier(1750.0, 0);
228 assert!(
229 mult_transonic > mult_mid,
230 "G7 transonic ({mult_transonic}) should now rise above mid-supersonic ({mult_mid})"
231 );
232 }
233
234 #[test]
235 fn test_apply_correction_g7() {
236 let cluster_bc = ClusterBCDegradation::new();
237 let bc = 0.475;
238
239 let hi = cluster_bc.apply_correction(bc, 0.308, 168.0, 2800.0);
241 assert!(hi >= bc * 0.95, "high-v should keep >=95% BC, got {}", hi / bc);
242
243 let transonic = cluster_bc.apply_correction(bc, 0.308, 168.0, 1100.0);
246 assert!(
247 transonic > bc,
248 "cluster-0 transonic G7 BC should rise above stated, got {}",
249 transonic / bc
250 );
251 }
252
253 #[test]
257 fn test_parity_with_python_reference() {
258 let m = ClusterBCDegradation::new();
259
260 let mult_goldens: &[(usize, f64, f64)] = &[
262 (0, 3600.0, 1.000000), (0, 3500.0, 1.000000), (0, 3250.0, 1.005000),
263 (0, 3000.0, 1.010000), (0, 2800.0, 1.010000), (0, 2500.0, 1.010000),
264 (0, 2250.0, 1.000000), (0, 2000.0, 0.990000), (0, 1750.0, 0.990000),
265 (0, 1500.0, 0.990000), (0, 1350.0, 1.010000), (0, 1200.0, 1.030000),
266 (0, 1100.0, 1.040000), (0, 1000.0, 1.050000), (0, 900.0, 1.039000),
267 (0, 800.0, 1.028000), (0, 500.0, 0.995000), (0, 0.0, 0.940000),
268 (1, 3600.0, 0.900000), (1, 3500.0, 0.900000), (1, 3250.0, 0.905000),
269 (1, 3000.0, 0.910000), (1, 2800.0, 0.922000), (1, 2500.0, 0.940000),
270 (1, 2250.0, 0.980000), (1, 2000.0, 1.020000), (1, 1750.0, 1.040000),
271 (1, 1500.0, 1.060000), (1, 1350.0, 1.115000), (1, 1200.0, 1.170000),
272 (1, 1100.0, 1.285000), (1, 1000.0, 1.400000), (1, 900.0, 1.374000),
273 (1, 800.0, 1.348000), (1, 500.0, 1.270000), (1, 0.0, 1.140000),
274 (2, 3600.0, 1.000000), (2, 3500.0, 1.000000), (2, 3250.0, 1.000000),
275 (2, 3000.0, 1.000000), (2, 2800.0, 1.004000), (2, 2500.0, 1.010000),
276 (2, 2250.0, 1.000000), (2, 2000.0, 0.990000), (2, 1750.0, 0.987500),
277 (2, 1500.0, 0.985000), (2, 1350.0, 0.997500), (2, 1200.0, 1.010000),
278 (2, 1100.0, 1.020000), (2, 1000.0, 1.030000), (2, 900.0, 1.017000),
279 (2, 800.0, 1.004000), (2, 500.0, 0.965000), (2, 0.0, 0.900000),
280 (3, 3600.0, 1.000000), (3, 3500.0, 1.000000), (3, 3250.0, 1.005000),
281 (3, 3000.0, 1.010000), (3, 2800.0, 1.006000), (3, 2500.0, 1.000000),
282 (3, 2250.0, 0.995000), (3, 2000.0, 0.990000), (3, 1750.0, 0.990000),
283 (3, 1500.0, 0.990000), (3, 1350.0, 1.005000), (3, 1200.0, 1.020000),
284 (3, 1100.0, 1.030000), (3, 1000.0, 1.040000), (3, 900.0, 1.030000),
285 (3, 800.0, 1.020000), (3, 500.0, 0.990000), (3, 0.0, 0.940000),
286 ];
287 for &(c, v, expected) in mult_goldens {
288 let got = m.get_bc_multiplier(v, c);
289 assert!(
290 (got - expected).abs() < 1e-6,
291 "multiplier parity: cluster {c} @{v}fps expected {expected}, got {got}"
292 );
293 }
294
295 let cluster_goldens: &[(f64, f64, f64, usize)] = &[
297 (0.308, 168.0, 0.475, 0), (0.308, 168.0, 0.223, 0),
298 (0.224, 55.0, 0.250, 2), (0.224, 77.0, 0.372, 2),
299 (0.458, 500.0, 0.295, 3), (0.375, 350.0, 0.310, 0),
300 (0.264, 140.0, 0.315, 0), (0.284, 180.0, 0.360, 0),
301 (0.338, 300.0, 0.400, 0), (0.243, 105.0, 0.278, 0),
302 (0.20, 40.0, 0.15, 1), (0.172, 20.0, 0.10, 1),
303 (0.510, 750.0, 0.500, 3), (0.30, 150.0, 0.19, 1),
304 (0.36, 410.0, 0.40, 3),
305 ];
306 for &(cal, wt, bc, expected) in cluster_goldens {
307 let got = m.predict_cluster(cal, wt, bc);
308 assert_eq!(
309 got, expected,
310 "cluster parity: ({cal}, {wt}, {bc}) expected {expected}, got {got}"
311 );
312 }
313 }
314}