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 pub fn estimate_bc_segments(
260 base_bc: f64,
261 caliber: f64,
262 weight: f64,
263 model: &str,
264 drag_model: &str,
265 ) -> Vec<BCSegmentData> {
266 let bullet_type = Self::identify_bullet_type_for_drag_model(
268 model,
269 weight,
270 caliber,
271 Some(base_bc),
272 drag_model,
273 );
274 let type_factors = bullet_type.get_factors();
275
276 let sd = Self::calculate_sectional_density(weight, caliber);
278
279 let sd_factor = (sd / 0.25).max(0.7).min(1.3);
282 let nominal_drop = type_factors.drop;
283
284 let mut segments = Vec::new();
286
287 match bullet_type {
289 BulletType::MatchBoatTail => {
290 segments.push(BCSegmentData {
292 velocity_min: 2800.0,
293 velocity_max: 5000.0,
294 bc_value: base_bc * 1.000,
295 });
296 segments.push(BCSegmentData {
297 velocity_min: 2400.0,
298 velocity_max: 2800.0,
299 bc_value: base_bc * 0.985,
300 });
301 segments.push(BCSegmentData {
302 velocity_min: 2000.0,
303 velocity_max: 2400.0,
304 bc_value: base_bc * 0.965,
305 });
306 segments.push(BCSegmentData {
307 velocity_min: 1600.0,
308 velocity_max: 2000.0,
309 bc_value: base_bc * 0.945,
310 });
311 segments.push(BCSegmentData {
312 velocity_min: 0.0,
313 velocity_max: 1600.0,
314 bc_value: base_bc * 0.925,
315 });
316 }
317 BulletType::VldHighBc | BulletType::Hybrid => {
318 segments.push(BCSegmentData {
320 velocity_min: 2800.0,
321 velocity_max: 5000.0,
322 bc_value: base_bc * 1.000,
323 });
324 segments.push(BCSegmentData {
325 velocity_min: 2200.0,
326 velocity_max: 2800.0,
327 bc_value: base_bc * 0.990,
328 });
329 segments.push(BCSegmentData {
330 velocity_min: 1600.0,
331 velocity_max: 2200.0,
332 bc_value: base_bc * 0.970,
333 });
334 segments.push(BCSegmentData {
335 velocity_min: 0.0,
336 velocity_max: 1600.0,
337 bc_value: base_bc * 0.950,
338 });
339 }
340 BulletType::HuntingBoatTail => {
341 segments.push(BCSegmentData {
343 velocity_min: 2600.0,
344 velocity_max: 5000.0,
345 bc_value: base_bc * 1.000,
346 });
347 segments.push(BCSegmentData {
348 velocity_min: 2200.0,
349 velocity_max: 2600.0,
350 bc_value: base_bc * 0.960,
351 });
352 segments.push(BCSegmentData {
353 velocity_min: 1800.0,
354 velocity_max: 2200.0,
355 bc_value: base_bc * 0.900,
356 });
357 segments.push(BCSegmentData {
358 velocity_min: 0.0,
359 velocity_max: 1800.0,
360 bc_value: base_bc * 0.850,
361 });
362 }
363 _ => {
364 segments.push(BCSegmentData {
366 velocity_min: 2800.0,
367 velocity_max: 5000.0,
368 bc_value: base_bc,
369 });
370
371 let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
372 segments.push(BCSegmentData {
373 velocity_min: 1800.0,
374 velocity_max: 2800.0,
375 bc_value: transonic_bc,
376 });
377
378 let subsonic_bc = base_bc * (1.0 - nominal_drop);
379 segments.push(BCSegmentData {
380 velocity_min: 0.0,
381 velocity_max: 1800.0,
382 bc_value: subsonic_bc,
383 });
384 }
385 }
386
387 if drag_model.eq_ignore_ascii_case("G7") {
394 const G7_DROP_SCALE: f64 = 0.8;
395 for segment in &mut segments {
396 let drop_from_nominal = base_bc - segment.bc_value;
397 segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
398 }
399 }
400
401 if sd_factor != 1.0 {
406 for segment in &mut segments {
407 let drop_from_nominal = base_bc - segment.bc_value;
408 segment.bc_value = base_bc - drop_from_nominal / sd_factor;
409 }
410 }
411
412 segments
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn test_bullet_type_identification() {
422 assert_eq!(
423 BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
424 BulletType::MatchFlatBase
425 );
426 assert_eq!(
427 BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
428 BulletType::MatchBoatTail
429 );
430 assert_eq!(
431 BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
432 BulletType::HuntingBoatTail
433 );
434 assert_eq!(
435 BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
436 BulletType::FMJ
437 );
438 assert_eq!(
439 BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
440 BulletType::RoundNose
441 );
442 assert_eq!(
443 BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
444 BulletType::VldHighBc
445 );
446 assert_eq!(
447 BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
448 BulletType::Unknown
449 );
450 }
451
452 #[test]
453 fn test_sectional_density() {
454 let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
455 assert!((sd - 0.253).abs() < 0.001);
456 }
457
458 #[test]
459 fn test_bc_estimation() {
460 let segments =
461 BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
462
463 assert!(segments.len() >= 3);
465 assert!((segments[0].bc_value - 0.450).abs() < 0.05);
467 assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
469 }
470
471 #[test]
472 fn g7_transition_adjustment_softens_each_band_drop() {
473 let base_bc = 0.5;
474 let caliber = 1.0;
476 let weight = 1750.0;
477
478 for model in ["SMK BT", "FMJ"] {
479 let g1 =
480 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
481 let g7 =
482 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
483 let lowercase_g7 =
484 BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
485 assert_eq!(g7.len(), g1.len());
486 assert_eq!(lowercase_g7.len(), g7.len());
487
488 for ((g1_band, g7_band), lowercase_band) in
489 g1.iter().zip(&g7).zip(&lowercase_g7)
490 {
491 assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
492 assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
493 assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
494 assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
495 assert_eq!(lowercase_band.bc_value.to_bits(), g7_band.bc_value.to_bits());
496 let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
497 assert!(
498 (g7_band.bc_value - expected_g7).abs() < 1e-12,
499 "{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
500 g1_band.velocity_min,
501 g1_band.velocity_max,
502 g1_band.bc_value,
503 g7_band.bc_value
504 );
505 }
506 }
507 }
508
509 #[test]
510 fn sectional_density_shapes_only_band_degradation() {
511 let base_bc = 0.5;
512 let caliber = 0.224;
513 let weight = 77.0;
514 let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
515 let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
516
517 for drag_model in ["G1", "G7"] {
518 let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
519 for (model, raw_retentions) in [
520 ("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
521 ("FMJ", &[1.0, 0.964, 0.88][..]),
522 ] {
523 let segments = BCSegmentEstimator::estimate_bc_segments(
524 base_bc,
525 caliber,
526 weight,
527 model,
528 drag_model,
529 );
530 assert_eq!(segments.len(), raw_retentions.len());
531
532 for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
533 let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
534 let expected = base_bc - raw_drop / sd_factor;
535 assert!(
536 (segment.bc_value - expected).abs() < 1e-12,
537 "{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
538 segment.velocity_min,
539 segment.velocity_max,
540 segment.bc_value
541 );
542 }
543 assert_eq!(
544 segments[0].bc_value.to_bits(),
545 base_bc.to_bits(),
546 "published muzzle BC must remain exact for {drag_model} {model}"
547 );
548 }
549 }
550
551 let high_sd_base_bc = 0.3;
554 let high_sd_segments = BCSegmentEstimator::estimate_bc_segments(
555 high_sd_base_bc,
556 0.308,
557 220.0,
558 "SMK BT",
559 "G7",
560 );
561 assert_eq!(high_sd_segments[0].bc_value.to_bits(), high_sd_base_bc.to_bits());
562 assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
563 assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
564 }
565
566 #[test]
567 fn generic_g7_bc_uses_g7_classification_space() {
568 let base_bc = 0.243;
572 let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
573
574 assert!(
575 segments.len() >= 4,
576 "G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
577 );
578 let subsonic_bc = segments.last().unwrap().bc_value;
579 assert!(
580 subsonic_bc >= base_bc * 0.92,
581 "G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
582 );
583
584 let g1_segments =
589 BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
590 assert_eq!(segments.len(), g1_segments.len());
591 let mut saw_g7_softening = false;
592 for (g7, g1) in segments.iter().zip(&g1_segments) {
593 assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
594 assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
595 let g7_retention = g7.bc_value / base_bc;
596 let g1_retention = g1.bc_value / (base_bc * 2.0);
597 assert!(g7_retention + 1e-12 >= g1_retention);
598 saw_g7_softening |= g7_retention > g1_retention + 1e-12;
599 }
600 assert!(saw_g7_softening);
601
602 let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
603 assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
604 assert_eq!(
605 legacy_g1,
606 BCSegmentEstimator::identify_bullet_type_for_drag_model(
607 "",
608 175.0,
609 0.308,
610 Some(base_bc),
611 "G1",
612 )
613 );
614 assert_eq!(
615 BCSegmentEstimator::identify_bullet_type_for_drag_model(
616 "175gr SMK",
617 175.0,
618 0.308,
619 Some(base_bc),
620 "g7",
621 ),
622 BulletType::MatchBoatTail
623 );
624 assert_eq!(
625 BCSegmentEstimator::identify_bullet_type_for_drag_model(
626 "",
627 175.0,
628 0.0,
629 Some(base_bc),
630 "G7",
631 ),
632 BulletType::Unknown
633 );
634 }
635}