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 + 63877.5) / 49000 = 816001/49000 = 16.653 fps (rounded to 16.65 fps
199 /// before squaring in the source document); FRE = (7/64.34)*16.65^2 = 30.22 ft-lb
200 /// ("or about 30 ft-lb due to the uncertainty of the exact shot charge weight and
201 /// velocity" -- SAAMI's own words). Converting through exact SI (kg/m/s, no
202 /// intermediate rounding) instead of SAAMI's g=32.17-and-round-16.65 shortcut gives
203 /// 16.653 fps / 30.17 ft-lb -- the same result within that acknowledged rounding
204 /// noise, which is exactly the cross-check this test performs.
205 #[test]
206 fn saami_shotgun_worked_example() {
207 let wf_kg = 7.0 * POUNDS_TO_KG;
208 let we_kg = 589.9 * GRAINS_TO_KG;
209 let wpc_kg = 33.4 * GRAINS_TO_KG;
210 let ve_mps = 1275.0 * FPS_TO_MPS;
211 let gas_model = GasVelocityModel::Saami(FirearmType::ShotgunAverage);
212 let vpg_mps = gas_model.resolve_mps(ve_mps);
213
214 let result = free_recoil(FreeRecoilInputs {
215 bullet_mass_kg: we_kg,
216 charge_mass_kg: wpc_kg,
217 muzzle_velocity_mps: ve_mps,
218 firearm_mass_kg: wf_kg,
219 gas_velocity_mps: vpg_mps,
220 })
221 .expect("valid inputs");
222
223 let recoil_velocity_fps = result.recoil_velocity_mps / FPS_TO_MPS;
224 let recoil_energy_ftlb = result.recoil_energy_j * J_TO_FTLB;
225
226 assert!(
227 (recoil_velocity_fps - 16.653).abs() < 0.01,
228 "expected ~16.653 fps, got {recoil_velocity_fps}"
229 );
230 // SAAMI's own document lands on 30.22 ft-lb by rounding V to 16.65 fps before
231 // squaring; carrying full precision through gives ~30.17. Both are "about 30
232 // ft-lb" per the source's own caveat, so allow that documented rounding slack.
233 assert!(
234 (recoil_energy_ftlb - 30.17).abs() < 0.1,
235 "expected ~30.17-30.22 ft-lb, got {recoil_energy_ftlb}"
236 );
237 }
238
239 /// SAAMI's own per-type gas-velocity factors (source PDF, page 2).
240 #[test]
241 fn saami_gas_factors_by_firearm_type() {
242 assert_eq!(FirearmType::Rifle.saami_gas_factor(), 1.75);
243 assert_eq!(FirearmType::Pistol.saami_gas_factor(), 1.50);
244 assert_eq!(FirearmType::ShotgunAverage.saami_gas_factor(), 1.50);
245 assert_eq!(FirearmType::ShotgunLong.saami_gas_factor(), 1.25);
246 }
247
248 #[test]
249 fn gas_velocity_model_variants_resolve() {
250 let muzzle = 800.0;
251 assert_eq!(
252 GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(muzzle),
253 1.75 * muzzle
254 );
255 assert_eq!(GasVelocityModel::Factor(2.0).resolve_mps(muzzle), 2.0 * muzzle);
256 // Fixed is independent of the muzzle velocity passed in.
257 assert_eq!(GasVelocityModel::Fixed(1433.0).resolve_mps(muzzle), 1433.0);
258 assert_eq!(GasVelocityModel::Fixed(1433.0).resolve_mps(1.0), 1433.0);
259 }
260
261 /// Zero charge weight degenerates to a bullet-only momentum balance
262 /// (no propellant-gas contribution at all).
263 #[test]
264 fn zero_charge_weight_is_bullet_only_momentum() {
265 let result = free_recoil(FreeRecoilInputs {
266 bullet_mass_kg: 0.01,
267 charge_mass_kg: 0.0,
268 muzzle_velocity_mps: 800.0,
269 firearm_mass_kg: 4.0,
270 gas_velocity_mps: 1500.0, // must be ignored: charge mass is zero
271 })
272 .expect("valid inputs");
273 let expected_v = 0.01 * 800.0 / 4.0;
274 assert!((result.recoil_velocity_mps - expected_v).abs() < 1e-12);
275 }
276
277 /// Doubling the firearm mass alone must halve recoil energy scaled by velocity^2:
278 /// V halves, so E = 0.5*M*V^2 -> (2M)*(V/2)^2 = 0.5*M*V^2 -- i.e. energy also halves.
279 #[test]
280 fn heavier_firearm_reduces_recoil_energy() {
281 let gas_velocity_mps = GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(823.0);
282 let base = FreeRecoilInputs {
283 bullet_mass_kg: 0.011,
284 charge_mass_kg: 0.0028,
285 muzzle_velocity_mps: 823.0,
286 firearm_mass_kg: 3.0,
287 gas_velocity_mps,
288 };
289 let light = free_recoil(base).expect("valid inputs");
290 let heavy = free_recoil(FreeRecoilInputs {
291 firearm_mass_kg: 6.0,
292 ..base
293 })
294 .expect("valid inputs");
295 assert!(heavy.recoil_velocity_mps < light.recoil_velocity_mps);
296 assert!(heavy.recoil_energy_j < light.recoil_energy_j);
297 assert!(
298 (heavy.recoil_energy_j - light.recoil_energy_j / 2.0).abs() < 1e-9,
299 "doubling firearm mass should exactly halve recoil energy"
300 );
301 }
302
303 #[test]
304 fn impulse_matches_firearm_momentum() {
305 let firearm_mass_kg = 3.86;
306 let result = free_recoil(FreeRecoilInputs {
307 bullet_mass_kg: 0.0109,
308 charge_mass_kg: 0.0028,
309 muzzle_velocity_mps: 823.0,
310 firearm_mass_kg,
311 gas_velocity_mps: GasVelocityModel::Saami(FirearmType::Rifle).resolve_mps(823.0),
312 })
313 .expect("valid inputs");
314 let expected_impulse = firearm_mass_kg * result.recoil_velocity_mps;
315 assert!(
316 (result.impulse_ns - expected_impulse).abs() < 1e-9,
317 "impulse must equal firearm_mass * recoil_velocity"
318 );
319 }
320
321 #[test]
322 fn rejects_non_positive_firearm_weight() {
323 let base = FreeRecoilInputs {
324 bullet_mass_kg: 0.01,
325 charge_mass_kg: 0.002,
326 muzzle_velocity_mps: 800.0,
327 firearm_mass_kg: 0.0,
328 gas_velocity_mps: 1200.0,
329 };
330 assert!(free_recoil(base).is_err());
331 assert!(free_recoil(FreeRecoilInputs {
332 firearm_mass_kg: -1.0,
333 ..base
334 })
335 .is_err());
336 }
337
338 #[test]
339 fn rejects_non_positive_bullet_weight() {
340 assert!(free_recoil(FreeRecoilInputs {
341 bullet_mass_kg: 0.0,
342 charge_mass_kg: 0.002,
343 muzzle_velocity_mps: 800.0,
344 firearm_mass_kg: 4.0,
345 gas_velocity_mps: 1200.0,
346 })
347 .is_err());
348 }
349
350 #[test]
351 fn rejects_non_positive_muzzle_velocity() {
352 assert!(free_recoil(FreeRecoilInputs {
353 bullet_mass_kg: 0.01,
354 charge_mass_kg: 0.002,
355 muzzle_velocity_mps: 0.0,
356 firearm_mass_kg: 4.0,
357 gas_velocity_mps: 1200.0,
358 })
359 .is_err());
360 }
361
362 #[test]
363 fn rejects_non_finite_inputs() {
364 assert!(free_recoil(FreeRecoilInputs {
365 bullet_mass_kg: f64::NAN,
366 charge_mass_kg: 0.002,
367 muzzle_velocity_mps: 800.0,
368 firearm_mass_kg: 4.0,
369 gas_velocity_mps: 1200.0,
370 })
371 .is_err());
372 assert!(free_recoil(FreeRecoilInputs {
373 bullet_mass_kg: 0.01,
374 charge_mass_kg: 0.002,
375 muzzle_velocity_mps: f64::INFINITY,
376 firearm_mass_kg: 4.0,
377 gas_velocity_mps: 1200.0,
378 })
379 .is_err());
380 }
381
382 #[test]
383 fn accepts_zero_gas_velocity() {
384 // A degenerate but not invalid input (e.g. an airgun-style GasVelocityModel::Fixed(0.0)).
385 assert!(free_recoil(FreeRecoilInputs {
386 bullet_mass_kg: 0.01,
387 charge_mass_kg: 0.0,
388 muzzle_velocity_mps: 800.0,
389 firearm_mass_kg: 4.0,
390 gas_velocity_mps: 0.0,
391 })
392 .is_ok());
393 }
394}