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.8: capacitor dielectric from the MPN's vendor code (issue 31); Murata C0G in the PDN model
f01bf13
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
//! Board checks that read the solved fields (0.9.0): what other PCB review tools check by rule of thumb, done here on the
//! solved copper where a field exists.
//! - ampacity: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there;
//! - ratings: each hot part's junction against its maximum, each capacitor's solved temperature against its type's rating
//! (electrolytic life by the 10 °C rule);
//! - EMC layout rules: clocks on outer layers or near connectors, signals along the board edge, connector pins with no
//! filtering, a converter's input filter against its switching frequency;
//! - the stackup: which layers are planes, adjacent signal layers, signals far from their reference, interplane
//! capacitance;
//! - via stubs: the quarter-wave resonance of each signal via's unused barrel;
//! - length groups: each group's delay skew against its limit (the spec's, or a protocol's).
use crate::board::{Board, LAYERS};
use crate::fields::{Fields, Issue, Spec};
use crate::flow::{is_ground, rail_from_name};
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Deserialize, Clone, Debug, Default)]
#[allow(non_snake_case)]
pub struct LengthGroup { pub nets: Vec<String>, #[serde(default)] pub maxSkewPs: Option<f64>, #[serde(default)] pub maxSkewMm: Option<f64>, /** usb2, usb3, pcie, ethernet, ddr, hdmi, mipi */ #[serde(default)] pub protocol: Option<String> }
fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }
fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
const C_MM_PS: f64 = 0.299792458; // mm per ps in vacuum
fn issue(sev: &str, kind: &str, what: String, at: Option<[f64; 2]>, net: Option<String>, reference: Option<String>, fix: String, action: Value) -> Issue {
Issue { severity: sev.into(), kind: kind.into(), what, at: at.map(|a| [r2(a[0]), r2(a[1])]), layer: None, net, reference, fix, action: Some(action) }
}
/// protocol skew limits within a group, ps (common design-guide values; the spec's maxSkewPs wins)
fn protocol_ps(p: &str) -> Option<f64> {
match p.to_ascii_lowercase().as_str() { "usb2" | "usb" => Some(25.0), "usb3" => Some(5.0), "pcie" => Some(5.0), "ethernet" | "eth" => Some(50.0), "ddr" | "ddr3" | "ddr4" => Some(10.0), "hdmi" => Some(15.0), "mipi" => Some(10.0), _ => None }
}
pub fn review(f: &Fields, b: &Board, spec: &Spec, rev: &Value, issues: &mut Vec<Issue>) -> Value {
let mut out = json!({});
let n = f.w * f.h;
let cell = |x: f64, y: f64| -> Option<usize> { crate::fields::cell_at(x, y, f.x0, f.y0, f.d, f.w, f.h).map(|(r, c)| r * f.w + c) };
let lay = |name: &str| LAYERS.iter().position(|l| *l == name);
let real: Vec<String> = if spec.real_copper.is_empty() { b.copper.clone() } else { spec.real_copper.clone() };
let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() };
let er = spec.epsilonR.unwrap_or(4.3);
let ambient = spec.ambientC.unwrap_or(25.0);
let signal = |net: &str| !net.is_empty() && !is_ground(net) && rail_from_name(net).is_none() && !spec.voltages.contains_key(net) && !spec.loads.contains_key(net) && !net.starts_with("unconnected-");
// ---- ampacity at each loaded net's narrowest neck ----------------------------------------------------------------------
let mut amp = Vec::new();
for x in &f.nets {
if x.peakJ <= 0.0 || x.amps <= 0.0 { continue; }
let t_um = spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm);
let w = x.amps / (x.peakJ * t_um / 1000.0);
let area_mil2 = w * t_um / 1000.0 / 0.00064516;
let external = x.peakLayer == "F.Cu" || x.peakLayer == "B.Cu";
let k = if external { 0.048 } else { 0.024 };
let i10 = k * 10f64.powf(0.44) * area_mil2.powf(0.725);
let rise = lay(&x.peakLayer).and_then(|k| cell(x.peakAt[0], x.peakAt[1]).map(|i| f.t[k].v[i] as f64));
amp.push(json!({"net": x.net, "amps": x.amps, "neckMm": r2(w), "layer": x.peakLayer, "copperUm": t_um, "ipc2221_10C_A": r2(i10), "solvedRiseC": rise.map(r1), "at": x.peakAt}));
if x.amps > i10 {
issues.push(issue(if x.amps > 1.5 * i10 { "medium" } else { "low" }, "ampacity", format!("{}: {} A through a neck about {:.2} mm wide on {} ({:.0} µm), over IPC-2221's {:.2} A for a 10 °C rise; the solved rise there is {}", x.net, x.amps, w, x.peakLayer, t_um, i10, rise.map(|r| format!("+{:.0} °C", r)).unwrap_or_else(|| "unknown".into())), Some(x.peakAt), Some(x.net.clone()), None, format!("Widen {} at the neck to about {:.2} mm, or add a parallel path on another layer; IPC-2152's charts are less conservative than IPC-2221, and the solved temperature is the better guide.", x.net, w * (x.amps / i10).powf(1.0 / 0.725)), json!({"type": "widen", "net": x.net, "at": x.peakAt, "layer": x.peakLayer, "toMm": r2(w * (x.amps / i10).powf(1.0 / 0.725)), "why": "ampacity"})));
}
}
out["ampacity"] = json!(amp);
// ---- ratings: junctions and capacitors ---------------------------------------------------------------------------------
let mut tj = Vec::new();
for c in &f.chips {
let h = spec.hot.get(&c.reference);
let tmax = h.and_then(|h| h.tjMaxC).unwrap_or(125.0);
let t = ambient + c.riseC + h.and_then(|h| h.thetaJcKPerW).map_or(0.0, |th| th * c.watts);
let tm = ambient + c.riseMovingC + h.and_then(|h| h.thetaJcKPerW).map_or(0.0, |th| th * c.watts);
tj.push(json!({"reference": c.reference, "watts": c.watts, "tjStillC": r1(t), "tjMovingC": r1(tm), "tjMaxC": tmax, "marginC": r1(tmax - t), "ambientC": ambient, "thetaJcIncluded": h.and_then(|h| h.thetaJcKPerW).is_some()}));
if t > tmax - 10.0 {
issues.push(issue(if t > tmax { "high" } else { "medium" }, "junction-temperature", format!("{}: junction about {:.0} °C in still air ({:.0} °C ambient), against its {:.0} °C maximum", c.reference, t, ambient, tmax), Some([c.x, c.y]), Some(c.tabNet.clone()), Some(c.reference.clone()), "More copper and vias under the part, a heatsink or airflow, or a part with a higher rating; give hot.<ref>.thetaJcKPerW from the datasheet for the junction itself.".into(), json!({"type": "review", "ref": c.reference, "why": "junction temperature", "tjC": r1(t), "tjMaxC": tmax})));
}
}
out["junctions"] = json!(tj);
let mut caps = Vec::new();
let mut hot_assumed: Vec<(String, f64, [f64; 2])> = Vec::new();
for (r, v) in b.values.iter().filter(|(r, _)| r.starts_with('C') && !r.starts_with("CN")) {
let pads: Vec<&crate::board::Pad> = b.pads.iter().filter(|p| p.reference == *r).collect();
let mut tmax: f64 = 0.0; let mut at = [0.0, 0.0];
for p in &pads { let k = if p.layers.iter().any(|l| l == "B.Cu") && !p.layers.iter().any(|l| l == "F.Cu") { LAYERS.len() - 1 } else { 0 }; if let Some(i) = cell(p.x, p.y) { let t = f.t[k].v[i] as f64; if t > tmax { tmax = t; at = [p.x, p.y]; } } }
if tmax < 5.0 { continue; }
let fp = b.footprints.get(r).cloned().unwrap_or_default(); let mpn = b.mpns.get(r).cloned().unwrap_or_default(); let all = format!("{v} {fp} {mpn}").to_ascii_uppercase();
let electrolytic = all.contains("CP_") || all.contains("ELEC") || all.contains("POLARIZED");
// a rating in °C wins; else the dielectric, from the value or the code inside the MPN (issue 31); else 85 °C, assumed
let stated = if all.contains("150C") { Some(150.0) } else if all.contains("125C") { Some(125.0) } else if all.contains("105C") { Some(105.0) } else if all.contains("85C") { Some(85.0) } else { None };
let diel = if electrolytic { None } else { crate::caps::dielectric(&format!("{v} {fp}"), &mpn) };
let rating = stated.or(diel.as_ref().map(|d| d.1)).unwrap_or(85.0);
let t = ambient + tmax;
let life = if electrolytic { Some(2f64.powf((rating - t) / 10.0)) } else { None };
caps.push(json!({"ref": r, "value": v, "kind": if electrolytic { "electrolytic" } else { "ceramic or other" }, "tempC": r1(t), "ratingC": rating, "ratingAssumed": stated.is_none() && diel.is_none(), "dielectric": diel.as_ref().map(|d| d.0), "ratingFrom": if stated.is_some() { Some("a rating in °C in the value or part number".to_string()) } else { diel.as_ref().map(|d| d.2.clone()) }, "mpn": if mpn.is_empty() { None } else { Some(mpn.clone()) }, "lifeFactor": life.map(r2), "at": at}));
let assumed = stated.is_none() && diel.is_none();
if assumed && !electrolytic { if t > rating - 10.0 { hot_assumed.push((r.clone(), t, at)); } continue; }
if t > rating - 10.0 {
issues.push(issue(if t > rating { "medium" } else { "low" }, "capacitor-temperature", format!("{} ({}): about {:.0} °C where it sits, against a {:.0} °C rating{}", r, v, t, rating, life.map(|l| format!("; electrolytic life about {:.1}× its rated hours", l)).unwrap_or_default()), Some(at), None, Some(r.clone()), format!("Move {} away from the hot part, or use a part rated for {:.0} °C or more (the rating is read from the value or part number; give it there if it is assumed).", r, (t + 20.0).ceil()), json!({"type": "move", "ref": r, "why": "capacitor temperature", "tempC": r1(t), "ratingC": rating})));
}
}
// ceramics whose rating is only assumed: one finding for the lot, the hottest named, so a hot board is not a wall of issues
if !hot_assumed.is_empty() {
hot_assumed.sort_by(|a, z| z.1.partial_cmp(&a.1).unwrap());
let (r0, t0, at0) = hot_assumed[0].clone();
let names: Vec<String> = hot_assumed.iter().take(8).map(|x| format!("{} {:.0} °C", x.0, x.1)).collect();
let no_mpn = hot_assumed.iter().filter(|x| b.mpns.get(&x.0).is_none_or(|m| m.trim().is_empty())).count();
let why = match no_mpn { 0 => "the dielectric could not be read from the value or the MPN field".to_string(), n if n == hot_assumed.len() => "the dielectric isn't in the value and they have no MPN field".to_string(), n => format!("the dielectric could not be read from the value or the MPN field ({n} {} no MPN field)", if n == 1 { "has" } else { "have" }) };
issues.push(issue(if t0 > 85.0 { "medium" } else { "low" }, "capacitor-temperature", format!("{} ceramic capacitor(s) sit within 10 °C of an 85 °C rating, or over it ({}{}); {why}, so 85 °C (X5R) is assumed", hot_assumed.len(), names.join(", "), if hot_assumed.len() > 8 { ", …" } else { "" }), Some(at0), None, Some(r0.clone()), "Put each part's MPN in the footprint's MPN field (Samsung CL, Murata GRM and GCM, TDK, Yageo, KEMET and Walsin codes are read), or its dielectric in the value (X7R: 125 °C). If the parts really are X5R, use X7R near the hot spots or move them away from the heat.".into(), json!({"type": "review", "refs": hot_assumed.iter().map(|x| x.0.clone()).collect::<Vec<_>>(), "why": "capacitor temperature (rating assumed)"})));
}
caps.sort_by(|a, z| z["tempC"].as_f64().partial_cmp(&a["tempC"].as_f64()).unwrap());
out["capacitors"] = json!(caps);
// ---- EMC layout rules ------------------------------------------------------------------------------------------------------
let seg_len = |t: &crate::board::Track| (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1);
let outer = |l: &str| l == "F.Cu" || l == "B.Cu";
let connectors: Vec<&crate::board::Pad> = b.pads.iter().filter(|p| p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS"))).collect();
let dist_seg = |px: f64, py: f64, t: &crate::board::Track| -> f64 { let (ax, ay, bx, by) = (t.a.0, t.a.1, t.b.0, t.b.1); let (dx, dy) = (bx - ax, by - ay); let l2 = dx * dx + dy * dy; let u = if l2 > 0.0 { (((px - ax) * dx + (py - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; (px - ax - u * dx).hypot(py - ay - u * dy) };
let mut rules = Vec::new();
let multilayer = real.len() >= 4;
for (cnet, ck) in &spec.clocks {
let tr: Vec<&crate::board::Track> = b.tracks.iter().filter(|t| t.net == *cnet).collect();
let out_len: f64 = tr.iter().filter(|t| outer(&t.layer)).map(|t| seg_len(t)).sum();
if multilayer && out_len > 10.0 {
rules.push(json!({"rule": "clock-outer-layer", "net": cnet, "outerMm": r1(out_len)}));
issues.push(issue("low", "clock-outer-layer", format!("{}: {:.0} mm of this {:.1} MHz clock runs on an outer layer, where it radiates more than between planes", cnet, out_len, ck.mid_hz() / 1e6), None, Some(cnet.clone()), None, "Route the clock on an inner layer between ground planes, or keep it short and over unbroken ground with a stitching via at each layer change.".into(), json!({"type": "review", "net": cnet, "why": "clock on an outer layer"})));
}
let near = connectors.iter().filter(|p| p.net != *cnet).filter_map(|p| tr.iter().map(|t| dist_seg(p.x, p.y, t)).fold(None, |a: Option<f64>, d| Some(a.map_or(d, |x| x.min(d)))).map(|d| (d, p))).min_by(|a, z| a.0.partial_cmp(&z.0).unwrap());
if let Some((d, p)) = near { if d < 10.0 {
rules.push(json!({"rule": "clock-near-connector", "net": cnet, "connector": p.reference, "mm": r2(d)}));
issues.push(issue("low", "clock-near-connector", format!("{}: the clock passes {:.1} mm from connector {}, whose cable can carry it off the board", cnet, d, p.reference), Some([p.x, p.y]), Some(cnet.clone()), Some(p.reference.clone()), format!("Keep {} at least 10 mm from {} and its pins, or guard it with ground.", cnet, p.reference), json!({"type": "review", "net": cnet, "near": p.reference, "why": "clock near a connector"})));
} }
}
// signals along the board edge (the outline's bounding box)
let edge_mm = 1.0;
let mut edge: std::collections::BTreeMap<String, f64> = Default::default();
for t in b.tracks.iter().filter(|t| signal(&t.net) && outer(&t.layer)) {
let ed = |x: f64, y: f64| (x - b.x0).min(b.x1 - x).min(y - b.y0).min(b.y1 - y);
if ed(t.a.0, t.a.1) < edge_mm && ed(t.b.0, t.b.1) < edge_mm { *edge.entry(t.net.clone()).or_default() += seg_len(t); }
}
for (net, l) in edge.iter().filter(|(_, l)| **l > 5.0) {
rules.push(json!({"rule": "edge-signal", "net": net, "mm": r1(*l)}));
issues.push(issue("low", "edge-signal", format!("{}: {:.0} mm runs within {:.0} mm of the board edge, where the field fringes off the board and radiates", net, l, edge_mm), None, Some(net.clone()), None, "Pull the trace in from the edge (about 3 times its height over the plane) and run a stitched ground ring along the edge.".into(), json!({"type": "review", "net": net, "why": "signal along the board edge"})));
}
// connector signal pins with nothing within 5 mm that filters them
for p in connectors.iter().filter(|p| signal(&p.net) && !spec.sensitiveNets.iter().any(|x| *x == p.net)) {
let parts: Vec<&crate::board::Pad> = b.pads.iter().filter(|q| q.net == p.net && q.reference != p.reference && (q.x - p.x).hypot(q.y - p.y) < 5.0).collect();
let filt = parts.iter().any(|q| { let pr: String = q.reference.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); ["R", "L", "FB", "C", "D", "TVS", "ESD"].contains(&pr.as_str()) });
if !filt {
rules.push(json!({"rule": "io-unfiltered", "connector": p.reference, "pin": p.pad, "net": p.net}));
issues.push(issue("low", "io-unfiltered", format!("{} pin {} ({}): no series resistor, ferrite or capacitor within 5 mm, so board noise rides out on its cable and outside noise comes in", p.reference, p.pad, p.net), Some([p.x, p.y]), Some(p.net.clone()), Some(p.reference.clone()), format!("Add a series resistor or ferrite and, if the signal allows, a small capacitor to ground right at {}.", p.reference), json!({"type": "add-part", "what": "series R or ferrite + shunt C", "nearPin": format!("{}.{}", p.reference, p.pad), "maxDistMm": 5.0, "why": "I/O filtering"})));
}
}
// a converter's input filter against its switching frequency
let conv = &rev["converter"];
if let (Some(inn), Some(fsw)) = (conv["inNet"].as_str(), conv["fswHz"].as_f64()) {
let cin = conv["cinUf"].as_f64().unwrap_or(0.0) * 1e-6;
let lref = conv["inductor"].as_str().unwrap_or("");
let filt = b.values.iter().filter(|(r, _)| (r.starts_with('L') || r.starts_with("FB")) && r.as_str() != lref).find(|(r, _)| b.pads.iter().any(|p| p.reference == **r && p.net == inn));
match filt {
Some((r, v)) => { if let (Some(l), true) = (crate::rf::value(v).filter(|_| r.starts_with('L')), cin > 0.0) {
let fc = 1.0 / (2.0 * std::f64::consts::PI * (l * cin).sqrt());
rules.push(json!({"rule": "input-filter-cutoff", "filter": r, "cutoffHz": fc.round(), "fswHz": fsw}));
if fc > fsw / 10.0 { issues.push(issue("low", "input-filter-cutoff", format!("{} with the input capacitors cuts off at {:.0} kHz, less than a decade below the {:.0} kHz switching frequency: little attenuation of the conducted noise", r, fc / 1e3, fsw / 1e3), None, Some(inn.to_string()), Some(r.clone()), "Lower the filter's corner to a tenth of the switching frequency or less (a larger inductor or more filter capacitance), and check it against the converter's input impedance (the PDN view's stability check).".into(), json!({"type": "review", "ref": r, "why": "input filter cutoff vs switching frequency"}))); }
} }
None => { rules.push(json!({"rule": "input-filter-missing", "net": inn})); }
}
}
out["emcRules"] = json!(rules);
// ---- the stackup ---------------------------------------------------------------------------------------------------------
let mut layers = Vec::new();
for l in &real {
let Some(k) = lay(l) else { continue };
let mut by: std::collections::BTreeMap<u16, usize> = Default::default();
for i in 0..n { let id = f.net_ids[k][i]; if id != u16::MAX { *by.entry(id).or_default() += 1; } }
let (top, cnt) = by.iter().max_by_key(|x| x.1).map(|(a, c)| (*a, *c)).unwrap_or((u16::MAX, 0));
let name = if top != u16::MAX { f.net_names[top as usize].clone() } else { String::new() };
let frac = cnt as f64 / n as f64;
let plane = frac > 0.5 && (is_ground(&name) || rail_from_name(&name).is_some() || spec.voltages.contains_key(&name));
let sig_len: f64 = b.tracks.iter().filter(|t| t.layer == *l && signal(&t.net)).map(|t| seg_len(t)).sum();
layers.push(json!({"layer": l, "plane": if plane { Value::from(name.clone()) } else { Value::Null }, "planeCover": r2(frac), "signalMm": r1(sig_len)}));
}
let mut stack_notes = Vec::new();
if real.len() >= 4 {
let z: Vec<f64> = { let mut acc = 0.0; let mut v = vec![0.0]; for g in gaps.iter().take(real.len() - 1) { acc += g; v.push(acc); } v };
for i in 0..layers.len() {
let is_plane = !layers[i]["plane"].is_null(); let sig = layers[i]["signalMm"].as_f64().unwrap_or(0.0);
if !is_plane && sig > 20.0 {
// nearest plane
let d = (0..layers.len()).filter(|j| !layers[*j]["plane"].is_null()).map(|j| (z[i] - z[j]).abs()).fold(f64::MAX, f64::min);
if d > 0.3 && d < f64::MAX { stack_notes.push(json!({"rule": "far-reference", "layer": layers[i]["layer"], "planeMm": r2(d)})); issues.push(issue("low", "stackup-far-reference", format!("{}: its {:.0} mm of signals sit {:.2} mm from the nearest plane: wider loops and more crosstalk than over a close plane", layers[i]["layer"].as_str().unwrap_or(""), sig, d), None, None, None, "Put a ground plane next to every signal layer (thin prepreg between them), or move fast signals to a layer next to a plane.".into(), json!({"type": "review", "why": "signal layer far from its reference plane"}))); }
if i + 1 < layers.len() && layers[i + 1]["plane"].is_null() && layers[i + 1]["signalMm"].as_f64().unwrap_or(0.0) > 20.0 { stack_notes.push(json!({"rule": "adjacent-signals", "layers": [layers[i]["layer"], layers[i + 1]["layer"]]})); issues.push(issue("low", "stackup-adjacent-signals", format!("{} and {} are both signal layers with no plane between them: broadside crosstalk and no clean return", layers[i]["layer"].as_str().unwrap_or(""), layers[i + 1]["layer"].as_str().unwrap_or("")), None, None, None, "Route them orthogonally at minimum, or put a plane between them.".into(), json!({"type": "review", "why": "adjacent signal layers"}))); }
}
if is_plane && i + 1 < layers.len() && !layers[i + 1]["plane"].is_null() {
let h = z[i + 1] - z[i]; let pf_cm2 = 8.854e-14 * er / (h / 10.0) * 1e12;
stack_notes.push(json!({"rule": "plane-pair", "layers": [layers[i]["layer"], layers[i + 1]["layer"]], "gapMm": r2(h), "pfPerCm2": r2(pf_cm2)}));
}
}
}
out["stackup"] = json!({"layers": layers, "notes": stack_notes});
// ---- via stubs -------------------------------------------------------------------------------------------------------------
let z_of: Vec<f64> = { let mut acc = 0.0; let mut v = vec![0.0]; for g in gaps.iter().take(real.len().saturating_sub(1)) { acc += g; v.push(acc); } v };
let thick = *z_of.last().unwrap_or(&1.6);
let mut stubs = Vec::new();
if real.len() >= 3 {
for v in b.vias.iter().filter(|v| signal(&v.net)) {
let used: Vec<usize> = real.iter().enumerate().filter(|(_, l)| b.tracks.iter().any(|t| t.net == v.net && t.layer == **l && (dist_seg(v.x, v.y, t) < v.size / 2.0 + 0.05))).map(|(i, _)| i).collect();
if used.len() < 2 { continue; }
let (lo, hi) = (*used.iter().min().unwrap(), *used.iter().max().unwrap());
let stub = (z_of[lo]).max(thick - z_of[hi]);
if stub < 0.1 { continue; }
let fr = C_MM_PS * 1e12 / (4.0 * stub * er.sqrt());
let sig_hz = spec.clocks.get(&v.net).map(|c| c.top_hz());
stubs.push(json!({"net": v.net, "at": [r2(v.x), r2(v.y)], "stubMm": r2(stub), "resonanceGHz": r2(fr / 1e9), "signalHz": sig_hz}));
if let Some(fs) = sig_hz { if fr < 5.0 * fs { issues.push(issue("medium", "via-stub", format!("{}: a {:.2} mm via stub resonates at {:.1} GHz, within 5× of its {:.0} MHz signal", v.net, stub, fr / 1e9, fs / 1e6), Some([v.x, v.y]), Some(v.net.clone()), None, "Change layers between the outer layers (no stub), back-drill the via, or use a blind/buried via.".into(), json!({"type": "review", "net": v.net, "at": [r2(v.x), r2(v.y)], "why": "via stub resonance"}))); } }
}
}
stubs.sort_by(|a, z| z["stubMm"].as_f64().partial_cmp(&a["stubMm"].as_f64()).unwrap());
out["viaStubs"] = json!(stubs.into_iter().take(40).collect::<Vec<_>>());
// ---- length groups ------------------------------------------------------------------------------------------------------
let eeff_outer = 0.475 * er + 0.67; // a microstrip's effective permittivity on FR4 (common approximation)
let mut groups = Vec::new();
for (gname, g) in &spec.lengthGroups {
let limit_ps = g.maxSkewPs.or_else(|| g.maxSkewMm.map(|mm| mm * er.sqrt() / C_MM_PS)).or_else(|| g.protocol.as_deref().and_then(protocol_ps)).unwrap_or(25.0);
let mut rows = Vec::new();
for net in &g.nets {
let (mut mm, mut ps) = (0.0, 0.0);
for t in b.tracks.iter().filter(|t| t.net == *net) { let l = seg_len(t); mm += l; ps += l * if outer(&t.layer) { eeff_outer } else { er }.sqrt() / C_MM_PS; }
rows.push(json!({"net": net, "mm": r2(mm), "ps": r1(ps)}));
}
let ps: Vec<f64> = rows.iter().filter_map(|r| r["ps"].as_f64()).filter(|x| *x > 0.0).collect();
let skew = if ps.len() >= 2 { ps.iter().cloned().fold(f64::MIN, f64::max) - ps.iter().cloned().fold(f64::MAX, f64::min) } else { 0.0 };
groups.push(json!({"group": gname, "nets": rows, "skewPs": r1(skew), "limitPs": limit_ps, "protocol": g.protocol}));
if skew > limit_ps { issues.push(issue("medium", "length-skew", format!("{}: {:.0} ps of skew across its {} nets, over the {:.0} ps limit", gname, skew, g.nets.len(), limit_ps), None, None, None, format!("Lengthen the shorter nets in {} (serpentine on the same layer) until they match within {:.0} ps (about {:.1} mm on an inner layer).", gname, limit_ps, limit_ps * C_MM_PS / er.sqrt()), json!({"type": "review", "group": gname, "why": "length matching"}))); }
}
out["lengthGroups"] = json!(groups);
out
}
#[cfg(test)] mod tests {
use super::*;
#[test] fn ipc2221_known_point() {
// 1 oz (35 µm), 1 mm wide external trace: about 2.5–3 A for a 10 °C rise per the IPC-2221 chart
let area: f64 = 1.0 * 0.035 / 0.00064516; let i = 0.048 * 10f64.powf(0.44) * area.powf(0.725);
assert!(i > 2.3 && i < 3.2, "{i}");
}
#[test] fn stub_quarter_wave() {
// a 1.0 mm stub in εr 4.3: c / (4 · 1 mm · √4.3) ≈ 36 GHz
let f = C_MM_PS * 1e12 / (4.0 * 1.0 * 4.3f64.sqrt()); assert!((f / 1e9 - 36.1).abs() < 0.5, "{}", f / 1e9);
}
}