main
John Lauer 0.9.5: compliance prediction with bench calibration, certification readiness report e0da4a3
//! Compliance prediction (0.9.5): every emission source the review models, in one table against the chosen limits,
//! each with an uncertainty band, a verdict per test, the fixes ranked by the dB they buy, the cheapest set that gets
//! to a 6 dB margin, and a bench plan that measures each prediction. Bench numbers come back through spec
//! `measurements` and move the prediction onto them (the model's spectral shape, the bench's level).
//! Bands (the model's own error, not lab-to-lab spread):
//!   hot loop (Ott differential mode)        ±6 dB
//!   cable common mode (an upper bound)      0 to +20 dB of margin: real cables see 35 to 350 Ω and often a ferrite
//!   conducted (differential mode, LISN)     ±4 dB: harmonic amplitude for quasi-peak errs safe, common mode is not modelled
//!   oscillator loop                         ±6 dB
//!   oscillator leak out of the antenna port ±3 dB (a nodal circuit solve; part tolerances)
//!   a source calibrated by a measurement    ±2 dB at the measured frequency, ±3 dB elsewhere
use crate::board::Board;
use crate::fields::Spec;
use serde::Deserialize;
use serde_json::{json, Value};
use std::f64::consts::PI;

/// A bench reading: `source` is `conducted`, `loop`, `cable:J2`, `lo:<net>` or `lo-leak:<net>` (the ids in review.compliance.sources);
/// `unit` is dBuV (LISN), dBuA (a current clamp on the cable), dBuVm (a chamber or OATS reading at 3 m) or dBm (the antenna port).
#[allow(non_snake_case)]
#[derive(Deserialize, Clone, Debug)]
pub struct Measurement { pub source: String, pub fMHz: f64, pub value: f64, #[serde(default = "dbuv")] pub unit: String, #[serde(default)] pub note: Option<String> }
fn dbuv() -> String { "dBuV".into() }

fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }
fn db(x: f64) -> f64 { 20.0 * x.max(1e-30).log10() }
fn sinc(x: f64) -> f64 { if x == 0.0 { 1.0 } else { x.sin() / x } }

struct Conv { fsw: f64, ipk: f64, duty: f64, tr: f64 }
impl Conv {
    fn of(c: &Value) -> Option<Conv> { let fsw = c["fswHz"].as_f64()?; Some(Conv { fsw, ipk: c["iout"].as_f64().unwrap_or(0.0) + c["rippleA"].as_f64().unwrap_or(0.0) / 2.0, duty: c["duty"].as_f64().unwrap_or(0.5), tr: c["riseNs"].as_f64().unwrap_or(8.0) * 1e-9 }) }
    /// the trapezoid's line amplitude at the harmonic nearest f, and that harmonic's frequency
    fn harmonic(&self, f: f64) -> (f64, f64) { let k = (f / self.fsw).round().max(1.0); let t = 1.0 / self.fsw; (2.0 * self.ipk * self.duty * sinc(PI * k * self.duty).abs() * sinc(PI * k * self.tr / t).abs(), k * self.fsw) }
    /// the envelope's edge term at f for rise time tr (1 below the corner 1/(π·tr))
    fn edge(tr: f64, f: f64) -> f64 { (1.0 / (PI * f * tr)).min(1.0) }
}

/// a cable's common-mode level at f: (E at 3 m in dBµV/m, the cable current in dBµA)
fn cable_level(c: &Conv, lg_nh: f64, len: f64, f: f64) -> (f64, f64) {
    let (ih, fr) = c.harmonic(f); let icm = ih * 2.0 * PI * fr * lg_nh * 1e-9 / 150.0;
    let leff = len.min(3e8 / fr / 4.0); (db(1.257e-6 * fr * icm * leff / 3.0 * 1e6), db(icm * 1e6))
}
/// the cable current that puts E at 3 m on the limit, dBµA
fn cable_limit_current(lim_dbuvm: f64, len: f64, f: f64) -> f64 { let leff = len.min(3e8 / f / 4.0); lim_dbuvm - db(1.257e-6 * f * leff / 3.0) }

pub fn review(b: &Board, spec: &Spec, rev: &Value) -> Value {
    let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());
    let label = crate::limits::label(&chosen);
    let conv = Conv::of(&rev["converter"]);
    let pos = |r: &str| { let p: Vec<_> = b.pads.iter().filter(|p| p.reference == r).collect(); if p.is_empty() { Value::Null } else { json!([r1(p.iter().map(|p| p.x).sum::<f64>() / p.len() as f64), r1(p.iter().map(|p| p.y).sum::<f64>() / p.len() as f64)]) } };
    let mut src: Vec<Value> = Vec::new();
    // ---- the sources ---------------------------------------------------------------------------------------------------
    let c = &rev["converter"];
    if let Some(m) = c["emissions"]["worstMarginDb"].as_f64() {
        let f = c["emissions"]["atMHz"].as_f64().unwrap_or(0.0); let lim = crate::limits::at(&chosen, &spec.emcLimits, f * 1e6).unwrap_or(0.0);
        let cap = c["hotLoop"]["cap"].as_str().unwrap_or("");
        src.push(json!({"id": "loop", "test": "radiated", "what": format!("switching hot loop ({} to {}), differential mode", c["switch"].as_str().unwrap_or("?"), cap), "at": pos(cap), "fMHz": f, "level": r1(lim - m), "unit": "dBuVm", "limit": lim, "marginDb": m, "band": [r1(m - 6.0), r1(m + 6.0)], "basis": "Ott small-loop model, ±6 dB"}));
    }
    for row in rev["cableEmissions"]["connectors"].as_array().into_iter().flatten() {
        let cj = row["connector"].as_str().unwrap_or(""); let m = row["worstMarginDb"].as_f64().unwrap_or(0.0); let f = row["atMHz"].as_f64().unwrap_or(0.0);
        let lim = crate::limits::at(&chosen, &spec.emcLimits, f * 1e6).unwrap_or(0.0); let len = row["cableM"].as_f64().unwrap_or(1.0);
        let icur = conv.as_ref().map(|cv| cable_level(cv, row["groundNh"].as_f64().unwrap_or(0.0), len, f * 1e6).1);
        src.push(json!({"id": format!("cable:{cj}"), "test": "radiated", "what": format!("{cj}'s {len} m cable, common mode from the switching loop's ground"), "at": pos(cj), "fMHz": f, "level": row["dBuVm"], "unit": "dBuVm", "limit": lim, "marginDb": m, "band": [r1(m), r1(m + 20.0)], "basis": "common-mode monopole, 150 Ω cable, no ferrite: an upper bound (0 to +20 dB)", "cableCurrentDbuA": icur.map(r1), "limitCurrentDbuA": r1(cable_limit_current(lim, len, f * 1e6))}));
    }
    for row in rev["rf"]["loEmission"].as_array().into_iter().flatten() {
        let m = row["marginDb"].as_f64().unwrap_or(0.0); let n = row["net"].as_str().unwrap_or("");
        src.push(json!({"id": format!("lo:{n}"), "test": "radiated", "what": format!("{n} oscillator copper as a loop"), "at": Value::Null, "fMHz": r1(row["atHz"].as_f64().unwrap_or(0.0) / 1e6), "level": row["dBuVm"], "unit": "dBuVm", "limit": row["limitDbuVm"], "marginDb": m, "band": [r1(m - 6.0), r1(m + 6.0)], "basis": "small-loop model of the oscillator copper, ±6 dB"}));
    }
    // a receiver's oscillator leaking out of its antenna port (FCC 15.111 / CISPR 32's antenna-port test)
    for row in rev["rf"]["loLeak"].as_array().into_iter().flatten() {
        let (w, lim) = (row["worstDbm"].as_f64().unwrap_or(-200.0), row["limitDbm"].as_f64().unwrap_or(-57.0)); let m = lim - w;
        let n = row["from"].as_str().unwrap_or(""); let ng = row["points"].as_array().map_or(false, |p| p.iter().all(|x| x["ngspiceDbm"].is_number()));
        src.push(json!({"id": format!("lo-leak:{n}"), "test": "antenna", "what": format!("{n} leaking out of {} (the receiver's antenna port)", row["to"].as_str().unwrap_or("the antenna")), "at": Value::Null, "fMHz": r1(row["worstHz"].as_f64().unwrap_or(0.0) / 1e6), "level": r1(w), "unit": "dBm", "limit": lim, "marginDb": r1(m), "band": [r1(m - 3.0), r1(m + 3.0)], "basis": if ng { "nodal solve of the front end's parts and parasitics, ngspice agrees; ±3 dB (part tolerances)" } else { "nodal solve of the front end's parts and parasitics; ±3 dB (part tolerances)" }}));
    }
    let ce = &rev["conductedEmissions"];
    if ce["ran"] == true {
        let m = ce["worstMarginDb"].as_f64().unwrap_or(0.0); let f = ce["atKHz"].as_f64().unwrap_or(0.0) / 1e3;
        src.push(json!({"id": "conducted", "test": "conducted", "what": format!("converter input ripple on {}'s supply cable (LISN)", c["inNet"].as_str().unwrap_or("the input")), "at": pos(c["inCaps"][0]["ref"].as_str().unwrap_or("")), "fMHz": f, "level": ce["dBuV"], "unit": "dBuV", "limit": r1(ce["dBuV"].as_f64().unwrap_or(0.0) + m), "marginDb": m, "band": [r1(m - 4.0), r1(m + 4.0)], "basis": "differential-mode LISN model, ±4 dB (common-mode conducted noise is not modelled)"}));
    }
    // ---- calibration from the bench -----------------------------------------------------------------------------------
    let mut cal = Vec::new();
    for ms in &spec.measurements {
        let Some(s) = src.iter_mut().find(|s| s["id"] == ms.source.as_str()) else { cal.push(json!({"source": ms.source, "used": false, "why": "no such source in this solve (ids: conducted, loop, cable:<J>, lo:<net>, lo-leak:<net>)"})); continue };
        let f = ms.fMHz * 1e6; let at_worst = (ms.fMHz - s["fMHz"].as_f64().unwrap_or(0.0)).abs() <= 0.1 * ms.fMHz.max(1e-9);
        // the model's level at the measured frequency, in the measured unit
        let pred: Option<f64> = match (s["id"].as_str().unwrap_or(""), ms.unit.as_str()) {
            ("conducted", "dBuV") => ce["spectrum"]["points"].as_array().and_then(|p| p.iter().min_by(|a, z| ((a[0].as_f64().unwrap_or(0.0) - ms.fMHz * 1e3).abs()).partial_cmp(&(z[0].as_f64().unwrap_or(0.0) - ms.fMHz * 1e3).abs()).unwrap()).and_then(|p| p[1].as_f64())),
            (id, u) if id.starts_with("cable:") && (u == "dBuA" || u == "dBuVm") => { let cj = &id[6..]; let row = rev["cableEmissions"]["connectors"].as_array().and_then(|a| a.iter().find(|r| r["connector"] == cj).cloned()); match (conv.as_ref(), row) { (Some(cv), Some(row)) => { let (e, i) = cable_level(cv, row["groundNh"].as_f64().unwrap_or(0.0), row["cableM"].as_f64().unwrap_or(1.0), f); Some(if u == "dBuA" { i } else { e }) } _ => None } }
            (_, "dBuVm") | (_, "dBm") if at_worst && s["unit"] == ms.unit.as_str() => s["level"].as_f64(),
            _ => None,
        };
        let Some(p) = pred else { cal.push(json!({"source": ms.source, "used": false, "why": format!("a {} reading at {} MHz does not map onto this source's model (conducted: dBuV; cable: dBuA or dBuVm; loop and lo: dBuVm, lo-leak: dBm, at the worst frequency ±10 %)", ms.unit, ms.fMHz)})); continue };
        let off = ms.value - p; let m0 = s["marginDb"].as_f64().unwrap_or(0.0); let m = m0 - off; let w = if at_worst { 2.0 } else { 3.0 };
        s["level"] = json!(r1(s["level"].as_f64().unwrap_or(0.0) + off)); s["marginDb"] = json!(r1(m)); s["band"] = json!([r1(m - w), r1(m + w)]);
        s["basis"] = json!(format!("calibrated: the bench read {:.1} {} at {} MHz against {:.1} predicted ({:+.1} dB); the model's shape, the bench's level, ±{w} dB", ms.value, ms.unit, ms.fMHz, p, off));
        cal.push(json!({"source": ms.source, "used": true, "offsetDb": r1(off), "marginBefore": r1(m0), "marginAfter": r1(m), "note": ms.note}));
    }
    // a cable reading says how far the common-mode model is off for this board: the cables not measured move by the same
    // offset (the mean of the measured ones), with ±4 dB for what differs between cables
    let offs: Vec<f64> = cal.iter().filter(|x| x["used"] == true && x["source"].as_str().unwrap_or("").starts_with("cable:")).filter_map(|x| x["offsetDb"].as_f64()).collect();
    if !offs.is_empty() { let off = offs.iter().sum::<f64>() / offs.len() as f64; let by: Vec<String> = cal.iter().filter(|x| x["used"] == true && x["source"].as_str().unwrap_or("").starts_with("cable:")).map(|x| x["source"].as_str().unwrap_or("")[6..].to_string()).collect();
        for s in src.iter_mut().filter(|s| s["id"].as_str().unwrap_or("").starts_with("cable:") && !s["basis"].as_str().unwrap_or("").starts_with("calibrated")) {
            let m = s["marginDb"].as_f64().unwrap_or(0.0) - off; s["marginDb"] = json!(r1(m)); s["level"] = json!(r1(s["level"].as_f64().unwrap_or(0.0) + off)); s["band"] = json!([r1(m - 4.0), r1(m + 4.0)]);
            s["basis"] = json!(format!("inferred from {}'s reading: the common-mode model moved by {:+.1} dB, ±4 dB", by.join(", "), off));
        } }
    // ---- fixes and their dB --------------------------------------------------------------------------------------------
    let mut fixes: Vec<Value> = Vec::new();
    let mut add = |id: &str, what: String, how: &str, cost: &str, gains: Vec<(String, f64)>| { let g: Vec<(String, f64)> = gains.into_iter().filter(|g| g.1 >= 0.5).collect(); if !g.is_empty() { fixes.push(json!({"id": id, "fix": what, "how": how, "cost": cost, "gains": g.iter().map(|(s, d)| json!({"source": s, "gainDb": r1(*d)})).collect::<Vec<_>>()})); } };
    let switching: Vec<(String, f64, &str)> = src.iter().filter(|s| s["id"] == "loop" || s["id"] == "conducted" || s["id"].as_str().unwrap_or("").starts_with("cable:")).map(|s| (s["id"].as_str().unwrap_or("").to_string(), s["fMHz"].as_f64().unwrap_or(0.0) * 1e6, if s["test"] == "conducted" { "c" } else { "r" })).collect();
    if let Some(cv) = &conv {
        add("slow-edge", format!("Slow the switch edge from {:.0} to {:.0} ns (a gate resistor; on a converter with its switch inside, a few ohms in series with the bootstrap capacitor; or the controller's slew setting)", cv.tr * 1e9, cv.tr * 2e9), "edge", "a resistor, a little efficiency", switching.iter().map(|(s, f, _)| (s.clone(), db(Conv::edge(cv.tr, *f) / Conv::edge(cv.tr * 2.0, *f)))).collect());
        add("spread-spectrum", "Turn on spread-spectrum (±5 % dither) if the controller has it".into(), "dither", "free if supported", switching.iter().map(|(s, f, t)| { let rbw = if *t == "c" { 9e3 } else { 120e3 }; (s.clone(), (10.0 * (0.1 * f / rbw).max(1.0).log10()).min(if *t == "c" { 8.0 } else { 10.0 })) }).collect());
    }
    if let (Some(a), Some(id)) = (c["hotLoop"]["mm2"].as_f64(), c["idealMm2"].as_f64()) { add("shrink-loop", format!("Shrink the hot loop from {a} to {id} mm² (the input capacitor tight against the switch, its ground via at the pad)"), "layout", "layout time", vec![("loop".into(), db(a / id))]); }
    for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("cable:")) {
        let id = s["id"].as_str().unwrap(); let cj = &id[6..];
        add(&format!("ferrite:{cj}"), format!("A clip-on ferrite on {cj}'s cable (about 200 Ω at {} MHz)", s["fMHz"]), "cable", "cents, no board change", vec![(id.into(), db(350.0 / 150.0))]);
        add(&format!("choke:{cj}"), format!("A common-mode choke on {cj} on the board (about 1 kΩ at {} MHz)", s["fMHz"]), "part", "a part and a re-layout", vec![(id.into(), db(1150.0 / 150.0))]);
        add(&format!("ground:{cj}"), format!("Tie {cj}'s ground next to the converter's input-capacitor ground and stitch between them (halves the shared ground inductance, or better)"), "layout", "layout time", vec![(id.into(), 6.0)]);
    }
    if ce["ran"] == true { let m = ce["worstMarginDb"].as_f64().unwrap_or(0.0); if m < 6.0 {
        let l = rev["conductedEmissions"]["inputFilter"].is_null();
        add("input-filter", format!("{} an input LC filter sized for 6 dB of margin (the conducted-emissions issue gives the inductor)", if l { "Add" } else { "Enlarge" }), "part", "an inductor, a bulk capacitor, re-layout", vec![("conducted".into(), 6.0 - m)]);
    } }
    for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("lo-leak:")) { let id = s["id"].as_str().unwrap(); add(&format!("isolate:{}", &id[8..]), "An RF amplifier (or a buffer) between the antenna and the mixer: its reverse isolation, about 20 dB, keeps the oscillator in".into(), "part", "a part and a re-layout", vec![(id.into(), 20.0)]); add(&format!("pad:{}", &id[8..]), "Weaken the oscillator's coupling to the mixer input (a smaller coupling capacitor, or the LO injected at the emitter), about 6 dB".into(), "layout", "a value change", vec![(id.into(), 6.0)]); }
    for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("lo:")) { add(&format!("shield:{}", &s["id"].as_str().unwrap()[3..]), "A shield can over the oscillator".into(), "part", "a can and its footprint", vec![(s["id"].as_str().unwrap().into(), 20.0)]); }
    // rank by what each buys on the worst source it touches
    let margin_of = |id: &str| src.iter().find(|s| s["id"] == id).and_then(|s| s["marginDb"].as_f64()).unwrap_or(99.0);
    fixes.sort_by(|a, z| { let k = |f: &Value| f["gains"].as_array().unwrap().iter().map(|g| (margin_of(g["source"].as_str().unwrap()), -g["gainDb"].as_f64().unwrap())).fold((99.0, 0.0), |acc, x| if x.0 < acc.0 || (x.0 == acc.0 && x.1 < acc.1) { x } else { acc }); let (ka, kz) = (k(a), k(z)); ka.0.partial_cmp(&kz.0).unwrap().then(ka.1.partial_cmp(&kz.1).unwrap()) });
    // ---- the cheapest set to 6 dB on every source: every subset of the fixes (a dozen or so), by cost then count ----------
    let cost = |f: &Value| match f["how"].as_str().unwrap_or("") { "dither" => 1.0, "cable" => 2.0, "edge" => 3.0, "layout" => 5.0, _ => 8.0 };
    let base: Vec<(String, f64)> = src.iter().map(|s| (s["id"].as_str().unwrap().to_string(), s["marginDb"].as_f64().unwrap_or(0.0))).collect();
    let gain = |f: &Value, id: &str| f["gains"].as_array().unwrap().iter().find(|g| g["source"] == id).and_then(|g| g["gainDb"].as_f64()).unwrap_or(0.0);
    let useful: Vec<&Value> = fixes.iter().filter(|f| base.iter().any(|(id, m)| *m < 6.0 && gain(f, id) > 0.0)).take(16).collect();
    let mut best: Option<(f64, f64, u32)> = None; // (shortfall, cost, mask)
    if base.iter().any(|b| b.1 < 6.0) { for mask in 0u32..(1 << useful.len()) {
        let mut short = 0.0; for (id, m) in &base { let g: f64 = useful.iter().enumerate().filter(|(k, _)| mask >> k & 1 == 1).map(|(_, f)| gain(f, id)).sum(); short += (6.0 - m - g).max(0.0); }
        let c: f64 = useful.iter().enumerate().filter(|(k, _)| mask >> k & 1 == 1).map(|(_, f)| cost(f)).sum();
        let key = (r1(short), c, mask); if best.map_or(true, |b| (key.0, key.1, mask.count_ones()) < (b.0, b.1, b.2.count_ones())) { best = Some(key); }
    } }
    let chosen_fixes: Vec<Value> = best.map(|(short, _, mask)| { let pick: Vec<&&Value> = useful.iter().enumerate().filter(|(k, _)| mask >> k & 1 == 1).map(|(_, f)| f).collect();
        vec![json!({"fixes": pick.iter().map(|f| json!({"id": f["id"], "fix": f["fix"], "cost": f["cost"]})).collect::<Vec<_>>(), "marginsAfter": base.iter().map(|(id, m)| json!({"source": id, "marginDb": r1(m + pick.iter().map(|f| gain(f, id)).sum::<f64>())})).collect::<Vec<_>>(), "reaches6Db": short == 0.0, "shortfallDb": short})] }).unwrap_or_default();
    // ---- verdicts -------------------------------------------------------------------------------------------------------
    let verdict = |test: &str| -> Value {
        let s: Vec<&Value> = src.iter().filter(|s| s["test"] == test).collect(); if s.is_empty() { return json!({"verdict": "not modelled"}); }
        let lo = s.iter().filter_map(|x| x["band"][0].as_f64()).fold(f64::MAX, f64::min); let hi_worst = s.iter().filter_map(|x| x["band"][1].as_f64()).fold(f64::MAX, f64::min);
        let w = s.iter().min_by(|a, z| a["band"][0].as_f64().partial_cmp(&z["band"][0].as_f64()).unwrap()).unwrap();
        json!({"verdict": if hi_worst < 0.0 { "likely fail" } else if lo < 0.0 { "at risk" } else if lo < 6.0 { "likely pass, thin margin" } else { "likely pass" }, "worst": w["id"], "worstBand": w["band"]})
    };
    // ---- the bench plan: cheapest and most telling first -------------------------------------------------------------
    let mut plan = Vec::new();
    if let Some(s) = src.iter().find(|s| s["id"] == "conducted") { plan.push(json!({"source": "conducted", "instrument": "LISN (50 Ω ∥ 50 µH) and a spectrum analyzer, peak detector", "setup": format!("the board powered through the LISN on {}, the load at its rated current", c["inNet"].as_str().unwrap_or("the input")), "span": "150 kHz to 30 MHz, RBW 9 kHz", "expect": format!("about {} dBuV at {} MHz (limit {} dBuV)", s["level"], s["fMHz"], s["limit"]), "record": json!({"source": "conducted", "fMHz": s["fMHz"], "value": "<reading>", "unit": "dBuV"})})); }
    for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("cable:")) { let cj = &s["id"].as_str().unwrap()[6..]; plan.push(json!({"source": s["id"], "instrument": "a common-mode current clamp (RF current probe) on the cable and a spectrum analyzer", "setup": format!("the clamp around the whole {cj} cable 5 cm from the connector, the board running at load"), "span": "30 to 300 MHz, RBW 120 kHz, peak", "expect": format!("at most {} dBuA at {} MHz (the model is an upper bound); {} dBuA puts the cable on the limit at 3 m", s["cableCurrentDbuA"], s["fMHz"], s["limitCurrentDbuA"]), "record": json!({"source": s["id"], "fMHz": s["fMHz"], "value": "<reading>", "unit": "dBuA"})})); }
    if let Some(s) = src.iter().find(|s| s["id"] == "loop") { plan.push(json!({"source": "loop", "instrument": "an H-field near-field probe (10 mm loop) and a spectrum analyzer", "setup": format!("the probe flat over the hot loop at {} about 5 mm up, then swept over the board", s["at"]), "span": format!("{} to 1000 MHz", (s["fMHz"].as_f64().unwrap_or(30.0) / 2.0).max(30.0).round()), "expect": "relative only: the loop should be the strongest spot; a near-field reading ranks sources and shows a fix working, it does not give dBuV/m (calibrate the loop with a chamber reading, unit dBuVm)", "record": Value::Null})); }
    for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("lo:")) { plan.push(json!({"source": s["id"], "instrument": "an H-field near-field probe, then a pre-scan antenna at 3 m", "setup": "the probe over the oscillator's tank and traces", "span": format!("{} MHz ± 5 %", s["fMHz"]), "expect": format!("about {} dBuV/m at 3 m (limit {})", s["level"], s["limit"]), "record": json!({"source": s["id"], "fMHz": s["fMHz"], "value": "<reading>", "unit": "dBuVm"})})); }
    for s in src.iter().filter(|s| s["id"].as_str().unwrap_or("").starts_with("lo-leak:")) { plan.push(json!({"source": s["id"], "instrument": "a spectrum analyzer on the antenna port (50 Ω), through a DC block", "setup": "the antenna removed, the receiver tuned across its band", "span": format!("the oscillator's range around {} MHz", s["fMHz"]), "expect": format!("about {} dBm (limit {} dBm)", s["level"], s["limit"]), "record": json!({"source": s["id"], "fMHz": s["fMHz"], "value": "<reading>", "unit": "dBm"})})); }
    json!({"limit": label, "radiated": verdict("radiated"), "conducted": verdict("conducted"), "antenna": verdict("antenna"), "sources": src, "fixes": fixes, "toSixDb": chosen_fixes, "benchPlan": plan, "calibration": cal,
        "notes": ["Margins are dB under the limit (negative is over). The bands are the models' own error, not lab-to-lab spread.", "Fix gains add in dB, as if independent; a re-solve or a measurement confirms them.", "Put each bench reading in spec measurements ({source, fMHz, value, unit}) and re-run: the source moves onto it and its band narrows to ±2 dB."]})
}

#[cfg(test)] mod tests {
    use super::*;
    #[test] fn cable_current_on_the_limit() {
        // a current that makes the limit's field gives the limit back
        let (lim, len, f) = (43.5, 1.0, 100e6); let i = cable_limit_current(lim, len, f);
        let e = db(1.257e-6 * f * 10f64.powf(i / 20.0) * 1e-6 * len.min(0.75) / 3.0 * 1e6); assert!((e - lim).abs() < 1e-9);
    }
    #[test] fn edge_gain() { assert!((db(Conv::edge(8e-9, 100e6) / Conv::edge(16e-9, 100e6)) - 6.02).abs() < 0.01); assert_eq!(Conv::edge(8e-9, 1e6), 1.0); }
}