Skip to main content

peat_schema/validation/
sensor.rs

1//! Sensor validators
2//!
3//! Validates SensorSpec and SensorStateUpdate messages for Peat Protocol.
4
5use super::{ValidationError, ValidationResult};
6use crate::sensor::v1::{
7    FieldOfView, GimbalLimits, GimbalState, SensorMountType, SensorOrientation, SensorSpec,
8    SensorStateUpdate, SensorStatus,
9};
10
11/// Validate sensor orientation values
12///
13/// Validates:
14/// - bearing_offset_deg is in range [0, 360)
15/// - elevation_offset_deg is in range [-90, 90]
16/// - roll_offset_deg is in range [-180, 180]
17pub fn validate_sensor_orientation(orientation: &SensorOrientation) -> ValidationResult<()> {
18    // Bearing should be [0, 360)
19    if orientation.bearing_offset_deg < 0.0 || orientation.bearing_offset_deg >= 360.0 {
20        return Err(ValidationError::InvalidValue(format!(
21            "bearing_offset_deg {} must be in range [0, 360)",
22            orientation.bearing_offset_deg
23        )));
24    }
25
26    // Elevation should be [-90, 90]
27    if orientation.elevation_offset_deg < -90.0 || orientation.elevation_offset_deg > 90.0 {
28        return Err(ValidationError::InvalidValue(format!(
29            "elevation_offset_deg {} must be in range [-90, 90]",
30            orientation.elevation_offset_deg
31        )));
32    }
33
34    // Roll should be [-180, 180]
35    if orientation.roll_offset_deg < -180.0 || orientation.roll_offset_deg > 180.0 {
36        return Err(ValidationError::InvalidValue(format!(
37            "roll_offset_deg {} must be in range [-180, 180]",
38            orientation.roll_offset_deg
39        )));
40    }
41
42    Ok(())
43}
44
45/// Validate field of view values
46///
47/// Validates:
48/// - horizontal_deg is positive and reasonable (< 360)
49/// - vertical_deg is positive and reasonable (< 180)
50/// - max_range_m is non-negative if specified
51pub fn validate_field_of_view(fov: &FieldOfView) -> ValidationResult<()> {
52    // Horizontal FOV must be positive and reasonable
53    if fov.horizontal_deg <= 0.0 || fov.horizontal_deg >= 360.0 {
54        return Err(ValidationError::InvalidValue(format!(
55            "horizontal_deg {} must be in range (0, 360)",
56            fov.horizontal_deg
57        )));
58    }
59
60    // Vertical FOV must be positive and reasonable
61    if fov.vertical_deg <= 0.0 || fov.vertical_deg >= 180.0 {
62        return Err(ValidationError::InvalidValue(format!(
63            "vertical_deg {} must be in range (0, 180)",
64            fov.vertical_deg
65        )));
66    }
67
68    // Max range must be non-negative
69    if fov.max_range_m < 0.0 {
70        return Err(ValidationError::InvalidValue(
71            "max_range_m must be non-negative".to_string(),
72        ));
73    }
74
75    Ok(())
76}
77
78/// Validate gimbal limits
79///
80/// Validates:
81/// - pan_min <= pan_max
82/// - tilt_min <= tilt_max
83/// - zoom_min <= zoom_max
84/// - zoom values are positive
85pub fn validate_gimbal_limits(limits: &GimbalLimits) -> ValidationResult<()> {
86    if limits.pan_min_deg > limits.pan_max_deg {
87        return Err(ValidationError::ConstraintViolation(
88            "pan_min_deg must be <= pan_max_deg".to_string(),
89        ));
90    }
91
92    if limits.tilt_min_deg > limits.tilt_max_deg {
93        return Err(ValidationError::ConstraintViolation(
94            "tilt_min_deg must be <= tilt_max_deg".to_string(),
95        ));
96    }
97
98    if limits.roll_min_deg > limits.roll_max_deg {
99        return Err(ValidationError::ConstraintViolation(
100            "roll_min_deg must be <= roll_max_deg".to_string(),
101        ));
102    }
103
104    if limits.zoom_min <= 0.0 {
105        return Err(ValidationError::InvalidValue(
106            "zoom_min must be positive".to_string(),
107        ));
108    }
109
110    if limits.zoom_max < limits.zoom_min {
111        return Err(ValidationError::ConstraintViolation(
112            "zoom_max must be >= zoom_min".to_string(),
113        ));
114    }
115
116    Ok(())
117}
118
119/// Validate gimbal state against limits
120///
121/// Validates:
122/// - pan_deg is within limits
123/// - tilt_deg is within limits
124/// - zoom is within limits
125pub fn validate_gimbal_state(
126    state: &GimbalState,
127    limits: Option<&GimbalLimits>,
128) -> ValidationResult<()> {
129    // Zoom must be positive
130    if state.zoom <= 0.0 {
131        return Err(ValidationError::InvalidValue(
132            "zoom must be positive".to_string(),
133        ));
134    }
135
136    // If limits are provided, validate state is within them
137    if let Some(limits) = limits {
138        if state.pan_deg < limits.pan_min_deg || state.pan_deg > limits.pan_max_deg {
139            return Err(ValidationError::ConstraintViolation(format!(
140                "pan_deg {} must be within limits [{}, {}]",
141                state.pan_deg, limits.pan_min_deg, limits.pan_max_deg
142            )));
143        }
144
145        if state.tilt_deg < limits.tilt_min_deg || state.tilt_deg > limits.tilt_max_deg {
146            return Err(ValidationError::ConstraintViolation(format!(
147                "tilt_deg {} must be within limits [{}, {}]",
148                state.tilt_deg, limits.tilt_min_deg, limits.tilt_max_deg
149            )));
150        }
151
152        if state.zoom < limits.zoom_min || state.zoom > limits.zoom_max {
153            return Err(ValidationError::ConstraintViolation(format!(
154                "zoom {} must be within limits [{}, {}]",
155                state.zoom, limits.zoom_min, limits.zoom_max
156            )));
157        }
158    }
159
160    Ok(())
161}
162
163/// Validate a complete sensor specification
164///
165/// Validates:
166/// - sensor_id is present
167/// - name is present
168/// - mount_type is specified
169/// - base_orientation is valid
170/// - field_of_view is present and valid
171/// - For non-fixed mounts: gimbal_limits should be present
172/// - For fixed mounts: gimbal_limits and current_state should be absent
173pub fn validate_sensor_spec(spec: &SensorSpec) -> ValidationResult<()> {
174    // Check required fields
175    if spec.sensor_id.is_empty() {
176        return Err(ValidationError::MissingField("sensor_id".to_string()));
177    }
178
179    if spec.name.is_empty() {
180        return Err(ValidationError::MissingField("name".to_string()));
181    }
182
183    // Mount type must be specified
184    if spec.mount_type == SensorMountType::Unspecified as i32 {
185        return Err(ValidationError::InvalidValue(
186            "mount_type must be specified".to_string(),
187        ));
188    }
189
190    // Validate base orientation if present
191    if let Some(ref orientation) = spec.base_orientation {
192        validate_sensor_orientation(orientation)?;
193    }
194
195    // Field of view is required
196    let fov = spec
197        .field_of_view
198        .as_ref()
199        .ok_or_else(|| ValidationError::MissingField("field_of_view".to_string()))?;
200    validate_field_of_view(fov)?;
201
202    // For articulated mounts, gimbal_limits should be present
203    let is_fixed = spec.mount_type == SensorMountType::Fixed as i32;
204
205    if !is_fixed {
206        // PTZ, Gimbal, or Turret should have limits
207        if spec.gimbal_limits.is_none() {
208            return Err(ValidationError::MissingField(
209                "gimbal_limits (required for non-fixed mount types)".to_string(),
210            ));
211        }
212
213        // Validate gimbal limits
214        if let Some(ref limits) = spec.gimbal_limits {
215            validate_gimbal_limits(limits)?;
216        }
217
218        // Validate current state if present
219        if let Some(ref state) = spec.current_state {
220            validate_gimbal_state(state, spec.gimbal_limits.as_ref())?;
221        }
222    }
223
224    // Resolution should be positive if specified
225    if spec.resolution_width > 0 && spec.resolution_height == 0 {
226        return Err(ValidationError::InvalidValue(
227            "resolution_height must be positive when resolution_width is set".to_string(),
228        ));
229    }
230
231    if spec.resolution_height > 0 && spec.resolution_width == 0 {
232        return Err(ValidationError::InvalidValue(
233            "resolution_width must be positive when resolution_height is set".to_string(),
234        ));
235    }
236
237    // Frame rate should be non-negative
238    if spec.frame_rate_fps < 0.0 {
239        return Err(ValidationError::InvalidValue(
240            "frame_rate_fps must be non-negative".to_string(),
241        ));
242    }
243
244    Ok(())
245}
246
247/// Validate a sensor state update message
248///
249/// Validates:
250/// - platform_id is present
251/// - sensor spec is valid
252/// - status is specified
253/// - timestamp is present
254pub fn validate_sensor_state_update(update: &SensorStateUpdate) -> ValidationResult<()> {
255    if update.platform_id.is_empty() {
256        return Err(ValidationError::MissingField("platform_id".to_string()));
257    }
258
259    // Sensor spec is required
260    let sensor = update
261        .sensor
262        .as_ref()
263        .ok_or_else(|| ValidationError::MissingField("sensor".to_string()))?;
264    validate_sensor_spec(sensor)?;
265
266    // Status must be specified
267    if update.status == SensorStatus::Unspecified as i32 {
268        return Err(ValidationError::InvalidValue(
269            "status must be specified".to_string(),
270        ));
271    }
272
273    // Timestamp is required
274    if update.timestamp.is_none() {
275        return Err(ValidationError::MissingField("timestamp".to_string()));
276    }
277
278    Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::common::v1::Timestamp;
285    use crate::sensor::v1::SensorModality;
286
287    fn valid_fixed_sensor() -> SensorSpec {
288        SensorSpec {
289            sensor_id: "eo-main".to_string(),
290            name: "Main EO Camera".to_string(),
291            mount_type: SensorMountType::Fixed as i32,
292            base_orientation: Some(SensorOrientation {
293                bearing_offset_deg: 0.0,   // Forward
294                elevation_offset_deg: 0.0, // Level
295                roll_offset_deg: 0.0,
296            }),
297            field_of_view: Some(FieldOfView {
298                horizontal_deg: 62.0,
299                vertical_deg: 48.0,
300                diagonal_deg: 0.0,
301                max_range_m: 500.0,
302            }),
303            modality: SensorModality::Eo as i32,
304            resolution_width: 1920,
305            resolution_height: 1080,
306            frame_rate_fps: 30.0,
307            gimbal_limits: None, // Fixed mount - no gimbal
308            current_state: None,
309            updated_at: None,
310        }
311    }
312
313    fn valid_ptz_sensor() -> SensorSpec {
314        SensorSpec {
315            sensor_id: "ptz-tower".to_string(),
316            name: "Tower PTZ Camera".to_string(),
317            mount_type: SensorMountType::Ptz as i32,
318            base_orientation: Some(SensorOrientation {
319                bearing_offset_deg: 0.0,
320                elevation_offset_deg: 0.0,
321                roll_offset_deg: 0.0,
322            }),
323            field_of_view: Some(FieldOfView {
324                horizontal_deg: 45.0,
325                vertical_deg: 35.0,
326                diagonal_deg: 0.0,
327                max_range_m: 1000.0,
328            }),
329            modality: SensorModality::Eo as i32,
330            resolution_width: 3840,
331            resolution_height: 2160,
332            frame_rate_fps: 30.0,
333            gimbal_limits: Some(GimbalLimits {
334                pan_min_deg: -180.0,
335                pan_max_deg: 180.0,
336                tilt_min_deg: -30.0,
337                tilt_max_deg: 90.0,
338                roll_min_deg: 0.0,
339                roll_max_deg: 0.0,
340                zoom_min: 1.0,
341                zoom_max: 30.0,
342                pan_rate_max: 45.0,
343                tilt_rate_max: 30.0,
344            }),
345            current_state: Some(GimbalState {
346                pan_deg: 45.0,
347                tilt_deg: 15.0,
348                roll_deg: 0.0,
349                zoom: 2.0,
350                tracking: false,
351                tracked_target_id: String::new(),
352            }),
353            updated_at: None,
354        }
355    }
356
357    #[test]
358    fn test_valid_fixed_sensor() {
359        let sensor = valid_fixed_sensor();
360        assert!(validate_sensor_spec(&sensor).is_ok());
361    }
362
363    #[test]
364    fn test_valid_ptz_sensor() {
365        let sensor = valid_ptz_sensor();
366        assert!(validate_sensor_spec(&sensor).is_ok());
367    }
368
369    #[test]
370    fn test_missing_sensor_id() {
371        let mut sensor = valid_fixed_sensor();
372        sensor.sensor_id = String::new();
373        let err = validate_sensor_spec(&sensor).unwrap_err();
374        assert!(matches!(err, ValidationError::MissingField(f) if f == "sensor_id"));
375    }
376
377    #[test]
378    fn test_missing_name() {
379        let mut sensor = valid_fixed_sensor();
380        sensor.name = String::new();
381        let err = validate_sensor_spec(&sensor).unwrap_err();
382        assert!(matches!(err, ValidationError::MissingField(f) if f == "name"));
383    }
384
385    #[test]
386    fn test_unspecified_mount_type() {
387        let mut sensor = valid_fixed_sensor();
388        sensor.mount_type = SensorMountType::Unspecified as i32;
389        let err = validate_sensor_spec(&sensor).unwrap_err();
390        assert!(matches!(err, ValidationError::InvalidValue(_)));
391    }
392
393    #[test]
394    fn test_missing_fov() {
395        let mut sensor = valid_fixed_sensor();
396        sensor.field_of_view = None;
397        let err = validate_sensor_spec(&sensor).unwrap_err();
398        assert!(matches!(err, ValidationError::MissingField(f) if f == "field_of_view"));
399    }
400
401    #[test]
402    fn test_ptz_without_gimbal_limits() {
403        let mut sensor = valid_ptz_sensor();
404        sensor.gimbal_limits = None;
405        let err = validate_sensor_spec(&sensor).unwrap_err();
406        assert!(matches!(err, ValidationError::MissingField(_)));
407    }
408
409    #[test]
410    fn test_invalid_bearing() {
411        let mut sensor = valid_fixed_sensor();
412        sensor.base_orientation = Some(SensorOrientation {
413            bearing_offset_deg: 400.0, // Invalid
414            elevation_offset_deg: 0.0,
415            roll_offset_deg: 0.0,
416        });
417        let err = validate_sensor_spec(&sensor).unwrap_err();
418        assert!(matches!(err, ValidationError::InvalidValue(_)));
419    }
420
421    #[test]
422    fn test_invalid_elevation() {
423        let mut sensor = valid_fixed_sensor();
424        sensor.base_orientation = Some(SensorOrientation {
425            bearing_offset_deg: 0.0,
426            elevation_offset_deg: 100.0, // Invalid
427            roll_offset_deg: 0.0,
428        });
429        let err = validate_sensor_spec(&sensor).unwrap_err();
430        assert!(matches!(err, ValidationError::InvalidValue(_)));
431    }
432
433    #[test]
434    fn test_invalid_horizontal_fov() {
435        let mut sensor = valid_fixed_sensor();
436        sensor.field_of_view = Some(FieldOfView {
437            horizontal_deg: 0.0, // Invalid
438            vertical_deg: 48.0,
439            diagonal_deg: 0.0,
440            max_range_m: 500.0,
441        });
442        let err = validate_sensor_spec(&sensor).unwrap_err();
443        assert!(matches!(err, ValidationError::InvalidValue(_)));
444    }
445
446    #[test]
447    fn test_invalid_gimbal_pan_range() {
448        let mut sensor = valid_ptz_sensor();
449        sensor.gimbal_limits = Some(GimbalLimits {
450            pan_min_deg: 100.0,
451            pan_max_deg: -100.0, // Invalid: min > max
452            tilt_min_deg: -30.0,
453            tilt_max_deg: 90.0,
454            roll_min_deg: 0.0,
455            roll_max_deg: 0.0,
456            zoom_min: 1.0,
457            zoom_max: 30.0,
458            pan_rate_max: 45.0,
459            tilt_rate_max: 30.0,
460        });
461        let err = validate_sensor_spec(&sensor).unwrap_err();
462        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
463    }
464
465    #[test]
466    fn test_gimbal_state_outside_limits() {
467        let mut sensor = valid_ptz_sensor();
468        sensor.current_state = Some(GimbalState {
469            pan_deg: 200.0, // Outside limits [-180, 180]
470            tilt_deg: 15.0,
471            roll_deg: 0.0,
472            zoom: 2.0,
473            tracking: false,
474            tracked_target_id: String::new(),
475        });
476        let err = validate_sensor_spec(&sensor).unwrap_err();
477        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
478    }
479
480    #[test]
481    fn test_valid_sensor_state_update() {
482        let update = SensorStateUpdate {
483            platform_id: "UGV-Alpha-1".to_string(),
484            sensor: Some(valid_fixed_sensor()),
485            status: SensorStatus::Operational as i32,
486            timestamp: Some(Timestamp {
487                seconds: 1702000000,
488                nanos: 0,
489            }),
490        };
491        assert!(validate_sensor_state_update(&update).is_ok());
492    }
493
494    #[test]
495    fn test_sensor_update_missing_platform_id() {
496        let update = SensorStateUpdate {
497            platform_id: String::new(),
498            sensor: Some(valid_fixed_sensor()),
499            status: SensorStatus::Operational as i32,
500            timestamp: Some(Timestamp {
501                seconds: 1702000000,
502                nanos: 0,
503            }),
504        };
505        let err = validate_sensor_state_update(&update).unwrap_err();
506        assert!(matches!(err, ValidationError::MissingField(f) if f == "platform_id"));
507    }
508
509    #[test]
510    fn test_sensor_update_unspecified_status() {
511        let update = SensorStateUpdate {
512            platform_id: "UGV-Alpha-1".to_string(),
513            sensor: Some(valid_fixed_sensor()),
514            status: SensorStatus::Unspecified as i32,
515            timestamp: Some(Timestamp {
516                seconds: 1702000000,
517                nanos: 0,
518            }),
519        };
520        let err = validate_sensor_state_update(&update).unwrap_err();
521        assert!(matches!(err, ValidationError::InvalidValue(_)));
522    }
523}