Skip to main content

ballistics_engine/
recoil.rs

1//! SAAMI free-recoil calculator (MBA-1372).
2//!
3//! Source: SAAMI "Gun Recoil - Technical" (Rev. 7/9/2018), the Sporting Arms and
4//! Ammunition Manufacturers' Institute's freely downloadable momentum-balance formula:
5//! <https://saami.org/wp-content/uploads/2025/03/Gun-Recoil-Formulae-2018-07-9.pdf>
6//!
7//! ## The physics
8//!
9//! Recoil is conservation of momentum: the momentum of the free-recoiling firearm is
10//! equal and opposite to the momentum of everything that leaves the muzzle -- the bullet
11//! plus the propellant gas. SAAMI's document equates the propellant gas *mass* to the
12//! powder charge weight ("because the propellant gases are extremely difficult to
13//! weigh"), giving:
14//!
15//! ```text
16//! firearm_mass * V_recoil = bullet_mass * V_muzzle + charge_mass * V_gas
17//! ```
18//!
19//! and free recoil energy is then simply `0.5 * firearm_mass * V_recoil^2`.
20//!
21//! ## Gas-velocity convention (a deliberate modelling choice, not an implementation detail)
22//!
23//! The propellant gas leaves the muzzle *faster* than the bullet, but its true velocity
24//! is hard to measure directly. Two conventions are in circulation:
25//!
26//! 1. **SAAMI's own type-keyed multiplier** (page 2 of the source PDF): `V_gas = f *
27//!    V_muzzle`, with `f` sourced from 1929-era British ballistic testing and keyed to
28//!    firearm class: high-powered rifle `f = 1.75`, pistol/revolver `f = 1.50`,
29//!    average-length shotgun `f = 1.50`, long-barrel shotgun `f = 1.25`.
30//! 2. **A fixed constant** (commonly ~4700 fps / ~1433 m/s for smokeless powder),
31//!    popularized by several reloading references, independent of muzzle velocity.
32//!
33//! **This module defaults to the SAAMI type-keyed factor** (`GasVelocityModel::Saami`)
34//! rather than the fixed constant, because: it is the cited, auditable industry-standard
35//! source with a worked example we reproduce in the tests below; it scales with muzzle
36//! velocity, so it stays physically sane across drastically different loads (a fixed
37//! constant over-states gas momentum for a slow subsonic load and under-states it for a
38//! magnum); and it is keyed to firearm class the way the industry actually publishes it.
39//! The fixed-velocity model is still exposed (`GasVelocityModel::Fixed`) for callers who
40//! want to reproduce that older convention or plug in a chronographed gas velocity --
41//! "expose both," per the brief, rather than silently picking one and hiding the other.
42//!
43//! ## Units
44//!
45//! [`free_recoil`] takes and returns pure SI (kg, m/s, J, N*s) -- the same internal
46//! convention `cli_api`/the rest of this crate uses; CLI/WASM front ends convert to/from
47//! display units (imperial: grains for bullet/charge, pounds for firearm weight, fps;
48//! metric: grams, kilograms, m/s).
49//!
50//! Note this deliberately differs from SAAMI's own metric appendix (page 4 of the source
51//! PDF), which divides a *weight-in-kilograms* by `g = 9.8` to produce energy in legacy
52//! "kilogram-meters" (kgf*m) rather than joules. This crate already treats every other
53//! mass input (bullet grams/grains, in `cli_api`) as a true SI mass rather than a
54//! gravity-dependent "weight," so `free_recoil` performs genuine `F = m*a` physics in
55//! kg/m/s/J throughout. No `g` factor is needed for the recoil *velocity* at all (it
56//! cancels in the momentum ratio -- see the SAAMI PDF's own algebra, page 1); `g` only
57//! ever entered SAAMI's imperial derivation to convert *weight* (pounds) to *mass*
58//! (slugs) for the energy step, which plain kilograms sidesteps entirely. The tests below
59//! confirm this reproduces the SAAMI worked example (page 3) to within its own rounding.
60
61/// Firearm type, selecting SAAMI's empirical propellant-gas-velocity multiplier `f`
62/// (`V_gas = f * V_muzzle`). See the module docs for the source and rationale.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum FirearmType {
65    /// High-powered rifle: `f = 1.75` (SAAMI's own headline worked case).
66    Rifle,
67    /// Pistol or revolver: `f = 1.50`.
68    Pistol,
69    /// Shotgun, average barrel length: `f = 1.50`.
70    ShotgunAverage,
71    /// Shotgun, long barrel: `f = 1.25`.
72    ShotgunLong,
73}
74
75impl FirearmType {
76    /// SAAMI's `f` multiplier: `V_gas = f * V_muzzle`.
77    pub fn saami_gas_factor(self) -> f64 {
78        match self {
79            FirearmType::Rifle => 1.75,
80            FirearmType::Pistol => 1.50,
81            FirearmType::ShotgunAverage => 1.50,
82            FirearmType::ShotgunLong => 1.25,
83        }
84    }
85}
86
87/// How the propellant gas velocity (SAAMI's `V_PG`) is resolved from the muzzle velocity.
88/// See the module docs for why the SAAMI type-keyed factor is the default.
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub enum GasVelocityModel {
91    /// `V_gas = firearm_type.saami_gas_factor() * V_muzzle` -- the default/recommended path.
92    Saami(FirearmType),
93    /// `V_gas = factor * V_muzzle`: an explicit override of the SAAMI per-type factor.
94    Factor(f64),
95    /// `V_gas` is an absolute constant (m/s), independent of muzzle velocity -- e.g. the
96    /// popular non-SAAMI rule of thumb of roughly 4700 fps (~1433 m/s) for smokeless
97    /// powder, or a chronographed/measured gas velocity.
98    Fixed(f64),
99}
100
101impl GasVelocityModel {
102    /// Resolve the propellant gas velocity (m/s) for a given muzzle velocity (m/s).
103    pub fn resolve_mps(self, muzzle_velocity_mps: f64) -> f64 {
104        match self {
105            GasVelocityModel::Saami(t) => t.saami_gas_factor() * muzzle_velocity_mps,
106            GasVelocityModel::Factor(f) => f * muzzle_velocity_mps,
107            GasVelocityModel::Fixed(v) => v,
108        }
109    }
110}
111
112/// SI inputs (kg, m/s) to [`free_recoil`].
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct FreeRecoilInputs {
115    pub bullet_mass_kg: f64,
116    pub charge_mass_kg: f64,
117    pub muzzle_velocity_mps: f64,
118    /// Firearm weight/mass in kg, including all attachments (scope, suppressor, etc.),
119    /// per SAAMI's own definition of `M`.
120    pub firearm_mass_kg: f64,
121    /// Propellant gas velocity (m/s) -- see [`GasVelocityModel::resolve_mps`].
122    pub gas_velocity_mps: f64,
123}
124
125/// Free recoil result, SI units throughout.
126#[derive(Debug, Clone, Copy, PartialEq)]
127pub struct FreeRecoilResult {
128    pub recoil_velocity_mps: f64,
129    pub recoil_energy_j: f64,
130    /// Recoil impulse == the firearm's momentum magnitude == the ejecta's momentum
131    /// magnitude (Newton's third law), in kg*m/s (== N*s).
132    pub impulse_ns: f64,
133}
134
135/// SAAMI free-recoil momentum balance (see module docs):
136/// `firearm_mass * V_recoil = bullet_mass * V_muzzle + charge_mass * V_gas`,
137/// `FRE = 0.5 * firearm_mass * V_recoil^2`, `impulse = firearm_mass * V_recoil`.
138pub fn free_recoil(inputs: FreeRecoilInputs) -> Result<FreeRecoilResult, String> {
139    let FreeRecoilInputs {
140        bullet_mass_kg,
141        charge_mass_kg,
142        muzzle_velocity_mps,
143        firearm_mass_kg,
144        gas_velocity_mps,
145    } = inputs;
146
147    if !firearm_mass_kg.is_finite() || firearm_mass_kg <= 0.0 {
148        return Err("firearm weight must be a positive, finite number".to_string());
149    }
150    if !bullet_mass_kg.is_finite() || bullet_mass_kg <= 0.0 {
151        return Err("bullet weight must be a positive, finite number".to_string());
152    }
153    if !charge_mass_kg.is_finite() || charge_mass_kg < 0.0 {
154        return Err("charge weight must be a non-negative, finite number".to_string());
155    }
156    if !muzzle_velocity_mps.is_finite() || muzzle_velocity_mps <= 0.0 {
157        return Err("muzzle velocity must be a positive, finite number".to_string());
158    }
159    if !gas_velocity_mps.is_finite() || gas_velocity_mps < 0.0 {
160        return Err("gas velocity must be a non-negative, finite number".to_string());
161    }
162
163    let recoil_velocity_mps = (bullet_mass_kg * muzzle_velocity_mps
164        + charge_mass_kg * gas_velocity_mps)
165        / firearm_mass_kg;
166    let recoil_energy_j = 0.5 * firearm_mass_kg * recoil_velocity_mps * recoil_velocity_mps;
167    let impulse_ns = firearm_mass_kg * recoil_velocity_mps;
168
169    Ok(FreeRecoilResult {
170        recoil_velocity_mps,
171        recoil_energy_j,
172        impulse_ns,
173    })
174}
175
176/// Avoirdupois pound in kilograms, exact by definition (1 lb = 0.45359237 kg). Used to
177/// convert firearm weight (pounds, imperial units) to the SI mass [`free_recoil`] needs --
178/// the only pound-denominated input in this crate (bullet/charge weight use grains, via
179/// `constants::GRAINS_TO_KG`).
180pub const POUNDS_TO_KG: f64 = 0.45359237;
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::constants::GRAINS_TO_KG;
186
187    const FPS_TO_MPS: f64 = 0.3048;
188    const J_TO_FTLB: f64 = 0.737562;
189
190    /// SAAMI's own worked example (source PDF, page 3): a 7 lb average-length shotgun
191    /// firing a 12ga 2-3/4"-3-1/2" No. 4 shot load.
192    ///   WF  = 7 lb (firearm weight)
193    ///   WE  = 1.25 oz shot (546.9 gr) + 43 gr wads = 589.9 gr (ejecta weight)
194    ///   WPC = 33.4 gr (propellant charge weight)
195    ///   VE  = 1275 fps (ejecta/shot velocity)
196    ///   f   = 1.50 (average-length shotgun)
197    /// SAAMI's own (rounded) arithmetic: V = (589.9*1275 + 33.4*1275*1.5) / (7000*7)
198    ///   = (752122.5 + 63877.5) / 49000 = 816000/49000 = 16.6531 fps (rounded to 16.65 fps
199    ///   before squaring in the source document). NOTE the shown steps do not reproduce
200    ///   SAAMI's printed result: (7/64.34)*16.65^2 = 30.161 ft-lb, and even the unrounded
201    ///   16.6531 gives 30.172 -- not the 30.22 ft-lb the document states. 30.22 is quoted
202    ///   here as the source's own stated answer, not as something derivable from the
203    ///   intermediates it shows; the assertion below brackets both.
204    ///   ("or about 30 ft-lb due to the uncertainty of the exact shot charge weight and
205    ///   velocity" -- SAAMI's own words). Converting through exact SI (kg/m/s, no
206    ///   intermediate rounding) instead of SAAMI's g=32.17-and-round-16.65 shortcut gives
207    ///   16.653 fps / 30.17 ft-lb -- the same result within that acknowledged rounding
208    ///   noise, which is exactly the cross-check this test performs.
209    #[test]
210    fn saami_shotgun_worked_example() {
211        let wf_kg = 7.0 * POUNDS_TO_KG;
212        let we_kg = 589.9 * GRAINS_TO_KG;
213        let wpc_kg = 33.4 * GRAINS_TO_KG;
214        let ve_mps = 1275.0 * FPS_TO_MPS;
215        let gas_model = GasVelocityModel::Saami(FirearmType::ShotgunAverage);
216        let vpg_mps = gas_model.resolve_mps(ve_mps);
217
218        let result = free_recoil(FreeRecoilInputs {
219            bullet_mass_kg: we_kg,
220            charge_mass_kg: wpc_kg,
221            muzzle_velocity_mps: ve_mps,
222            firearm_mass_kg: wf_kg,
223            gas_velocity_mps: vpg_mps,
224        })
225        .expect("valid inputs");
226
227        let recoil_velocity_fps = result.recoil_velocity_mps / FPS_TO_MPS;
228        let recoil_energy_ftlb = result.recoil_energy_j * J_TO_FTLB;
229
230        assert!(
231            (recoil_velocity_fps - 16.653).abs() < 0.01,
232            "expected ~16.653 fps, got {recoil_velocity_fps}"
233        );
234        // SAAMI's own document lands on 30.22 ft-lb by rounding V to 16.65 fps before
235        // squaring; carrying full precision through gives ~30.17. Both are "about 30
236        // ft-lb" per the source's own caveat, so allow that documented rounding slack.
237        assert!(
238            (recoil_energy_ftlb - 30.17).abs() < 0.1,
239            "expected ~30.17-30.22 ft-lb, got {recoil_energy_ftlb}"
240        );
241    }
242
243    /// SAAMI's own per-type gas-velocity factors (source PDF, page 2).
244    #[test]
245    fn saami_gas_factors_by_firearm_type() {
246        assert_eq!(FirearmType::Rifle.saami_gas_factor(), 1.75);
247        assert_eq!(FirearmType::Pistol.saami_gas_factor(), 1.50);
248        assert_eq!(FirearmType::ShotgunAverage.saami_gas_factor(), 1.50);
249        assert_eq!(FirearmType::ShotgunLong.saami_gas_factor(), 1.25);
250    }
251
252    #[test]
253    fn gas_velocity_model_variants_resolve() {
254        let muzzle = 800.0;
255        assert_eq!(
256            GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(muzzle),
257            1.75 * muzzle
258        );
259        assert_eq!(GasVelocityModel::Factor(2.0).resolve_mps(muzzle), 2.0 * muzzle);
260        // Fixed is independent of the muzzle velocity passed in.
261        assert_eq!(GasVelocityModel::Fixed(1433.0).resolve_mps(muzzle), 1433.0);
262        assert_eq!(GasVelocityModel::Fixed(1433.0).resolve_mps(1.0), 1433.0);
263    }
264
265    /// Zero charge weight degenerates to a bullet-only momentum balance
266    /// (no propellant-gas contribution at all).
267    #[test]
268    fn zero_charge_weight_is_bullet_only_momentum() {
269        let result = free_recoil(FreeRecoilInputs {
270            bullet_mass_kg: 0.01,
271            charge_mass_kg: 0.0,
272            muzzle_velocity_mps: 800.0,
273            firearm_mass_kg: 4.0,
274            gas_velocity_mps: 1500.0, // must be ignored: charge mass is zero
275        })
276        .expect("valid inputs");
277        let expected_v = 0.01 * 800.0 / 4.0;
278        assert!((result.recoil_velocity_mps - expected_v).abs() < 1e-12);
279    }
280
281    /// Doubling the firearm mass alone must halve recoil energy scaled by velocity^2:
282    /// V halves, so E = 0.5*M*V^2 -> (2M)*(V/2)^2 = 0.5*M*V^2 -- i.e. energy also halves.
283    #[test]
284    fn heavier_firearm_reduces_recoil_energy() {
285        let gas_velocity_mps = GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(823.0);
286        let base = FreeRecoilInputs {
287            bullet_mass_kg: 0.011,
288            charge_mass_kg: 0.0028,
289            muzzle_velocity_mps: 823.0,
290            firearm_mass_kg: 3.0,
291            gas_velocity_mps,
292        };
293        let light = free_recoil(base).expect("valid inputs");
294        let heavy = free_recoil(FreeRecoilInputs {
295            firearm_mass_kg: 6.0,
296            ..base
297        })
298        .expect("valid inputs");
299        assert!(heavy.recoil_velocity_mps < light.recoil_velocity_mps);
300        assert!(heavy.recoil_energy_j < light.recoil_energy_j);
301        assert!(
302            (heavy.recoil_energy_j - light.recoil_energy_j / 2.0).abs() < 1e-9,
303            "doubling firearm mass should exactly halve recoil energy"
304        );
305    }
306
307    #[test]
308    fn impulse_matches_firearm_momentum() {
309        let firearm_mass_kg = 3.86;
310        let result = free_recoil(FreeRecoilInputs {
311            bullet_mass_kg: 0.0109,
312            charge_mass_kg: 0.0028,
313            muzzle_velocity_mps: 823.0,
314            firearm_mass_kg,
315            gas_velocity_mps: GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(823.0),
316        })
317        .expect("valid inputs");
318        let expected_impulse = firearm_mass_kg * result.recoil_velocity_mps;
319        assert!(
320            (result.impulse_ns - expected_impulse).abs() < 1e-9,
321            "impulse must equal firearm_mass * recoil_velocity"
322        );
323    }
324
325    #[test]
326    fn rejects_non_positive_firearm_weight() {
327        let base = FreeRecoilInputs {
328            bullet_mass_kg: 0.01,
329            charge_mass_kg: 0.002,
330            muzzle_velocity_mps: 800.0,
331            firearm_mass_kg: 0.0,
332            gas_velocity_mps: 1200.0,
333        };
334        assert!(free_recoil(base).is_err());
335        assert!(free_recoil(FreeRecoilInputs {
336            firearm_mass_kg: -1.0,
337            ..base
338        })
339        .is_err());
340    }
341
342    #[test]
343    fn rejects_non_positive_bullet_weight() {
344        assert!(free_recoil(FreeRecoilInputs {
345            bullet_mass_kg: 0.0,
346            charge_mass_kg: 0.002,
347            muzzle_velocity_mps: 800.0,
348            firearm_mass_kg: 4.0,
349            gas_velocity_mps: 1200.0,
350        })
351        .is_err());
352    }
353
354    #[test]
355    fn rejects_non_positive_muzzle_velocity() {
356        assert!(free_recoil(FreeRecoilInputs {
357            bullet_mass_kg: 0.01,
358            charge_mass_kg: 0.002,
359            muzzle_velocity_mps: 0.0,
360            firearm_mass_kg: 4.0,
361            gas_velocity_mps: 1200.0,
362        })
363        .is_err());
364    }
365
366    #[test]
367    fn rejects_non_finite_inputs() {
368        assert!(free_recoil(FreeRecoilInputs {
369            bullet_mass_kg: f64::NAN,
370            charge_mass_kg: 0.002,
371            muzzle_velocity_mps: 800.0,
372            firearm_mass_kg: 4.0,
373            gas_velocity_mps: 1200.0,
374        })
375        .is_err());
376        assert!(free_recoil(FreeRecoilInputs {
377            bullet_mass_kg: 0.01,
378            charge_mass_kg: 0.002,
379            muzzle_velocity_mps: f64::INFINITY,
380            firearm_mass_kg: 4.0,
381            gas_velocity_mps: 1200.0,
382        })
383        .is_err());
384    }
385
386    #[test]
387    fn accepts_zero_gas_velocity() {
388        // A degenerate but not invalid input (e.g. an airgun-style GasVelocityModel::Fixed(0.0)).
389        assert!(free_recoil(FreeRecoilInputs {
390            bullet_mass_kg: 0.01,
391            charge_mass_kg: 0.0,
392            muzzle_velocity_mps: 800.0,
393            firearm_mass_kg: 4.0,
394            gas_velocity_mps: 0.0,
395        })
396        .is_ok());
397    }
398}