main
John Lauer adom-fields 0.9.0: cable CM emissions, cross-section impedance solver, limit sets, board checks af6e56e
//! Receiver screens left open by the issue 9 review (Noah's AM/FM superhet), each a screening estimate, never a
//! measurement:
//! - the LO level at the antenna port: the circuit path an LO takes to a sensitive net (found by the noise review), as a
//!   small AC network (every part touching the path, transistors as their junction capacitances) solved for the voltage
//!   on the antenna's port resistance, against FCC 15.111's 2 nW (-57 dBm) receiver limit; cross-checked in ngspice when
//!   it is installed;
//! - the isolation between stages through a shared rail: an oscillator stage's feed driving the rail through its series
//!   resistor, the rail's capacitors and source, and each other stage's feed filter, at the oscillator's frequency, in dB;
//! - the LO's own radiated emission: its copper as a small loop over the reference plane, carrying the current its
//!   voltage pushes into the copper's and the parts' capacitance, against FCC class B at 3 m.
//! The fourth open item (USB and limiter harmonics as aggressors) lives in the noise review's frequency plan.
use crate::board::Board;
use crate::fields::{Issue, Spec};
use crate::flow::{is_ground, rail_from_name};
use serde_json::{json, Value};
use std::f64::consts::PI;

#[derive(Clone, Copy, Debug)]
pub struct Cx(pub f64, pub f64);
impl Cx {
    fn add(self, o: Cx) -> Cx { Cx(self.0 + o.0, self.1 + o.1) }
    fn sub(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 div(self, o: Cx) -> Cx { self.mul(o.inv()) }
    pub fn abs(self) -> f64 { self.0.hypot(self.1) }
}
fn par(a: Cx, b: Cx) -> Cx { a.mul(b).div(a.add(b)) }
fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }

/// a part's value in its base unit: 47, 4.7k, 4k7, 2.2p, 120n, 10u, 1M, 0R
pub fn value(s: &str) -> Option<f64> {
    let t = s.trim().replace(',', ".").replace('µ', "u").replace('Ω', "");
    let t = t.split_whitespace().next()?.to_string();
    let mul = |c: char| match c { 'p' => Some(1e-12), 'n' => Some(1e-9), 'u' => Some(1e-6), 'm' => Some(1e-3), 'k' | 'K' => Some(1e3), 'M' => Some(1e6), 'R' | 'r' => Some(1.0), _ => None };
    let chars: Vec<char> = t.chars().collect();
    // 4k7 / 2p2 / 0R1: the multiplier stands in for the decimal point
    if let Some(i) = chars.iter().position(|c| mul(*c).is_some()) {
        let (a, z): (String, String) = (chars[..i].iter().collect(), chars[i + 1..].iter().take_while(|c| c.is_ascii_digit()).collect());
        if !a.is_empty() && a.chars().all(|c| c.is_ascii_digit() || c == '.') {
            let v: f64 = if z.is_empty() { a.parse().ok()? } else { format!("{a}.{z}").parse().ok()? };
            return Some(v * mul(chars[i])?);
        }
    }
    let num: String = chars.iter().take_while(|c| c.is_ascii_digit() || **c == '.').collect();
    num.parse().ok()
}

/// the network: node 0 is AC ground (ground and every rail), each element an admittance between two nodes
struct Net { names: Vec<String>, el: Vec<(usize, usize, String, f64, char)> }
impl Net {
    fn node(&mut self, n: &str) -> usize { if is_ground(n) || rail_from_name(n).is_some() { return 0; } if let Some(i) = self.names.iter().position(|x| x == n) { return i + 1; } self.names.push(n.to_string()); self.names.len() }
    fn y(kind: char, v: f64, w: f64) -> Cx { match kind { 'R' => Cx(1.0 / v.max(1e-6), 0.0), 'C' => Cx(0.0, w * v), 'L' => Cx(0.0, -1.0 / (w * v.max(1e-15))), _ => Cx(0.0, 0.0) } }
    /// node voltages with node `src` held at `vs`, a resistive port `port` to ground; a 1 GΩ leak keeps floating nodes defined
    fn solve(&self, w: f64, src: usize, vs: f64, port: usize, port_ohm: f64) -> Vec<Cx> {
        let n = self.names.len();
        let mut a = vec![vec![Cx(0.0, 0.0); n + 1]; n];
        for (i, row) in a.iter_mut().enumerate() { row[i] = Cx(1e-9, 0.0); }
        a[port - 1][port - 1] = a[port - 1][port - 1].add(Cx(1.0 / port_ohm, 0.0));
        for &(p, q, _, v, k) in &self.el { let y = Self::y(k, v, w); for (u, x) in [(p, q), (q, p)] { if u == 0 { continue; } a[u - 1][u - 1] = a[u - 1][u - 1].add(y); if x != 0 { a[u - 1][x - 1] = a[u - 1][x - 1].sub(y); } } }
        // the source node held: its row becomes v = vs
        for c in 0..=n { a[src - 1][c] = Cx(0.0, 0.0); } a[src - 1][src - 1] = Cx(1.0, 0.0); a[src - 1][n] = Cx(vs, 0.0);
        // Gaussian elimination with partial pivoting
        for c in 0..n {
            let p = (c..n).max_by(|x, y| a[*x][c].abs().partial_cmp(&a[*y][c].abs()).unwrap()).unwrap(); a.swap(c, p);
            let d = a[c][c]; if d.abs() < 1e-30 { continue; }
            for r in 0..n { if r != c { let f = a[r][c].div(d); if f.abs() == 0.0 { continue; } for k in c..=n { a[r][k] = a[r][k].sub(f.mul(a[c][k])); } } }
        }
        let mut v = vec![Cx(0.0, 0.0)]; for r in 0..n { v.push(a[r][n].div(a[r][r])); } v
    }
}

fn kind_of(reference: &str, val: &str) -> Option<char> {
    let p: String = reference.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
    match p.as_str() { "R" => Some('R'), "C" => Some('C'), "L" => Some('L'), "FB" => Some('B'), "D" => Some('D'), "Q" if !val.to_ascii_uppercase().contains("FET") => Some('Q'), _ => None }
}

pub fn review(b: &Board, spec: &Spec, rev: &Value, work: &std::path::Path, issues: &mut Vec<Issue>) -> Value {
    let rf = &spec.rf;
    let cbc = rf.cbcPf.unwrap_or(0.7) * 1e-12; let cbe = rf.cbePf.unwrap_or(2.0) * 1e-12; let port = rf.antennaOhm.unwrap_or(50.0);
    let limit_dbm = rf.loLeakLimitDbm.unwrap_or(-57.0);
    let mut out = json!({"assumptions": [
        format!("Transistors on a leakage path are their junction capacitances only (Cbc {:.1} pF, Cbe {:.1} pF; spec rf.cbcPf / rf.cbePf): reverse isolation of an unbiased stage, no gain", cbc * 1e12, cbe * 1e12),
        format!("The sensitive net's port is {port:.0} Ω (spec rf.antennaOhm); rails and ground are AC ground"),
        "The LO amplitude is the spec's clocks.<net>.amplitudeV, taken as peak, held at the LO node".to_string(),
        "Rail isolation: regulator 0.1 Ω + 50 nH, each capacitor with 30 mΩ ESR and 1 nH ESL (estimates)".to_string()]});
    // ---- 1. the LO level at the antenna, along each circuit path the noise review found ---------------------------------
    let pads_of = |r: &str| -> Vec<&crate::board::Pad> { b.pads.iter().filter(|p| p.reference == r).collect() };
    let mut leaks = Vec::new();
    for path in rev["noise"]["circuitPaths"].as_array().cloned().unwrap_or_default() {
        let (from, to) = (path["from"].as_str().unwrap_or("").to_string(), path["to"].as_str().unwrap_or("").to_string());
        let Some(clock) = spec.clocks.get(&from) else { continue };
        let Some(amp) = clock.amplitude() else { continue };
        let mut set: Vec<String> = vec![from.clone()];
        for h in path["hops"].as_array().cloned().unwrap_or_default() { for k in ["from", "to"] { if let Some(s) = h[k].as_str() { if !set.iter().any(|x| x == s) { set.push(s.to_string()); } } } }
        // a transistor on the path brings all its pins' nets
        let parts_on = |nets: &[String]| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| nets.iter().any(|n| *n == p.net)).map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };
        for r in parts_on(&set) { if kind_of(&r, b.values.get(&r).map(String::as_str).unwrap_or("")) == Some('Q') && path["hops"].as_array().is_some_and(|hs| hs.iter().any(|h| h["part"] == r.as_str())) { for p in pads_of(&r) { if !set.contains(&p.net) && !p.net.is_empty() { set.push(p.net.clone()); } } } }
        let mut net = Net { names: Vec::new(), el: Vec::new() };
        for s in &set { net.node(s); }
        let mut deck = format!("* adom-fields LO leakage: {from} to {to}\n");
        for r in parts_on(&set) {
            let val = b.values.get(&r).cloned().unwrap_or_default();
            let Some(k) = kind_of(&r, &val) else { continue };
            let ps = pads_of(&r);
            match k {
                'Q' => {
                    // the pins: by name (B, C, E), by the net's suffix, else SOT-23 order (1 B, 2 E, 3 C)
                    let pin = |role: char| -> Option<String> { ps.iter().find(|p| p.func.eq_ignore_ascii_case(&role.to_string())).or_else(|| ps.iter().find(|p| p.net.to_ascii_uppercase().ends_with(&format!("_{role}")))).or_else(|| ps.iter().find(|p| p.pad == match role { 'B' => "1", 'E' => "2", _ => "3" })).map(|p| p.net.clone()) };
                    if let (Some(bn), Some(cn), Some(en)) = (pin('B'), pin('C'), pin('E')) { let (nb, nc, ne) = (net.node(&bn), net.node(&cn), net.node(&en)); net.el.push((nb, nc, format!("{r}_cbc"), cbc, 'C')); net.el.push((nb, ne, format!("{r}_cbe"), cbe, 'C')); }
                }
                _ => {
                    if ps.len() != 2 { continue; }
                    let (v, kk) = match k { 'B' => (100.0, 'R'), 'D' => (1e-12, 'C'), _ => match value(&val) { Some(v) => (v, k), None => continue } };
                    let (p, q) = (net.node(&ps[0].net), net.node(&ps[1].net)); if p == q { continue; }
                    net.el.push((p, q, r.clone(), v, kk));
                }
            }
        }
        let (si, pi) = (net.node(&from), net.node(&to));
        let n_of = |i: usize| if i == 0 { "0".to_string() } else { format!("n{i}") };
        for (k, (p, q, name, v, kk)) in net.el.iter().enumerate() { deck.push_str(&format!("{}{}_{} {} {} {:e}\n", kk, k, name.replace(|c: char| !c.is_ascii_alphanumeric(), "_"), n_of(*p), n_of(*q), v)); }
        for i in 1..=net.names.len() { deck.push_str(&format!("Rleak{i} {} 0 1e9\n", n_of(i))); }
        deck.push_str(&format!("Rport {} 0 {port}\nVlo {} 0 AC {amp}\n", n_of(pi), n_of(si)));
        let freqs: Vec<f64> = if clock.swept() { vec![clock.lo_hz(), clock.mid_hz(), clock.top_hz()] } else { vec![clock.mid_hz()] };
        let mut pts = Vec::new(); let mut worst: (f64, f64) = (f64::MIN, 0.0);
        for &fq in &freqs {
            let v = net.solve(2.0 * PI * fq, si, amp, pi, port)[pi].abs();
            let dbm = 10.0 * ((v * v / 2.0 / port) / 1e-3).max(1e-30).log10();
            let spice = spice_leak(&deck, fq, pi, work);
            if dbm > worst.0 { worst = (dbm, fq); }
            pts.push(json!({"hz": fq, "portV": v, "dBm": r1(dbm), "ngspiceDbm": spice.map(|vs| r1(10.0 * ((vs * vs / 2.0 / port) / 1e-3).max(1e-30).log10()))}));
        }
        leaks.push(json!({"from": from, "to": to, "amplitudeV": amp, "nodes": net.names.len(), "elements": net.el.len(), "points": pts, "worstDbm": r1(worst.0), "worstHz": worst.1, "limitDbm": limit_dbm}));
        if worst.0 > limit_dbm - 6.0 {
            issues.push(Issue { severity: if worst.0 > limit_dbm { "medium".into() } else { "low".into() }, kind: "lo-leak".into(), what: format!("{from} reaches {to} at about {:.0} dBm ({:.1} MHz) through the circuit path, against {limit_dbm:.0} dBm (FCC 15.111: 2 nW at a receiver's antenna terminals)", worst.0, worst.1 / 1e6), at: None, layer: None, net: Some(to.clone()), reference: None, fix: format!("Raise the isolation between the LO and the front end: a smaller coupling capacitor into the mixer, a buffer stage, or an RF stage with more reverse isolation (cascode); then measure the LO at {to} with a spectrum analyser."), action: Some(json!({"type": "review", "net": to, "why": "LO leakage to the antenna", "dBm": r1(worst.0)})) });
        }
    }
    out["loLeak"] = json!(leaks);
    // ---- 2. isolation between stages through a shared rail, at the oscillator's frequency ---------------------------------
    let feeds = rev["stageFeeds"].as_array().cloned().unwrap_or_default();
    let target = rf.stageIsolationDb.unwrap_or(40.0);
    let zcap = |c: f64, w: f64| Cx(0.03, w * 1e-9 - 1.0 / (w * c));
    let feed_r = |f: &Value| f["seriesValue"].as_str().and_then(value).unwrap_or(10.0);
    let feed_z = |f: &Value, w: f64| -> Cx { let mut z = Cx(1e9, 0.0); for c in f["caps"].as_array().cloned().unwrap_or_default() { if let Some(u) = c["uF"].as_f64() { z = par(z, zcap(u * 1e-6, w)); } } z };
    let mut iso = Vec::new();
    for a in &feeds {
        let Some(stage) = a["stage"].as_str() else { continue };
        // an oscillator stage: its transistor sits on a clock net
        let Some((cnet, clock)) = pads_of(stage).iter().find_map(|p| spec.clocks.get_key_value(&p.net)) else { continue };
        let f = clock.mid_hz(); let w = 2.0 * PI * f; let rail = a["rail"].as_str().unwrap_or("");
        // the rail: its source and its own capacitors (those not inside a feed)
        let mut zr = Cx(0.1, w * 50e-9);
        for r in b.values.keys().filter(|r| r.starts_with('C')) {
            let ps = pads_of(r); if ps.len() != 2 { continue; }
            let on = ps.iter().any(|p| p.net == rail) && ps.iter().any(|p| is_ground(&p.net));
            if on { if let Some(c) = b.values.get(r).and_then(|v| value(v)) { zr = par(zr, zcap(c, w)); } }
        }
        for o in feeds.iter().filter(|o| o["rail"] == rail && o["feed"] != a["feed"]) { zr = par(zr, Cx(feed_r(o), 0.0).add(feed_z(o, w))); }
        let v_rail = zr.div(Cx(feed_r(a), 0.0).add(zr));
        for v in feeds.iter().filter(|o| o["rail"] == rail && o["feed"] != a["feed"]) {
            let zv = feed_z(v, w); let vv = v_rail.mul(zv.div(Cx(feed_r(v), 0.0).add(zv))).abs();
            let db = -20.0 * vv.max(1e-30).log10();
            iso.push(json!({"aggressor": a["feed"], "oscillator": cnet, "atHz": f, "victim": v["feed"], "victimStage": v["stage"], "rail": rail, "isolationDb": r1(db), "targetDb": target}));
            if db < target {
                issues.push(Issue { severity: if db < target - 10.0 { "medium".into() } else { "low".into() }, kind: "stage-isolation".into(), what: format!("{} ({}, {:.1} MHz) reaches {} through {} with only {:.0} dB of isolation (target {target:.0} dB)", a["feed"].as_str().unwrap_or(""), stage, f / 1e6, v["feed"].as_str().unwrap_or(""), rail, db), at: None, layer: None, net: v["feed"].as_str().map(String::from), reference: v["stage"].as_str().map(String::from), fix: format!("Raise the series element in {}'s or {}'s feed (a ferrite bead or a larger resistor), add a capacitor that is low at {:.0} MHz right at each feed, or give the oscillator its own regulator.", a["feed"].as_str().unwrap_or(""), v["feed"].as_str().unwrap_or(""), f / 1e6), action: Some(json!({"type": "add-part", "what": "ferrite bead or larger series resistor", "net": a["feed"], "why": "stage isolation through a shared rail", "isolationDb": r1(db), "targetDb": target})) });
            }
        }
    }
    iso.sort_by(|x, y| x["isolationDb"].as_f64().partial_cmp(&y["isolationDb"].as_f64()).unwrap());
    out["stageIsolation"] = json!(iso);
    // ---- 3. the LO's own radiated emission: its copper as a small loop over the plane ---------------------------------------
    let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() };
    let er = spec.epsilonR.unwrap_or(4.3); let e0 = 8.854e-15; // F/mm
    let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());
    let lim = |f: f64| crate::limits::at(&chosen, &spec.emcLimits, f).unwrap_or(54.0);
    let mut rad = Vec::new();
    for (cnet, clock) in &spec.clocks {
        let Some(amp) = clock.amplitude() else { continue };
        let f = clock.top_hz(); if f < 30e6 { continue; }
        let (mut len, mut area, mut cplate) = (0.0, 0.0, 0.0);
        for t in b.tracks.iter().filter(|t| t.net == *cnet) {
            let l = (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1);
            let h = if t.layer == "F.Cu" { gaps.first().copied().unwrap_or(0.2) } else if t.layer == "B.Cu" { gaps.last().copied().unwrap_or(0.2) } else { gaps.iter().cloned().fold(f64::MAX, f64::min) };
            len += l; area += l * h; cplate += e0 * er * l * t.w / h;
        }
        let cparts: f64 = b.values.iter().filter(|(r, _)| r.starts_with('C')).filter(|(r, _)| { let ps = pads_of(r); ps.iter().any(|p| p.net == *cnet) }).filter_map(|(_, v)| value(v)).map(|c| c.min(10e-12)).sum();
        let i = amp * 2.0 * PI * f * (cplate + cparts);
        let e = 1.316e-14 * i * area * 1e-6 * f * f / 3.0;
        let db = 20.0 * (e * 1e6).max(1e-30).log10(); let margin = lim(f) - db;
        rad.push(json!({"net": cnet, "atHz": f, "copperMm": r1(len), "loopMm2": r1(area), "currentMa": (i * 1e6).round() / 1e3, "dBuVm": r1(db), "limitDbuVm": lim(f), "marginDb": r1(margin)}));
        if margin < 6.0 {
            issues.push(Issue { severity: if margin < 0.0 { "medium".into() } else { "low".into() }, kind: "lo-emission".into(), what: format!("{cnet} at {:.1} MHz is estimated to radiate {:.1} dBµV/m at 3 m from {:.0} mm² of loop, {:.1} dB under {}", f / 1e6, db, area, margin, crate::limits::label(&chosen)), at: None, layer: None, net: Some(cnet.clone()), reference: None, fix: "Shorten the oscillator's traces, keep them on the layer next to the ground plane, and keep the tank's loop small; then measure with a near-field probe.".into(), action: Some(json!({"type": "review", "net": cnet, "why": "LO radiated emission", "marginDb": r1(margin)})) });
        }
    }
    out["loEmission"] = json!(rad);
    out
}

/// the same network in ngspice, when it is installed: |V| at the port at one frequency
fn spice_leak(deck: &str, f: f64, port: usize, work: &std::path::Path) -> Option<f64> {
    let dir = work.join("rf"); let _ = std::fs::create_dir_all(&dir);
    let cir = dir.join(format!("leak-{:.0}.cir", f)); let outp = dir.join(format!("leak-{:.0}.txt", f));
    let d = format!("{deck}.ac lin 1 {f:e} {f:e}\n.control\nset wr_singlescale\nrun\nwrdata {} vm(n{port})\n.endc\n.end\n", outp.display());
    std::fs::write(&cir, d).ok()?;
    let r = std::process::Command::new("timeout").args(["30", "ngspice", "-b"]).arg(&cir).output().ok()?;
    if !r.status.success() { return None; }
    let txt = std::fs::read_to_string(&outp).ok()?;
    txt.lines().filter_map(|l| { let v: Vec<f64> = l.split_whitespace().filter_map(|x| x.parse().ok()).collect(); if v.len() >= 2 { Some(v[1]) } else { None } }).last()
}

#[cfg(test)] mod tests {
    use super::*;
    #[test] fn values() {
        assert_eq!(value("47"), Some(47.0)); assert!((value("4k7").unwrap() - 4700.0).abs() < 1e-9); assert!((value("2.2p").unwrap() - 2.2e-12).abs() < 1e-24);
        assert!((value("120n").unwrap() - 120e-9).abs() < 1e-18); assert!((value("10u").unwrap() - 10e-6).abs() < 1e-15); assert_eq!(value("0R"), Some(0.0)); assert!((value("1M").unwrap() - 1e6).abs() < 1e-6);
    }
    #[test] fn divider_matches_hand() {
        // 1 V through a 1 pF series cap into 50 Ω at 100 MHz: |V| = 50 / |50 + 1/(jωC)|
        let mut n = Net { names: Vec::new(), el: Vec::new() }; let (s, p) = (n.node("lo"), n.node("ant")); n.el.push((s, p, "c".into(), 1e-12, 'C'));
        let w = 2.0 * PI * 100e6; let v = n.solve(w, s, 1.0, p, 50.0)[p].abs();
        let xc = 1.0 / (w * 1e-12); let exact = 50.0 / (50.0f64.hypot(xc));
        assert!((v - exact).abs() / exact < 1e-6, "{v} vs {exact}");
    }
}