Skip to main content

dsfb_semiconductor/
plots.rs

1use crate::baselines::BaselineSet;
2use crate::config::PipelineConfig;
3use crate::error::{DsfbSemiconductorError, Result};
4use crate::grammar::{GrammarSet, GrammarState};
5use crate::metrics::BenchmarkMetrics;
6use crate::nominal::NominalModel;
7use crate::phm2018_loader::{
8    Phm2018EarlyWarningStats, Phm2018RunDetail, Phm2018StructuralMetrics,
9};
10use crate::precursor::DsaEvaluation;
11use crate::preprocessing::PreparedDataset;
12use crate::residual::ResidualSet;
13use crate::signs::SignSet;
14use csv::Writer;
15use plotters::coord::types::{RangedCoordf64, RangedCoordusize};
16use plotters::prelude::*;
17use serde::Serialize;
18use std::path::{Path, PathBuf};
19
20const WIDTH: u32 = 1400;
21const HEIGHT: u32 = 800;
22const COMBINED_WIDTH: u32 = 2880;
23const COMBINED_HEIGHT: u32 = 1620;
24
25#[derive(Debug, Clone)]
26struct DrscDsaCombinedRow {
27    run_index: usize,
28    timestamp: String,
29    label: i8,
30    feature: String,
31    residual_over_rho: f64,
32    drift_over_threshold: f64,
33    slew_over_threshold: f64,
34    display_state: GrammarState,
35    persistent_boundary: bool,
36    persistent_violation: bool,
37    feature_dsa_alert: bool,
38    run_level_dsa_alert: bool,
39    feature_count_dsa_alert: usize,
40    threshold_run_signal: bool,
41    ewma_run_signal: bool,
42}
43
44#[derive(Debug, Clone)]
45struct AnnotationCandidate {
46    run_index: usize,
47    label: String,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct DrscManifest {
52    pub figure_file: String,
53    pub trace_csv: String,
54    pub feature_index: usize,
55    pub feature_name: String,
56    pub failure_run_index: usize,
57    pub window_start_run_index: usize,
58    pub window_end_run_index: usize,
59    pub first_persistent_boundary_run: Option<usize>,
60    pub first_persistent_violation_run: Option<usize>,
61}
62
63#[derive(Debug, Clone, Serialize)]
64pub struct DsaFocusManifest {
65    pub figure_file: String,
66    pub trace_csv: String,
67    pub feature_index: usize,
68    pub feature_name: String,
69    pub failure_run_index: usize,
70    pub window_start_run_index: usize,
71    pub window_end_run_index: usize,
72}
73
74#[derive(Debug, Clone, Serialize)]
75pub struct DrscDsaCombinedManifest {
76    pub figure_file: String,
77    pub trace_csv: String,
78    pub feature_index: usize,
79    pub feature_name: String,
80    pub failure_run_index: usize,
81    pub window_start_run_index: usize,
82    pub window_end_run_index: usize,
83    pub feature_selection_basis: String,
84    pub normalization_note: String,
85    pub state_display_note: String,
86    pub dsa_rendering_note: String,
87    pub baseline_rendering_note: String,
88}
89
90#[derive(Debug, Clone, Serialize)]
91pub struct FigureManifest {
92    pub figure_dir: PathBuf,
93    pub files: Vec<String>,
94    pub drsc: Option<DrscManifest>,
95    pub drsc_dsa_combined: Option<DrscDsaCombinedManifest>,
96    pub dsa_focus: Option<DsaFocusManifest>,
97}
98
99pub fn generate_figures(
100    run_dir: &Path,
101    dataset: &PreparedDataset,
102    nominal: &NominalModel,
103    residuals: &ResidualSet,
104    signs: &SignSet,
105    baselines: &BaselineSet,
106    grammar: &GrammarSet,
107    metrics: &BenchmarkMetrics,
108    dsa: &DsaEvaluation,
109    config: &PipelineConfig,
110) -> Result<FigureManifest> {
111    let figure_dir = run_dir.join("figures");
112    std::fs::create_dir_all(&figure_dir)?;
113
114    let mut files = Vec::new();
115    draw_missingness_chart(&figure_dir, dataset)?;
116    files.push("missingness_top20.png".into());
117
118    draw_multi_feature_chart(
119        &figure_dir.join("top_feature_residual_norms.png"),
120        "Top feature residual norms",
121        "Residual norm",
122        &metrics.top_feature_indices,
123        nominal,
124        residuals,
125        signs,
126        baselines,
127        residual_norms_for_feature,
128    )?;
129    files.push("top_feature_residual_norms.png".into());
130
131    draw_multi_feature_chart(
132        &figure_dir.join("top_feature_drift.png"),
133        "Top feature drift traces",
134        "Drift",
135        &metrics.top_feature_indices,
136        nominal,
137        residuals,
138        signs,
139        baselines,
140        drift_for_feature,
141    )?;
142    files.push("top_feature_drift.png".into());
143
144    draw_multi_feature_chart(
145        &figure_dir.join("top_feature_ewma.png"),
146        "Top feature EWMA traces",
147        "EWMA residual norm",
148        &metrics.top_feature_indices,
149        nominal,
150        residuals,
151        signs,
152        baselines,
153        ewma_for_feature,
154    )?;
155    files.push("top_feature_ewma.png".into());
156
157    draw_multi_feature_chart(
158        &figure_dir.join("top_feature_slew.png"),
159        "Top feature slew traces",
160        "Slew",
161        &metrics.top_feature_indices,
162        nominal,
163        residuals,
164        signs,
165        baselines,
166        slew_for_feature,
167    )?;
168    files.push("top_feature_slew.png".into());
169
170    draw_grammar_timeline(&figure_dir, metrics, grammar)?;
171    files.push("grammar_timeline.png".into());
172
173    draw_baseline_comparison(&figure_dir, metrics, dsa)?;
174    files.push("benchmark_comparison.png".into());
175
176    let (drsc, drsc_dsa_combined, dsa_focus) = if let Some(feature_index) =
177        metrics.top_feature_indices.first().copied()
178    {
179        let figure_file = "drsc_top_feature.png".to_string();
180        let trace_csv = "drsc_top_feature.csv".to_string();
181        let drsc_window = drsc_window(
182            dataset,
183            grammar,
184            feature_index,
185            config.pre_failure_lookback_runs,
186        );
187        draw_drsc_chart(
188            &figure_dir.join(&figure_file),
189            dataset,
190            nominal,
191            residuals,
192            signs,
193            baselines,
194            grammar,
195            dsa,
196            feature_index,
197            config,
198            &drsc_window,
199        )?;
200        write_drsc_trace_csv(
201            &run_dir.join(&trace_csv),
202            dataset,
203            nominal,
204            residuals,
205            signs,
206            baselines,
207            grammar,
208            dsa,
209            feature_index,
210            &drsc_window,
211        )?;
212        files.push(figure_file.clone());
213        let combined_figure_file = "drsc_dsa_combined.png".to_string();
214        let combined_trace_csv = "drsc_dsa_combined.csv".to_string();
215        let combined_trace = build_drsc_dsa_combined_trace(
216            dataset,
217            nominal,
218            residuals,
219            signs,
220            baselines,
221            grammar,
222            dsa,
223            feature_index,
224            &drsc_window,
225        )?;
226        draw_drsc_dsa_combined_chart(
227            &figure_dir.join(&combined_figure_file),
228            &combined_trace,
229            &drsc_window,
230        )?;
231        write_drsc_dsa_combined_trace_csv(&run_dir.join(&combined_trace_csv), &combined_trace)?;
232        files.push(combined_figure_file.clone());
233        let dsa_figure_file = "dsa_top_feature.png".to_string();
234        let dsa_trace_csv = "dsa_top_feature.csv".to_string();
235        draw_dsa_focus_chart(
236            &figure_dir.join(&dsa_figure_file),
237            dataset,
238            residuals,
239            baselines,
240            grammar,
241            dsa,
242            feature_index,
243            config,
244            &drsc_window,
245        )?;
246        write_dsa_focus_trace_csv(
247            &run_dir.join(&dsa_trace_csv),
248            dataset,
249            baselines,
250            grammar,
251            dsa,
252            feature_index,
253            &drsc_window,
254        )?;
255        files.push(dsa_figure_file.clone());
256        (
257            Some(DrscManifest {
258                figure_file,
259                trace_csv,
260                feature_index,
261                feature_name: nominal.features[feature_index].feature_name.clone(),
262                failure_run_index: drsc_window.failure_run_index,
263                window_start_run_index: drsc_window.window_start,
264                window_end_run_index: drsc_window.window_end,
265                first_persistent_boundary_run: drsc_window.first_persistent_boundary_run,
266                first_persistent_violation_run: drsc_window.first_persistent_violation_run,
267            }),
268            Some(DrscDsaCombinedManifest {
269                figure_file: combined_figure_file,
270                trace_csv: combined_trace_csv,
271                feature_index,
272                feature_name: nominal.features[feature_index].feature_name.clone(),
273                failure_run_index: drsc_window.failure_run_index,
274                window_start_run_index: drsc_window.window_start,
275                window_end_run_index: drsc_window.window_end,
276                feature_selection_basis:
277                    "Top boundary-activity feature selected by benchmark feature ranking."
278                        .into(),
279                normalization_note:
280                    "Residual is residual/rho, drift is drift/drift-threshold, and slew is slew/slew-threshold; each scale falls back to 1.0 only if a saved threshold is non-positive."
281                        .into(),
282                state_display_note:
283                    "Display band uses the actual persistent DSFB state alias mapping: Admissible, Boundary, Violation."
284                        .into(),
285                dsa_rendering_note:
286                    "Panel 3 uses a two-strip binary rendering: upper strip is feature-level DSA alert; lower strip is the corroborated run-level DSA alert."
287                        .into(),
288                baseline_rendering_note:
289                    "Panel 4 uses run-level threshold and EWMA any-feature alarm timing as binary trigger rows."
290                        .into(),
291            }),
292            Some(DsaFocusManifest {
293                figure_file: dsa_figure_file,
294                trace_csv: dsa_trace_csv,
295                feature_index,
296                feature_name: nominal.features[feature_index].feature_name.clone(),
297                failure_run_index: drsc_window.failure_run_index,
298                window_start_run_index: drsc_window.window_start,
299                window_end_run_index: drsc_window.window_end,
300            }),
301        )
302    } else {
303        (None, None, None)
304    };
305
306    Ok(FigureManifest {
307        figure_dir,
308        files,
309        drsc,
310        drsc_dsa_combined,
311        dsa_focus,
312    })
313}
314
315fn draw_missingness_chart(figure_dir: &Path, dataset: &PreparedDataset) -> Result<()> {
316    let mut rows = dataset
317        .feature_names
318        .iter()
319        .enumerate()
320        .map(|(index, name)| (name.clone(), dataset.per_feature_missing_fraction[index]))
321        .collect::<Vec<_>>();
322    rows.sort_by(|left, right| {
323        right
324            .1
325            .partial_cmp(&left.1)
326            .unwrap_or(std::cmp::Ordering::Equal)
327    });
328    rows.truncate(20);
329
330    let out_path = figure_dir.join("missingness_top20.png");
331    let root = BitMapBackend::new(&out_path, (WIDTH, HEIGHT)).into_drawing_area();
332    root.fill(&WHITE).map_err(plot_error)?;
333    let max_missing = rows
334        .iter()
335        .map(|(_, value)| *value)
336        .fold(0.0_f64, f64::max)
337        .max(0.1);
338
339    let mut chart = ChartBuilder::on(&root)
340        .caption("SECOM top-20 feature missingness", ("sans-serif", 28))
341        .margin(20)
342        .x_label_area_size(60)
343        .y_label_area_size(60)
344        .build_cartesian_2d(0..rows.len(), 0.0f64..max_missing * 1.15)
345        .map_err(plot_error)?;
346
347    chart
348        .configure_mesh()
349        .disable_mesh()
350        .x_labels(rows.len())
351        .x_label_formatter(&|idx| rows.get(*idx).map(|row| row.0.clone()).unwrap_or_default())
352        .y_desc("Missing fraction")
353        .draw()
354        .map_err(plot_error)?;
355
356    chart
357        .draw_series(rows.iter().enumerate().map(|(index, (_, value))| {
358            Rectangle::new([(index, 0.0), (index + 1, *value)], BLUE.mix(0.7).filled())
359        }))
360        .map_err(plot_error)?;
361
362    root.present().map_err(plot_error)?;
363    Ok(())
364}
365
366fn draw_multi_feature_chart<F>(
367    output_path: &Path,
368    title: &str,
369    y_desc: &str,
370    top_feature_indices: &[usize],
371    nominal: &NominalModel,
372    residuals: &ResidualSet,
373    signs: &SignSet,
374    baselines: &BaselineSet,
375    selector: F,
376) -> Result<()>
377where
378    F: Fn(usize, &ResidualSet, &SignSet, &BaselineSet) -> Vec<f64>,
379{
380    let root = BitMapBackend::new(output_path, (WIDTH, HEIGHT)).into_drawing_area();
381    root.fill(&WHITE).map_err(plot_error)?;
382    let titled = root.titled(title, ("sans-serif", 28)).map_err(plot_error)?;
383    let columns = 3usize;
384    let rows = top_feature_indices.len().max(1).div_ceil(columns);
385    let areas = titled.split_evenly((rows, columns));
386    let x_upper = residuals
387        .traces
388        .first()
389        .map(|trace| trace.norms.len())
390        .unwrap_or(0);
391
392    for (area, feature_index) in areas.into_iter().zip(top_feature_indices.iter().copied()) {
393        let values = selector(feature_index, residuals, signs, baselines);
394        let (min_value, max_value) = value_range(&values);
395        let mut chart = ChartBuilder::on(&area)
396            .margin(10)
397            .x_label_area_size(30)
398            .y_label_area_size(45)
399            .caption(
400                nominal.features[feature_index].feature_name.as_str(),
401                ("sans-serif", 20),
402            )
403            .build_cartesian_2d(0..x_upper, min_value..max_value)
404            .map_err(plot_error)?;
405
406        chart
407            .configure_mesh()
408            .x_desc("Run")
409            .y_desc(y_desc)
410            .max_light_lines(4)
411            .draw()
412            .map_err(plot_error)?;
413
414        chart
415            .draw_series(LineSeries::new(
416                values.into_iter().enumerate(),
417                ShapeStyle::from(BLUE).stroke_width(2),
418            ))
419            .map_err(plot_error)?;
420    }
421
422    root.present().map_err(plot_error)?;
423    Ok(())
424}
425
426fn draw_named_series<'a, DB: DrawingBackend>(
427    chart: &mut ChartContext<'a, DB, Cartesian2d<RangedCoordusize, RangedCoordf64>>,
428    start_index: usize,
429    values: &[f64],
430    color: RGBColor,
431    label: &'static str,
432) -> Result<()> {
433    chart
434        .draw_series(LineSeries::new(
435            (start_index..(start_index + values.len())).zip(values.iter().copied()),
436            ShapeStyle::from(color).stroke_width(2),
437        ))
438        .map_err(plot_error)?
439        .label(label)
440        .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], color.stroke_width(2)));
441    Ok(())
442}
443
444fn residual_norms_for_feature(
445    feature_index: usize,
446    residuals: &ResidualSet,
447    _signs: &SignSet,
448    _baselines: &BaselineSet,
449) -> Vec<f64> {
450    residuals.traces[feature_index].norms.clone()
451}
452
453fn drift_for_feature(
454    feature_index: usize,
455    _residuals: &ResidualSet,
456    signs: &SignSet,
457    _baselines: &BaselineSet,
458) -> Vec<f64> {
459    signs.traces[feature_index].drift.clone()
460}
461
462fn ewma_for_feature(
463    feature_index: usize,
464    _residuals: &ResidualSet,
465    _signs: &SignSet,
466    baselines: &BaselineSet,
467) -> Vec<f64> {
468    baselines.ewma[feature_index].ewma.clone()
469}
470
471fn slew_for_feature(
472    feature_index: usize,
473    _residuals: &ResidualSet,
474    signs: &SignSet,
475    _baselines: &BaselineSet,
476) -> Vec<f64> {
477    signs.traces[feature_index].slew.clone()
478}
479
480fn draw_grammar_timeline(
481    figure_dir: &Path,
482    metrics: &BenchmarkMetrics,
483    grammar: &GrammarSet,
484) -> Result<()> {
485    let out_path = figure_dir.join("grammar_timeline.png");
486    let root = BitMapBackend::new(&out_path, (WIDTH, HEIGHT)).into_drawing_area();
487    root.fill(&WHITE).map_err(plot_error)?;
488
489    let feature_indices = metrics.top_feature_indices.clone();
490    let run_count = grammar
491        .traces
492        .first()
493        .map(|trace| trace.states.len())
494        .unwrap_or_default();
495
496    let mut chart = ChartBuilder::on(&root)
497        .caption(
498            "DSFB grammar-state timeline (top features)",
499            ("sans-serif", 28),
500        )
501        .margin(20)
502        .x_label_area_size(50)
503        .y_label_area_size(120)
504        .build_cartesian_2d(0..run_count, 0..feature_indices.len())
505        .map_err(plot_error)?;
506    chart
507        .configure_mesh()
508        .disable_mesh()
509        .x_desc("Run index")
510        .y_labels(feature_indices.len())
511        .y_label_formatter(&|idx| {
512            feature_indices
513                .get(*idx)
514                .map(|feature_index| format!("S{:03}", feature_index + 1))
515                .unwrap_or_default()
516        })
517        .draw()
518        .map_err(plot_error)?;
519
520    for (row_index, feature_index) in feature_indices.iter().enumerate() {
521        let trace = &grammar.traces[*feature_index];
522        for run_index in 0..trace.states.len() {
523            let color = state_color(display_state(trace, run_index));
524            chart
525                .draw_series(std::iter::once(Rectangle::new(
526                    [(run_index, row_index), (run_index + 1, row_index + 1)],
527                    color.filled(),
528                )))
529                .map_err(plot_error)?;
530        }
531    }
532
533    root.present().map_err(plot_error)?;
534    Ok(())
535}
536
537fn draw_baseline_comparison(
538    figure_dir: &Path,
539    metrics: &BenchmarkMetrics,
540    dsa: &DsaEvaluation,
541) -> Result<()> {
542    let out_path = figure_dir.join("benchmark_comparison.png");
543    let root = BitMapBackend::new(&out_path, (WIDTH, HEIGHT)).into_drawing_area();
544    root.fill(&WHITE).map_err(plot_error)?;
545    let areas = root.split_evenly((1, 2));
546    let lead_labels = [
547        (
548            "DSA",
549            dsa.summary.mean_lead_time_runs.unwrap_or(0.0),
550            MAGENTA.mix(0.7),
551        ),
552        (
553            "DSFB raw boundary",
554            metrics
555                .lead_time_summary
556                .mean_raw_boundary_lead_runs
557                .unwrap_or(0.0),
558            BLUE.mix(0.7),
559        ),
560        (
561            "DSFB Violation",
562            metrics
563                .lead_time_summary
564                .mean_raw_violation_lead_runs
565                .unwrap_or(0.0),
566            CYAN.mix(0.7),
567        ),
568        (
569            "EWMA",
570            metrics.lead_time_summary.mean_ewma_lead_runs.unwrap_or(0.0),
571            GREEN.mix(0.7),
572        ),
573        (
574            "CUSUM",
575            metrics
576                .lead_time_summary
577                .mean_cusum_lead_runs
578                .unwrap_or(0.0),
579            RGBColor(120, 70, 20).mix(0.7),
580        ),
581        (
582            "Run energy",
583            dsa.comparison_summary
584                .run_energy
585                .mean_lead_time_runs
586                .unwrap_or(0.0),
587            RGBColor(90, 90, 90).mix(0.7),
588        ),
589        (
590            "PCA T2/SPE",
591            dsa.comparison_summary
592                .pca_fdc
593                .mean_lead_time_runs
594                .unwrap_or(0.0),
595            RGBColor(80, 40, 140).mix(0.7),
596        ),
597        (
598            "Threshold",
599            metrics
600                .lead_time_summary
601                .mean_threshold_lead_runs
602                .unwrap_or(0.0),
603            RED.mix(0.7),
604        ),
605    ];
606    let nuisance_labels = [
607        ("DSA", dsa.summary.pass_run_nuisance_proxy, MAGENTA.mix(0.7)),
608        (
609            "DSFB raw boundary",
610            metrics.summary.pass_run_dsfb_raw_boundary_nuisance_rate,
611            BLUE.mix(0.7),
612        ),
613        (
614            "DSFB Violation",
615            metrics.summary.pass_run_dsfb_raw_violation_nuisance_rate,
616            CYAN.mix(0.7),
617        ),
618        (
619            "EWMA",
620            metrics.summary.pass_run_ewma_nuisance_rate,
621            GREEN.mix(0.7),
622        ),
623        (
624            "CUSUM",
625            metrics.summary.pass_run_cusum_nuisance_rate,
626            RGBColor(120, 70, 20).mix(0.7),
627        ),
628        (
629            "Run energy",
630            metrics.summary.pass_run_run_energy_nuisance_rate,
631            RGBColor(90, 90, 90).mix(0.7),
632        ),
633        (
634            "PCA T2/SPE",
635            metrics.summary.pass_run_pca_fdc_nuisance_rate,
636            RGBColor(80, 40, 140).mix(0.7),
637        ),
638        (
639            "Threshold",
640            metrics.summary.pass_run_threshold_nuisance_rate,
641            RED.mix(0.7),
642        ),
643    ];
644
645    let max_lead = lead_labels
646        .iter()
647        .map(|(_, value, _)| *value)
648        .fold(0.0_f64, f64::max)
649        .max(1.0);
650    let max_nuisance = nuisance_labels
651        .iter()
652        .map(|(_, value, _)| *value)
653        .fold(0.0_f64, f64::max)
654        .max(0.05);
655
656    let mut lead_chart = ChartBuilder::on(&areas[0])
657        .caption("Mean pre-failure lead", ("sans-serif", 24))
658        .margin(20)
659        .x_label_area_size(50)
660        .y_label_area_size(60)
661        .build_cartesian_2d(0..lead_labels.len(), 0.0f64..(max_lead * 1.1))
662        .map_err(plot_error)?;
663    lead_chart
664        .configure_mesh()
665        .disable_mesh()
666        .x_labels(lead_labels.len())
667        .x_label_formatter(&|idx| {
668            lead_labels
669                .get(*idx)
670                .map(|row| row.0.to_string())
671                .unwrap_or_default()
672        })
673        .y_desc("Mean lead runs")
674        .draw()
675        .map_err(plot_error)?;
676    lead_chart
677        .draw_series(
678            lead_labels
679                .iter()
680                .enumerate()
681                .map(|(index, (_label, value, color))| {
682                    Rectangle::new([(index, 0.0f64), (index + 1, *value)], color.filled())
683                }),
684        )
685        .map_err(plot_error)?;
686
687    let mut nuisance_chart = ChartBuilder::on(&areas[1])
688        .caption("Pass-run nuisance proxy", ("sans-serif", 24))
689        .margin(20)
690        .x_label_area_size(50)
691        .y_label_area_size(60)
692        .build_cartesian_2d(0..nuisance_labels.len(), 0.0f64..(max_nuisance * 1.2))
693        .map_err(plot_error)?;
694    nuisance_chart
695        .configure_mesh()
696        .disable_mesh()
697        .x_labels(nuisance_labels.len())
698        .x_label_formatter(&|idx| {
699            nuisance_labels
700                .get(*idx)
701                .map(|row| row.0.to_string())
702                .unwrap_or_default()
703        })
704        .y_desc("Fraction of pass-labeled runs with signal")
705        .draw()
706        .map_err(plot_error)?;
707    nuisance_chart
708        .draw_series(
709            nuisance_labels
710                .iter()
711                .enumerate()
712                .map(|(index, (_label, value, color))| {
713                    Rectangle::new([(index, 0.0f64), (index + 1, *value)], color.filled())
714                }),
715        )
716        .map_err(plot_error)?;
717
718    root.present().map_err(plot_error)?;
719    Ok(())
720}
721
722fn draw_drsc_chart(
723    output_path: &Path,
724    dataset: &PreparedDataset,
725    nominal: &NominalModel,
726    residuals: &ResidualSet,
727    signs: &SignSet,
728    baselines: &BaselineSet,
729    grammar: &GrammarSet,
730    dsa: &DsaEvaluation,
731    feature_index: usize,
732    config: &PipelineConfig,
733    drsc_window: &DrscWindow,
734) -> Result<()> {
735    let feature = &nominal.features[feature_index];
736    let residual_trace = &residuals.traces[feature_index];
737    let sign_trace = &signs.traces[feature_index];
738    let ewma_trace = &baselines.ewma[feature_index];
739    let grammar_trace = &grammar.traces[feature_index];
740    let dsa_trace = &dsa.traces[feature_index];
741
742    let window_start = drsc_window.window_start;
743    let window_end = drsc_window.window_end;
744    let window_runs = window_end.saturating_sub(window_start);
745    let residual_scale = positive_or_one(feature.rho);
746    let drift_scale = positive_or_one(sign_trace.drift_threshold);
747    let slew_scale = positive_or_one(sign_trace.slew_threshold);
748    let ewma_scale = positive_or_one(ewma_trace.threshold);
749
750    let residual_series = residual_trace
751        .residuals
752        .iter()
753        .skip(window_start)
754        .take(window_runs)
755        .map(|value| *value / residual_scale)
756        .collect::<Vec<_>>();
757    let drift_series = sign_trace
758        .drift
759        .iter()
760        .skip(window_start)
761        .take(window_runs)
762        .map(|value| *value / drift_scale)
763        .collect::<Vec<_>>();
764    let slew_series = sign_trace
765        .slew
766        .iter()
767        .skip(window_start)
768        .take(window_runs)
769        .map(|value| *value / slew_scale)
770        .collect::<Vec<_>>();
771    let occupancy_series = residual_trace
772        .norms
773        .iter()
774        .skip(window_start)
775        .take(window_runs)
776        .map(|value| *value / residual_scale)
777        .collect::<Vec<_>>();
778    let ewma_series = ewma_trace
779        .ewma
780        .iter()
781        .skip(window_start)
782        .take(window_runs)
783        .map(|value| *value / ewma_scale)
784        .collect::<Vec<_>>();
785
786    let root = BitMapBackend::new(output_path, (WIDTH, HEIGHT + 420)).into_drawing_area();
787    root.fill(&WHITE).map_err(plot_error)?;
788    let areas = root.split_evenly((4, 1));
789
790    let structure_max = residual_series
791        .iter()
792        .chain(drift_series.iter())
793        .chain(slew_series.iter())
794        .map(|value| value.abs())
795        .fold(1.2_f64, f64::max)
796        .max(1.2);
797    let mut structure_chart = ChartBuilder::on(&areas[0])
798        .caption(
799            format!(
800                "DRSC: persistent-state view for feature {} around failure run {}",
801                feature.feature_name, drsc_window.failure_run_index
802            ),
803            ("sans-serif", 26),
804        )
805        .margin(15)
806        .x_label_area_size(40)
807        .y_label_area_size(70)
808        .build_cartesian_2d(window_start..window_end, -structure_max..structure_max)
809        .map_err(plot_error)?;
810    structure_chart
811        .configure_mesh()
812        .x_desc("Run index")
813        .y_desc("Normalized residual / drift / slew")
814        .draw()
815        .map_err(plot_error)?;
816    structure_chart
817        .draw_series(LineSeries::new(
818            (window_start..window_end).zip(residual_series.iter().copied()),
819            ShapeStyle::from(BLUE).stroke_width(2),
820        ))
821        .map_err(plot_error)?
822        .label("residual / rho")
823        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], BLUE.stroke_width(2)));
824    structure_chart
825        .draw_series(LineSeries::new(
826            (window_start..window_end).zip(drift_series.iter().copied()),
827            ShapeStyle::from(GREEN).stroke_width(2),
828        ))
829        .map_err(plot_error)?
830        .label("drift / drift threshold")
831        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], GREEN.stroke_width(2)));
832    structure_chart
833        .draw_series(LineSeries::new(
834            (window_start..window_end).zip(slew_series.iter().copied()),
835            ShapeStyle::from(MAGENTA).stroke_width(2),
836        ))
837        .map_err(plot_error)?
838        .label("slew / slew threshold")
839        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], MAGENTA.stroke_width(2)));
840    structure_chart
841        .configure_series_labels()
842        .background_style(WHITE.mix(0.9))
843        .border_style(BLACK)
844        .draw()
845        .map_err(plot_error)?;
846
847    let mut state_chart = ChartBuilder::on(&areas[1])
848        .caption(
849            "Persistent deterministic state band (hysteresis confirmed)",
850            ("sans-serif", 24),
851        )
852        .margin(15)
853        .x_label_area_size(40)
854        .y_label_area_size(70)
855        .build_cartesian_2d(window_start as f64..window_end as f64, 0.0f64..3.0f64)
856        .map_err(plot_error)?;
857    state_chart
858        .configure_mesh()
859        .disable_y_mesh()
860        .disable_x_mesh()
861        .x_desc("Run index")
862        .y_labels(0)
863        .draw()
864        .map_err(plot_error)?;
865    for run_index in window_start..window_end {
866        let color = state_color(display_state(grammar_trace, run_index));
867        state_chart
868            .draw_series(std::iter::once(Rectangle::new(
869                [(run_index as f64, 0.0), ((run_index + 1) as f64, 3.0)],
870                color.filled(),
871            )))
872            .map_err(plot_error)?;
873    }
874
875    let dsa_score = dsa_trace
876        .dsa_score
877        .iter()
878        .skip(window_start)
879        .take(window_runs)
880        .copied()
881        .collect::<Vec<_>>();
882    let dsa_score_max = dsa_score
883        .iter()
884        .copied()
885        .fold(config.dsa.alert_tau.max(1.0), f64::max)
886        .max(config.dsa.alert_tau);
887    let mut dsa_chart = ChartBuilder::on(&areas[2])
888        .caption(
889            "DSA persistence-constrained overlay (feature + run level)",
890            ("sans-serif", 24),
891        )
892        .margin(15)
893        .x_label_area_size(40)
894        .y_label_area_size(70)
895        .build_cartesian_2d(window_start..window_end, 0.0f64..(dsa_score_max * 1.15))
896        .map_err(plot_error)?;
897    dsa_chart
898        .configure_mesh()
899        .x_desc("Run index")
900        .y_desc("DSA score")
901        .draw()
902        .map_err(plot_error)?;
903    for run_index in window_start..window_end {
904        if dsa.run_signals.primary_run_alert[run_index] {
905            dsa_chart
906                .draw_series(std::iter::once(Rectangle::new(
907                    [(run_index, 0.0), (run_index + 1, dsa_score_max * 1.15)],
908                    RGBAColor(160, 0, 160, 0.08).filled(),
909                )))
910                .map_err(plot_error)?;
911        } else if !dsa_trace.consistent[run_index] {
912            dsa_chart
913                .draw_series(std::iter::once(Rectangle::new(
914                    [(run_index, 0.0), (run_index + 1, dsa_score_max * 1.15)],
915                    RGBAColor(180, 180, 180, 0.08).filled(),
916                )))
917                .map_err(plot_error)?;
918        }
919    }
920    dsa_chart
921        .draw_series(LineSeries::new(
922            (window_start..window_end).zip(dsa_score.iter().copied()),
923            ShapeStyle::from(RGBColor(160, 0, 160)).stroke_width(2),
924        ))
925        .map_err(plot_error)?
926        .label("feature DSA score")
927        .legend(|(x, y)| {
928            PathElement::new(
929                vec![(x, y), (x + 18, y)],
930                RGBColor(160, 0, 160).stroke_width(2),
931            )
932        });
933    dsa_chart
934        .draw_series(std::iter::once(PathElement::new(
935            vec![
936                (window_start, config.dsa.alert_tau),
937                (window_end, config.dsa.alert_tau),
938            ],
939            RED.mix(0.8).stroke_width(2),
940        )))
941        .map_err(plot_error)?
942        .label("DSA tau")
943        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], RED.mix(0.8).stroke_width(2)));
944    dsa_chart
945        .configure_series_labels()
946        .background_style(WHITE.mix(0.9))
947        .border_style(BLACK)
948        .draw()
949        .map_err(plot_error)?;
950
951    let run_energy_series = baselines.run_energy.energy[window_start..window_end]
952        .iter()
953        .map(|value| *value / positive_or_one(baselines.run_energy.threshold))
954        .collect::<Vec<_>>();
955    let pca_fdc_series = (window_start..window_end)
956        .map(|run_index| {
957            let t2 =
958                baselines.pca_fdc.t2[run_index] / positive_or_one(baselines.pca_fdc.t2_threshold);
959            let spe =
960                baselines.pca_fdc.spe[run_index] / positive_or_one(baselines.pca_fdc.spe_threshold);
961            t2.max(spe)
962        })
963        .collect::<Vec<_>>();
964    let occupancy_max = occupancy_series
965        .iter()
966        .chain(ewma_series.iter())
967        .chain(run_energy_series.iter())
968        .chain(pca_fdc_series.iter())
969        .copied()
970        .fold(1.2_f64, f64::max)
971        .max(1.2);
972    let mut occupancy_chart = ChartBuilder::on(&areas[3])
973        .caption(
974            "Admissibility and run-level comparator overlay",
975            ("sans-serif", 24),
976        )
977        .margin(15)
978        .x_label_area_size(45)
979        .y_label_area_size(70)
980        .build_cartesian_2d(window_start..window_end, 0.0f64..occupancy_max * 1.1)
981        .map_err(plot_error)?;
982    occupancy_chart
983        .configure_mesh()
984        .x_desc("Run index")
985        .y_desc("Normalized occupancy")
986        .draw()
987        .map_err(plot_error)?;
988    for run_index in window_start..window_end {
989        if dataset.labels[run_index] == 1 {
990            occupancy_chart
991                .draw_series(std::iter::once(Rectangle::new(
992                    [(run_index, 0.0), (run_index + 1, occupancy_max * 1.1)],
993                    RGBAColor(160, 0, 0, 0.08).filled(),
994                )))
995                .map_err(plot_error)?;
996        }
997    }
998    occupancy_chart
999        .draw_series(LineSeries::new(
1000            (window_start..window_end).zip(occupancy_series.iter().copied()),
1001            ShapeStyle::from(BLUE).stroke_width(2),
1002        ))
1003        .map_err(plot_error)?
1004        .label("|r| / rho")
1005        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], BLUE.stroke_width(2)));
1006    occupancy_chart
1007        .draw_series(LineSeries::new(
1008            (window_start..window_end).zip(ewma_series.iter().copied()),
1009            ShapeStyle::from(GREEN).stroke_width(2),
1010        ))
1011        .map_err(plot_error)?
1012        .label("EWMA / EWMA threshold")
1013        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], GREEN.stroke_width(2)));
1014    occupancy_chart
1015        .draw_series(LineSeries::new(
1016            (window_start..window_end).zip(run_energy_series.iter().copied()),
1017            ShapeStyle::from(BLACK.mix(0.75)).stroke_width(2),
1018        ))
1019        .map_err(plot_error)?
1020        .label("run energy / threshold")
1021        .legend(|(x, y)| {
1022            PathElement::new(vec![(x, y), (x + 18, y)], BLACK.mix(0.75).stroke_width(2))
1023        });
1024    occupancy_chart
1025        .draw_series(LineSeries::new(
1026            (window_start..window_end).zip(pca_fdc_series.iter().copied()),
1027            ShapeStyle::from(RGBColor(80, 40, 140)).stroke_width(2),
1028        ))
1029        .map_err(plot_error)?
1030        .label("PCA T2/SPE / threshold")
1031        .legend(|(x, y)| {
1032            PathElement::new(
1033                vec![(x, y), (x + 18, y)],
1034                RGBColor(80, 40, 140).stroke_width(2),
1035            )
1036        });
1037    occupancy_chart
1038        .draw_series(std::iter::once(PathElement::new(
1039            vec![
1040                (
1041                    window_start,
1042                    nominal.features[feature_index].rho * 0.0 + 1.0,
1043                ),
1044                (window_end, 1.0),
1045            ],
1046            RED.mix(0.6).stroke_width(2),
1047        )))
1048        .map_err(plot_error)?
1049        .label("violation threshold")
1050        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], RED.mix(0.6).stroke_width(2)));
1051    occupancy_chart
1052        .draw_series(std::iter::once(PathElement::new(
1053            vec![
1054                (window_start, config.boundary_fraction_of_rho),
1055                (window_end, config.boundary_fraction_of_rho),
1056            ],
1057            RGBColor(255, 179, 0).mix(0.8).stroke_width(2),
1058        )))
1059        .map_err(plot_error)?
1060        .label("boundary fraction of rho")
1061        .legend(|(x, y)| {
1062            PathElement::new(
1063                vec![(x, y), (x + 18, y)],
1064                RGBColor(255, 179, 0).mix(0.8).stroke_width(2),
1065            )
1066        });
1067
1068    for (run_index, label, color) in [
1069        (
1070            drsc_window.first_persistent_boundary_run,
1071            "first persistent boundary",
1072            RGBColor(255, 179, 0),
1073        ),
1074        (
1075            drsc_window.first_persistent_violation_run,
1076            "first persistent violation",
1077            RGBColor(200, 0, 0),
1078        ),
1079        (
1080            Some(drsc_window.failure_run_index),
1081            "failure label",
1082            RGBColor(90, 90, 90),
1083        ),
1084    ] {
1085        if let Some(run_index) = run_index {
1086            structure_chart
1087                .draw_series(std::iter::once(PathElement::new(
1088                    vec![(run_index, -structure_max), (run_index, structure_max)],
1089                    color.mix(0.55).stroke_width(2),
1090                )))
1091                .map_err(plot_error)?;
1092            occupancy_chart
1093                .draw_series(std::iter::once(PathElement::new(
1094                    vec![(run_index, 0.0f64), (run_index, occupancy_max * 1.1)],
1095                    color.mix(0.55).stroke_width(2),
1096                )))
1097                .map_err(plot_error)?;
1098            state_chart
1099                .draw_series(std::iter::once(PathElement::new(
1100                    vec![(run_index as f64, 0.0f64), (run_index as f64, 3.0f64)],
1101                    color.mix(0.7).stroke_width(2),
1102                )))
1103                .map_err(plot_error)?
1104                .label(label)
1105                .legend(move |(x, y)| {
1106                    PathElement::new(vec![(x, y), (x + 18, y)], color.mix(0.7).stroke_width(2))
1107                });
1108            dsa_chart
1109                .draw_series(std::iter::once(PathElement::new(
1110                    vec![(run_index, 0.0f64), (run_index, dsa_score_max * 1.15)],
1111                    color.mix(0.55).stroke_width(2),
1112                )))
1113                .map_err(plot_error)?;
1114        }
1115    }
1116    state_chart
1117        .configure_series_labels()
1118        .background_style(WHITE.mix(0.9))
1119        .border_style(BLACK)
1120        .draw()
1121        .map_err(plot_error)?;
1122    occupancy_chart
1123        .configure_series_labels()
1124        .background_style(WHITE.mix(0.9))
1125        .border_style(BLACK)
1126        .draw()
1127        .map_err(plot_error)?;
1128
1129    root.present().map_err(plot_error)?;
1130    Ok(())
1131}
1132
1133fn build_drsc_dsa_combined_trace(
1134    dataset: &PreparedDataset,
1135    nominal: &NominalModel,
1136    residuals: &ResidualSet,
1137    signs: &SignSet,
1138    baselines: &BaselineSet,
1139    grammar: &GrammarSet,
1140    dsa: &DsaEvaluation,
1141    feature_index: usize,
1142    drsc_window: &DrscWindow,
1143) -> Result<Vec<DrscDsaCombinedRow>> {
1144    if drsc_window.window_start >= drsc_window.window_end {
1145        return Err(DsfbSemiconductorError::DatasetFormat(
1146            "combined DRSC+DSA figure requires a non-empty window".into(),
1147        ));
1148    }
1149
1150    let feature = nominal.features.get(feature_index).ok_or_else(|| {
1151        DsfbSemiconductorError::DatasetFormat(format!(
1152            "combined DRSC+DSA figure missing nominal feature index {feature_index}"
1153        ))
1154    })?;
1155    let residual_trace = residuals.traces.get(feature_index).ok_or_else(|| {
1156        DsfbSemiconductorError::DatasetFormat(format!(
1157            "combined DRSC+DSA figure missing residual trace for feature index {feature_index}"
1158        ))
1159    })?;
1160    let sign_trace = signs.traces.get(feature_index).ok_or_else(|| {
1161        DsfbSemiconductorError::DatasetFormat(format!(
1162            "combined DRSC+DSA figure missing sign trace for feature index {feature_index}"
1163        ))
1164    })?;
1165    let grammar_trace = grammar.traces.get(feature_index).ok_or_else(|| {
1166        DsfbSemiconductorError::DatasetFormat(format!(
1167            "combined DRSC+DSA figure missing grammar trace for feature index {feature_index}"
1168        ))
1169    })?;
1170    let dsa_trace = dsa.traces.get(feature_index).ok_or_else(|| {
1171        DsfbSemiconductorError::DatasetFormat(format!(
1172            "combined DRSC+DSA figure missing DSA trace for feature index {feature_index}"
1173        ))
1174    })?;
1175
1176    let residual_scale = positive_or_one(feature.rho);
1177    let drift_scale = positive_or_one(sign_trace.drift_threshold);
1178    let slew_scale = positive_or_one(sign_trace.slew_threshold);
1179    let threshold_run_signal = run_level_threshold_signal(residuals);
1180    let ewma_run_signal = run_level_ewma_signal(baselines);
1181
1182    let mut rows = Vec::with_capacity(drsc_window.window_end - drsc_window.window_start);
1183    for run_index in drsc_window.window_start..drsc_window.window_end {
1184        rows.push(DrscDsaCombinedRow {
1185            run_index,
1186            timestamp: dataset.timestamps[run_index]
1187                .format("%Y-%m-%d %H:%M:%S")
1188                .to_string(),
1189            label: dataset.labels[run_index],
1190            feature: feature.feature_name.clone(),
1191            residual_over_rho: residual_trace.residuals[run_index] / residual_scale,
1192            drift_over_threshold: sign_trace.drift[run_index] / drift_scale,
1193            slew_over_threshold: sign_trace.slew[run_index] / slew_scale,
1194            display_state: display_state(grammar_trace, run_index),
1195            persistent_boundary: grammar_trace.persistent_boundary[run_index],
1196            persistent_violation: grammar_trace.persistent_violation[run_index],
1197            feature_dsa_alert: dsa_trace.dsa_alert[run_index],
1198            run_level_dsa_alert: dsa.run_signals.primary_run_alert[run_index],
1199            feature_count_dsa_alert: dsa.run_signals.feature_count_dsa_alert[run_index],
1200            threshold_run_signal: threshold_run_signal[run_index],
1201            ewma_run_signal: ewma_run_signal[run_index],
1202        });
1203    }
1204
1205    if rows.is_empty() {
1206        return Err(DsfbSemiconductorError::DatasetFormat(
1207            "combined DRSC+DSA figure produced no rows".into(),
1208        ));
1209    }
1210
1211    Ok(rows)
1212}
1213
1214fn write_drsc_dsa_combined_trace_csv(
1215    output_path: &Path,
1216    rows: &[DrscDsaCombinedRow],
1217) -> Result<()> {
1218    if rows.is_empty() {
1219        return Err(DsfbSemiconductorError::DatasetFormat(
1220            "combined DRSC+DSA CSV requires at least one row".into(),
1221        ));
1222    }
1223
1224    let mut writer = Writer::from_path(output_path)?;
1225    writer.write_record([
1226        "run_index",
1227        "timestamp",
1228        "label",
1229        "feature",
1230        "residual_over_rho",
1231        "drift_over_threshold",
1232        "slew_over_threshold",
1233        "display_state",
1234        "persistent_boundary",
1235        "persistent_violation",
1236        "feature_dsa_alert",
1237        "run_level_dsa_alert",
1238        "feature_count_dsa_alert",
1239        "threshold_run_signal",
1240        "ewma_run_signal",
1241    ])?;
1242
1243    for row in rows {
1244        writer.write_record([
1245            row.run_index.to_string(),
1246            row.timestamp.clone(),
1247            row.label.to_string(),
1248            row.feature.clone(),
1249            row.residual_over_rho.to_string(),
1250            row.drift_over_threshold.to_string(),
1251            row.slew_over_threshold.to_string(),
1252            format!("{:?}", row.display_state),
1253            row.persistent_boundary.to_string(),
1254            row.persistent_violation.to_string(),
1255            row.feature_dsa_alert.to_string(),
1256            row.run_level_dsa_alert.to_string(),
1257            row.feature_count_dsa_alert.to_string(),
1258            row.threshold_run_signal.to_string(),
1259            row.ewma_run_signal.to_string(),
1260        ])?;
1261    }
1262    writer.flush()?;
1263    Ok(())
1264}
1265
1266fn draw_drsc_dsa_combined_chart(
1267    output_path: &Path,
1268    rows: &[DrscDsaCombinedRow],
1269    drsc_window: &DrscWindow,
1270) -> Result<()> {
1271    if rows.is_empty() {
1272        return Err(DsfbSemiconductorError::DatasetFormat(
1273            "combined DRSC+DSA figure requires at least one row".into(),
1274        ));
1275    }
1276
1277    let window_start = rows.first().map(|row| row.run_index).unwrap_or(0);
1278    let window_end = rows.last().map(|row| row.run_index + 1).unwrap_or(0);
1279    let feature_name = rows
1280        .first()
1281        .map(|row| row.feature.clone())
1282        .unwrap_or_else(|| "unknown".into());
1283    let residual_points = rows
1284        .iter()
1285        .map(|row| (row.run_index, row.residual_over_rho))
1286        .collect::<Vec<_>>();
1287    let drift_points = rows
1288        .iter()
1289        .map(|row| (row.run_index, row.drift_over_threshold))
1290        .collect::<Vec<_>>();
1291    let slew_points = rows
1292        .iter()
1293        .map(|row| (row.run_index, row.slew_over_threshold))
1294        .collect::<Vec<_>>();
1295
1296    let root =
1297        BitMapBackend::new(output_path, (COMBINED_WIDTH, COMBINED_HEIGHT)).into_drawing_area();
1298    root.fill(&WHITE).map_err(plot_error)?;
1299
1300    // Non-equal panel heights: signal panel 40%, state/DSA/scalar each 20%.
1301    let total_h = COMBINED_HEIGHT as f64;
1302    let panel_heights = [
1303        (total_h * 0.40) as u32,
1304        (total_h * 0.20) as u32,
1305        (total_h * 0.20) as u32,
1306        (total_h * 0.20) as u32,
1307    ];
1308    let mut panel_areas = Vec::with_capacity(4);
1309    let mut y_offset = 0;
1310    for h in &panel_heights {
1311        panel_areas.push(root.clone().shrink((0u32, y_offset), (COMBINED_WIDTH, *h)));
1312        y_offset += h;
1313    }
1314
1315    let structure_max = residual_points
1316        .iter()
1317        .chain(drift_points.iter())
1318        .chain(slew_points.iter())
1319        .map(|(_, value)| value.abs())
1320        .fold(1.2_f64, f64::max)
1321        .max(1.2);
1322    let shared_x_labels = window_end.saturating_sub(window_start).max(2).min(8);
1323
1324    // Panel (a): Continuous signal layer.
1325    let mut structure_chart = ChartBuilder::on(&panel_areas[0])
1326        .caption(
1327            format!(
1328                "(a) Normalized signals \u{2014} {} (runs {}\u{2013}{}, failure at {})",
1329                feature_name,
1330                window_start,
1331                window_end.saturating_sub(1),
1332                drsc_window.failure_run_index,
1333            ),
1334            ("sans-serif", 36),
1335        )
1336        .margin(22)
1337        .x_label_area_size(14)
1338        .y_label_area_size(100)
1339        .build_cartesian_2d(window_start..window_end, -structure_max..structure_max)
1340        .map_err(plot_error)?;
1341    structure_chart
1342        .configure_mesh()
1343        .disable_x_mesh()
1344        .light_line_style(WHITE)
1345        .x_labels(shared_x_labels)
1346        .y_desc("r/\u{03c1},  d/d\u{209c},  s/s\u{209c}")
1347        .label_style(("sans-serif", 26))
1348        .draw()
1349        .map_err(plot_error)?;
1350    // Zero baseline.
1351    structure_chart
1352        .draw_series(std::iter::once(PathElement::new(
1353            vec![(window_start, 0.0), (window_end, 0.0)],
1354            BLACK.mix(0.20).stroke_width(1),
1355        )))
1356        .map_err(plot_error)?;
1357    // Residual: solid black.
1358    structure_chart
1359        .draw_series(LineSeries::new(
1360            residual_points.iter().copied(),
1361            BLACK.stroke_width(3),
1362        ))
1363        .map_err(plot_error)?
1364        .label("residual / \u{03c1}")
1365        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 28, y)], BLACK.stroke_width(3)));
1366    // Drift: dashed mid-gray (drawn as skip-segment pairs for plotters compatibility).
1367    structure_chart
1368        .draw_series(
1369            drift_points
1370                .windows(2)
1371                .enumerate()
1372                .filter(|(index, _)| index % 2 == 0)
1373                .map(|(_, segment)| {
1374                    PathElement::new(
1375                        vec![(segment[0].0, segment[0].1), (segment[1].0, segment[1].1)],
1376                        RGBColor(80, 80, 80).stroke_width(3),
1377                    )
1378                }),
1379        )
1380        .map_err(plot_error)?
1381        .label("drift / d\u{209c}")
1382        .legend(|(x, y)| {
1383            PathElement::new(
1384                vec![(x, y), (x + 10, y), (x + 18, y), (x + 28, y)],
1385                RGBColor(80, 80, 80).stroke_width(3),
1386            )
1387        });
1388    // Slew: light-gray dotted line + circle markers.
1389    structure_chart
1390        .draw_series(LineSeries::new(
1391            slew_points.iter().copied(),
1392            RGBColor(150, 150, 150).stroke_width(1),
1393        ))
1394        .map_err(plot_error)?;
1395    structure_chart
1396        .draw_series(rows.iter().map(|row| {
1397            Circle::new(
1398                (row.run_index, row.slew_over_threshold),
1399                6,
1400                RGBColor(150, 150, 150).filled(),
1401            )
1402        }))
1403        .map_err(plot_error)?
1404        .label("slew / s\u{209c}")
1405        .legend(|(x, y)| Circle::new((x + 14, y), 5, RGBColor(150, 150, 150).filled()));
1406    structure_chart
1407        .configure_series_labels()
1408        .position(SeriesLabelPosition::UpperLeft)
1409        .background_style(WHITE.mix(0.94))
1410        .border_style(BLACK)
1411        .label_font(("sans-serif", 24))
1412        .draw()
1413        .map_err(plot_error)?;
1414
1415    // Panel (b): Deterministic DSFB state band.
1416    let mut state_chart = ChartBuilder::on(&panel_areas[1])
1417        .caption(
1418            "(b) DSFB state (Admissible / Boundary / Violation)",
1419            ("sans-serif", 32),
1420        )
1421        .margin(22)
1422        .x_label_area_size(14)
1423        .y_label_area_size(100)
1424        .build_cartesian_2d(window_start..window_end, 0.0f64..1.0f64)
1425        .map_err(plot_error)?;
1426    state_chart
1427        .configure_mesh()
1428        .disable_mesh()
1429        .x_labels(shared_x_labels)
1430        .y_labels(0)
1431        .label_style(("sans-serif", 26))
1432        .draw()
1433        .map_err(plot_error)?;
1434    for row in rows {
1435        state_chart
1436            .draw_series(std::iter::once(Rectangle::new(
1437                [(row.run_index, 0.0), (row.run_index + 1, 1.0)],
1438                state_shade(row.display_state).filled(),
1439            )))
1440            .map_err(plot_error)?;
1441    }
1442
1443    // Panel (c): DSA precursor activation.
1444    let mut dsa_chart = ChartBuilder::on(&panel_areas[2])
1445        .caption(
1446            "(c) DSA precursor (top: feature alert, bottom: corroborated run-level)",
1447            ("sans-serif", 32),
1448        )
1449        .margin(22)
1450        .x_label_area_size(14)
1451        .y_label_area_size(100)
1452        .build_cartesian_2d(window_start..window_end, 0.0f64..2.0f64)
1453        .map_err(plot_error)?;
1454    dsa_chart
1455        .configure_mesh()
1456        .disable_mesh()
1457        .x_labels(shared_x_labels)
1458        .y_labels(0)
1459        .label_style(("sans-serif", 26))
1460        .draw()
1461        .map_err(plot_error)?;
1462    dsa_chart
1463        .draw_series(std::iter::once(PathElement::new(
1464            vec![(window_start, 1.0), (window_end, 1.0)],
1465            BLACK.mix(0.18).stroke_width(1),
1466        )))
1467        .map_err(plot_error)?;
1468    for row in rows {
1469        if row.run_level_dsa_alert {
1470            dsa_chart
1471                .draw_series(std::iter::once(Rectangle::new(
1472                    [(row.run_index, 0.12), (row.run_index + 1, 0.88)],
1473                    RGBColor(110, 110, 110).filled(),
1474                )))
1475                .map_err(plot_error)?;
1476        }
1477        if row.feature_dsa_alert {
1478            dsa_chart
1479                .draw_series(std::iter::once(Rectangle::new(
1480                    [(row.run_index, 1.12), (row.run_index + 1, 1.88)],
1481                    BLACK.filled(),
1482                )))
1483                .map_err(plot_error)?;
1484        }
1485    }
1486
1487    // Panel (d): Scalar baseline trigger timing.
1488    let mut scalar_chart = ChartBuilder::on(&panel_areas[3])
1489        .caption(
1490            "(d) Scalar triggers (top: threshold, bottom: EWMA)",
1491            ("sans-serif", 32),
1492        )
1493        .margin(22)
1494        .x_label_area_size(50)
1495        .y_label_area_size(100)
1496        .build_cartesian_2d(window_start..window_end, 0.0f64..2.0f64)
1497        .map_err(plot_error)?;
1498    scalar_chart
1499        .configure_mesh()
1500        .disable_mesh()
1501        .x_desc("Run index")
1502        .y_labels(0)
1503        .label_style(("sans-serif", 26))
1504        .draw()
1505        .map_err(plot_error)?;
1506    scalar_chart
1507        .draw_series(std::iter::once(PathElement::new(
1508            vec![(window_start, 1.0), (window_end, 1.0)],
1509            BLACK.mix(0.18).stroke_width(1),
1510        )))
1511        .map_err(plot_error)?;
1512    for row in rows {
1513        if row.threshold_run_signal {
1514            scalar_chart
1515                .draw_series(std::iter::once(Rectangle::new(
1516                    [(row.run_index, 1.12), (row.run_index + 1, 1.88)],
1517                    RGBColor(64, 64, 64).filled(),
1518                )))
1519                .map_err(plot_error)?;
1520        }
1521        if row.ewma_run_signal {
1522            scalar_chart
1523                .draw_series(std::iter::once(Rectangle::new(
1524                    [(row.run_index, 0.12), (row.run_index + 1, 0.88)],
1525                    RGBColor(160, 160, 160).filled(),
1526                )))
1527                .map_err(plot_error)?;
1528        }
1529    }
1530
1531    // Failure marker across all panels (dashed segments for grayscale visibility).
1532    for (chart, y_min, y_max) in [
1533        (&mut structure_chart, -structure_max, structure_max),
1534        (&mut state_chart, 0.0, 1.0),
1535        (&mut dsa_chart, 0.0, 2.0),
1536        (&mut scalar_chart, 0.0, 2.0),
1537    ] {
1538        draw_failure_marker_dashed(chart, drsc_window.failure_run_index, y_min, y_max)?;
1539    }
1540
1541    // Annotations.
1542    if let Some(candidate) = boundary_filtered_annotation(rows) {
1543        annotate_chart(
1544            &mut state_chart,
1545            candidate.run_index,
1546            0.7,
1547            candidate.run_index.saturating_sub(6).max(window_start + 1),
1548            0.86,
1549            &candidate.label,
1550        )?;
1551    }
1552    if let Some(candidate) = precursor_annotation(rows) {
1553        annotate_chart(
1554            &mut dsa_chart,
1555            candidate.run_index,
1556            0.5,
1557            (candidate.run_index + 2).min(window_end.saturating_sub(1)),
1558            0.30,
1559            &candidate.label,
1560        )?;
1561    }
1562    if let Some(candidate) = scalar_annotation(rows) {
1563        annotate_chart(
1564            &mut scalar_chart,
1565            candidate.run_index,
1566            1.5,
1567            (candidate.run_index + 3).min(window_end.saturating_sub(1)),
1568            1.74,
1569            &candidate.label,
1570        )?;
1571    }
1572
1573    root.present().map_err(plot_error)?;
1574    Ok(())
1575}
1576
1577fn write_drsc_trace_csv(
1578    output_path: &Path,
1579    dataset: &PreparedDataset,
1580    nominal: &NominalModel,
1581    residuals: &ResidualSet,
1582    signs: &SignSet,
1583    baselines: &BaselineSet,
1584    grammar: &GrammarSet,
1585    dsa: &DsaEvaluation,
1586    feature_index: usize,
1587    drsc_window: &DrscWindow,
1588) -> Result<()> {
1589    let feature = &nominal.features[feature_index];
1590    let residual_trace = &residuals.traces[feature_index];
1591    let sign_trace = &signs.traces[feature_index];
1592    let ewma_trace = &baselines.ewma[feature_index];
1593    let grammar_trace = &grammar.traces[feature_index];
1594    let dsa_trace = &dsa.traces[feature_index];
1595    let residual_scale = positive_or_one(feature.rho);
1596    let drift_scale = positive_or_one(sign_trace.drift_threshold);
1597    let slew_scale = positive_or_one(sign_trace.slew_threshold);
1598    let ewma_scale = positive_or_one(ewma_trace.threshold);
1599    let run_energy_scale = positive_or_one(baselines.run_energy.threshold);
1600    let pca_t2_scale = positive_or_one(baselines.pca_fdc.t2_threshold);
1601    let pca_spe_scale = positive_or_one(baselines.pca_fdc.spe_threshold);
1602
1603    let mut writer = Writer::from_path(output_path)?;
1604    writer.write_record([
1605        "run_index",
1606        "timestamp",
1607        "label",
1608        "feature",
1609        "residual",
1610        "residual_norm",
1611        "residual_over_rho",
1612        "drift",
1613        "drift_over_threshold",
1614        "slew",
1615        "slew_over_threshold",
1616        "ewma",
1617        "ewma_over_threshold",
1618        "run_energy",
1619        "run_energy_over_threshold",
1620        "pca_t2",
1621        "pca_t2_over_threshold",
1622        "pca_spe",
1623        "pca_spe_over_threshold",
1624        "threshold_alarm",
1625        "ewma_alarm",
1626        "run_energy_alarm",
1627        "pca_fdc_alarm",
1628        "raw_state",
1629        "confirmed_state",
1630        "persistent_state",
1631        "raw_reason",
1632        "confirmed_reason",
1633        "persistent_boundary",
1634        "persistent_violation",
1635        "boundary_density_W",
1636        "drift_persistence_W",
1637        "slew_density_W",
1638        "ewma_occupancy_W",
1639        "motif_recurrence_W",
1640        "consistent",
1641        "dsa_score",
1642        "dsa_active",
1643        "dsa_alert",
1644        "primary_run_signal",
1645        "primary_run_alert",
1646        "any_feature_dsa_alert",
1647        "any_feature_raw_violation",
1648        "feature_count_dsa_alert",
1649        "is_failure_run",
1650        "is_first_persistent_boundary_before_failure",
1651        "is_first_persistent_violation_before_failure",
1652    ])?;
1653
1654    for run_index in drsc_window.window_start..drsc_window.window_end {
1655        writer.write_record([
1656            run_index.to_string(),
1657            dataset.timestamps[run_index]
1658                .format("%Y-%m-%d %H:%M:%S")
1659                .to_string(),
1660            dataset.labels[run_index].to_string(),
1661            feature.feature_name.clone(),
1662            residual_trace.residuals[run_index].to_string(),
1663            residual_trace.norms[run_index].to_string(),
1664            (residual_trace.residuals[run_index] / residual_scale).to_string(),
1665            sign_trace.drift[run_index].to_string(),
1666            (sign_trace.drift[run_index] / drift_scale).to_string(),
1667            sign_trace.slew[run_index].to_string(),
1668            (sign_trace.slew[run_index] / slew_scale).to_string(),
1669            ewma_trace.ewma[run_index].to_string(),
1670            (ewma_trace.ewma[run_index] / ewma_scale).to_string(),
1671            baselines.run_energy.energy[run_index].to_string(),
1672            (baselines.run_energy.energy[run_index] / run_energy_scale).to_string(),
1673            baselines.pca_fdc.t2[run_index].to_string(),
1674            (baselines.pca_fdc.t2[run_index] / pca_t2_scale).to_string(),
1675            baselines.pca_fdc.spe[run_index].to_string(),
1676            (baselines.pca_fdc.spe[run_index] / pca_spe_scale).to_string(),
1677            residual_trace.threshold_alarm[run_index].to_string(),
1678            ewma_trace.alarm[run_index].to_string(),
1679            baselines.run_energy.alarm[run_index].to_string(),
1680            baselines.pca_fdc.alarm[run_index].to_string(),
1681            format!("{:?}", grammar_trace.raw_states[run_index]),
1682            format!("{:?}", grammar_trace.states[run_index]),
1683            format!("{:?}", display_state(grammar_trace, run_index)),
1684            format!("{:?}", grammar_trace.raw_reasons[run_index]),
1685            format!("{:?}", grammar_trace.reasons[run_index]),
1686            grammar_trace.persistent_boundary[run_index].to_string(),
1687            grammar_trace.persistent_violation[run_index].to_string(),
1688            dsa_trace.boundary_density_w[run_index].to_string(),
1689            dsa_trace.drift_persistence_w[run_index].to_string(),
1690            dsa_trace.slew_density_w[run_index].to_string(),
1691            dsa_trace.ewma_occupancy_w[run_index].to_string(),
1692            dsa_trace.motif_recurrence_w[run_index].to_string(),
1693            dsa_trace.consistent[run_index].to_string(),
1694            dsa_trace.dsa_score[run_index].to_string(),
1695            dsa_trace.dsa_active[run_index].to_string(),
1696            dsa_trace.dsa_alert[run_index].to_string(),
1697            dsa.run_signals.primary_run_signal.clone(),
1698            dsa.run_signals.primary_run_alert[run_index].to_string(),
1699            dsa.run_signals.any_feature_dsa_alert[run_index].to_string(),
1700            dsa.run_signals.any_feature_raw_violation[run_index].to_string(),
1701            dsa.run_signals.feature_count_dsa_alert[run_index].to_string(),
1702            (run_index == drsc_window.failure_run_index).to_string(),
1703            (Some(run_index) == drsc_window.first_persistent_boundary_run).to_string(),
1704            (Some(run_index) == drsc_window.first_persistent_violation_run).to_string(),
1705        ])?;
1706    }
1707    writer.flush()?;
1708    Ok(())
1709}
1710
1711fn draw_dsa_focus_chart(
1712    output_path: &Path,
1713    _dataset: &PreparedDataset,
1714    residuals: &ResidualSet,
1715    baselines: &BaselineSet,
1716    grammar: &GrammarSet,
1717    dsa: &DsaEvaluation,
1718    feature_index: usize,
1719    config: &PipelineConfig,
1720    drsc_window: &DrscWindow,
1721) -> Result<()> {
1722    let dsa_trace = &dsa.traces[feature_index];
1723    let grammar_trace = &grammar.traces[feature_index];
1724    let threshold_trace = &residuals.traces[feature_index];
1725    let ewma_trace = &baselines.ewma[feature_index];
1726    let cusum_trace = &baselines.cusum[feature_index];
1727    let window_start = drsc_window.window_start;
1728    let window_end = drsc_window.window_end;
1729    let window_runs = window_end.saturating_sub(window_start);
1730
1731    let root = BitMapBackend::new(output_path, (WIDTH, HEIGHT + 250)).into_drawing_area();
1732    root.fill(&WHITE).map_err(plot_error)?;
1733    let areas = root.split_evenly((3, 1));
1734
1735    let boundary_density = dsa_trace
1736        .boundary_density_w
1737        .iter()
1738        .skip(window_start)
1739        .take(window_runs)
1740        .copied()
1741        .collect::<Vec<_>>();
1742    let drift_persistence = dsa_trace
1743        .drift_persistence_w
1744        .iter()
1745        .skip(window_start)
1746        .take(window_runs)
1747        .copied()
1748        .collect::<Vec<_>>();
1749    let slew_density = dsa_trace
1750        .slew_density_w
1751        .iter()
1752        .skip(window_start)
1753        .take(window_runs)
1754        .copied()
1755        .collect::<Vec<_>>();
1756    let ewma_occupancy = dsa_trace
1757        .ewma_occupancy_w
1758        .iter()
1759        .skip(window_start)
1760        .take(window_runs)
1761        .copied()
1762        .collect::<Vec<_>>();
1763    let motif_recurrence = dsa_trace
1764        .motif_recurrence_w
1765        .iter()
1766        .skip(window_start)
1767        .take(window_runs)
1768        .copied()
1769        .collect::<Vec<_>>();
1770    let dsa_score = dsa_trace
1771        .dsa_score
1772        .iter()
1773        .skip(window_start)
1774        .take(window_runs)
1775        .copied()
1776        .collect::<Vec<_>>();
1777
1778    let mut feature_chart = ChartBuilder::on(&areas[0])
1779        .caption(
1780            format!(
1781                "DSA structural features for feature {} around failure run {}",
1782                dsa_trace.feature_name, drsc_window.failure_run_index
1783            ),
1784            ("sans-serif", 26),
1785        )
1786        .margin(15)
1787        .x_label_area_size(40)
1788        .y_label_area_size(70)
1789        .build_cartesian_2d(window_start..window_end, 0.0f64..1.05f64)
1790        .map_err(plot_error)?;
1791    feature_chart
1792        .configure_mesh()
1793        .x_desc("Run index")
1794        .y_desc("Rolling structural features")
1795        .draw()
1796        .map_err(plot_error)?;
1797    draw_named_series(
1798        &mut feature_chart,
1799        window_start,
1800        &boundary_density,
1801        BLUE,
1802        "boundary density",
1803    )?;
1804    draw_named_series(
1805        &mut feature_chart,
1806        window_start,
1807        &drift_persistence,
1808        GREEN,
1809        "drift persistence",
1810    )?;
1811    draw_named_series(
1812        &mut feature_chart,
1813        window_start,
1814        &slew_density,
1815        MAGENTA,
1816        "slew density",
1817    )?;
1818    draw_named_series(
1819        &mut feature_chart,
1820        window_start,
1821        &ewma_occupancy,
1822        CYAN,
1823        "EWMA occupancy",
1824    )?;
1825    draw_named_series(
1826        &mut feature_chart,
1827        window_start,
1828        &motif_recurrence,
1829        RGBColor(120, 70, 20),
1830        "motif recurrence",
1831    )?;
1832    feature_chart
1833        .configure_series_labels()
1834        .background_style(WHITE.mix(0.9))
1835        .border_style(BLACK)
1836        .draw()
1837        .map_err(plot_error)?;
1838
1839    let score_max = dsa_score
1840        .iter()
1841        .copied()
1842        .fold(config.dsa.alert_tau.max(1.0), f64::max)
1843        .max(config.dsa.alert_tau);
1844    let mut score_chart = ChartBuilder::on(&areas[1])
1845        .caption(
1846            "DSA score, consistency, and persistence gate",
1847            ("sans-serif", 24),
1848        )
1849        .margin(15)
1850        .x_label_area_size(40)
1851        .y_label_area_size(70)
1852        .build_cartesian_2d(window_start..window_end, 0.0f64..(score_max * 1.15))
1853        .map_err(plot_error)?;
1854    score_chart
1855        .configure_mesh()
1856        .x_desc("Run index")
1857        .y_desc("DSA score")
1858        .draw()
1859        .map_err(plot_error)?;
1860    for run_index in window_start..window_end {
1861        if dsa_trace.dsa_alert[run_index] {
1862            score_chart
1863                .draw_series(std::iter::once(Rectangle::new(
1864                    [(run_index, 0.0), (run_index + 1, score_max * 1.15)],
1865                    RGBAColor(160, 0, 160, 0.10).filled(),
1866                )))
1867                .map_err(plot_error)?;
1868        } else if !dsa_trace.consistent[run_index] {
1869            score_chart
1870                .draw_series(std::iter::once(Rectangle::new(
1871                    [(run_index, 0.0), (run_index + 1, score_max * 1.15)],
1872                    RGBAColor(180, 180, 180, 0.10).filled(),
1873                )))
1874                .map_err(plot_error)?;
1875        }
1876    }
1877    draw_named_series(
1878        &mut score_chart,
1879        window_start,
1880        &dsa_score,
1881        RGBColor(160, 0, 160),
1882        "DSA score",
1883    )?;
1884    score_chart
1885        .draw_series(std::iter::once(PathElement::new(
1886            vec![
1887                (window_start, config.dsa.alert_tau),
1888                (window_end, config.dsa.alert_tau),
1889            ],
1890            RED.mix(0.8).stroke_width(2),
1891        )))
1892        .map_err(plot_error)?
1893        .label("tau")
1894        .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 18, y)], RED.mix(0.8).stroke_width(2)));
1895    score_chart
1896        .configure_series_labels()
1897        .background_style(WHITE.mix(0.9))
1898        .border_style(BLACK)
1899        .draw()
1900        .map_err(plot_error)?;
1901
1902    let raw_boundary_flags = grammar_trace
1903        .raw_states
1904        .iter()
1905        .map(|state| *state == GrammarState::Boundary)
1906        .collect::<Vec<_>>();
1907    let raw_violation_flags = grammar_trace
1908        .raw_states
1909        .iter()
1910        .map(|state| *state == GrammarState::Violation)
1911        .collect::<Vec<_>>();
1912    let signal_rows = [
1913        ("DSA alert", RGBColor(160, 0, 160), &dsa_trace.dsa_alert),
1914        ("raw boundary", RGBColor(255, 179, 0), &raw_boundary_flags),
1915        ("raw violation", RGBColor(200, 0, 0), &raw_violation_flags),
1916        ("threshold", RED, &threshold_trace.threshold_alarm),
1917        ("EWMA", GREEN, &ewma_trace.alarm),
1918        ("CUSUM", RGBColor(120, 70, 20), &cusum_trace.alarm),
1919        ("run energy", BLACK, &baselines.run_energy.alarm),
1920        (
1921            "PCA T2/SPE",
1922            RGBColor(80, 40, 140),
1923            &baselines.pca_fdc.alarm,
1924        ),
1925    ];
1926    let mut band_chart = ChartBuilder::on(&areas[2])
1927        .caption(
1928            "Feature-level alert band across DSA and comparators",
1929            ("sans-serif", 24),
1930        )
1931        .margin(15)
1932        .x_label_area_size(45)
1933        .y_label_area_size(100)
1934        .build_cartesian_2d(window_start..window_end, 0..signal_rows.len())
1935        .map_err(plot_error)?;
1936    band_chart
1937        .configure_mesh()
1938        .disable_mesh()
1939        .x_desc("Run index")
1940        .y_labels(signal_rows.len())
1941        .y_label_formatter(&|idx| {
1942            signal_rows
1943                .get(*idx)
1944                .map(|(label, _, _)| label.to_string())
1945                .unwrap_or_default()
1946        })
1947        .draw()
1948        .map_err(plot_error)?;
1949    for (row_index, (_label, color, flags)) in signal_rows.iter().enumerate() {
1950        for run_index in window_start..window_end {
1951            let fill = if flags[run_index] {
1952                color.mix(0.75).filled()
1953            } else {
1954                WHITE.mix(0.0).filled()
1955            };
1956            band_chart
1957                .draw_series(std::iter::once(Rectangle::new(
1958                    [(run_index, row_index), (run_index + 1, row_index + 1)],
1959                    fill,
1960                )))
1961                .map_err(plot_error)?;
1962        }
1963    }
1964
1965    root.present().map_err(plot_error)?;
1966    Ok(())
1967}
1968
1969fn write_dsa_focus_trace_csv(
1970    output_path: &Path,
1971    dataset: &PreparedDataset,
1972    baselines: &BaselineSet,
1973    grammar: &GrammarSet,
1974    dsa: &DsaEvaluation,
1975    feature_index: usize,
1976    drsc_window: &DrscWindow,
1977) -> Result<()> {
1978    let dsa_trace = &dsa.traces[feature_index];
1979    let grammar_trace = &grammar.traces[feature_index];
1980    let ewma_trace = &baselines.ewma[feature_index];
1981    let cusum_trace = &baselines.cusum[feature_index];
1982    let mut writer = Writer::from_path(output_path)?;
1983    writer.write_record([
1984        "run_index",
1985        "timestamp",
1986        "label",
1987        "feature",
1988        "boundary_density_W",
1989        "drift_persistence_W",
1990        "slew_density_W",
1991        "ewma_occupancy_W",
1992        "motif_recurrence_W",
1993        "consistent",
1994        "dsa_score",
1995        "dsa_active",
1996        "dsa_alert",
1997        "primary_run_signal",
1998        "primary_run_alert",
1999        "ewma_alarm",
2000        "cusum_alarm",
2001        "run_energy",
2002        "run_energy_over_threshold",
2003        "run_energy_alarm",
2004        "pca_t2",
2005        "pca_t2_over_threshold",
2006        "pca_spe",
2007        "pca_spe_over_threshold",
2008        "pca_fdc_alarm",
2009        "raw_state",
2010        "persistent_boundary",
2011        "persistent_violation",
2012    ])?;
2013    for run_index in drsc_window.window_start..drsc_window.window_end {
2014        writer.write_record([
2015            run_index.to_string(),
2016            dataset.timestamps[run_index]
2017                .format("%Y-%m-%d %H:%M:%S")
2018                .to_string(),
2019            dataset.labels[run_index].to_string(),
2020            dsa_trace.feature_name.clone(),
2021            dsa_trace.boundary_density_w[run_index].to_string(),
2022            dsa_trace.drift_persistence_w[run_index].to_string(),
2023            dsa_trace.slew_density_w[run_index].to_string(),
2024            dsa_trace.ewma_occupancy_w[run_index].to_string(),
2025            dsa_trace.motif_recurrence_w[run_index].to_string(),
2026            dsa_trace.consistent[run_index].to_string(),
2027            dsa_trace.dsa_score[run_index].to_string(),
2028            dsa_trace.dsa_active[run_index].to_string(),
2029            dsa_trace.dsa_alert[run_index].to_string(),
2030            dsa.run_signals.primary_run_signal.clone(),
2031            dsa.run_signals.primary_run_alert[run_index].to_string(),
2032            ewma_trace.alarm[run_index].to_string(),
2033            cusum_trace.alarm[run_index].to_string(),
2034            baselines.run_energy.energy[run_index].to_string(),
2035            (baselines.run_energy.energy[run_index]
2036                / positive_or_one(baselines.run_energy.threshold))
2037            .to_string(),
2038            baselines.run_energy.alarm[run_index].to_string(),
2039            baselines.pca_fdc.t2[run_index].to_string(),
2040            (baselines.pca_fdc.t2[run_index] / positive_or_one(baselines.pca_fdc.t2_threshold))
2041                .to_string(),
2042            baselines.pca_fdc.spe[run_index].to_string(),
2043            (baselines.pca_fdc.spe[run_index] / positive_or_one(baselines.pca_fdc.spe_threshold))
2044                .to_string(),
2045            baselines.pca_fdc.alarm[run_index].to_string(),
2046            format!("{:?}", grammar_trace.raw_states[run_index]),
2047            grammar_trace.persistent_boundary[run_index].to_string(),
2048            grammar_trace.persistent_violation[run_index].to_string(),
2049        ])?;
2050    }
2051    writer.flush()?;
2052    Ok(())
2053}
2054
2055#[derive(Debug, Clone)]
2056struct DrscWindow {
2057    failure_run_index: usize,
2058    window_start: usize,
2059    window_end: usize,
2060    first_persistent_boundary_run: Option<usize>,
2061    first_persistent_violation_run: Option<usize>,
2062}
2063
2064fn drsc_window(
2065    dataset: &PreparedDataset,
2066    grammar: &GrammarSet,
2067    feature_index: usize,
2068    lookback_runs: usize,
2069) -> DrscWindow {
2070    let trace = &grammar.traces[feature_index];
2071    let failure_run_index = dataset
2072        .labels
2073        .iter()
2074        .enumerate()
2075        .filter_map(|(index, label)| (*label == 1).then_some(index))
2076        .find(|&failure_index| {
2077            let start = failure_index.saturating_sub(lookback_runs);
2078            trace.persistent_boundary[start..failure_index]
2079                .iter()
2080                .any(|flag| *flag)
2081                || trace.persistent_violation[start..failure_index]
2082                    .iter()
2083                    .any(|flag| *flag)
2084        })
2085        .or_else(|| {
2086            dataset
2087                .labels
2088                .iter()
2089                .enumerate()
2090                .find_map(|(index, label)| (*label == 1).then_some(index))
2091        })
2092        .unwrap_or_else(|| dataset.labels.len().saturating_sub(1));
2093    let window_start = failure_run_index.saturating_sub(lookback_runs);
2094    let window_end = (failure_run_index + 1).min(dataset.labels.len());
2095    let first_persistent_boundary_run =
2096        (window_start..failure_run_index).find(|&run_index| trace.persistent_boundary[run_index]);
2097    let first_persistent_violation_run =
2098        (window_start..failure_run_index).find(|&run_index| trace.persistent_violation[run_index]);
2099
2100    DrscWindow {
2101        failure_run_index,
2102        window_start,
2103        window_end,
2104        first_persistent_boundary_run,
2105        first_persistent_violation_run,
2106    }
2107}
2108
2109fn display_state(trace: &crate::grammar::FeatureGrammarTrace, run_index: usize) -> GrammarState {
2110    if trace.persistent_violation[run_index] {
2111        GrammarState::Violation
2112    } else if trace.persistent_boundary[run_index] {
2113        GrammarState::Boundary
2114    } else {
2115        GrammarState::Admissible
2116    }
2117}
2118
2119fn run_level_threshold_signal(residuals: &ResidualSet) -> Vec<bool> {
2120    let run_count = residuals
2121        .traces
2122        .first()
2123        .map(|trace| trace.threshold_alarm.len())
2124        .unwrap_or(0);
2125    (0..run_count)
2126        .map(|run_index| {
2127            residuals
2128                .traces
2129                .iter()
2130                .any(|trace| trace.threshold_alarm[run_index])
2131        })
2132        .collect()
2133}
2134
2135fn run_level_ewma_signal(baselines: &BaselineSet) -> Vec<bool> {
2136    let run_count = baselines
2137        .ewma
2138        .first()
2139        .map(|trace| trace.alarm.len())
2140        .unwrap_or(0);
2141    (0..run_count)
2142        .map(|run_index| baselines.ewma.iter().any(|trace| trace.alarm[run_index]))
2143        .collect()
2144}
2145
2146fn state_shade(state: GrammarState) -> RGBColor {
2147    match state {
2148        GrammarState::Admissible => RGBColor(234, 234, 234),
2149        GrammarState::Boundary => RGBColor(148, 148, 148),
2150        GrammarState::Violation => RGBColor(36, 36, 36),
2151    }
2152}
2153
2154fn state_color(state: GrammarState) -> RGBColor {
2155    match state {
2156        GrammarState::Admissible => RGBColor(220, 220, 220),
2157        GrammarState::Boundary => RGBColor(255, 179, 0),
2158        GrammarState::Violation => RGBColor(200, 0, 0),
2159    }
2160}
2161
2162fn value_range(values: &[f64]) -> (f64, f64) {
2163    if values.is_empty() {
2164        return (-1.0, 1.0);
2165    }
2166    let min_value = values.iter().copied().fold(f64::INFINITY, f64::min);
2167    let max_value = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2168    if (max_value - min_value).abs() < f64::EPSILON {
2169        (min_value - 1.0, max_value + 1.0)
2170    } else {
2171        let padding = (max_value - min_value) * 0.1;
2172        (min_value - padding, max_value + padding)
2173    }
2174}
2175
2176fn plot_error<E: std::fmt::Display>(err: E) -> DsfbSemiconductorError {
2177    DsfbSemiconductorError::DatasetFormat(format!("plotting error: {err}"))
2178}
2179
2180fn positive_or_one(value: f64) -> f64 {
2181    if value.is_finite() && value > 0.0 {
2182        value
2183    } else {
2184        1.0
2185    }
2186}
2187
2188fn boundary_filtered_annotation(rows: &[DrscDsaCombinedRow]) -> Option<AnnotationCandidate> {
2189    rows.iter()
2190        .find(|row| row.persistent_boundary && !row.feature_dsa_alert && !row.run_level_dsa_alert)
2191        .map(|row| AnnotationCandidate {
2192            run_index: row.run_index,
2193            label: "Boundary activity filtered".into(),
2194        })
2195}
2196
2197fn precursor_annotation(rows: &[DrscDsaCombinedRow]) -> Option<AnnotationCandidate> {
2198    rows.iter()
2199        .find(|row| row.run_level_dsa_alert)
2200        .map(|row| AnnotationCandidate {
2201            run_index: row.run_index,
2202            label: "Persistent structural precursor".into(),
2203        })
2204}
2205
2206fn scalar_annotation(rows: &[DrscDsaCombinedRow]) -> Option<AnnotationCandidate> {
2207    rows.iter()
2208        .find(|row| row.threshold_run_signal || row.ewma_run_signal)
2209        .map(|row| AnnotationCandidate {
2210            run_index: row.run_index,
2211            label: "Scalar trigger".into(),
2212        })
2213}
2214
2215fn draw_failure_marker_dashed<DB: DrawingBackend>(
2216    chart: &mut ChartContext<'_, DB, Cartesian2d<RangedCoordusize, RangedCoordf64>>,
2217    run_index: usize,
2218    y_min: f64,
2219    y_max: f64,
2220) -> Result<()> {
2221    // Draw a segmented vertical line to simulate dashing in grayscale.
2222    let segments = 8;
2223    let span = y_max - y_min;
2224    let seg_len = span / (2 * segments) as f64;
2225    for i in 0..segments {
2226        let lo = y_min + (2 * i) as f64 * seg_len;
2227        let hi = lo + seg_len;
2228        chart
2229            .draw_series(std::iter::once(PathElement::new(
2230                vec![(run_index, lo), (run_index, hi)],
2231                BLACK.mix(0.55).stroke_width(3),
2232            )))
2233            .map_err(plot_error)?;
2234    }
2235    Ok(())
2236}
2237
2238fn annotate_chart<DB: DrawingBackend>(
2239    chart: &mut ChartContext<'_, DB, Cartesian2d<RangedCoordusize, RangedCoordf64>>,
2240    target_x: usize,
2241    target_y: f64,
2242    text_x: usize,
2243    text_y: f64,
2244    label: &str,
2245) -> Result<()> {
2246    chart
2247        .draw_series(std::iter::once(PathElement::new(
2248            vec![(target_x, target_y), (text_x, text_y)],
2249            BLACK.stroke_width(2),
2250        )))
2251        .map_err(plot_error)?;
2252    chart
2253        .draw_series(std::iter::once(Text::new(
2254            label.to_string(),
2255            (text_x, text_y),
2256            ("sans-serif", 28).into_font(),
2257        )))
2258        .map_err(plot_error)?;
2259    Ok(())
2260}
2261
2262// ── PHM2018 figures ──────────────────────────────────────────────────────────
2263
2264/// Generate all PHM2018 comparison figures into `run_dir/figures/`.
2265/// Returns the list of filenames created.
2266pub fn generate_phm2018_figures(
2267    run_dir: &Path,
2268    run_details: &[Phm2018RunDetail],
2269    _early_warning: &Phm2018EarlyWarningStats,
2270    structural: &Phm2018StructuralMetrics,
2271) -> Result<Vec<String>> {
2272    let figure_dir = run_dir.join("figures");
2273    std::fs::create_dir_all(&figure_dir).map_err(|e| {
2274        DsfbSemiconductorError::Io(std::io::Error::new(e.kind(), format!("create figures dir: {e}")))
2275    })?;
2276    let mut files = Vec::new();
2277
2278    draw_phm_lead_before_fault(&figure_dir, run_details)?;
2279    files.push("phm_lead_before_fault.png".into());
2280
2281    draw_phm_lead_delta_per_run(&figure_dir, run_details)?;
2282    files.push("phm_lead_delta_per_run.png".into());
2283
2284    draw_phm_structural_emergence(&figure_dir, run_details, structural)?;
2285    files.push("phm_structural_emergence.png".into());
2286
2287    Ok(files)
2288}
2289
2290/// Grouped bar per run: DSFB lead (blue) vs threshold lead (red) before fault.
2291/// "Lead" = fault_time - detection_time. Runs without any detection are omitted.
2292fn draw_phm_lead_before_fault(figure_dir: &Path, run_details: &[Phm2018RunDetail]) -> Result<()> {
2293    let out_path = figure_dir.join("phm_lead_before_fault.png");
2294
2295    // Per-run: (label, dsfb_lead, threshold_lead). Skip runs with no detection at all.
2296    let rows: Vec<(String, Option<f64>, Option<f64>)> = run_details
2297        .iter()
2298        .filter(|r| r.dsfb_detection_time.is_some() || r.threshold_detection_time.is_some())
2299        .map(|r| {
2300            let dsfb = r
2301                .dsfb_detection_time
2302                .map(|t| (r.fault_time - t) as f64 / 1_000.0);
2303            let thr = r
2304                .threshold_detection_time
2305                .map(|t| (r.fault_time - t) as f64 / 1_000.0);
2306            (r.run_id.clone(), dsfb, thr)
2307        })
2308        .collect();
2309
2310    let n = rows.len();
2311    if n == 0 {
2312        return Ok(());
2313    }
2314
2315    let max_y = rows
2316        .iter()
2317        .flat_map(|(_, d, t)| [*d, *t])
2318        .flatten()
2319        .fold(0.0_f64, f64::max)
2320        * 1.15;
2321    let max_y = max_y.max(1.0);
2322
2323    // 3 x-slots per run: DSFB bar, threshold bar, gap
2324    let x_max = n * 3;
2325
2326    let root = BitMapBackend::new(&out_path, (WIDTH, HEIGHT)).into_drawing_area();
2327    root.fill(&WHITE).map_err(plot_error)?;
2328
2329    let mut chart = ChartBuilder::on(&root)
2330        .caption(
2331            "PHM2018: Lead time before fault — DSFB vs threshold (per run)",
2332            ("sans-serif", 24),
2333        )
2334        .margin(20)
2335        .x_label_area_size(70)
2336        .y_label_area_size(80)
2337        .build_cartesian_2d(0..x_max, 0.0f64..max_y)
2338        .map_err(plot_error)?;
2339
2340    chart
2341        .configure_mesh()
2342        .disable_mesh()
2343        .x_labels(n)
2344        .x_label_formatter(&|x| {
2345            let run_idx = x / 3;
2346            if x % 3 == 1 {
2347                rows.get(run_idx)
2348                    .map(|(id, _, _)| id.clone())
2349                    .unwrap_or_default()
2350            } else {
2351                String::new()
2352            }
2353        })
2354        .x_label_style(("sans-serif", 14).into_font().transform(FontTransform::Rotate90))
2355        .y_desc("Timestamp units before fault (÷1000)")
2356        .draw()
2357        .map_err(plot_error)?;
2358
2359    // DSFB bars (blue)
2360    chart
2361        .draw_series(rows.iter().enumerate().filter_map(|(i, (_, dsfb, _))| {
2362            dsfb.map(|v| {
2363                Rectangle::new(
2364                    [(i * 3, 0.0), (i * 3 + 1, v)],
2365                    BLUE.mix(0.75).filled(),
2366                )
2367            })
2368        }))
2369        .map_err(plot_error)?
2370        .label("DSFB (DSA)")
2371        .legend(|(x, y)| {
2372            Rectangle::new([(x, y - 7), (x + 18, y + 7)], BLUE.mix(0.75).filled())
2373        });
2374
2375    // Threshold bars (red)
2376    chart
2377        .draw_series(rows.iter().enumerate().filter_map(|(i, (_, _, thr))| {
2378            thr.map(|v| {
2379                Rectangle::new(
2380                    [(i * 3 + 1, 0.0), (i * 3 + 2, v)],
2381                    RED.mix(0.65).filled(),
2382                )
2383            })
2384        }))
2385        .map_err(plot_error)?
2386        .label("Run-energy threshold")
2387        .legend(|(x, y)| {
2388            Rectangle::new([(x, y - 7), (x + 18, y + 7)], RED.mix(0.65).filled())
2389        });
2390
2391    chart
2392        .configure_series_labels()
2393        .position(SeriesLabelPosition::UpperRight)
2394        .background_style(WHITE.mix(0.85))
2395        .border_style(BLACK)
2396        .label_font(("sans-serif", 18))
2397        .draw()
2398        .map_err(plot_error)?;
2399
2400    root.present().map_err(plot_error)?;
2401    Ok(())
2402}
2403
2404/// Single bar per run showing lead_time_delta (positive = DSFB earlier than threshold).
2405/// Green bars above zero, red bars below, grey for missing.
2406fn draw_phm_lead_delta_per_run(
2407    figure_dir: &Path,
2408    run_details: &[Phm2018RunDetail],
2409) -> Result<()> {
2410    let out_path = figure_dir.join("phm_lead_delta_per_run.png");
2411
2412    let n = run_details.len();
2413    if n == 0 {
2414        return Ok(());
2415    }
2416
2417    let deltas: Vec<f64> = run_details
2418        .iter()
2419        .map(|r| r.lead_time_delta.map(|d| d as f64 / 1_000.0).unwrap_or(0.0))
2420        .collect();
2421
2422    let max_abs = deltas
2423        .iter()
2424        .copied()
2425        .map(f64::abs)
2426        .fold(0.0_f64, f64::max)
2427        .max(1.0)
2428        * 1.2;
2429
2430    let root = BitMapBackend::new(&out_path, (WIDTH, HEIGHT)).into_drawing_area();
2431    root.fill(&WHITE).map_err(plot_error)?;
2432
2433    let mut chart = ChartBuilder::on(&root)
2434        .caption(
2435            "PHM2018: Lead delta per run (DSFB earlier > 0, threshold earlier < 0)",
2436            ("sans-serif", 22),
2437        )
2438        .margin(20)
2439        .x_label_area_size(70)
2440        .y_label_area_size(80)
2441        .build_cartesian_2d(0..n, -max_abs..max_abs)
2442        .map_err(plot_error)?;
2443
2444    chart
2445        .configure_mesh()
2446        .disable_mesh()
2447        .x_labels(n)
2448        .x_label_formatter(&|i| {
2449            run_details
2450                .get(*i)
2451                .map(|r| r.run_id.clone())
2452                .unwrap_or_default()
2453        })
2454        .x_label_style(("sans-serif", 14).into_font().transform(FontTransform::Rotate90))
2455        .y_desc("Timestamp units (÷1000)")
2456        .draw()
2457        .map_err(plot_error)?;
2458
2459    // Zero baseline
2460    chart
2461        .draw_series(LineSeries::new(
2462            [(0, 0.0), (n, 0.0)],
2463            BLACK.stroke_width(1),
2464        ))
2465        .map_err(plot_error)?;
2466
2467    // Bars
2468    for (i, (row, delta)) in run_details.iter().zip(deltas.iter()).enumerate() {
2469        let (lo, hi) = if *delta >= 0.0 {
2470            (0.0, *delta)
2471        } else {
2472            (*delta, 0.0)
2473        };
2474        let color = if row.lead_time_delta.is_none() {
2475            RGBColor(160, 160, 160).mix(0.7)
2476        } else if *delta >= 0.0 {
2477            GREEN.mix(0.75)
2478        } else {
2479            RED.mix(0.65)
2480        };
2481        chart
2482            .draw_series(std::iter::once(Rectangle::new(
2483                [(i, lo), (i + 1, hi)],
2484                color.filled(),
2485            )))
2486            .map_err(plot_error)?;
2487    }
2488
2489    root.present().map_err(plot_error)?;
2490    Ok(())
2491}
2492
2493/// Summary bar chart: how many runs fall in each structural emergence category.
2494fn draw_phm_structural_emergence(
2495    figure_dir: &Path,
2496    run_details: &[Phm2018RunDetail],
2497    structural: &Phm2018StructuralMetrics,
2498) -> Result<()> {
2499    let out_path = figure_dir.join("phm_structural_emergence.png");
2500
2501    let mut structure_before_threshold = 0usize;
2502    let mut threshold_before_structure = 0usize;
2503    let mut dsfb_only = 0usize;
2504    let mut threshold_only = 0usize;
2505    let mut neither = 0usize;
2506    let mut tied = 0usize;
2507
2508    for r in run_details {
2509        match (r.lead_time_delta, r.structure_minus_threshold_delta) {
2510            (Some(delta), _) if delta > 0 => structure_before_threshold += 1,
2511            (Some(delta), _) if delta < 0 => threshold_before_structure += 1,
2512            (Some(_), _) => tied += 1, // delta == 0
2513            _ => {}
2514        }
2515        match (r.dsfb_detection_time, r.threshold_detection_time) {
2516            (Some(_), None) => dsfb_only += 1,
2517            (None, Some(_)) => threshold_only += 1,
2518            (None, None) => neither += 1,
2519            _ => {}
2520        }
2521    }
2522
2523    let categories: Vec<(&str, usize, RGBColor)> = vec![
2524        ("DSFB earlier", structure_before_threshold, RGBColor(30, 100, 200)),
2525        ("Threshold earlier", threshold_before_structure, RGBColor(200, 50, 50)),
2526        ("Tied", tied, RGBColor(100, 100, 100)),
2527        ("DSFB only", dsfb_only, RGBColor(60, 160, 80)),
2528        ("Threshold only", threshold_only, RGBColor(180, 120, 40)),
2529        ("Neither detected", neither, RGBColor(160, 160, 160)),
2530    ];
2531
2532    let max_count = categories
2533        .iter()
2534        .map(|(_, c, _)| *c)
2535        .max()
2536        .unwrap_or(1)
2537        .max(1);
2538    let n = categories.len();
2539
2540    let root = BitMapBackend::new(&out_path, (WIDTH, HEIGHT)).into_drawing_area();
2541    root.fill(&WHITE).map_err(plot_error)?;
2542
2543    let mut chart = ChartBuilder::on(&root)
2544        .caption(
2545            format!(
2546                "PHM2018: Detection outcome summary ({} runs, {} with structure before threshold)",
2547                run_details.len(),
2548                structural.runs_with_structure_before_threshold
2549            ),
2550            ("sans-serif", 20),
2551        )
2552        .margin(20)
2553        .x_label_area_size(60)
2554        .y_label_area_size(60)
2555        .build_cartesian_2d(0..n, 0usize..((max_count as f64 * 1.2) as usize + 1))
2556        .map_err(plot_error)?;
2557
2558    chart
2559        .configure_mesh()
2560        .disable_mesh()
2561        .x_labels(n)
2562        .x_label_formatter(&|i| {
2563            categories
2564                .get(*i)
2565                .map(|(label, _, _)| label.to_string())
2566                .unwrap_or_default()
2567        })
2568        .x_label_style(("sans-serif", 18).into_font())
2569        .y_desc("Number of runs")
2570        .draw()
2571        .map_err(plot_error)?;
2572
2573    for (i, (label, count, color)) in categories.iter().enumerate() {
2574        chart
2575            .draw_series(std::iter::once(Rectangle::new(
2576                [(i, 0), (i + 1, *count)],
2577                color.mix(0.8).filled(),
2578            )))
2579            .map_err(plot_error)?
2580            .label(*label)
2581            .legend({
2582                let c = *color;
2583                move |(x, y)| {
2584                    Rectangle::new([(x, y - 7), (x + 18, y + 7)], c.mix(0.8).filled())
2585                }
2586            });
2587
2588        // Count label on bar
2589        if *count > 0 {
2590            chart
2591                .draw_series(std::iter::once(Text::new(
2592                    count.to_string(),
2593                    (i, *count),
2594                    ("sans-serif", 20).into_font(),
2595                )))
2596                .map_err(plot_error)?;
2597        }
2598    }
2599
2600    chart
2601        .configure_series_labels()
2602        .position(SeriesLabelPosition::UpperRight)
2603        .background_style(WHITE.mix(0.85))
2604        .border_style(BLACK)
2605        .label_font(("sans-serif", 16))
2606        .draw()
2607        .map_err(plot_error)?;
2608
2609    root.present().map_err(plot_error)?;
2610    Ok(())
2611}
2612
2613#[cfg(test)]
2614mod tests {
2615    use super::*;
2616
2617    #[test]
2618    fn combined_annotation_candidates_are_detected_deterministically() {
2619        let rows = vec![
2620            DrscDsaCombinedRow {
2621                run_index: 5,
2622                timestamp: "2008-01-01 00:00:00".into(),
2623                label: -1,
2624                feature: "S000".into(),
2625                residual_over_rho: 0.0,
2626                drift_over_threshold: 0.0,
2627                slew_over_threshold: 0.0,
2628                display_state: GrammarState::Admissible,
2629                persistent_boundary: false,
2630                persistent_violation: false,
2631                feature_dsa_alert: false,
2632                run_level_dsa_alert: false,
2633                feature_count_dsa_alert: 0,
2634                threshold_run_signal: true,
2635                ewma_run_signal: false,
2636            },
2637            DrscDsaCombinedRow {
2638                run_index: 6,
2639                timestamp: "2008-01-01 00:10:00".into(),
2640                label: -1,
2641                feature: "S000".into(),
2642                residual_over_rho: 0.4,
2643                drift_over_threshold: 0.5,
2644                slew_over_threshold: 0.2,
2645                display_state: GrammarState::Boundary,
2646                persistent_boundary: true,
2647                persistent_violation: false,
2648                feature_dsa_alert: false,
2649                run_level_dsa_alert: false,
2650                feature_count_dsa_alert: 0,
2651                threshold_run_signal: false,
2652                ewma_run_signal: false,
2653            },
2654            DrscDsaCombinedRow {
2655                run_index: 7,
2656                timestamp: "2008-01-01 00:20:00".into(),
2657                label: -1,
2658                feature: "S000".into(),
2659                residual_over_rho: 0.6,
2660                drift_over_threshold: 0.7,
2661                slew_over_threshold: 0.3,
2662                display_state: GrammarState::Boundary,
2663                persistent_boundary: true,
2664                persistent_violation: false,
2665                feature_dsa_alert: true,
2666                run_level_dsa_alert: true,
2667                feature_count_dsa_alert: 3,
2668                threshold_run_signal: false,
2669                ewma_run_signal: false,
2670            },
2671        ];
2672
2673        assert_eq!(boundary_filtered_annotation(&rows).unwrap().run_index, 6);
2674        assert_eq!(precursor_annotation(&rows).unwrap().run_index, 7);
2675        assert_eq!(scalar_annotation(&rows).unwrap().run_index, 5);
2676    }
2677
2678    #[test]
2679    fn combined_render_rejects_empty_rows() {
2680        let temp = tempfile::tempdir().unwrap();
2681        let path = temp.path().join("drsc_dsa_combined.png");
2682        let err = draw_drsc_dsa_combined_chart(
2683            &path,
2684            &[],
2685            &DrscWindow {
2686                failure_run_index: 0,
2687                window_start: 0,
2688                window_end: 0,
2689                first_persistent_boundary_run: None,
2690                first_persistent_violation_run: None,
2691            },
2692        )
2693        .unwrap_err();
2694        assert!(err
2695            .to_string()
2696            .contains("combined DRSC+DSA figure requires at least one row"));
2697    }
2698}