use crate::BCSegmentData;
pub(crate) fn velocity_segment_bc(
velocity_fps: f64,
segments: &[BCSegmentData],
fallback_bc: f64,
) -> f64 {
if let Some(segment) = segments.iter().find(|segment| {
velocity_fps >= segment.velocity_min && velocity_fps < segment.velocity_max
}) {
return segment.bc_value;
}
let lowest = segments
.iter()
.min_by(|a, b| a.velocity_min.total_cmp(&b.velocity_min));
if let Some(segment) = lowest {
if velocity_fps < segment.velocity_min {
return segment.bc_value;
}
}
let highest = segments
.iter()
.max_by(|a, b| a.velocity_max.total_cmp(&b.velocity_max));
if let Some(segment) = highest {
if velocity_fps >= segment.velocity_max {
return segment.bc_value;
}
}
fallback_bc
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BulletType {
MatchBoatTail,
MatchFlatBase,
HuntingBoatTail,
HuntingFlatBase,
VldHighBc,
Hybrid,
FMJ,
RoundNose,
Unknown,
}
pub struct BulletTypeFactors {
pub drop: f64,
pub transition_curve: f64,
}
impl BulletType {
pub fn get_factors(&self) -> BulletTypeFactors {
match self {
BulletType::MatchBoatTail => BulletTypeFactors {
drop: 0.075, transition_curve: 0.3,
},
BulletType::MatchFlatBase => BulletTypeFactors {
drop: 0.10, transition_curve: 0.35,
},
BulletType::HuntingBoatTail => BulletTypeFactors {
drop: 0.15, transition_curve: 0.45,
},
BulletType::HuntingFlatBase => BulletTypeFactors {
drop: 0.20, transition_curve: 0.5,
},
BulletType::VldHighBc => BulletTypeFactors {
drop: 0.05, transition_curve: 0.25,
},
BulletType::Hybrid => BulletTypeFactors {
drop: 0.06, transition_curve: 0.28,
},
BulletType::FMJ => BulletTypeFactors {
drop: 0.12, transition_curve: 0.4,
},
BulletType::RoundNose => BulletTypeFactors {
drop: 0.35, transition_curve: 0.7,
},
BulletType::Unknown => BulletTypeFactors {
drop: 0.15, transition_curve: 0.5,
},
}
}
}
pub struct BCSegmentEstimator;
impl BCSegmentEstimator {
pub fn identify_bullet_type(
model: &str,
weight: f64,
caliber: f64,
bc_value: Option<f64>,
) -> BulletType {
Self::identify_bullet_type_for_drag_model(model, weight, caliber, bc_value, "G1")
}
pub fn identify_bullet_type_for_drag_model(
model: &str,
weight: f64,
caliber: f64,
bc_value: Option<f64>,
drag_model: &str,
) -> BulletType {
let model_lower = model.to_lowercase();
if model_lower.contains("vld")
|| model_lower.contains("berger")
|| model_lower.contains("hybrid")
|| model_lower.contains("elite")
{
if model_lower.contains("hybrid") {
return BulletType::Hybrid;
}
return BulletType::VldHighBc;
}
if model_lower.contains("smk")
|| model_lower.contains("matchking")
|| model_lower.contains("match")
|| model_lower.contains("bthp")
|| model_lower.contains("competition")
|| model_lower.contains("target")
|| model_lower.contains("a-max")
|| model_lower.contains("eld-m")
|| model_lower.contains("scenar")
|| model_lower.contains("x-ring")
{
if model_lower.contains("bt") || model_lower.contains("boat") {
return BulletType::MatchBoatTail;
}
if let Some(bc) = bc_value {
let sd = Self::calculate_sectional_density(weight, caliber);
if sd > 0.0 && Self::classification_bc_sd_ratio(bc, sd, drag_model) > 1.6 {
return BulletType::MatchBoatTail;
}
}
return BulletType::MatchFlatBase;
}
if model_lower.contains("gameking")
|| model_lower.contains("hunting")
|| model_lower.contains("sst")
|| model_lower.contains("eld-x")
|| model_lower.contains("partition")
|| model_lower.contains("accubond")
|| model_lower.contains("core-lokt")
|| model_lower.contains("ballistic tip")
|| model_lower.contains("v-max")
|| model_lower.contains("hornady sp")
|| model_lower.contains("interlock")
|| model_lower.contains("tsx")
{
if model_lower.contains("bt")
|| model_lower.contains("boat")
|| model_lower.contains("sst")
|| model_lower.contains("accubond")
{
return BulletType::HuntingBoatTail;
}
return BulletType::HuntingFlatBase;
}
if model_lower.contains("fmj")
|| model_lower.contains("ball")
|| model_lower.contains("m80")
|| model_lower.contains("m855")
|| model_lower.contains("tracer")
{
return BulletType::FMJ;
}
if model_lower.contains("rn")
|| model_lower.contains("round nose")
|| model_lower.contains("rnsp")
{
return BulletType::RoundNose;
}
if let Some(bc) = bc_value {
let sd = Self::calculate_sectional_density(weight, caliber);
if sd > 0.0 {
let bc_to_sd_ratio = Self::classification_bc_sd_ratio(bc, sd, drag_model);
if bc_to_sd_ratio > 1.8 {
return BulletType::VldHighBc;
} else if bc_to_sd_ratio > 1.5 {
return BulletType::MatchBoatTail;
} else if bc_to_sd_ratio < 1.2 {
return BulletType::HuntingFlatBase;
}
}
}
BulletType::Unknown
}
fn classification_bc_sd_ratio(bc: f64, sd: f64, drag_model: &str) -> f64 {
let g1_equivalent_bc = if drag_model.eq_ignore_ascii_case("G7") {
bc * 2.0
} else {
bc
};
g1_equivalent_bc / sd
}
pub fn calculate_sectional_density(weight_grains: f64, caliber_inches: f64) -> f64 {
if caliber_inches <= 0.0 {
return 0.0;
}
weight_grains / (7000.0 * caliber_inches * caliber_inches)
}
pub fn estimate_bc_segments(
base_bc: f64,
caliber: f64,
weight: f64,
model: &str,
drag_model: &str,
) -> Vec<BCSegmentData> {
let bullet_type = Self::identify_bullet_type_for_drag_model(
model,
weight,
caliber,
Some(base_bc),
drag_model,
);
let type_factors = bullet_type.get_factors();
let sd = Self::calculate_sectional_density(weight, caliber);
let sd_factor = (sd / 0.25).max(0.7).min(1.3);
let nominal_drop = type_factors.drop;
let mut segments = Vec::new();
match bullet_type {
BulletType::MatchBoatTail => {
segments.push(BCSegmentData {
velocity_min: 2800.0,
velocity_max: 5000.0,
bc_value: base_bc * 1.000,
});
segments.push(BCSegmentData {
velocity_min: 2400.0,
velocity_max: 2800.0,
bc_value: base_bc * 0.985,
});
segments.push(BCSegmentData {
velocity_min: 2000.0,
velocity_max: 2400.0,
bc_value: base_bc * 0.965,
});
segments.push(BCSegmentData {
velocity_min: 1600.0,
velocity_max: 2000.0,
bc_value: base_bc * 0.945,
});
segments.push(BCSegmentData {
velocity_min: 0.0,
velocity_max: 1600.0,
bc_value: base_bc * 0.925,
});
}
BulletType::VldHighBc | BulletType::Hybrid => {
segments.push(BCSegmentData {
velocity_min: 2800.0,
velocity_max: 5000.0,
bc_value: base_bc * 1.000,
});
segments.push(BCSegmentData {
velocity_min: 2200.0,
velocity_max: 2800.0,
bc_value: base_bc * 0.990,
});
segments.push(BCSegmentData {
velocity_min: 1600.0,
velocity_max: 2200.0,
bc_value: base_bc * 0.970,
});
segments.push(BCSegmentData {
velocity_min: 0.0,
velocity_max: 1600.0,
bc_value: base_bc * 0.950,
});
}
BulletType::HuntingBoatTail => {
segments.push(BCSegmentData {
velocity_min: 2600.0,
velocity_max: 5000.0,
bc_value: base_bc * 1.000,
});
segments.push(BCSegmentData {
velocity_min: 2200.0,
velocity_max: 2600.0,
bc_value: base_bc * 0.960,
});
segments.push(BCSegmentData {
velocity_min: 1800.0,
velocity_max: 2200.0,
bc_value: base_bc * 0.900,
});
segments.push(BCSegmentData {
velocity_min: 0.0,
velocity_max: 1800.0,
bc_value: base_bc * 0.850,
});
}
_ => {
segments.push(BCSegmentData {
velocity_min: 2800.0,
velocity_max: 5000.0,
bc_value: base_bc,
});
let transonic_bc = base_bc * (1.0 - nominal_drop * 0.3);
segments.push(BCSegmentData {
velocity_min: 1800.0,
velocity_max: 2800.0,
bc_value: transonic_bc,
});
let subsonic_bc = base_bc * (1.0 - nominal_drop);
segments.push(BCSegmentData {
velocity_min: 0.0,
velocity_max: 1800.0,
bc_value: subsonic_bc,
});
}
}
if drag_model.eq_ignore_ascii_case("G7") {
const G7_DROP_SCALE: f64 = 0.8;
for segment in &mut segments {
let drop_from_nominal = base_bc - segment.bc_value;
segment.bc_value = base_bc - drop_from_nominal * G7_DROP_SCALE;
}
}
if sd_factor != 1.0 {
for segment in &mut segments {
let drop_from_nominal = base_bc - segment.bc_value;
segment.bc_value = base_bc - drop_from_nominal / sd_factor;
}
}
segments
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bullet_type_identification() {
assert_eq!(
BCSegmentEstimator::identify_bullet_type("168gr SMK", 168.0, 0.308, None),
BulletType::MatchFlatBase
);
assert_eq!(
BCSegmentEstimator::identify_bullet_type("168gr SMK BT", 168.0, 0.308, None),
BulletType::MatchBoatTail
);
assert_eq!(
BCSegmentEstimator::identify_bullet_type("150gr SST", 150.0, 0.308, None),
BulletType::HuntingBoatTail
);
assert_eq!(
BCSegmentEstimator::identify_bullet_type("147gr FMJ", 147.0, 0.308, None),
BulletType::FMJ
);
assert_eq!(
BCSegmentEstimator::identify_bullet_type("180gr RN", 180.0, 0.308, None),
BulletType::RoundNose
);
assert_eq!(
BCSegmentEstimator::identify_bullet_type("168gr VLD", 168.0, 0.308, None),
BulletType::VldHighBc
);
assert_eq!(
BCSegmentEstimator::identify_bullet_type("Some bullet", 150.0, 0.308, None),
BulletType::Unknown
);
}
#[test]
fn test_sectional_density() {
let sd = BCSegmentEstimator::calculate_sectional_density(168.0, 0.308);
assert!((sd - 0.253).abs() < 0.001);
}
#[test]
fn test_bc_estimation() {
let segments =
BCSegmentEstimator::estimate_bc_segments(0.450, 0.308, 168.0, "168gr SMK", "G1");
assert!(segments.len() >= 3);
assert!((segments[0].bc_value - 0.450).abs() < 0.05);
assert!(segments[segments.len() - 1].bc_value < segments[0].bc_value);
}
#[test]
fn g7_transition_adjustment_softens_each_band_drop() {
let base_bc = 0.5;
let caliber = 1.0;
let weight = 1750.0;
for model in ["SMK BT", "FMJ"] {
let g1 =
BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G1");
let g7 =
BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "G7");
let lowercase_g7 =
BCSegmentEstimator::estimate_bc_segments(base_bc, caliber, weight, model, "g7");
assert_eq!(g7.len(), g1.len());
assert_eq!(lowercase_g7.len(), g7.len());
for ((g1_band, g7_band), lowercase_band) in
g1.iter().zip(&g7).zip(&lowercase_g7)
{
assert_eq!(g7_band.velocity_min, g1_band.velocity_min);
assert_eq!(g7_band.velocity_max, g1_band.velocity_max);
assert_eq!(lowercase_band.velocity_min, g7_band.velocity_min);
assert_eq!(lowercase_band.velocity_max, g7_band.velocity_max);
assert_eq!(lowercase_band.bc_value.to_bits(), g7_band.bc_value.to_bits());
let expected_g7 = base_bc - (base_bc - g1_band.bc_value) * 0.8;
assert!(
(g7_band.bc_value - expected_g7).abs() < 1e-12,
"{model} band {}-{} did not soften the G1 loss: G1={}, G7={}, expected={expected_g7}",
g1_band.velocity_min,
g1_band.velocity_max,
g1_band.bc_value,
g7_band.bc_value
);
}
}
}
#[test]
fn sectional_density_shapes_only_band_degradation() {
let base_bc = 0.5;
let caliber = 0.224;
let weight = 77.0;
let sd = BCSegmentEstimator::calculate_sectional_density(weight, caliber);
let sd_factor = (sd / 0.25).clamp(0.7, 1.3);
for drag_model in ["G1", "G7"] {
let model_drop_scale = if drag_model == "G7" { 0.8 } else { 1.0 };
for (model, raw_retentions) in [
("SMK BT", &[1.0, 0.985, 0.965, 0.945, 0.925][..]),
("FMJ", &[1.0, 0.964, 0.88][..]),
] {
let segments = BCSegmentEstimator::estimate_bc_segments(
base_bc,
caliber,
weight,
model,
drag_model,
);
assert_eq!(segments.len(), raw_retentions.len());
for (segment, raw_retention) in segments.iter().zip(raw_retentions) {
let raw_drop = base_bc * (1.0 - raw_retention) * model_drop_scale;
let expected = base_bc - raw_drop / sd_factor;
assert!(
(segment.bc_value - expected).abs() < 1e-12,
"{drag_model} {model} band {}-{} misapplied SD: got {}, expected {expected}",
segment.velocity_min,
segment.velocity_max,
segment.bc_value
);
}
assert_eq!(
segments[0].bc_value.to_bits(),
base_bc.to_bits(),
"published muzzle BC must remain exact for {drag_model} {model}"
);
}
}
let high_sd_base_bc = 0.3;
let high_sd_segments = BCSegmentEstimator::estimate_bc_segments(
high_sd_base_bc,
0.308,
220.0,
"SMK BT",
"G7",
);
assert_eq!(high_sd_segments[0].bc_value.to_bits(), high_sd_base_bc.to_bits());
assert!(high_sd_segments.last().unwrap().bc_value < high_sd_base_bc);
assert!((high_sd_segments.last().unwrap().bc_value - 0.28615384615384615).abs() < 1e-12);
}
#[test]
fn generic_g7_bc_uses_g7_classification_space() {
let base_bc = 0.243;
let segments = BCSegmentEstimator::estimate_bc_segments(base_bc, 0.308, 175.0, "", "G7");
assert!(
segments.len() >= 4,
"G7 match bullet should use a near-flat match/VLD ladder: {segments:?}"
);
let subsonic_bc = segments.last().unwrap().bc_value;
assert!(
subsonic_bc >= base_bc * 0.92,
"G7 match bullet was over-degraded: {base_bc} -> {subsonic_bc}"
);
let g1_segments =
BCSegmentEstimator::estimate_bc_segments(base_bc * 2.0, 0.308, 175.0, "", "G1");
assert_eq!(segments.len(), g1_segments.len());
let mut saw_g7_softening = false;
for (g7, g1) in segments.iter().zip(&g1_segments) {
assert_eq!(g7.velocity_min.to_bits(), g1.velocity_min.to_bits());
assert_eq!(g7.velocity_max.to_bits(), g1.velocity_max.to_bits());
let g7_retention = g7.bc_value / base_bc;
let g1_retention = g1.bc_value / (base_bc * 2.0);
assert!(g7_retention + 1e-12 >= g1_retention);
saw_g7_softening |= g7_retention > g1_retention + 1e-12;
}
assert!(saw_g7_softening);
let legacy_g1 = BCSegmentEstimator::identify_bullet_type("", 175.0, 0.308, Some(base_bc));
assert_eq!(legacy_g1, BulletType::HuntingFlatBase);
assert_eq!(
legacy_g1,
BCSegmentEstimator::identify_bullet_type_for_drag_model(
"",
175.0,
0.308,
Some(base_bc),
"G1",
)
);
assert_eq!(
BCSegmentEstimator::identify_bullet_type_for_drag_model(
"175gr SMK",
175.0,
0.308,
Some(base_bc),
"g7",
),
BulletType::MatchBoatTail
);
assert_eq!(
BCSegmentEstimator::identify_bullet_type_for_drag_model(
"",
175.0,
0.0,
Some(base_bc),
"G7",
),
BulletType::Unknown
);
}
}