main
John Lauer adom-fields 0.10.5: converter power ground and shunt values with ratings (issue 24) 7169a99
//! The switching converter (0.10.4, issue 24): its switch nodes, inductor, switch legs, input and output, and per
//! operating mode the duty, inductor ripple, capacitor ripple, hot loop and the hot loop's radiated emissions.
//! - **Topologies.** A buck (one switch node, the inductor from it to the output) and a 4-switch buck-boost (the
//!   inductor between two switch nodes: the input leg on one, the output leg on the other).
//! - **The inductor** is found through low-value series parts on its path (a current-sense shunt, a ferrite, a 0 Ω
//!   link), or named in spec `converter.inductor`.
//! - **A switch leg** is every part switching that node (spec `sources.<node>`, else the Q/U parts on it). A discrete
//!   half-bridge's hot loop runs from the high side's input pin to the low side's ground pin.
//! - **Modes.** Buck mode when the input is above the output (at the spec's input voltage), boost mode when it is below
//!   (a buck-boost at spec `converter.vinMin`). The hot loop is the input capacitors on the input leg in buck mode,
//!   and the output capacitors on the output leg in boost mode. The top-level numbers are the buck mode's when it is
//!   evaluated (the conducted test's case), else the boost mode's; `modes` has each one.
//! - **Unsupported:** a switch node that cannot be modelled returns `{"unsupported": why}` rather than nothing, so a caller
//!   can tell "not modelled" from "missing data".
use crate::board::{Board, Pad};
use crate::emc::parse_value;
use crate::fields::{Fields, Issue, Spec};
use crate::flow::{is_ground, is_switch_name, rail_from_name};
use serde_json::{json, Value};
use std::f64::consts::PI;

fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
fn shoelace(p: &[(f64, f64)]) -> f64 { let mut a = 0.0; for i in 0..p.len() { let j = (i + 1) % p.len(); a += p[i].0 * p[j].1 - p[j].0 * p[i].1; } (a / 2.0).abs() }
fn issue(sev: &str, kind: &str, what: String, at: Option<[f64; 2]>, net: Option<&str>, reference: Option<&str>, fix: String, action: Value) -> Issue {
    Issue { severity: sev.into(), kind: kind.into(), what, at: at.map(|a| [r2(a[0]), r2(a[1])]), layer: Some("F.Cu".into()), net: net.map(str::to_string), reference: reference.map(str::to_string), fix, action: Some(action) }
}

/// A resistor value in ohms from its Value text: "3m", "3mR", "0R003", "0.003", "0", "10k", "4R7"
pub fn ohms(s: &str) -> Option<f64> {
    // the Value often carries a rating or a size after the resistance ("3m 1W", "3mR 1%", "10k 0402", "3.0 0.75W"):
    // drop rating tokens, then take the first token, joined with the next when that is a bare unit ("3 mΩ")
    let rating = |w: &str| { let l = w.to_ascii_lowercase(); l.ends_with('w') || l.ends_with('v') || l.ends_with('%') || l.contains("ppm") };
    let toks: Vec<&str> = s.split_whitespace().filter(|w| !rating(w)).collect();
    let first = match toks.as_slice() { [] => return None, [a, u, ..] if u.chars().all(|c| c.is_alphabetic()) => format!("{a}{u}"), [a, ..] => a.to_string() };
    let t = first.replace('Ω', "").replace("ohm", "").replace("Ohm", "");
    let t = t.split(['/', '_', ',']).next().unwrap_or("").to_string();
    if t.is_empty() { return None; }
    // a multiplier or R in place of the decimal point: 4R7, 0R003, 4k7, 3m3
    for (c, m) in [('R', 1.0), ('r', 1.0), ('k', 1e3), ('K', 1e3), ('M', 1e6), ('m', 1e-3)] {
        if let Some(i) = t.find(c) { let (a, b) = (&t[..i], &t[i + c.len_utf8()..]); let b = b.trim_end_matches(['R', 'r']);
            if a.chars().all(|x| x.is_ascii_digit() || x == '.') && b.chars().all(|x| x.is_ascii_digit()) && !(a.is_empty() && b.is_empty()) {
                let v: f64 = format!("{}{}{}", if a.is_empty() { "0" } else { a }, if b.is_empty() { "" } else { "." }, b).parse().ok()?; return Some(v * m); } }
    }
    t.parse().ok()
}

/// The parts that join two nets with almost no impedance, for following an inductor's path: resistors of 1 Ω or less
/// (current shunts, 0 Ω links), ferrite beads, and 2-pad net ties and jumpers.
fn series(b: &Board, spec: &Spec, r: &str) -> bool {
    if spec.converter.as_ref().is_some_and(|c| c.series.iter().any(|x| x == r)) { return true; }
    let v = b.values.get(r).map(String::as_str).unwrap_or("");
    (r.starts_with('R') && ohms(v).is_some_and(|x| x <= 1.0)) || r.starts_with("FB") || r.starts_with("NT") || r.starts_with("JP") || (r.starts_with('L') && v.to_ascii_lowercase().contains("ferrite"))
}

pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Value {
    let rail = |n: &str| spec.voltages.get(n).copied().or_else(|| rail_from_name(n));
    let grounds: Vec<String> = f.net_names.iter().filter(|n| is_ground(n)).cloned().collect();
    if grounds.is_empty() { return Value::Null; }
    let pads_of = |r: &str, n: &str| -> Vec<&Pad> { b.pads.iter().filter(|p| p.reference == r && p.net == n).collect() };
    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 refs: Vec<String> = { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };
    let near = |list: &[&Pad], x: f64, y: f64| list.iter().min_by(|a, c| ((a.x - x).hypot(a.y - y)).partial_cmp(&(c.x - x).hypot(c.y - y)).unwrap()).map(|p| (*p).clone());
    // the switch nodes: the spec's, else a net named like one
    let mut nodes: Vec<String> = spec.switching.keys().cloned().collect();
    if nodes.is_empty() { if let Some(n) = f.net_names.iter().find(|n| is_switch_name(n)) { nodes.push(n.clone()); } }
    if nodes.is_empty() { return Value::Null; }
    let unsupported = |why: String| json!({"unsupported": why, "switchNodes": nodes});
    // the nets a node reaches through series parts (two hops), and the parts on the way
    let through = |start: &str| -> (Vec<String>, Vec<String>) {
        let (mut nets, mut parts) = (vec![start.to_string()], Vec::new());
        for _ in 0..2 { let cur = nets.clone(); for n in &cur { for r in refs.iter().filter(|r| series(b, spec, r) && nets_of(r).len() == 2 && nets_of(r).contains(n)) { for m in nets_of(r) { if !nets.contains(&m) && !grounds.contains(&m) { nets.push(m); if !parts.contains(r) { parts.push(r.clone()); } } } } } }
        (nets, parts)
    };
    // the inductor: named, else an L (not a ferrite) with a pad on a node or on a net the node reaches through series parts
    let named = spec.converter.as_ref().and_then(|c| c.inductor.clone());
    let mut found: Option<(String, String, String, Vec<String>)> = None; // (inductor, node, its far net, series parts)
    for n in &nodes {
        let (reach, parts) = through(n);
        let cand: Vec<&String> = match &named { Some(l) => refs.iter().filter(|r| *r == l).collect(), None => refs.iter().filter(|r| r.starts_with('L') && !series(b, spec, r) && nets_of(r).len() == 2).collect() };
        if let Some(l) = cand.into_iter().find(|r| nets_of(r).iter().any(|x| reach.contains(x))) {
            let ln = nets_of(l); let near_side = ln.iter().find(|x| reach.contains(*x)).cloned().unwrap(); let far = ln.into_iter().find(|x| *x != near_side).unwrap_or_default();
            let used: Vec<String> = parts.into_iter().filter(|p| { let pn = nets_of(p); pn.contains(&near_side) || (near_side != *n && pn.contains(n)) }).collect();
            found = Some((l.clone(), n.clone(), far, used)); break;
        }
    }
    let Some((lref, node_a, far, mut via)) = found else { return unsupported(format!("no inductor on {} (directly or through a current shunt, ferrite or 0 Ω link); name it in spec converter.inductor", nodes.join(" or "))) };
    // a second switch node at the inductor's far side (through series parts too): a 4-switch buck-boost
    let node_b = nodes.iter().filter(|n| **n != node_a).find(|n| { let (reach, _) = through(n); reach.contains(&far) }).cloned();
    if let Some(nb) = &node_b { let (_, p) = through(nb); for x in p { if nets_of(&x).contains(&far) && !via.contains(&x) { via.push(x); } } }
    // a switch leg: the spec's sources for the node, else the Q and U parts on it
    let leg = |n: &str| -> Vec<String> { spec.sources.get(n).cloned().filter(|v| !v.is_empty()).unwrap_or_else(|| refs.iter().filter(|r| (r.starts_with('Q') || r.starts_with('U')) && nets_of(r).contains(&n.to_string())).cloned().collect()) };
    let leg_a = leg(&node_a); if leg_a.is_empty() { return unsupported(format!("no switch on {node_a}: give spec sources.{node_a}")); }
    // a leg's rail: the highest-voltage net its parts reach besides the node and ground
    let leg_rail = |parts: &[String], node: &str| -> Option<String> { let mut c: Vec<String> = parts.iter().flat_map(|p| nets_of(p)).filter(|n| n != node && !grounds.contains(n) && rail(n).is_some()).collect(); c.sort(); c.dedup(); c.sort_by(|a, z| rail(z).partial_cmp(&rail(a)).unwrap()); c.into_iter().next() };
    let Some(in_net) = leg_rail(&leg_a, &node_a) else { return unsupported(format!("{}'s parts reach no rail with a known voltage: give its input in spec voltages", node_a)) };
    let (topology, leg_b, out_net) = match &node_b {
        Some(nb) => { let lb = leg(nb); let Some(o) = leg_rail(&lb, nb) else { return unsupported(format!("{nb}'s parts reach no rail with a known voltage: give the output in spec voltages")) }; ("buck-boost (4-switch)", lb, o) }
        None => { // a buck's output: the inductor's far net, or the rail it reaches through series parts
            let (reach, _) = through(&far); let o = if rail(&far).is_some() { far.clone() } else { reach.into_iter().find(|n| rail(n).is_some()).unwrap_or(far.clone()) }; ("buck", vec![], o) }
    };
    // the power ground (issue 24): the spec's, else the ground net the switch legs return to (a low-side switch's
    // source), ties broken by the capacitors from the input and output to it; else the first ground net
    let leg_parts: Vec<String> = leg_a.iter().chain(leg_b.iter()).cloned().collect();
    let score = |g: &String| -> (usize, usize) { (leg_parts.iter().map(|p| pads_of(p, g).len()).sum(), refs.iter().filter(|r| r.starts_with('C') && { let n = nets_of(r); n.contains(g) && (n.contains(&in_net) || n.contains(&out_net)) }).count()) };
    let named_gnd = spec.converter.as_ref().and_then(|c| c.ground.clone());
    let (gnd, gnd_from) = match &named_gnd {
        Some(g) => (g.clone(), "spec converter.ground"),
        None => { let best = grounds.iter().max_by_key(|g| score(g)).cloned().unwrap(); if score(&best).0 > 0 { (best, "switch legs") } else if score(&best).1 > 0 { (best, "input and output capacitors") } else { (grounds[0].clone(), "first ground net") } }
    };
    if !f.net_names.contains(&gnd) { return unsupported(format!("spec converter.ground {gnd} is not a net on this board")); }
    if node_b.is_none() && rail(&out_net).is_none() { return unsupported(format!("{lref}'s far side, {out_net}, has no known voltage: if {out_net} is a second switch node (a 4-switch buck-boost), add it to spec switching with its switches in spec sources; otherwise give the output's voltage in spec voltages")); }
    let (Some(l), Some(vin), Some(vout)) = (b.values.get(&lref).and_then(|v| parse_value(v, 'H')), rail(&in_net), rail(&out_net)) else { return unsupported(format!("{lref}'s inductance, {in_net}'s or {out_net}'s voltage is unknown (part Value, spec voltages)")) };
    let Some(iout) = spec.loads.get(&out_net).map(|x| x.amps) else { return unsupported(format!("no load on {out_net}: give spec loads.{out_net}")) };
    if vout <= 0.0 { return unsupported(format!("{out_net} is not positive")); }
    let vin_min = spec.converter.as_ref().and_then(|c| c.vinMin);
    if topology == "buck" && vout >= vin { return unsupported(format!("{in_net} ({vin} V) is not above {out_net} ({vout} V): a buck steps down; for a boost or a buck-boost the model needs the second switch node in spec switching")); }
    // capacitors at their DC bias, on a rail to ground
    let lib = crate::caps::Library::load(spec.capCurves.as_deref());
    let capm = |net: &str| -> Vec<(String, crate::caps::CapModel)> { let vr = rail(net).unwrap_or(0.0); refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net.to_string()) && nets_of(r).contains(&gnd)).filter_map(|r| { let val = b.values.get(r)?; let c = parse_value(val, 'F')?; let pv = pads_of(r, net); let pg = pads_of(r, &gnd); let span = match (pv.first(), pg.first()) { (Some(a), Some(z)) => (a.x - z.x).hypot(a.y - z.y), _ => 1.5 }; Some((r.clone(), crate::caps::model(&lib, val, b.mpns.get(r).map(String::as_str).unwrap_or(""), b.footprints.get(r).map(String::as_str).unwrap_or(""), c, vr, span))) }).collect() };
    let (cin_m, cout_m) = (capm(&in_net), capm(&out_net));
    let esr_par = |ms: &[(String, crate::caps::CapModel)]| -> f64 { let g: f64 = ms.iter().map(|(_, m)| 1.0 / m.esr.max(1e-5)).sum(); if g > 0.0 { 1.0 / g } else { 0.0 } };
    let (ccin, ccout): (f64, f64) = (cin_m.iter().map(|c| c.1.c_eff).sum(), cout_m.iter().map(|c| c.1.c_eff).sum());
    let swcfg = |n: &str| spec.switching.get(n);
    let fsw = swcfg(&node_a).and_then(|s| s.freqHz).unwrap_or(500e3); let t = 1.0 / fsw;
    let tr = swcfg(&node_a).and_then(|s| s.riseNs).unwrap_or(8.0) * 1e-9;
    let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());
    // a hot loop: each capacitor on `rail_net` → the nearest leg pin on it → the nearest leg pin on ground → its ground pad
    let hot = |caps: &[(String, crate::caps::CapModel)], rail_net: &str, parts: &[String]| -> (Vec<(String, f64, f64, [Pad; 4])>, Option<(f64, Pad, Pad)>) {
        let rp: Vec<&Pad> = parts.iter().flat_map(|p| pads_of(p, rail_net)).collect(); let gp: Vec<&Pad> = parts.iter().flat_map(|p| pads_of(p, &gnd)).collect();
        let mut loops = Vec::new();
        if rp.is_empty() || gp.is_empty() { return (loops, None); }
        for (r, m) in caps { let (cv, cg) = (pads_of(r, rail_net), pads_of(r, &gnd)); if cv.is_empty() || cg.is_empty() { continue; } let uv = near(&rp, cv[0].x, cv[0].y).unwrap(); let ug = near(&gp, cg[0].x, cg[0].y).unwrap(); let a = shoelace(&[(cv[0].x, cv[0].y), (uv.x, uv.y), (ug.x, ug.y), (cg[0].x, cg[0].y)]); loops.push((r.clone(), m.c_eff, a, [cv[0].clone(), uv, ug, cg[0].clone()])); }
        // the smallest loop the leg allows: a capacitor straight across its nearest rail and ground pins
        let ideal = rp.iter().flat_map(|a| gp.iter().map(move |z| ((a.x - z.x).hypot(a.y - z.y), (*a).clone(), (*z).clone()))).min_by(|a, z| a.0.partial_cmp(&z.0).unwrap()).map(|(d, a, z)| (d * 0.8, a, z));
        (loops, ideal)
    };
    // a hot loop's radiated emissions (Ott small loop, trapezoid harmonics) against every limit set
    let emissions = |area_mm2: f64, ipk: f64, duty: f64| -> (f64, f64, Vec<Value>) {
        let sinc = |x: f64| if x == 0.0 { 1.0 } else { x.sin() / x };
        let sets = crate::limits::sets(&chosen, &spec.emcLimits); let mut per: Vec<(f64, f64)> = vec![(f64::MAX, 0.0); sets.len()];
        let lim = |fr: f64| crate::limits::at(&chosen, &spec.emcLimits, fr).unwrap_or(54.0);
        let (mut worst, mut wf) = (f64::MAX, 0.0); let a = area_mm2 * 1e-6;
        let mut n = (30e6 / fsw).ceil() as u64; let stepn = ((1e9 - 30e6) / fsw / 4000.0).max(1.0) as u64;
        while (n as f64) * fsw <= 1e9 { let fr = n as f64 * fsw; let inn = 2.0 * ipk * duty * sinc(PI * n as f64 * duty).abs() * sinc(PI * n as f64 * tr / t).abs(); let e = 1.316e-14 * fr * fr * a * inn / 3.0; let db = 20.0 * (e * 1e6 + 1e-30).log10(); let m = lim(fr) - db; if m < worst { worst = m; wf = fr; } for (k, st) in sets.iter().enumerate() { if let Some(l) = crate::limits::at(st, &spec.emcLimits, fr) { if l - db < per[k].0 { per[k] = (l - db, fr); } } } n += stepn; }
        (worst, wf, sets.iter().zip(&per).map(|(st, (m, fr))| json!({"limit": crate::limits::label(st), "set": st, "worstMarginDb": (m * 10.0).round() / 10.0, "atMHz": (fr / 1e6).round()})).collect())
    };
    let label = crate::limits::label(&chosen);
    let capj = |ms: &[(String, crate::caps::CapModel)]| -> Value { json!(ms.iter().map(|(r, m)| json!({"ref": r, "type": m.kind, "uF": (m.c_nom * 1e9).round() / 1e3, "uFAtBias": (m.c_eff * 1e9).round() / 1e3, "esrMohm": (m.esr * 1e4).round() / 10.0, "biasModel": m.source})).collect::<Vec<_>>()) };
    // ---- the operating modes ----------------------------------------------------------------------------------------
    struct Mode { name: &'static str, vin: f64, duty: f64, di: f64, il: f64, rip_in: f64, rip_out: f64, loop_rail: String, leg: Vec<String>, node: String, caps_in: bool, ipk: f64, pulse_duty: f64 }
    let mut modes: Vec<Mode> = Vec::new(); let mut notes: Vec<String> = Vec::new();
    if vin > vout { // buck mode: the input leg switches, the input capacitors carry the pulsed current
        let d = vout / vin; let di = (vin - vout) * d * t / l;
        let rip_out = if ccout > 0.0 { di / (8.0 * fsw * ccout) + di * esr_par(&cout_m) } else { f64::NAN };
        let rip_in = if ccin > 0.0 { iout * d * (1.0 - d) / (fsw * ccin) + iout * esr_par(&cin_m) } else { f64::NAN };
        modes.push(Mode { name: "buck", vin, duty: d, di, il: iout, rip_in, rip_out, loop_rail: in_net.clone(), leg: leg_a.clone(), node: node_a.clone(), caps_in: true, ipk: iout + di / 2.0, pulse_duty: d });
    }
    if topology != "buck" {
        let vb = if vin < vout { Some(vin) } else { vin_min.filter(|v| *v < vout) };
        match vb {
            Some(vb) => { // boost mode: the output leg switches, the output capacitors carry the pulsed current
                let d = 1.0 - vb / vout; let il = iout / (1.0 - d); let di = vb * d * t / l;
                let rip_out = if ccout > 0.0 { iout * d / (fsw * ccout) + esr_par(&cout_m) * (il + di / 2.0) } else { f64::NAN };
                let rip_in = if ccin > 0.0 { di / (8.0 * fsw * ccin) + esr_par(&cin_m) * di } else { f64::NAN };
                modes.push(Mode { name: "boost", vin: vb, duty: d, di, il, rip_in, rip_out, loop_rail: out_net.clone(), leg: leg_b.clone(), node: node_b.clone().unwrap(), caps_in: false, ipk: il + di / 2.0, pulse_duty: 1.0 - d });
            }
            None => notes.push(format!("boost mode not evaluated: give spec converter.vinMin (the lowest input) when it falls below {out_net} ({vout} V); in a buck-boost it is usually the worst case for ripple and heat")),
        }
    }
    if modes.is_empty() { return unsupported(format!("{in_net} ({vin} V) equals {out_net}: no mode to evaluate (give converter.vinMin)")); }
    let mut mode_out = Vec::new(); let mut top: Option<Value> = None;
    for m in modes.iter() {
        let caps = if m.caps_in { &cin_m } else { &cout_m };
        let (loops, ideal) = hot(caps, &m.loop_rail, &m.leg);
        let hf = loops.iter().min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).cloned();
        let em = hf.as_ref().map(|h| emissions(h.2, m.ipk, m.pulse_duty));
        let leg_name = m.leg.join("/");
        // each mode's findings: its own hot loop, ripple and emissions
        if let (Some(h), Some((ia, uv, ug))) = (&hf, &ideal) { if h.2 > (ia * 2.0).max(ia + 3.0) {
            let gain = 20.0 * (h.2 / ia).log10();
            issues.push(issue(if h.2 > 4.0 * ia { "medium" } else { "low" }, "hot-loop", format!("{}: the {} hot loop {}→{}→{} is {:.1} mm²; the switch{} allow{} {:.1} mm²", h.0, m.name, h.0, leg_name, gnd, h.2, if m.leg.len() > 1 { "es" } else { "" }, if m.leg.len() > 1 { "" } else { "s" }, ia), Some([(h.3[0].x + h.3[3].x) / 2.0, (h.3[0].y + h.3[3].y) / 2.0]), Some(&m.loop_rail), Some(&h.0),
                format!("Move {} so its pads sit straight across {}.{} ({}) and {}.{} ({}), on the same layer with no vias in the loop: about {:.0} dB less radiated noise.", h.0, uv.reference, uv.pad, m.loop_rail, ug.reference, ug.pad, gnd, gain),
                json!({"type": "move", "ref": h.0, "toPins": [format!("{}.{}", uv.reference, uv.pad), format!("{}.{}", ug.reference, ug.pad)], "targetMm2": (ia * 10.0).round() / 10.0, "currentMm2": (h.2 * 10.0).round() / 10.0, "gainDb": (gain * 10.0).round() / 10.0, "mode": m.name})));
        } }
        if let (Some(h), Some((worst, wf, _))) = (&hf, &em) { if *worst < 6.0 { issues.push(issue(if *worst < 0.0 { "high" } else { "medium" }, "emissions", format!("The {} hot loop's estimated emissions come within {:.1} dB of {} at {:.0} MHz (differential mode, 3 m)", m.name, worst, label, wf / 1e6), Some([h.3[1].x, h.3[1].y]), Some(&m.node), m.leg.first().map(String::as_str), "Shrink the hot loop (the capacitor across the switches' rail and ground pins), slow the edge if the part allows, and keep the switch node's copper small.".into(), json!({"type": "move", "ref": h.0, "toPins": ideal.as_ref().map(|(_, uv, ug)| vec![format!("{}.{}", uv.reference, uv.pad), format!("{}.{}", ug.reference, ug.pad)]), "why": "emissions margin", "mode": m.name}))); } }
        let ratio = m.di / m.il;
        let lfor30 = |il: f64| if m.name == "buck" { (m.vin - vout) * m.duty * t / (0.3 * il) } else { m.vin * m.duty * t / (0.3 * il) };
        if !(0.15..=0.5).contains(&ratio) { issues.push(issue("low", "inductor-ripple", format!("{}: in {} mode the ripple is {:.0} % of the inductor current (typical design: 20 to 40 %)", lref, m.name, ratio * 100.0), b.pads.iter().find(|p| p.reference == lref).map(|p| [p.x, p.y]), Some(&m.node), Some(&lref), if ratio > 0.5 { format!("A larger inductance (about {:.0} µH for 30 %) or a higher switching frequency.", lfor30(m.il) * 1e6) } else { format!("A smaller inductance (about {:.0} µH for 30 %) responds faster and is smaller.", lfor30(m.il) * 1e6) }, json!({"type": "review", "what": "inductor value", "ref": lref, "suggestUh": (lfor30(m.il) * 1e7).round() / 10.0, "mode": m.name}))); }
        if m.rip_out.is_finite() && m.rip_out > 0.01 * vout { let need = if m.name == "buck" { m.di / (8.0 * fsw * 0.01 * vout) } else { iout * m.duty / (fsw * 0.01 * vout) }; issues.push(issue("medium", "output-ripple", format!("{}: output ripple about {:.1} mV in {} mode, over 1 % of {} V", out_net, m.rip_out * 1e3, m.name, vout), None, Some(&out_net), None, format!("Add output capacitance (about {:.0} µF more for 1 %) or low-ESR ceramics close to {}.", ((need - ccout) * 1e6).max(0.0), if m.name == "buck" { lref.clone() } else { leg_name.clone() }), json!({"type": "add-part", "what": "output capacitance", "net": out_net, "uF": ((need - ccout) * 1e6).max(0.0).ceil(), "mode": m.name}))); }
        let rec = json!({"mode": m.name, "vin": m.vin, "duty": m.duty, "inductorA": m.il, "rippleA": m.di, "rippleRatio": ratio, "rippleInMv": m.rip_in * 1e3, "rippleOutMv": m.rip_out * 1e3, "switchNet": m.node, "switches": m.leg, "loopCaps": if m.caps_in { "input" } else { "output" },
            "hotLoops": loops.iter().map(|(r, c, a, _)| json!({"cap": r, "uF": c * 1e6, "mm2": r2(*a)})).collect::<Vec<_>>(), "hotLoop": hf.as_ref().map(|h| json!({"cap": h.0, "mm2": r2(h.2)})), "idealMm2": ideal.as_ref().map(|i| r2(i.0)),
            "emissions": em.as_ref().map(|(m, fr, sets)| json!({"worstMarginDb": (m * 10.0).round() / 10.0, "atMHz": (fr / 1e6).round(), "limit": format!("{label}, 3 m"), "model": "Ott differential-mode loop; no cables", "allLimits": sets}))});
        if top.is_none() {
            top = Some(json!({"topology": topology, "switchNodes": if let Some(nb) = &node_b { json!([node_a, nb]) } else { json!([node_a]) }, "seriesParts": via.iter().map(|r| json!({"ref": r, "value": b.values.get(r)})).collect::<Vec<_>>(),
                "ground": gnd, "groundFrom": gnd_from, "groundNets": grounds, "inCaps": capj(&cin_m), "outCaps": capj(&cout_m), "switchNet": m.node, "switch": m.leg.first(), "switches": m.leg, "inductor": lref, "inNet": in_net, "outNet": out_net, "vin": m.vin, "vout": vout, "iout": iout, "fswHz": fsw, "riseNs": tr * 1e9,
                "duty": m.duty, "rippleA": m.di, "rippleRatio": ratio, "lUh": l * 1e6, "cinUf": ccin * 1e6, "coutUf": ccout * 1e6, "rippleInMv": m.rip_in * 1e3, "rippleOutMv": m.rip_out * 1e3, "inputPulsed": m.caps_in,
                "hotLoops": rec["hotLoops"].clone(), "hotLoop": rec["hotLoop"].clone(), "idealMm2": rec["idealMm2"].clone(), "emissions": rec["emissions"].clone()}));
        }
        mode_out.push(rec);
    }
    let mut v = top.unwrap(); v["modes"] = json!(mode_out); if !notes.is_empty() { v["notes"] = json!(notes); }
    v
}

#[cfg(test)] mod tests {
    use super::*;
    #[test] fn resistor_values() {
        for (s, v) in [("3m", 0.003), ("3mR", 0.003), ("0R003", 0.003), ("0.003", 0.003), ("0", 0.0), ("10k", 10e3), ("4R7", 4.7), ("4k7", 4700.0), ("100", 100.0), ("3 mΩ", 0.003), ("3m 1W", 0.003), ("3mR 1%", 0.003), ("0R003 1W", 0.003), ("3.0 0.75W", 3.0), ("10k 0402", 10e3), ("0.003 1/2W", 0.003), ("10k 1% 50ppm", 10e3)] { assert!((ohms(s).unwrap() - v).abs() < 1e-12, "{s}"); }
        assert!(ohms("DNP").is_none());
    }
}