ballistics_engine/power_factor.rs
1//! Power-factor calculator and per-organization rulebook pass/fail (MBA-1372).
2//!
3//! Power factor (PF) is `bullet weight (grains) * velocity (fps) / 1000` -- a shared,
4//! organization-agnostic formula used by essentially every American practical/action
5//! shooting sport to gate ammunition into scoring categories. This module computes from
6//! bullet weight and velocity only; it does not run a trajectory.
7//!
8//! ## Truncation convention -- a real rules detail, not rounding noise
9//!
10//! USPSA's rulebook is explicit: "The final result will ignore all decimal places (e.g.
11//! for USPSA purposes, a result of 124.9999 is not 125)" (*USPSA Competition Rules*,
12//! March 2026 edition, Sec. 5.6, Rule 36). IDPA's rulebook states the same convention in
13//! different words: "...divide by 1000, and ignore numbers to the right of the decimal"
14//! (*2024 IDPA Rulebook*, Sec. 8.3.4.7). Both **truncate toward zero (floor)** -- neither
15//! rounds.
16//!
17//! [`scored_power_factor`] reports a single canonical PF as `floor(weight_gr * v_fps /
18//! 1000)`, matching those two rulebooks' own scored value exactly. SASS's handbook does
19//! not spell out a truncation rule for its own threshold check (its worked examples
20//! happen to land on exact values), so applying the same floor to the SASS row too is a
21//! deliberate, conservative choice: it never reports a marginal load as passing a SASS
22//! minimum that an official chronograph session could truncate below.
23//!
24//! ## Boundary semantics: `>=` ("meets or exceeds"), never strict `>`
25//!
26//! Every source rulebook uses "meets or exceeds" / "at least" / "not less than" language,
27//! never a strict "greater than":
28//! - USPSA: rule tables read "Minimum power factor for Major: 165" / "...for Minor:
29//! 125"; Rule 37: "...until the competitor **meets** the minimum power factor...".
30//! - IDPA, Sec. 8.3.5.1.1: "If two of the three rounds **meet or exceed** the required
31//! power factor, the ammunition is in compliance."
32//! - SASS *Shooter's Handbook*: "not less than a minimum power factor of 60" / "If the
33//! average velocity...**meets or exceeds** the calculated power factor of 60".
34//!
35//! A load landing exactly ON a threshold (PF == 125, velocity == 400, etc.) therefore
36//! **passes**. Every threshold in [`PF_THRESHOLDS`] is evaluated as `pf >= minimum` and
37//! every velocity cap as `v <= maximum` / `v >= minimum` (inclusive on both ends) --
38//! never a strict inequality. [`evaluate_all`]'s boundary tests exercise both sides of
39//! every published number in the table.
40//!
41//! ## Data sources (published rulebook facts -- no copyright exposure; cited with revision)
42//!
43//! - USPSA: *USPSA Competition Rules*, March 2026 edition, Sec. 5.6 (per-division rule
44//! tables: "Minimum power factor for Major: 165" / "...for Minor: 125").
45//! - IDPA: *2024 IDPA Rulebook*, Sec. 8.3.4 ("Ammunition minimum power factors"):
46//! 8.3.4.1 SSP/ESP/CO 125; 8.3.4.2 CDP 165; 8.3.4.3 Stock Revolver/CCP 105;
47//! 8.3.4.4 Enhanced Revolver 155; 8.3.4.5 BUG 95; 8.3.4.6 PCC 135.
48//! - SASS: *SASS Cowboy Action Shooting Shooter's Handbook*, Version 28, January 2026,
49//! "Power Factors" and Glossary entry "Power factor": minimum PF 60, minimum velocity
50//! 400 fps, maximum velocity 1000 fps (revolver/pistol) / 1400 fps (rifle).
51
52/// One organization/class row in the power-factor rulebook table.
53#[derive(Debug, Clone, Copy)]
54pub struct PfThreshold {
55 pub organization: &'static str,
56 pub class: &'static str,
57 pub min_pf: f64,
58 /// Minimum permitted velocity (fps), if the org's rule imposes one (SASS only).
59 pub min_velocity_fps: Option<f64>,
60 /// Maximum permitted velocity (fps), if the org's rule imposes one (SASS only).
61 pub max_velocity_fps: Option<f64>,
62 /// Rulebook citation, including edition/revision (kept alongside the numbers so a
63 /// future rulebook update is a one-line diff against a named source).
64 pub source: &'static str,
65}
66
67/// Rulebook power-factor/velocity thresholds. A DATA TABLE by design (per the brief) so
68/// updating a rulebook figure is a one-line edit here, not a code change elsewhere. See
69/// the module docs for citations.
70pub const PF_THRESHOLDS: &[PfThreshold] = &[
71 PfThreshold {
72 organization: "USPSA",
73 class: "Minor",
74 min_pf: 125.0,
75 min_velocity_fps: None,
76 max_velocity_fps: None,
77 source: "USPSA Competition Rules, March 2026, Sec. 5.6",
78 },
79 PfThreshold {
80 organization: "USPSA",
81 class: "Major",
82 min_pf: 165.0,
83 min_velocity_fps: None,
84 max_velocity_fps: None,
85 source: "USPSA Competition Rules, March 2026, Sec. 5.6",
86 },
87 PfThreshold {
88 organization: "IDPA",
89 class: "BUG",
90 min_pf: 95.0,
91 min_velocity_fps: None,
92 max_velocity_fps: None,
93 source: "2024 IDPA Rulebook, Sec. 8.3.4.5",
94 },
95 PfThreshold {
96 organization: "IDPA",
97 class: "Stock Revolver / CCP",
98 min_pf: 105.0,
99 min_velocity_fps: None,
100 max_velocity_fps: None,
101 source: "2024 IDPA Rulebook, Sec. 8.3.4.3",
102 },
103 PfThreshold {
104 organization: "IDPA",
105 class: "SSP / ESP / CO",
106 min_pf: 125.0,
107 min_velocity_fps: None,
108 max_velocity_fps: None,
109 source: "2024 IDPA Rulebook, Sec. 8.3.4.1",
110 },
111 PfThreshold {
112 organization: "IDPA",
113 class: "PCC",
114 min_pf: 135.0,
115 min_velocity_fps: None,
116 max_velocity_fps: None,
117 source: "2024 IDPA Rulebook, Sec. 8.3.4.6",
118 },
119 PfThreshold {
120 organization: "IDPA",
121 class: "Enhanced Revolver",
122 min_pf: 155.0,
123 min_velocity_fps: None,
124 max_velocity_fps: None,
125 source: "2024 IDPA Rulebook, Sec. 8.3.4.4",
126 },
127 PfThreshold {
128 organization: "IDPA",
129 class: "CDP",
130 min_pf: 165.0,
131 min_velocity_fps: None,
132 max_velocity_fps: None,
133 source: "2024 IDPA Rulebook, Sec. 8.3.4.2",
134 },
135 PfThreshold {
136 organization: "SASS",
137 class: "Smokeless - Pistol-Revolver",
138 min_pf: 60.0,
139 min_velocity_fps: Some(400.0),
140 max_velocity_fps: Some(1000.0),
141 source: "SASS Cowboy Action Shooting Shooter's Handbook, Version 28, Jan 2026",
142 },
143 PfThreshold {
144 organization: "SASS",
145 class: "Smokeless - Rifle",
146 min_pf: 60.0,
147 min_velocity_fps: Some(400.0),
148 max_velocity_fps: Some(1400.0),
149 source: "SASS Cowboy Action Shooting Shooter's Handbook, Version 28, Jan 2026",
150 },
151];
152
153/// Raw power factor: `weight_gr * velocity_fps / 1000`, no rounding/truncation.
154pub fn power_factor(weight_gr: f64, velocity_fps: f64) -> f64 {
155 weight_gr * velocity_fps / 1000.0
156}
157
158/// The truncated (floored) PF used for rulebook scoring/threshold comparisons. See the
159/// module docs' "Truncation convention" section for why floor (not round) is correct,
160/// and why it is applied uniformly across all three organizations.
161pub fn scored_power_factor(weight_gr: f64, velocity_fps: f64) -> f64 {
162 power_factor(weight_gr, velocity_fps).floor()
163}
164
165/// One rulebook row's evaluation against a computed load.
166#[derive(Debug, Clone, Copy)]
167pub struct PfEvaluation {
168 pub organization: &'static str,
169 pub class: &'static str,
170 pub min_pf: f64,
171 pub pf_pass: bool,
172 pub min_velocity_fps: Option<f64>,
173 pub max_velocity_fps: Option<f64>,
174 /// `None` when the row has no velocity constraint at all (only SASS rows do).
175 pub velocity_pass: Option<bool>,
176 /// Overall pass: `pf_pass && velocity_pass.unwrap_or(true)`.
177 pub pass: bool,
178}
179
180/// Evaluate a load (bullet weight in grains, velocity in fps) against every row in
181/// [`PF_THRESHOLDS`]. Boundary semantics: `>=`/`<=` throughout (see module docs).
182pub fn evaluate_all(weight_gr: f64, velocity_fps: f64) -> Vec<PfEvaluation> {
183 let pf = scored_power_factor(weight_gr, velocity_fps);
184 PF_THRESHOLDS
185 .iter()
186 .map(|t| {
187 let pf_pass = pf >= t.min_pf;
188 let min_ok = t.min_velocity_fps.is_none_or(|m| velocity_fps >= m);
189 let max_ok = t.max_velocity_fps.is_none_or(|m| velocity_fps <= m);
190 let velocity_pass = if t.min_velocity_fps.is_some() || t.max_velocity_fps.is_some() {
191 Some(min_ok && max_ok)
192 } else {
193 None
194 };
195 PfEvaluation {
196 organization: t.organization,
197 class: t.class,
198 min_pf: t.min_pf,
199 pf_pass,
200 min_velocity_fps: t.min_velocity_fps,
201 max_velocity_fps: t.max_velocity_fps,
202 velocity_pass,
203 pass: pf_pass && velocity_pass.unwrap_or(true),
204 }
205 })
206 .collect()
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 /// SASS's own worked examples (Shooter's Handbook, "Power Factors" section).
214 #[test]
215 fn sass_worked_examples() {
216 assert!((power_factor(100.0, 600.0) - 60.0).abs() < 1e-9);
217 assert!((power_factor(77.0, 800.0) - 61.6).abs() < 1e-9);
218 assert!((power_factor(200.0, 400.0) - 80.0).abs() < 1e-9);
219 }
220
221 /// USPSA's own worked truncation example (Sec. 5.6, Rule 36): a raw PF of 124.9999
222 /// truncates to 124, i.e. it is NOT 125.
223 #[test]
224 fn uspsa_truncation_example() {
225 // weight*velocity/1000 = 124.9999 exactly.
226 let raw = 124.9999_f64;
227 let weight_gr = 1000.0;
228 let velocity_fps = raw; // weight=1000 makes velocity numerically equal to the raw PF
229 assert!((power_factor(weight_gr, velocity_fps) - 124.9999).abs() < 1e-9);
230 assert_eq!(scored_power_factor(weight_gr, velocity_fps), 124.0);
231 }
232
233 /// IDPA's own worked truncation example (Sec. 8.3.4.7): 230.1gr @ 794.7fps ->
234 /// 182.86047, reported as 182 power factor (decimal digits ignored/truncated).
235 #[test]
236 fn idpa_truncation_example() {
237 let raw = power_factor(230.1, 794.7);
238 assert!((raw - 182.86047).abs() < 1e-3);
239 assert_eq!(scored_power_factor(230.1, 794.7), 182.0);
240 }
241
242 /// Every published threshold: exactly AT the boundary must PASS (>=, not >), and one
243 /// PF point below must FAIL. Exercises both sides of every number in the table.
244 #[test]
245 fn every_pf_threshold_boundary_both_sides() {
246 for t in PF_THRESHOLDS {
247 // A bullet weight of 1000gr makes velocity_fps numerically equal to the raw
248 // (and, since it's already a whole number, scored) power factor.
249 let at_threshold = evaluate_all(1000.0, t.min_pf);
250 let row_at = at_threshold
251 .iter()
252 .find(|e| e.organization == t.organization && e.class == t.class)
253 .expect("row present");
254 assert!(
255 row_at.pf_pass,
256 "{} {} at exactly its min_pf {} must PASS (>=, not >)",
257 t.organization, t.class, t.min_pf
258 );
259
260 let below_threshold = evaluate_all(1000.0, t.min_pf - 1.0);
261 let row_below = below_threshold
262 .iter()
263 .find(|e| e.organization == t.organization && e.class == t.class)
264 .expect("row present");
265 assert!(
266 !row_below.pf_pass,
267 "{} {} one PF point below min_pf {} must FAIL",
268 t.organization, t.class, t.min_pf
269 );
270 }
271 }
272
273 /// SASS velocity caps: exactly at the min/max boundary must PASS; one fps outside
274 /// must FAIL. A PF of 60 (100gr @ 600fps => 60.0 exactly) keeps the PF check passing
275 /// throughout so only the velocity boundary is under test.
276 #[test]
277 fn sass_velocity_boundaries_both_sides() {
278 // Minimum velocity (400 fps): exactly at it passes; one fps under fails.
279 let weight_gr = 200.0; // PF = weight*v/1000; chosen so PF stays >= 60 even at 399 fps.
280 let at_min = evaluate_all(weight_gr, 400.0);
281 let below_min = evaluate_all(weight_gr, 399.0);
282 for class in ["Smokeless - Pistol-Revolver", "Smokeless - Rifle"] {
283 let row = at_min
284 .iter()
285 .find(|e| e.organization == "SASS" && e.class == class)
286 .unwrap();
287 assert!(row.velocity_pass == Some(true), "{class} at 400 fps must pass");
288 assert!(row.pass, "{class} at 400 fps must pass overall");
289
290 let row = below_min
291 .iter()
292 .find(|e| e.organization == "SASS" && e.class == class)
293 .unwrap();
294 assert_eq!(row.velocity_pass, Some(false), "{class} at 399 fps must fail");
295 assert!(!row.pass);
296 }
297
298 // Maximum velocity: pistol/revolver cap 1000 fps, rifle cap 1400 fps.
299 let pistol_weight = 65.0; // 65gr @ 1000fps -> PF 65.0 (>=60); @1001fps -> 65.065 (still >=60)
300 let at_pistol_max = evaluate_all(pistol_weight, 1000.0);
301 let over_pistol_max = evaluate_all(pistol_weight, 1001.0);
302 let row = at_pistol_max
303 .iter()
304 .find(|e| e.organization == "SASS" && e.class == "Smokeless - Pistol-Revolver")
305 .unwrap();
306 assert_eq!(row.velocity_pass, Some(true));
307 let row = over_pistol_max
308 .iter()
309 .find(|e| e.organization == "SASS" && e.class == "Smokeless - Pistol-Revolver")
310 .unwrap();
311 assert_eq!(row.velocity_pass, Some(false));
312
313 let rifle_weight = 65.0;
314 let at_rifle_max = evaluate_all(rifle_weight, 1400.0);
315 let over_rifle_max = evaluate_all(rifle_weight, 1401.0);
316 let row = at_rifle_max
317 .iter()
318 .find(|e| e.organization == "SASS" && e.class == "Smokeless - Rifle")
319 .unwrap();
320 assert_eq!(row.velocity_pass, Some(true));
321 let row = over_rifle_max
322 .iter()
323 .find(|e| e.organization == "SASS" && e.class == "Smokeless - Rifle")
324 .unwrap();
325 assert_eq!(row.velocity_pass, Some(false));
326
327 // The pistol cap must not leak onto the rifle row and vice versa: 1200 fps is
328 // over the pistol/revolver max (1000) but under the rifle max (1400).
329 let mixed = evaluate_all(rifle_weight, 1200.0);
330 let pistol_row = mixed
331 .iter()
332 .find(|e| e.organization == "SASS" && e.class == "Smokeless - Pistol-Revolver")
333 .unwrap();
334 let rifle_row = mixed
335 .iter()
336 .find(|e| e.organization == "SASS" && e.class == "Smokeless - Rifle")
337 .unwrap();
338 assert_eq!(pistol_row.velocity_pass, Some(false));
339 assert_eq!(rifle_row.velocity_pass, Some(true));
340 }
341
342 /// Non-SASS rows carry no velocity constraint at all: `velocity_pass` is `None` and
343 /// never gates the overall pass/fail.
344 #[test]
345 fn non_sass_rows_have_no_velocity_gate() {
346 let rows = evaluate_all(1000.0, 200.0);
347 for row in rows.iter().filter(|r| r.organization != "SASS") {
348 assert_eq!(row.velocity_pass, None);
349 assert_eq!(row.pass, row.pf_pass);
350 }
351 }
352
353 /// A 9mm 147gr @ 900fps load: PF = 132.3 -> scored 132. Passes USPSA Minor (125),
354 /// IDPA SSP/ESP/CO (125) and PCC (135)? No -- 132 < 135, so PCC fails while
355 /// USPSA-Minor and SSP/ESP/CO pass. Cross-checks evaluate_all end to end.
356 #[test]
357 fn realistic_9mm_load_cross_check() {
358 let rows = evaluate_all(147.0, 900.0);
359 let uspsa_minor = rows
360 .iter()
361 .find(|e| e.organization == "USPSA" && e.class == "Minor")
362 .unwrap();
363 assert!(uspsa_minor.pass);
364 let uspsa_major = rows
365 .iter()
366 .find(|e| e.organization == "USPSA" && e.class == "Major")
367 .unwrap();
368 assert!(!uspsa_major.pass);
369 let idpa_ssp = rows
370 .iter()
371 .find(|e| e.organization == "IDPA" && e.class == "SSP / ESP / CO")
372 .unwrap();
373 assert!(idpa_ssp.pass);
374 let idpa_pcc = rows
375 .iter()
376 .find(|e| e.organization == "IDPA" && e.class == "PCC")
377 .unwrap();
378 assert!(!idpa_pcc.pass);
379 }
380
381 #[test]
382 fn table_has_expected_row_count_and_orgs() {
383 assert_eq!(PF_THRESHOLDS.len(), 10);
384 assert!(PF_THRESHOLDS.iter().any(|t| t.organization == "USPSA"));
385 assert!(PF_THRESHOLDS.iter().any(|t| t.organization == "IDPA"));
386 assert!(PF_THRESHOLDS.iter().any(|t| t.organization == "SASS"));
387 }
388}