phytrace_sdk/models/domains/
maintenance.rs1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use validator::Validate;
6
7use crate::models::enums::{ComponentHealth, MaintenanceUrgency};
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
11pub struct MaintenanceDomain {
12 #[serde(skip_serializing_if = "Option::is_none")]
14 #[validate(range(min = 0.0, max = 100.0))]
15 pub health_score: Option<f64>,
16
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub components: Option<Vec<ComponentStatus>>,
20
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub diagnostics: Option<Vec<Diagnostic>>,
24
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub maintenance_due: Option<Vec<MaintenanceItem>>,
28
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub last_maintenance: Option<DateTime<Utc>>,
32
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub next_maintenance: Option<DateTime<Utc>>,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub operating_hours: Option<f64>,
40
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub urgency: Option<MaintenanceUrgency>,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)]
48pub struct ComponentStatus {
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub component_id: Option<String>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub name: Option<String>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub component_type: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub health: Option<ComponentHealth>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 #[validate(range(min = 0.0, max = 100.0))]
68 pub health_score: Option<f64>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub operating_hours: Option<f64>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub remaining_life_hours: Option<f64>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub last_inspection: Option<DateTime<Utc>>,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub notes: Option<String>,
85}
86
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct Diagnostic {
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub code: Option<String>,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub description: Option<String>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub severity: Option<String>,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub component: Option<String>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub first_detected: Option<DateTime<Utc>>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub occurrence_count: Option<u32>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub recommended_action: Option<String>,
117
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub is_active: Option<bool>,
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125pub struct MaintenanceItem {
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub item_id: Option<String>,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub description: Option<String>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub maintenance_type: Option<String>,
137
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub due_date: Option<DateTime<Utc>>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub due_at_hours: Option<f64>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub urgency: Option<MaintenanceUrgency>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub component: Option<String>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub estimated_duration_min: Option<u32>,
157}
158
159impl MaintenanceDomain {
160 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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}