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}