#[derive(Debug, Clone, Copy)]
pub struct PfThreshold {
pub organization: &'static str,
pub class: &'static str,
pub min_pf: f64,
pub min_velocity_fps: Option<f64>,
pub max_velocity_fps: Option<f64>,
pub source: &'static str,
}
pub const PF_THRESHOLDS: &[PfThreshold] = &[
PfThreshold {
organization: "USPSA",
class: "Minor",
min_pf: 125.0,
min_velocity_fps: None,
max_velocity_fps: None,
source: "USPSA Competition Rules, March 2026, Sec. 5.6",
},
PfThreshold {
organization: "USPSA",
class: "Major",
min_pf: 165.0,
min_velocity_fps: None,
max_velocity_fps: None,
source: "USPSA Competition Rules, March 2026, Sec. 5.6",
},
PfThreshold {
organization: "IDPA",
class: "BUG",
min_pf: 95.0,
min_velocity_fps: None,
max_velocity_fps: None,
source: "2024 IDPA Rulebook, Sec. 8.3.4.5",
},
PfThreshold {
organization: "IDPA",
class: "Stock Revolver / CCP",
min_pf: 105.0,
min_velocity_fps: None,
max_velocity_fps: None,
source: "2024 IDPA Rulebook, Sec. 8.3.4.3",
},
PfThreshold {
organization: "IDPA",
class: "SSP / ESP / CO",
min_pf: 125.0,
min_velocity_fps: None,
max_velocity_fps: None,
source: "2024 IDPA Rulebook, Sec. 8.3.4.1",
},
PfThreshold {
organization: "IDPA",
class: "PCC",
min_pf: 135.0,
min_velocity_fps: None,
max_velocity_fps: None,
source: "2024 IDPA Rulebook, Sec. 8.3.4.6",
},
PfThreshold {
organization: "IDPA",
class: "Enhanced Revolver",
min_pf: 155.0,
min_velocity_fps: None,
max_velocity_fps: None,
source: "2024 IDPA Rulebook, Sec. 8.3.4.4",
},
PfThreshold {
organization: "IDPA",
class: "CDP",
min_pf: 165.0,
min_velocity_fps: None,
max_velocity_fps: None,
source: "2024 IDPA Rulebook, Sec. 8.3.4.2",
},
PfThreshold {
organization: "SASS",
class: "Smokeless - Pistol-Revolver",
min_pf: 60.0,
min_velocity_fps: Some(400.0),
max_velocity_fps: Some(1000.0),
source: "SASS Cowboy Action Shooting Shooter's Handbook, Version 28, Jan 2026",
},
PfThreshold {
organization: "SASS",
class: "Smokeless - Rifle",
min_pf: 60.0,
min_velocity_fps: Some(400.0),
max_velocity_fps: Some(1400.0),
source: "SASS Cowboy Action Shooting Shooter's Handbook, Version 28, Jan 2026",
},
];
pub fn power_factor(weight_gr: f64, velocity_fps: f64) -> f64 {
weight_gr * velocity_fps / 1000.0
}
pub fn scored_power_factor(weight_gr: f64, velocity_fps: f64) -> f64 {
power_factor(weight_gr, velocity_fps).floor()
}
#[derive(Debug, Clone, Copy)]
pub struct PfEvaluation {
pub organization: &'static str,
pub class: &'static str,
pub min_pf: f64,
pub pf_pass: bool,
pub min_velocity_fps: Option<f64>,
pub max_velocity_fps: Option<f64>,
pub velocity_pass: Option<bool>,
pub pass: bool,
}
pub fn evaluate_all(weight_gr: f64, velocity_fps: f64) -> Vec<PfEvaluation> {
let pf = scored_power_factor(weight_gr, velocity_fps);
PF_THRESHOLDS
.iter()
.map(|t| {
let pf_pass = pf >= t.min_pf;
let min_ok = t.min_velocity_fps.is_none_or(|m| velocity_fps >= m);
let max_ok = t.max_velocity_fps.is_none_or(|m| velocity_fps <= m);
let velocity_pass = if t.min_velocity_fps.is_some() || t.max_velocity_fps.is_some() {
Some(min_ok && max_ok)
} else {
None
};
PfEvaluation {
organization: t.organization,
class: t.class,
min_pf: t.min_pf,
pf_pass,
min_velocity_fps: t.min_velocity_fps,
max_velocity_fps: t.max_velocity_fps,
velocity_pass,
pass: pf_pass && velocity_pass.unwrap_or(true),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sass_worked_examples() {
assert!((power_factor(100.0, 600.0) - 60.0).abs() < 1e-9);
assert!((power_factor(77.0, 800.0) - 61.6).abs() < 1e-9);
assert!((power_factor(200.0, 400.0) - 80.0).abs() < 1e-9);
}
#[test]
fn uspsa_truncation_example() {
let raw = 124.9999_f64;
let weight_gr = 1000.0;
let velocity_fps = raw; assert!((power_factor(weight_gr, velocity_fps) - 124.9999).abs() < 1e-9);
assert_eq!(scored_power_factor(weight_gr, velocity_fps), 124.0);
}
#[test]
fn idpa_truncation_example() {
let raw = power_factor(230.1, 794.7);
assert!((raw - 182.86047).abs() < 1e-3);
assert_eq!(scored_power_factor(230.1, 794.7), 182.0);
}
#[test]
fn every_pf_threshold_boundary_both_sides() {
for t in PF_THRESHOLDS {
let at_threshold = evaluate_all(1000.0, t.min_pf);
let row_at = at_threshold
.iter()
.find(|e| e.organization == t.organization && e.class == t.class)
.expect("row present");
assert!(
row_at.pf_pass,
"{} {} at exactly its min_pf {} must PASS (>=, not >)",
t.organization, t.class, t.min_pf
);
let below_threshold = evaluate_all(1000.0, t.min_pf - 1.0);
let row_below = below_threshold
.iter()
.find(|e| e.organization == t.organization && e.class == t.class)
.expect("row present");
assert!(
!row_below.pf_pass,
"{} {} one PF point below min_pf {} must FAIL",
t.organization, t.class, t.min_pf
);
}
}
#[test]
fn sass_velocity_boundaries_both_sides() {
let weight_gr = 200.0; let at_min = evaluate_all(weight_gr, 400.0);
let below_min = evaluate_all(weight_gr, 399.0);
for class in ["Smokeless - Pistol-Revolver", "Smokeless - Rifle"] {
let row = at_min
.iter()
.find(|e| e.organization == "SASS" && e.class == class)
.unwrap();
assert!(row.velocity_pass == Some(true), "{class} at 400 fps must pass");
assert!(row.pass, "{class} at 400 fps must pass overall");
let row = below_min
.iter()
.find(|e| e.organization == "SASS" && e.class == class)
.unwrap();
assert_eq!(row.velocity_pass, Some(false), "{class} at 399 fps must fail");
assert!(!row.pass);
}
let pistol_weight = 65.0; let at_pistol_max = evaluate_all(pistol_weight, 1000.0);
let over_pistol_max = evaluate_all(pistol_weight, 1001.0);
let row = at_pistol_max
.iter()
.find(|e| e.organization == "SASS" && e.class == "Smokeless - Pistol-Revolver")
.unwrap();
assert_eq!(row.velocity_pass, Some(true));
let row = over_pistol_max
.iter()
.find(|e| e.organization == "SASS" && e.class == "Smokeless - Pistol-Revolver")
.unwrap();
assert_eq!(row.velocity_pass, Some(false));
let rifle_weight = 65.0;
let at_rifle_max = evaluate_all(rifle_weight, 1400.0);
let over_rifle_max = evaluate_all(rifle_weight, 1401.0);
let row = at_rifle_max
.iter()
.find(|e| e.organization == "SASS" && e.class == "Smokeless - Rifle")
.unwrap();
assert_eq!(row.velocity_pass, Some(true));
let row = over_rifle_max
.iter()
.find(|e| e.organization == "SASS" && e.class == "Smokeless - Rifle")
.unwrap();
assert_eq!(row.velocity_pass, Some(false));
let mixed = evaluate_all(rifle_weight, 1200.0);
let pistol_row = mixed
.iter()
.find(|e| e.organization == "SASS" && e.class == "Smokeless - Pistol-Revolver")
.unwrap();
let rifle_row = mixed
.iter()
.find(|e| e.organization == "SASS" && e.class == "Smokeless - Rifle")
.unwrap();
assert_eq!(pistol_row.velocity_pass, Some(false));
assert_eq!(rifle_row.velocity_pass, Some(true));
}
#[test]
fn non_sass_rows_have_no_velocity_gate() {
let rows = evaluate_all(1000.0, 200.0);
for row in rows.iter().filter(|r| r.organization != "SASS") {
assert_eq!(row.velocity_pass, None);
assert_eq!(row.pass, row.pf_pass);
}
}
#[test]
fn realistic_9mm_load_cross_check() {
let rows = evaluate_all(147.0, 900.0);
let uspsa_minor = rows
.iter()
.find(|e| e.organization == "USPSA" && e.class == "Minor")
.unwrap();
assert!(uspsa_minor.pass);
let uspsa_major = rows
.iter()
.find(|e| e.organization == "USPSA" && e.class == "Major")
.unwrap();
assert!(!uspsa_major.pass);
let idpa_ssp = rows
.iter()
.find(|e| e.organization == "IDPA" && e.class == "SSP / ESP / CO")
.unwrap();
assert!(idpa_ssp.pass);
let idpa_pcc = rows
.iter()
.find(|e| e.organization == "IDPA" && e.class == "PCC")
.unwrap();
assert!(!idpa_pcc.pass);
}
#[test]
fn table_has_expected_row_count_and_orgs() {
assert_eq!(PF_THRESHOLDS.len(), 10);
assert!(PF_THRESHOLDS.iter().any(|t| t.organization == "USPSA"));
assert!(PF_THRESHOLDS.iter().any(|t| t.organization == "IDPA"));
assert!(PF_THRESHOLDS.iter().any(|t| t.organization == "SASS"));
}
}