phytrace_sdk/models/domains/
safety.rs

1//! Safety Domain - Safety state, e-stop, zones, and violations.
2//!
3//! Contains safety state, emergency stop information, zone monitoring, and safety violations.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use validator::Validate;
8
9use super::common::{Polygon2D, Position2D};
10use crate::models::enums::{
11    EStopType, SafetyState, SafetyZoneType, ViolationSeverity, ViolationType,
12};
13
14/// Safety domain containing safety state and violation information.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
16pub struct SafetyDomain {
17    // === Overall Safety State ===
18    /// Current safety state
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub safety_state: Option<SafetyState>,
21
22    /// Whether robot is safe to operate
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub is_safe: Option<bool>,
25
26    // === Emergency Stop ===
27    /// E-stop information
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub e_stop: Option<EStopInfo>,
30
31    // === Protective Stop ===
32    /// Whether protective stop is active
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub protective_stop_active: Option<bool>,
35
36    /// Protective stop reason
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub protective_stop_reason: Option<String>,
39
40    // === Safety Zones ===
41    /// Active safety zones
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub zones: Option<Vec<SafetyZone>>,
44
45    /// Current zone IDs the robot is in
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub current_zone_ids: Option<Vec<String>>,
48
49    // === Proximity ===
50    /// Proximity monitoring
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub proximity: Option<ProximityInfo>,
53
54    // === Violations ===
55    /// Active safety violations
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub violations: Option<Vec<SafetyViolation>>,
58
59    /// Violation count
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub violation_count: Option<u32>,
62
63    // === Collaborative Operation (ISO/TS 15066) ===
64    /// Collaborative operation settings
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub collaborative_operation: Option<CollaborativeOperation>,
67
68    // === Speed Limits ===
69    /// Current speed limit (m/s)
70    #[serde(skip_serializing_if = "Option::is_none")]
71    #[validate(range(min = 0.0))]
72    pub speed_limit_mps: Option<f64>,
73
74    /// Reason for speed limit
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub speed_limit_reason: Option<String>,
77
78    // === Safety System Status ===
79    /// Safety system operational
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub safety_system_ok: Option<bool>,
82
83    /// Safety PLC status
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub safety_plc_status: Option<String>,
86
87    /// Time since last safety check
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub last_safety_check: Option<DateTime<Utc>>,
90}
91
92/// Emergency stop information.
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct EStopInfo {
95    /// Whether e-stop is active
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub is_active: Option<bool>,
98
99    /// E-stop type
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub e_stop_type: Option<EStopType>,
102
103    /// E-stop source/location
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub source: Option<String>,
106
107    /// Reason for e-stop
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub reason: Option<String>,
110
111    /// Time e-stop was triggered
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub triggered_at: Option<DateTime<Utc>>,
114
115    /// Whether e-stop can be remotely reset
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub can_remote_reset: Option<bool>,
118}
119
120impl EStopInfo {
121    /// Create an active e-stop.
122    pub fn active(e_stop_type: EStopType, reason: impl Into<String>) -> Self {
123        Self {
124            is_active: Some(true),
125            e_stop_type: Some(e_stop_type),
126            reason: Some(reason.into()),
127            triggered_at: Some(Utc::now()),
128            ..Default::default()
129        }
130    }
131
132    /// Create an inactive e-stop status.
133    pub fn inactive() -> Self {
134        Self {
135            is_active: Some(false),
136            ..Default::default()
137        }
138    }
139}
140
141/// Safety zone definition.
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct SafetyZone {
144    /// Zone ID
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub zone_id: Option<String>,
147
148    /// Zone name
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub name: Option<String>,
151
152    /// Zone type
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub zone_type: Option<SafetyZoneType>,
155
156    /// Whether zone is active
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub is_active: Option<bool>,
159
160    /// Whether robot is inside this zone
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub robot_inside: Option<bool>,
163
164    /// Zone boundary polygon
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub boundary: Option<Polygon2D>,
167
168    /// Speed limit in this zone (m/s)
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub speed_limit_mps: Option<f64>,
171
172    /// Required action when entering
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub required_action: Option<String>,
175}
176
177/// Proximity monitoring information.
178#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
179pub struct ProximityInfo {
180    /// Closest object distance (meters)
181    #[serde(skip_serializing_if = "Option::is_none")]
182    #[validate(range(min = 0.0))]
183    pub closest_distance_m: Option<f64>,
184
185    /// Direction to closest object (degrees)
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub closest_bearing_deg: Option<f64>,
188
189    /// Closest human distance (meters)
190    #[serde(skip_serializing_if = "Option::is_none")]
191    #[validate(range(min = 0.0))]
192    pub closest_human_m: Option<f64>,
193
194    /// Number of humans detected nearby
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub humans_nearby: Option<u32>,
197
198    /// Warning distance threshold (meters)
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub warning_distance_m: Option<f64>,
201
202    /// Stop distance threshold (meters)
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub stop_distance_m: Option<f64>,
205
206    /// Whether warning threshold is breached
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub warning_active: Option<bool>,
209
210    /// Whether stop threshold is breached
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub stop_active: Option<bool>,
213}
214
215/// Safety violation record.
216#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217pub struct SafetyViolation {
218    /// Violation ID
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub violation_id: Option<String>,
221
222    /// Violation type
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub violation_type: Option<ViolationType>,
225
226    /// Violation severity
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub severity: Option<ViolationSeverity>,
229
230    /// Description
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub description: Option<String>,
233
234    /// Time violation occurred
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub timestamp: Option<DateTime<Utc>>,
237
238    /// Location where violation occurred
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub location: Option<Position2D>,
241
242    /// Zone ID if zone-related
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub zone_id: Option<String>,
245
246    /// Whether violation is still active
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub is_active: Option<bool>,
249
250    /// Resolution action taken
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub resolution: Option<String>,
253
254    /// Resolution timestamp
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub resolved_at: Option<DateTime<Utc>>,
257
258    /// Measured value that caused violation
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub measured_value: Option<f64>,
261
262    /// Threshold value that was exceeded
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub threshold_value: Option<f64>,
265}
266
267impl SafetyViolation {
268    /// Create a new violation.
269    pub fn new(
270        violation_type: ViolationType,
271        severity: ViolationSeverity,
272        description: impl Into<String>,
273    ) -> Self {
274        Self {
275            violation_id: Some(uuid::Uuid::new_v4().to_string()),
276            violation_type: Some(violation_type),
277            severity: Some(severity),
278            description: Some(description.into()),
279            timestamp: Some(Utc::now()),
280            is_active: Some(true),
281            ..Default::default()
282        }
283    }
284
285    /// Create a speed violation.
286    pub fn speed_exceeded(actual_mps: f64, limit_mps: f64) -> Self {
287        Self::new(
288            ViolationType::SpeedExceeded,
289            if actual_mps > limit_mps * 1.5 {
290                ViolationSeverity::High
291            } else {
292                ViolationSeverity::Medium
293            },
294            format!(
295                "Speed {:.2} m/s exceeded limit of {:.2} m/s",
296                actual_mps, limit_mps
297            ),
298        )
299        .with_values(actual_mps, limit_mps)
300    }
301
302    /// Create a proximity violation.
303    pub fn proximity_violation(distance_m: f64, threshold_m: f64) -> Self {
304        Self::new(
305            ViolationType::ProximityViolation,
306            ViolationSeverity::High,
307            format!(
308                "Distance {:.2}m below threshold {:.2}m",
309                distance_m, threshold_m
310            ),
311        )
312        .with_values(distance_m, threshold_m)
313    }
314
315    /// Add measured and threshold values.
316    pub fn with_values(mut self, measured: f64, threshold: f64) -> Self {
317        self.measured_value = Some(measured);
318        self.threshold_value = Some(threshold);
319        self
320    }
321
322    /// Add location.
323    pub fn with_location(mut self, x: f64, y: f64) -> Self {
324        self.location = Some(Position2D::new(x, y));
325        self
326    }
327}
328
329/// Collaborative operation settings (ISO/TS 15066).
330#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
331pub struct CollaborativeOperation {
332    /// Whether collaborative mode is enabled
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub enabled: Option<bool>,
335
336    /// Collaborative operation method
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub method: Option<CollaborativeMethod>,
339
340    /// Maximum allowed speed in collaborative mode (m/s)
341    #[serde(skip_serializing_if = "Option::is_none")]
342    #[validate(range(min = 0.0))]
343    pub max_speed_mps: Option<f64>,
344
345    /// Maximum allowed force (N)
346    #[serde(skip_serializing_if = "Option::is_none")]
347    #[validate(range(min = 0.0))]
348    pub max_force_n: Option<f64>,
349
350    /// Maximum allowed pressure (N/cm²)
351    #[serde(skip_serializing_if = "Option::is_none")]
352    #[validate(range(min = 0.0))]
353    pub max_pressure_n_cm2: Option<f64>,
354
355    /// Separation distance requirement (m)
356    #[serde(skip_serializing_if = "Option::is_none")]
357    #[validate(range(min = 0.0))]
358    pub separation_distance_m: Option<f64>,
359
360    /// Whether human is in collaborative space
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub human_in_space: Option<bool>,
363}
364
365/// Collaborative operation method (ISO/TS 15066).
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
367#[serde(rename_all = "snake_case")]
368pub enum CollaborativeMethod {
369    /// Safety-rated monitored stop
370    #[default]
371    SafetyRatedMonitoredStop,
372    /// Hand guiding
373    HandGuiding,
374    /// Speed and separation monitoring
375    SpeedAndSeparationMonitoring,
376    /// Power and force limiting
377    PowerAndForceLimiting,
378}
379
380impl SafetyDomain {
381    /// Create with safety state.
382    pub fn new(state: SafetyState) -> Self {
383        Self {
384            safety_state: Some(state),
385            is_safe: Some(state == SafetyState::Normal),
386            ..Default::default()
387        }
388    }
389
390    /// Create a normal safety state.
391    pub fn normal() -> Self {
392        Self::new(SafetyState::Normal)
393    }
394
395    /// Create an e-stopped safety state.
396    pub fn e_stopped(e_stop: EStopInfo) -> Self {
397        Self {
398            safety_state: Some(SafetyState::EmergencyStop),
399            is_safe: Some(false),
400            e_stop: Some(e_stop),
401            ..Default::default()
402        }
403    }
404
405    /// Builder to add violation.
406    pub fn with_violation(mut self, violation: SafetyViolation) -> Self {
407        let violations = self.violations.get_or_insert_with(Vec::new);
408        violations.push(violation);
409        self.violation_count = Some(violations.len() as u32);
410        self
411    }
412
413    /// Builder to add proximity info.
414    pub fn with_proximity(mut self, proximity: ProximityInfo) -> Self {
415        self.proximity = Some(proximity);
416        self
417    }
418
419    /// Builder to add speed limit.
420    pub fn with_speed_limit(mut self, limit_mps: f64, reason: impl Into<String>) -> Self {
421        self.speed_limit_mps = Some(limit_mps);
422        self.speed_limit_reason = Some(reason.into());
423        self
424    }
425
426    /// Builder to add collaborative operation.
427    pub fn with_collaborative(mut self, collab: CollaborativeOperation) -> Self {
428        self.collaborative_operation = Some(collab);
429        self
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_safety_normal() {
439        let safety = SafetyDomain::normal();
440        assert_eq!(safety.safety_state, Some(SafetyState::Normal));
441        assert_eq!(safety.is_safe, Some(true));
442    }
443
444    #[test]
445    fn test_safety_violation() {
446        let violation = SafetyViolation::speed_exceeded(2.5, 1.5);
447        assert_eq!(violation.violation_type, Some(ViolationType::SpeedExceeded));
448        assert_eq!(violation.measured_value, Some(2.5));
449        assert_eq!(violation.threshold_value, Some(1.5));
450    }
451
452    #[test]
453    fn test_e_stop() {
454        let e_stop = EStopInfo::active(EStopType::Hardware, "Button pressed");
455        let safety = SafetyDomain::e_stopped(e_stop);
456        assert_eq!(safety.safety_state, Some(SafetyState::EmergencyStop));
457        assert_eq!(safety.is_safe, Some(false));
458    }
459
460    // ==========================================================================
461    // SafetyDomain Additional Tests
462    // ==========================================================================
463
464    #[test]
465    fn test_safety_domain_default() {
466        let safety = SafetyDomain::default();
467        assert!(safety.safety_state.is_none());
468        assert!(safety.is_safe.is_none());
469        assert!(safety.e_stop.is_none());
470        assert!(safety.protective_stop_active.is_none());
471        assert!(safety.zones.is_none());
472        assert!(safety.proximity.is_none());
473        assert!(safety.violations.is_none());
474    }
475
476    #[test]
477    fn test_safety_domain_new() {
478        let safety = SafetyDomain::new(SafetyState::ReducedSpeed);
479        assert_eq!(safety.safety_state, Some(SafetyState::ReducedSpeed));
480        assert_eq!(safety.is_safe, Some(false)); // ReducedSpeed != Normal
481    }
482
483    #[test]
484    fn test_safety_domain_with_violation() {
485        let violation = SafetyViolation::speed_exceeded(3.0, 2.0);
486        let safety = SafetyDomain::normal().with_violation(violation);
487
488        assert!(safety.violations.is_some());
489        assert_eq!(safety.violation_count, Some(1));
490    }
491
492    #[test]
493    fn test_safety_domain_with_multiple_violations() {
494        let v1 = SafetyViolation::speed_exceeded(3.0, 2.0);
495        let v2 = SafetyViolation {
496            violation_type: Some(ViolationType::ZoneViolation),
497            ..Default::default()
498        };
499
500        let safety = SafetyDomain::normal().with_violation(v1).with_violation(v2);
501
502        assert_eq!(safety.violation_count, Some(2));
503    }
504
505    #[test]
506    fn test_safety_domain_with_proximity() {
507        let proximity = ProximityInfo {
508            closest_distance_m: Some(1.5),
509            closest_human_m: Some(2.0),
510            humans_nearby: Some(3),
511            ..Default::default()
512        };
513
514        let safety = SafetyDomain::normal().with_proximity(proximity);
515        assert!(safety.proximity.is_some());
516        assert_eq!(
517            safety.proximity.as_ref().unwrap().closest_human_m,
518            Some(2.0)
519        );
520    }
521
522    #[test]
523    fn test_safety_domain_with_speed_limit() {
524        let safety = SafetyDomain::normal().with_speed_limit(0.5, "Zone restriction");
525
526        assert_eq!(safety.speed_limit_mps, Some(0.5));
527        assert_eq!(
528            safety.speed_limit_reason,
529            Some("Zone restriction".to_string())
530        );
531    }
532
533    #[test]
534    fn test_safety_domain_with_collaborative() {
535        let collab = CollaborativeOperation {
536            enabled: Some(true),
537            method: Some(CollaborativeMethod::PowerAndForceLimiting),
538            ..Default::default()
539        };
540
541        let safety = SafetyDomain::normal().with_collaborative(collab);
542        assert!(safety.collaborative_operation.is_some());
543    }
544
545    #[test]
546    fn test_safety_domain_chained_builders() {
547        let safety = SafetyDomain::normal()
548            .with_violation(SafetyViolation::speed_exceeded(2.0, 1.0))
549            .with_proximity(ProximityInfo::default())
550            .with_speed_limit(1.0, "Safety zone")
551            .with_collaborative(CollaborativeOperation::default());
552
553        assert!(safety.violations.is_some());
554        assert!(safety.proximity.is_some());
555        assert!(safety.speed_limit_mps.is_some());
556        assert!(safety.collaborative_operation.is_some());
557    }
558
559    // ==========================================================================
560    // EStopInfo Tests
561    // ==========================================================================
562
563    #[test]
564    fn test_estop_info_active() {
565        let estop = EStopInfo::active(EStopType::Software, "Safety violation");
566
567        assert_eq!(estop.is_active, Some(true));
568        assert_eq!(estop.e_stop_type, Some(EStopType::Software));
569        assert_eq!(estop.reason, Some("Safety violation".to_string()));
570        assert!(estop.triggered_at.is_some());
571    }
572
573    #[test]
574    fn test_estop_info_inactive() {
575        let estop = EStopInfo::inactive();
576        assert_eq!(estop.is_active, Some(false));
577    }
578
579    #[test]
580    fn test_estop_info_default() {
581        let estop = EStopInfo::default();
582        assert!(estop.is_active.is_none());
583        assert!(estop.e_stop_type.is_none());
584        assert!(estop.source.is_none());
585        assert!(estop.reason.is_none());
586        assert!(estop.triggered_at.is_none());
587        assert!(estop.can_remote_reset.is_none());
588    }
589
590    #[test]
591    fn test_estop_info_full() {
592        let estop = EStopInfo {
593            is_active: Some(true),
594            e_stop_type: Some(EStopType::Remote),
595            source: Some("Fleet Manager".to_string()),
596            reason: Some("Maintenance required".to_string()),
597            triggered_at: Some(Utc::now()),
598            can_remote_reset: Some(true),
599        };
600
601        assert_eq!(estop.source, Some("Fleet Manager".to_string()));
602        assert_eq!(estop.can_remote_reset, Some(true));
603    }
604
605    // ==========================================================================
606    // SafetyZone Tests
607    // ==========================================================================
608
609    #[test]
610    fn test_safety_zone_default() {
611        let zone = SafetyZone::default();
612        assert!(zone.zone_id.is_none());
613        assert!(zone.name.is_none());
614        assert!(zone.zone_type.is_none());
615        assert!(zone.is_active.is_none());
616        assert!(zone.robot_inside.is_none());
617        assert!(zone.boundary.is_none());
618        assert!(zone.speed_limit_mps.is_none());
619        assert!(zone.required_action.is_none());
620    }
621
622    #[test]
623    fn test_safety_zone_full() {
624        let zone = SafetyZone {
625            zone_id: Some("zone-001".to_string()),
626            name: Some("Restricted Area A".to_string()),
627            zone_type: Some(SafetyZoneType::Restricted),
628            is_active: Some(true),
629            robot_inside: Some(false),
630            boundary: Some(Polygon2D {
631                vertices: vec![],
632                closed: true,
633            }),
634            speed_limit_mps: Some(0.5),
635            required_action: Some("Slow down".to_string()),
636        };
637
638        assert_eq!(zone.zone_type, Some(SafetyZoneType::Restricted));
639        assert_eq!(zone.speed_limit_mps, Some(0.5));
640    }
641
642    // ==========================================================================
643    // ProximityInfo Tests
644    // ==========================================================================
645
646    #[test]
647    fn test_proximity_info_default() {
648        let prox = ProximityInfo::default();
649        assert!(prox.closest_distance_m.is_none());
650        assert!(prox.closest_bearing_deg.is_none());
651        assert!(prox.closest_human_m.is_none());
652        assert!(prox.humans_nearby.is_none());
653        assert!(prox.warning_distance_m.is_none());
654    }
655
656    #[test]
657    fn test_proximity_info_full() {
658        let prox = ProximityInfo {
659            closest_distance_m: Some(0.8),
660            closest_bearing_deg: Some(45.0),
661            closest_human_m: Some(1.2),
662            humans_nearby: Some(2),
663            warning_distance_m: Some(2.0),
664            stop_distance_m: Some(0.5),
665            warning_active: Some(true),
666            stop_active: Some(false),
667        };
668
669        assert_eq!(prox.closest_distance_m, Some(0.8));
670        assert_eq!(prox.humans_nearby, Some(2));
671    }
672
673    // ==========================================================================
674    // SafetyViolation Tests
675    // ==========================================================================
676
677    #[test]
678    fn test_safety_violation_speed_exceeded() {
679        // 4.0 > 2.0 * 1.5 (3.0) so this is High severity
680        let viol = SafetyViolation::speed_exceeded(4.0, 2.0);
681
682        assert!(viol.violation_id.is_some());
683        assert_eq!(viol.violation_type, Some(ViolationType::SpeedExceeded));
684        assert_eq!(viol.severity, Some(ViolationSeverity::High));
685        assert_eq!(viol.measured_value, Some(4.0));
686        assert_eq!(viol.threshold_value, Some(2.0));
687        assert!(viol.timestamp.is_some());
688    }
689
690    #[test]
691    fn test_safety_violation_default() {
692        let viol = SafetyViolation::default();
693        assert!(viol.violation_id.is_none());
694        assert!(viol.violation_type.is_none());
695        assert!(viol.severity.is_none());
696        assert!(viol.description.is_none());
697        assert!(viol.measured_value.is_none());
698        assert!(viol.threshold_value.is_none());
699        assert!(viol.timestamp.is_none());
700        assert!(viol.is_active.is_none());
701    }
702
703    // ==========================================================================
704    // CollaborativeOperation Tests
705    // ==========================================================================
706
707    #[test]
708    fn test_collaborative_operation_default() {
709        let collab = CollaborativeOperation::default();
710        assert!(collab.enabled.is_none());
711        assert!(collab.method.is_none());
712    }
713
714    #[test]
715    fn test_collaborative_operation_full() {
716        let collab = CollaborativeOperation {
717            enabled: Some(true),
718            method: Some(CollaborativeMethod::PowerAndForceLimiting),
719            max_speed_mps: Some(0.25),
720            max_force_n: Some(150.0),
721            max_pressure_n_cm2: Some(10.0),
722            separation_distance_m: Some(0.5),
723            human_in_space: Some(true),
724        };
725
726        assert_eq!(collab.max_speed_mps, Some(0.25));
727        assert_eq!(collab.human_in_space, Some(true));
728    }
729
730    // ==========================================================================
731    // Serialization Roundtrip Tests
732    // ==========================================================================
733
734    #[test]
735    fn test_safety_domain_serialization_roundtrip() {
736        let safety = SafetyDomain::normal()
737            .with_violation(SafetyViolation::speed_exceeded(2.5, 1.5))
738            .with_speed_limit(1.0, "Safety zone");
739
740        let json = serde_json::to_string(&safety).unwrap();
741        let deserialized: SafetyDomain = serde_json::from_str(&json).unwrap();
742
743        assert_eq!(deserialized.safety_state, Some(SafetyState::Normal));
744        assert!(deserialized.violations.is_some());
745        assert_eq!(deserialized.speed_limit_mps, Some(1.0));
746    }
747}