struktura 1.8.7

Time-series anomaly detection with no training data: detrended fluctuation analysis (DFA, Hurst exponent), a self-calibrating streaming monitor for sensors and telemetry, and C99 code generation for embedded and flight software. no_std.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
//! Spacecraft health monitoring via DFA structural analysis.
//!
//! Real-time anomaly detection for telemetry channels: reaction wheels,
//! magnetometers, thermal sensors, battery voltage, solar array current.
//! Tracks structural (DFA α) changes alongside threshold-based monitors.
//!
//! ```
//! use struktura::space::{SpacecraftMonitor, voyager_demo};
//! let result = voyager_demo();
//! // 2021 vs 2022 magnetometer slices: alpha differs, but z = 1.5 (inconclusive).
//! assert!(result.shift < 0.0);
//! ```

#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[cfg(not(feature = "std"))]
use alloc::string::String;
#[cfg(not(feature = "std"))]
use alloc::format;

use crate::{analyze, health_check, dfa, HealthVerdict, BaselineTracker};
use core::fmt;

/// Spacecraft subsystem being monitored.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Subsystem {
    ReactionWheel,
    Magnetometer,
    ThermalSensor,
    BatteryVoltage,
    SolarArray,
    Gyroscope,
    StarTracker,
    Thruster,
    Transponder,
    Custom,
}

impl fmt::Display for Subsystem {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Subsystem::ReactionWheel => write!(f, "RWA"),
            Subsystem::Magnetometer => write!(f, "MAG"),
            Subsystem::ThermalSensor => write!(f, "THM"),
            Subsystem::BatteryVoltage => write!(f, "BAT"),
            Subsystem::SolarArray => write!(f, "SA"),
            Subsystem::Gyroscope => write!(f, "GYR"),
            Subsystem::StarTracker => write!(f, "STR"),
            Subsystem::Thruster => write!(f, "THR"),
            Subsystem::Transponder => write!(f, "XPDR"),
            Subsystem::Custom => write!(f, "CUST"),
        }
    }
}

/// Result of a spacecraft telemetry health assessment.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TelemetryHealth {
    pub subsystem: Subsystem,
    pub channel_name: String,
    pub current_alpha: f64,
    pub baseline_alpha: f64,
    pub shift: f64,
    pub r_squared: f64,
    pub verdict: HealthVerdict,
    pub samples: usize,
}

impl fmt::Display for TelemetryHealth {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{}:{}] α={:.3} baseline={:.3} shift={:.3} R²={:.4} → {}",
            self.subsystem, self.channel_name,
            self.current_alpha, self.baseline_alpha, self.shift,
            self.r_squared, self.verdict)
    }
}

/// Real-time spacecraft telemetry monitor.
///
/// Wraps [`BaselineTracker`] with spacecraft-specific defaults:
/// - 512-sample sliding window (typical for 1Hz telemetry over ~8 minutes)
/// - 2048-sample learning period (builds baseline over ~34 minutes)
/// - Configurable per-subsystem thresholds
pub struct SpacecraftMonitor {
    tracker: BaselineTracker,
    subsystem: Subsystem,
    channel: String,
    threshold: f64,
}

impl SpacecraftMonitor {
    pub fn new(subsystem: Subsystem, channel: &str) -> Self {
        let (window, learning) = match subsystem {
            Subsystem::ReactionWheel => (256, 1024),
            Subsystem::Magnetometer => (512, 2048),
            Subsystem::BatteryVoltage => (1024, 4096),
            Subsystem::ThermalSensor => (1024, 4096),
            _ => (512, 2048),
        };
        SpacecraftMonitor {
            tracker: BaselineTracker::new(window, learning),
            subsystem,
            channel: String::from(channel),
            threshold: 0.08,
        }
    }

    pub fn with_threshold(mut self, threshold: f64) -> Self {
        self.threshold = threshold;
        self
    }

    pub fn with_window(subsystem: Subsystem, channel: &str, window: usize, learning: usize) -> Self {
        SpacecraftMonitor {
            tracker: BaselineTracker::new(window, learning),
            subsystem,
            channel: String::from(channel),
            threshold: 0.08,
        }
    }

    pub fn push(&mut self, value: f64) -> Option<HealthVerdict> {
        self.tracker.push(value)
    }

    pub fn baseline(&self) -> Option<f64> {
        self.tracker.baseline()
    }

    pub fn is_learning(&self) -> bool {
        self.tracker.is_learning()
    }

    pub fn assess(&self, values: &[f64]) -> TelemetryHealth {
        let law = analyze(values);
        let baseline = self.baseline().unwrap_or(law.dfa.alpha);
        let shift = law.dfa.alpha - baseline;
        let verdict = HealthVerdict::from_shift_threshold(shift, self.threshold);
        TelemetryHealth {
            subsystem: self.subsystem,
            channel_name: self.channel.clone(),
            current_alpha: law.dfa.alpha,
            baseline_alpha: baseline,
            shift,
            r_squared: law.dfa.r_squared,
            verdict,
            samples: values.len(),
        }
    }
}

/// Analyze a batch of telemetry channels simultaneously.
pub fn multi_channel_health(
    channels: &[(&str, Subsystem, &[f64], f64)],
) -> Vec<TelemetryHealth> {
    channels.iter().map(|(name, subsystem, data, baseline_alpha)| {
        let law = analyze(data);
        let shift = law.dfa.alpha - baseline_alpha;
        let verdict = HealthVerdict::from_shift(shift);
        TelemetryHealth {
            subsystem: *subsystem,
            channel_name: String::from(*name),
            current_alpha: law.dfa.alpha,
            baseline_alpha: *baseline_alpha,
            shift,
            r_squared: law.dfa.r_squared,
            verdict,
            samples: data.len(),
        }
    }).collect()
}

/// Result of the Voyager demo.
#[derive(Debug)]
pub struct VoyagerDemoResult {
    pub healthy_alpha: f64,
    pub healthy_r2: f64,
    pub anomaly_alpha: f64,
    pub anomaly_r2: f64,
    pub shift: f64,
    pub anomaly_detected: bool,
    pub verdict: HealthVerdict,
}

impl fmt::Display for VoyagerDemoResult {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Voyager 1 Magnetometer (NASA SPDF 48s averages)\n\
                    2021 healthy:     α={:.3} R²={:.4}\n\
                    2022 anomaly:     α={:.3} R²={:.4}\n\
                    Structural shift: {:.3}\n\
                    Verdict:          {}",
            self.healthy_alpha, self.healthy_r2,
            self.anomaly_alpha, self.anomaly_r2,
            self.shift, self.verdict)
    }
}

/// Run DFA on real Voyager 1 magnetometer data.
///
/// Compares 2021 vs May-Jul 2022 magnetometer slices. The 2022 window
/// overlaps Voyager 1's AACS anomaly, but this is a year-over-year
/// comparison: the anomaly window vs the months just before it shows no
/// significant α shift (p = 0.52), and these slices give z = 1.5.
/// `anomaly_detected` is the fixed-threshold `HealthVerdict`, not a
/// significance test (docs/CLAIMS-AUDIT-2026-09-17.md).
pub fn voyager_demo() -> VoyagerDemoResult {
    let healthy: Vec<f64> = include_str!("../data/voyager1_healthy_4k.csv")
        .lines().filter_map(|l| l.trim().parse().ok()).collect();
    let anomaly: Vec<f64> = include_str!("../data/voyager1_anomaly_4k.csv")
        .lines().filter_map(|l| l.trim().parse().ok()).collect();

    let law_h = dfa(&healthy);
    let law_a = dfa(&anomaly);
    let shift = law_a.alpha - law_h.alpha;
    let verdict = health_check(
        &analyze(&anomaly),
        law_h.alpha,
    );

    VoyagerDemoResult {
        healthy_alpha: law_h.alpha,
        healthy_r2: law_h.r_squared,
        anomaly_alpha: law_a.alpha,
        anomaly_r2: law_a.r_squared,
        shift,
        anomaly_detected: verdict != HealthVerdict::Healthy,
        verdict,
    }
}

/// Result of the heliopause crossing demo.
#[derive(Debug)]
pub struct HelioDemoResult {
    pub helio_alpha: f64,
    pub helio_r2: f64,
    pub interstellar_alpha: f64,
    pub interstellar_r2: f64,
    pub shift: f64,
    pub crossing_detected: bool,
    pub verdict: HealthVerdict,
}

impl fmt::Display for HelioDemoResult {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Voyager 1 Heliopause Crossing (NASA SPDF 48s averages)\n\
                    Heliosphere (2012 days 1-200):    α={:.3} R²={:.4}\n\
                    Interstellar (2012 days 260-331):  α={:.3} R²={:.4}\n\
                    Structural shift:                  {:.3}\n\
                    Verdict:                           {}",
            self.helio_alpha, self.helio_r2,
            self.interstellar_alpha, self.interstellar_r2,
            self.shift, self.verdict)
    }
}

/// Run DFA on Voyager 1 magnetometer data across the heliopause crossing.
///
/// On August 25, 2012 (DOY 238), Voyager 1 crossed from the heliosphere
/// into interstellar space, the first human-made object to leave the solar
/// system. DFA detects the structural transition in the magnetic field.
///
/// Heliosphere: sun's magnetic field dominates, strong long-range persistence.
/// Interstellar: galactic magnetic field, different correlation structure.
pub fn heliopause_demo() -> HelioDemoResult {
    let helio: Vec<f64> = include_str!("../data/voyager1_helio_pre.csv")
        .lines().filter_map(|l| l.trim().parse().ok()).collect();
    let inter: Vec<f64> = include_str!("../data/voyager1_helio_post.csv")
        .lines().filter_map(|l| l.trim().parse().ok()).collect();

    let law_h = dfa(&helio);
    let law_i = dfa(&inter);
    let shift = law_i.alpha - law_h.alpha;
    let verdict = health_check(
        &analyze(&inter),
        law_h.alpha,
    );

    HelioDemoResult {
        helio_alpha: law_h.alpha,
        helio_r2: law_h.r_squared,
        interstellar_alpha: law_i.alpha,
        interstellar_r2: law_i.r_squared,
        shift,
        crossing_detected: verdict != HealthVerdict::Healthy,
        verdict,
    }
}

/// Result of the IMS run-to-failure demo.
#[derive(Debug)]
pub struct ImsDemoResult {
    pub baseline_alpha: f64,
    pub pre_failure_alpha: f64,
    pub failure_alpha: f64,
    pub early_warning_recording: usize,
    pub failure_recording: usize,
    pub total_recordings: usize,
}

impl fmt::Display for ImsDemoResult {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "IMS Bearing Run-to-Failure (NASA/IMS, U. Cincinnati)\n\
                    Baseline (rec 1-900):    α={:.3}\n\
                    First alarm (rec 970):   α={:.3}\n\
                    Last recording (984):    α={:.3}\n\
                    Alarm {} recordings ({} min) before the test ended; a plain RMS threshold trips earlier",
            self.baseline_alpha, self.pre_failure_alpha, self.failure_alpha,
            self.failure_recording - self.early_warning_recording,
            (self.failure_recording - self.early_warning_recording) * 10)
    }
}

/// Run the IMS bearing run-to-failure demo.
///
/// Uses embedded timeline data from the NASA IMS 2nd test (2004).
/// Bearing 1 outer race fault: DFA detects structural stiffening
/// (α spikes from 0.17 to 0.47) 2+ hours before final collapse.
pub fn ims_demo() -> ImsDemoResult {
    let timeline: Vec<(usize, f64, f64)> = include_str!("../data/ims_timeline.csv")
        .lines()
        .filter_map(|line| {
            let parts: Vec<&str> = line.split(',').collect();
            if parts.len() >= 4 {
                let rec: usize = parts[0].parse().ok()?;
                let alpha: f64 = parts[2].parse().ok()?;
                let _rms: f64 = parts[3].parse().ok()?;
                Some((rec, alpha, _rms))
            } else { None }
        })
        .collect();

    let baseline_alpha = timeline.iter()
        .filter(|(r, _, _)| *r <= 900)
        .map(|(_, a, _)| *a)
        .sum::<f64>() / timeline.iter().filter(|(r, _, _)| *r <= 900).count() as f64;

    let pre_failure = timeline.iter()
        .filter(|(r, _, _)| *r >= 970 && *r <= 982)
        .map(|(_, a, _)| *a)
        .fold(0.0f64, |max, a| if a > max { a } else { max });

    let failure_alpha = timeline.last().map(|(_, a, _)| *a).unwrap_or(0.0);

    ImsDemoResult {
        baseline_alpha,
        pre_failure_alpha: pre_failure,
        failure_alpha,
        early_warning_recording: 970,
        failure_recording: 984,
        total_recordings: 984,
    }
}

/// Generate synthetic reaction wheel telemetry with optional degradation.
///
/// Returns current-draw values. `degradation_start` (0.0-1.0) is the fraction
/// through the signal where bearing wear begins. Set to 1.0 for healthy-only.
pub fn synth_reaction_wheel(n: usize, seed: u64, degradation_start: f64) -> Vec<f64> {
    let degrade_at = (n as f64 * degradation_start.clamp(0.0, 1.0)) as usize;
    let mut state = seed;
    let mut out = Vec::with_capacity(n);
    for i in 0..n {
        state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
        let noise = (state >> 33) as f64 / (1u64 << 31) as f64 - 0.5;
        let base = 2.5 + 0.3 * crate::sin(crate::ln((i as f64 + 1.0).max(1.0)));
        let degradation = if i >= degrade_at {
            let progress = (i - degrade_at) as f64 / (n - degrade_at).max(1) as f64;
            0.8 * progress * progress + 0.5 * progress * noise
        } else {
            0.0
        };
        out.push(base + noise * 0.1 + degradation);
    }
    out
}

/// Generate synthetic battery voltage cycling with optional cell degradation.
pub fn synth_battery_voltage(n: usize, seed: u64, degradation_start: f64) -> Vec<f64> {
    let degrade_at = (n as f64 * degradation_start.clamp(0.0, 1.0)) as usize;
    let mut state = seed;
    let mut out = Vec::with_capacity(n);
    for i in 0..n {
        state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
        let noise = (state >> 33) as f64 / (1u64 << 31) as f64 - 0.5;
        let orbit_phase = crate::sin(i as f64 * 0.0065);
        let base = 28.2 + 1.5 * orbit_phase;
        let degradation = if i >= degrade_at {
            let progress = (i - degrade_at) as f64 / (n - degrade_at).max(1) as f64;
            -0.8 * progress - 0.3 * progress * orbit_phase.abs()
        } else {
            0.0
        };
        out.push(base + noise * 0.05 + degradation);
    }
    out
}

/// Generate synthetic thermal sensor data with drift.
pub fn synth_thermal(n: usize, seed: u64, drift_rate: f64) -> Vec<f64> {
    let mut state = seed;
    let mut out = Vec::with_capacity(n);
    for i in 0..n {
        state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
        let noise = (state >> 33) as f64 / (1u64 << 31) as f64 - 0.5;
        let orbit_thermal = 15.0 * crate::sin(i as f64 * 0.006);
        let base = 22.0 + orbit_thermal + drift_rate * i as f64;
        out.push(base + noise * 0.8);
    }
    out
}

/// Generate synthetic telemetry with a correlation-structure fault.
///
/// First half: clean sinusoidal signal with correlated noise.
/// Second half (after `fault_start`): same amplitude range but
/// DESTROYED correlation: noise becomes independent. DFA detects
/// the structural change; amplitude-based detectors miss it.
pub fn synth_structural_fault(n: usize, seed: u64, fault_start: f64) -> Vec<f64> {
    let fault_at = (n as f64 * fault_start.clamp(0.0, 1.0)) as usize;
    let mut state = seed;
    let mut out = Vec::with_capacity(n);
    let mut prev = 0.0f64;
    for i in 0..n {
        state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
        let raw = (state >> 33) as f64 / (1u64 << 31) as f64 - 0.5;
        if i < fault_at {
            // Strongly correlated AR(1) process: high α (~0.9+)
            prev = prev * 0.95 + raw * 0.05;
            out.push(prev);
        } else {
            // White noise: low α (~0.5). Same amplitude range, different structure.
            out.push(raw * 0.05);
        }
    }
    out
}

/// Run a full spacecraft health demo with synthetic telemetry.
///
/// Generates 4 channels (RWA, BAT, THM, MAG), splits each into
/// healthy baseline and degraded period, runs DFA comparison.
pub fn spacecraft_demo() -> Vec<TelemetryHealth> {
    let n = 4096;
    let channels = [
        ("RWA_current", Subsystem::ReactionWheel, synth_reaction_wheel(n, 42, 0.6)),
        ("BAT_voltage", Subsystem::BatteryVoltage, synth_battery_voltage(n, 77, 0.7)),
        ("THM_panel_A", Subsystem::ThermalSensor, synth_thermal(n, 99, 0.001)),
        ("MAG_B_total", Subsystem::Magnetometer, {
            include_str!("../data/voyager1_healthy_4k.csv")
                .lines().filter_map(|l| l.trim().parse().ok()).collect()
        }),
    ];

    let mut results = Vec::new();
    for (name, subsystem, data) in &channels {
        let mid = data.len() / 2;
        let baseline_law = crate::dfa(&data[..mid]);
        let current_law = crate::analyze(&data[mid..]);
        let shift = current_law.dfa.alpha - baseline_law.alpha;
        let verdict = crate::HealthVerdict::from_shift(shift);
        results.push(TelemetryHealth {
            subsystem: *subsystem,
            channel_name: String::from(*name),
            current_alpha: current_law.dfa.alpha,
            baseline_alpha: baseline_law.alpha,
            shift,
            r_squared: current_law.dfa.r_squared,
            verdict,
            samples: data.len(),
        });
    }
    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn voyager_year_over_year_shift_is_measurable() {
        // Pins the bundled 2021 vs 2022 slices; not evidence of detecting
        // the AACS anomaly (see voyager_demo docs).
        let result = voyager_demo();
        assert!(result.anomaly_detected, "fixed-threshold verdict on the bundled slices");
        assert!(result.healthy_r2 > 0.9, "healthy R² should be high");
        assert!(result.anomaly_r2 > 0.9, "anomaly R² should be high");
        assert!(result.shift.abs() > 0.03, "shift should be measurable: {}", result.shift);
    }

    #[test]
    fn spacecraft_monitor_learns_baseline() {
        let mut mon = SpacecraftMonitor::new(Subsystem::Magnetometer, "B_total");
        let healthy: Vec<f64> = include_str!("../data/voyager1_healthy_4k.csv")
            .lines().filter_map(|l| l.trim().parse().ok()).collect();
        for &v in &healthy {
            mon.push(v);
        }
        assert!(!mon.is_learning(), "should have finished learning after 4096 samples");
        assert!(mon.baseline().is_some(), "baseline should be established");
    }

    #[test]
    fn multi_channel_produces_verdicts() {
        let healthy: Vec<f64> = include_str!("../data/voyager1_healthy_4k.csv")
            .lines().filter_map(|l| l.trim().parse().ok()).collect();
        let anomaly: Vec<f64> = include_str!("../data/voyager1_anomaly_4k.csv")
            .lines().filter_map(|l| l.trim().parse().ok()).collect();
        let baseline = dfa(&healthy).alpha;

        let results = multi_channel_health(&[
            ("B_total", Subsystem::Magnetometer, &healthy, baseline),
            ("B_anomaly", Subsystem::Magnetometer, &anomaly, baseline),
        ]);

        assert_eq!(results.len(), 2);
        assert_eq!(results[0].verdict, HealthVerdict::Healthy);
        assert_ne!(results[1].verdict, HealthVerdict::Healthy);
    }

    #[test]
    fn synth_rwa_degradation_shifts_alpha() {
        let healthy = synth_reaction_wheel(4096, 42, 1.0);
        let degraded = synth_reaction_wheel(4096, 42, 0.3);
        let h = dfa(&healthy);
        let d = dfa(&degraded);
        assert!((h.alpha - d.alpha).abs() > 0.02,
            "degraded RWA should shift alpha: healthy={:.3} degraded={:.3}", h.alpha, d.alpha);
    }

    #[test]
    fn heliopause_demo_detects_crossing() {
        let result = heliopause_demo();
        assert!(result.helio_r2 > 0.9, "helio R² too low: {:.4}", result.helio_r2);
        assert!(result.interstellar_r2 > 0.9, "interstellar R² too low: {:.4}", result.interstellar_r2);
        assert!(result.helio_alpha > result.interstellar_alpha,
            "helio α should be higher: {:.3} vs {:.3}", result.helio_alpha, result.interstellar_alpha);
        assert!(result.crossing_detected, "heliopause crossing not detected");
    }

    #[test]
    fn spacecraft_demo_runs() {
        let results = spacecraft_demo();
        assert_eq!(results.len(), 4);
        for r in &results {
            assert!(r.r_squared > 0.5, "{} R² too low: {:.4}", r.channel_name, r.r_squared);
        }
    }
}