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 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 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 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 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 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 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 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 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 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 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 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 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
2262pub 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
2290fn 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 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 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 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 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
2404fn 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 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 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
2493fn 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, _ => {}
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 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}