1use crate::error::{PhysicsError, PhysicsResult};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct HealthIndicator {
20 pub name: String,
22 pub score: f64,
24 pub evidence: HashMap<String, f64>,
26 pub description: String,
28}
29
30impl HealthIndicator {
31 pub fn from_vibration(
37 samples: &[f64],
38 nominal_rms: f64,
39 failure_rms: f64,
40 ) -> PhysicsResult<Self> {
41 if samples.is_empty() {
42 return Err(PhysicsError::ConstraintViolation(
43 "vibration samples must not be empty".to_string(),
44 ));
45 }
46 if failure_rms <= nominal_rms {
47 return Err(PhysicsError::ConstraintViolation(
48 "failure_rms must be greater than nominal_rms".to_string(),
49 ));
50 }
51
52 let rms = compute_rms(samples);
53 let score = compute_linear_score(rms, nominal_rms, failure_rms);
54
55 let mut evidence = HashMap::new();
56 evidence.insert("rms".to_string(), rms);
57 evidence.insert(
58 "peak".to_string(),
59 samples.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
60 );
61
62 Ok(Self {
63 name: "vibration_rms".to_string(),
64 score,
65 evidence,
66 description: format!("Vibration RMS = {rms:.4} m/s² (score = {score:.3})"),
67 })
68 }
69
70 pub fn from_temperature(
74 temperature: f64,
75 nominal_temp: f64,
76 max_temp: f64,
77 ) -> PhysicsResult<Self> {
78 if max_temp <= nominal_temp {
79 return Err(PhysicsError::ConstraintViolation(
80 "max_temp must be greater than nominal_temp".to_string(),
81 ));
82 }
83 let score = compute_linear_score(temperature, nominal_temp, max_temp);
84 let mut evidence = HashMap::new();
85 evidence.insert("temperature_K".to_string(), temperature);
86 Ok(Self {
87 name: "thermal_health".to_string(),
88 score,
89 evidence,
90 description: format!("Temperature = {temperature:.2} K (score = {score:.3})"),
91 })
92 }
93
94 pub fn from_pressure(pressure: f64, nominal_pa: f64, max_pa: f64) -> PhysicsResult<Self> {
96 if max_pa <= nominal_pa {
97 return Err(PhysicsError::ConstraintViolation(
98 "max_pa must be greater than nominal_pa".to_string(),
99 ));
100 }
101 let score = compute_linear_score(pressure, nominal_pa, max_pa);
102 let mut evidence = HashMap::new();
103 evidence.insert("pressure_Pa".to_string(), pressure);
104 Ok(Self {
105 name: "pressure_health".to_string(),
106 score,
107 evidence,
108 description: format!("Pressure = {pressure:.2} Pa (score = {score:.3})"),
109 })
110 }
111
112 pub fn aggregate(indicators: &[HealthIndicator]) -> PhysicsResult<Self> {
114 if indicators.is_empty() {
115 return Err(PhysicsError::ConstraintViolation(
116 "no indicators to aggregate".to_string(),
117 ));
118 }
119 let score = indicators.iter().map(|h| h.score).sum::<f64>() / indicators.len() as f64;
120 let mut evidence = HashMap::new();
121 for h in indicators {
122 for (k, v) in &h.evidence {
123 evidence.insert(format!("{}/{}", h.name, k), *v);
124 }
125 }
126 Ok(Self {
127 name: "composite_health".to_string(),
128 score,
129 evidence,
130 description: format!(
131 "Composite health from {} indicators (score = {score:.3})",
132 indicators.len()
133 ),
134 })
135 }
136}
137
138fn compute_rms(samples: &[f64]) -> f64 {
139 let sum_sq: f64 = samples.iter().map(|x| x * x).sum();
140 (sum_sq / samples.len() as f64).sqrt()
141}
142
143fn compute_linear_score(value: f64, nominal: f64, failure: f64) -> f64 {
145 if value <= nominal {
146 1.0
147 } else if value >= failure {
148 0.0
149 } else {
150 1.0 - (value - nominal) / (failure - nominal)
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct RulEstimate {
161 pub remaining_time: f64,
163 pub confidence_interval: f64,
165 pub model_name: String,
167}
168
169pub trait DegradationModel: Send + Sync {
171 fn fit(&mut self, times: &[f64], health_scores: &[f64]) -> PhysicsResult<()>;
175
176 fn predict_rul(&self, current_time: f64, failure_threshold: f64) -> PhysicsResult<RulEstimate>;
178}
179
180#[derive(Debug, Default)]
184pub struct LinearDegradationModel {
185 intercept: f64,
186 slope: f64,
187 residual_std: f64,
188 fitted: bool,
189}
190
191impl LinearDegradationModel {
192 pub fn new() -> Self {
193 Self::default()
194 }
195}
196
197impl DegradationModel for LinearDegradationModel {
198 fn fit(&mut self, times: &[f64], health_scores: &[f64]) -> PhysicsResult<()> {
199 if times.len() != health_scores.len() {
200 return Err(PhysicsError::ConstraintViolation(
201 "times and health_scores must have equal length".to_string(),
202 ));
203 }
204 if times.len() < 2 {
205 return Err(PhysicsError::ConstraintViolation(
206 "need at least 2 data points for linear fit".to_string(),
207 ));
208 }
209
210 let n = times.len() as f64;
211 let sum_t: f64 = times.iter().sum();
212 let sum_h: f64 = health_scores.iter().sum();
213 let sum_tt: f64 = times.iter().map(|t| t * t).sum();
214 let sum_th: f64 = times
215 .iter()
216 .zip(health_scores.iter())
217 .map(|(t, h)| t * h)
218 .sum();
219
220 let denom = n * sum_tt - sum_t * sum_t;
221 if denom.abs() < 1e-14 {
222 return Err(PhysicsError::ConstraintViolation(
223 "degenerate time series (all times equal)".to_string(),
224 ));
225 }
226
227 self.slope = (n * sum_th - sum_t * sum_h) / denom;
228 self.intercept = (sum_h - self.slope * sum_t) / n;
229
230 let ss_res: f64 = times
232 .iter()
233 .zip(health_scores.iter())
234 .map(|(t, h)| (h - (self.intercept + self.slope * t)).powi(2))
235 .sum();
236 self.residual_std = (ss_res / (n - 2.0).max(1.0)).sqrt();
237 self.fitted = true;
238 Ok(())
239 }
240
241 fn predict_rul(&self, current_time: f64, failure_threshold: f64) -> PhysicsResult<RulEstimate> {
242 if !self.fitted {
243 return Err(PhysicsError::ConstraintViolation(
244 "model has not been fitted yet".to_string(),
245 ));
246 }
247 if self.slope.abs() < 1e-14 {
248 return Ok(RulEstimate {
250 remaining_time: f64::INFINITY,
251 confidence_interval: 0.0,
252 model_name: "LinearDegradation".to_string(),
253 });
254 }
255
256 let t_failure = (failure_threshold - self.intercept) / self.slope;
258 let rul = (t_failure - current_time).max(0.0);
259 let ci = 1.96 * self.residual_std / self.slope.abs();
261
262 Ok(RulEstimate {
263 remaining_time: rul,
264 confidence_interval: ci.abs(),
265 model_name: "LinearDegradation".to_string(),
266 })
267 }
268}
269
270#[derive(Debug, Default)]
274pub struct ExponentialDegradationModel {
275 amplitude: f64,
276 decay_rate: f64,
277 residual_std_log: f64,
278 fitted: bool,
279}
280
281impl ExponentialDegradationModel {
282 pub fn new() -> Self {
283 Self::default()
284 }
285}
286
287impl DegradationModel for ExponentialDegradationModel {
288 fn fit(&mut self, times: &[f64], health_scores: &[f64]) -> PhysicsResult<()> {
289 if times.len() != health_scores.len() || times.len() < 2 {
290 return Err(PhysicsError::ConstraintViolation(
291 "need matching arrays with ≥ 2 points".to_string(),
292 ));
293 }
294
295 let log_health: Vec<f64> = health_scores
297 .iter()
298 .map(|&h| if h > 0.0 { h.ln() } else { f64::NEG_INFINITY })
299 .collect();
300
301 let valid: Vec<(f64, f64)> = times
302 .iter()
303 .zip(log_health.iter())
304 .filter(|(_, &lh)| lh.is_finite())
305 .map(|(&t, &lh)| (t, lh))
306 .collect();
307
308 if valid.len() < 2 {
309 return Err(PhysicsError::ConstraintViolation(
310 "not enough positive health scores for exponential fit".to_string(),
311 ));
312 }
313
314 let n = valid.len() as f64;
315 let sum_t: f64 = valid.iter().map(|(t, _)| t).sum();
316 let sum_lh: f64 = valid.iter().map(|(_, lh)| lh).sum();
317 let sum_tt: f64 = valid.iter().map(|(t, _)| t * t).sum();
318 let sum_tlh: f64 = valid.iter().map(|(t, lh)| t * lh).sum();
319
320 let denom = n * sum_tt - sum_t * sum_t;
321 if denom.abs() < 1e-14 {
322 return Err(PhysicsError::ConstraintViolation(
323 "degenerate time series".to_string(),
324 ));
325 }
326
327 let slope = (n * sum_tlh - sum_t * sum_lh) / denom;
328 let ln_a = (sum_lh - slope * sum_t) / n;
329
330 self.amplitude = ln_a.exp();
331 self.decay_rate = -slope; let ss_res: f64 = valid
334 .iter()
335 .map(|(t, lh)| (lh - (ln_a + slope * t)).powi(2))
336 .sum();
337 self.residual_std_log = (ss_res / (n - 2.0).max(1.0)).sqrt();
338 self.fitted = true;
339 Ok(())
340 }
341
342 fn predict_rul(&self, current_time: f64, failure_threshold: f64) -> PhysicsResult<RulEstimate> {
343 if !self.fitted {
344 return Err(PhysicsError::ConstraintViolation(
345 "model not fitted".to_string(),
346 ));
347 }
348 if self.amplitude <= 0.0 || failure_threshold <= 0.0 {
349 return Err(PhysicsError::ConstraintViolation(
350 "amplitude and threshold must be positive".to_string(),
351 ));
352 }
353 if self.decay_rate.abs() < 1e-14 {
354 return Ok(RulEstimate {
355 remaining_time: f64::INFINITY,
356 confidence_interval: 0.0,
357 model_name: "ExponentialDegradation".to_string(),
358 });
359 }
360
361 let t_fail = (self.amplitude / failure_threshold).ln() / self.decay_rate;
363 let rul = (t_fail - current_time).max(0.0);
364 let ci = 1.96 * self.residual_std_log / self.decay_rate;
365
366 Ok(RulEstimate {
367 remaining_time: rul,
368 confidence_interval: ci.abs(),
369 model_name: "ExponentialDegradation".to_string(),
370 })
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
380pub enum MaintenancePriority {
381 Low,
382 Medium,
383 High,
384 Critical,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct MaintenanceTask {
390 pub description: String,
391 pub priority: MaintenancePriority,
392 pub due_in_hours: f64,
394 pub estimated_duration_hours: f64,
396}
397
398#[derive(Debug, Default, Clone, Serialize, Deserialize)]
400pub struct MaintenanceSchedule {
401 pub tasks: Vec<MaintenanceTask>,
402}
403
404impl MaintenanceSchedule {
405 pub fn new() -> Self {
406 Self::default()
407 }
408
409 pub fn add_task(&mut self, task: MaintenanceTask) {
411 self.tasks.push(task);
412 self.tasks.sort_by(|a, b| {
413 b.priority.cmp(&a.priority).then(
414 a.due_in_hours
415 .partial_cmp(&b.due_in_hours)
416 .unwrap_or(std::cmp::Ordering::Equal),
417 )
418 });
419 }
420
421 pub fn next_task(&self) -> Option<&MaintenanceTask> {
423 self.tasks.first()
424 }
425
426 pub fn tasks_due_within(&self, horizon_hours: f64) -> Vec<&MaintenanceTask> {
428 self.tasks
429 .iter()
430 .filter(|t| t.due_in_hours <= horizon_hours)
431 .collect()
432 }
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub enum AnomalyCategory {
442 Thermal,
443 Mechanical,
444 Electrical,
445 Pressure,
446 Unknown,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct Anomaly {
452 pub category: AnomalyCategory,
453 pub description: String,
454 pub severity: f64,
456 pub triggered_by: String,
458}
459
460pub struct AnomalyClassifier {
462 thermal_limits: HashMap<String, (f64, f64)>,
464 mechanical_limits: HashMap<String, (f64, f64)>,
466 electrical_limits: HashMap<String, (f64, f64)>,
468 pressure_limits: HashMap<String, (f64, f64)>,
470}
471
472impl AnomalyClassifier {
473 pub fn new() -> Self {
474 Self {
475 thermal_limits: HashMap::new(),
476 mechanical_limits: HashMap::new(),
477 electrical_limits: HashMap::new(),
478 pressure_limits: HashMap::new(),
479 }
480 }
481
482 pub fn add_thermal_limit(&mut self, quantity: impl Into<String>, nominal: f64, alarm: f64) {
483 self.thermal_limits
484 .insert(quantity.into(), (nominal, alarm));
485 }
486
487 pub fn add_mechanical_limit(&mut self, quantity: impl Into<String>, nominal: f64, alarm: f64) {
488 self.mechanical_limits
489 .insert(quantity.into(), (nominal, alarm));
490 }
491
492 pub fn add_electrical_limit(&mut self, quantity: impl Into<String>, nominal: f64, alarm: f64) {
493 self.electrical_limits
494 .insert(quantity.into(), (nominal, alarm));
495 }
496
497 pub fn add_pressure_limit(&mut self, quantity: impl Into<String>, nominal: f64, alarm: f64) {
498 self.pressure_limits
499 .insert(quantity.into(), (nominal, alarm));
500 }
501
502 pub fn classify(&self, readings: &HashMap<String, f64>) -> Vec<Anomaly> {
504 let mut anomalies = Vec::new();
505
506 Self::check_limits(
507 readings,
508 &self.thermal_limits,
509 AnomalyCategory::Thermal,
510 "thermal",
511 &mut anomalies,
512 );
513 Self::check_limits(
514 readings,
515 &self.mechanical_limits,
516 AnomalyCategory::Mechanical,
517 "mechanical",
518 &mut anomalies,
519 );
520 Self::check_limits(
521 readings,
522 &self.electrical_limits,
523 AnomalyCategory::Electrical,
524 "electrical",
525 &mut anomalies,
526 );
527 Self::check_limits(
528 readings,
529 &self.pressure_limits,
530 AnomalyCategory::Pressure,
531 "pressure",
532 &mut anomalies,
533 );
534
535 anomalies
536 }
537
538 fn check_limits(
539 readings: &HashMap<String, f64>,
540 limits: &HashMap<String, (f64, f64)>,
541 category: AnomalyCategory,
542 label: &str,
543 out: &mut Vec<Anomaly>,
544 ) {
545 for (qty, &(nominal, alarm)) in limits {
546 if let Some(&value) = readings.get(qty) {
547 if value > nominal {
548 let severity = compute_linear_score(value, nominal, alarm);
549 let severity = 1.0 - severity;
551 let description = format!(
552 "{label} anomaly on `{qty}`: value={value:.4} > nominal={nominal:.4} (alarm={alarm:.4})"
553 );
554 out.push(Anomaly {
555 category: category.clone(),
556 description,
557 severity: severity.clamp(0.0, 1.0),
558 triggered_by: qty.clone(),
559 });
560 }
561 }
562 }
563 }
564}
565
566impl Default for AnomalyClassifier {
567 fn default() -> Self {
568 Self::new()
569 }
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct FailureMode {
579 pub name: String,
580 pub probability: f64,
581 pub description: String,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct PrognosticReport {
587 pub health_score: f64,
589 pub rul_estimate: Option<RulEstimate>,
591 pub failure_modes: Vec<FailureMode>,
593 pub maintenance_tasks: MaintenanceSchedule,
595 pub anomalies: Vec<Anomaly>,
597}
598
599impl PrognosticReport {
600 pub fn new(
602 health_indicators: &[HealthIndicator],
603 rul_estimate: Option<RulEstimate>,
604 anomalies: Vec<Anomaly>,
605 ) -> PhysicsResult<Self> {
606 let health_score = if health_indicators.is_empty() {
607 1.0
608 } else {
609 health_indicators.iter().map(|h| h.score).sum::<f64>() / health_indicators.len() as f64
610 };
611
612 let mut failure_modes = Vec::new();
614 if health_score < 0.3 {
615 failure_modes.push(FailureMode {
616 name: "ImmidentFailure".to_string(),
617 probability: 1.0 - health_score,
618 description: "Health score critically low; failure imminent".to_string(),
619 });
620 }
621 for anomaly in &anomalies {
622 if anomaly.severity > 0.7 {
623 failure_modes.push(FailureMode {
624 name: format!("{:?}Failure", anomaly.category),
625 probability: anomaly.severity,
626 description: anomaly.description.clone(),
627 });
628 }
629 }
630
631 let mut schedule = MaintenanceSchedule::new();
633 if let Some(ref rul) = rul_estimate {
634 let priority = if rul.remaining_time < 24.0 {
635 MaintenancePriority::Critical
636 } else if rul.remaining_time < 168.0 {
637 MaintenancePriority::High
638 } else {
639 MaintenancePriority::Medium
640 };
641 schedule.add_task(MaintenanceTask {
642 description: format!(
643 "Inspect before estimated failure (RUL: {:.1} h ± {:.1})",
644 rul.remaining_time, rul.confidence_interval
645 ),
646 priority,
647 due_in_hours: rul.remaining_time * 0.8,
648 estimated_duration_hours: 2.0,
649 });
650 }
651
652 Ok(Self {
653 health_score,
654 rul_estimate,
655 failure_modes,
656 maintenance_tasks: schedule,
657 anomalies,
658 })
659 }
660
661 pub fn is_healthy(&self) -> bool {
663 self.health_score > 0.6
664 && self
665 .maintenance_tasks
666 .next_task()
667 .map(|t| t.priority < MaintenancePriority::High)
668 .unwrap_or(true)
669 }
670}
671
672#[cfg(test)]
677mod tests {
678 use super::*;
679
680 #[test]
683 fn health_indicator_vibration_nominal() {
684 let samples = vec![0.1_f64; 100];
685 let hi = HealthIndicator::from_vibration(&samples, 0.5, 2.0).expect("should succeed");
686 assert!((hi.score - 1.0).abs() < 1e-9);
688 }
689
690 #[test]
691 fn health_indicator_vibration_degraded() {
692 let samples = vec![1.25_f64; 100];
693 let hi = HealthIndicator::from_vibration(&samples, 0.5, 2.0).expect("should succeed");
695 assert!((hi.score - 0.5).abs() < 1e-6);
696 }
697
698 #[test]
699 fn health_indicator_temperature() {
700 let hi = HealthIndicator::from_temperature(500.0, 400.0, 600.0).expect("should succeed");
701 assert!((hi.score - 0.5).abs() < 1e-6);
703 }
704
705 #[test]
706 fn health_indicator_aggregate() {
707 let h1 = HealthIndicator::from_temperature(300.0, 400.0, 600.0).expect("should succeed"); let h2 = HealthIndicator::from_temperature(500.0, 400.0, 600.0).expect("should succeed"); let agg = HealthIndicator::aggregate(&[h1, h2]).expect("should succeed");
710 assert!((agg.score - 0.75).abs() < 1e-6);
711 }
712
713 #[test]
716 fn linear_degradation_fit_and_rul() {
717 let times: Vec<f64> = (0..=100).map(|i| i as f64).collect();
719 let scores: Vec<f64> = times.iter().map(|t| 1.0 - 0.01 * t).collect();
720
721 let mut model = LinearDegradationModel::new();
722 model.fit(×, &scores).expect("should succeed");
723
724 let rul = model.predict_rul(50.0, 0.0).expect("should succeed");
726 assert!(
727 (rul.remaining_time - 50.0).abs() < 0.1,
728 "RUL: {}",
729 rul.remaining_time
730 );
731 }
732
733 #[test]
736 fn exponential_degradation_fit_and_rul() {
737 let times: Vec<f64> = (0..=80).map(|i| i as f64 * 0.5).collect();
739 let scores: Vec<f64> = times.iter().map(|t| (-0.05 * t).exp()).collect();
740
741 let mut model = ExponentialDegradationModel::new();
742 model.fit(×, &scores).expect("should succeed");
743
744 let rul = model.predict_rul(0.0, 0.05).expect("should succeed");
746 let expected = (1.0_f64 / 0.05).ln() / 0.05;
747 assert!(
748 (rul.remaining_time - expected).abs() < 1.0,
749 "RUL: {}",
750 rul.remaining_time
751 );
752 }
753
754 #[test]
757 fn anomaly_classifier_thermal_detection() {
758 let mut clf = AnomalyClassifier::new();
759 clf.add_thermal_limit("temperature", 350.0, 500.0);
760
761 let mut readings = HashMap::new();
762 readings.insert("temperature".to_string(), 450.0);
763
764 let anomalies = clf.classify(&readings);
765 assert_eq!(anomalies.len(), 1);
766 assert_eq!(anomalies[0].category, AnomalyCategory::Thermal);
767 assert!(anomalies[0].severity > 0.0);
769 }
770
771 #[test]
772 fn anomaly_classifier_no_anomaly_within_nominal() {
773 let mut clf = AnomalyClassifier::new();
774 clf.add_mechanical_limit("vibration_rms", 0.5, 2.0);
775
776 let mut readings = HashMap::new();
777 readings.insert("vibration_rms".to_string(), 0.3);
778
779 let anomalies = clf.classify(&readings);
780 assert!(anomalies.is_empty());
781 }
782
783 #[test]
786 fn prognostic_report_healthy() {
787 let hi = HealthIndicator::from_temperature(310.0, 400.0, 600.0).expect("should succeed");
788 let report = PrognosticReport::new(&[hi], None, Vec::new()).expect("should succeed");
789 assert!(report.health_score > 0.9);
790 assert!(report.is_healthy());
791 }
792}