app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
main
John Lauer
0.10.2: PEEC decoupling-loop inductance in the pdn module (where the stackup allows)
6a8f94a
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
//! Optional analysis modules (0.10.0). The core always runs: current, heat, the copper and thermal findings, ground
//! offset, decoupling, the converter's hot loop and ripple, the inputs assumed. Each optional module runs when the
//! caller asks (`--with emc,pdn` or `--with all`) or when the spec already uses that module's own keys. A module that
//! did not run is never silent: `detect` reads the board for the reasons it may matter here, and the output says what
//! it would add and how to turn it on, so the calling AI can decide.
use crate::board::Board;
use crate::fields::{Issue, Spec};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
pub const OPTIONAL: &[&str] = &["emc", "esd", "pdn", "si", "noise", "rf"];
/// what each module adds, in one line (the hint's second half)
pub fn adds(m: &str) -> &'static str {
match m {
"emc" => "radiated emissions of the hot loop and of each cable (common mode, the usual chamber failure), conducted emissions through a LISN, the stitching fence and return paths, and a compliance prediction (FCC/CISPR) with the cheapest fixes by dB and a bench plan",
"esd" => "each connector pin's protection: a TVS within 3 mm and a filter on every line that faces the outside world",
"pdn" => "each rail's impedance from its load pin against a target (1 kHz to 1 GHz, ngspice-checked) with every decoupling capacitor's loop inductance solved from the copper (PEEC), the cheapest decoupling set, hot-plug overshoot and input-filter stability",
"si" => "every signal trace's impedance (2-D field solve), TDR and overshoot with the series resistor that fixes it, length matching, via stubs and the stackup's reference planes",
"noise" => "plane-cavity resonances against the switching and clock harmonics, and crosstalk into quiet nets",
"rf" => "a receiver's oscillator leaking out of the antenna (FCC 15.111), isolation between stages, the frequency plan's in-band tones and the oscillator's own emission",
_ => "",
}
}
/// the spec keys that switch a module on by themselves (the caller already asked for that analysis)
fn spec_keys(m: &str) -> &'static [&'static str] {
match m { "emc" => &["emcLimit", "emcLimits", "cables", "measurements"], "esd" => &["external"], "pdn" => &["pdn"], "si" => &["impedance", "tdr", "lengthGroups"], "noise" => &["crosstalkMv", "victimBudgetsMv"], "rf" => &["rf", "bands", "sensitiveNets"], _ => &[] }
}
/// The module a finding belongs to; anything not listed is core.
pub fn module_of(kind: &str) -> &'static str {
match kind {
"emissions" | "cable-emissions" | "conducted-emissions" | "stitching-gap" | "ground-loop-risk" | "no-reference" | "clock-outer-layer" | "clock-near-connector" | "edge-signal" | "input-filter-cutoff" => "emc",
"esd-unprotected" | "io-unfiltered" => "esd",
"pdn-impedance" | "input-filter-stability" | "hot-plug-overshoot" | "regulator" => "pdn",
"impedance" | "diff-impedance" | "reflection" | "length-skew" | "via-stub" | "stackup-adjacent-signals" | "stackup-far-reference" | "plane" => "si",
"cavity-resonance" | "crosstalk" | "tone-in-band" => "noise",
"lo-leak" | "stage-isolation" | "lo-emission" | "stage-feed-unbypassed" | "circuit-path" | "antenna-ground-loop" => "rf",
_ => "core",
}
}
pub struct Mods { pub on: BTreeMap<String, String> }
impl Mods {
pub fn has(&self, m: &str) -> bool { m == "core" || self.on.contains_key(m) }
/// `--with` (comma list, `all`) and the spec's own keys
pub fn choose(with: &[String], raw: &Value) -> Result<Mods, String> {
let mut on = BTreeMap::new();
for w in with.iter().flat_map(|w| w.split(',')).map(|w| w.trim().to_ascii_lowercase()).filter(|w| !w.is_empty()) {
if w == "all" { for m in OPTIONAL { on.insert(m.to_string(), "--with all".to_string()); } }
else if OPTIONAL.contains(&w.as_str()) { on.insert(w.clone(), format!("--with {w}")); }
else if w != "core" { return Err(format!("--with {w}: no such module; the optional modules are {} (or all)", OPTIONAL.join(", "))); }
}
for m in OPTIONAL { if on.contains_key(*m) { continue; } if let Some(k) = spec_keys(m).iter().find(|k| !raw[**k].is_null()) { on.insert(m.to_string(), format!("the spec gives {k}")); } }
Ok(Mods { on })
}
}
/// Why each module that did not run may matter on this board: read from the board and the core review, cheaply.
pub fn detect(b: &Board, spec: &Spec, rev: &Value) -> BTreeMap<String, String> {
let mut why = BTreeMap::new();
let refs_with = |pre: &[&str]| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).filter(|r| pre.iter().any(|p| r.starts_with(p)) && !r.starts_with("PS") && !r.starts_with("MP")).collect(); v.sort(); v.dedup(); v };
let conns = refs_with(&["J", "P", "USB", "CN"]);
let list = |v: &[String], n: usize| -> String { if v.len() <= n { v.join(", ") } else { format!("{} and {} more", v[..n].join(", "), v.len() - n) } };
let nets: BTreeSet<String> = b.pads.iter().map(|p| p.net.clone()).filter(|n| !n.is_empty() && !n.starts_with("unconnected-")).collect();
let up = |n: &str| n.to_ascii_uppercase();
let c = &rev["converter"];
// emc
if let Some(fsw) = c["fswHz"].as_f64() {
why.insert("emc".into(), format!("{} switches at {:.0} kHz{}: a switcher's noise riding out on a cable is the most common reason a board fails FCC/CISPR radiated, and its input ripple is what the conducted test measures", c["switch"].as_str().unwrap_or("a converter"), fsw / 1e3, if conns.is_empty() { String::new() } else { format!(" and {} connector(s) can carry cables ({})", conns.len(), list(&conns, 4)) }));
} else if !spec.clocks.is_empty() || nets.iter().any(|n| up(n).contains("CLK") || up(n).contains("XTAL")) {
why.insert("emc".into(), format!("clock nets ({}) with {} connector(s): a clock edge near a connector or on an outer layer is a common radiated-emissions failure", list(&nets.iter().filter(|n| up(n).contains("CLK") || up(n).contains("XTAL") || spec.clocks.contains_key(*n)).cloned().collect::<Vec<_>>(), 3), conns.len()));
} else if !conns.is_empty() { why.insert("emc".into(), format!("{} connector(s) ({}): only if the product goes through FCC/CISPR testing", conns.len(), list(&conns, 4))); }
// esd
if !conns.is_empty() { why.insert("esd".into(), format!("{} connector(s) ({}): a pin that reaches the outside world (a user cable, a hot-plugged lead) needs a TVS within 3 mm; name the outside-facing ones in spec external: [\"J1\", ...] so only those are checked", conns.len(), list(&conns, 4))); }
// pdn: rails that feed a chip with many pins
let chips_on = |n: &str| -> Vec<(String, usize)> { let mut by: BTreeMap<String, usize> = BTreeMap::new(); for p in b.pads.iter().filter(|p| p.reference.starts_with('U')) { *by.entry(p.reference.clone()).or_default() += 1; } let mut v: Vec<(String, usize)> = b.pads.iter().filter(|p| p.net == n && p.reference.starts_with('U')).map(|p| (p.reference.clone(), by[&p.reference])).collect(); v.sort(); v.dedup(); v };
let rails: Vec<String> = nets.iter().filter(|n| !crate::flow::is_ground(n) && (crate::flow::rail_from_name(n).is_some() || spec.voltages.contains_key(*n))).filter_map(|n| { let cs = chips_on(n); let big = cs.iter().filter(|c| c.1 >= 8).map(|c| format!("{} ({} pins)", c.0, c.1)).collect::<Vec<_>>(); if big.is_empty() { None } else { Some(format!("{n} → {}", big.join(", "))) } }).collect();
let mut pdn_why = Vec::new();
if !rails.is_empty() { pdn_why.push(format!("rails feeding chips: {}: a chip's fast current edges need low rail impedance up to its switching frequencies, or it browns out and radiates", list(&rails, 3))); }
if let Some(inn) = c["inNet"].as_str() { pdn_why.push(format!("the converter's input {inn}: plugging in a supply rings its low-ESR capacitors (hot-plug overshoot past their rating), and an input filter can oscillate with the converter (Middlebrook)")); }
if !pdn_why.is_empty() { why.insert("pdn".into(), pdn_why.join("; ")); }
// si
let pairs: Vec<String> = nets.iter().filter_map(|n| { for (a, z) in [("_P", "_N"), ("+", "-"), ("DP", "DM"), ("_H", "_L")] { if let Some(s) = n.strip_suffix(a) { if nets.contains(&format!("{s}{z}")) { return Some(format!("{n}/{s}{z}")); } } } None }).collect();
let fast: Vec<String> = nets.iter().filter(|n| { let u = up(n); ["CLK", "SCK", "USB", "SDIO", "MOSI", "MISO", "QSPI", "ETH", "MII", "LVDS", "MIPI", "HDMI", "DDR"].iter().any(|k| u.contains(k)) }).cloned().collect();
let long: usize = { let mut by: BTreeMap<&str, f64> = BTreeMap::new(); for t in &b.tracks { if crate::flow::rail_from_name(&t.net).is_none() && !crate::flow::is_ground(&t.net) { *by.entry(t.net.as_str()).or_default() += (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1); } } by.values().filter(|l| **l > 50.0).count() };
if !pairs.is_empty() || !fast.is_empty() || long > 0 {
let mut parts = Vec::new(); if !pairs.is_empty() { parts.push(format!("pairs {}", list(&pairs, 3))); } if !fast.is_empty() { parts.push(format!("fast nets {}", list(&fast, 4))); } if long > 0 { parts.push(format!("{long} signal net(s) over 50 mm")); }
why.insert("si".into(), format!("{}: an edge faster than the trace's round trip rings, and a pair off its impedance fails its eye", parts.join("; ")));
}
// noise
let sw = c["switchNet"].as_str().map(String::from).or_else(|| spec.switching.keys().next().cloned());
let pours: usize = { let mut s: BTreeSet<&str> = BTreeSet::new(); for p in &b.polys { if !crate::flow::is_ground(&p.net) && !p.net.is_empty() { s.insert(&p.net); } } s.len() };
if sw.is_some() || !spec.clocks.is_empty() || pours > 0 {
let mut parts = Vec::new(); if let Some(s) = &sw { parts.push(format!("switch node {s} next to quiet nets")); } if !spec.clocks.is_empty() { parts.push(format!("{} clock(s)", spec.clocks.len())); } if pours > 0 { parts.push(format!("{pours} power pour(s) over ground, each a cavity that can ring")); }
why.insert("noise".into(), format!("{}: a plane resonance near a switching harmonic, or a few mV coupled into a sensitive input, is a classic board bug", parts.join("; ")));
}
// rf
let rfn: Vec<String> = nets.iter().filter(|n| { let u = up(n); ["ANT", "RF", "LNA", "MIX", "_LO", "LO_", "IF_", "_IF", "VCO", "TUNER"].iter().any(|k| u.contains(k)) }).cloned().collect();
if !rfn.is_empty() || !spec.bands.is_empty() { why.insert("rf".into(), format!("RF nets ({}): a receiver's oscillator leaking out of its antenna, or a tone landing in its band, fails FCC 15.111 or deafens it", list(&rfn, 4))); }
why
}
/// The lines that tell the caller what did not run and why it may matter here; and the record for fields.json.
pub fn report(mods: &Mods, why: &BTreeMap<String, String>) -> (Vec<String>, Value) {
let mut lines = Vec::new(); let mut skipped = Vec::new();
for m in OPTIONAL { if mods.has(m) { continue; }
let reason = why.get(*m).cloned();
if let Some(r) = &reason { lines.push(format!("Not run: {m}. On this board: {r}. `--with {m}` adds {}.", adds(m))); }
skipped.push(json!({"module": m, "whyItMayMatter": reason, "adds": adds(m), "turnOn": format!("--with {m}")}));
}
let quiet: Vec<&str> = OPTIONAL.iter().copied().filter(|m| !mods.has(m) && !why.contains_key(*m)).collect();
if !quiet.is_empty() { lines.push(format!("Not run, nothing on this board points to them: {} (`--with all` runs every module for a sign-off pass).", quiet.join(", "))); }
else if !skipped.is_empty() { lines.push("`--with all` runs every module for a sign-off pass; each adds seconds, not minutes.".into()); }
let ran: Vec<Value> = mods.on.iter().map(|(m, w)| json!({"module": m, "because": w})).collect();
(lines, json!({"core": "always: current, heat, copper and thermal findings, ground offset, decoupling, the converter's hot loop and ripple, the inputs assumed", "ran": ran, "skipped": skipped}))
}
/// Fewer, sharper findings: one barrel finding per net, ampacity folded into a neck on the same net, and three or more
/// low findings of one kind grouped into one with every item kept (the action's `items`).
pub fn group(iss: Vec<Issue>, external: &[String]) -> Vec<Issue> {
let mut out: Vec<Issue> = Vec::new();
// ESD and filtering only on the connectors the spec says face outside, when it says
let iss: Vec<Issue> = iss.into_iter().filter(|i| { if external.is_empty() || module_of(&i.kind) != "esd" { return true; }
let r = i.reference.clone().or_else(|| i.action.as_ref().and_then(|a| a["nearPin"].as_str()).map(|p| p.split('.').next().unwrap_or("").to_string())).unwrap_or_default(); external.iter().any(|e| *e == r) }).collect();
let necks: BTreeSet<String> = iss.iter().filter(|i| i.kind == "current-neck").filter_map(|i| i.net.clone()).collect();
let mut barrels: BTreeMap<String, Vec<Issue>> = BTreeMap::new();
let mut lows: BTreeMap<String, Vec<Issue>> = BTreeMap::new();
for i in iss {
if i.kind == "ampacity" && i.net.as_ref().is_some_and(|n| necks.contains(n)) { continue; }
if i.kind == "current-barrel" { barrels.entry(i.net.clone().unwrap_or_default()).or_default().push(i); continue; }
if i.severity == "low" && !matches!(i.kind.as_str(), "current-neck" | "tab-rise" | "thermal-vias") { lows.entry(i.kind.clone()).or_default().push(i); continue; }
out.push(i);
}
let item = |i: &Issue| json!({"what": i.what, "at": i.at, "net": i.net, "reference": i.reference, "action": i.action});
for (net, v) in barrels {
if v.len() == 1 { out.extend(v); continue; }
let mut g = v[0].clone(); g.what = format!("{net}: {} plated connections carry more than the density threshold; the first: {}", v.len(), v[0].what); g.action = Some(json!({"type": "group", "of": "review", "items": v.iter().map(item).collect::<Vec<_>>()})); out.push(g);
}
for (kind, v) in lows {
if v.len() < 3 { out.extend(v); continue; }
let names: Vec<String> = v.iter().filter_map(|i| i.net.clone().or(i.reference.clone())).collect::<BTreeSet<_>>().into_iter().collect();
let mut g = v[0].clone();
g.what = format!("{} {kind} findings, grouped ({}{}); the first: {}", v.len(), names.iter().take(8).cloned().collect::<Vec<_>>().join(", "), if names.len() > 8 { format!(" and {} more", names.len() - 8) } else { String::new() }, v[0].what);
g.action = Some(json!({"type": "group", "of": v[0].action.as_ref().and_then(|a| a["type"].as_str()).unwrap_or("review"), "items": v.iter().map(item).collect::<Vec<_>>()}));
out.push(g);
}
out
}
#[cfg(test)] mod tests {
use super::*;
fn iss(kind: &str, sev: &str, net: &str) -> Issue { Issue { severity: sev.into(), kind: kind.into(), what: format!("{kind} on {net}"), at: None, layer: None, net: Some(net.into()), reference: None, fix: String::new(), action: Some(json!({"type": "review"})) } }
#[test] fn choose_and_group() {
let m = Mods::choose(&["pdn,si".into()], &json!({"cables": {"J1": 2}})).unwrap();
assert!(m.has("pdn") && m.has("si") && m.has("emc") && !m.has("rf") && m.has("core"));
assert!(Mods::choose(&["bogus".into()], &json!({})).is_err());
assert_eq!(Mods::choose(&["all".into()], &json!({})).unwrap().on.len(), OPTIONAL.len());
let g = group(vec![iss("current-neck", "high", "A"), iss("ampacity", "medium", "A"), iss("ampacity", "medium", "B"), iss("current-barrel", "medium", "A"), iss("current-barrel", "medium", "A"), iss("no-reference", "low", "X"), iss("no-reference", "low", "Y"), iss("no-reference", "low", "Z"), iss("crosstalk", "low", "Q")], &[]);
let kinds: Vec<&str> = g.iter().map(|i| i.kind.as_str()).collect();
assert_eq!(kinds.iter().filter(|k| **k == "ampacity").count(), 1); // B kept, A folded into its neck
assert_eq!(kinds.iter().filter(|k| **k == "current-barrel").count(), 1);
assert_eq!(kinds.iter().filter(|k| **k == "no-reference").count(), 1);
assert!(kinds.contains(&"crosstalk") && kinds.contains(&"current-neck"));
let nr = g.iter().find(|i| i.kind == "no-reference").unwrap(); assert_eq!(nr.action.as_ref().unwrap()["items"].as_array().unwrap().len(), 3);
}
}