Skip to main content

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}