Skip to main content

oxirs_physics/predictive_maintenance/
mod.rs

1//! Predictive Maintenance Module
2//!
3//! Provides health indicators, Remaining Useful Life (RUL) prediction,
4//! maintenance scheduling, anomaly classification, and prognostic reports
5//! for physical assets monitored by digital twins.
6
7use crate::error::{PhysicsError, PhysicsResult};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11// ─────────────────────────────────────────────────────────────────────────────
12// HealthIndicator
13// ─────────────────────────────────────────────────────────────────────────────
14
15/// A scalar health indicator computed from one or more sensor time-series.
16///
17/// `score` is in [0, 1]: 1.0 = perfect health, 0.0 = complete failure.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct HealthIndicator {
20    /// Name of the indicator (e.g. "vibration_rms").
21    pub name: String,
22    /// Computed health score ∈ [0, 1].
23    pub score: f64,
24    /// Supporting evidence (sensor name → raw value).
25    pub evidence: HashMap<String, f64>,
26    /// Narrative description.
27    pub description: String,
28}
29
30impl HealthIndicator {
31    /// Compute a vibration RMS health indicator.
32    ///
33    /// `samples` is a slice of acceleration samples (m/s²).
34    /// `nominal_rms` is the expected RMS in healthy conditions.
35    /// `failure_rms` is the RMS at which the component is considered failed.
36    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    /// Compute a temperature-based health indicator.
71    ///
72    /// `temperature` in Kelvin.  Healthy below `nominal_temp`; failed at `max_temp`.
73    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    /// Compute a pressure-based health indicator.
95    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    /// Aggregate multiple indicators into one composite score (arithmetic mean).
113    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
143/// Returns a score in [0, 1]; 1.0 when `value ≤ nominal`, 0.0 when `value ≥ failure`.
144fn 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// ─────────────────────────────────────────────────────────────────────────────
155// Degradation Models & RUL Predictor
156// ─────────────────────────────────────────────────────────────────────────────
157
158/// Remaining Useful Life estimate.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct RulEstimate {
161    /// Predicted remaining time (e.g. hours or cycles).
162    pub remaining_time: f64,
163    /// Confidence interval half-width (±).
164    pub confidence_interval: f64,
165    /// Model name used.
166    pub model_name: String,
167}
168
169/// Trait for degradation models used in RUL prediction.
170pub trait DegradationModel: Send + Sync {
171    /// Fit the model to the given time-series of health scores.
172    ///
173    /// `times` and `health_scores` must have the same length.
174    fn fit(&mut self, times: &[f64], health_scores: &[f64]) -> PhysicsResult<()>;
175
176    /// Predict the time at which health will reach `failure_threshold`.
177    fn predict_rul(&self, current_time: f64, failure_threshold: f64) -> PhysicsResult<RulEstimate>;
178}
179
180/// Linear degradation: health(t) = a + b·t.
181///
182/// Fits using ordinary least squares.
183#[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        // Compute residual std.
231        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            // Flat degradation — never reaches failure.
249            return Ok(RulEstimate {
250                remaining_time: f64::INFINITY,
251                confidence_interval: 0.0,
252                model_name: "LinearDegradation".to_string(),
253            });
254        }
255
256        // t_failure = (threshold - intercept) / slope
257        let t_failure = (failure_threshold - self.intercept) / self.slope;
258        let rul = (t_failure - current_time).max(0.0);
259        // 95% CI: ±1.96 * residual_std / |slope|
260        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/// Exponential degradation: health(t) = A · exp(−λ·t).
271///
272/// Fitted by log-linearizing: ln(health) = ln(A) − λ·t.
273#[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        // Filter out non-positive health scores.
296        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; // positive decay rate
332
333        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        // A·exp(−λ·t_fail) = threshold  ⟹  t_fail = ln(A/threshold) / λ
362        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// ─────────────────────────────────────────────────────────────────────────────
375// Maintenance Schedule
376// ─────────────────────────────────────────────────────────────────────────────
377
378/// Priority of a maintenance task.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
380pub enum MaintenancePriority {
381    Low,
382    Medium,
383    High,
384    Critical,
385}
386
387/// A single maintenance task.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct MaintenanceTask {
390    pub description: String,
391    pub priority: MaintenancePriority,
392    /// Estimated hours until the task must be performed.
393    pub due_in_hours: f64,
394    /// Estimated duration of the task (hours).
395    pub estimated_duration_hours: f64,
396}
397
398/// A time-ordered, priority-sorted maintenance schedule.
399#[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    /// Add a task; tasks are kept sorted by (priority desc, due_in_hours asc).
410    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    /// Next task by priority (highest first).
422    pub fn next_task(&self) -> Option<&MaintenanceTask> {
423        self.tasks.first()
424    }
425
426    /// Tasks due within `horizon_hours`.
427    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// ─────────────────────────────────────────────────────────────────────────────
436// AnomalyClassifier
437// ─────────────────────────────────────────────────────────────────────────────
438
439/// Category of detected anomaly.
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub enum AnomalyCategory {
442    Thermal,
443    Mechanical,
444    Electrical,
445    Pressure,
446    Unknown,
447}
448
449/// A classified anomaly.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct Anomaly {
452    pub category: AnomalyCategory,
453    pub description: String,
454    /// Severity in [0, 1].
455    pub severity: f64,
456    /// The sensor / quantity that triggered the classification.
457    pub triggered_by: String,
458}
459
460/// Rule-based anomaly classifier operating on named sensor readings.
461pub struct AnomalyClassifier {
462    /// Thermal limits: quantity name → (nominal, alarm) thresholds in Kelvin.
463    thermal_limits: HashMap<String, (f64, f64)>,
464    /// Mechanical limits: quantity name → (nominal_rms, alarm_rms).
465    mechanical_limits: HashMap<String, (f64, f64)>,
466    /// Electrical limits: quantity name → (nominal, alarm).
467    electrical_limits: HashMap<String, (f64, f64)>,
468    /// Pressure limits: quantity name → (nominal, alarm).
469    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    /// Classify anomalies in `readings` (quantity name → value).
503    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                    // Invert: score → 0 means severe, severity 1 = fully alarmed.
550                    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// ─────────────────────────────────────────────────────────────────────────────
573// PrognosticReport
574// ─────────────────────────────────────────────────────────────────────────────
575
576/// Failure mode identified in the prognostic report.
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct FailureMode {
579    pub name: String,
580    pub probability: f64,
581    pub description: String,
582}
583
584/// Comprehensive prognostic report combining health, RUL, anomalies, and tasks.
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct PrognosticReport {
587    /// Aggregate health score ∈ [0, 1].
588    pub health_score: f64,
589    /// Remaining useful life estimate.
590    pub rul_estimate: Option<RulEstimate>,
591    /// Identified failure modes.
592    pub failure_modes: Vec<FailureMode>,
593    /// Recommended maintenance tasks.
594    pub maintenance_tasks: MaintenanceSchedule,
595    /// Detected anomalies.
596    pub anomalies: Vec<Anomaly>,
597}
598
599impl PrognosticReport {
600    /// Build a report from constituent parts.
601    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        // Derive failure modes from anomalies and health score.
613        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        // Build maintenance schedule from RUL and anomalies.
632        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    /// Returns `true` when no immediate action is required.
662    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// ─────────────────────────────────────────────────────────────────────────────
673// Tests
674// ─────────────────────────────────────────────────────────────────────────────
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    // ── HealthIndicator ───────────────────────────────────────────────────────
681
682    #[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        // RMS of 0.1 < nominal 0.5 → perfect health.
687        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        // nominal=0.5, failure=2.0 → score = 1 - (1.25-0.5)/(2.0-0.5) = 0.5
694        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        // score = 1 - (500-400)/(600-400) = 0.5
702        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"); // score 1.0
708        let h2 = HealthIndicator::from_temperature(500.0, 400.0, 600.0).expect("should succeed"); // score 0.5
709        let agg = HealthIndicator::aggregate(&[h1, h2]).expect("should succeed");
710        assert!((agg.score - 0.75).abs() < 1e-6);
711    }
712
713    // ── LinearDegradationModel ────────────────────────────────────────────────
714
715    #[test]
716    fn linear_degradation_fit_and_rul() {
717        // Perfect linear degradation: h(t) = 1.0 - 0.01·t
718        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(&times, &scores).expect("should succeed");
723
724        // At t=50 the health is 0.5; failure threshold 0.0 → failure at t=100.
725        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    // ── ExponentialDegradationModel ───────────────────────────────────────────
734
735    #[test]
736    fn exponential_degradation_fit_and_rul() {
737        // Exponential: h(t) = 1.0 · exp(−0.05·t)
738        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(&times, &scores).expect("should succeed");
743
744        // At t=0; failure when h=0.05 → t_fail = ln(1/0.05)/0.05 ≈ 59.9.
745        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    // ── AnomalyClassifier ─────────────────────────────────────────────────────
755
756    #[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        // severity = 1 - (1 - (450-350)/(500-350)) = (450-350)/(500-350) ≈ 0.666
768        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    // ── PrognosticReport ──────────────────────────────────────────────────────
784
785    #[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}