1use crate::DragModel;
25
26type VelocityBand = (f64, f64);
30
31const G7_TO_G1_CLASSIFICATION_RATIO: f64 = 1.98;
35
36const VELOCITY_BANDS: [[VelocityBand; 8]; 4] = [
39 [
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 [
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 [
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 [
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 pub fn predict_cluster(&self, caliber: f64, weight_gr: f64, bc_g1: f64) -> usize {
101 let sd = if caliber > 0.0 {
103 weight_gr / (7000.0 * caliber * caliber)
104 } else {
105 0.0
106 };
107 let form_factor = if sd > 0.0 { bc_g1 / sd } else { 1.5 };
109
110 if weight_gr > 400.0 && caliber > 0.35 {
112 return 3;
113 }
114 if form_factor < 1.1 && bc_g1 < 0.2 {
116 return 1;
117 }
118 if weight_gr < 100.0 && caliber < 0.3 {
120 return 2;
121 }
122 0
124 }
125
126 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 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 return multiplier;
168 }
169 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 bands[bands.len() - 1].1
178 }
179
180 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 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 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 assert_eq!(cluster_bc.predict_cluster(0.308, 168.0, 0.475), 0);
239
240 assert_eq!(cluster_bc.predict_cluster(0.224, 55.0, 0.250), 2);
242
243 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 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 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 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 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 = cluster_bc.predict_cluster_for_drag_model(
301 caliber,
302 weight,
303 g7_bc,
304 DragModel::G7,
305 );
306 assert_eq!(
307 got,
308 expected,
309 "G7 classification for ({caliber}, {weight}, {g7_bc})"
310 );
311 }
312
313 let hi = cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 2800.0, DragModel::G7);
314 assert!(
315 (hi / bc - 1.004).abs() < 1e-12,
316 "wrong cluster: {}",
317 hi / bc
318 );
319
320 let transonic =
321 cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 1100.0, DragModel::G7);
322 assert!(
323 (transonic / bc - 1.02).abs() < 1e-12,
324 "wrong transonic cluster: {}",
325 transonic / bc
326 );
327
328 let legacy = cluster_bc.apply_correction(bc, 0.224, 77.0, 2800.0);
331 let explicit_g1 = cluster_bc.apply_correction_for_drag_model(
332 bc,
333 0.224,
334 77.0,
335 2800.0,
336 DragModel::G1,
337 );
338 assert_eq!(legacy.to_bits(), explicit_g1.to_bits());
339 }
340
341 #[test]
342 fn g7_reference_ladder_is_inert_for_other_drag_models() {
343 let cluster_bc = ClusterBCDegradation::new();
344 let bc = 0.475;
345
346 for drag_model in [
347 DragModel::G1,
348 DragModel::G2,
349 DragModel::G5,
350 DragModel::G6,
351 DragModel::G8,
352 DragModel::GI,
353 DragModel::GS,
354 ] {
355 let corrected = cluster_bc.apply_correction_for_drag_model(
356 bc,
357 0.308,
358 168.0,
359 1100.0,
360 drag_model,
361 );
362 assert_eq!(
363 corrected.to_bits(),
364 bc.to_bits(),
365 "G7-reference multiplier must not shape {drag_model} BC"
366 );
367 }
368 assert_eq!(
369 cluster_bc
370 .apply_correction(bc, 0.308, 168.0, 1100.0)
371 .to_bits(),
372 bc.to_bits(),
373 "legacy G1 entry point must also remain unmodified"
374 );
375 }
376
377 #[test]
381 fn test_parity_with_python_reference() {
382 let m = ClusterBCDegradation::new();
383
384 let mult_goldens: &[(usize, f64, f64)] = &[
386 (0, 3600.0, 1.000000), (0, 3500.0, 1.000000), (0, 3250.0, 1.005000),
387 (0, 3000.0, 1.010000), (0, 2800.0, 1.010000), (0, 2500.0, 1.010000),
388 (0, 2250.0, 1.000000), (0, 2000.0, 0.990000), (0, 1750.0, 0.990000),
389 (0, 1500.0, 0.990000), (0, 1350.0, 1.010000), (0, 1200.0, 1.030000),
390 (0, 1100.0, 1.040000), (0, 1000.0, 1.050000), (0, 900.0, 1.039000),
391 (0, 800.0, 1.028000), (0, 500.0, 0.995000), (0, 0.0, 0.940000),
392 (1, 3600.0, 0.900000), (1, 3500.0, 0.900000), (1, 3250.0, 0.905000),
393 (1, 3000.0, 0.910000), (1, 2800.0, 0.922000), (1, 2500.0, 0.940000),
394 (1, 2250.0, 0.980000), (1, 2000.0, 1.020000), (1, 1750.0, 1.040000),
395 (1, 1500.0, 1.060000), (1, 1350.0, 1.115000), (1, 1200.0, 1.170000),
396 (1, 1100.0, 1.285000), (1, 1000.0, 1.400000), (1, 900.0, 1.374000),
397 (1, 800.0, 1.348000), (1, 500.0, 1.270000), (1, 0.0, 1.140000),
398 (2, 3600.0, 1.000000), (2, 3500.0, 1.000000), (2, 3250.0, 1.000000),
399 (2, 3000.0, 1.000000), (2, 2800.0, 1.004000), (2, 2500.0, 1.010000),
400 (2, 2250.0, 1.000000), (2, 2000.0, 0.990000), (2, 1750.0, 0.987500),
401 (2, 1500.0, 0.985000), (2, 1350.0, 0.997500), (2, 1200.0, 1.010000),
402 (2, 1100.0, 1.020000), (2, 1000.0, 1.030000), (2, 900.0, 1.017000),
403 (2, 800.0, 1.004000), (2, 500.0, 0.965000), (2, 0.0, 0.900000),
404 (3, 3600.0, 1.000000), (3, 3500.0, 1.000000), (3, 3250.0, 1.005000),
405 (3, 3000.0, 1.010000), (3, 2800.0, 1.006000), (3, 2500.0, 1.000000),
406 (3, 2250.0, 0.995000), (3, 2000.0, 0.990000), (3, 1750.0, 0.990000),
407 (3, 1500.0, 0.990000), (3, 1350.0, 1.005000), (3, 1200.0, 1.020000),
408 (3, 1100.0, 1.030000), (3, 1000.0, 1.040000), (3, 900.0, 1.030000),
409 (3, 800.0, 1.020000), (3, 500.0, 0.990000), (3, 0.0, 0.940000),
410 ];
411 for &(c, v, expected) in mult_goldens {
412 let got = m.get_bc_multiplier(v, c);
413 assert!(
414 (got - expected).abs() < 1e-6,
415 "multiplier parity: cluster {c} @{v}fps expected {expected}, got {got}"
416 );
417 }
418
419 let cluster_goldens: &[(f64, f64, f64, usize)] = &[
421 (0.308, 168.0, 0.475, 0), (0.308, 168.0, 0.223, 0),
422 (0.224, 55.0, 0.250, 2), (0.224, 77.0, 0.372, 2),
423 (0.458, 500.0, 0.295, 3), (0.375, 350.0, 0.310, 0),
424 (0.264, 140.0, 0.315, 0), (0.284, 180.0, 0.360, 0),
425 (0.338, 300.0, 0.400, 0), (0.243, 105.0, 0.278, 0),
426 (0.20, 40.0, 0.15, 1), (0.172, 20.0, 0.10, 1),
427 (0.510, 750.0, 0.500, 3), (0.30, 150.0, 0.19, 1),
428 (0.36, 410.0, 0.40, 3),
429 ];
430 for &(cal, wt, bc, expected) in cluster_goldens {
431 let got = m.predict_cluster(cal, wt, bc);
432 assert_eq!(
433 got, expected,
434 "cluster parity: ({cal}, {wt}, {bc}) expected {expected}, got {got}"
435 );
436 }
437 }
438}