main
Colby Knox adom-fields 0.10.6: grid covers the board (issue 27); one board thickness and solid-uncoupled (issue 28); decoupling co… cb236bf
//! Certification readiness (0.9.5): one report over a board's solves. It reads the fields solve, plus Adom Safety's
//! safety.json and Adom Mechanics' mechanics.json when given. Each area (thermal, copper, power, signals, EMC,
//! electrical safety, mechanics) gets ready / needs work / blocked / not checked, its key numbers and its open
//! findings. A blocked area has a high finding, a likely fail or a safety error; needs work has a medium finding or an
//! at-risk test. The report says what it does not cover, so nobody reads "ready" as "certified".
use serde_json::{json, Value};
use std::path::Path;

const AREAS: &[(&str, &str, &[&str])] = &[
    ("thermal", "Thermal", &["tab-rise", "thermal-vias", "junction-temperature", "capacitor-temperature", "electro-thermal", "capacitor", "solid-uncoupled"]),
    ("copper", "Current and copper", &["current-neck", "ampacity", "ir-drop", "current-barrel", "pour-fragmented", "load-unsolved", "solve-unconverged"]),
    ("power", "Power delivery", &["pdn-impedance", "decoupling-far", "decoupling-missing", "output-ripple", "inductor-ripple", "input-filter-stability", "hot-plug-overshoot", "regulator", "stage-feed-unbypassed"]),
    ("signals", "Signal integrity", &["impedance", "diff-impedance", "length-skew", "crosstalk", "no-reference", "via-stub", "stackup-adjacent-signals", "stackup-far-reference", "plane", "cavity-resonance", "tone-in-band", "stage-isolation", "circuit-path"]),
    ("emc", "EMC and ESD", &["hot-loop", "emissions", "cable-emissions", "cable", "conducted-emissions", "lo-emission", "lo-leak", "esd-unprotected", "io-unfiltered", "input-filter-cutoff", "clock-outer-layer", "clock-near-connector", "edge-signal", "stitching-gap", "ground-offset", "ground-island", "ground-loop-risk", "antenna-ground-loop"]),
];

fn load(p: Option<&Path>, name: &str) -> Result<Option<Value>, String> {
    let Some(p) = p else { return Ok(None) };
    let f = if p.is_dir() { p.join(name) } else { p.to_path_buf() };
    let t = std::fs::read_to_string(&f).map_err(|e| format!("{}: {e}", f.display()))?;
    serde_json::from_str(&t).map(Some).map_err(|e| format!("{}: {e}", f.display()))
}
fn sev(s: &str) -> u8 { match s { "high" | "error" => 0, "medium" | "warning" => 1, _ => 2 } }
fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }

fn area(id: &str, title: &str, issues: Vec<Value>, numbers: Vec<(String, String)>, force: Option<(&str, String)>) -> Value {
    let (h, m, l) = (issues.iter().filter(|i| sev(i["severity"].as_str().unwrap_or("")) == 0).count(), issues.iter().filter(|i| sev(i["severity"].as_str().unwrap_or("")) == 1).count(), issues.iter().filter(|i| sev(i["severity"].as_str().unwrap_or("")) == 2).count());
    let mut status = if h > 0 { "blocked" } else if m > 0 { "needs work" } else { "ready" };
    let mut why = if h > 0 { format!("{h} high finding(s)") } else if m > 0 { format!("{m} medium finding(s)") } else if l > 0 { format!("{l} low finding(s) only") } else { "no findings".into() };
    if let Some((s, w)) = force { let rank = |s: &str| match s { "blocked" => 0, "needs work" => 1, _ => 2 }; if rank(s) < rank(status) { status = if s == "blocked" { "blocked" } else { "needs work" }; why = w; } else if rank(s) == rank(status) { why = format!("{why}; {w}"); } }
    let mut top = issues.clone(); top.sort_by_key(|i| sev(i["severity"].as_str().unwrap_or("")));
    json!({"id": id, "title": title, "status": status, "why": why, "counts": {"high": h, "medium": m, "low": l}, "numbers": numbers.iter().map(|(k, v)| json!({"what": k, "value": v})).collect::<Vec<_>>(), "top": top.iter().take(5).map(|i| json!({"severity": i["severity"], "kind": i["kind"], "what": i["what"], "fix": i["fix"]})).collect::<Vec<_>>()})
}

pub fn build(fields: &Path, safety: Option<&Path>, mechanics: Option<&Path>) -> Result<Value, String> {
    let f = load(Some(fields), "fields.json")?.unwrap();
    let sf = load(safety, "safety.json")?; let mc = load(mechanics, "mechanics.json")?;
    let issues = f["issues"].as_array().cloned().unwrap_or_default();
    let of = |kinds: &[&str]| -> Vec<Value> { issues.iter().filter(|i| kinds.contains(&i["kind"].as_str().unwrap_or(""))).cloned().collect() };
    let rev = &f["review"]; let mut areas = Vec::new();
    // ---- from the fields solve
    for (id, title, kinds) in AREAS {
        let mut nums: Vec<(String, String)> = Vec::new(); let mut force = None;
        match *id {
            "thermal" => {
                let h = &f["heat"]; nums.push(("Peak rise, still air".into(), format!("+{:.0} °C (budget +{:.0} °C)", h["peakRiseC"].as_f64().unwrap_or(0.0), h["budgetC"].as_f64().unwrap_or(0.0))));
                nums.push(("Peak rise, moving air".into(), format!("+{:.0} °C", h["peakRiseMovingC"].as_f64().unwrap_or(0.0))));
                if let Some(c) = f["chips"].as_array().and_then(|a| a.iter().max_by(|a, b| a["riseC"].as_f64().partial_cmp(&b["riseC"].as_f64()).unwrap())) { nums.push(("Hottest part".into(), format!("{} +{:.0} °C", c["reference"].as_str().unwrap_or(""), c["riseC"].as_f64().unwrap_or(0.0)))); }
                if h["sourcesModelled"] == 0 { force = Some(("needs work", "no heat sources in the spec: only the copper's own loss was solved".to_string())); }
            }
            "copper" => { if let Some(n) = f["nets"].as_array().and_then(|a| a.iter().max_by(|a, b| a["peakJ"].as_f64().partial_cmp(&b["peakJ"].as_f64()).unwrap())) { nums.push(("Densest copper".into(), format!("{} {:.0} A/mm² at ({}, {}) on {}", n["net"].as_str().unwrap_or(""), n["peakJ"].as_f64().unwrap_or(0.0), n["peakAt"][0], n["peakAt"][1], n["peakLayer"].as_str().unwrap_or("")))); }
                nums.push(("Copper loss".into(), format!("{} W", f["heat"]["jouleW"]))); }
            "power" => { for r in rev["pdn"]["rails"].as_array().into_iter().flatten().filter(|r| r["skipped"].is_null()) { nums.push((format!("{} impedance", r["net"].as_str().unwrap_or("")), { let hz = r["worst"]["hz"].as_f64().unwrap_or(0.0); format!("{:.2}× its target at worst, at {}", r["worst"]["ratio"].as_f64().unwrap_or(0.0), if hz >= 1e6 { format!("{:.1} MHz", hz / 1e6) } else { format!("{:.1} kHz", hz / 1e3) }) })); }
                if let Some(v) = rev["converter"]["rippleOutMv"].as_f64() { nums.push(("Converter output ripple".into(), format!("{:.1} mV", v))); } }
            "signals" => { let n = rev["impedance"]["nets"].as_array().map_or(0, |a| a.len()); if n > 0 { nums.push(("Nets with solved impedance".into(), format!("{n}"))); } }
            "emc" => {
                let c = &rev["compliance"];
                for t in ["radiated", "conducted", "antenna"] { let v = &c[t]; if v["verdict"].is_string() && v["verdict"] != "not modelled" { let lim = match t { "conducted" => rev["conductedEmissions"]["limit"].as_str().unwrap_or("").to_string(), "antenna" => "antenna port, FCC 15.111".to_string(), _ => c["limit"].as_str().unwrap_or("").to_string() };
                    nums.push((format!("{} ({})", if t == "antenna" { "Oscillator leak" } else if t == "radiated" { "Radiated" } else { "Conducted" }, lim), format!("{}: worst {} {}…{} dB", v["verdict"].as_str().unwrap_or(""), v["worst"].as_str().unwrap_or(""), v["worstBand"][0], v["worstBand"][1]))); } }
                let fail: Vec<&str> = ["radiated", "conducted", "antenna"].into_iter().filter(|t| c[*t]["verdict"] == "likely fail").collect();
                let risk: Vec<&str> = ["radiated", "conducted", "antenna"].into_iter().filter(|t| c[*t]["verdict"] == "at risk").collect();
                if !fail.is_empty() { force = Some(("blocked", format!("{} likely to fail", fail.join(" and ")))); } else if !risk.is_empty() { force = Some(("needs work", format!("{} at risk", risk.join(" and ")))); }
                if let Some(set) = c["toSixDb"][0]["fixes"].as_array() { if !set.is_empty() { nums.push(("Cheapest fixes to 6 dB".into(), set.iter().map(|x| x["id"].as_str().unwrap_or("")).collect::<Vec<_>>().join(", "))); } }
                if let Some(g) = rev["ground"]["stitching"]["edgeCoveredPct"].as_f64() { nums.push(("Board edge stitched".into(), format!("{g} %"))); }
            }
            _ => {}
        }
        areas.push(area(id, title, of(kinds), nums, force));
    }
    let known: Vec<&str> = AREAS.iter().flat_map(|a| a.2.iter().copied()).collect();
    let rest: Vec<Value> = issues.iter().filter(|i| !known.contains(&i["kind"].as_str().unwrap_or(""))).cloned().collect();
    if !rest.is_empty() { areas.push(area("other", "Other findings", rest, vec![], None)); }
    // ---- electrical safety
    match &sf {
        Some(s) => {
            let iss: Vec<Value> = s["issues"].as_array().cloned().unwrap_or_default();
            let e = s["summary"]["errors"].as_u64().unwrap_or(0);
            let mut a = area("safety", "Electrical safety (creepage, clearance)", iss, vec![("Standard".into(), format!("{}, PD{}, group {}, OVC {}", s["standard"].as_str().unwrap_or(""), s["pollutionDegree"], s["materialGroup"].as_str().unwrap_or(""), s["overvoltageCategory"].as_str().unwrap_or(""))), ("Net pairs checked".into(), format!("{}: {} error(s), {} warning(s)", s["summary"]["pairs"], e, s["summary"]["warnings"])), ("Nets on default voltages".into(), format!("{}", s["defaultedNets"].as_array().map_or(0, |a| a.len())))], if e > 0 { Some(("blocked", format!("{e} required distance(s) not met"))) } else { None });
            if s["defaultedNets"].as_array().map_or(false, |a| !a.is_empty()) && a["status"] == "ready" { a["status"] = json!("needs work"); a["why"] = json!("nets on default voltages: give them their real ones"); }
            areas.push(a);
        }
        None => areas.push(json!({"id": "safety", "title": "Electrical safety (creepage, clearance)", "status": "not checked", "why": "no safety.json: run adom-safety check --board <board> --spec <spec> and pass --safety", "counts": {}, "numbers": [], "top": []})),
    }
    // ---- mechanics
    match &mc {
        Some(m) => {
            let iss: Vec<Value> = m["issues"].as_array().cloned().unwrap_or_default();
            let mut nums = vec![("First mode".into(), format!("{} Hz", m["vibration"]["firstModeHz"]))];
            for lc in m["loadCases"].as_array().into_iter().flatten().take(4) { nums.push((format!("Load case {}", lc["name"].as_str().unwrap_or("")), format!("{} mm, {} µε", lc["maxDeflectionMm"].as_f64().map_or("?".into(), |v| format!("{v:.3}")), lc["maxStrainUe"].as_f64().map(|v| v.round()).map_or("?".into(), |v| v.to_string())))); }
            areas.push(area("mechanics", "Mechanical reliability", iss, nums, None));
        }
        None => areas.push(json!({"id": "mechanics", "title": "Mechanical reliability", "status": "not checked", "why": "no mechanics.json: run adom-mechanics analyze --board <board> --fields <this solve> and pass --mechanics", "counts": {}, "numbers": [], "top": []})),
    }
    let rank = |s: &str| match s { "blocked" => 0, "needs work" => 1, "not checked" => 2, _ => 3 };
    let overall = areas.iter().map(|a| a["status"].as_str().unwrap_or("")).min_by_key(|s| rank(s)).unwrap_or("ready").to_string();
    Ok(json!({"board": f["board"]["name"], "description": f["board"]["description"], "overall": overall, "areas": areas,
        "evidence": {"fields": {"tool": "adom-fields", "solverVersion": f["inputIdentity"]["solverVersion"], "boardSha256": f["inputIdentity"]["board"], "specSha256": f["inputIdentity"]["spec"]}, "safety": sf.as_ref().map(|s| json!({"tool": s["tool"], "version": s["version"], "board": s["board"]})), "mechanics": mc.as_ref().map(|m| json!({"tool": m["tool"], "version": m["version"]}))},
        "notCovered": ["The emissions are predictions with bands, not measurements: pre-scan in a chamber (or with the bench plan in review.compliance) before booking the lab.", "Immunity (ESD discharge, radiated and conducted immunity, EFT, surge) is screened only as missing protection, not simulated.", "Safety covers spacing; it does not cover insulation materials' ratings, flammability (UL 94), the enclosure, touch current or the product standard's other clauses.", "Manufacturability (DFM) is the fab check's job (adom-aiflow fab), not this report's.", "Every number is a screening estimate from a published model with its inputs in the solve; qualify by test."]}))
}

pub fn markdown(r: &Value) -> String {
    let icon = |s: &str| match s { "blocked" => "🔴 Blocked", "needs work" => "🟠 Needs work", "not checked" => "⚪ Not checked", _ => "🟢 Ready" };
    let mut o = format!("# Certification readiness: {}\n\n{}\n\n**Overall: {}**\n\n| Area | Status | Why |\n|---|---|---|\n", r["board"].as_str().unwrap_or(""), r["description"].as_str().unwrap_or(""), icon(r["overall"].as_str().unwrap_or("")));
    for a in r["areas"].as_array().into_iter().flatten() { o += &format!("| {} | {} | {} |\n", a["title"].as_str().unwrap_or(""), icon(a["status"].as_str().unwrap_or("")), a["why"].as_str().unwrap_or("").replace('|', "/")); }
    for a in r["areas"].as_array().into_iter().flatten() {
        o += &format!("\n## {}: {}\n\n", a["title"].as_str().unwrap_or(""), icon(a["status"].as_str().unwrap_or("")));
        let nums = a["numbers"].as_array().cloned().unwrap_or_default();
        if !nums.is_empty() { for n in &nums { o += &format!("- **{}:** {}\n", n["what"].as_str().unwrap_or(""), n["value"].as_str().unwrap_or("")); } o += "\n"; }
        let top = a["top"].as_array().cloned().unwrap_or_default();
        if top.is_empty() { o += &format!("{}\n", a["why"].as_str().unwrap_or("")); continue; }
        o += &format!("Findings: {} high, {} medium, {} low. The first {}:\n\n", a["counts"]["high"], a["counts"]["medium"], a["counts"]["low"], top.len());
        for i in &top { o += &format!("- **{}** `{}`: {}\n  - Fix: {}\n", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""), i["fix"].as_str().unwrap_or("")); }
    }
    o += "\n## Not covered\n\n"; for n in r["notCovered"].as_array().into_iter().flatten() { o += &format!("- {}\n", n.as_str().unwrap_or("")); }
    let e = &r["evidence"]; o += &format!("\n## Evidence\n\n- adom-fields {} on board sha256 `{}`, spec sha256 `{}`\n", e["fields"]["solverVersion"].as_str().unwrap_or(""), &e["fields"]["boardSha256"].as_str().unwrap_or("")[..12.min(e["fields"]["boardSha256"].as_str().unwrap_or("").len())], &e["fields"]["specSha256"].as_str().unwrap_or("")[..12.min(e["fields"]["specSha256"].as_str().unwrap_or("").len())]);
    if !e["safety"].is_null() { o += &format!("- {} {}\n", e["safety"]["tool"].as_str().unwrap_or("adom-safety"), e["safety"]["version"].as_str().unwrap_or("")); }
    if !e["mechanics"].is_null() { o += &format!("- {} {}\n", e["mechanics"]["tool"].as_str().unwrap_or("adom-mechanics"), e["mechanics"]["version"].as_str().unwrap_or("")); }
    o
}