phytrace_sdk/models/domains/
maintenance.rs

1//! Maintenance Domain - Health, diagnostics, and maintenance schedules.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use validator::Validate;
6
7use crate::models::enums::{ComponentHealth, MaintenanceUrgency};
8
9/// Maintenance domain containing health and diagnostics information.
10#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
11pub struct MaintenanceDomain {
12    /// Overall health score (0-100)
13    #[serde(skip_serializing_if = "Option::is_none")]
14    #[validate(range(min = 0.0, max = 100.0))]
15    pub health_score: Option<f64>,
16
17    /// Component health statuses
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub components: Option<Vec<ComponentStatus>>,
20
21    /// Active diagnostics
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub diagnostics: Option<Vec<Diagnostic>>,
24
25    /// Scheduled maintenance items
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub maintenance_due: Option<Vec<MaintenanceItem>>,
28
29    /// Last maintenance date
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub last_maintenance: Option<DateTime<Utc>>,
32
33    /// Next scheduled maintenance
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub next_maintenance: Option<DateTime<Utc>>,
36
37    /// Total operating hours
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub operating_hours: Option<f64>,
40
41    /// Maintenance urgency
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub urgency: Option<MaintenanceUrgency>,
44}
45
46/// Component health status.
47#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
48pub struct ComponentStatus {
49    /// Component ID
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub component_id: Option<String>,
52
53    /// Component name
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub name: Option<String>,
56
57    /// Component type
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub component_type: Option<String>,
60
61    /// Health status
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub health: Option<ComponentHealth>,
64
65    /// Health score (0-100)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    #[validate(range(min = 0.0, max = 100.0))]
68    pub health_score: Option<f64>,
69
70    /// Operating hours
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub operating_hours: Option<f64>,
73
74    /// Remaining useful life (hours)
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub remaining_life_hours: Option<f64>,
77
78    /// Last inspection date
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub last_inspection: Option<DateTime<Utc>>,
81
82    /// Notes
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub notes: Option<String>,
85}
86
87/// Diagnostic entry.
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct Diagnostic {
90    /// Diagnostic code
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub code: Option<String>,
93
94    /// Description
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub description: Option<String>,
97
98    /// Severity
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub severity: Option<String>,
101
102    /// Affected component
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub component: Option<String>,
105
106    /// First detected
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub first_detected: Option<DateTime<Utc>>,
109
110    /// Occurrence count
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub occurrence_count: Option<u32>,
113
114    /// Recommended action
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub recommended_action: Option<String>,
117
118    /// Whether active
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub is_active: Option<bool>,
121}
122
123/// Scheduled maintenance item.
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125pub struct MaintenanceItem {
126    /// Item ID
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub item_id: Option<String>,
129
130    /// Description
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub description: Option<String>,
133
134    /// Maintenance type
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub maintenance_type: Option<String>,
137
138    /// Due date
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub due_date: Option<DateTime<Utc>>,
141
142    /// Due at hours
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub due_at_hours: Option<f64>,
145
146    /// Urgency
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub urgency: Option<MaintenanceUrgency>,
149
150    /// Affected component
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub component: Option<String>,
153
154    /// Estimated duration (minutes)
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub estimated_duration_min: Option<u32>,
157}
158
159impl MaintenanceDomain {
160    /// Create with health score.
161    pub fn new(health_score: f64) -> Self {
162        Self {
163            health_score: Some(health_score),
164            urgency: Some(if health_score < 50.0 {
165                MaintenanceUrgency::Urgent
166            } else if health_score < 75.0 {
167                MaintenanceUrgency::Soon
168            } else {
169                MaintenanceUrgency::None
170            }),
171            ..Default::default()
172        }
173    }
174
175    /// Add a component status.
176    pub fn with_component(mut self, component: ComponentStatus) -> Self {
177        let components = self.components.get_or_insert_with(Vec::new);
178        components.push(component);
179        self
180    }
181
182    /// Add a diagnostic.
183    pub fn with_diagnostic(mut self, diagnostic: Diagnostic) -> Self {
184        let diagnostics = self.diagnostics.get_or_insert_with(Vec::new);
185        diagnostics.push(diagnostic);
186        self
187    }
188}
189
190impl ComponentStatus {
191    /// Create a healthy component.
192    pub fn healthy(name: impl Into<String>) -> Self {
193        Self {
194            name: Some(name.into()),
195            health: Some(ComponentHealth::Healthy),
196            health_score: Some(100.0),
197            ..Default::default()
198        }
199    }
200
201    /// Create a degraded component.
202    pub fn degraded(name: impl Into<String>, score: f64) -> Self {
203        Self {
204            name: Some(name.into()),
205            health: Some(ComponentHealth::Degraded),
206            health_score: Some(score),
207            ..Default::default()
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_maintenance_domain() {
218        let maint =
219            MaintenanceDomain::new(85.0).with_component(ComponentStatus::healthy("battery"));
220
221        assert_eq!(maint.urgency, Some(MaintenanceUrgency::None));
222        assert_eq!(maint.components.unwrap().len(), 1);
223    }
224
225    // ==========================================================================
226    // MaintenanceDomain Additional Tests
227    // ==========================================================================
228
229    #[test]
230    fn test_maintenance_domain_default() {
231        let maint = MaintenanceDomain::default();
232        assert!(maint.health_score.is_none());
233        assert!(maint.components.is_none());
234        assert!(maint.diagnostics.is_none());
235        assert!(maint.maintenance_due.is_none());
236        assert!(maint.last_maintenance.is_none());
237        assert!(maint.next_maintenance.is_none());
238        assert!(maint.operating_hours.is_none());
239        assert!(maint.urgency.is_none());
240    }
241
242    #[test]
243    fn test_maintenance_domain_new_urgent() {
244        let maint = MaintenanceDomain::new(40.0);
245        assert_eq!(maint.health_score, Some(40.0));
246        assert_eq!(maint.urgency, Some(MaintenanceUrgency::Urgent));
247    }
248
249    #[test]
250    fn test_maintenance_domain_new_soon() {
251        let maint = MaintenanceDomain::new(60.0);
252        assert_eq!(maint.health_score, Some(60.0));
253        assert_eq!(maint.urgency, Some(MaintenanceUrgency::Soon));
254    }
255
256    #[test]
257    fn test_maintenance_domain_new_none() {
258        let maint = MaintenanceDomain::new(90.0);
259        assert_eq!(maint.health_score, Some(90.0));
260        assert_eq!(maint.urgency, Some(MaintenanceUrgency::None));
261    }
262
263    #[test]
264    fn test_maintenance_domain_with_component() {
265        let comp = ComponentStatus::healthy("motor");
266        let maint = MaintenanceDomain::new(80.0).with_component(comp);
267
268        assert!(maint.components.is_some());
269        assert_eq!(
270            maint.components.as_ref().unwrap()[0].name,
271            Some("motor".to_string())
272        );
273    }
274
275    #[test]
276    fn test_maintenance_domain_with_diagnostic() {
277        let diag = Diagnostic {
278            code: Some("D001".to_string()),
279            description: Some("Battery degradation".to_string()),
280            severity: Some("warning".to_string()),
281            ..Default::default()
282        };
283
284        let maint = MaintenanceDomain::new(75.0).with_diagnostic(diag);
285        assert!(maint.diagnostics.is_some());
286        assert_eq!(
287            maint.diagnostics.as_ref().unwrap()[0].code,
288            Some("D001".to_string())
289        );
290    }
291
292    #[test]
293    fn test_maintenance_domain_chained_builders() {
294        let maint = MaintenanceDomain::new(80.0)
295            .with_component(ComponentStatus::healthy("motor_left"))
296            .with_component(ComponentStatus::degraded("motor_right", 70.0))
297            .with_diagnostic(Diagnostic::default());
298
299        assert_eq!(maint.components.as_ref().unwrap().len(), 2);
300        assert!(maint.diagnostics.is_some());
301    }
302
303    // ==========================================================================
304    // ComponentStatus Tests
305    // ==========================================================================
306
307    #[test]
308    fn test_component_status_healthy() {
309        let comp = ComponentStatus::healthy("lidar");
310
311        assert_eq!(comp.name, Some("lidar".to_string()));
312        assert_eq!(comp.health, Some(ComponentHealth::Healthy));
313        assert_eq!(comp.health_score, Some(100.0));
314    }
315
316    #[test]
317    fn test_component_status_degraded() {
318        let comp = ComponentStatus::degraded("camera", 65.0);
319
320        assert_eq!(comp.name, Some("camera".to_string()));
321        assert_eq!(comp.health, Some(ComponentHealth::Degraded));
322        assert_eq!(comp.health_score, Some(65.0));
323    }
324
325    #[test]
326    fn test_component_status_default() {
327        let comp = ComponentStatus::default();
328        assert!(comp.component_id.is_none());
329        assert!(comp.name.is_none());
330        assert!(comp.component_type.is_none());
331        assert!(comp.health.is_none());
332        assert!(comp.health_score.is_none());
333        assert!(comp.operating_hours.is_none());
334        assert!(comp.remaining_life_hours.is_none());
335        assert!(comp.last_inspection.is_none());
336        assert!(comp.notes.is_none());
337    }
338
339    #[test]
340    fn test_component_status_full() {
341        let comp = ComponentStatus {
342            component_id: Some("comp-001".to_string()),
343            name: Some("wheel_motor".to_string()),
344            component_type: Some("motor".to_string()),
345            health: Some(ComponentHealth::Degraded),
346            health_score: Some(55.0),
347            operating_hours: Some(5000.0),
348            remaining_life_hours: Some(1000.0),
349            last_inspection: Some(Utc::now()),
350            notes: Some("Showing wear".to_string()),
351        };
352
353        assert_eq!(comp.remaining_life_hours, Some(1000.0));
354        assert_eq!(comp.notes, Some("Showing wear".to_string()));
355    }
356
357    // ==========================================================================
358    // Diagnostic Tests
359    // ==========================================================================
360
361    #[test]
362    fn test_diagnostic_default() {
363        let diag = Diagnostic::default();
364        assert!(diag.code.is_none());
365        assert!(diag.description.is_none());
366        assert!(diag.severity.is_none());
367        assert!(diag.component.is_none());
368        assert!(diag.first_detected.is_none());
369        assert!(diag.occurrence_count.is_none());
370        assert!(diag.recommended_action.is_none());
371        assert!(diag.is_active.is_none());
372    }
373
374    #[test]
375    fn test_diagnostic_full() {
376        let diag = Diagnostic {
377            code: Some("ERR_MOTOR_01".to_string()),
378            description: Some("Motor overcurrent detected".to_string()),
379            severity: Some("critical".to_string()),
380            component: Some("motor_left".to_string()),
381            first_detected: Some(Utc::now()),
382            occurrence_count: Some(5),
383            recommended_action: Some("Replace motor".to_string()),
384            is_active: Some(true),
385        };
386
387        assert_eq!(diag.code, Some("ERR_MOTOR_01".to_string()));
388        assert_eq!(diag.occurrence_count, Some(5));
389    }
390
391    // ==========================================================================
392    // MaintenanceItem Tests
393    // ==========================================================================
394
395    #[test]
396    fn test_maintenance_item_default() {
397        let item = MaintenanceItem::default();
398        assert!(item.item_id.is_none());
399        assert!(item.description.is_none());
400        assert!(item.maintenance_type.is_none());
401        assert!(item.due_date.is_none());
402        assert!(item.due_at_hours.is_none());
403        assert!(item.urgency.is_none());
404        assert!(item.component.is_none());
405        assert!(item.estimated_duration_min.is_none());
406    }
407
408    #[test]
409    fn test_maintenance_item_full() {
410        let item = MaintenanceItem {
411            item_id: Some("maint-001".to_string()),
412            description: Some("Replace battery".to_string()),
413            maintenance_type: Some("replacement".to_string()),
414            due_date: Some(Utc::now()),
415            due_at_hours: Some(10000.0),
416            urgency: Some(MaintenanceUrgency::Soon),
417            component: Some("battery".to_string()),
418            estimated_duration_min: Some(60),
419        };
420
421        assert_eq!(item.estimated_duration_min, Some(60));
422        assert_eq!(item.maintenance_type, Some("replacement".to_string()));
423    }
424
425    // ==========================================================================
426    // Serialization Roundtrip Tests
427    // ==========================================================================
428
429    #[test]
430    fn test_maintenance_domain_serialization_roundtrip() {
431        let maint = MaintenanceDomain::new(75.0)
432            .with_component(ComponentStatus::healthy("motor"))
433            .with_diagnostic(Diagnostic {
434                code: Some("D001".to_string()),
435                ..Default::default()
436            });
437
438        let json = serde_json::to_string(&maint).unwrap();
439        let deserialized: MaintenanceDomain = serde_json::from_str(&json).unwrap();
440
441        assert_eq!(deserialized.health_score, Some(75.0));
442        assert!(deserialized.components.is_some());
443        assert!(deserialized.diagnostics.is_some());
444    }
445}