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
//! Power delivery: each rail's impedance seen from its load pin, 1 kHz to 1 GHz, against a target, with
//! the capacitors that would bring it under. The rail is a set of branches in parallel at the load pin:
//!  - the source: a regulator output (flat below its loop crossover, then an inductance that resonates with
//!    the output capacitors, damped to Q about 1, a phase margin near 50°), or a cable from a connector;
//!  - every capacitor from the rail to ground: its value from the board, ESR by type, its body inductance
//!    from its pad span, and the mounting inductance of the copper between it and the load pin (a
//!    microstrip over the ground plane, so it grows with distance);
//!  - the rail's own copper over the ground plane, a parallel-plate capacitor.
//! |Z(f)| is the exact parallel sum; the target is V·tolerance / step current. A peak over the target becomes
//! an issue whose action is the capacitor (value, package, pin) that brings it down, chosen by re-solving
//! with each candidate. The same branches are written as an ngspice netlist and, when ngspice is there,
//! run as a cross-check. A converter's input rail also gets Middlebrook's check: its input network has to
//! stay well under the converter's negative input resistance, V²/P, or the loop can oscillate.
use crate::board::{Board, Pad, LAYERS};
use crate::fields::{Fields, Issue, Spec};
use crate::flow::{is_ground, rail_from_name};
use serde_json::{json, Value};
use std::f64::consts::PI;

const E0: f64 = 8.854e-15; // F/mm
fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
fn sig(v: f64) -> f64 { if v == 0.0 || !v.is_finite() { return v; } let p = 10f64.powi(3 - v.abs().log10().floor() as i32); (v * p).round() / p }

#[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) }
}

/// One branch from the rail to ground: R, then L, then (Lp ∥ Rp), then C (0 = no such element).
#[derive(Clone)]
pub struct Branch { pub name: String, pub kind: &'static str, pub r: f64, pub l: f64, pub lp: f64, pub rp: f64, pub c: f64, pub at: Option<[f64; 2]>, pub extra: Value }
impl Branch {
    fn z(&self, w: f64) -> Cx {
        let mut z = Cx(self.r, w * self.l);
        if self.lp > 0.0 { let zl = Cx(0.0, w * self.lp); z = z.add(if self.rp > 0.0 { zl.mul(Cx(self.rp, 0.0)).mul(zl.add(Cx(self.rp, 0.0)).inv()) } else { zl }); }
        if self.c > 0.0 { z = z.add(Cx(0.0, -1.0 / (w * self.c))); }
        z
    }
}
fn zrail(bs: &[Branch], f: f64) -> Cx { let w = 2.0 * PI * f; bs.iter().fold(Cx(0.0, 0.0), |y, b| y.add(b.z(w).inv())).inv() }
pub fn freqs() -> Vec<f64> { (0..=240).map(|k| 10f64.powf(3.0 + k as f64 / 40.0)).collect() }

/// A microstrip's inductance per mm (Hammerstad), w wide at h over the plane: Z0·√εeff / c.
fn l_per_mm(w: f64, h: f64) -> f64 {
    let u = w / h; let er = 4.3;
    let ee = (er + 1.0) / 2.0 + (er - 1.0) / 2.0 / (1.0 + 12.0 / u).sqrt();
    let z0 = if u <= 1.0 { 60.0 / ee.sqrt() * (8.0 / u + u / 4.0).ln() } else { 120.0 * PI / (ee.sqrt() * (u + 1.393 + 0.667 * (u + 1.444).ln())) };
    z0 * ee.sqrt() / 2.99792458e8 * 1e-3
}
fn fmt_c(c: f64) -> String { if c >= 1e-6 { format!("{}uF", sig(c * 1e6)) } else if c >= 1e-9 { format!("{}nF", sig(c * 1e9)) } else { format!("{}pF", sig(c * 1e12)) } }
fn fmt_hz(f: f64) -> String { if f >= 1e9 { format!("{:.2} GHz", f / 1e9) } else if f >= 1e6 { format!("{:.1} MHz", f / 1e6) } else { format!("{:.0} kHz", f / 1e3) } }
fn fmt_ohm(z: f64) -> String { if z >= 1.0 { format!("{:.2} Ω", z) } else { format!("{:.0} mΩ", z * 1e3) } }

#[derive(serde::Deserialize, Clone, Default)]
#[allow(non_snake_case)]
pub struct RailCfg {
    /// the target impedance (Ω); else V·tolPct/100 / stepA
    #[serde(default)] pub targetOhm: Option<f64>, #[serde(default)] pub tolPct: Option<f64>, #[serde(default)] pub stepA: Option<f64>,
    /// the band the target holds over (Hz)
    #[serde(default)] pub fminHz: Option<f64>, #[serde(default)] pub fmaxHz: Option<f64>,
    /// the source, when it is not a modelled converter: a cable's R (Ω) and L (nH); a regulator's loop crossover (Hz)
    #[serde(default)] pub sourceOhm: Option<f64>, #[serde(default)] pub sourceNh: Option<f64>, #[serde(default)] pub crossoverHz: Option<f64>,
    /// the load pin ("U3.7"), when the first chip pin on the rail is not it
    #[serde(default)] pub load: Option<String>,
}

pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec<Issue>, out_dir: Option<&std::path::Path>) -> Value {
    let rail = |n: &str| spec.voltages.get(n).copied().or_else(|| rail_from_name(n));
    let Some((gnd, _)) = crate::fields::board_ground(spec, b, &f.net_names) else { return json!({"rails": [], "note": "no ground net"}) };
    let refs: Vec<String> = { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };
    let nets_of = |r: &str| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.clone()).collect(); v.sort(); v.dedup(); v };
    let pads_of = |r: &str, n: &str| -> Vec<&Pad> { b.pads.iter().filter(|p| p.reference == r && p.net == n).collect() };
    let n = f.w * f.h;
    let real: Vec<usize> = (0..LAYERS.len()).filter(|k| f.net_ids[*k].iter().filter(|x| **x != u16::MAX).count() > n * 15 / 100).collect();
    // the top copper's gap to the next real copper layer, and that dielectric's εr
    let (h, er_top) = if real.len() >= 2 { crate::fields::gap_between(spec, b, real[0], real[1]) } else { (1.465, spec.epsilonR.unwrap_or(4.3)) };
    let fs = freqs();
    let lib = crate::caps::Library::load(spec.capCurves.as_deref());
    // PEEC (crate::peec): each capacitor's loop to its load pin solved from the copper, every copper layer at its height
    let peec_layers: Vec<(String, f64)> = { let cu: Vec<String> = if spec.real_copper.is_empty() { b.copper.clone() } else { spec.real_copper.clone() }; let gaps = spec.real_gaps.clone(); let top: f64 = gaps.iter().sum(); let mut z = top; cu.iter().enumerate().map(|(i, l)| { let zi = z; if i < gaps.len() { z -= gaps[i]; } (l.clone(), zi) }).collect() };
    let peec_t0 = std::time::Instant::now();
    let mut peec_count = (0usize, 0usize); // (solved, fell back)
    let mut rails = Vec::new();
    for (name, nid) in f.net_names.iter().enumerate().map(|(i, x)| (x.clone(), i as u16)) {
        let Some(v) = rail(&name) else { continue }; if v <= 0.0 || is_ground(&name) || spec.switching.contains_key(&name) { continue; }
        let cfg = spec.pdn.get(&name).cloned().unwrap_or_default();
        let is_out = conv["outNet"].as_str() == Some(&name); let is_in = conv["inNet"].as_str() == Some(&name);
        let srcs: Vec<String> = spec.sources.get(&name).cloned().unwrap_or_default();
        // the rail's return: the ground most of its capacitors go to, the board's on a tie or with none, so a rail decoupled to
        // PGND is judged on a board whose ground is something else (issue 25)
        let count_on = |g: &String| refs.iter().filter(|r| r.starts_with('C') && { let ns = nets_of(r); ns.contains(&name) && ns.contains(g) }).count();
        let gnd = { let mut best = (gnd.clone(), count_on(&gnd)); for g in f.net_names.iter().filter(|x| is_ground(x)) { let c = count_on(g); if c > best.1 { best = (g.clone(), c); } } best.0 };
        let gid = f.net_names.iter().position(|x| *x == gnd).map(|i| i as u16);
        let other_ground_caps: Vec<String> = refs.iter().filter(|r| r.starts_with('C') && { let ns = nets_of(r); ns.contains(&name) && !ns.contains(&gnd) && ns.iter().any(|x| *x != name && is_ground(x)) }).cloned().collect();
        let caps: Vec<String> = refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&name) && nets_of(r).contains(&gnd)).cloned().collect();
        if caps.is_empty() && !is_out { continue; }
        // what feeds the rail: the spec's sources, the modelled converter, an on-board regulator (a part named
        // like one, bridging this rail, ground and a higher rail), or the connector the solve found the
        // current entering by; nothing else is guessed: a rail with no known source gets no verdict
        let bridges = |r: &str| { let ns = nets_of(r); ns.contains(&name) && ns.contains(&gnd) && ns.iter().any(|x| *x != name && rail(x).map_or(false, |vx| vx > v)) };
        const REG: [&str; 26] = ["ldo", "reg", "tlv7", "tps7", "lp59", "lp29", "lp38", "ap21", "ap7", "mcp17", "xc62", "ams1117", "lm1117", "ld11", "ld39", "ldl", "lm317", "lm78", "78l", "ncp1", "ht7", "rt9", "sgm2", "me62", "adp1", "max88"];
        let looks_reg = |r: &str| b.values.get(r).map_or(false, |v| { let l = v.to_ascii_lowercase(); REG.iter().any(|k| l.contains(k)) });
        // a chip whose pin on this rail is named as an output (VOUT, OUT, VO) is its regulator, whatever it is called
        let out_pin = |r: &str| b.pads.iter().any(|p| p.reference == r && p.net == name && { let f = p.func.to_ascii_uppercase(); let f = f.split('_').next().unwrap_or(""); f == "VOUT" || f == "OUT" || f == "VO" || f == "OUTPUT" });
        let reg: Option<String> = if is_out { None } else { srcs.iter().find(|r| r.starts_with('U')).cloned()
            .or_else(|| refs.iter().find(|r| r.starts_with('U') && out_pin(r) && nets_of(r).contains(&gnd)).cloned())
            .or_else(|| refs.iter().find(|r| r.starts_with('U') && bridges(r) && looks_reg(r)).cloned()) };
        let entry: Vec<String> = if !srcs.is_empty() { srcs.iter().filter(|r| r.starts_with('J') || r.starts_with('P')).cloned().collect() } else { f.flow.get(&name).map(|nf| { let mut v: Vec<String> = nf.terminals.iter().filter(|t| t.side == "ext" && (t.reference.starts_with('J') || t.reference.starts_with('P'))).map(|t| t.reference.clone()).collect(); v.dedup(); v }).unwrap_or_default() };
        // a rail fed through a series part from another rail (a ferrite, a fuse, an OR-ing diode, a resistor): walk up
        // the chain to a connector or a regulator output and model the whole path as one source branch
        let chain: Option<(Vec<String>, &'static str, String)> = if !is_out && reg.is_none() && entry.is_empty() && cfg.sourceOhm.is_none() {
            fn walk(b: &Board, net: &str, gnd: &str, rail: &dyn Fn(&str) -> Option<f64>, reg_of: &dyn Fn(&str) -> Option<String>, depth: usize, seen: &mut Vec<String>) -> Option<(Vec<String>, &'static str, String)> {
                if depth > 5 { return None; }
                let mut refs: Vec<&str> = b.pads.iter().filter(|p| p.net == net).map(|p| p.reference.as_str()).collect(); refs.sort(); refs.dedup();
                for r in refs {
                    if !(r.starts_with('R') || r.starts_with('L') || r.starts_with('F') || r.starts_with('D') || r.starts_with('Q')) || seen.iter().any(|x| x == r) { continue; }
                    let mut ns: Vec<&str> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.as_str()).collect(); ns.sort(); ns.dedup();
                    // a two-terminal part, or a transistor switch whose other two pins are this rail and a higher one
                    let up = if ns.len() == 2 { if ns[0] == net { ns[1] } else { ns[0] } } else if r.starts_with('Q') && ns.len() == 3 { match ns.iter().find(|x| **x != net && rail(x).map_or(false, |vx| vx >= rail(net).unwrap_or(0.0))) { Some(x) => x, None => continue } } else { continue };
                    if up == gnd || is_ground(up) || rail(up).is_none() || seen.iter().any(|x| x == up) { continue; }
                    seen.push(r.to_string()); seen.push(up.to_string());
                    if let Some(j) = b.pads.iter().find(|p| p.net == up && (p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS")))) { return Some((vec![r.to_string()], "cable", j.reference.clone())); }
                    if let Some(u) = b.pads.iter().find(|p| p.net == up && p.reference.starts_with('U') && { let f = p.func.to_ascii_uppercase(); let f = f.split('_').next().unwrap_or(""); f == "VOUT" || f == "OUT" || f == "VO" }) { return Some((vec![r.to_string()], "regulator", u.reference.clone())); }
                    if let Some(u) = reg_of(up) { return Some((vec![r.to_string()], "regulator", u)); }
                    if let Some((mut v, k, top)) = walk(b, up, gnd, rail, reg_of, depth + 1, seen) { v.insert(0, r.to_string()); return Some((v, k, top)); }
                }
                None
            }
            // a regulator on the upstream rail recognised by its part number, as for a rail of its own
            let reg_of = |up: &str| -> Option<String> { refs.iter().find(|r| r.starts_with('U') && { let ns = nets_of(r); ns.contains(&up.to_string()) && ns.contains(&gnd) && ns.iter().any(|x| *x != up && rail(x).map_or(false, |vx| vx > rail(up).unwrap_or(0.0))) } && looks_reg(r)).cloned() };
            let mut seen = vec![name.clone()]; walk(b, &name, &gnd, &rail, &reg_of, 0, &mut seen)
        } else { None };
        if !is_out && reg.is_none() && entry.is_empty() && cfg.sourceOhm.is_none() && chain.is_none() {
            let maybe: Vec<String> = refs.iter().filter(|r| r.starts_with('U') && bridges(r) && b.pads.iter().filter(|p| p.reference == **r).count() <= 8).cloned().collect();
            rails.push(json!({"net": name, "railV": v, "skipped": format!("no known source{}: name the regulator or the connector in spec sources.{}, or give pdn.{}.sourceOhm and sourceNh", if maybe.is_empty() { String::new() } else { format!(" ({} bridges {} and a higher rail: if it is the regulator, say so)", maybe.join(", "), name) }, name, name)}));
            continue;
        }
        // the load pin: named, else the chip with the most pins on the rail, else a connector pin that is not the entry
        let skip = |r: &str| srcs.iter().any(|x| x == r) || reg.as_deref() == Some(r) || entry.iter().any(|x| x == r);
        let mut chips: Vec<(String, usize)> = refs.iter().filter(|r| r.starts_with('U') && !skip(r)).map(|r| (r.clone(), b.pads.iter().filter(|p| p.reference == *r && p.net == name).count())).filter(|x| x.1 > 0).collect();
        chips.sort_by(|a, z| z.1.cmp(&a.1));
        let conn_pins: Vec<&Pad> = b.pads.iter().filter(|p| p.net == name && (p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS")))).collect();
        let load: Option<&Pad> = cfg.load.as_ref().and_then(|l| { let (r, p) = l.split_once('.')?; b.pads.iter().find(|x| x.reference == r && x.pad == p) })
            .or_else(|| chips.first().and_then(|c| b.pads.iter().find(|p| p.reference == c.0 && p.net == name)))
            .or_else(|| conn_pins.iter().find(|p| !entry.contains(&p.reference)).copied());
        let Some(load) = load else { rails.push(json!({"net": name, "railV": v, "skipped": "no load pin: no chip or outgoing connector on the rail (pdn.<net>.load names one)"})); continue };
        let at_chip = load.reference.starts_with('U');
        // the copper's own geometry: a pour is wider than a trace
        let k0 = real.first().copied().unwrap_or(0);
        let area_top = f.net_ids[k0].iter().filter(|x| **x == nid).count() as f64 * f.d * f.d;
        let wmm = if area_top > 15.0 { 2.0 } else { 0.8 };
        let lpm = l_per_mm(wmm, h);
        let mut bs: Vec<Branch> = Vec::new();
        let mut cap_total = 0.0;
        for r in &caps {
            let Some(val) = b.values.get(r) else { continue }; let Some(c) = crate::emc::parse_value(val, 'F') else { continue };
            let cv = pads_of(r, &name); let cg = pads_of(r, &gnd); let (Some(cv), Some(cg)) = (cv.first(), cg.first()) else { continue };
            let span = (cv.x - cg.x).hypot(cv.y - cg.y);
            // the part as it really sits on this rail: its technology, ESR, body inductance, and C at the rail's bias
            let mpn = b.mpns.get(r).map(String::as_str).unwrap_or(""); let fpn = b.footprints.get(r).map(String::as_str).unwrap_or("");
            let m = crate::caps::model(&lib, val, mpn, fpn, c, v, span);
            let (esr, esl, c) = (m.esr, m.esl, m.c_eff);
            let dist = (cv.x - load.x).hypot(cv.y - load.y);
            let estimate = (lpm * dist + 0.2e-9).max(0.2e-9);
            // the solved loop: the cap's pads to the load pin and the load part's nearest ground pin (the loop closes
            // inside the part), in a window around them; the estimate stays when the window is too big or the loop open
            let lg = b.pads.iter().filter(|p| p.reference == load.reference && p.net == gnd).min_by(|a, z| (a.x - load.x).hypot(a.y - load.y).partial_cmp(&(z.x - load.x).hypot(z.y - load.y)).unwrap());
            let solved: Result<(f64, usize, f64, String), String> = match lg { None => Err(format!("{} has no ground pin to close the loop", load.reference)), Some(lg) => {
                if peec_t0.elapsed().as_secs_f64() > 40.0 || peec_layers.len() < 2 { Err("the 40 s PEEC budget is spent".into()) } else {
                let xs = [cv.x, cg.x, load.x, lg.x]; let ys = [cv.y, cg.y, load.y, lg.y];
                // the return current spreads under the loop over a few dielectric heights: the window keeps three
                let mg = (3.0 * h).clamp(2.0, 5.0);
                let win = [xs.iter().cloned().fold(f64::MAX, f64::min) - mg, ys.iter().cloned().fold(f64::MAX, f64::min) - mg, xs.iter().cloned().fold(f64::MIN, f64::max) + mg, ys.iter().cloned().fold(f64::MIN, f64::max) + mg];
                let area = (win[2] - win[0]) * (win[3] - win[1]);
                let pitch = (area * peec_layers.len() as f64 / 6000.0).sqrt().clamp(0.25, 0.6);
                crate::peec::loop_nh(b, &peec_layers, spec.copperUm * 1e-3, [name.as_str(), gnd.as_str()], [(cv.x, cv.y), (cg.x, cg.y)], [(load.x, load.y), (lg.x, lg.y)], win, pitch, 1500, h).map(|(l, nb, pitch)| (l, nb, pitch, format!("{}.{} and {}.{}", load.reference, load.pad, lg.reference, lg.pad)))
            } } };
            let mount = match &solved { Ok((l, ..)) => { peec_count.0 += 1; l * 1e-9 } Err(_) => { peec_count.1 += 1; estimate } };
            cap_total += c;
            let mut extra = json!({"esrMohm": sig(esr * 1e3), "eslNh": sig(esl * 1e9), "mountNh": sig(mount * 1e9), "distMm": r2(dist), "srfHz": sig(1.0 / (2.0 * PI * (c * (esl + mount)).sqrt())),
                "mountModel": match &solved { Ok((_, nb, pitch, pins)) => format!("solved (PEEC): the loop from the capacitor's pads to {pins} over the copper ({nb} branches, {pitch:.2} mm cells)"), Err(why) => format!("estimate: a microstrip over the plane from the capacitor to the load pin; not solved because {why}") },
                "mountNhEstimate": sig(estimate * 1e9)});
            for (k, x) in crate::caps::info(&m, val, mpn) { extra[k] = x; }
            bs.push(Branch { name: r.clone(), kind: "capacitor", r: esr, l: esl + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(cv.x), r2(cv.y)]), extra });
        }
        // the plane: this rail's copper facing ground on the next real layer
        let mut over = 0usize;
        if let Some(g) = gid { for w in real.windows(2) { for i in 0..n { let (a, z) = (f.net_ids[w[0]][i], f.net_ids[w[1]][i]); if (a == nid && z == g) || (a == g && z == nid) { over += 1; } } } }
        let gap = h;
        let cplane = E0 * er_top * over as f64 * f.d * f.d / gap;
        if cplane > 0.0 { bs.push(Branch { name: "plane".into(), kind: "plane", r: 0.002, l: 0.0, lp: 0.0, rp: 0.0, c: cplane, at: Some([r2(load.x), r2(load.y)]), extra: json!({"pF": sig(cplane * 1e12), "mm2": r2(over as f64 * f.d * f.d), "gapMm": gap}) }); }
        // the source
        // a regulator loop: flat below its crossover, then L_eq resonating with the rail's capacitors there,
        // damped to Q 1; well above the crossover the loop gives up (a converter's output is its power
        // inductor), so a series L a decade above keeps the damping resistor from looking like a source
        let loop_branch = |nm: String, fc: f64, at: Option<[f64; 2]>, path: f64, what: &str| -> Branch {
            let ctot = (cap_total + cplane).max(1e-9); let wc = 2.0 * PI * fc;
            let leq = 1.0 / (wc * wc * ctot); let rd = (leq / ctot).sqrt(); let lhf = rd / (2.0 * PI * 10.0 * fc);
            Branch { name: nm, kind: "regulator", r: 0.005, l: path + lhf, lp: leq, rp: rd, c: 0.0, at, extra: json!({"crossoverHz": sig(fc), "lEqNh": sig(leq * 1e9), "dampOhm": sig(rd), "lHfNh": sig(lhf * 1e9), "rFlatMohm": 5.0, "model": format!("{what}: flat 5 mΩ, then L_eq resonating with the rail's capacitors at the loop crossover, damped to Q 1, fading out a decade above it (pdn.<net>.crossoverHz)")}) }
        };
        let path_from = |p: Option<&Pad>| p.map(|p| lpm * (p.x - load.x).hypot(p.y - load.y)).unwrap_or(0.0);
        let src: Option<Branch> = if let Some(r) = cfg.sourceOhm {
            let l = cfg.sourceNh.unwrap_or(700.0) * 1e-9; let feed = entry.first().and_then(|e| b.pads.iter().find(|p| p.reference == *e && p.net == name));
            Some(Branch { name: entry.first().map(|e| format!("{e} cable")).unwrap_or("source".into()), kind: "cable", r, l: l + path_from(feed), lp: 0.0, rp: 0.0, c: 0.0, at: feed.map(|p| [r2(p.x), r2(p.y)]), extra: json!({"rOhm": r, "nH": sig(l * 1e9), "model": "the spec's source: pdn.<net>.sourceOhm/sourceNh"}) })
        } else if is_out {
            let fsw = conv["fswHz"].as_f64().unwrap_or(500e3);
            let lp = pads_of(conv["inductor"].as_str().unwrap_or("L1"), &name).first().copied();
            Some(loop_branch(format!("{} loop", conv["switch"].as_str().unwrap_or("regulator")), cfg.crossoverHz.unwrap_or(fsw / 10.0), lp.map(|p| [r2(p.x), r2(p.y)]), path_from(lp), "the converter's output"))
        } else if let Some(u) = &reg {
            let up = pads_of(u, &name).first().copied();
            Some(loop_branch(format!("{u} loop"), cfg.crossoverHz.unwrap_or(100e3), up.map(|p| [r2(p.x), r2(p.y)]), path_from(up), "a linear regulator, crossover about 100 kHz assumed"))
        } else if let Some((parts, top_kind, top)) = &chain {
            // each series part as R + L (a ferrite as its DC resistance plus a lossy inductance, Z at its rated frequency)
            let (mut r, mut l, mut lp, mut rp) = (0.0, 0.0, 0.0, 0.0);
            let mut desc = Vec::new();
            for p in parts {
                let val = b.values.get(p).cloned().unwrap_or_default(); let u = val.to_ascii_uppercase();
                if p.starts_with("FB") || u.contains("@") {
                    let z: f64 = u.split(|c: char| c == 'R' || c == 'Ω' || c == '@').next().and_then(|x| x.trim().parse().ok()).unwrap_or(600.0);
                    let fz: f64 = u.split('@').nth(1).and_then(|x| x.trim_end_matches("HZ").trim_end_matches('M').trim().parse::<f64>().ok()).map(|m| m * 1e6).unwrap_or(100e6);
                    r += 0.15; lp += z / (2.0 * PI * fz); rp += z; desc.push(format!("{p} ferrite {}", val));
                } else if p.starts_with('F') { r += 0.25; l += 5e-9; desc.push(format!("{p} fuse")); }
                else if p.starts_with('D') { r += 0.15; l += 3e-9; desc.push(format!("{p} diode")); }
                else if p.starts_with('Q') { r += 0.5; l += 3e-9; desc.push(format!("{p} switch {}", val)); }
                else if p.starts_with('L') { r += 0.1; l += crate::emc::parse_value(&val, 'H').unwrap_or(1e-6); desc.push(format!("{p} {}", val)); }
                else { let ohm = { let t = u.replace("OHM", "").replace('Ω', ""); let t = t.trim(); let (num, mul) = if let Some(i) = t.find(|c: char| c == 'K' || c == 'M' || c == 'R') { let m = match &t[i..i + 1] { "K" => 1e3, "M" => 1e6, _ => 1.0 }; (format!("{}.{}", &t[..i], &t[i + 1..]).trim_end_matches('.').to_string(), m) } else { (t.to_string(), 1.0) }; num.parse::<f64>().unwrap_or(0.0) * mul }; r += ohm; l += 2e-9; desc.push(format!("{p} {}", val)); }
            }
            let (r0, l0, what) = if *top_kind == "cable" { (0.03, cfg.sourceNh.unwrap_or(700.0) * 1e-9, format!("about a metre of supply leads into {top}")) } else { (0.005, 20e-9, format!("the {top} regulator output, taken as flat")) };
            let first = b.pads.iter().find(|p| p.reference == parts[0] && p.net == name);
            Some(Branch { name: format!("{top} via {}", parts.join(", ")), kind: if *top_kind == "cable" { "cable" } else { "filter" }, r: r + r0, l: l + l0 + path_from(first), lp, rp, c: 0.0, at: first.map(|p| [r2(p.x), r2(p.y)]), extra: json!({"rOhm": sig(r + r0), "nH": sig((l + l0) * 1e9), "chain": desc, "model": format!("{what}, then {}; the upstream rails' own capacitors are left out (conservative at high frequency)", parts.join(", "))}) })
        } else {
            // a connector feeding the rail: about a metre of supply leads
            let feed = entry.iter().find_map(|e| b.pads.iter().find(|p| p.reference == *e && p.net == name));
            let (r, l) = (0.03, cfg.sourceNh.unwrap_or(700.0) * 1e-9);
            Some(Branch { name: format!("{} cable", entry[0]), kind: "cable", r, l: l + path_from(feed), lp: 0.0, rp: 0.0, c: 0.0, at: feed.map(|p| [r2(p.x), r2(p.y)]), extra: json!({"rOhm": r, "nH": sig(l * 1e9), "model": "about a metre of supply leads: 30 mΩ, 0.7 µH (spec pdn.<net>.sourceOhm/sourceNh)"}) })
        };
        if let Some(s) = src { bs.insert(0, s); }
        if bs.is_empty() { continue; }
        // the target and its band
        let step = cfg.stepA.unwrap_or_else(|| if is_in { conv["iout"].as_f64().unwrap_or(1.0) } else { spec.loads.get(&name).map(|l| l.amps * 0.5).unwrap_or(0.1) });
        let tol = cfg.tolPct.unwrap_or(5.0);
        let zt = cfg.targetOhm.unwrap_or(v * tol / 100.0 / step.max(1e-6));
        let flo = cfg.fminHz.unwrap_or(1e3);
        let fhi = cfg.fmaxHz.unwrap_or_else(|| if is_in { 0.35 / (conv["riseNs"].as_f64().unwrap_or(8.0) * 1e-9) } else if at_chip { 100e6 } else { 1e6 });
        let band = |x: f64| x >= flo * 0.999 && x <= fhi * 1.001;
        let curve = |bs: &[Branch]| -> Vec<f64> { fs.iter().map(|x| zrail(bs, *x).abs()).collect() };
        let worst = |z: &[f64]| -> (usize, f64) { let mut wi = 0; let mut wr = 0.0; for (i, x) in fs.iter().enumerate() { if band(*x) && z[i] / zt > wr { wr = z[i] / zt; wi = i; } } (wi, wr) };
        let z0 = curve(&bs);
        let (wi, wr) = worst(&z0);
        // which branches make the peak: the inductive one and the capacitive one with the largest admittance
        let blame = |bs: &[Branch], fq: f64| -> (Option<String>, Option<String>) { let w = 2.0 * PI * fq; let (mut li, mut ci) = ((None, 0.0), (None, 0.0)); for x in bs { let z = x.z(w); let y = 1.0 / z.abs(); if z.1 > 0.0 { if y > li.1 { li = (Some(x.name.clone()), y); } } else if y > ci.1 { ci = (Some(x.name.clone()), y); } } (li.0, ci.0) };
        let (bl, bc) = blame(&bs, fs[wi]);
        let load_name = format!("{}.{}", load.reference, load.pad);
        // the fix: add the capacitor that lowers the worst peak most, re-solve, up to three
        let mut fixes: Vec<Value> = Vec::new(); let mut cur = bs.clone(); let mut cur_r = wr; let mut zf = z0.clone();
        if wr > 1.0 {
            let mlcc = [1e-9, 2.2e-9, 4.7e-9, 10e-9, 22e-9, 47e-9, 100e-9, 220e-9, 470e-9, 1e-6, 2.2e-6, 4.7e-6, 10e-6, 22e-6];
            let mount = lpm * 1.0 + 0.2e-9;
            for _ in 0..3 {
                let mut best: Option<(f64, Branch)> = None;
                // new ceramics are X5R/X7R, so they too lose capacitance at the rail's bias (the same estimate)
                let mut cands: Vec<Branch> = mlcc.iter().map(|&c| { let (pkg, span) = if c <= 1e-6 { ("0402", 1.0) } else if c <= 4.7e-6 { ("0603", 1.6) } else { ("0805", 2.0) }; let m = crate::caps::model(&lib, "", "", &format!("C_{pkg}"), c, v, span); Branch { name: format!("new {}", fmt_c(c)), kind: "capacitor", r: m.esr, l: m.esl + mount, lp: 0.0, rp: 0.0, c: m.c_eff, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": pkg, "type": "ceramic X5R/X7R", "uFAtBias": sig(m.c_eff * 1e6), "new": true}) } }).collect();
                // a polymer bulk part: low ESR, no bias loss
                for c in [47e-6, 100e-6] { cands.push(Branch { name: format!("new {} polymer", fmt_c(c)), kind: "capacitor", r: 0.03, l: 1.5e-9 + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": "polymer chip", "type": "polymer", "esrOhm": 0.03, "new": true}) }); }
                // a damping bulk capacitor for a low resonance: its ESR soaks up the LC ring
                for c in [47e-6, 100e-6, 220e-6] { cands.push(Branch { name: format!("new {}", fmt_c(c)), kind: "capacitor", r: 0.15, l: 3e-9 + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": "SMD can", "type": "electrolytic", "esrOhm": 0.15, "new": true}) }); }
                for cand in cands { let mut t = cur.clone(); t.push(cand.clone()); let z = curve(&t); let (_, r) = worst(&z); if best.as_ref().map_or(true, |b| r < b.0 * 0.98) { best = Some((r, cand)); } }
                let Some((r, cand)) = best else { break };
                if r > cur_r * 0.97 { break; }
                let before = cur_r; cur.push(cand.clone()); zf = curve(&cur); let (wi2, _) = worst(&zf); cur_r = r;
                fixes.push(json!({"value": cand.extra["value"], "package": cand.extra["package"], "type": cand.extra["type"], "nearPin": load_name, "ratioBefore": sig(before), "ratioAfter": sig(r), "worstAfterHz": sig(fs[wi2]), "worstAfterOhm": sig(zf[wi2])}));
                if r <= 1.0 { break; }
            }
            let peak = format!("{}: |Z| {} at {} seen from {}, {:.1}× its {} target ({} V, ±{} % for a {} A step){}", name, fmt_ohm(z0[wi]), fmt_hz(fs[wi]), load_name, wr, fmt_ohm(zt), v, tol, sig(step), match (&bl, &bc) { (Some(l), Some(c)) if wr > 1.0 => format!(": {} going inductive against {}", l, c), _ => String::new() });
            let sev = if wr > 2.0 { "medium" } else { "low" };
            if fixes.is_empty() {
                issues.push(Issue { severity: sev.into(), kind: "pdn-impedance".into(), what: peak.clone(), at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: Some(load.reference.clone()), fix: format!("No single capacitor brings it under: widen {} and its ground into planes, and move the existing capacitors next to {}.", name, load_name), action: Some(json!({"type": "review", "net": name, "why": "PDN impedance over target"})) });
            }
            for (k, fx) in fixes.iter().enumerate() {
                let what = if k == 0 { peak.clone() } else { format!("{}: still {:.1}× the target after the capacitor(s) above", name, fixes[k - 1]["ratioAfter"].as_f64().unwrap_or(0.0)) };
                let ok = fx["ratioAfter"].as_f64().unwrap_or(9.0) <= 1.0;
                issues.push(Issue { severity: sev.into(), kind: "pdn-impedance".into(), what, at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: Some(load.reference.clone()),
                    fix: format!("Add a {} {} {} from {} to {} within 1 mm of {}: the worst peak drops to {:.2}× the target{}.", fx["value"].as_str().unwrap_or(""), fx["package"].as_str().unwrap_or(""), fx["type"].as_str().unwrap_or(""), name, gnd, load_name, fx["ratioAfter"].as_f64().unwrap_or(0.0), if ok { ", under it" } else { "" }),
                    action: Some(json!({"type": "add-part", "what": "capacitor", "value": fx["value"], "package": fx["package"], "dielectric": fx["type"], "between": [name, gnd], "nearPin": load_name, "maxDistMm": 1.0, "why": "PDN impedance", "ratioAfter": fx["ratioAfter"]})) });
            }
            if !fixes.is_empty() && cur_r > 1.0 {
                issues.push(Issue { severity: sev.into(), kind: "pdn-impedance".into(), what: format!("{}: still {:.1}× the target after {} added capacitor(s): the copper's own inductance sets the floor", name, cur_r, fixes.len()), at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: Some(load.reference.clone()),
                    fix: format!("Pour {} as a plane right over {} on the next layer (a thinner dielectric lowers the inductance in proportion), or relax the target for {} (spec pdn.{}).", name, gnd, name, name),
                    action: Some(json!({"type": "pour", "net": name, "over": gnd, "why": "PDN impedance floor: mounting inductance", "ratioAfterParts": sig(cur_r)})) });
            }
        }
        // Middlebrook: a converter's input network against its negative input resistance
        let mut mb = Value::Null;
        if is_in {
            let (vin, vout, io) = (conv["vin"].as_f64().unwrap_or(v), conv["vout"].as_f64().unwrap_or(0.0), conv["iout"].as_f64().unwrap_or(0.0));
            let p = vout * io / 0.9;
            if p > 0.0 {
                let rneg = vin * vin / p; let fsw = conv["fswHz"].as_f64().unwrap_or(500e3);
                // the negative resistance holds only inside the converter's loop bandwidth: up to fsw at most
                let (mi, mz) = z0.iter().enumerate().filter(|(i, _)| fs[*i] <= fsw).fold((0, 0.0), |a, (i, z)| if *z > a.1 { (i, *z) } else { a });
                let margin = 20.0 * (rneg / mz).log10();
                mb = json!({"negInputOhm": sig(rneg), "peakOhm": sig(mz), "atHz": sig(fs[mi]), "marginDb": r2(margin), "ok": margin >= 10.0});
                if margin < 10.0 {
                    let cin: f64 = bs.iter().filter(|x| x.kind == "capacitor").map(|x| x.c).sum(); let lsrc = bs.first().map(|x| x.l).unwrap_or(0.7e-6);
                    let cd = (4.0 * cin).max(10e-6); let rd = (lsrc / cin.max(1e-9)).sqrt();
                    issues.push(Issue { severity: if margin < 0.0 { "high".into() } else { "medium".into() }, kind: "input-filter-stability".into(), what: format!("{}: the input network peaks at {} near {}, only {:.0} dB under the converter's negative input resistance ({}): the input can ring with the regulator's loop", name, fmt_ohm(mz), fmt_hz(fs[mi]), margin, fmt_ohm(rneg)), at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: Some(load.reference.clone()),
                        fix: format!("Add a damping bulk capacitor on {}: at least {} with about {} of ESR (an electrolytic or a ceramic plus a series resistor), near {}.", name, fmt_c(cd), fmt_ohm(rd), load_name),
                        action: Some(json!({"type": "add-part", "what": "capacitor", "value": fmt_c(cd), "dielectric": "electrolytic", "esrOhm": sig(rd), "between": [name, gnd], "nearPin": load_name, "maxDistMm": 10.0, "why": "input filter damping (Middlebrook)"})) });
                }
            }
        }
        // hot-plug: the supply leads (R, L) switched onto the rail's capacitors at once. A low-ESR bulk part with no
        // damping rings the rail up toward twice its voltage; solved in time (backward Euler), ESL ignored
        let mut hot = Value::Null;
        if bs.first().map_or(false, |x| x.kind == "cable") {
            let src = &bs[0]; let br: Vec<&Branch> = bs.iter().filter(|x| x.c > 0.0).collect();
            let ctot: f64 = br.iter().map(|x| x.c).sum();
            if ctot > 0.0 && src.l > 0.0 {
                let t_lc = 2.0 * PI * (src.l * ctot).sqrt(); let dt = t_lc / 400.0; let steps = 1600;
                let g: Vec<f64> = br.iter().map(|x| 1.0 / (x.r.max(1e-5) + dt / x.c)).collect(); let gsum: f64 = g.iter().sum();
                let mut vc = vec![0.0; br.len()]; let (mut il, mut peak, mut tpk) = (0.0f64, 0.0f64, 0.0f64);
                let a_l = src.l / dt + src.r;
                for k in 0..steps {
                    let ssum: f64 = vc.iter().zip(&g).map(|(v, gi)| v * gi).sum();
                    let vn = (ssum + (v + src.l * il / dt) / a_l) / (gsum + 1.0 / a_l);
                    il = (v - vn + src.l * il / dt) / a_l;
                    for (i, x) in br.iter().enumerate() { let ib = (vn - vc[i]) * g[i]; vc[i] += dt * ib / x.c; }
                    if vn > peak { peak = vn; tpk = (k + 1) as f64 * dt; }
                }
                // the lowest voltage rating on the rail: "35V" in a Value, or Murata's code in the MPN (ECASD41E, GRM31CR71E: 1E = 25 V)
                let eia = |c: &str| match c { "0G" => Some(4.0), "0J" => Some(6.3), "1A" => Some(10.0), "1C" => Some(16.0), "1E" => Some(25.0), "YA" => Some(35.0), "1V" => Some(35.0), "1H" => Some(50.0), "2A" => Some(100.0), _ => None };
                let rating = |r: &str| -> Option<f64> {
                    let m = b.mpns.get(r).map(|x| x.to_ascii_uppercase()).unwrap_or_default();
                    let by_mpn = if m.starts_with("ECAS") && m.len() > 8 { eia(&m[6..8]) } else if m.starts_with("GRM") && m.len() > 10 { eia(&m[8..10]) } else { None };
                    by_mpn.or_else(|| b.values.get(r).and_then(|val| val.split_whitespace().find_map(|t| t.strip_suffix('V').or_else(|| t.strip_suffix('v')).and_then(|n| n.parse::<f64>().ok()))))
                };
                let rated: Vec<(String, f64)> = caps.iter().filter_map(|r| rating(r).map(|x| (r.clone(), x))).collect();
                let low = rated.iter().cloned().min_by(|a, z| a.1.partial_cmp(&z.1).unwrap());
                let over = (peak / v - 1.0) * 100.0;
                // the same step in ngspice, when it is there
                let mut spice_pk = Value::Null;
                if let Some(dir) = out_dir {
                    let mut d = format!("* adom-fields hot-plug: {} through the supply leads\nV1 vin 0 PWL(0 0 10n {})\nRs vin s1 {:e}\nLs s1 rail {:e}\n", name, v, src.r.max(1e-6), src.l);
                    for (k, x) in br.iter().enumerate() { d.push_str(&format!("R{k} rail c{k} {:e}\nC{k} c{k} 0 {:e}\n", x.r.max(1e-6), x.c)); }
                    let out = dir.join(format!("hotplug-{}.txt", name.replace(|c: char| !c.is_ascii_alphanumeric(), "_")));
                    d.push_str(&format!(".tran {:e} {:e}\n.control\nset wr_singlescale\nrun\nwrdata {} v(rail)\n.endc\n.end\n", dt, dt * steps as f64, out.display()));
                    let deck = dir.join(format!("hotplug-{}.cir", name.replace(|c: char| !c.is_ascii_alphanumeric(), "_")));
                    if std::fs::write(&deck, &d).is_ok() {
                        if let Ok(r) = std::process::Command::new("timeout").args(["30", "ngspice", "-b"]).arg(&deck).output() { if r.status.success() {
                            let pk = std::fs::read_to_string(&out).unwrap_or_default().lines().filter_map(|l| l.split_whitespace().nth(1).and_then(|x| x.parse::<f64>().ok())).fold(0.0f64, f64::max);
                            let _ = std::fs::remove_file(&out); if pk > 0.0 { spice_pk = json!(r2(pk)); }
                        } }
                    }
                }
                hot = json!({"spicePeakV": spice_pk, "sourceOhm": src.r, "sourceNh": sig(src.l * 1e9), "peakV": r2(peak), "overshootPct": r2(over), "atUs": r2(tpk * 1e6), "lowestRating": low.as_ref().map(|x| json!({"ref": x.0, "volts": x.1})), "model": "supply leads (R, L) switched onto every capacitor branch at their rail bias, ESL ignored; backward-Euler transient"});
                let breach = low.as_ref().map_or(false, |x| peak > x.1);
                if breach || over > 50.0 {
                    let cer: f64 = br.iter().filter(|x| x.r < 0.05).map(|x| x.c).sum(); let lsrc = src.l;
                    let cd = { let c = (4.0 * cer.max(1e-6)).max(10e-6); let dec = 10f64.powf(c.log10().floor()); let m = c / dec; dec * [1.0, 1.5, 2.2, 3.3, 4.7, 6.8, 10.0].iter().copied().find(|e| *e >= m - 1e-9).unwrap_or(10.0) }; let rd = (lsrc / cer.max(1e-9)).sqrt();
                    let sev = if breach { "high" } else { "medium" };
                    issues.push(Issue { severity: sev.into(), kind: "hot-plug-overshoot".into(), what: format!("{}: plugging in the supply rings the rail to {:.1} V ({:.0} % over {} V){}", name, peak, over, v, match &low { Some(x) if breach => format!(", over {}'s {} V rating", x.0, x.1), Some(x) => format!(" (lowest rating on the rail: {} at {} V)", x.0, x.1), None => String::new() }), at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: low.as_ref().map(|x| x.0.clone()),
                        fix: format!("Damp the input: add at least {} of bulk capacitance with about {} of ESR (an electrolytic, or a polymer part plus a series resistor) near the input connector, or a TVS rated just above {} V.", fmt_c(cd), fmt_ohm(rd), v),
                        action: Some(json!({"type": "add-part", "what": "capacitor", "value": fmt_c(cd), "dielectric": "electrolytic", "esrOhm": sig(rd), "between": [name, gnd], "nearPin": bs[0].name.replace(" cable", ""), "maxDistMm": 10.0, "why": "hot-plug overshoot damping", "peakV": r2(peak)})) });
                }
            }
        }
        // ---- the decoupling optimiser: the cheapest set of up to six new capacitors that meets the target (beam search over
        // values and kinds, by estimated part cost then board area), the lowest-|Z| set when none can, and the existing
        // capacitors the rail can do without. Typical catalogue prices and footprints, for ranking, not a quote.
        let mount = lpm * 1.0 + 0.2e-9;
        let cand_list: Vec<(Branch, f64, f64)> = {
            let rail_v = v; let mut cl: Vec<(Branch, f64, f64)> = Vec::new();
            for &c in &[1e-9, 2.2e-9, 4.7e-9, 10e-9, 22e-9, 47e-9, 100e-9, 220e-9, 470e-9, 1e-6, 2.2e-6, 4.7e-6, 10e-6, 22e-6] {
                let (pkg, span, cost, area) = if c <= 1e-6 { ("0402", 1.0, 0.004, 0.5) } else if c <= 4.7e-6 { ("0603", 1.6, 0.01, 1.3) } else { ("0805", 2.0, 0.025, 2.5) };
                let m = crate::caps::model(&lib, "", "", &format!("C_{pkg}"), c, rail_v, span);
                cl.push((Branch { name: format!("{} {}", fmt_c(c), pkg), kind: "capacitor", r: m.esr, l: m.esl + mount, lp: 0.0, rp: 0.0, c: m.c_eff, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": pkg, "type": "ceramic", "new": true}) }, cost, area));
            }
            for (c, cost) in [(47e-6, 0.25), (100e-6, 0.35)] { cl.push((Branch { name: format!("{} polymer", fmt_c(c)), kind: "capacitor", r: 0.03, l: 1.5e-9 + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": "polymer chip", "type": "polymer", "new": true}) }, cost, 12.0)); }
            cl
        };
        let mut plan = json!({});
        {
            // beam search: states are multisets of candidate indices
            let score = |set: &[usize]| -> (f64, f64, f64) { let mut t = bs.clone(); for &i in set { t.push(cand_list[i].0.clone()); } let (_, r) = worst(&curve(&t)); let cost: f64 = set.iter().map(|&i| cand_list[i].1).sum(); let area: f64 = set.iter().map(|&i| cand_list[i].2).sum(); (r, cost, area) };
            let mut beam: Vec<Vec<usize>> = vec![Vec::new()]; let mut meeting: Vec<(Vec<usize>, f64, f64, f64)> = Vec::new(); let mut lowest: (Vec<usize>, f64) = (Vec::new(), wr);
            if wr > 1.0 { for _depth in 0..6 {
                let mut next: Vec<(Vec<usize>, f64, f64, f64)> = Vec::new();
                for st in &beam { let from = st.last().copied().unwrap_or(0); for i in from..cand_list.len() { let mut ns = st.clone(); ns.push(i); let (r, c, a) = score(&ns); if r < lowest.1 { lowest = (ns.clone(), r); } if r <= 1.0 { meeting.push((ns, r, c, a)); } else { next.push((ns, r, c, a)); } } }
                if !meeting.is_empty() { break; }
                next.sort_by(|x, y| x.1.partial_cmp(&y.1).unwrap()); next.truncate(8); beam = next.into_iter().map(|x| x.0).collect();
                if beam.is_empty() { break; }
            } }
            meeting.sort_by(|x, y| x.2.partial_cmp(&y.2).unwrap().then(x.3.partial_cmp(&y.3).unwrap()));
            let desc = |set: &[usize]| -> Vec<Value> { set.iter().map(|&i| json!({"value": cand_list[i].0.extra["value"], "package": cand_list[i].0.extra["package"], "type": cand_list[i].0.extra["type"]})).collect() };
            if let Some((set, r, c, a)) = meeting.first() { plan["cheapest"] = json!({"add": desc(set), "ratioAfter": sig(*r), "costUsd": (c * 1000.0).round() / 1000.0, "areaMm2": r2(*a), "nearPin": load_name}); }
            plan["optionsMeetingTarget"] = json!(meeting.iter().take(5).map(|(set, r, c, a)| json!({"add": desc(set), "ratioAfter": sig(*r), "costUsd": (c * 1000.0).round() / 1000.0, "areaMm2": r2(*a)})).collect::<Vec<_>>());
            if meeting.is_empty() && wr > 1.0 { plan["bestPossible"] = json!({"add": desc(&lowest.0), "ratioAfter": sig(lowest.1), "note": "no set of up to six capacitors at this pin meets the target: the copper's inductance sets the floor"}); }
            // the existing capacitors this rail can lose and still meet its target (greedy, largest saving first)
            if wr <= 1.0 {
                let mut keep = bs.clone(); let mut removable = Vec::new();
                // never the converter's own input or output capacitors: ripple, the hot loop and the loop's stability need them
                let conv_caps: Vec<String> = ["inCaps", "outCaps"].iter().flat_map(|k| conv[*k].as_array().cloned().unwrap_or_default()).filter_map(|c| c["ref"].as_str().map(String::from)).collect();
                loop { let mut best: Option<usize> = None; for (j, x) in keep.iter().enumerate() { if x.kind != "capacitor" || conv_caps.iter().any(|c| x.name == *c || x.name.starts_with(&format!("{c} "))) { continue; } let mut t = keep.clone(); t.remove(j); let (_, r) = worst(&curve(&t)); if r <= 0.9 { best = Some(j); break; } } match best { Some(j) => { removable.push(keep[j].name.clone()); keep.remove(j); } None => break } }
                plan["removable"] = json!(removable);
                plan["removableNote"] = json!("judged on this rail's impedance target alone (the converter's own input and output capacitors are never offered); check the regulator's datasheet minimum and any filtering role before removing");
            }
            plan["costModel"] = json!("typical catalogue prices (0402 $0.004, 0603 $0.01, 0805 $0.025, polymer $0.25-0.35) and footprints, for ranking only");
        }
        // the ngspice cross-check of the same branches
        let mut spice = Value::Null;
        if let Some(dir) = out_dir {
            let net_txt = netlist(&name, &load_name, &bs);
            let file = dir.join(format!("pdn-{}.cir", name.replace(|c: char| !c.is_ascii_alphanumeric(), "_")));
            if std::fs::write(&file, &net_txt).is_ok() { spice = run_spice(&file, &fs, &z0); spice["netlist"] = json!(file.file_name().map(|x| x.to_string_lossy().to_string())); }
        }
        let br: Vec<Value> = bs.iter().map(|x| json!({"name": x.name, "kind": x.kind, "at": x.at, "r": x.r, "l": x.l, "lp": x.lp, "rp": x.rp, "c": x.c, "info": x.extra})).collect();
        let added: Vec<Value> = cur.iter().skip(bs.len()).map(|x| json!({"name": x.name, "kind": x.kind, "at": x.at, "r": x.r, "l": x.l, "lp": 0.0, "rp": 0.0, "c": x.c, "info": x.extra})).collect();
        rails.push(json!({"net": name, "railV": v, "return": gnd, "capsToOtherGrounds": other_ground_caps, "load": load_name, "loadAt": [r2(load.x), r2(load.y)], "targetOhm": sig(zt), "stepA": sig(step), "tolPct": tol, "bandHz": [flo, sig(fhi)],
            "worst": {"hz": sig(fs[wi]), "ohm": sig(z0[wi]), "ratio": sig(wr), "inductive": bl, "capacitive": bc}, "traceWidthMm": wmm, "nHPerMm": sig(lpm * 1e9),
            "branches": br, "added": added, "fixes": fixes, "decapPlan": plan, "zOhm": z0.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "zAfterOhm": if fixes.is_empty() { Value::Null } else { json!(zf.iter().map(|x| sig(*x)).collect::<Vec<_>>()) }, "middlebrook": mb, "hotPlug": hot, "spice": spice}));
    }
    json!({"fHz": fs.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "rails": rails, "model": "each branch R + L + (Lp ∥ Rp) + C in parallel at the load pin; each capacitor at the rail's DC bias (a measured curve for its MPN when one is loaded, else an estimate for class-II ceramics), its technology from the Value, MPN and footprint; mounting inductance solved by PEEC from the copper (the loop from each capacitor's pads to the load pin and its ground pin), a microstrip estimate where that cannot be solved", "capCurvesLoaded": lib.len(), "peec": {"solved": peec_count.0, "estimated": peec_count.1, "seconds": r2(peec_t0.elapsed().as_secs_f64())}})
}

/// The same branches as an ngspice deck: a 1 A AC current into the rail, so |V| is |Z|.
pub fn netlist(net: &str, load: &str, bs: &[Branch]) -> String {
    let mut s = format!("* adom-fields PDN: {} seen from {}\n* each branch R, L, (Lp || Rp), C to ground; 1 A AC in, so V(rail) is Z\nIin 0 rail AC 1\n", net, load);
    for (k, b) in bs.iter().enumerate() {
        let mut node = "rail".to_string(); let mut m = 0; let mut nx = |s: &mut String, kind: &str, val: f64| -> () { let next = format!("b{}_{}", k, m); m += 1; s.push_str(&format!("{}{}_{} {} {} {:e}\n", kind, k, m, node, next, val)); node = next; };
        s.push_str(&format!("* {} ({})\n", b.name, b.kind));
        nx(&mut s, "R", b.r.max(1e-6)); if b.l > 0.0 { nx(&mut s, "L", b.l); }
        if b.lp > 0.0 { let next = format!("b{}_p", k); s.push_str(&format!("Lp{} {} {} {:e}\n", k, node, next, b.lp)); if b.rp > 0.0 { s.push_str(&format!("Rp{} {} {} {:e}\n", k, node, next, b.rp)); } node = next; }
        if b.c > 0.0 { s.push_str(&format!("C{} {} 0 {:e}\n", k, node, b.c)); } else { s.push_str(&format!("Rg{} {} 0 1e-6\n", k, node)); }
    }
    s.push_str(".ac dec 40 1k 1g\n.end\n");
    s
}

fn run_spice(file: &std::path::Path, fs: &[f64], z: &[f64]) -> Value {
    let out = file.with_extension("ac.txt");
    let ctl = file.with_extension("ctl.cir");
    let deck = std::fs::read_to_string(file).unwrap_or_default().replace(".end\n", &format!(".control\nset wr_singlescale\nrun\nwrdata {} vm(rail)\n.endc\n.end\n", out.display()));
    if std::fs::write(&ctl, deck).is_err() { return json!({"ran": false}); }
    let r = std::process::Command::new("timeout").args(["30", "ngspice", "-b"]).arg(&ctl).output();
    let _ = std::fs::remove_file(&ctl);
    let Ok(r) = r else { return json!({"ran": false, "why": "ngspice not installed"}) };
    if !r.status.success() { return json!({"ran": false, "why": "ngspice failed"}); }
    let txt = std::fs::read_to_string(&out).unwrap_or_default(); let _ = std::fs::remove_file(&out);
    let pts: Vec<(f64, f64)> = 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[0], v[1])) } else { None } }).collect();
    if pts.is_empty() { return json!({"ran": false, "why": "no ngspice output"}); }
    let mut err: f64 = 0.0; let mut cmp = 0;
    for (i, fq) in fs.iter().enumerate() { if let Some(p) = pts.iter().find(|p| (p.0 / fq - 1.0).abs() < 1e-3) { err = err.max((p.1 / z[i] - 1.0).abs()); cmp += 1; } }
    json!({"ran": true, "points": cmp, "maxErrPct": sig(err * 100.0)})
}

#[cfg(test)] mod tests {
    use super::*;
    #[test] fn rlc_series_resonance() {
        // a lone 100 nF with 1 nH and 10 mΩ: |Z| is the ESR at its SRF, 15.9 MHz
        let b = vec![Branch { name: "c".into(), kind: "capacitor", r: 0.01, l: 1e-9, lp: 0.0, rp: 0.0, c: 100e-9, at: None, extra: Value::Null }];
        let f0 = 1.0 / (2.0 * PI * (1e-9f64 * 100e-9).sqrt());
        assert!((zrail(&b, f0).abs() - 0.01).abs() < 1e-6);
        assert!((zrail(&b, 1e3).abs() - 1.0 / (2.0 * PI * 1e3 * 100e-9)).abs() / 1591.5 < 1e-3);
    }
    #[test] fn microstrip() { let l = l_per_mm(0.8, 1.465) * 1e9; assert!(l > 0.5 && l < 0.9, "{l}"); }
}