main
Colby Knox adom-fields 0.10.7: the board's ground (issue 25); coincident lands as one terminal (issue 26); verify keeps via copper… 554eeec
//! Common-mode cable radiation (0.9.0): the usual reason a board fails the chamber. A switching converter's loop current
//! returns through the ground copper; the copper's inductance between the loop and a connector's ground pin turns each
//! harmonic into a voltage between that connector's ground and the rest of the board, and the cable on that connector,
//! driven by it, radiates as a monopole (Ott, Electromagnetic Compatibility Engineering, ch. 12; Paul):
//!   V_cm(f) = I(f) · 2πf · L_g,   L_g = μ0 · h · n_sq,
//!   n_sq    = the solved DC resistance of the ground copper between the loop's ground and the connector's ground,
//!             over the copper's sheet resistance (the same squares carry the inductance of a plane at height h),
//!   I_cm    = V_cm / 150 Ω (a cable's common-mode impedance),
//!   E       = 1.257e-6 · f · I_cm · L / r,  L capped at λ/4 (the cable is resonant above that).
//! An upper-bound screen: it assumes the loop's whole return current crosses that copper, and ignores ferrites and
//! filtering on the cable. Compared with the chosen limit set (crate::limits) at 3 m.
use crate::board::{Board, LAYERS};
use crate::fields::{Fields, Issue, Spec};
use serde_json::{json, Value};
use std::f64::consts::PI;

fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }

pub fn review(f: &Fields, b: &Board, spec: &Spec, rev: &Value, issues: &mut Vec<Issue>) -> Value {
    let conv = &rev["converter"];
    let Some(fsw) = conv["fswHz"].as_f64() else { return json!({"ran": false, "why": "no switching converter found"}) };
    // the hot loop returns on the converter's power ground; else the board's
    let Some(gname) = conv["ground"].as_str().map(str::to_string).or_else(|| crate::fields::board_ground(spec, b, &f.net_names).map(|g| g.0)) else { return json!({"ran": false, "why": "no ground net"}) };
    let Some(nf) = f.flow.get(&gname) else { return json!({"ran": false, "why": "the ground current was not solved"}) };
    let ig = nf.amps.max(1e-6);
    let (iout, ripple, duty, tr) = (conv["iout"].as_f64().unwrap_or(0.0), conv["rippleA"].as_f64().unwrap_or(0.0), conv["duty"].as_f64().unwrap_or(0.5), conv["riseNs"].as_f64().unwrap_or(8.0) * 1e-9);
    let ipk = iout + ripple / 2.0; let t = 1.0 / fsw;
    // the loop's ground point: the hot-loop capacitor's ground pad (else the switch's)
    let cap = conv["hotLoop"]["cap"].as_str().or_else(|| conv["switch"].as_str()).unwrap_or("");
    let Some(lp) = b.pads.iter().find(|p| p.reference == cap && p.net == gname) else { return json!({"ran": false, "why": "no ground pad on the hot loop"}) };
    let n = f.w * f.h;
    let v_at = |x: f64, y: f64| -> Option<f64> {
        let (c0, r0) = (((x - f.x0) / f.d) as isize, ((y - f.y0) / f.d) as isize); let mut best: Option<f64> = None;
        for rad in 0..6isize { for dr in -rad..=rad { for dc in -rad..=rad { let (r, c) = (r0 + dr, c0 + dc); if r < 0 || c < 0 || r as usize >= f.h || c as usize >= f.w { continue; } let i = r as usize * f.w + c as usize; for k in 0..LAYERS.len() { if nf.on[k].v[i] != 0.0 { best = Some(nf.v[k].v[i] as f64); } } } } if best.is_some() { break; } }
        let _ = n; best
    };
    let Some(v_loop) = v_at(lp.x, lp.y) else { return json!({"ran": false, "why": "the loop's ground pad is off the solved copper"}) };
    let t_um = spec.copperUmByLayer.get("F.Cu").copied().unwrap_or(spec.copperUm);
    let r_sheet = 1.72e-8 / (t_um * 1e-6); // Ω per square
    let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() };
    let h = gaps.first().copied().unwrap_or(1.5) * 1e-3; // the loop's layer to the plane under it, m
    let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());
    let sinc = |x: f64| if x == 0.0 { 1.0 } else { x.sin() / x };
    let mut rows = Vec::new();
    let conns: Vec<String> = { let mut v: Vec<String> = b.pads.iter().filter(|p| (p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS"))) && p.net == gname).map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };
    for cj in &conns {
        let gp = b.pads.iter().find(|p| p.reference == *cj && p.net == gname).unwrap();
        let Some(vc) = v_at(gp.x, gp.y) else { continue };
        let r_eff = (vc - v_loop).abs() / ig;
        let nsq = (r_eff / r_sheet).max(0.05);
        let lg = 4e-7 * PI * h * nsq; // H
        let len = spec.cables.get(cj).copied().unwrap_or(1.0);
        let (mut worst, mut wf, mut wdb) = (f64::MAX, 0.0, 0.0);
        let mut k = (30e6 / fsw).ceil().max(1.0) as u64; let step = ((1e9 - 30e6) / fsw / 3000.0).max(1.0) as u64;
        while (k as f64) * fsw <= 1e9 {
            let fr = k as f64 * fsw;
            let ih = 2.0 * ipk * duty * sinc(PI * k as f64 * duty).abs() * sinc(PI * k as f64 * tr / t).abs();
            let vcm = ih * 2.0 * PI * fr * lg; let icm = vcm / 150.0;
            let leff = len.min(3e8 / fr / 4.0);
            let e = 1.257e-6 * fr * icm * leff / 3.0;
            let db = 20.0 * (e * 1e6).max(1e-30).log10();
            if let Some(l) = crate::limits::at(&chosen, &spec.emcLimits, fr) { if l - db < worst { worst = l - db; wf = fr; wdb = db; } }
            k += step;
        }
        rows.push(json!({"connector": cj, "groundOhm": r_eff, "squares": r1(nsq), "groundNh": r1(lg * 1e9), "cableM": len, "worstMarginDb": r1(worst), "atMHz": (wf / 1e6).round(), "dBuVm": r1(wdb), "limit": crate::limits::label(&chosen)}));
        if worst < 6.0 {
            issues.push(Issue { severity: if worst < 0.0 { "medium".into() } else { "low".into() }, kind: "cable-emissions".into(), what: format!("{}'s cable ({} m) is estimated to radiate {:.0} dBµV/m at {:.0} MHz at 3 m, {:.1} dB {} {} (an upper-bound common-mode screen with no ferrite: the switching loop's return current across {:.1} nH of ground between the loop and {}'s ground pin)", cj, len, wdb, wf / 1e6, worst.abs(), if worst < 0.0 { "over" } else { "under" }, crate::limits::label(&chosen), lg * 1e9, cj), at: Some([gp.x, gp.y]), layer: None, net: Some(gname.clone()), reference: Some(cj.clone()), fix: format!("Keep the switching loop's return off the path to {cj}: put {cj}'s ground next to the converter's input capacitor's ground (or the converter far from {cj}), stitch the ground layers between them, and add a common-mode choke or ferrite on {cj}'s cable."), action: Some(json!({"type": "review", "ref": cj, "why": "common-mode cable radiation", "marginDb": r1(worst), "atMHz": (wf / 1e6).round()})) });
        }
    }
    json!({"ran": true, "model": "Ott common-mode cable monopole driven by the ground inductance between the switching loop and each connector's ground (upper bound; no ferrites)", "loopGroundPad": format!("{}.{}", lp.reference, lp.pad), "heightMm": h * 1e3, "sheetOhm": r_sheet, "connectors": rows})
}