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    #[allow(clippy::manual_clamp)] // max/min intentionally maps a NaN SD to the lower fallback.
260    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        // Identify bullet type
268        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        // Calculate sectional density
278        let sd = Self::calculate_sectional_density(weight, caliber);
279
280        // Adjust BC drop based on sectional density
281        // Higher SD = more stable BC
282        let sd_factor = (sd / 0.25).max(0.7).min(1.3);
283        let nominal_drop = type_factors.drop;
284
285        // Generate segments based on bullet type
286        let mut segments = Vec::new();
287
288        // Determine velocity ranges and BC retention factors
289        match bullet_type {
290            BulletType::MatchBoatTail => {
291                // Match boat tail - minimal BC degradation
292                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                // VLD/Hybrid - very stable BC
320                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                // Hunting boat tail - moderate degradation
343                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                // Default degradation profile
366                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        // G7 reference drag follows modern boat-tail projectiles more closely,
389        // so their banded BC varies less than the G1-shaped ladders above. Scale
390        // each loss from nominal rather than the BC itself: this leaves the muzzle
391        // band unchanged by this adjustment and makes the model effective for every
392        // named and default profile. Do not run the identity algebra for G1, so
393        // its established floating-point outputs remain bit-for-bit unchanged.
394        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        // Sectional density shapes only the degradation depth. Scaling the BC
403        // itself would alter the user's published muzzle value and would apply SD
404        // twice in the default profile. Skip identity algebra so SD=0.25 keeps
405        // established output bits unchanged.
406        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        // Match rifles typically have 4 segments
465        assert!(segments.len() >= 3);
466        // First segment should be close to base BC
467        assert!((segments[0].bc_value - 0.450).abs() < 0.05);
468        // BC should degrade at lower velocities
469        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        // SD = 0.25 exactly, so the independent sectional-density adjustment is neutral.
476        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        // High SD used to multiply every band above nominal and then cap them
553        // all to base_bc, erasing the degradation ladder.
554        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        // A representative 175 gr .308 match bullet. Its G7 BC is ordinary for a
570        // boat-tail projectile, but the same numeric value looks like a blunt
571        // flat-base bullet when interpreted with the G1 BC/SD thresholds.
572        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        // The normalization is deliberately equivalent to classifying the
586        // approximate G1 BC, while the G7 transition adjustment then softens
587        // the loss within that same ladder and the legacy entry point remains
588        // G1-compatible.
589        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}