Skip to main content

ballistics_engine/
bc_estimation.rs

1use crate::BCSegmentData;
2
3/// Resolve a velocity-keyed BC table without assuming segment order.
4///
5/// Bands are half-open (`velocity_min <= v < velocity_max`), so a shared boundary belongs to
6/// the upper band. Below/above global coverage, clamp to the lowest/highest-velocity band;
7/// an interior coverage gap or empty table uses the caller's projectile-specific scalar BC.
8pub(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/// Bullet type classification based on model name
41#[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
54/// BC degradation factors for different bullet types
55pub struct BulletTypeFactors {
56    pub drop: f64,
57    pub transition_curve: f64,
58}
59
60impl BulletType {
61    /// Get degradation factors for this bullet type
62    pub fn get_factors(&self) -> BulletTypeFactors {
63        match self {
64            BulletType::MatchBoatTail => BulletTypeFactors {
65                drop: 0.075, // 7.5% total drop for match boat tail
66                transition_curve: 0.3,
67            },
68            BulletType::MatchFlatBase => BulletTypeFactors {
69                drop: 0.10, // 10% for match flat base
70                transition_curve: 0.35,
71            },
72            BulletType::HuntingBoatTail => BulletTypeFactors {
73                drop: 0.15, // 15% for hunting boat tail
74                transition_curve: 0.45,
75            },
76            BulletType::HuntingFlatBase => BulletTypeFactors {
77                drop: 0.20, // 20% for hunting flat base
78                transition_curve: 0.5,
79            },
80            BulletType::VldHighBc => BulletTypeFactors {
81                drop: 0.05, // 5% for VLD (very low drag)
82                transition_curve: 0.25,
83            },
84            BulletType::Hybrid => BulletTypeFactors {
85                drop: 0.06, // 6% for hybrid designs
86                transition_curve: 0.28,
87            },
88            BulletType::FMJ => BulletTypeFactors {
89                drop: 0.12, // 12% for military ball
90                transition_curve: 0.4,
91            },
92            BulletType::RoundNose => BulletTypeFactors {
93                drop: 0.35, // 35% for round nose
94                transition_curve: 0.7,
95            },
96            BulletType::Unknown => BulletTypeFactors {
97                drop: 0.15, // Conservative 15%
98                transition_curve: 0.5,
99            },
100        }
101    }
102}
103
104/// BC segment estimator based on physics and known patterns
105pub struct BCSegmentEstimator;
106
107impl BCSegmentEstimator {
108    /// Identify bullet type from model name and characteristics.
109    ///
110    /// This compatibility entry point interprets `bc_value` as a G1 BC. Call
111    /// [`Self::identify_bullet_type_for_drag_model`] when the reference drag
112    /// model is known.
113    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    /// Identify bullet type while interpreting the BC in its reference-model space.
123    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        // VLD/High BC bullets
133        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        // Match bullets (competition/target)
145        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            // Check for boat tail
157            if model_lower.contains("bt") || model_lower.contains("boat") {
158                return BulletType::MatchBoatTail;
159            }
160            // Check if high BC indicates boat tail (guard sd>0: calculate_sectional_density
161            // returns 0 for non-positive caliber, which would make bc/sd == +Inf).
162            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        // Hunting bullets (expanding)
172        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            // Check for boat tail
186            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        // FMJ/Military
197        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        // Round nose
207        if model_lower.contains("rn")
208            || model_lower.contains("round nose")
209            || model_lower.contains("rnsp")
210        {
211            return BulletType::RoundNose;
212        }
213
214        // Use BC value as hint if available. Guard sd>0 (zero for non-positive caliber)
215        // so a degenerate input falls through to Unknown instead of dividing by zero
216        // (bc/0 == +Inf, which would silently classify as VldHighBc).
217        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    /// Convert the reference-model-dependent BC/SD ratio into the G1 space used
236    /// by the legacy coarse classification thresholds above. Typical boat-tail
237    /// G1 BCs are approximately twice their G7 BCs; this normalization prevents
238    /// ordinary G7 match bullets from looking like low-BC G1 flat-base bullets.
239    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    /// Calculate sectional density (SD) from weight and caliber
249    pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
250        // SD = weight / (7000 * caliber^2)
251        // Protect against division by zero or negative caliber
252        if caliber_inches <= 0.0 {
253            return 0.0;
254        }
255        weight_grains / (7000.0 * caliber_inches * caliber_inches)
256    }
257
258    /// Estimate BC segments based on bullet characteristics
259    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        // Identify bullet type
267        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        // Calculate sectional density
277        let sd = Self::calculate_sectional_density(weight, caliber);
278
279        // Adjust BC drop based on sectional density
280        // Higher SD = more stable BC
281        let sd_factor = (sd / 0.25).max(0.7).min(1.3);
282        let nominal_drop = type_factors.drop;
283
284        // Generate segments based on bullet type
285        let mut segments = Vec::new();
286
287        // Determine velocity ranges and BC retention factors
288        match bullet_type {
289            BulletType::MatchBoatTail => {
290                // Match boat tail - minimal BC degradation
291                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                // VLD/Hybrid - very stable BC
319                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                // Hunting boat tail - moderate degradation
342                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                // Default degradation profile
365                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        // G7 reference drag follows modern boat-tail projectiles more closely,
388        // so their banded BC varies less than the G1-shaped ladders above. Scale
389        // each loss from nominal rather than the BC itself: this leaves the muzzle
390        // band unchanged by this adjustment and makes the model effective for every
391        // named and default profile. Do not run the identity algebra for G1, so
392        // its established floating-point outputs remain bit-for-bit unchanged.
393        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        // Sectional density shapes only the degradation depth. Scaling the BC
402        // itself would alter the user's published muzzle value and would apply SD
403        // twice in the default profile. Skip identity algebra so SD=0.25 keeps
404        // established output bits unchanged.
405        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        // Match rifles typically have 4 segments
464        assert!(segments.len() >= 3);
465        // First segment should be close to base BC
466        assert!((segments[0].bc_value - 0.450).abs() < 0.05);
467        // BC should degrade at lower velocities
468        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        // SD = 0.25 exactly, so the independent sectional-density adjustment is neutral.
475        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        // High SD used to multiply every band above nominal and then cap them
552        // all to base_bc, erasing the degradation ladder.
553        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        // A representative 175 gr .308 match bullet. Its G7 BC is ordinary for a
569        // boat-tail projectile, but the same numeric value looks like a blunt
570        // flat-base bullet when interpreted with the G1 BC/SD thresholds.
571        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        // The normalization is deliberately equivalent to classifying the
585        // approximate G1 BC, while the G7 transition adjustment then softens
586        // the loss within that same ladder and the legacy entry point remains
587        // G1-compatible.
588        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}