use crate::models::command::{EndEffectorForce, EndEffectorPosition};
use crate::models::profile::ProximityZone;
use crate::models::verdict::CheckResult;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BodyRegionLimit {
pub region: &'static str,
pub max_quasi_static_n: f64,
pub max_transient_n: f64,
}
pub const BODY_REGION_LIMITS: &[BodyRegionLimit] = &[
BodyRegionLimit {
region: "skull_forehead",
max_quasi_static_n: 130.0,
max_transient_n: 130.0,
},
BodyRegionLimit {
region: "face",
max_quasi_static_n: 65.0,
max_transient_n: 65.0,
},
BodyRegionLimit {
region: "neck_side",
max_quasi_static_n: 150.0,
max_transient_n: 150.0,
},
BodyRegionLimit {
region: "chest",
max_quasi_static_n: 140.0,
max_transient_n: 140.0,
},
BodyRegionLimit {
region: "abdomen",
max_quasi_static_n: 110.0,
max_transient_n: 110.0,
},
BodyRegionLimit {
region: "hand_finger",
max_quasi_static_n: 140.0,
max_transient_n: 180.0,
},
BodyRegionLimit {
region: "upper_arm",
max_quasi_static_n: 150.0,
max_transient_n: 190.0,
},
BodyRegionLimit {
region: "lower_leg",
max_quasi_static_n: 130.0,
max_transient_n: 160.0,
},
];
pub const MOST_CONSERVATIVE_FORCE_N: f64 = 65.0;
pub fn limit_for_region(region: &str) -> Option<&'static BodyRegionLimit> {
BODY_REGION_LIMITS.iter().find(|l| l.region == region)
}
fn is_human_critical(zone: &ProximityZone) -> bool {
match zone {
ProximityZone::Sphere { name, .. } => name.to_ascii_lowercase().contains("human_critical"),
}
}
pub fn check_iso15066_force_limits(
ee_positions: &[EndEffectorPosition],
ee_forces: &[EndEffectorForce],
proximity_zones: &[ProximityZone],
override_body_region: Option<&str>,
) -> CheckResult {
let critical_zones: Vec<&ProximityZone> = proximity_zones
.iter()
.filter(|z| is_human_critical(z))
.collect();
if critical_zones.is_empty() || ee_positions.is_empty() || ee_forces.is_empty() {
return CheckResult {
name: "iso15066_force_limits".to_string(),
category: "physics".to_string(),
passed: true,
details: "no human-critical proximity zones active or no force data".to_string(),
};
}
let force_limit = match override_body_region {
Some(region) => match limit_for_region(region) {
Some(limit) => limit.max_quasi_static_n,
None => MOST_CONSERVATIVE_FORCE_N,
},
None => MOST_CONSERVATIVE_FORCE_N,
};
let mut violations: Vec<String> = Vec::new();
for ee_pos in ee_positions {
if !ee_pos.position[0].is_finite()
|| !ee_pos.position[1].is_finite()
|| !ee_pos.position[2].is_finite()
{
continue; }
let inside_critical = critical_zones.iter().any(|zone| match zone {
ProximityZone::Sphere { center, radius, .. } => {
point_in_sphere(&ee_pos.position, center, *radius)
}
});
if !inside_critical {
continue;
}
if let Some(force_entry) = ee_forces.iter().find(|f| f.name == ee_pos.name) {
if force_entry.force.iter().any(|f| !f.is_finite()) {
violations.push(format!(
"'{}': force contains NaN/Inf inside human-critical zone",
ee_pos.name
));
continue;
}
let norm = vector_norm(&force_entry.force);
if norm > force_limit {
let region_label = override_body_region.unwrap_or("face (default)");
violations.push(format!(
"'{}': force {norm:.1} N exceeds ISO/TS 15066 limit {force_limit:.1} N \
for body region '{region_label}' inside human-critical zone",
ee_pos.name
));
}
}
}
if violations.is_empty() {
let region_label = override_body_region.unwrap_or("face (default)");
CheckResult {
name: "iso15066_force_limits".to_string(),
category: "physics".to_string(),
passed: true,
details: format!(
"all forces within ISO/TS 15066 limits ({force_limit:.1} N, region: {region_label})"
),
}
} else {
CheckResult {
name: "iso15066_force_limits".to_string(),
category: "physics".to_string(),
passed: false,
details: violations.join("; "),
}
}
}
#[inline]
fn vector_norm(v: &[f64; 3]) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
#[inline]
fn point_in_sphere(point: &[f64; 3], center: &[f64; 3], radius: f64) -> bool {
let dx = point[0] - center[0];
let dy = point[1] - center[1];
let dz = point[2] - center[2];
dx * dx + dy * dy + dz * dz <= radius * radius
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::command::{EndEffectorForce, EndEffectorPosition};
use crate::models::profile::ProximityZone;
fn human_critical_zone(center: [f64; 3], radius: f64) -> ProximityZone {
ProximityZone::Sphere {
name: "human_critical".into(),
center,
radius,
velocity_scale: 0.1,
dynamic: true,
}
}
fn human_warning_zone(center: [f64; 3], radius: f64) -> ProximityZone {
ProximityZone::Sphere {
name: "human_warning".into(),
center,
radius,
velocity_scale: 0.5,
dynamic: true,
}
}
fn ee_pos(name: &str, pos: [f64; 3]) -> EndEffectorPosition {
EndEffectorPosition {
name: name.into(),
position: pos,
}
}
fn ee_force(name: &str, fx: f64, fy: f64, fz: f64) -> EndEffectorForce {
EndEffectorForce {
name: name.into(),
force: [fx, fy, fz],
torque: [0.0, 0.0, 0.0],
grasp_force: None,
}
}
#[test]
fn body_region_table_has_8_entries() {
assert_eq!(BODY_REGION_LIMITS.len(), 8);
}
#[test]
fn most_conservative_is_face() {
let min = BODY_REGION_LIMITS
.iter()
.map(|l| l.max_quasi_static_n)
.fold(f64::MAX, f64::min);
assert_eq!(min, 65.0);
assert_eq!(MOST_CONSERVATIVE_FORCE_N, 65.0);
}
#[test]
fn limit_for_region_known() {
let chest = limit_for_region("chest").unwrap();
assert_eq!(chest.max_quasi_static_n, 140.0);
assert_eq!(chest.max_transient_n, 140.0);
let hand = limit_for_region("hand_finger").unwrap();
assert_eq!(hand.max_quasi_static_n, 140.0);
assert_eq!(hand.max_transient_n, 180.0);
}
#[test]
fn limit_for_region_unknown_returns_none() {
assert!(limit_for_region("ankle").is_none());
}
#[test]
fn no_critical_zones_passes() {
let zones = vec![human_warning_zone([1.0, 0.0, 1.0], 1.0)];
let positions = vec![ee_pos("gripper", [1.0, 0.0, 1.0])];
let forces = vec![ee_force("gripper", 200.0, 0.0, 0.0)];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(result.passed);
}
#[test]
fn ee_outside_critical_zone_passes() {
let zones = vec![human_critical_zone([5.0, 0.0, 1.0], 0.5)];
let positions = vec![ee_pos("gripper", [0.0, 0.0, 1.0])]; let forces = vec![ee_force("gripper", 200.0, 0.0, 0.0)];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(result.passed);
}
#[test]
fn ee_inside_critical_zone_force_within_limit_passes() {
let zones = vec![human_critical_zone([1.0, 0.0, 1.0], 1.0)];
let positions = vec![ee_pos("gripper", [1.0, 0.0, 1.0])]; let forces = vec![ee_force("gripper", 30.0, 0.0, 0.0)];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(result.passed);
assert!(result.details.contains("65.0 N"));
}
#[test]
fn ee_inside_critical_zone_force_exceeds_limit_fails() {
let zones = vec![human_critical_zone([1.0, 0.0, 1.0], 1.0)];
let positions = vec![ee_pos("gripper", [1.0, 0.0, 1.0])]; let forces = vec![ee_force("gripper", 100.0, 0.0, 0.0)];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(!result.passed);
assert!(result.details.contains("ISO/TS 15066"));
assert!(result.details.contains("100.0 N"));
assert!(result.details.contains("65.0 N"));
assert!(result.details.contains("face (default)"));
}
#[test]
fn body_region_override_uses_specified_limit() {
let zones = vec![human_critical_zone([1.0, 0.0, 1.0], 1.0)];
let positions = vec![ee_pos("gripper", [1.0, 0.0, 1.0])];
let forces = vec![ee_force("gripper", 100.0, 0.0, 0.0)];
let result = check_iso15066_force_limits(&positions, &forces, &zones, Some("chest"));
assert!(result.passed);
assert!(result.details.contains("140.0 N"));
assert!(result.details.contains("chest"));
}
#[test]
fn body_region_override_unknown_falls_back_to_conservative() {
let zones = vec![human_critical_zone([1.0, 0.0, 1.0], 1.0)];
let positions = vec![ee_pos("gripper", [1.0, 0.0, 1.0])];
let forces = vec![ee_force("gripper", 100.0, 0.0, 0.0)];
let result = check_iso15066_force_limits(&positions, &forces, &zones, Some("ankle"));
assert!(!result.passed); }
#[test]
fn force_at_exact_iso_limit_passes() {
let zones = vec![human_critical_zone([0.0, 0.0, 0.0], 2.0)];
let positions = vec![ee_pos("gripper", [0.0, 0.0, 0.0])];
let forces = vec![ee_force("gripper", 65.0, 0.0, 0.0)];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(result.passed);
}
#[test]
fn multiple_ees_only_inside_one_checked() {
let zones = vec![human_critical_zone([0.0, 0.0, 0.0], 1.0)];
let positions = vec![
ee_pos("left_hand", [0.0, 0.0, 0.0]), ee_pos("right_hand", [5.0, 0.0, 0.0]), ];
let forces = vec![
ee_force("left_hand", 30.0, 0.0, 0.0), ee_force("right_hand", 200.0, 0.0, 0.0), ];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(result.passed); }
#[test]
fn ee_inside_zone_but_no_force_data_passes() {
let zones = vec![human_critical_zone([0.0, 0.0, 0.0], 1.0)];
let positions = vec![ee_pos("gripper", [0.0, 0.0, 0.0])];
let forces: Vec<EndEffectorForce> = vec![];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(result.passed);
}
#[test]
fn nan_force_inside_critical_zone_fails() {
let zones = vec![human_critical_zone([0.0, 0.0, 0.0], 1.0)];
let positions = vec![ee_pos("gripper", [0.0, 0.0, 0.0])];
let forces = vec![EndEffectorForce {
name: "gripper".into(),
force: [f64::NAN, 0.0, 0.0],
torque: [0.0, 0.0, 0.0],
grasp_force: None,
}];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(!result.passed);
assert!(result.details.contains("NaN"));
}
#[test]
fn diagonal_force_checked_correctly() {
let zones = vec![human_critical_zone([0.0, 0.0, 0.0], 2.0)];
let positions = vec![ee_pos("gripper", [0.0, 0.0, 0.0])];
let forces = vec![ee_force("gripper", 40.0, 40.0, 40.0)];
let result = check_iso15066_force_limits(&positions, &forces, &zones, None);
assert!(!result.passed);
}
#[test]
fn human_critical_zone_name_detection() {
assert!(is_human_critical(&ProximityZone::Sphere {
name: "human_critical".into(),
center: [0.0, 0.0, 0.0],
radius: 1.0,
velocity_scale: 0.1,
dynamic: true,
}));
assert!(is_human_critical(&ProximityZone::Sphere {
name: "Human_Critical_Zone_1".into(),
center: [0.0, 0.0, 0.0],
radius: 1.0,
velocity_scale: 0.1,
dynamic: true,
}));
assert!(!is_human_critical(&ProximityZone::Sphere {
name: "human_warning".into(),
center: [0.0, 0.0, 0.0],
radius: 1.0,
velocity_scale: 0.5,
dynamic: true,
}));
}
}