1use crate::BCSegmentData;
2
3pub(crate) fn velocity_segment_bc(
9 velocity_fps: f64,
10 segments: &[BCSegmentData],
11 fallback_bc: f64,
12) -> f64 {
13 if let Some(segment) = segments.iter().find(|segment| {
14 velocity_fps >= segment.velocity_min && velocity_fps < segment.velocity_max
15 }) {
16 return segment.bc_value;
17 }
18
19 let lowest = segments
20 .iter()
21 .min_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
22 if let Some(segment) = lowest {
23 if velocity_fps < segment.velocity_min {
24 return segment.bc_value;
25 }
26 }
27
28 let highest = segments
29 .iter()
30 .max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max));
31 if let Some(segment) = highest {
32 if velocity_fps >= segment.velocity_max {
33 return segment.bc_value;
34 }
35 }
36
37 fallback_bc
38}
39
40#[derive(Debug, Clone, Copy, PartialEq)]
42pub enum BulletType {
43 MatchBoatTail,
44 MatchFlatBase,
45 HuntingBoatTail,
46 HuntingFlatBase,
47 VldHighBc,
48 Hybrid,
49 FMJ,
50 RoundNose,
51 Unknown,
52}
53
54pub struct BulletTypeFactors {
56 pub drop: f64,
57 pub transition_curve: f64,
58}
59
60impl BulletType {
61 pub fn get_factors(&self) -> BulletTypeFactors {
63 match self {
64 BulletType::MatchBoatTail => BulletTypeFactors {
65 drop: 0.075, transition_curve: 0.3,
67 },
68 BulletType::MatchFlatBase => BulletTypeFactors {
69 drop: 0.10, transition_curve: 0.35,
71 },
72 BulletType::HuntingBoatTail => BulletTypeFactors {
73 drop: 0.15, transition_curve: 0.45,
75 },
76 BulletType::HuntingFlatBase => BulletTypeFactors {
77 drop: 0.20, transition_curve: 0.5,
79 },
80 BulletType::VldHighBc => BulletTypeFactors {
81 drop: 0.05, transition_curve: 0.25,
83 },
84 BulletType::Hybrid => BulletTypeFactors {
85 drop: 0.06, transition_curve: 0.28,
87 },
88 BulletType::FMJ => BulletTypeFactors {
89 drop: 0.12, transition_curve: 0.4,
91 },
92 BulletType::RoundNose => BulletTypeFactors {
93 drop: 0.35, transition_curve: 0.7,
95 },
96 BulletType::Unknown => BulletTypeFactors {
97 drop: 0.15, transition_curve: 0.5,
99 },
100 }
101 }
102}
103
104pub struct BCSegmentEstimator;
106
107impl BCSegmentEstimator {
108 pub fn identify_bullet_type(
114 model: &str,
115 weight: f64,
116 caliber: f64,
117 bc_value: Option<f64>,
118 ) -> BulletType {
119 Self::identify_bullet_type_for_drag_model(model, weight, caliber, bc_value, "G1")
120 }
121
122 pub fn identify_bullet_type_for_drag_model(
124 model: &str,
125 weight: f64,
126 caliber: f64,
127 bc_value: Option<f64>,
128 drag_model: &str,
129 ) -> BulletType {
130 let model_lower = model.to_lowercase();
131
132 if model_lower.contains("vld")
134 || model_lower.contains("berger")
135 || model_lower.contains("hybrid")
136 || model_lower.contains("elite")
137 {
138 if model_lower.contains("hybrid") {
139 return BulletType::Hybrid;
140 }
141 return BulletType::VldHighBc;
142 }
143
144 if model_lower.contains("smk")
146 || model_lower.contains("matchking")
147 || model_lower.contains("match")
148 || model_lower.contains("bthp")
149 || model_lower.contains("competition")
150 || model_lower.contains("target")
151 || model_lower.contains("a-max")
152 || model_lower.contains("eld-m")
153 || model_lower.contains("scenar")
154 || model_lower.contains("x-ring")
155 {
156 if model_lower.contains("bt") || model_lower.contains("boat") {
158 return BulletType::MatchBoatTail;
159 }
160 if let Some(bc) = bc_value {
163 let sd = Self::calculate_sectional_density(weight, caliber);
164 if sd > 0.0 && Self::classification_bc_sd_ratio(bc, sd, drag_model) > 1.6 {
165 return BulletType::MatchBoatTail;
166 }
167 }
168 return BulletType::MatchFlatBase;
169 }
170
171 if model_lower.contains("gameking")
173 || model_lower.contains("hunting")
174 || model_lower.contains("sst")
175 || model_lower.contains("eld-x")
176 || model_lower.contains("partition")
177 || model_lower.contains("accubond")
178 || model_lower.contains("core-lokt")
179 || model_lower.contains("ballistic tip")
180 || model_lower.contains("v-max")
181 || model_lower.contains("hornady sp")
182 || model_lower.contains("interlock")
183 || model_lower.contains("tsx")
184 {
185 if model_lower.contains("bt")
187 || model_lower.contains("boat")
188 || model_lower.contains("sst")
189 || model_lower.contains("accubond")
190 {
191 return BulletType::HuntingBoatTail;
192 }
193 return BulletType::HuntingFlatBase;
194 }
195
196 if model_lower.contains("fmj")
198 || model_lower.contains("ball")
199 || model_lower.contains("m80")
200 || model_lower.contains("m855")
201 || model_lower.contains("tracer")
202 {
203 return BulletType::FMJ;
204 }
205
206 if model_lower.contains("rn")
208 || model_lower.contains("round nose")
209 || model_lower.contains("rnsp")
210 {
211 return BulletType::RoundNose;
212 }
213
214 if let Some(bc) = bc_value {
218 let sd = Self::calculate_sectional_density(weight, caliber);
219 if sd > 0.0 {
220 let bc_to_sd_ratio = Self::classification_bc_sd_ratio(bc, sd, drag_model);
221
222 if bc_to_sd_ratio > 1.8 {
223 return BulletType::VldHighBc;
224 } else if bc_to_sd_ratio > 1.5 {
225 return BulletType::MatchBoatTail;
226 } else if bc_to_sd_ratio < 1.2 {
227 return BulletType::HuntingFlatBase;
228 }
229 }
230 }
231
232 BulletType::Unknown
233 }
234
235 fn classification_bc_sd_ratio(bc: f64, sd: f64, drag_model: &str) -> f64 {
240 let g1_equivalent_bc = if drag_model.eq_ignore_ascii_case("G7") {
241 bc * 2.0
242 } else {
243 bc
244 };
245 g1_equivalent_bc / sd
246 }
247
248 pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
250 if caliber_inches <= 0.0 {
253 return 0.0;
254 }
255 weight_grains / (7000.0 * caliber_inches * caliber_inches)
256 }
257
258 #[allow(clippy::manual_clamp)] pub fn estimate_bc_segments(
261 base_bc: f64,
262 caliber: f64,
263 weight: f64,
264 model: &str,
265 drag_model: &str,
266 ) -> Vec<BCSegmentData> {
267 let bullet_type = Self::identify_bullet_type_for_drag_model(
269 model,
270 weight,
271 caliber,
272 Some(base_bc),
273 drag_model,
274 );
275 let type_factors = bullet_type.get_factors();
276
277 let sd = Self::calculate_sectional_density(weight, caliber);
279
280 let sd_factor = (sd / 0.25).max(0.7).min(1.3);
283 let nominal_drop = type_factors.drop;
284
285 let mut segments = Vec::new();
287
288 match bullet_type {
290 BulletType::MatchBoatTail => {
291 segments.push(BCSegmentData {
293 velocity_min: 2800.0,
294 velocity_max: 5000.0,
295 bc_value: base_bc * 1.000,
296 });
297 segments.push(BCSegmentData {
298 velocity_min: 2400.0,
299 velocity_max: 2800.0,
300 bc_value: base_bc * 0.985,
301 });
302 segments.push(BCSegmentData {
303 velocity_min: 2000.0,
304 velocity_max: 2400.0,
305 bc_value: base_bc * 0.965,
306 });
307 segments.push(BCSegmentData {
308 velocity_min: 1600.0,
309 velocity_max: 2000.0,
310 bc_value: base_bc * 0.945,
311 });
312 segments.push(BCSegmentData {
313 velocity_min: 0.0,
314 velocity_max: 1600.0,
315 bc_value: base_bc * 0.925,
316 });
317 }
318 BulletType::VldHighBc | BulletType::Hybrid => {
319 segments.push(BCSegmentData {
321 velocity_min: 2800.0,
322 velocity_max: 5000.0,
323 bc_value: base_bc * 1.000,
324 });
325 segments.push(BCSegmentData {
326 velocity_min: 2200.0,
327 velocity_max: 2800.0,
328 bc_value: base_bc * 0.990,
329 });
330 segments.push(BCSegmentData {
331 velocity_min: 1600.0,
332 velocity_max: 2200.0,
333 bc_value: base_bc * 0.970,
334 });
335 segments.push(BCSegmentData {
336 velocity_min: 0.0,
337 velocity_max: 1600.0,
338 bc_value: base_bc * 0.950,
339 });
340 }
341 BulletType::HuntingBoatTail => {
342 segments.push(BCSegmentData {
344 velocity_min: 2600.0,
345 velocity_max: 5000.0,
346 bc_value: base_bc * 1.000,
347 });
348 segments.push(BCSegmentData {
349 velocity_min: 2200.0,
350 velocity_max: 2600.0,
351 bc_value: base_bc * 0.960,
352 });
353 segments.push(BCSegmentData {
354 velocity_min: 1800.0,
355 velocity_max: 2200.0,
356 bc_value: base_bc * 0.900,
357 });
358 segments.push(BCSegmentData {
359 velocity_min: 0.0,
360 velocity_max: 1800.0,
361 bc_value: base_bc * 0.850,
362 });
363 }
364 _ => {
365 segments.push(BCSegmentData {
367 velocity_min: 2800.0,
368 velocity_max: 5000.0,
369 bc_value: base_bc,
370 });
371
372 let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
373 segments.push(BCSegmentData {
374 velocity_min: 1800.0,
375 velocity_max: 2800.0,
376 bc_value: transonic_bc,
377 });
378
379 let subsonic_bc = base_bc * (1.0 - nominal_drop);
380 segments.push(BCSegmentData {
381 velocity_min: 0.0,
382 velocity_max: 1800.0,
383 bc_value: subsonic_bc,
384 });
385 }
386 }
387
388 if drag_model.eq_ignore_ascii_case("G7") {
395 const G7_DROP_SCALE: f64 = 0.8;
396 for segment in &mut segments {
397 let drop_from_nominal = base_bc - segment.bc_value;
398 segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
399 }
400 }
401
402 if sd_factor != 1.0 {
407 for segment in &mut segments {
408 let drop_from_nominal = base_bc - segment.bc_value;
409 segment.bc_value = base_bc - drop_from_nominal / sd_factor;
410 }
411 }
412
413 segments
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_bullet_type_identification() {
423 assert_eq!(
424 BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
425 BulletType::MatchFlatBase
426 );
427 assert_eq!(
428 BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
429 BulletType::MatchBoatTail
430 );
431 assert_eq!(
432 BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
433 BulletType::HuntingBoatTail
434 );
435 assert_eq!(
436 BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
437 BulletType::FMJ
438 );
439 assert_eq!(
440 BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
441 BulletType::RoundNose
442 );
443 assert_eq!(
444 BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
445 BulletType::VldHighBc
446 );
447 assert_eq!(
448 BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
449 BulletType::Unknown
450 );
451 }
452
453 #[test]
454 fn test_sectional_density() {
455 let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
456 assert!((sd - 0.253).abs() < 0.001);
457 }
458
459 #[test]
460 fn test_bc_estimation() {
461 let segments =
462 BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
463
464 assert!(segments.len() >= 3);
466 assert!((segments[0].bc_value - 0.450).abs() < 0.05);
468 assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
470 }
471
472 #[test]
473 fn g7_transition_adjustment_softens_each_band_drop() {
474 let base_bc = 0.5;
475 let caliber = 1.0;
477 let weight = 1750.0;
478
479 for model in ["SMK BT", "FMJ"] {
480 let g1 =
481 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
482 let g7 =
483 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
484 let lowercase_g7 =
485 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
486 assert_eq!(g7.len(), g1.len());
487 assert_eq!(lowercase_g7.len(), g7.len());
488
489 for ((g1_band, g7_band), lowercase_band) in
490 g1.iter().zip(&g7).zip(&lowercase_g7)
491 {
492 assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
493 assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
494 assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
495 assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
496 assert_eq!(lowercase_band.bc_value.to_bits(), g7_band.bc_value.to_bits());
497 let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
498 assert!(
499 (g7_band.bc_value - expected_g7).abs() < 1e-12,
500 "{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
501 g1_band.velocity_min,
502 g1_band.velocity_max,
503 g1_band.bc_value,
504 g7_band.bc_value
505 );
506 }
507 }
508 }
509
510 #[test]
511 fn sectional_density_shapes_only_band_degradation() {
512 let base_bc = 0.5;
513 let caliber = 0.224;
514 let weight = 77.0;
515 let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
516 let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
517
518 for drag_model in ["G1", "G7"] {
519 let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
520 for (model, raw_retentions) in [
521 ("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
522 ("FMJ", &[1.0, 0.964, 0.88][..]),
523 ] {
524 let segments = BCSegmentEstimator::estimate_bc_segments(
525 base_bc,
526 caliber,
527 weight,
528 model,
529 drag_model,
530 );
531 assert_eq!(segments.len(), raw_retentions.len());
532
533 for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
534 let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
535 let expected = base_bc - raw_drop / sd_factor;
536 assert!(
537 (segment.bc_value - expected).abs() < 1e-12,
538 "{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
539 segment.velocity_min,
540 segment.velocity_max,
541 segment.bc_value
542 );
543 }
544 assert_eq!(
545 segments[0].bc_value.to_bits(),
546 base_bc.to_bits(),
547 "published muzzle BC must remain exact for {drag_model} {model}"
548 );
549 }
550 }
551
552 let high_sd_base_bc = 0.3;
555 let high_sd_segments = BCSegmentEstimator::estimate_bc_segments(
556 high_sd_base_bc,
557 0.308,
558 220.0,
559 "SMK BT",
560 "G7",
561 );
562 assert_eq!(high_sd_segments[0].bc_value.to_bits(), high_sd_base_bc.to_bits());
563 assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
564 assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
565 }
566
567 #[test]
568 fn generic_g7_bc_uses_g7_classification_space() {
569 let base_bc = 0.243;
573 let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
574
575 assert!(
576 segments.len() >= 4,
577 "G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
578 );
579 let subsonic_bc = segments.last().unwrap().bc_value;
580 assert!(
581 subsonic_bc >= base_bc * 0.92,
582 "G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
583 );
584
585 let g1_segments =
590 BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
591 assert_eq!(segments.len(), g1_segments.len());
592 let mut saw_g7_softening = false;
593 for (g7, g1) in segments.iter().zip(&g1_segments) {
594 assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
595 assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
596 let g7_retention = g7.bc_value / base_bc;
597 let g1_retention = g1.bc_value / (base_bc * 2.0);
598 assert!(g7_retention + 1e-12 >= g1_retention);
599 saw_g7_softening |= g7_retention > g1_retention + 1e-12;
600 }
601 assert!(saw_g7_softening);
602
603 let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
604 assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
605 assert_eq!(
606 legacy_g1,
607 BCSegmentEstimator::identify_bullet_type_for_drag_model(
608 "",
609 175.0,
610 0.308,
611 Some(base_bc),
612 "G1",
613 )
614 );
615 assert_eq!(
616 BCSegmentEstimator::identify_bullet_type_for_drag_model(
617 "175gr SMK",
618 175.0,
619 0.308,
620 Some(base_bc),
621 "g7",
622 ),
623 BulletType::MatchBoatTail
624 );
625 assert_eq!(
626 BCSegmentEstimator::identify_bullet_type_for_drag_model(
627 "",
628 175.0,
629 0.0,
630 Some(base_bc),
631 "G7",
632 ),
633 BulletType::Unknown
634 );
635 }
636}