dsfb_robotics/platform.rs
1//! Robot operating context for envelope scaling and violation suppression.
2//!
3//! Residuals observed during commissioning, calibration, or planned
4//! maintenance are **not** violations — they are expected. The
5//! [`RobotContext`] encodes which operating regime the residual stream
6//! is in, and the [`RobotContext::admissibility_multiplier`] method
7//! returns the scaling factor DSFB applies to the envelope radius ρ.
8//!
9//! During commissioning and maintenance the multiplier is `+∞`, which
10//! makes envelope violations structurally impossible — the grammar FSM
11//! is forced to `Admissible` regardless of residual magnitude.
12
13/// The robot's current operating regime.
14///
15/// Set by the caller from whatever state source is authoritative for
16/// the deployment (ROS 2 `mode` topic, OPC UA `OperationalStatus`
17/// node, programmable-logic flag, manual operator switch). DSFB never
18/// transitions between contexts on its own — it is an observer.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub enum RobotContext {
22 /// Arm is being commissioned: dynamic parameter identification in
23 /// progress, friction tables being populated, excitation
24 /// trajectories being executed. Residuals are expected to be large
25 /// and non-stationary. Violations are suppressed.
26 ArmCommissioning,
27
28 /// Arm is in normal operation: trajectory tracking, force control,
29 /// teleoperation. Residuals are expected to be small and stationary
30 /// within the calibrated envelope. Full violation enforcement.
31 ArmOperating,
32
33 /// Legged platform is in a stance phase: at least one end effector
34 /// in contact with the ground, contact forces actively controlled
35 /// by the whole-body controller. Residuals include MPC force
36 /// tracking error and centroidal-momentum estimator discrepancy.
37 /// Full violation enforcement.
38 LeggedStance,
39
40 /// Legged platform is in a swing phase: at least one end effector
41 /// off the ground, swing-foot trajectory tracking active. Contact
42 /// residuals are not applicable; only joint-level kinematic
43 /// residuals are enforced. Full violation enforcement with a
44 /// relaxed envelope (swing-phase residuals are typically looser
45 /// than stance-phase).
46 LeggedSwing,
47
48 /// Planned maintenance: operator-initiated diagnostics or
49 /// mechanical service. Residuals may be very large as the robot is
50 /// deliberately moved through unusual configurations. Violations
51 /// are suppressed.
52 Maintenance,
53}
54
55impl RobotContext {
56 /// Returns `true` if violations are suppressed in this context.
57 ///
58 /// Commissioning and maintenance periods both produce residual
59 /// patterns that look like faults to a naive observer but are
60 /// expected by design. DSFB recognises these contexts and holds
61 /// the grammar FSM in `Admissible`.
62 #[inline]
63 #[must_use]
64 pub const fn is_suppressed(self) -> bool {
65 matches!(self, Self::ArmCommissioning | Self::Maintenance)
66 }
67
68 /// Multiplier applied to the envelope radius ρ in this context.
69 ///
70 /// - `ArmOperating`, `LeggedStance`: `1.0` (baseline envelope).
71 /// - `LeggedSwing`: `1.5` (swing residuals run wider than stance).
72 /// - `ArmCommissioning`, `Maintenance`: `f64::INFINITY` (no
73 /// violation possible — residuals are expected to be arbitrary).
74 ///
75 /// The choice of `1.5` for swing is intentionally modest; the
76 /// caller may override with a custom envelope per-phase if a
77 /// tighter bound is known from the controller.
78 #[inline]
79 #[must_use]
80 pub fn admissibility_multiplier(self) -> f64 {
81 match self {
82 Self::ArmOperating | Self::LeggedStance => 1.0,
83 Self::LeggedSwing => 1.5,
84 Self::ArmCommissioning | Self::Maintenance => f64::INFINITY,
85 }
86 }
87
88 /// Short stable string label, for logging and JSON emission.
89 #[inline]
90 #[must_use]
91 pub const fn label(self) -> &'static str {
92 match self {
93 Self::ArmCommissioning => "ArmCommissioning",
94 Self::ArmOperating => "ArmOperating",
95 Self::LeggedStance => "LeggedStance",
96 Self::LeggedSwing => "LeggedSwing",
97 Self::Maintenance => "Maintenance",
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn suppressed_contexts_are_commissioning_and_maintenance() {
108 assert!(RobotContext::ArmCommissioning.is_suppressed());
109 assert!(RobotContext::Maintenance.is_suppressed());
110 assert!(!RobotContext::ArmOperating.is_suppressed());
111 assert!(!RobotContext::LeggedStance.is_suppressed());
112 assert!(!RobotContext::LeggedSwing.is_suppressed());
113 }
114
115 #[test]
116 fn multiplier_matches_contract() {
117 assert_eq!(RobotContext::ArmOperating.admissibility_multiplier(), 1.0);
118 assert_eq!(RobotContext::LeggedStance.admissibility_multiplier(), 1.0);
119 assert_eq!(RobotContext::LeggedSwing.admissibility_multiplier(), 1.5);
120 assert!(RobotContext::ArmCommissioning.admissibility_multiplier().is_infinite());
121 assert!(RobotContext::Maintenance.admissibility_multiplier().is_infinite());
122 }
123
124 #[test]
125 fn labels_are_stable_and_unique() {
126 let labels = [
127 RobotContext::ArmCommissioning.label(),
128 RobotContext::ArmOperating.label(),
129 RobotContext::LeggedStance.label(),
130 RobotContext::LeggedSwing.label(),
131 RobotContext::Maintenance.label(),
132 ];
133 for (i, a) in labels.iter().enumerate() {
134 for (j, b) in labels.iter().enumerate() {
135 if i != j {
136 assert_ne!(a, b, "labels must be unique");
137 }
138 }
139 }
140 }
141}