main
John Lauer adom-fields 0.9.2: decoupling optimiser, conducted emissions 087519a
//! Conducted emissions (0.9.2): a switching converter's input current is a pulse train; its harmonics split between the
//! input capacitors and the supply cable, and the test lab's LISN (50 Ω ∥ 50 µH) reads the cable's share as a voltage.
//!   I_h(n) = 2·Iin_pk·D·|sinc(πnD)|·|sinc(πn·tr/T)|          (the trapezoid's line amplitude)
//!   I_lisn = I_h · Z_cin / (Z_cin + Z_filter + Z_lisn),      V = I_lisn · |Z_lisn|  (the 50 Ω port)
//! Z_cin: the converter's input capacitors at their DC bias, each with its ESR and about 1.5 nH of ESL and mounting.
//! Z_filter: an inductor (or ferrite) found in series on the input, else nothing. Differential mode only: common-mode
//! conducted noise needs the board's parasitic capacitance to the chassis, which is not modelled.
//! Compared with CISPR 32 / FCC 15.107 class B (or A) quasi-peak, 150 kHz to 30 MHz (a harmonic's amplitude stands in for
//! its quasi-peak: conservative).
use crate::board::Board;
use crate::fields::{Issue, Spec};
use serde_json::{json, Value};
use std::f64::consts::PI;

#[derive(Clone, Copy)] struct Cx(f64, f64);
impl Cx { fn add(self, o: Cx) -> Cx { Cx(self.0 + o.0, self.1 + o.1) } fn mul(self, o: Cx) -> Cx { Cx(self.0 * o.0 - self.1 * o.1, self.0 * o.1 + self.1 * o.0) } fn inv(self) -> Cx { let d = self.0 * self.0 + self.1 * self.1; Cx(self.0 / d, -self.1 / d) } fn abs(self) -> f64 { self.0.hypot(self.1) } }

/// quasi-peak limit, dBµV, 150 kHz to 30 MHz
pub fn limit(class_a: bool, f: f64) -> f64 {
    let m = f / 1e6;
    if class_a { if m < 0.5 { 79.0 } else { 73.0 } }
    else if m < 0.5 { 66.0 - 10.0 * ((m / 0.15).log10() / (0.5f64 / 0.15).log10()) } else if m < 5.0 { 56.0 } else { 60.0 }
}

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

pub fn review(b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec<Issue>) -> Value {
    let Some(fsw) = conv["fswHz"].as_f64() else { return json!({"ran": false, "why": "no switching converter found"}) };
    let (iout, duty, tr) = (conv["iout"].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 ripple = conv["rippleA"].as_f64().unwrap_or(0.0);
    let ipk = iout + ripple / 2.0; let t = 1.0 / fsw;
    let class_a = spec.emcLimit.as_deref().is_some_and(|s| s.ends_with("-a"));
    let inn = conv["inNet"].as_str().unwrap_or("");
    // the input filter: an inductor or ferrite with one pad on the converter's input net, not the converter's own inductor
    let lref = conv["inductor"].as_str().unwrap_or("");
    let filt = b.values.iter().filter(|(r, _)| (r.starts_with('L') || r.starts_with("FB")) && r.as_str() != lref).find(|(r, _)| b.pads.iter().any(|p| p.reference == **r && p.net == inn)).map(|(r, v)| (r.clone(), if r.starts_with('L') { crate::rf::value(v) } else { None }));
    let caps: Vec<(f64, f64)> = conv["inCaps"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| Some((c["uFAtBias"].as_f64()? * 1e-6, c["esrMohm"].as_f64().unwrap_or(10.0) * 1e-3))).collect();
    if caps.is_empty() { return json!({"ran": false, "why": "no input capacitors found"}); }
    let sinc = |x: f64| if x == 0.0 { 1.0 } else { x.sin() / x };
    let (mut worst, mut wf, mut wdb) = (f64::MAX, 0.0, 0.0); let mut pts = Vec::new();
    let mut k = (150e3 / fsw).ceil().max(1.0) as u64;
    while (k as f64) * fsw <= 30e6 {
        let fr = k as f64 * fsw; let w = 2.0 * PI * fr;
        let ih = 2.0 * ipk * duty * sinc(PI * k as f64 * duty).abs() * sinc(PI * k as f64 * tr / t).abs();
        let mut ycin = Cx(0.0, 0.0); for (c, esr) in &caps { let z = Cx(*esr, w * 1.5e-9 - 1.0 / (w * c)); ycin = ycin.add(z.inv()); }
        let zcin = ycin.inv();
        let zf = match &filt { Some((_, Some(l))) => Cx(0.05, w * l), Some((_, None)) => Cx(60.0, 0.0), None => Cx(0.0, 0.0) };
        let zl = { let a = Cx(50.0, 0.0); let bz = Cx(0.0, w * 50e-6); a.mul(bz).mul(a.add(bz).inv()) };
        let i_lisn = ih * (zcin.abs() / zcin.add(zf).add(zl).abs());
        let v = i_lisn * zl.abs(); let db = 20.0 * (v * 1e6).max(1e-30).log10();
        let m = limit(class_a, fr) - db;
        if m < worst { worst = m; wf = fr; wdb = db; }
        if pts.len() < 400 { pts.push(json!([r1(fr / 1e3), r1(db), r1(limit(class_a, fr))])); }
        k += 1;
    }
    let lim_name = if class_a { "CISPR 32 / FCC 15.107 class A" } else { "CISPR 32 / FCC 15.107 class B" };
    if worst < 6.0 {
        // the extra attenuation needed, and an LC that gives it at the worst frequency (a second-order filter: 40 dB/decade above its corner)
        let need = (6.0 - worst).max(0.0); let fc = wf / 10f64.powf(need / 40.0);
        let ctot: f64 = caps.iter().map(|c| c.0).sum();
        let l_needed = 1.0 / ((2.0 * PI * fc).powi(2) * ctot);
        issues.push(Issue { severity: if worst < 0.0 { "medium".into() } else { "low".into() }, kind: "conducted-emissions".into(), what: format!("The converter's input ripple reaches the supply cable at about {:.0} dBµV at {:.0} kHz, {:.1} dB {} {} (differential mode, through the LISN)", wdb, wf / 1e3, worst.abs(), if worst < 0.0 { "over" } else { "under" }, lim_name), at: None, layer: None, net: Some(inn.to_string()), reference: filt.as_ref().map(|f| f.0.clone()), fix: format!("Add (or enlarge) an input filter: about {:.1} µH in series on {} with the input capacitors after it puts the corner near {:.0} kHz and gains about {:.0} dB at {:.0} kHz; damp it (a bulk capacitor with some ESR) and re-check the input-filter stability in the PDN view.", l_needed * 1e6, inn, fc / 1e3, need, wf / 1e3), action: Some(json!({"type": "add-part", "what": "input filter inductor", "valueUh": (l_needed * 1e7).round() / 10.0, "net": inn, "why": "conducted emissions", "marginDb": r1(worst), "atKHz": (wf / 1e3).round()})) });
    }
    json!({"ran": true, "limit": lim_name, "model": "differential-mode trapezoid input current into the input capacitors and the LISN (50 Ω ∥ 50 µH); harmonic amplitude as quasi-peak; no common mode", "inputFilter": filt.as_ref().map(|f| json!({"ref": f.0, "uH": f.1.map(|l| l * 1e6)})), "worstMarginDb": r1(worst), "atKHz": (wf / 1e3).round(), "dBuV": r1(wdb), "spectrum": {"columns": ["kHz", "dBuV", "limit"], "points": pts}})
}

#[cfg(test)] mod tests {
    use super::*;
    #[test] fn limits() { assert!((limit(false, 150e3) - 66.0).abs() < 1e-9); assert!((limit(false, 500e3) - 56.0).abs() < 1e-6); assert_eq!(limit(false, 10e6), 60.0); assert_eq!(limit(true, 1e6), 73.0); }
}