app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
//! The design review the caller acts on: EMC, ground, rails and the converter, each finding an issue with
//! a plain fix and an `action` an AI can carry out (move a part, add vias, widen, pour, review), plus the
//! ordered fix plan. The app draws the same findings (the EMC, Ground, River and Cycle views); this is the
//! part an AI flow reads without looking at a screen.
use crate::board::{Board, Pad, LAYERS};
use crate::fields::{Fields, Issue, Spec};
use crate::flow::{is_ground, is_switch_name, rail_from_name};
use serde_json::{json, Value};
const MU: f64 = 1.0;
fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
pub fn parse_value(s: &str, unit: char) -> Option<f64> {
for tok in s.replace(',', ".").split_whitespace() {
let t: Vec<char> = tok.chars().collect();
let mut i = 0; while i < t.len() && (t[i].is_ascii_digit() || t[i] == '.') { i += 1; }
if i == 0 || i >= t.len() { continue; }
let whole: String = t[..i].iter().collect();
let mul = match t[i] { 'p' => 1e-12, 'n' => 1e-9, 'u' | 'µ' => 1e-6, 'm' => 1e-3, _ => continue };
let mut j = i + 1; while j < t.len() && t[j].is_ascii_digit() { j += 1; }
let frac: String = t[i + 1..j].iter().collect();
let rest: String = t[j..].iter().collect();
if !rest.is_empty() && !rest.eq_ignore_ascii_case(&unit.to_string()) { continue; }
let v: f64 = if frac.is_empty() { whole.parse().ok()? } else { format!("{whole}.{frac}").parse().ok()? };
return Some(v * mul * MU);
}
None
}
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) }
}
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() }
/// The review: adds issues to `f.issues`-style list and returns the `review` block for fields.json.
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 picked = crate::fields::board_ground(spec, b, &f.net_names);
let gnd = picked.as_ref().map(|g| g.0.clone());
// any net that is a ground: a protector or a bypass capacitor to one of them is doing its job (issue 25)
let any_ground = |n: &str| is_ground(n) || spec.voltages.get(n) == Some(&0.0) || gnd.as_deref() == Some(n);
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());
let mut out = json!({});
// ---- the converter: switching node, inductor, switch, input, output ------------------------------
// the switching converter: buck or 4-switch buck-boost, each operating mode (crate::converter, issue 24)
out["converter"] = crate::converter::review(f, b, spec, issues);
// ---- the reference plane under power and switching copper ----------------------------------------
let (w, h, n) = (f.w, f.h, 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();
let gid = gnd.as_ref().and_then(|g| f.net_names.iter().position(|x| x == g)).map(|i| i as u16);
let mut refs_out = Vec::new();
// what each net's copper sits over (issue 9): on each layer, the nearest real copper layers; ground there is a return
// path, a supply plane is not (the return rides it through bypass capacitors), nothing at all is a void. Every net
// but ground and the rails is checked; switching nodes, clocks, signal pairs and sensitive nets are medium.
if let Some(g) = gid {
let gids: Vec<u16> = f.net_names.iter().enumerate().filter(|(_, x)| is_ground(x)).map(|(i, _)| i as u16).collect();
let important = |name: &str| spec.switching.contains_key(name) || is_switch_name(name) || spec.clocks.contains_key(name) || spec.sensitiveNets.iter().any(|x| x == name) || spec.signals.differential_pairs.iter().any(|p| p.positive == name || p.negative == name);
let mut per: std::collections::BTreeMap<u16, (usize, usize, usize, f64, f64, std::collections::BTreeMap<String, usize>, Vec<String>)> = Default::default();
// a net's own pads and via rings sit in the planes' clearance holes: those cells are not its route
let mut own: std::collections::BTreeMap<String, Vec<(f64, f64, f64)>> = Default::default();
for p in &b.pads { own.entry(p.net.clone()).or_default().push((p.x, p.y, (p.area / std::f64::consts::PI).sqrt() + 0.35)); }
for v in &b.vias { own.entry(v.net.clone()).or_default().push((v.x, v.y, v.size / 2.0 + 0.35)); }
for (ri, &k) in real.iter().enumerate() {
let nbrs: Vec<usize> = [ri.checked_sub(1), Some(ri + 1)].into_iter().flatten().filter_map(|j| real.get(j).copied()).collect();
for i in 0..n {
let id = f.net_ids[k][i]; if id == u16::MAX || gids.contains(&id) { continue; }
let name = &f.net_names[id as usize]; if rail(name).is_some() && !spec.switching.contains_key(name) { continue; }
let (cx, cy) = (f.x0 + ((i % w) as f64 + 0.5) * f.d, f.y0 + ((i / w) as f64 + 0.5) * f.d);
if own.get(name).map_or(false, |v| v.iter().any(|(x, y, r)| (cx - x).hypot(cy - y) <= *r)) { continue; }
let e = per.entry(id).or_insert((0, 0, 0, 0.0, 0.0, Default::default(), Vec::new()));
let under: Vec<u16> = nbrs.iter().map(|&o| f.net_ids[o][i]).collect();
if under.iter().any(|u| gids.contains(u)) { e.0 += 1; }
else if let Some(sup) = under.iter().find(|u| **u != u16::MAX && rail(&f.net_names[**u as usize]).is_some()) { e.1 += 1; *e.5.entry(f.net_names[*sup as usize].clone()).or_default() += 1; e.3 += f.x0 + ((i % w) as f64 + 0.5) * f.d; e.4 += f.y0 + ((i / w) as f64 + 0.5) * f.d; if !e.6.contains(&LAYERS[k].to_string()) { e.6.push(LAYERS[k].to_string()); } }
else { e.2 += 1; e.3 += f.x0 + ((i % w) as f64 + 0.5) * f.d; e.4 += f.y0 + ((i / w) as f64 + 0.5) * f.d; if !e.6.contains(&LAYERS[k].to_string()) { e.6.push(LAYERS[k].to_string()); } }
}
}
let _ = g;
for (id, (on, sup, void, sx, sy, sups, lays)) in per {
let name = f.net_names[id as usize].clone(); let tot = (on + sup + void).max(1); let bad = sup + void;
let mm = bad as f64 * f.d; // cells along a trace about one cell wide: millimetres of route
let pct = bad as f64 / tot as f64 * 100.0;
let sup_name = sups.iter().max_by_key(|x| x.1).map(|x| x.0.clone());
refs_out.push(json!({"net": name, "unreferencedPct": (pct * 10.0).round() / 10.0, "unreferencedMm2": ((bad as f64 * f.d * f.d) * 10.0).round() / 10.0, "overSupplyMm": (sup as f64 * f.d * 10.0).round() / 10.0, "overVoidMm": (void as f64 * f.d * 10.0).round() / 10.0, "supplyPlane": sup_name, "layers": lays}));
let imp = important(&name);
// a poured net is copper area, not a route: say so, and flag it only when it matters (a switching or signal net)
let poured = b.polys.iter().any(|p| p.net == name) || b.pours.iter().any(|p| p.net == name);
let size = if poured { format!("{:.0} mm² of its copper", bad as f64 * f.d * f.d) } else { format!("about {:.0} mm of it", mm) };
// never from an empty set (issue 13: a poured switch node fully over ground reported 0 mm² at (0, 0)); a poured
// important net needs more than 2 mm² of its copper off a plane, a trace more than 2 mm (15 mm when not important)
let bad_mm2 = bad as f64 * f.d * f.d;
if bad > 0 && ((imp && if poured { bad_mm2 > 2.0 } else { mm > 2.0 }) || (!poured && mm > 15.0)) {
let at = [sx / bad.max(1) as f64, sy / bad.max(1) as f64];
let what = match (&sup_name, sup >= void) { (Some(sp), true) => format!("{}: {} runs over the {} plane, not ground ({}), so its return current rides {} through the bypass capacitors", name, size, sp, lays.join(", "), sp), _ => format!("{}: {} runs over no plane at all ({}): its return current has no path under it", name, size, lays.join(", ")) };
issues.push(issue(if imp { "medium" } else { "low" }, "no-reference", what, Some(at), Some(&name), None, format!("Route {} over solid ground: move it to the layer next to the ground plane, or pour ground under it; where it must change layers, put a ground via within 1 mm of the signal via.", name), json!({"type": "reroute", "net": name, "over": f.net_names.iter().find(|x| is_ground(x)), "why": "return path", "at": [r2(at[0]), r2(at[1])], "overSupplyMm": r2(sup as f64 * f.d), "overVoidMm": r2(void as f64 * f.d)})));
}
}
refs_out.sort_by(|a, z| z["unreferencedMm2"].as_f64().partial_cmp(&a["unreferencedMm2"].as_f64()).unwrap());
}
out["reference"] = json!(refs_out);
// ---- ground: offset, islands, stitching, connectors to the outside -----------------------------------
if let (Some(gname), Some(g)) = (gnd.clone(), gid) {
let mut gout = json!({"net": gname, "groundFrom": picked.as_ref().map(|g| g.1), "groundNets": f.net_names.iter().filter(|n| any_ground(n)).cloned().collect::<Vec<_>>()});
// a ground solve that did not converge has no offset worth reporting (issue 9)
let g_ok = f.nets.iter().find(|x| x.net == gname).map_or(true, |x| x.converged);
if !g_ok { gout["offsetNote"] = json!("the ground current solve did not converge: no offset reported"); }
if let Some(nf) = f.flow.get(&gname).filter(|_| g_ok) {
let (mut lo, mut hi, mut at_hi) = (f64::MAX, f64::MIN, [0.0, 0.0]);
for k in 0..LAYERS.len() { for i in 0..n { if nf.on[k].v[i] == 0.0 { continue; } let v = (nf.v_ext - nf.v[k].v[i] as f64).max(f64::MIN); if v < lo { lo = v; } if v > hi { hi = v; at_hi = [f.x0 + ((i % w) as f64 + 0.5) * f.d, f.y0 + ((i / w) as f64 + 0.5) * f.d]; } } }
let span = (hi - lo).max(0.0);
gout["offsetMv"] = json!((span * 1e5).round() / 100.0);
let budget = spec.groundOffsetMv.unwrap_or(10.0);
if span * 1e3 > budget { issues.push(issue("medium", "ground-offset", format!("{}: {:.1} mV across the ground copper (budget {:.0} mV)", gname, span * 1e3, budget), Some(at_hi), Some(&gname), None, format!("Widen the ground path near ({:.1}, {:.1}) or add ground vias to the other layer there, so the return current has a lower-resistance way home.", at_hi[0], at_hi[1]), json!({"type": "add-vias", "net": gname, "at": at_hi, "why": "ground offset"}))); }
let ext: Vec<&crate::flow::Terminal> = { let mut v: Vec<&crate::flow::Terminal> = nf.terminals.iter().filter(|t| t.side == "ext").collect(); v.dedup_by(|a, z| a.reference == z.reference); v };
gout["toOutside"] = json!(ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>());
// the loop that matters on a receiver: a power or data cable's ground and the antenna's (issue 9)
{
let conns: Vec<String> = { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS"))).map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };
let ant: Vec<&String> = conns.iter().filter(|r| b.pads.iter().any(|p| p.reference == **r && spec.sensitiveNets.iter().any(|s| *s == p.net))).collect();
for aj in ant { let ap = b.pads.iter().find(|p| p.reference == *aj).unwrap();
for cj in conns.iter().filter(|r| *r != aj && b.pads.iter().any(|p| p.reference == **r && rail(&p.net).map_or(false, |v| v > 0.0))) { let cp = b.pads.iter().find(|p| p.reference == *cj).unwrap(); let dist = (ap.x - cp.x).hypot(ap.y - cp.y);
let data = b.pads.iter().any(|p| p.reference == *cj && rail(&p.net).is_none() && !is_ground(&p.net) && !p.net.is_empty() && !p.net.starts_with("unconnected-"));
issues.push(issue("medium", "antenna-ground-loop", format!("{} brings a cable's ground onto the board {:.0} mm from the antenna connector {}: common-mode noise from that cable crosses the ground plane into the antenna's shield, which is part of the antenna", cj, dist, aj), Some([(ap.x + cp.x) / 2.0, (ap.y + cp.y) / 2.0]), Some(&gname), Some(cj), format!("Choke the cable at {}: a common-mode choke on its data lines{}, a ferrite on its supply, and its ground tied to the plane right at the connector; keep the antenna's ground return short and away from the {} return path.", cj, if data { "" } else { " (if it carries any)" }, cj), json!({"type": "add-part", "what": "common-mode choke and ferrite", "at": cj, "why": "cable common mode into the antenna ground", "antenna": aj, "distanceMm": r2(dist)}))); }
}
}
if ext.len() > 1 { let (a, z) = (ext[0], ext[ext.len() - 1]); issues.push(issue("low", "ground-loop-risk", format!("{} connectors carry {} off the board ({}): cables that meet off the board close a loop", ext.len(), gname, ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>().join(", ")), Some([(a.x + z.x) / 2.0, (a.y + z.y) / 2.0]), Some(&gname), None, "Return each cable's ground on its own connector and route paired wires together (twisted or adjacent), or tie the grounds at one point.".into(), json!({"type": "review", "what": "ground cabling", "connectors": ext.iter().map(|t| t.reference.clone()).collect::<Vec<_>>()}))); }
}
// islands: ground copper with no via or pin to the main body
if real.len() >= 1 {
let mut link = vec![false; n];
// a via or plated pad joins the layers over its whole ring, not its centre: the drill is a void (PR 9)
let mut mark = |x: f64, y: f64, rad: f64| { let rr = (rad / f.d).ceil() as isize + 1; let (c0, r0) = (((x - f.x0) / f.d) as isize, ((y - f.y0) / f.d) as isize); for dr in -rr..=rr { for dc in -rr..=rr { let (r, c) = (r0 + dr, c0 + dc); if c >= 0 && r >= 0 && (c as usize) < w && (r as usize) < h { link[r as usize * w + c as usize] = true; } } } };
for v in b.vias.iter().chain(b.plated_holes.iter()).filter(|v| v.net == gname) { mark(v.x, v.y, v.size.max(v.drill_long.unwrap_or(0.0)) / 2.0); }
for p in b.pads.iter().filter(|p| p.net == gname && p.layers.len() > 1) { mark(p.x, p.y, (p.area / std::f64::consts::PI).sqrt()); }
let nl = real.len(); let mut comp = vec![-1i32; nl * n]; let mut sizes: Vec<(usize, f64, f64)> = Vec::new();
for li in 0..nl { for i in 0..n { if f.net_ids[real[li]][i] != g || comp[li * n + i] >= 0 { continue; } let id = sizes.len() as i32; let mut st = vec![li * n + i]; comp[li * n + i] = id; let (mut sz, mut sx, mut sy) = (0usize, 0.0, 0.0);
while let Some(v) = st.pop() { let (l, j) = (v / n, v % n); let (r, c) = (j / w, j % w); sz += 1; sx += c as f64; sy += r as f64; let mut nb = Vec::new(); if c > 0 { nb.push(v - 1); } if c + 1 < w { nb.push(v + 1); } if r > 0 { nb.push(v - w); } if r + 1 < h { nb.push(v + w); } if link[j] { for l2 in 0..nl { if l2 != l { nb.push(l2 * n + j); } } } for u in nb { if comp[u] < 0 && f.net_ids[real[u / n]][u % n] == g { comp[u] = id; st.push(u); } } }
sizes.push((sz, sx / sz as f64, sy / sz as f64)); } }
let main = sizes.iter().enumerate().max_by_key(|(_, s)| s.0).map(|(i, _)| i);
let isl: Vec<(f64, [f64; 2])> = sizes.iter().enumerate().filter(|(i, s)| Some(*i) != main && s.0 as f64 * f.d * f.d > 0.5).map(|(_, s)| (s.0 as f64 * f.d * f.d, [f.x0 + (s.1 + 0.5) * f.d, f.y0 + (s.2 + 0.5) * f.d])).collect();
gout["islands"] = json!(isl.iter().map(|(a, at)| json!({"mm2": (a * 10.0).round() / 10.0, "at": at})).collect::<Vec<_>>());
for (a, at) in &isl { issues.push(issue("low", "ground-island", format!("{}: a {:.1} mm² island of ground copper with no via or pin to the rest", gname, a), Some(*at), Some(&gname), None, format!("Add a ground via in the island at ({:.1}, {:.1}) to tie it to the plane, or remove it: floating copper acts as an antenna.", at[0], at[1]), json!({"type": "add-vias", "net": gname, "at": at, "count": 1, "why": "tie a floating island"}))); }
}
// the stitching fence along the board edge: lambda/20 at the highest frequency of interest
// the fence's top frequency: the spec's, else the board's own strongest tones (a converter's edge to 1 GHz; a
// crystal or LC oscillator to its 5th harmonic, a logic clock to 1 GHz), else 1 GHz (issue 9)
let fmax = spec.emcFmaxHz.unwrap_or_else(|| {
if !spec.switching.is_empty() || spec.clocks.is_empty() { return 1e9; }
spec.clocks.iter().map(|(net, c)| { let sine = b.pads.iter().any(|p| p.net == *net && (p.reference.starts_with('Y') || p.reference.starts_with('X'))) || !b.pads.iter().any(|p| p.net == *net && p.reference.starts_with('U')); if sine { 5.0 * c.top_hz() } else { 1e9 } }).fold(0.0, f64::max).min(1e9)
});
let er = crate::fields::gap_between(spec, b, 0, LAYERS.len() - 1).1; let lambda = 299.792458 / (fmax / 1e9) / ((er + 1.0) / 2.0f64).sqrt(); let pitch = lambda / 20.0; let reach = pitch / 2.0;
// fence posts: ground vias, and plated through-hole ground pads (machine pins, mounting holes: issue 9)
let gv: Vec<(f64, f64)> = b.vias.iter().filter(|v| v.net == gname).map(|v| (v.x, v.y)).chain(b.pads.iter().filter(|p| p.net == gname && p.layers.len() >= 2).map(|p| (p.x, p.y))).collect();
let (bw, bh) = (b.width(), b.height()); let per = 2.0 * (bw + bh); let step = 0.4; let mut s = 0.0; let mut gaps: Vec<Vec<(f64, f64)>> = Vec::new(); let mut run: Vec<(f64, f64)> = Vec::new(); let (mut cov, mut tot) = (0usize, 0usize);
while s < per { let (x, y) = if s < bw { (b.x0 + s, b.y0) } else if s < bw + bh { (b.x0 + bw, b.y0 + s - bw) } else if s < 2.0 * bw + bh { (b.x0 + bw - (s - bw - bh), b.y0 + bh) } else { (b.x0, b.y0 + bh - (s - 2.0 * bw - bh)) };
let ok = gv.iter().any(|v| (v.0 - x).hypot(v.1 - y) <= reach + 1.0); tot += 1; if ok { cov += 1; if run.len() > 1 { gaps.push(std::mem::take(&mut run)); } run.clear(); } else { run.push((x, y)); } s += step; }
if run.len() > 1 { gaps.push(run); }
gaps.sort_by(|a, z| z.len().cmp(&a.len()));
gout["stitching"] = json!({"fmaxHz": fmax, "pitchMm": (pitch * 10.0).round() / 10.0, "edgeCoveredPct": (cov as f64 / tot.max(1) as f64 * 1000.0).round() / 10.0, "gaps": gaps.iter().take(6).map(|g| json!({"from": [g[0].0, g[0].1], "to": [g[g.len() - 1].0, g[g.len() - 1].1], "mm": g.len() as f64 * step})).collect::<Vec<_>>()});
// a stitching via's centre sits its radius plus KiCad's default 0.5 mm edge clearance in from the outline, at the
// board's most common via size (0.6 mm when it has none)
let inset = { let mut sizes: Vec<i64> = b.vias.iter().map(|v| (v.size * 1000.0).round() as i64).collect(); sizes.sort(); let mut best = (600i64, 0usize); let mut k = 0; while k < sizes.len() { let e = sizes[k..].iter().take_while(|x| **x == sizes[k]).count(); if e > best.1 { best = (sizes[k], e); } k += e; } 0.5 + best.0 as f64 / 2000.0 };
for gp in gaps.iter().filter(|g| g.len() as f64 * step > pitch.min(4.0)) { let mm = gp.len() as f64 * step; let cnt = (mm / pitch).ceil().max(1.0) as usize; issues.push(issue("low", "stitching-gap", format!("{}: {:.1} mm of board edge with no ground via within {:.1} mm", gname, mm, reach), Some([gp[gp.len() / 2].0, gp[gp.len() / 2].1]), Some(&gname), None, format!("Add {} ground via(s) along the edge (the action lists each point) from ({:.1}, {:.1}) to ({:.1}, {:.1}), one every {:.1} mm, about {:.1} mm in from the edge (the via's copper 0.5 mm clear of it).", cnt, gp[0].0, gp[0].1, gp[gp.len() - 1].0, gp[gp.len() - 1].1, pitch, inset), { // the via points themselves, along the gap, set in from whichever edge each sits on
let every = ((pitch / step).round() as usize).max(1); let first = (gp.len() % every) / 2;
let pts: Vec<[f64; 2]> = gp.iter().enumerate().filter(|(k, _)| *k >= first && (k - first) % every == 0).map(|(_, p)| { let (mut x, mut y) = *p; if (y - b.y0).abs() < 1e-6 { y += inset; } else if (y - (b.y0 + bh)).abs() < 1e-6 { y -= inset; } if (x - b.x0).abs() < 1e-6 { x += inset; } else if (x - (b.x0 + bw)).abs() < 1e-6 { x -= inset; } [r2(x), r2(y)] }).collect();
json!({"type": "add-vias", "net": gname, "points": pts, "from": [r2(gp[0].0), r2(gp[0].1)], "to": [r2(gp[gp.len() - 1].0), r2(gp[gp.len() - 1].1)], "pitchMm": (pitch * 10.0).round() / 10.0, "count": pts.len().max(cnt), "insetMm": r2(inset)}) })); }
out["ground"] = gout;
}
// ---- decoupling: every chip power pin and its nearest capacitor ----------------------------------------
let mut dec = Vec::new();
if let Some(gname) = gnd.clone() {
for u in refs.iter().filter(|r| r.starts_with('U')) {
// the returns that count (issue 29): every ground the chip has a pin on, so a gate-drive rail decoupled to PGND
// is decoupled on a board whose review ground is AGND, and the review's ground as before. A capacitor to a
// ground the chip is not on still does not count.
let mut returns: Vec<String> = nets_of(u).into_iter().filter(|n| is_ground(n) || rail(n) == Some(0.0)).collect();
if !returns.contains(&gname) { returns.push(gname.clone()); }
for net in nets_of(u) {
let Some(rv) = rail(&net) else { continue }; if rv <= 0.0 || is_ground(&net) || spec.switching.contains_key(&net) || is_switch_name(&net) { continue; }
for pin in pads_of(u, &net) {
// the capacitors bridging this rail to a return, nearest first
let mut cands: Vec<(String, f64, f64, f64, String)> = refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net)).filter_map(|r| { let ret = returns.iter().find(|g| **g != net && nets_of(r).contains(*g))?.clone(); let gpins = pads_of(u, &ret); let cv = pads_of(r, &net); let cg = pads_of(r, &ret); let (cv, cg) = (cv.first()?, cg.first()?); let c = b.values.get(r).and_then(|v| parse_value(v, 'F')).unwrap_or(f64::NAN); let gp = near(&gpins, cg.x, cg.y); let area = gp.map(|g| shoelace(&[(pin.x, pin.y), (cv.x, cv.y), (cg.x, cg.y), (g.x, g.y)])).unwrap_or(f64::NAN); Some((r.clone(), (cv.x - pin.x).hypot(cv.y - pin.y), area, c, ret)) }).collect();
cands.sort_by(|a, z| a.1.partial_cmp(&z.1).unwrap());
let hf = cands.iter().filter(|c| c.3.is_finite() && c.3 <= 1.1e-6).min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).or(cands.first()).cloned();
let entry = json!({"chip": u, "pin": pin.pad, "net": net, "at": [r2(pin.x), r2(pin.y)], "cap": hf.as_ref().map(|c| c.0.clone()), "distMm": hf.as_ref().map(|c| r2(c.1)), "loopMm2": hf.as_ref().map(|c| r2(c.2)), "return": hf.as_ref().map(|c| c.4.clone()), "capAt": hf.as_ref().and_then(|c| pads_of(&c.0, &net).first().map(|p| [r2(p.x), r2(p.y)]))});
dec.push(entry);
match &hf {
None => {
// suggest the return of the chip's ground pin nearest this pin
let ret = returns.iter().flat_map(|g| pads_of(u, g).into_iter().map(move |p| (g, (p.x - pin.x).hypot(p.y - pin.y)))).min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).map_or(&gname, |x| x.0);
issues.push(issue("medium", "decoupling-missing", format!("{} pin {} ({}): no capacitor from {} to {} anywhere on the board", u, pin.pad, net, net, returns.join(" or ")), Some([pin.x, pin.y]), Some(&net), Some(u), format!("Add a 100 nF ceramic from {} to {} right at {} pin {}, its ground pad straight to the chip's ground pin.", net, ret, u, pin.pad), json!({"type": "add-part", "what": "capacitor", "value": "100nF", "between": [net, ret], "nearPin": format!("{}.{}", u, pin.pad), "maxDistMm": 1.0})))
}
Some(c) if c.1 > 3.0 => issues.push(issue(if c.1 > 6.0 { "medium" } else { "low" }, "decoupling-far", format!("{} pin {} ({}): the nearest small capacitor, {}, is {:.1} mm away (loop {:.1} mm²)", u, pin.pad, net, c.0, c.1, c.2), Some([pin.x, pin.y]), Some(&net), Some(u), format!("Move {} to within 1 mm of {} pin {}, its ground pad straight to the chip's ground pin, or add a 100 nF there.", c.0, u, pin.pad), json!({"type": "move", "ref": c.0, "toPins": [format!("{}.{}", u, pin.pad)], "maxDistMm": 1.0, "currentDistMm": r2(c.1)}))),
_ => {}
}
}
}
}
}
out["decoupling"] = json!(dec);
// ---- discrete stage feeds (issue 9): a rail through a series resistor or inductor into a net that feeds a
// transistor stage; its bypass capacitors to ground, the filter corner, and the caps' self-resonance
let mut feeds = Vec::new();
if let Some(gname) = gnd.clone() {
let parse_ohm = |val: &str| -> Option<f64> { let u = val.to_ascii_uppercase().replace("OHM", "").replace('Ω', ""); let t = u.trim(); 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('.').parse::<f64>().ok().map(|v| v * m) } else { t.parse().ok() } };
for r in refs.iter().filter(|r| r.starts_with('R') || r.starts_with('L') || r.starts_with("FB")) {
let ns = nets_of(r); if ns.len() != 2 { continue; }
let (rl, feed) = match (rail(&ns[0]).filter(|v| *v > 0.0), rail(&ns[1]).filter(|v| *v > 0.0)) { (Some(_), None) => (ns[0].clone(), ns[1].clone()), (None, Some(_)) => (ns[1].clone(), ns[0].clone()), _ => continue };
if is_ground(&feed) || feed.starts_with("unconnected-") { continue; }
let val = b.values.get(r).cloned().unwrap_or_default();
let ser = if r.starts_with('R') { match parse_ohm(&val) { Some(o) if o <= 470.0 => o, _ => continue } } else { 1.0 };
// it feeds a stage: a transistor on this net, or an inductor from it to a transistor
let stage = refs.iter().find(|q| q.starts_with('Q') && (nets_of(q).contains(&feed) || refs.iter().any(|l| l.starts_with('L') && nets_of(l).contains(&feed) && nets_of(l).iter().any(|n| nets_of(q).contains(n) && *n != feed)))).cloned();
let Some(stage) = stage else { continue };
let cap_return = |c: &str| nets_of(c).into_iter().find(|n| *n != feed && any_ground(n));
let caps: Vec<(String, f64)> = refs.iter().filter(|c| c.starts_with('C') && nets_of(c).contains(&feed) && cap_return(c).is_some()).filter_map(|c| b.values.get(c).and_then(|v| parse_value(v, 'F')).map(|v| (c.clone(), v))).collect();
let ctot: f64 = caps.iter().map(|c| c.1).sum();
let fc = if ctot > 0.0 && r.starts_with('R') { Some(1.0 / (2.0 * std::f64::consts::PI * ser * ctot)) } else { None };
let srf: Vec<Value> = caps.iter().map(|(c, v)| { let span = { let pv = pads_of(c, &feed); let pg = cap_return(c).map_or(Vec::new(), |g| pads_of(c, &g)); match (pv.first(), pg.first()) { (Some(a), Some(z)) => (a.x - z.x).hypot(a.y - z.y), _ => 1.0 } }; let esl = (0.15 + 0.3 * span + 0.5) * 1e-9; json!({"cap": c, "uF": (v * 1e9).round() / 1e3, "srfMHz": ((1.0 / (2.0 * std::f64::consts::PI * (v * esl).sqrt())) / 1e4).round() / 100.0}) }).collect();
let at = pads_of(r, &feed).first().map(|p| [r2(p.x), r2(p.y)]);
feeds.push(json!({"feed": feed, "rail": rl, "series": r, "seriesValue": val, "stage": stage, "caps": srf, "cornerHz": fc.map(|x| x.round())}));
if caps.is_empty() { issues.push(issue("medium", "stage-feed-unbypassed", format!("{} feeds {} from {} through {} ({}) with no capacitor to {} on it: the stage's signal rides the rail into every other stage on {}", feed, stage, rl, r, val, gname, rl), at, Some(&feed), Some(r), format!("Add a 100 nF plus a small high-frequency ceramic (1 nF for an IF stage, 100 pF for a VHF stage) from {} to {} at {}'s end, each with its own ground via.", feed, gname, stage), json!({"type": "add-part", "what": "capacitor", "value": "100nF", "also": "1nF or 100pF for the stage frequency", "between": [feed, gname], "near": stage, "maxDistMm": 2.0, "why": "stage feed bypass"}))); }
}
}
feeds.sort_by(|a, z| a["rail"].as_str().cmp(&z["rail"].as_str()));
out["stageFeeds"] = json!(feeds);
// ---- ESD: each connector pin to the outside, protected or not ----------------------------------------
let mut esd = Vec::new();
if let Some(gname) = gnd.clone() {
// a protector: a diode or TVS by its designator, or an ESD array by its part number (USBLC6, TPD…, PESD…), on the line and ground
const ESD: [&str; 12] = ["USBLC", "TPD", "PESD", "ESD", "TVS", "SP05", "SRV05", "PRTR", "SMF", "SMAJ", "SMBJ", "RCLAMP"];
let esd_part = |r: &str| [b.values.get(r), b.mpns.get(r)].iter().flatten().any(|v| { let u = v.to_ascii_uppercase(); ESD.iter().any(|k| u.contains(k)) });
let protector = |net: &str| refs.iter().find(|r| (r.starts_with('D') || r.starts_with("TVS") || r.starts_with("ESD") || esd_part(r)) && nets_of(r).contains(&net.to_string()) && nets_of(r).iter().any(|n| n != net && any_ground(n))).cloned();
for jref in refs.iter().filter(|r| r.starts_with('J') || (r.starts_with('P') && !r.starts_with("PS"))) {
for p in b.pads.iter().filter(|p| p.reference == *jref && !p.net.is_empty() && !is_ground(&p.net) && !p.net.starts_with("unconnected-")) {
let prot = protector(&p.net);
let sig = rail(&p.net).is_none();
esd.push(json!({"connector": jref, "pin": p.pad, "net": p.net, "at": [r2(p.x), r2(p.y)], "protectedBy": prot, "signal": sig}));
let rf = spec.sensitiveNets.iter().any(|x| *x == p.net);
if prot.is_none() { issues.push(issue("low", "esd-unprotected", format!("{} pin {} ({}): no protection diode from {} to {}", jref, p.pad, p.net, p.net, gname), Some([p.x, p.y]), Some(&p.net), Some(jref), if rf { format!("{} is an RF input: protect it with a part of 0.5 pF or less at the band's top frequency (about 3 dB loss budget into 50 Ω), placed within 3 mm of {} and before the first stage; check its junction for intermodulation with strong stations, or use a gas discharge tube or a shunt inductor to ground where the band allows.", p.net, jref) } else { format!("If {} faces the outside world, add a TVS{} from {} to {} within 3 mm of the pin, before any chip.", jref, if sig { " (low capacitance, for a signal)" } else { " rated above the rail" }, p.net, gname) }, json!({"type": "add-part", "what": "TVS diode", "between": [p.net.clone(), gname.clone()], "nearPin": format!("{}.{}", jref, p.pad), "maxDistMm": 3.0, "signal": sig}))); }
}
}
}
out["esd"] = json!(esd);
// ---- the rails: IR drop against a budget ------------------------------------------------------------
let mut rails = Vec::new();
for (name, nf) in &f.flow {
let Some(rv) = rail(name) else { continue }; if rv <= 0.0 || spec.switching.contains_key(name) { continue; }
let (mut lo, mut hi) = (f64::MAX, f64::MIN); for k in 0..LAYERS.len() { for i in 0..n { if nf.on[k].v[i] != 0.0 { let v = nf.v[k].v[i] as f64; lo = lo.min(v); hi = hi.max(v); } } }
let drop = (hi - lo).max(0.0); let budget = spec.dropPct.unwrap_or(1.0);
rails.push(json!({"net": name, "railV": rv, "dropMv": (drop * 1e5).round() / 100.0, "dropPct": (drop / rv * 1e4).round() / 100.0}));
if drop / rv * 100.0 > budget && spec.loads.get(name).and_then(|l| l.maxDropMv).is_none() { let nr = f.nets.iter().find(|x| x.net == *name); issues.push(issue("medium", "ir-drop", format!("{}: {:.0} mV of IR drop, {:.1} % of {} V (budget {:.1} %)", name, drop * 1e3, drop / rv * 100.0, rv, budget), nr.map(|x| x.peakAt), Some(name), None, format!("Widen {} where it narrows (its current-density peak marks the spot) or add a parallel path on another layer.", name), json!({"type": "widen", "net": name, "at": nr.map(|x| x.peakAt), "why": "IR drop"}))); }
}
out["rails"] = json!(rails);
// ---- each loaded net against its own drop budget, mV (issue 14): source terminal to the farthest sink -------
let mut drops = Vec::new();
for (name, load) in &spec.loads {
let Some(nf) = f.flow.get(name) else { continue };
let (mut lo, mut hi, mut ilo, mut ihi) = (f64::MAX, f64::MIN, 0usize, 0usize); for k in 0..LAYERS.len() { for i in 0..n { if nf.on[k].v[i] != 0.0 { let v = nf.v[k].v[i] as f64; if v < lo { lo = v; ilo = i; } if v > hi { hi = v; ihi = i; } } } }
if lo > hi { continue; }
let drop_mv = (hi - lo) * 1e3;
// the two ends, named by the terminal nearest each voltage extreme (a resistive net's extremes sit at its terminals)
let name_at = |i: usize| -> Option<String> { let (x, y) = (f.x0 + ((i % f.w) as f64 + 0.5) * f.d, f.y0 + ((i / f.w) as f64 + 0.5) * f.d); nf.terminals.iter().min_by(|a, z| (a.x - x).hypot(a.y - y).partial_cmp(&(z.x - x).hypot(z.y - y)).unwrap()).map(|t| format!("{}.{}", t.reference, t.pad)) };
let (hi_t, lo_t) = (name_at(ihi), name_at(ilo));
drops.push(json!({"net": name, "dropMv": (drop_mv * 1000.0).round() / 1000.0, "budgetMv": load.maxDropMv, "highEnd": hi_t, "lowEnd": lo_t}));
if let Some(bmv) = load.maxDropMv { if drop_mv > bmv {
let nr = f.nets.iter().find(|x| x.net == *name);
let mut is = issue(if drop_mv > 2.0 * bmv { "high" } else { "medium" }, "ir-drop", format!("{}: {:.2} mV of IR drop from {} to {} against a {:.2} mV budget", name, drop_mv, hi_t.clone().unwrap_or_default(), lo_t.clone().unwrap_or_default(), bmv), nr.map(|x| x.peakAt), Some(name), None,
format!("Widen {} at its densest point ({}) or add a parallel path on another layer with vias at both ends; the drop has to fall by {:.0} %.", name, nr.map(|x| format!("({:.1}, {:.1}) on {}", x.peakAt[0], x.peakAt[1], x.peakLayer)).unwrap_or_default(), (1.0 - bmv / drop_mv) * 100.0),
json!({"type": "widen", "net": name, "at": nr.map(|x| x.peakAt), "layer": nr.map(|x| x.peakLayer.clone()), "dropMv": (drop_mv * 1000.0).round() / 1000.0, "budgetMv": bmv, "between": [hi_t, lo_t], "orAddVias": "a parallel path on another layer, stitched at both ends", "why": "IR drop over budget"}));
is.layer = nr.map(|x| x.peakLayer.clone());
issues.push(is);
} }
}
out["drops"] = json!(drops);
out
}
trait Pipe: Sized { fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R { f(self) } }
impl Pipe for f64 {}
/// Actions for the solver's own issues (current necks, tab rise, thermal vias), so every finding carries one.
pub fn act_on_core(issues: &mut [Issue], f: &Fields) {
for i in issues.iter_mut() {
if i.action.is_some() { continue; }
i.action = Some(match i.kind.as_str() {
"current-neck" => { let nr = i.net.as_ref().and_then(|n| f.nets.iter().find(|x| x.net == *n)); json!({"type": "widen", "net": i.net, "at": i.at, "layer": i.layer, "amps": nr.map(|x| x.amps), "why": "current density over the neck threshold"}) }
"tab-rise" => json!({"type": "add-copper", "ref": i.reference, "net": i.net, "at": i.at, "also": ["add thermal vias under the tab", "heatsink or airflow"]}),
"thermal-vias" => json!({"type": "add-vias", "ref": i.reference, "net": i.net, "at": i.at, "why": "thermal vias under the tab"}),
_ => json!({"type": "review"}),
});
}
}
/// The fix plan: every action, worst first, numbered, for the caller to work down.
pub fn fix_plan(issues: &[Issue]) -> Value {
let rank = |s: &str| match s { "high" => 0, "medium" => 1, _ => 2 };
let review = |i: &Issue| i.action.as_ref().and_then(|a| a["type"].as_str()).map_or(1, |t| if t == "review" { 1 } else { 0 });
let mut v: Vec<&Issue> = issues.iter().filter(|i| i.action.is_some()).collect();
v.sort_by_key(|i| (rank(&i.severity), review(i)));
json!(v.iter().enumerate().map(|(k, i)| json!({"step": k + 1, "severity": i.severity, "kind": i.kind, "do": i.fix, "action": i.action, "at": i.at})).collect::<Vec<_>>())
}
#[cfg(test)] mod tests {
use super::*;
fn pad(reference: &str, name: &str, net: &str, x: f64, y: f64) -> Pad { Pad { reference: reference.into(), pad: name.into(), net: net.into(), x, y, layers: vec!["F.Cu".into()], area: 0.5, func: String::new() } }
fn missing(pads: Vec<Pad>, voltages: Value) -> (Vec<String>, Value) {
let mut b = Board { x1: 30., y1: 30., layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], pads, ..Board::default() };
for (c, v) in [("C16", "1u"), ("C1", "100n"), ("C2", "100n"), ("C3", "100n")] { b.values.insert(c.into(), v.into()); }
// each ground has a pour of its own, side by side on the back
let mut grounds: Vec<String> = b.pads.iter().map(|p| p.net.clone()).filter(|n| is_ground(n)).collect(); grounds.sort(); grounds.dedup();
for (i, g) in grounds.iter().enumerate() { let x = i as f64 * 10.0; b.polys.push(crate::board::Poly { net: g.clone(), layer: "B.Cu".into(), pts: vec![(x, 0.), (x + 9., 0.), (x + 9., 30.), (x, 30.)] }); }
let spec: crate::fields::Spec = serde_json::from_value(json!({"copperUm": 35, "voltages": voltages, "hot": {"U1": {"watts": 0.1, "tabNet": "PGND"}}})).unwrap();
let f = crate::fields::analyze(&b, &spec, 0.5, 60., &mut |_| {});
let mut issues = Vec::new();
let r = review(&f, &b, &spec, &mut issues);
(issues.iter().filter(|i| i.kind == "decoupling-missing").map(|i| format!("{} {}", i.net.clone().unwrap(), serde_json::to_string(&i.action).unwrap())).collect(), r["decoupling"].clone())
}
// issue 29: a rail decoupled to any ground the chip is on counts; no capacitor, or one to a ground the chip is not on, does not
#[test] fn decoupling_counts_the_chips_own_grounds_and_only_those() {
let (miss, dec) = missing(vec![
pad("U1", "1", "AGND", 10., 10.), pad("U1", "2", "VCC1", 10., 12.), pad("U1", "3", "VBAD", 10., 14.), pad("U1", "4", "VCC2", 14., 10.), pad("U1", "5", "VCC2", 14., 11.), pad("U1", "6", "PGND", 14., 12.), pad("U1", "7", "VOTHER", 14., 14.),
pad("C16", "1", "VCC2", 16., 10.), pad("C16", "2", "PGND", 16., 11.), // the datasheet's way: gate drive to the power ground
pad("C1", "1", "VCC1", 8., 12.), pad("C1", "2", "AGND", 8., 11.), // to the review's ground, as before
pad("C2", "1", "VOTHER", 16., 14.), pad("C2", "2", "CGND", 16., 15.), // to a ground the chip has no pin on
pad("J1", "1", "CGND", 20., 20.),
], json!({"VCC1": 5, "VCC2": 10, "VBAD": 3.3, "VOTHER": 3.3}));
let nets: Vec<&str> = miss.iter().map(|m| m.split(' ').next().unwrap()).collect();
assert!(nets.contains(&"VBAD"), "a rail with no capacitor must stay a finding: {miss:?}");
assert!(nets.contains(&"VOTHER"), "a capacitor to a ground the chip is not on must stay a finding: {miss:?}");
assert!(!nets.contains(&"VCC2"), "{miss:?}"); assert!(!nets.contains(&"VCC1"), "{miss:?}");
let row = |pin: &str| dec.as_array().unwrap().iter().find(|e| e["pin"] == pin).unwrap().clone();
assert_eq!(row("4")["cap"], "C16"); assert_eq!(row("4")["return"], "PGND"); assert!(row("4")["loopMm2"].as_f64().unwrap() > 0.0);
assert_eq!(row("2")["return"], "AGND");
assert!(row("3")["cap"].is_null());
// the suggested capacitor goes to the chip's nearest ground pin
assert!(miss.iter().any(|m| m.starts_with("VBAD") && m.contains("[\"VBAD\",\"AGND\"]")), "{miss:?}");
}
// issue 25: GND is the ground though BRIDGE_GND sorts first; the reviews measure against GND, and a protector or a
// bypass capacitor to a ground counts
#[test] fn the_review_uses_the_real_ground() {
let mut b = Board { x1: 30., y1: 30., layer_names: vec!["F.Cu".into(), "B.Cu".into()], copper: vec!["F.Cu".into(), "B.Cu".into()], ..Board::default() };
b.pads = vec![pad("U1", "1", "GND", 10., 10.), pad("U1", "2", "VCC", 10., 12.), pad("C1", "1", "VCC", 8., 12.), pad("C1", "2", "BRIDGE_GND", 8., 11.),
pad("J1", "1", "GND", 25., 25.), pad("J1", "2", "DATA", 25., 27.), pad("J1", "3", "OTHER", 25., 29.), pad("D1", "1", "DATA", 22., 27.), pad("D1", "2", "GND", 22., 28.),
pad("R5", "1", "+5V", 5., 20.), pad("R5", "2", "FEED", 5., 22.), pad("Q5", "1", "FEED", 5., 25.), pad("C5", "1", "FEED", 8., 22.), pad("C5", "2", "PGND", 8., 23.), pad("U9", "1", "PGND", 15., 25.)];
b.values.insert("C1".into(), "100n".into()); b.values.insert("C5".into(), "100n".into()); b.values.insert("R5".into(), "10".into());
for (i, g) in ["BRIDGE_GND", "GND", "PGND"].iter().enumerate() { let x = i as f64 * 10.0; b.polys.push(crate::board::Poly { net: (*g).into(), layer: "B.Cu".into(), pts: vec![(x, 0.), (x + 9., 0.), (x + 9., 30.), (x, 30.)] }); }
let spec: crate::fields::Spec = serde_json::from_value(json!({"copperUm": 35, "voltages": {"VCC": 5}, "hot": {"U1": {"watts": 0.1, "tabNet": "GND"}}})).unwrap();
let f = crate::fields::analyze(&b, &spec, 0.5, 60., &mut |_| {});
let mut issues = Vec::new();
let r = review(&f, &b, &spec, &mut issues);
assert_eq!(r["ground"]["net"], "GND"); assert_eq!(r["ground"]["groundFrom"], "the net named GND");
assert_eq!(r["ground"]["groundNets"], json!(["BRIDGE_GND", "GND", "PGND"].iter().filter(|n| f.net_names.iter().any(|x| x == *n)).collect::<Vec<_>>()));
// U1 is on GND only: its capacitor to BRIDGE_GND doesn't decouple it, which 0.10.6 could not see while BRIDGE_GND was the ground
assert!(issues.iter().any(|i| i.kind == "decoupling-missing" && i.net.as_deref() == Some("VCC") && i.what.contains("to GND anywhere")), "{:?}", issues.iter().map(|i| &i.what).collect::<Vec<_>>());
let esd: Vec<&str> = issues.iter().filter(|i| i.kind == "esd-unprotected").filter_map(|i| i.net.as_deref()).collect();
assert!(!esd.contains(&"DATA"), "D1 protects DATA to GND: {esd:?}");
assert!(!issues.iter().any(|i| i.kind == "stage-feed-unbypassed"), "C5 bypasses FEED to PGND");
assert_eq!(r["stageFeeds"][0]["feed"], "FEED"); assert_eq!(r["stageFeeds"][0]["caps"][0]["cap"], "C5");
}
// a board with one ground: the same finding as before, naming that ground
#[test] fn one_ground_finding_is_unchanged() {
let (miss, _) = missing(vec![pad("U1", "1", "GND", 10., 10.), pad("U1", "2", "VCC", 10., 12.), pad("U1", "3", "VDD", 12., 12.), pad("C1", "1", "VDD", 13., 12.), pad("C1", "2", "GND", 13., 13.), pad("J1", "1", "GND", 20., 20.)], json!({"VCC": 5, "VDD": 3.3}));
assert_eq!(miss.len(), 1); assert!(miss[0].starts_with("VCC") && miss[0].contains("[\"VCC\",\"GND\"]"), "{miss:?}");
}
#[test] fn values() { assert!((parse_value("15uH", 'H').unwrap() - 15e-6).abs() < 1e-12); assert!((parse_value("22uF 10V", 'F').unwrap() - 22e-6).abs() < 1e-12); assert!((parse_value("4u7", 'F').unwrap() - 4.7e-6).abs() < 1e-12); assert_eq!(parse_value("10k", 'F'), None); assert!((parse_value("100nF", 'F').unwrap() - 1e-7).abs() < 1e-15); }
}