1use crate::config::ValidationProfile;
42use crate::metrics::ComparisonResult;
43use serde::{Deserialize, Serialize};
44use std::collections::BTreeMap;
45use std::path::Path;
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ValidationReport {
50 pub timestamp: String,
52 pub git_commit: Option<String>,
54 pub sample_rate: u32,
56 pub oversample: u32,
58 pub suites: BTreeMap<String, SuiteResult>,
60 pub summary: ReportSummary,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ReportSummary {
67 pub total_tests: usize,
71 pub passed: usize,
72 pub failed: usize,
73 pub skipped: usize,
74 #[serde(default)]
77 pub pending: usize,
78 pub pass_rate: f64,
79 #[serde(default)]
81 pub profiles: BTreeMap<String, ProfileSummary>,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct ProfileSummary {
87 pub total_tests: usize,
88 pub passed: usize,
89 pub failed: usize,
90 #[serde(default)]
91 pub pending: usize,
92 pub pass_rate: f64,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SuiteResult {
98 pub description: String,
99 pub passed: usize,
100 pub failed: usize,
101 #[serde(default)]
104 pub pending: usize,
105 pub tests: BTreeMap<String, TestResult>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct TestResult {
111 #[serde(default)]
113 pub profile: ValidationProfile,
114 pub passed: bool,
115 #[serde(default)]
119 pub pending: bool,
120 pub error: Option<String>,
121 pub signals: Vec<SignalResult>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct SignalResult {
127 pub label: String,
128 pub passed: bool,
129 #[serde(default)]
131 pub pending: bool,
132 pub comparison: Option<ComparisonMetrics>,
133 pub error: Option<String>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ComparisonMetrics {
139 pub normalized_rms_error_db: f64,
140 pub peak_error_db: f64,
141 pub thd_error_db: Option<f64>,
142 #[serde(default)]
145 pub thd_plus_n_error_db: Option<f64>,
146 #[serde(default)]
149 pub harmonic_mag_error_db: Option<f64>,
150 #[serde(default)]
153 pub even_odd_ratio_error_db: Option<f64>,
154 pub spectral_error_db: f64,
156 #[serde(default)]
159 pub spectral_error_full_db: f64,
160 pub even_odd_ratio_db: Option<f64>,
161 pub dc_drift_mv: Option<f64>,
162}
163
164impl From<ComparisonResult> for ComparisonMetrics {
165 fn from(cr: ComparisonResult) -> Self {
166 Self {
167 normalized_rms_error_db: cr.normalized_rms_error_db,
168 peak_error_db: cr.peak_error_db,
169 thd_error_db: cr.thd_error_db,
170 thd_plus_n_error_db: cr.thd_plus_n_error_db,
171 harmonic_mag_error_db: cr.harmonic_mag_error_db,
172 even_odd_ratio_error_db: cr.even_odd_ratio_error_db,
173 spectral_error_db: cr.spectral_error_db,
174 spectral_error_full_db: cr.spectral_error_full_db,
175 even_odd_ratio_db: cr.even_odd_ratio_db,
176 dc_drift_mv: cr.dc_drift_mv,
177 }
178 }
179}
180
181impl ValidationReport {
182 pub fn new(suites: BTreeMap<String, SuiteResult>, sample_rate: u32, oversample: u32) -> Self {
184 let mut total = 0;
185 let mut passed = 0;
186 let mut failed = 0;
187 let mut pending = 0;
188
189 for suite in suites.values() {
190 total += suite.passed + suite.failed;
194 passed += suite.passed;
195 failed += suite.failed;
196 pending += suite.pending;
197 }
198
199 let mut profiles: BTreeMap<String, ProfileSummary> = BTreeMap::new();
200 for suite in suites.values() {
201 for test in suite.tests.values() {
202 let bucket = profiles
203 .entry(test.profile.as_str().to_string())
204 .or_default();
205 if test.pending {
206 bucket.pending += 1;
207 } else {
208 bucket.total_tests += 1;
209 if test.passed {
210 bucket.passed += 1;
211 } else {
212 bucket.failed += 1;
213 }
214 }
215 }
216 }
217 for bucket in profiles.values_mut() {
218 bucket.pass_rate = if bucket.total_tests > 0 {
219 bucket.passed as f64 / bucket.total_tests as f64
220 } else {
221 0.0
222 };
223 }
224
225 let pass_rate = if total > 0 {
226 passed as f64 / total as f64
227 } else {
228 0.0
229 };
230
231 Self {
232 timestamp: chrono_lite_timestamp(),
233 git_commit: get_git_commit(),
234 sample_rate,
235 oversample,
236 suites,
237 summary: ReportSummary {
238 total_tests: total,
239 passed,
240 failed,
241 skipped: 0,
242 pending,
243 pass_rate,
244 profiles,
245 },
246 }
247 }
248
249 pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), std::io::Error> {
251 let path = path.as_ref();
252 if let Some(parent) = path.parent() {
253 std::fs::create_dir_all(parent)?;
254 }
255 let json = serde_json::to_string_pretty(self)
256 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
257 std::fs::write(path, json)
258 }
259
260 pub fn print_summary(&self) {
262 use colored::Colorize;
263
264 println!("\n{}", "═".repeat(60).bold());
265 println!("{}", " PEDALKERNEL VALIDATION REPORT ".bold().on_blue());
266 println!("{}", "═".repeat(60).bold());
267
268 if let Some(ref commit) = self.git_commit {
269 println!("Git commit: {}", commit.dimmed());
270 }
271 println!("Timestamp: {}", self.timestamp.dimmed());
272 println!(
273 "Config: {}Hz × {}x oversample",
274 self.sample_rate, self.oversample
275 );
276 if !self.summary.profiles.is_empty() {
277 let buckets: Vec<String> = self
278 .summary
279 .profiles
280 .iter()
281 .map(|(name, summary)| {
282 if summary.pending > 0 {
283 format!(
284 "{} {}/{} ({} pending)",
285 name, summary.passed, summary.total_tests, summary.pending
286 )
287 } else {
288 format!("{} {}/{}", name, summary.passed, summary.total_tests)
289 }
290 })
291 .collect();
292 println!("Profiles: {}", buckets.join(" | ").dimmed());
293 }
294 println!();
295
296 for (suite_name, suite) in &self.suites {
297 let status = if suite.failed == 0 {
298 "PASS".green().bold()
299 } else {
300 "FAIL".red().bold()
301 };
302
303 let pend_note = if suite.pending > 0 {
304 format!(" [{} pending]", suite.pending)
305 } else {
306 String::new()
307 };
308 println!(
309 "[{}] {} - {} ({}/{}){}",
310 status,
311 suite_name.bold(),
312 suite.description.dimmed(),
313 suite.passed,
314 suite.passed + suite.failed,
315 pend_note.yellow()
316 );
317
318 for (test_name, test) in &suite.tests {
319 if test.pending {
320 let reason = test
323 .error
324 .clone()
325 .unwrap_or_else(|| "golden not generated".to_string());
326 println!(
327 " {} {} ({})",
328 "[PEND]".yellow().bold(),
329 test_name,
330 reason.dimmed()
331 );
332 continue;
333 }
334
335 let test_status = if test.passed {
336 "✓".green()
337 } else {
338 "✗".red()
339 };
340
341 println!(
342 " {} {} [{}]",
343 test_status,
344 test_name,
345 test.profile.as_str().dimmed()
346 );
347
348 if let Some(ref err) = test.error {
349 println!(" {} {}", "Error:".red(), err);
350 }
351
352 for signal in &test.signals {
353 if let Some(ref comp) = signal.comparison {
354 let signal_status = if signal.passed {
355 "✓".green()
356 } else {
357 "✗".red()
358 };
359 println!(
360 " {} {} | RMS: {:.1}dB | Peak: {:.1}dB | Spectral: {:.1}dB",
361 signal_status,
362 signal.label.dimmed(),
363 comp.normalized_rms_error_db,
364 comp.peak_error_db,
365 comp.spectral_error_db
366 );
367 if let Some(thd) = comp.thd_error_db {
368 println!(" THD error: {:.2}dB", thd);
369 }
370 } else if let Some(ref err) = signal.error {
371 println!(" {} {} - {}", "⚠".yellow(), signal.label.dimmed(), err);
372 }
373 }
374 }
375 println!();
376 }
377
378 println!("{}", "─".repeat(60));
379 let overall_status = if self.summary.failed == 0 {
380 "ALL TESTS PASSED".green().bold()
381 } else {
382 format!("{} TESTS FAILED", self.summary.failed).red().bold()
383 };
384 let pend_suffix = if self.summary.pending > 0 {
385 format!(" | {} pending", self.summary.pending)
386 } else {
387 String::new()
388 };
389 println!(
390 "{} | {}/{} passed ({:.1}%){}",
391 overall_status,
392 self.summary.passed,
393 self.summary.total_tests,
394 self.summary.pass_rate * 100.0,
395 pend_suffix.yellow()
396 );
397 println!("{}\n", "═".repeat(60).bold());
398 }
399
400 pub fn print_detailed(&self) {
402 use tabled::{Table, Tabled};
403
404 #[derive(Tabled)]
405 struct MetricRow {
406 suite: String,
407 test: String,
408 signal: String,
409 profile: String,
410 #[tabled(rename = "RMS (dB)")]
411 rms_db: String,
412 #[tabled(rename = "Peak (dB)")]
413 peak_db: String,
414 #[tabled(rename = "THD Err")]
415 thd_err: String,
416 #[tabled(rename = "Spectral")]
417 spectral: String,
418 status: String,
419 }
420
421 let mut rows = vec![];
422
423 for (suite_name, suite) in &self.suites {
424 for (test_name, test) in &suite.tests {
425 if test.pending {
426 rows.push(MetricRow {
427 suite: suite_name.clone(),
428 test: test_name.clone(),
429 signal: "-".to_string(),
430 profile: test.profile.as_str().to_string(),
431 rms_db: "-".to_string(),
432 peak_db: "-".to_string(),
433 thd_err: "-".to_string(),
434 spectral: "-".to_string(),
435 status: "PEND".to_string(),
436 });
437 continue;
438 }
439 for signal in &test.signals {
440 let (rms, peak, thd, spectral) = if let Some(ref c) = signal.comparison {
441 (
442 format!("{:.1}", c.normalized_rms_error_db),
443 format!("{:.1}", c.peak_error_db),
444 c.thd_error_db
445 .map(|t| format!("{:.2}", t))
446 .unwrap_or_else(|| "-".to_string()),
447 format!("{:.1}", c.spectral_error_db),
448 )
449 } else {
450 (
451 "-".to_string(),
452 "-".to_string(),
453 "-".to_string(),
454 "-".to_string(),
455 )
456 };
457
458 rows.push(MetricRow {
459 suite: suite_name.clone(),
460 test: test_name.clone(),
461 signal: signal.label.clone(),
462 profile: test.profile.as_str().to_string(),
463 rms_db: rms,
464 peak_db: peak,
465 thd_err: thd,
466 spectral: spectral,
467 status: if signal.passed {
468 "PASS".to_string()
469 } else {
470 "FAIL".to_string()
471 },
472 });
473 }
474 }
475 }
476
477 if !rows.is_empty() {
478 let table = Table::new(rows);
479 println!("\nDetailed Metrics:\n{}", table);
480 }
481 }
482}
483
484use pedalkernel_rt::processor::{
489 BoundaryLoadBinding, BoundaryLoadDisposition, BoundaryLoadSummary,
490};
491
492fn si_value(value: f64, unit: &str) -> String {
494 if !value.is_finite() {
495 return format!("{value}{unit}");
496 }
497 let a = value.abs();
498 let (scale, prefix) = if a == 0.0 {
499 (1.0, "")
500 } else if a >= 1e6 {
501 (1e-6, "M")
502 } else if a >= 1e3 {
503 (1e-3, "k")
504 } else if a >= 1.0 {
505 (1.0, "")
506 } else if a >= 1e-3 {
507 (1e3, "m")
508 } else if a >= 1e-6 {
509 (1e6, "µ")
510 } else if a >= 1e-9 {
511 (1e9, "n")
512 } else {
513 (1e12, "p")
514 };
515 let scaled = value * scale;
516 let s = if (scaled - scaled.round()).abs() < 5e-3 {
517 format!("{}", scaled.round() as i64)
518 } else {
519 format!("{scaled:.2}")
520 };
521 format!("{s}{prefix}{unit}")
522}
523
524pub fn format_boundary_model(m: &BoundaryLoadSummary) -> String {
527 match m {
528 BoundaryLoadSummary::Unloaded => "Unloaded".to_string(),
529 BoundaryLoadSummary::GroundedResistive {
530 r_total,
531 component_ids,
532 } => format!(
533 "GroundedResistive{{{}; {}}}",
534 si_value(*r_total, "Ω"),
535 component_ids.join(",")
536 ),
537 BoundaryLoadSummary::SeriesCapIntoLoad {
538 c,
539 r_total,
540 component_ids,
541 } => format!(
542 "SeriesCapIntoLoad{{{} → {}; {}}}",
543 si_value(*c, "F"),
544 si_value(*r_total, "Ω"),
545 component_ids.join(",")
546 ),
547 BoundaryLoadSummary::PassiveNetwork { component_ids } => {
548 format!("PassiveNetwork{{{}}}", component_ids.join(","))
549 }
550 BoundaryLoadSummary::SeriesCapIntoPotLoad {
551 c,
552 pot_id,
553 r_pot,
554 r_fixed,
555 component_ids,
556 } => {
557 let load = match r_fixed {
558 Some(rf) => format!(
559 "{pot_id}:{} pot ∥ {}",
560 si_value(*r_pot, "Ω"),
561 si_value(*rf, "Ω")
562 ),
563 None => format!("{pot_id}:{} pot", si_value(*r_pot, "Ω")),
564 };
565 format!(
566 "SeriesCapIntoPotLoad{{{} → {}; {}}}",
567 si_value(*c, "F"),
568 load,
569 component_ids.join(",")
570 )
571 }
572 BoundaryLoadSummary::PotDividerAtOut {
573 pot_id,
574 r_pot,
575 component_ids,
576 } => format!(
577 "PotDividerAtOut{{{pot_id}:{} pot; {}}}",
578 si_value(*r_pot, "Ω"),
579 component_ids.join(",")
580 ),
581 BoundaryLoadSummary::CollectorChainIntoOut {
582 c,
583 pot_id,
584 r_pot,
585 r_tap_fixed,
586 component_ids,
587 } => format!(
588 "CollectorChainIntoOut{{{} → {} tap ∥ {}:{} pot; {}}}",
589 si_value(*c, "F"),
590 si_value(*r_tap_fixed, "Ω"),
591 pot_id,
592 si_value(*r_pot, "Ω"),
593 component_ids.join(",")
594 ),
595 }
596}
597
598pub fn format_boundary_disposition(d: &BoundaryLoadDisposition) -> String {
601 match d {
602 BoundaryLoadDisposition::FusedUpstream => "FusedUpstream".to_string(),
603 BoundaryLoadDisposition::Unloaded { reason } => format!("Unloaded{{{reason}}}"),
604 BoundaryLoadDisposition::ReflectedThroughTransformer {
605 turns_ratio,
606 r_reflected,
607 } => format!("ReflectedThroughTransformer{{n={turns_ratio}, r_reflected={r_reflected}}}"),
608 }
609}
610
611pub fn unloaded_output_flag(loads: &[BoundaryLoadBinding]) -> Option<String> {
627 if loads.iter().any(|b| {
628 matches!(
629 b.disposition,
630 BoundaryLoadDisposition::FusedUpstream
631 | BoundaryLoadDisposition::ReflectedThroughTransformer { .. }
632 )
633 }) {
634 return None; }
636 if loads.is_empty() {
637 return Some(
638 "⚠ OUTPUT BOUNDARY UNANALYZED: empty table — no feedback group reached the \
639 general-MNA call site (blockwise/other compile paths)"
640 .to_string(),
641 );
642 }
643 let mut reasons: Vec<&str> = loads
644 .iter()
645 .filter_map(|b| match &b.disposition {
646 BoundaryLoadDisposition::Unloaded { reason } => Some(reason.as_str()),
647 BoundaryLoadDisposition::FusedUpstream
648 | BoundaryLoadDisposition::ReflectedThroughTransformer { .. } => None,
649 })
650 .collect();
651 reasons.sort_unstable();
652 reasons.dedup();
653 Some(format!(
654 "⚠ OUTPUT BOUNDARY UNLOADED: {}",
655 reasons.join(", ")
656 ))
657}
658
659pub fn render_boundary_loads(circuits: &[(String, Vec<BoundaryLoadBinding>)]) -> String {
666 use std::fmt::Write as _;
667 use tabled::settings::Style;
668 use tabled::{Table, Tabled};
669
670 #[derive(Tabled)]
671 struct BoundaryRow {
672 circuit: String,
673 stage: String,
674 #[tabled(rename = "boundary node")]
675 node: String,
676 #[tabled(rename = "load model")]
677 model: String,
678 disposition: String,
679 }
680
681 let mut rows = Vec::new();
682 for (circuit, loads) in circuits {
683 if loads.is_empty() {
684 rows.push(BoundaryRow {
685 circuit: circuit.clone(),
686 stage: "-".into(),
687 node: "-".into(),
688 model: "-".into(),
689 disposition: "no-analysis (empty table)".into(),
690 });
691 continue;
692 }
693 for (i, b) in loads.iter().enumerate() {
694 rows.push(BoundaryRow {
695 circuit: if i == 0 { circuit.clone() } else { String::new() },
696 stage: if b.upstream_stage == usize::MAX {
697 "?".into()
698 } else {
699 b.upstream_stage.to_string()
700 },
701 node: b.boundary_node.to_string(),
702 model: format_boundary_model(&b.model),
703 disposition: format_boundary_disposition(&b.disposition),
704 });
705 }
706 }
707
708 let mut out = String::new();
709 out.push_str(
710 "\n────────────────── BOUNDARY-LOAD DECISION TABLE (compile-time) ──────────────────\n \
711 What hangs downstream of each stage's OUTPUT boundary and what the compiler did.\n \
712 `Unloaded{reason}` = analyzed, declined (boundary left open — stage solves into an\n \
713 open circuit). `no-analysis (empty table)` = no feedback group reached the\n \
714 general-MNA call site at all (blockwise/other paths) — a DISTINCT triage signal.\n",
715 );
716 if !rows.is_empty() {
717 let mut table = Table::new(rows);
718 table.with(Style::rounded());
719 let _ = writeln!(out, "{}", table);
720 }
721 for (circuit, loads) in circuits {
722 if let Some(flag) = unloaded_output_flag(loads) {
723 let _ = writeln!(out, " {circuit}: {flag}");
724 }
725 }
726 out.push_str(
727 "──────────────────────────────────────────────────────────────────────────────────\n",
728 );
729 out
730}
731
732pub fn render_bias_accuracy(
750 results: &[crate::metrics::op_point::OpPointResult],
751 criteria: &crate::metrics::op_point::OpPassCriteria,
752 boundary_loads: &BTreeMap<String, Vec<BoundaryLoadBinding>>,
753) -> String {
754 use crate::metrics::op_point::Verdict;
755 use std::fmt::Write as _;
756 use tabled::settings::Style;
757 use tabled::{Table, Tabled};
758
759 #[derive(Tabled)]
760 struct BiasRow {
761 circuit: String,
762 device: String,
763 model: String,
764 #[tabled(rename = "Vbe ours")]
765 vbe_ours: String,
766 #[tabled(rename = "Vbe ng")]
767 vbe_ng: String,
768 #[tabled(rename = "ΔVbe")]
769 d_vbe: String,
770 #[tabled(rename = "Vce ours")]
771 vce_ours: String,
772 #[tabled(rename = "Vce ng")]
773 vce_ng: String,
774 #[tabled(rename = "ΔVce")]
775 d_vce: String,
776 #[tabled(rename = "Ic ours")]
777 ic_ours: String,
778 #[tabled(rename = "Ic ng")]
779 ic_ng: String,
780 #[tabled(rename = "ΔIc%")]
781 d_ic: String,
782 }
783
784 fn fmt_i(a: f64) -> String {
785 if a.abs() >= 1e-3 {
786 format!("{:.3}mA", a * 1e3)
787 } else {
788 format!("{:.1}µA", a * 1e6)
789 }
790 }
791
792 let mut rows = Vec::new();
793 for r in results {
794 if r.devices.is_empty() {
795 rows.push(BiasRow {
796 circuit: r.circuit.clone(),
797 device: "(no BJT devices)".to_string(),
798 model: "-".into(),
799 vbe_ours: "-".into(),
800 vbe_ng: "-".into(),
801 d_vbe: "-".into(),
802 vce_ours: "-".into(),
803 vce_ng: "-".into(),
804 d_vce: "-".into(),
805 ic_ours: "-".into(),
806 ic_ng: "-".into(),
807 d_ic: "-".into(),
808 });
809 }
810 for (i, d) in r.devices.iter().enumerate() {
811 let flag = if d.d_vbe.abs() > criteria.vbe_fail_v {
812 "!"
813 } else if d.d_vbe.abs() > criteria.vbe_warn_v {
814 "~"
815 } else {
816 ""
817 };
818 rows.push(BiasRow {
819 circuit: if i == 0 { r.circuit.clone() } else { String::new() },
820 device: d.reference.clone(),
821 model: d.model.clone(),
822 vbe_ours: format!("{:+.4}", d.ours.0),
823 vbe_ng: format!("{:+.4}", d.spice.0),
824 d_vbe: format!("{:+.4}{}", d.d_vbe, flag),
825 vce_ours: format!("{:+.3}", d.ours.1),
826 vce_ng: format!("{:+.3}", d.spice.1),
827 d_vce: format!("{:+.3}", d.d_vce),
828 ic_ours: fmt_i(d.ours.2),
829 ic_ng: fmt_i(d.spice.2),
830 d_ic: format!("{:+.1}", d.d_ic_pct),
831 });
832 }
833 }
834
835 let mut out = String::new();
836 out.push_str("\n══════════════════════ DC BIAS ACCURACY (WDF vs ngspice .op) ══════════════════════\n");
837 out.push_str(
838 " MATCH = our settled DC bias vs ngspice .op (Layer B / solver-root).\n \
839 ΔVbe flags: '~' warn >",
840 );
841 let _ = write!(
842 out,
843 "{:.0}mV, '!' fail >{:.0}mV; |ΔVce| fail >{:.2}V; |ΔIc| fail >{:.0}%.\n",
844 criteria.vbe_warn_v * 1e3,
845 criteria.vbe_fail_v * 1e3,
846 criteria.vce_fail_v,
847 criteria.ic_fail_pct,
848 );
849 if !rows.is_empty() {
850 let mut table = Table::new(rows);
851 table.with(Style::rounded());
852 let _ = write!(out, "{}\n", table);
853 }
854
855 out.push_str(
857 "\n RESIDUAL = F(ngspice_op): is ngspice's .op a fixed point of OUR DC equations?\n \
858 (residual ≈ 0 ⇒ formulation ok → Layer B; residual ≫ 0 ⇒ formulation bug → Layer A)\n",
859 );
860 for r in results {
861 let mark = match r.verdict {
862 Verdict::BiasOk => "✓",
863 Verdict::FormulationOkSolverOff => "→B",
864 Verdict::FormulationBug => "→A",
865 Verdict::NoData => "·",
866 };
867 let _ = write!(
868 out,
869 " [{mark}] {:<22} max|resid|={:>7.4}V (full={:.2e}V, {} ports) \
870 max ΔVbe={:.4}V ΔVce={:.3}V ΔIc={:.1}% ⇒ {}\n",
871 r.circuit,
872 r.max_abs_residual,
873 r.max_abs_residual_full,
874 r.n_residual_ports,
875 r.max_abs_d_vbe,
876 r.max_abs_d_vce,
877 r.max_abs_d_ic_pct,
878 r.verdict.label(),
879 );
880 if let Some(loads) = boundary_loads.get(&r.circuit) {
883 if let Some(flag) = unloaded_output_flag(loads) {
884 let _ = writeln!(out, " {flag}");
885 }
886 }
887 }
888 out.push_str("═══════════════════════════════════════════════════════════════════════════════════\n");
889 out
890}
891
892pub fn render_ac_accuracy(
905 results: &[crate::metrics::ac_accuracy::AcResult],
906 th: &crate::metrics::ac_accuracy::AcThresholds,
907) -> String {
908 use crate::metrics::ac_accuracy::AcVerdict;
909 use std::fmt::Write as _;
910 use tabled::settings::Style;
911 use tabled::{Table, Tabled};
912
913 #[derive(Tabled)]
914 struct AcRow {
915 circuit: String,
916 #[tabled(rename = "gain@f")]
917 gain: String,
918 #[tabled(rename = "shape rms")]
919 shape_rms: String,
920 #[tabled(rename = "shape spec")]
921 shape_spec: String,
922 #[tabled(rename = "THD wdf")]
923 thd_wdf: String,
924 #[tabled(rename = "THD ng")]
925 thd_ng: String,
926 #[tabled(rename = "ΔTHD")]
927 d_thd: String,
928 #[tabled(rename = "resp tilt")]
929 tilt: String,
930 verdict: String,
931 }
932
933 let mut rows = Vec::new();
934 for r in results {
935 let mark = match r.verdict {
936 AcVerdict::Clean => "✓",
937 AcVerdict::LevelOnly => "≈",
938 AcVerdict::ShapeError => "!S",
939 AcVerdict::HarmonicError => "!H",
940 AcVerdict::ResponseError => "!R",
941 AcVerdict::NoData => "·",
942 };
943 rows.push(AcRow {
944 circuit: format!("{mark} {}", r.circuit),
945 gain: format!("{:+.2}dB", r.gain_db),
946 shape_rms: format!("{:.1}dB", r.shape_rms_db),
947 shape_spec: format!("{:.1}dB", r.shape_spectral_db),
948 thd_wdf: format!("{:.1}", r.thd_wdf_db),
949 thd_ng: format!("{:.1}", r.thd_golden_db),
950 d_thd: format!("{:.1}", r.thd_error_db()),
951 tilt: format!("{:.2}dB", r.response_tilt_db),
952 verdict: r.verdict.label().to_string(),
953 });
954 }
955
956 let mut out = String::new();
957 out.push_str("\n══════════════════════ AC SIGNAL ACCURACY (WDF vs ngspice golden) ══════════════════════\n");
958 let _ = write!(
959 out,
960 " LEVEL = ac_gain@f (scalar offset). SHAPE = gain-normalized residual (rms time-domain,\n \
961 spec = phase-immune magnitude, in-band). THD ratio is level-independent. \n \
962 resp tilt = max−min gain across the sweep (≈0 ⇒ flat scalar; ≫0 ⇒ frequency-shaped).\n \
963 flags: '≈' level-only (shape clean); '!S' shape; '!H' harmonic; '!R' response. \n \
964 thresholds: |gain|>{:.1}dB=level, shape_rms>{:.0}dB, shape_spec>{:.0}dB, ΔTHD>{:.0}dB, \
965 harm>{:.0}dB, tilt>{:.0}dB.\n",
966 th.gain_warn_db,
967 th.shape_rms_fail_db,
968 th.shape_spectral_fail_db,
969 th.thd_error_fail_db,
970 th.harmonic_fail_db,
971 th.response_tilt_fail_db,
972 );
973 if !rows.is_empty() {
974 let mut table = Table::new(rows);
975 table.with(Style::rounded());
976 let _ = write!(out, "{}\n", table);
977 }
978
979 for r in results {
981 let _ = write!(
982 out,
983 "\n [{}] {} @ {:.0} Hz — {}\n",
984 match r.verdict {
985 AcVerdict::Clean => "✓",
986 AcVerdict::LevelOnly => "≈",
987 AcVerdict::ShapeError => "!S",
988 AcVerdict::HarmonicError => "!H",
989 AcVerdict::ResponseError => "!R",
990 AcVerdict::NoData => "·",
991 },
992 r.circuit,
993 r.test_freq_hz,
994 r.verdict.label(),
995 );
996 out.push_str(" harmonics (dBc, wdf / ng / Δ): ");
997 if r.harmonics.is_empty() {
998 out.push_str("(none)");
999 }
1000 for h in &r.harmonics {
1001 let note = if h.scored { "" } else { "·" };
1002 let _ = write!(
1003 out,
1004 "h{}={:.1}/{:.1}/{:.1}{} ",
1005 h.order, h.wdf_dbc, h.golden_dbc, h.error_db, note
1006 );
1007 }
1008 out.push('\n');
1009 if !r.response.is_empty() {
1010 out.push_str(" response gain vs freq: ");
1011 for p in &r.response {
1012 let g = if p.gain_db.is_finite() {
1013 format!("{:+.2}", p.gain_db)
1014 } else {
1015 "n/a".to_string()
1016 };
1017 let _ = write!(out, "{:.0}Hz={}dB ", p.freq_hz, g);
1018 }
1019 let _ = write!(out, " (tilt {:.2} dB)\n", r.response_tilt_db);
1020 }
1021 }
1022 out.push_str("══════════════════════════════════════════════════════════════════════════════════════\n");
1023 out
1024}
1025
1026fn chrono_lite_timestamp() -> String {
1028 use std::time::{SystemTime, UNIX_EPOCH};
1029 let duration = SystemTime::now()
1030 .duration_since(UNIX_EPOCH)
1031 .unwrap_or_default();
1032 format!("{}", duration.as_secs())
1033}
1034
1035fn get_git_commit() -> Option<String> {
1037 std::process::Command::new("git")
1038 .args(["rev-parse", "--short", "HEAD"])
1039 .output()
1040 .ok()
1041 .and_then(|o| {
1042 if o.status.success() {
1043 String::from_utf8(o.stdout)
1044 .ok()
1045 .map(|s| s.trim().to_string())
1046 } else {
1047 None
1048 }
1049 })
1050}
1051
1052impl SignalResult {
1054 pub fn from_comparison(label: String, comparison: ComparisonResult, passed: bool) -> Self {
1055 Self {
1056 label,
1057 passed,
1058 pending: false,
1059 comparison: Some(comparison.into()),
1060 error: None,
1061 }
1062 }
1063
1064 pub fn from_error(label: String, error: String) -> Self {
1065 Self {
1066 label,
1067 passed: false,
1068 pending: false,
1069 comparison: None,
1070 error: Some(error),
1071 }
1072 }
1073
1074 pub fn pending(label: String, reason: String) -> Self {
1076 Self {
1077 label,
1078 passed: false,
1079 pending: true,
1080 comparison: None,
1081 error: Some(reason),
1082 }
1083 }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089
1090 #[test]
1091 fn report_summary_calculation() {
1092 let mut suites = BTreeMap::new();
1093 suites.insert(
1094 "test_suite".to_string(),
1095 SuiteResult {
1096 description: "Test".to_string(),
1097 passed: 3,
1098 failed: 1,
1099 pending: 0,
1100 tests: BTreeMap::new(),
1101 },
1102 );
1103
1104 let report = ValidationReport::new(suites, 96000, 4);
1105 assert_eq!(report.summary.total_tests, 4);
1106 assert_eq!(report.summary.passed, 3);
1107 assert_eq!(report.summary.failed, 1);
1108 assert!((report.summary.pass_rate - 0.75).abs() < 0.001);
1109 }
1110}