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 =
301 cluster_bc.predict_cluster_for_drag_model(caliber, weight, g7_bc, DragModel::G7);
302 assert_eq!(
303 got, expected,
304 "G7 classification for ({caliber}, {weight}, {g7_bc})"
305 );
306 }
307
308 let hi = cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 2800.0, DragModel::G7);
309 assert!(
310 (hi / bc - 1.004).abs() < 1e-12,
311 "wrong cluster: {}",
312 hi / bc
313 );
314
315 let transonic =
316 cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 1100.0, DragModel::G7);
317 assert!(
318 (transonic / bc - 1.02).abs() < 1e-12,
319 "wrong transonic cluster: {}",
320 transonic / bc
321 );
322
323 let legacy = cluster_bc.apply_correction(bc, 0.224, 77.0, 2800.0);
326 let explicit_g1 =
327 cluster_bc.apply_correction_for_drag_model(bc, 0.224, 77.0, 2800.0, DragModel::G1);
328 assert_eq!(legacy.to_bits(), explicit_g1.to_bits());
329 }
330
331 #[test]
332 fn g7_reference_ladder_is_inert_for_other_drag_models() {
333 let cluster_bc = ClusterBCDegradation::new();
334 let bc = 0.475;
335
336 for drag_model in [
337 DragModel::G1,
338 DragModel::G2,
339 DragModel::G5,
340 DragModel::G6,
341 DragModel::G8,
342 DragModel::GI,
343 DragModel::GS,
344 ] {
345 let corrected =
346 cluster_bc.apply_correction_for_drag_model(bc, 0.308, 168.0, 1100.0, drag_model);
347 assert_eq!(
348 corrected.to_bits(),
349 bc.to_bits(),
350 "G7-reference multiplier must not shape {drag_model} BC"
351 );
352 }
353 assert_eq!(
354 cluster_bc
355 .apply_correction(bc, 0.308, 168.0, 1100.0)
356 .to_bits(),
357 bc.to_bits(),
358 "legacy G1 entry point must also remain unmodified"
359 );
360 }
361
362 #[test]
366 fn test_parity_with_python_reference() {
367 let m = ClusterBCDegradation::new();
368
369 let mult_goldens: &[(usize, f64, f64)] = &[
371 (0, 3600.0, 1.000000),
372 (0, 3500.0, 1.000000),
373 (0, 3250.0, 1.005000),
374 (0, 3000.0, 1.010000),
375 (0, 2800.0, 1.010000),
376 (0, 2500.0, 1.010000),
377 (0, 2250.0, 1.000000),
378 (0, 2000.0, 0.990000),
379 (0, 1750.0, 0.990000),
380 (0, 1500.0, 0.990000),
381 (0, 1350.0, 1.010000),
382 (0, 1200.0, 1.030000),
383 (0, 1100.0, 1.040000),
384 (0, 1000.0, 1.050000),
385 (0, 900.0, 1.039000),
386 (0, 800.0, 1.028000),
387 (0, 500.0, 0.995000),
388 (0, 0.0, 0.940000),
389 (1, 3600.0, 0.900000),
390 (1, 3500.0, 0.900000),
391 (1, 3250.0, 0.905000),
392 (1, 3000.0, 0.910000),
393 (1, 2800.0, 0.922000),
394 (1, 2500.0, 0.940000),
395 (1, 2250.0, 0.980000),
396 (1, 2000.0, 1.020000),
397 (1, 1750.0, 1.040000),
398 (1, 1500.0, 1.060000),
399 (1, 1350.0, 1.115000),
400 (1, 1200.0, 1.170000),
401 (1, 1100.0, 1.285000),
402 (1, 1000.0, 1.400000),
403 (1, 900.0, 1.374000),
404 (1, 800.0, 1.348000),
405 (1, 500.0, 1.270000),
406 (1, 0.0, 1.140000),
407 (2, 3600.0, 1.000000),
408 (2, 3500.0, 1.000000),
409 (2, 3250.0, 1.000000),
410 (2, 3000.0, 1.000000),
411 (2, 2800.0, 1.004000),
412 (2, 2500.0, 1.010000),
413 (2, 2250.0, 1.000000),
414 (2, 2000.0, 0.990000),
415 (2, 1750.0, 0.987500),
416 (2, 1500.0, 0.985000),
417 (2, 1350.0, 0.997500),
418 (2, 1200.0, 1.010000),
419 (2, 1100.0, 1.020000),
420 (2, 1000.0, 1.030000),
421 (2, 900.0, 1.017000),
422 (2, 800.0, 1.004000),
423 (2, 500.0, 0.965000),
424 (2, 0.0, 0.900000),
425 (3, 3600.0, 1.000000),
426 (3, 3500.0, 1.000000),
427 (3, 3250.0, 1.005000),
428 (3, 3000.0, 1.010000),
429 (3, 2800.0, 1.006000),
430 (3, 2500.0, 1.000000),
431 (3, 2250.0, 0.995000),
432 (3, 2000.0, 0.990000),
433 (3, 1750.0, 0.990000),
434 (3, 1500.0, 0.990000),
435 (3, 1350.0, 1.005000),
436 (3, 1200.0, 1.020000),
437 (3, 1100.0, 1.030000),
438 (3, 1000.0, 1.040000),
439 (3, 900.0, 1.030000),
440 (3, 800.0, 1.020000),
441 (3, 500.0, 0.990000),
442 (3, 0.0, 0.940000),
443 ];
444 for &(c, v, expected) in mult_goldens {
445 let got = m.get_bc_multiplier(v, c);
446 assert!(
447 (got - expected).abs() < 1e-6,
448 "multiplier parity: cluster {c} @{v}fps expected {expected}, got {got}"
449 );
450 }
451
452 let cluster_goldens: &[(f64, f64, f64, usize)] = &[
454 (0.308, 168.0, 0.475, 0),
455 (0.308, 168.0, 0.223, 0),
456 (0.224, 55.0, 0.250, 2),
457 (0.224, 77.0, 0.372, 2),
458 (0.458, 500.0, 0.295, 3),
459 (0.375, 350.0, 0.310, 0),
460 (0.264, 140.0, 0.315, 0),
461 (0.284, 180.0, 0.360, 0),
462 (0.338, 300.0, 0.400, 0),
463 (0.243, 105.0, 0.278, 0),
464 (0.20, 40.0, 0.15, 1),
465 (0.172, 20.0, 0.10, 1),
466 (0.510, 750.0, 0.500, 3),
467 (0.30, 150.0, 0.19, 1),
468 (0.36, 410.0, 0.40, 3),
469 ];
470 for &(cal, wt, bc, expected) in cluster_goldens {
471 let got = m.predict_cluster(cal, wt, bc);
472 assert_eq!(
473 got, expected,
474 "cluster parity: ({cal}, {wt}, {bc}) expected {expected}, got {got}"
475 );
476 }
477 }
478}