#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FirearmType {
Rifle,
Pistol,
ShotgunAverage,
ShotgunLong,
}
impl FirearmType {
pub fn saami_gas_factor(self) -> f64 {
match self {
FirearmType::Rifle => 1.75,
FirearmType::Pistol => 1.50,
FirearmType::ShotgunAverage => 1.50,
FirearmType::ShotgunLong => 1.25,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GasVelocityModel {
Saami(FirearmType),
Factor(f64),
Fixed(f64),
}
impl GasVelocityModel {
pub fn resolve_mps(self, muzzle_velocity_mps: f64) -> f64 {
match self {
GasVelocityModel::Saami(t) => t.saami_gas_factor() * muzzle_velocity_mps,
GasVelocityModel::Factor(f) => f * muzzle_velocity_mps,
GasVelocityModel::Fixed(v) => v,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FreeRecoilInputs {
pub bullet_mass_kg: f64,
pub charge_mass_kg: f64,
pub muzzle_velocity_mps: f64,
pub firearm_mass_kg: f64,
pub gas_velocity_mps: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FreeRecoilResult {
pub recoil_velocity_mps: f64,
pub recoil_energy_j: f64,
pub impulse_ns: f64,
}
pub fn free_recoil(inputs: FreeRecoilInputs) -> Result<FreeRecoilResult, String> {
let FreeRecoilInputs {
bullet_mass_kg,
charge_mass_kg,
muzzle_velocity_mps,
firearm_mass_kg,
gas_velocity_mps,
} = inputs;
if !firearm_mass_kg.is_finite() || firearm_mass_kg <= 0.0 {
return Err("firearm weight must be a positive, finite number".to_string());
}
if !bullet_mass_kg.is_finite() || bullet_mass_kg <= 0.0 {
return Err("bullet weight must be a positive, finite number".to_string());
}
if !charge_mass_kg.is_finite() || charge_mass_kg < 0.0 {
return Err("charge weight must be a non-negative, finite number".to_string());
}
if !muzzle_velocity_mps.is_finite() || muzzle_velocity_mps <= 0.0 {
return Err("muzzle velocity must be a positive, finite number".to_string());
}
if !gas_velocity_mps.is_finite() || gas_velocity_mps < 0.0 {
return Err("gas velocity must be a non-negative, finite number".to_string());
}
let recoil_velocity_mps = (bullet_mass_kg * muzzle_velocity_mps
+ charge_mass_kg * gas_velocity_mps)
/ firearm_mass_kg;
let recoil_energy_j = 0.5 * firearm_mass_kg * recoil_velocity_mps * recoil_velocity_mps;
let impulse_ns = firearm_mass_kg * recoil_velocity_mps;
Ok(FreeRecoilResult {
recoil_velocity_mps,
recoil_energy_j,
impulse_ns,
})
}
pub const POUNDS_TO_KG: f64 = 0.45359237;
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::GRAINS_TO_KG;
const FPS_TO_MPS: f64 = 0.3048;
const J_TO_FTLB: f64 = 0.737562;
#[test]
fn saami_shotgun_worked_example() {
let wf_kg = 7.0 * POUNDS_TO_KG;
let we_kg = 589.9 * GRAINS_TO_KG;
let wpc_kg = 33.4 * GRAINS_TO_KG;
let ve_mps = 1275.0 * FPS_TO_MPS;
let gas_model = GasVelocityModel::Saami(FirearmType::ShotgunAverage);
let vpg_mps = gas_model.resolve_mps(ve_mps);
let result = free_recoil(FreeRecoilInputs {
bullet_mass_kg: we_kg,
charge_mass_kg: wpc_kg,
muzzle_velocity_mps: ve_mps,
firearm_mass_kg: wf_kg,
gas_velocity_mps: vpg_mps,
})
.expect("valid inputs");
let recoil_velocity_fps = result.recoil_velocity_mps / FPS_TO_MPS;
let recoil_energy_ftlb = result.recoil_energy_j * J_TO_FTLB;
assert!(
(recoil_velocity_fps - 16.653).abs() < 0.01,
"expected ~16.653 fps, got {recoil_velocity_fps}"
);
assert!(
(recoil_energy_ftlb - 30.17).abs() < 0.1,
"expected ~30.17-30.22 ft-lb, got {recoil_energy_ftlb}"
);
}
#[test]
fn saami_gas_factors_by_firearm_type() {
assert_eq!(FirearmType::Rifle.saami_gas_factor(), 1.75);
assert_eq!(FirearmType::Pistol.saami_gas_factor(), 1.50);
assert_eq!(FirearmType::ShotgunAverage.saami_gas_factor(), 1.50);
assert_eq!(FirearmType::ShotgunLong.saami_gas_factor(), 1.25);
}
#[test]
fn gas_velocity_model_variants_resolve() {
let muzzle = 800.0;
assert_eq!(
GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(muzzle),
1.75 * muzzle
);
assert_eq!(GasVelocityModel::Factor(2.0).resolve_mps(muzzle), 2.0 * muzzle);
assert_eq!(GasVelocityModel::Fixed(1433.0).resolve_mps(muzzle), 1433.0);
assert_eq!(GasVelocityModel::Fixed(1433.0).resolve_mps(1.0), 1433.0);
}
#[test]
fn zero_charge_weight_is_bullet_only_momentum() {
let result = free_recoil(FreeRecoilInputs {
bullet_mass_kg: 0.01,
charge_mass_kg: 0.0,
muzzle_velocity_mps: 800.0,
firearm_mass_kg: 4.0,
gas_velocity_mps: 1500.0, })
.expect("valid inputs");
let expected_v = 0.01 * 800.0 / 4.0;
assert!((result.recoil_velocity_mps - expected_v).abs() < 1e-12);
}
#[test]
fn heavier_firearm_reduces_recoil_energy() {
let gas_velocity_mps = GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(823.0);
let base = FreeRecoilInputs {
bullet_mass_kg: 0.011,
charge_mass_kg: 0.0028,
muzzle_velocity_mps: 823.0,
firearm_mass_kg: 3.0,
gas_velocity_mps,
};
let light = free_recoil(base).expect("valid inputs");
let heavy = free_recoil(FreeRecoilInputs {
firearm_mass_kg: 6.0,
..base
})
.expect("valid inputs");
assert!(heavy.recoil_velocity_mps < light.recoil_velocity_mps);
assert!(heavy.recoil_energy_j < light.recoil_energy_j);
assert!(
(heavy.recoil_energy_j - light.recoil_energy_j / 2.0).abs() < 1e-9,
"doubling firearm mass should exactly halve recoil energy"
);
}
#[test]
fn impulse_matches_firearm_momentum() {
let firearm_mass_kg = 3.86;
let result = free_recoil(FreeRecoilInputs {
bullet_mass_kg: 0.0109,
charge_mass_kg: 0.0028,
muzzle_velocity_mps: 823.0,
firearm_mass_kg,
gas_velocity_mps: GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(823.0),
})
.expect("valid inputs");
let expected_impulse = firearm_mass_kg * result.recoil_velocity_mps;
assert!(
(result.impulse_ns - expected_impulse).abs() < 1e-9,
"impulse must equal firearm_mass * recoil_velocity"
);
}
#[test]
fn rejects_non_positive_firearm_weight() {
let base = FreeRecoilInputs {
bullet_mass_kg: 0.01,
charge_mass_kg: 0.002,
muzzle_velocity_mps: 800.0,
firearm_mass_kg: 0.0,
gas_velocity_mps: 1200.0,
};
assert!(free_recoil(base).is_err());
assert!(free_recoil(FreeRecoilInputs {
firearm_mass_kg: -1.0,
..base
})
.is_err());
}
#[test]
fn rejects_non_positive_bullet_weight() {
assert!(free_recoil(FreeRecoilInputs {
bullet_mass_kg: 0.0,
charge_mass_kg: 0.002,
muzzle_velocity_mps: 800.0,
firearm_mass_kg: 4.0,
gas_velocity_mps: 1200.0,
})
.is_err());
}
#[test]
fn rejects_non_positive_muzzle_velocity() {
assert!(free_recoil(FreeRecoilInputs {
bullet_mass_kg: 0.01,
charge_mass_kg: 0.002,
muzzle_velocity_mps: 0.0,
firearm_mass_kg: 4.0,
gas_velocity_mps: 1200.0,
})
.is_err());
}
#[test]
fn rejects_non_finite_inputs() {
assert!(free_recoil(FreeRecoilInputs {
bullet_mass_kg: f64::NAN,
charge_mass_kg: 0.002,
muzzle_velocity_mps: 800.0,
firearm_mass_kg: 4.0,
gas_velocity_mps: 1200.0,
})
.is_err());
assert!(free_recoil(FreeRecoilInputs {
bullet_mass_kg: 0.01,
charge_mass_kg: 0.002,
muzzle_velocity_mps: f64::INFINITY,
firearm_mass_kg: 4.0,
gas_velocity_mps: 1200.0,
})
.is_err());
}
#[test]
fn accepts_zero_gas_velocity() {
assert!(free_recoil(FreeRecoilInputs {
bullet_mass_kg: 0.01,
charge_mass_kg: 0.0,
muzzle_velocity_mps: 800.0,
firearm_mass_kg: 4.0,
gas_velocity_mps: 0.0,
})
.is_ok());
}
}