app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
0.10.4: 4-switch buck-boost converter model (issue 24); local impedance reference per section (issue 23)
13 files changed
+336−104
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.10.3"+version = "0.10.4" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.10.3"+version = "0.10.4" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
docs/release-0.10.4.mdadded+18@@ -0,0 +1,18 @@+# adom-fields 0.10.4++- **4-switch buck-boost converters (issue 24).** The converter model no longer assumes a single-switch-node buck.+ - **Through series parts:** 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`.+ - **Switch legs:** a switch leg is every part on its node (spec `sources.<node>`), so a discrete half-bridge's hot loop runs from the high side's input pin to the low side's ground pin.+ - **Buck-boost:** an inductor between two switch nodes is a 4-switch buck-boost.+ - **Buck mode** is evaluated at the input voltage: the input capacitors on the input leg.+ - **Boost mode** is evaluated at spec `converter.vinMin`: the output capacitors on the output leg, the inductor at Iout/(1−D).+ - Each mode reports its duty, inductor current and ripple, capacitor ripple, hot loop and emissions in `review.converter.modes`. Without `vinMin`, a note says what the boost mode needs.+ - **Never null:** a switch node that cannot be modelled now returns `{"unsupported": "<why>"}`, never `null`.+ - **Unchanged elsewhere:** a plain buck gives exactly the numbers and findings it gave before.+- **Impedance reference found locally (issue 23).**+ - **What changed:** each trace segment's reference plane is now found under it. On each side, it is the nearest layer whose ground or supply copper covers 90 % of the segment (centre and both edges, every 0.25 mm). It used to be the nearest layer that a whole-layer statistic called a plane.+ - **Every row names its reference:** the layer, net, distance and coverage.+ - **Partial references get no number:** a nearer layer covering only part of a segment makes it "mixed reference", and nothing under it makes it "no reference"; neither gets an impedance.+ - **Transitions stay separate:** points within 0.3 mm of a via, a through-hole or the trace's own pads are left out of the coverage, and a segment that is all transition counts in `transitionMm`.+ - **Target findings:** they use only fully referenced sections, and a target net with unresolved sections gets its own finding.+ - **The check:** on a four-layer test board, a USB pair over a local In2 GND strip now reads the same 86.2 Ω whether or not unrelated In2 copper elsewhere crosses the whole-layer plane threshold. It read 110.9 or 86.2 before. With the strip broken, the pair is reported as mixed, not given an ideal-plane value.
package.json+2−2@@ -1,8 +1,8 @@ { "slug": "adom-fields", "type": "app",- "version": "0.10.3",- "changelog": "0.10.3: KiCad 8 boards: numbered track and via nets resolve through the net table (issue 21); same-net copper closer than a solve cell is no longer joined by the grid, and the spots are reported (issue 22).",+ "version": "0.10.4",+ "changelog": "0.10.4: 4-switch buck-boost converters (inductor through a current shunt, buck and boost modes, unsupported instead of null; issue 24); impedance reference found under each trace, reported per section, mixed or missing references not given an impedance (issue 23).", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
page.json+2−2@@ -1,8 +1,8 @@ { "slug": "adom-fields", "type": "app",- "version": "0.10.3",- "changelog": "0.10.3: KiCad 8 boards: numbered track and via nets resolve through the net table (issue 21); same-net copper closer than a solve cell is no longer joined by the grid, and the spots are reported (issue 22).",+ "version": "0.10.4",+ "changelog": "0.10.4: 4-switch buck-boost converters (inductor through a current shunt, buck and boost modes, unsupported instead of null; issue 24); impedance reference found under each trace, reported per section, mixed or missing references not given an impedance (issue 23).", "title": "Adom Fields", "brief": "Adom Fields: where the current and the heat go on your board.", "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",
skills/adom-fields-si/SKILL.md+15@@ -10,6 +10,21 @@ Parent skill: **adom-fields** Turn on `si` when the board has differential pairs (USB, Ethernet, LVDS, MIPI), clocks or fast buses (SPI, SDIO, QSPI), traces over about 50 mm, or impedance targets. +## The reference under each trace (0.10.4)++Each trace segment's reference is found locally, not from whole-layer statistics:+- **The rule:** on each side, the nearest layer whose ground or supply copper covers 90 % of the segment, sampled at its centre and both edges every 0.25 mm.+- **What each `review.impedance` row carries:**+ - `status`;+ - `reference.below` and `reference.above`, each with layer, net, `distanceMm` and coverage;+ - `layersUnder`, when the section is unresolved.+- **Unresolved sections get no `z0`:**+ - **mixed reference:** a nearer layer covers only part of it;+ - **no reference:** no ground or supply copper under it at all.+- **Transitions are separate:** points within 0.3 mm of a via, a through-hole or the trace's own pads are not counted, and stretches that are all transition sum to the net's `transitionMm`.+- **Target findings** use only referenced sections. A target net or pair with unresolved sections gets a low `impedance` or `diff-impedance` finding naming them.+- **The TDR** takes unresolved stretches as 50 Ω and says so (`assumedMm`).+ ## Reflections and TDR (0.9.6) `review.reflections` checks the nets where edges matter: spec `impedance` targets, `clocks`, `lengthGroups` members, and any net in spec `tdr: {net: {driverOhm, riseNs, loadPf, from, to}}`.
skills/adom-fields-spec/SKILL.md+8@@ -56,3 +56,11 @@ chips sit over the fields. - **Fan levels** (0.8.0): `fanLevels: [0, 0.25, 0.5, 0.75, 1]` solves each level fully. h on every exposed face (board, bodies, a packageCooling sink) follows (h_still³ + (h_forced·sⁿ)³)^⅓, where s is the fraction of full speed, s = 1 gives the moving-air h, and n is `fanExponent` (0.5). `fields.json` `airStates` gives each level's h, chip rises and bodies. The viewer's Fan control (Off, 25, 50, 75, 100 %) switches the board and the bodies together, on one colour scale, so the levels compare by eye. `ui view --air fan50` drives it. - **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md. - **`adom-fields thermal3d --spec --out`** (PR 12): axis-aligned 3D solids with explicit materials, contacts and convection for an off-board package and finned sink, with a GLB of the surface temperatures. Not CFD. See docs/thermal3d.md.++## The converter block (0.10.4)++`converter: {inductor: "L1", vinMin: 21}`, both optional:+- **`inductor`** names the inductor when its path cannot be traced. The tracing already follows current shunts, ferrites and 0 Ω links.+- **`vinMin`** is the lowest input. For a 4-switch buck-boost (the inductor between two switch nodes, each listed in `switching` with its switches in `sources`), it evaluates the boost mode alongside the buck mode at the spec's input voltage.+- **The record:** `review.converter` has `topology`, `switchNodes`, `seriesParts` and `modes` (each with duty, inductor current, ripple, hot loop and emissions). It is `{"unsupported": why}` when the board cannot be modelled.+
src/converter.rsadded+194@@ -0,0 +1,194 @@+//! 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> {+ let t = s.trim().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, r: &str) -> bool {+ 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 Some(gnd) = f.net_names.iter().find(|n| is_ground(n)).cloned() else { 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, r) && nets_of(r).len() == 2 && nets_of(r).contains(n)) { for m in nets_of(r) { if !nets.contains(&m) && m != gnd { 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, 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 && *n != gnd && 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) }+ };+ 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<_>>(),+ "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)] { assert!((ohms(s).unwrap() - v).abs() < 1e-12, "{s}"); }+ assert!(ohms("DNP").is_none());+ }+}
src/emc.rs+2−72@@ -40,78 +40,8 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va 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 ------------------------------- let sw = spec.switching.keys().next().cloned().or_else(|| f.net_names.iter().find(|n| is_switch_name(n)).cloned());- let mut conv: Option<Value> = None;- if let (Some(sw), Some(gnd)) = (sw.clone(), gnd.clone()) {- let lref = refs.iter().find(|r| r.starts_with('L') && nets_of(r).contains(&sw)).cloned();- let out_net = lref.as_ref().and_then(|l| nets_of(l).into_iter().find(|n| *n != sw));- let swref = spec.sources.get(&sw).and_then(|v| v.first().cloned()).or_else(|| refs.iter().find(|r| r.starts_with('U') && nets_of(r).contains(&sw)).cloned());- let in_net = swref.as_ref().and_then(|u| { let mut c: Vec<String> = nets_of(u).into_iter().filter(|n| *n != sw && *n != gnd && rail(n).is_some()).collect(); c.sort_by(|a, z| rail(z).partial_cmp(&rail(a)).unwrap()); c.into_iter().next() });- if let (Some(lref), Some(out_net), Some(swref), Some(in_net)) = (lref, out_net, swref, in_net) {- // each capacitor at its rail's DC bias (crate::caps): an X5R loses most of its capacitance at 12 V- 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 cin: Vec<(String, f64)> = cin_m.iter().map(|(r, m)| (r.clone(), m.c_eff)).collect(); let cout: Vec<(String, f64)> = cout_m.iter().map(|(r, m)| (r.clone(), m.c_eff)).collect();- 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 l = b.values.get(&lref).and_then(|v| parse_value(v, 'H'));- let (vin, vout) = (rail(&in_net), rail(&out_net));- let iout = spec.loads.get(&out_net).map(|l| l.amps);- let swcfg = spec.switching.get(&sw);- let fsw = swcfg.and_then(|s| s.freqHz).unwrap_or(500e3);- let tr = swcfg.and_then(|s| s.riseNs).unwrap_or(8.0) * 1e-9;- if let (Some(l), Some(vin), Some(vout), Some(iout)) = (l, vin, vout, iout) {- if vout < vin && vout > 0.0 {- let t = 1.0 / fsw; let dcy = vout / vin; let di = (vin - vout) * dcy * t / l;- let ccin: f64 = cin.iter().map(|c| c.1).sum(); let ccout: f64 = cout.iter().map(|c| c.1).sum();- // the capacitive part plus the ESR part (added, a conservative upper bound): an electrolytic's ESR dominates- 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 * dcy * (1.0 - dcy) / (fsw * ccin) + iout * esr_par(&cin_m) } else { f64::NAN };- let ratio = di / iout;- // the hot loop of each input capacitor: its rail pad -> the switch's input pin -> its ground pin -> its ground pad- let (vin_pins, g_pins) = (pads_of(&swref, &in_net), pads_of(&swref, &gnd));- let mut loops: Vec<(String, f64, f64, [Pad; 4])> = Vec::new();- for (r, c) in &cin { let (cv, cg) = (pads_of(r, &in_net), pads_of(r, &gnd)); if cv.is_empty() || cg.is_empty() || vin_pins.is_empty() || g_pins.is_empty() { continue; } let uv = near(&vin_pins, cv[0].x, cv[0].y).unwrap(); let ug = near(&g_pins, 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(), *c, a, [cv[0].clone(), uv, ug, cg[0].clone()])); }- let hf = loops.iter().min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).cloned();- let ideal = if let (Some(uv), Some(_)) = (vin_pins.first(), g_pins.first()) { let ug = near(&g_pins, uv.x, uv.y).unwrap(); Some(((uv.x - ug.x).hypot(uv.y - ug.y) * 0.8, (*uv).clone(), ug)) } else { None };- // the hot loop as a loop antenna (Ott), trapezoid harmonics, FCC class B at 3 m- let mut margin = None;- let mut emission_sets: Vec<Value> = Vec::new(); let mut emission_label = String::from("FCC 15.109 class B");- if let Some(h) = &hf {- let (a, ipk) = (h.2 * 1e-6, iout + di / 2.0); let sinc = |x: f64| if x == 0.0 { 1.0 } else { x.sin() / x };- // the chosen limit set (spec emcLimit, FCC class B by default) and every other one, each a margin (crate::limits)- let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());- 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 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 * dcy * (std::f64::consts::PI * n as f64 * dcy).pipe(sinc).abs() * (std::f64::consts::PI * n as f64 * tr / t).pipe(sinc).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; }- emission_sets = 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();- emission_label = crate::limits::label(&chosen);- margin = Some((worst, wf));- if let Some((ia, uv, ug)) = &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 allows {:.1} mm²", h.0, h.0, swref, gnd, h.2, ia), Some([(h.3[0].x + h.3[3].x) / 2.0, (h.3[0].y + h.3[3].y) / 2.0]), Some(&in_net), Some(&h.0),- format!("Move {} so its pads sit straight across {} pin {} ({}) and pin {} ({}), on the same layer with no vias in the loop: about {:.0} dB less radiated noise.", h.0, swref, uv.pad, in_net, ug.pad, gnd, gain),- json!({"type": "move", "ref": h.0, "toPins": [format!("{}.{}", swref, uv.pad), format!("{}.{}", swref, ug.pad)], "targetMm2": (ia * 10.0).round() / 10.0, "currentMm2": (h.2 * 10.0).round() / 10.0, "gainDb": (gain * 10.0).round() / 10.0})));- }- }- 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)", worst, emission_label, wf / 1e6), Some([h.3[1].x, h.3[1].y]), Some(&sw), Some(&swref), "Shrink the hot loop (the input capacitor across the switch's 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!("{}.{}", swref, uv.pad), format!("{}.{}", swref, ug.pad)]), "why": "emissions margin"}))); }- }- // the converter itself: the inductor ripple ratio and the output ripple- if !(0.15..=0.5).contains(&ratio) { issues.push(issue("low", "inductor-ripple", format!("{}: the ripple is {:.0} % of the load current (typical design: 20 to 40 %)", lref, ratio * 100.0), b.pads.iter().find(|p| p.reference == lref).map(|p| [p.x, p.y]), Some(&sw), Some(&lref), if ratio > 0.5 { format!("A larger inductance (about {:.0} µH for 30 %) or a higher switching frequency.", (vin - vout) * dcy * t / (0.3 * iout) * 1e6) } else { format!("A smaller inductance (about {:.0} µH for 30 %) responds faster and is smaller.", (vin - vout) * dcy * t / (0.3 * iout) * 1e6) }, json!({"type": "review", "what": "inductor value", "ref": lref, "suggestUh": ((vin - vout) * dcy * t / (0.3 * iout) * 1e7).round() / 10.0}))); }- if rip_out.is_finite() && rip_out > 0.01 * vout { issues.push(issue("medium", "output-ripple", format!("{}: output ripple about {:.1} mV, over 1 % of {} V", out_net, rip_out * 1e3, vout), None, Some(&out_net), None, format!("Add output capacitance (about {:.0} µF more for 1 %) or low-ESR ceramics close to {}.", (di / (8.0 * fsw * 0.01 * vout) - ccout) * 1e6, lref), json!({"type": "add-part", "what": "output capacitance", "net": out_net, "uF": ((di / (8.0 * fsw * 0.01 * vout) - ccout) * 1e6).ceil()}))); }- 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<_>>()) };- conv = Some(json!({"inCaps": capj(&cin_m), "outCaps": capj(&cout_m), "switchNet": sw, "switch": swref, "inductor": lref, "inNet": in_net, "outNet": out_net, "vin": vin, "vout": vout, "iout": iout, "fswHz": fsw, "riseNs": tr * 1e9, "duty": dcy, "rippleA": di, "rippleRatio": ratio, "lUh": l * 1e6, "cinUf": ccin * 1e6, "coutUf": ccout * 1e6, "rippleInMv": rip_in * 1e3, "rippleOutMv": rip_out * 1e3,- "hotLoops": loops.iter().map(|(r, c, a, _)| json!({"cap": r, "uF": c * 1e6, "mm2": (a * 100.0).round() / 100.0})).collect::<Vec<_>>(), "hotLoop": hf.as_ref().map(|h| json!({"cap": h.0, "mm2": (h.2 * 100.0).round() / 100.0})), "idealMm2": ideal.as_ref().map(|i| (i.0 * 100.0).round() / 100.0),- "emissions": margin.map(|(m, fr)| json!({"worstMarginDb": (m * 10.0).round() / 10.0, "atMHz": (fr / 1e6).round(), "limit": format!("{emission_label}, 3 m"), "model": "Ott differential-mode loop; no cables", "allLimits": emission_sets}))}));- }- }- }- }- out["converter"] = conv.unwrap_or(Value::Null);+ // 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();
src/fields.rs+7@@ -51,6 +51,9 @@ pub struct Spec { #[serde(default)] pub tdr: BTreeMap<String, crate::tdr::TdrCfg>, /// the connectors that face the outside world (a user cable, a hot-plugged lead): the esd module checks only these when given #[serde(default)] pub external: Vec<String>,+ /// the switching converter, where the board alone does not say: {inductor: "L1", vinMin: 21} (vinMin evaluates a+ /// buck-boost's boost mode)+ #[serde(default)] pub converter: Option<ConverterCfg>, #[serde(default)] pub lengthGroups: BTreeMap<String, crate::checks::LengthGroup>, /// fan levels to solve besides still air (0) and full moving air (1), as fractions of full speed, e.g. [0.25, 0.5, 0.75] #[serde(default)] pub fanLevels: Vec<f64>,@@ -755,3 +758,7 @@ fn current_peak_and_clip(grid: &mut Grid, fraction: &Grid) -> Option<(f64, usize assert_eq!(current_peak_and_clip(&mut dens, &frac), Some((12.0, 1))); frac.v.fill(0.0); assert_eq!(current_peak_and_clip(&mut dens, &frac), None); } }++#[allow(non_snake_case)]+#[derive(Deserialize, Clone, Debug, Default)]+pub struct ConverterCfg { #[serde(default)] pub inductor: Option<String>, #[serde(default)] pub vinMin: Option<f64> }
src/main.rs+2−2@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod caps; mod checks; mod verify; mod compliance; mod report; mod inputs; mod tdr; mod modules; mod slim; mod peec; mod pieces; mod cm; mod conducted; mod xsec; mod limits; mod rf; mod solids; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor;+mod caps; mod checks; mod verify; mod compliance; mod report; mod inputs; mod tdr; mod modules; mod slim; mod peec; mod pieces; mod converter; mod cm; mod conducted; mod xsec; mod limits; mod rf; mod solids; mod pours; mod probe; mod terminals; mod barrel; mod package_cooling; mod thermal3d; mod eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use std::path::PathBuf;@@ -300,7 +300,7 @@ fn main() { ok(&format!("{} nets solved, {} hot parts, {} issue(s), in {} s: {}", nets, s["chips"].as_array().map(|a| a.len()).unwrap_or(0), issues.len(), s["seconds"], out.join("fields.json").display()), &[ format!("Peak current density {:.0} A/mm²; peak rise {:.0} °C still air, {:.0} °C moving air, budget {:.0} °C; Joule loss {} W on top of {} W from the tabs.", s["nets"].as_array().into_iter().flatten().filter_map(|n| n["peakJ"].as_f64()).fold(0.0, f64::max), s["heat"]["peakRiseC"].as_f64().unwrap_or(0.0), s["heat"]["peakRiseMovingC"].as_f64().unwrap_or(0.0), s["heat"]["budgetC"].as_f64().unwrap_or(0.0), s["heat"]["jouleW"], s["heat"]["tabW"]), issues.iter().take(3).map(|i| format!("{} {}: {}", i["severity"].as_str().unwrap_or(""), i["kind"].as_str().unwrap_or(""), i["what"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" | "),- { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c.is_null() { "no switching converter modelled".to_string() } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; {}{} island(s), edge stitched {} %", if g["offsetMv"].is_number() { format!("ground offset {} mV, ", g["offsetMv"]) } else { "ground offset not solved (ground is not a loaded net), ".to_string() }, g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).take(5).map(|x| format!("; {} {}% without ground under it", x["net"].as_str().unwrap_or(""), x["unreferencedPct"])).collect::<String>()).unwrap_or_default()) },+ { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c["fswHz"].is_null() { match c["unsupported"].as_str() { Some(w) => format!("switching converter not modelled: {w}"), None => "no switching converter modelled".to_string() } } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; {}{} island(s), edge stitched {} %", if g["offsetMv"].is_number() { format!("ground offset {} mV, ", g["offsetMv"]) } else { "ground offset not solved (ground is not a loaded net), ".to_string() }, g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).take(5).map(|x| format!("; {} {}% without ground under it", x["net"].as_str().unwrap_or(""), x["unreferencedPct"])).collect::<String>()).unwrap_or_default()) }, if s["review"]["pdn"]["ran"] == false { String::new() } else { let all = s["review"]["pdn"]["rails"].as_array().cloned().unwrap_or_default(); let rs: Vec<Value> = all.iter().filter(|r| r["skipped"].is_null()).cloned().collect(); let sk: String = all.iter().filter(|r| !r["skipped"].is_null()).map(|r| format!("; {} not checked: {}", r["net"].as_str().unwrap_or(""), r["skipped"].as_str().unwrap_or(""))).collect(); if rs.is_empty() { format!("PDN: no rail checked{}.", sk) } else { format!("PDN (|Z| seen from each load pin against V·tol/step): {}{}.", rs.iter().map(|r| format!("{} {}× target at {} Hz (from {})", r["net"].as_str().unwrap_or(""), r["worst"]["ratio"], r["worst"]["hz"], r["load"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "), if rs.iter().all(|r| r["spice"]["ran"] == true) { format!("; ngspice agrees within {} %{}", rs.iter().map(|r| r["spice"]["maxErrPct"].as_f64().unwrap_or(0.0)).fold(0.0, f64::max), sk) } else { sk }) } }, if s["review"]["compliance"]["ran"] == false { String::new() } else { let c = &s["review"]["compliance"]; let v = |t: &str| { let x = &c[t.split(' ').next().unwrap_or(t)]; if x["verdict"] == "not modelled" { format!("{t} not modelled") } else { format!("{t} {} (worst {} {}…{} dB)", x["verdict"].as_str().unwrap_or(""), x["worst"].as_str().unwrap_or(""), x["worstBand"][0], x["worstBand"][1]) } }; let f: Vec<String> = c["toSixDb"][0]["fixes"].as_array().into_iter().flatten().map(|x| x["id"].as_str().unwrap_or("").to_string()).collect(); format!("Compliance ({}): {}; {}{}.{} review.compliance has every source, the fixes by dB and a bench plan.", c["limit"].as_str().unwrap_or(""), v("radiated"), v("conducted"), if c["antenna"]["verdict"] == "not modelled" { String::new() } else { format!("; {}", v("antenna port")) }, if f.is_empty() { String::new() } else { format!(" Cheapest set to {}: {}.", if c["toSixDb"][0]["reaches6Db"] == true { "6 dB everywhere" } else { "get closest to 6 dB" }, f.join(", ")) }) }, { let i = &s["review"]["inputs"]; let top: Vec<String> = i["items"].as_array().into_iter().flatten().filter(|x| x["matters"] == "high").take(4).map(|x| format!("{}{}", x["what"].as_str().unwrap_or(""), x["part"]["mpn"].as_str().filter(|m| !m.is_empty()).map(|m| format!(" ({m})")).unwrap_or_default())).collect(); format!("Inputs assumed: {} that matter most, {} medium, {} low{}. review.inputs names each datasheet field and spec key; specPatch is the fragment to fill.", i["counts"]["high"], i["counts"]["medium"], i["counts"]["low"], if top.is_empty() { String::new() } else { format!(": {}", top.join("; ")) }) },
src/tdr.rs+6−2@@ -88,7 +88,7 @@ pub fn review(b: &Board, spec: &Spec, xs: &Value, out: &std::path::Path, issues: let Some((rn, rp)) = to else { res.push(json!({"net": net, "ran": false, "why": "one pad only"})); continue }; let Some((segs, vias, branches)) = path(b, net, dp, rp) else { res.push(json!({"net": net, "ran": false, "why": format!("no copper path from {dn} to {rn} over segments and vias (a pour or an unrouted net)")})); continue }; let rows = rows_of(net);- let mut els: Vec<El> = Vec::new(); let mut vi = 0; let mut last_layer = String::new();+ let mut els: Vec<El> = Vec::new(); let mut vi = 0; let mut last_layer = String::new(); let mut assumed = 0.0f64; for (si, _) in &segs { let t = &b.tracks[*si]; if !last_layer.is_empty() && last_layer != t.layer && vi < vias.len() {@@ -97,7 +97,10 @@ pub fn review(b: &Board, spec: &Spec, xs: &Value, out: &std::path::Path, issues: } last_layer = t.layer.clone(); let len = (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1); if len < 1e-6 { continue; }- let row = rows.iter().find(|r| r["layer"] == t.layer.as_str() && (r["widthMm"].as_f64().unwrap_or(0.0) - t.w).abs() < 1e-3);+ // the solved impedance for this layer and width; a stretch with no reference under it has none, and is taken as+ // 50 Ω with the length counted in `assumedMm`+ let row = rows.iter().find(|r| r["layer"] == t.layer.as_str() && (r["widthMm"].as_f64().unwrap_or(0.0) - t.w).abs() < 1e-3 && r["z0"].is_number());+ if row.is_none() { assumed += (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1); } let (z0, ps) = row.map(|r| (r["z0"].as_f64().unwrap_or(50.0), r["psPerMm"].as_f64().unwrap_or(6.7))).unwrap_or((50.0, 6.7)); // adjacent segments of one width and layer are one line if let Some(El::Line { z0: z, td, len: l, layer, w }) = els.last_mut() { if *layer == t.layer && (*w - t.w).abs() < 1e-6 && (*z - z0).abs() < 1e-9 { *td += len * ps * 1e-12; *l += len; continue; } }@@ -113,6 +116,7 @@ pub fn review(b: &Board, spec: &Spec, xs: &Value, out: &std::path::Path, issues: let target = spec.impedance.get(net).copied(); let short = td < tr / 6.0; let mut row = json!({"net": net, "ran": true, "driver": dn, "receiver": rn, "lengthMm": r1(len), "delayPs": r1(td * 1e12), "segments": els.iter().filter(|e| matches!(e, El::Line { .. })).count(), "vias": els.iter().filter(|e| matches!(e, El::Via { .. })).count(), "branches": branches, "edgeNs": (tr * 1e12).round() / 1e3, "driverOhm": rs, "loadPf": cl * 1e12, "zMin": zs.iter().cloned().fold(f64::MAX, f64::min), "zMax": zs.iter().cloned().fold(0.0, f64::max), "targetOhm": target, "electricallyShort": short});+ if assumed > 0.0 { row["assumedMm"] = json!(r1(assumed)); row["assumedNote"] = json!("stretches with no solved impedance (no reference plane under them, see review.impedance) are taken as 50 Ω"); } if short { row["note"] = json!(format!("{:.0} ps of line against a {:.0} ps edge: shorter than a sixth of it, so reflections stay inside the edge", td * 1e12, tr * 1e12)); } if has_spice && !els.is_empty() { // TDR: 50 Ω, a 35 ps step, long enough to see the far end
src/xsec.rs+78−22@@ -140,19 +140,58 @@ pub fn review(b: &Board, spec: &Spec, stack: &Value, issues: &mut Vec<Issue>) -> let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() }; if real.len() < 2 { return json!({"ran": false}); } 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 };- let planes: Vec<bool> = real.iter().map(|l| stack["layers"].as_array().and_then(|a| a.iter().find(|x| x["layer"] == l.as_str())).map_or(false, |x| !x["plane"].is_null())).collect();- // a two-layer board with no plane detected: the other outer layer is the reference- let plane_of = |i: usize| -> bool { planes[i] || (real.len() == 2) };+ let _ = stack; // the whole-layer plane classification is no longer the reference (issue 23): each segment's is found locally let er = spec.epsilonR.unwrap_or(4.3); let mask = if spec.rf.noSolderMask == Some(true) { None } else { Some((20e-6, 3.6)) };- let xs_for = |li: usize, w: f64, gap: Option<f64>| -> Option<Xs> {+ // the cross-section with its references given: `below` the nearest reference layer on one side, `above` the one on+ // the other side (a stripline), each a real-layer index+ let xs_for = |li: usize, w: f64, gap: Option<f64>, below_i: usize, above_i: Option<usize>| -> Xs { let t = spec.copperUmByLayer.get(&real[li]).copied().unwrap_or(spec.copperUm) * 1e-6;- let below_i = (0..real.len()).filter(|j| *j != li && plane_of(*j)).filter(|j| if li == 0 { true } else if li == real.len() - 1 { true } else { true }).min_by(|a, z2| (z[*a] - z[li]).abs().partial_cmp(&(z[*z2] - z[li]).abs()).unwrap())?; let d1 = (z[below_i] - z[li]).abs() * 1e-3; let outer = li == 0 || li == real.len() - 1;- // a stripline: a plane on each side- let other = if outer { None } else { (0..real.len()).filter(|j| plane_of(*j) && ((z[*j] - z[li]) * (z[below_i] - z[li])) < 0.0).min_by(|a, z2| (z[*a] - z[li]).abs().partial_cmp(&(z[*z2] - z[li]).abs()).unwrap()) };- Some(Xs { w: w * 1e-3, t, gap: gap.map(|g| g * 1e-3), below: d1.max(t), above: other.map(|j| ((z[j] - z[li]).abs() * 1e-3 - t).max(t)), er, mask: if outer { mask } else { None } })+ Xs { w: w * 1e-3, t, gap: gap.map(|g| g * 1e-3), below: d1.max(t), above: above_i.map(|j| ((z[j] - z[li]).abs() * 1e-3 - t).max(t)), er, mask: if outer && above_i.is_none() { mask } else { None } }+ };+ // ---- the reference copper under each trace, found locally (issue 23) -----------------------------------------------+ // ground and supply copper on every real layer, rasterised at 0.1 mm; a trace's reference on a layer is the share of+ // its length that layer's reference copper covers under the trace's centre and both edges+ let refnet = |n: &str| is_ground(n) || rail_from_name(n).is_some() || spec.voltages.contains_key(n);+ let mut ras = crate::raster::Raster::new(b, 0.1); ras.draw_board(b);+ let (rw, rh, rres, rx0, ry0) = (ras.w, ras.h, ras.res, ras.x0, ras.y0);+ let refmask: Vec<Vec<u16>> = real.iter().map(|l| { let mut m = vec![0u16; rw * rh]; let mut names: Vec<&String> = ras.masks.keys().filter(|(n, ll)| ll == l && refnet(n)).map(|(n, _)| n).collect(); names.sort();+ for (k, n) in names.iter().enumerate() { if let Some(mm) = ras.masks.get(&((*n).clone(), l.clone())) { for i in 0..rw * rh { if mm[i] != 0 && m[i] == 0 { m[i] = k as u16 + 1; } } } } m }).collect();+ let refnames: Vec<Vec<String>> = real.iter().map(|l| { let mut names: Vec<String> = ras.masks.keys().filter(|(n, ll)| ll == l && refnet(n)).map(|(n, _)| n.clone()).collect(); names.sort(); names }).collect();+ let at = |k: usize, x: f64, y: f64| -> u16 { let (c, r) = (((x - rx0) / rres).floor(), ((y - ry0) / rres).floor()); if c < 0.0 || r < 0.0 || c as usize >= rw || r as usize >= rh { 0 } else { refmask[k][r as usize * rw + c as usize] } };+ // transitions are not reference: a point within 0.3 mm of a via's or a through-hole's ring (any net: its antipad cuts+ // the planes) or of the trace's own pads is left out of the coverage, and a segment that is all transition gets no+ // verdict (its length is counted in `transitionMm`)+ let holes: Vec<(f64, f64, f64)> = b.vias.iter().chain(b.plated_holes.iter()).map(|v| (v.x, v.y, v.size / 2.0 + 0.3)).collect();+ let transition = |t: &crate::board::Track, x: f64, y: f64| -> bool { holes.iter().any(|(hx, hy, r)| (x - hx).hypot(y - hy) <= *r) || b.pads.iter().any(|p| p.net == t.net && (x - p.x).hypot(y - p.y) <= (p.area / std::f64::consts::PI).sqrt() + 0.3) };+ let samples = |t: &crate::board::Track| -> usize { let len = (t.b.0 - t.a.0).hypot(t.b.1 - t.a.1); let steps = (len / 0.25).ceil().max(1.0) as usize; (0..=steps).filter(|i| { let f = *i as f64 / steps as f64; !transition(t, t.a.0 + f * (t.b.0 - t.a.0), t.a.1 + f * (t.b.1 - t.a.1)) }).count() };+ // each other layer's coverage of a set of segments, and the net covering most of it+ let coverage = |segs: &[&crate::board::Track], k: usize| -> (f64, Option<String>) {+ let (mut n, mut hit) = (0usize, 0usize); let mut by: std::collections::BTreeMap<u16, usize> = Default::default();+ for t in segs { let (dx, dy) = (t.b.0 - t.a.0, t.b.1 - t.a.1); let len = dx.hypot(dy); let steps = (len / 0.25).ceil().max(1.0) as usize; let (nx, ny) = if len > 0.0 { (-dy / len, dx / len) } else { (0.0, 1.0) };+ for i in 0..=steps { let f = i as f64 / steps as f64; let (x, y) = (t.a.0 + f * dx, t.a.1 + f * dy); if transition(t, x, y) { continue; } n += 1;+ let ids = [at(k, x, y), at(k, x + nx * t.w / 2.0, y + ny * t.w / 2.0), at(k, x - nx * t.w / 2.0, y - ny * t.w / 2.0)];+ if ids.iter().all(|i| *i != 0) { hit += 1; *by.entry(ids[0]).or_default() += 1; } } }+ let net = by.iter().max_by_key(|x| x.1).map(|(id, _)| refnames[k][*id as usize - 1].clone());+ (if n == 0 { 0.0 } else { hit as f64 / n as f64 }, net)+ };+ // the references of a set of segments on layer li: on each side the nearest layer covering 90 % of them; a nearer layer+ // covering part of them makes the section mixed, nothing at all leaves it with no reference+ let reference = |segs: &[&crate::board::Track], li: usize| -> Result<(usize, Option<usize>, Value), (String, Value)> {+ let mut sides: Vec<Option<(usize, f64, Option<String>)>> = Vec::new(); let mut partial: Vec<String> = Vec::new(); let mut seen = Vec::new();+ for side in [-1.0f64, 1.0] {+ let mut cand: Vec<usize> = (0..real.len()).filter(|j| *j != li && (z[*j] - z[li]) * side > 0.0).collect(); cand.sort_by(|a, b2| (z[*a] - z[li]).abs().partial_cmp(&(z[*b2] - z[li]).abs()).unwrap());+ let mut chosen = None;+ for j in cand { let (c, net) = coverage(segs, j); seen.push(json!({"layer": real[j], "coverage": (c * 100.0).round() / 100.0, "net": net}));+ if c >= 0.9 { chosen = Some((j, c, net)); break; } if c >= 0.1 { partial.push(format!("{} covers {:.0} %", real[j], c * 100.0)); break; } }+ sides.push(chosen);+ }+ let mut refs: Vec<(usize, f64, Option<String>)> = sides.into_iter().flatten().collect(); refs.sort_by(|a, b2| (z[a.0] - z[li]).abs().partial_cmp(&(z[b2.0] - z[li]).abs()).unwrap());+ let rec = |r: &(usize, f64, Option<String>)| json!({"layer": real[r.0], "net": r.2, "distanceMm": ((z[r.0] - z[li]).abs() * 1000.0).round() / 1000.0, "coverage": (r.1 * 100.0).round() / 100.0});+ if !partial.is_empty() { return Err((format!("mixed reference: {}", partial.join(", ")), json!(seen))); }+ match refs.first() { None => Err(("no reference: no ground or supply copper under it on any layer".into(), json!(seen))), Some(r0) => Ok((r0.0, refs.get(1).map(|r| r.0), json!({"below": rec(r0), "above": refs.get(1).map(rec)}))) } }; let signal = |n: &str| !n.is_empty() && !is_ground(n) && rail_from_name(n).is_none() && !spec.voltages.contains_key(n) && !spec.loads.contains_key(n) && !n.starts_with("unconnected-"); let mut cache: std::collections::BTreeMap<String, Zr> = Default::default();@@ -161,19 +200,31 @@ pub fn review(b: &Board, spec: &Spec, stack: &Value, issues: &mut Vec<Issue>) -> for t in b.tracks.iter().filter(|t| signal(&t.net)) { by_net.entry(t.net.clone()).or_default().push(t); } let targets = &spec.impedance; for (net, ts) in &by_net {- let mut groups: std::collections::BTreeMap<(String, i64), f64> = Default::default();- for t in ts { *groups.entry((t.layer.clone(), (t.w * 1000.0).round() as i64)).or_default() += (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1); }+ // group the segments by layer, width and their own reference (a long net can cross several)+ let mut groups: std::collections::BTreeMap<(String, i64, String), Vec<&crate::board::Track>> = Default::default();+ let mut refs_of: std::collections::BTreeMap<String, Result<(usize, Option<usize>, Value), (String, Value)>> = Default::default();+ let mut transition_mm = 0.0;+ for t in ts { let Some(li) = real.iter().position(|l| *l == t.layer) else { continue }; if samples(t) == 0 { transition_mm += (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1); continue; } let r = reference(&[*t], li); let key = match &r { Ok((a, b2, _)) => format!("{a}:{b2:?}"), Err((w, _)) => w.clone() }; refs_of.entry(key.clone()).or_insert(r); groups.entry((t.layer.clone(), (t.w * 1000.0).round() as i64, key)).or_default().push(t); } let mut rows = Vec::new();- for ((layer, wum), len) in &groups {+ for ((layer, wum, key), segs) in &groups { let Some(li) = real.iter().position(|l| l == layer) else { continue };- let key = format!("{layer}:{wum}");- if !cache.contains_key(&key) { if let Some(x) = xs_for(li, *wum as f64 / 1000.0, None) { cache.insert(key.clone(), solve(&x)); } }- if let Some(zr) = cache.get(&key) { rows.push(json!({"layer": layer, "widthMm": *wum as f64 / 1000.0, "lengthMm": r1(*len), "z0": r1(zr.z0), "eeff": (zr.eeff * 100.0).round() / 100.0, "psPerMm": (zr.eeff.sqrt() / C0 * 1e9 * 100.0).round() / 100.0, "pfPerM": r1(zr.cpf_m), "nhPerM": r1(zr.lnh_m)})); }+ let len: f64 = segs.iter().map(|t| (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1)).sum();+ match &refs_of[key] {+ Ok((bi, ai, rec)) => {+ let ck = format!("{layer}:{wum}:{bi}:{ai:?}");+ if !cache.contains_key(&ck) { cache.insert(ck.clone(), solve(&xs_for(li, *wum as f64 / 1000.0, None, *bi, *ai))); }+ let zr = &cache[&ck];+ rows.push(json!({"layer": layer, "widthMm": *wum as f64 / 1000.0, "lengthMm": r1(len), "status": "ok", "reference": rec, "z0": r1(zr.z0), "eeff": (zr.eeff * 100.0).round() / 100.0, "psPerMm": (zr.eeff.sqrt() / C0 * 1e9 * 100.0).round() / 100.0, "pfPerM": r1(zr.cpf_m), "nhPerM": r1(zr.lnh_m)}));+ }+ Err((why, seen)) => rows.push(json!({"layer": layer, "widthMm": *wum as f64 / 1000.0, "lengthMm": r1(len), "status": why, "layersUnder": seen, "z0": Value::Null})),+ } }- if rows.is_empty() { continue; }+ if rows.is_empty() && transition_mm == 0.0 { continue; } let target = targets.get(net).copied();- if let Some(tz) = target { for r in &rows { let z0 = r["z0"].as_f64().unwrap_or(0.0); if (z0 - tz).abs() > 0.1 * tz { issues.push(Issue { severity: "medium".into(), kind: "impedance".into(), what: format!("{}: {:.0} Ω on {} at {:.3} mm wide, against a {:.0} Ω target", net, z0, r["layer"].as_str().unwrap_or(""), r["widthMm"].as_f64().unwrap_or(0.0), tz), at: None, layer: r["layer"].as_str().map(String::from), net: Some(net.clone()), reference: None, fix: "Change the trace width (wider lowers Z0) or the stackup's dielectric under it; the cross-section solve gives the width for the target.".into(), action: Some(json!({"type": "widen", "net": net, "why": "impedance target", "z0": z0, "target": tz})) }); } } }- nets_out.push(json!({"net": net, "segments": rows, "targetOhm": target}));+ if let Some(tz) = target { let unresolved: f64 = rows.iter().filter(|r| r["z0"].is_null()).filter_map(|r| r["lengthMm"].as_f64()).sum();+ if unresolved > 0.0 { issues.push(Issue { severity: "low".into(), kind: "impedance".into(), what: format!("{net}: {unresolved:.1} mm of it has a mixed or missing reference, so its impedance is not evaluated there ({})", rows.iter().filter(|r| r["z0"].is_null()).filter_map(|r| r["status"].as_str()).collect::<std::collections::BTreeSet<_>>().into_iter().collect::<Vec<_>>().join("; ")), at: None, layer: None, net: Some(net.clone()), reference: None, fix: "Route it over continuous ground (or a plane), or check that stretch with a field solver that models the actual reference; review.impedance lists each stretch and the copper under it.".into(), action: Some(json!({"type": "review", "net": net, "why": "impedance reference"})) }); }+ for r in rows.iter().filter(|r| r["z0"].is_number()) { let z0 = r["z0"].as_f64().unwrap_or(0.0); if (z0 - tz).abs() > 0.1 * tz { issues.push(Issue { severity: "medium".into(), kind: "impedance".into(), what: format!("{}: {:.0} Ω on {} at {:.3} mm wide over {} {} {:.3} mm away, against a {:.0} Ω target", net, z0, r["layer"].as_str().unwrap_or(""), r["widthMm"].as_f64().unwrap_or(0.0), r["reference"]["below"]["layer"].as_str().unwrap_or("?"), r["reference"]["below"]["net"].as_str().unwrap_or(""), r["reference"]["below"]["distanceMm"].as_f64().unwrap_or(0.0), tz), at: None, layer: r["layer"].as_str().map(String::from), net: Some(net.clone()), reference: None, fix: "Change the trace width (wider lowers Z0) or the stackup's dielectric under it; the cross-section solve gives the width for the target.".into(), action: Some(json!({"type": "widen", "net": net, "why": "impedance target", "z0": z0, "target": tz})) }); } } }+ nets_out.push(json!({"net": net, "segments": rows, "targetOhm": target, "transitionMm": r1(transition_mm)})); } // pairs: by name, with the median spacing of their parallel segments on a shared layer let mut pairs = Vec::new(); let mut seen: std::collections::BTreeSet<String> = Default::default();@@ -192,14 +243,19 @@ pub fn review(b: &Board, spec: &Spec, stack: &Value, issues: &mut Vec<Issue>) -> if gaps_mm.is_empty() { continue; } gaps_mm.sort_by(|x, y| x.partial_cmp(y).unwrap()); let gap = gaps_mm[gaps_mm.len() / 2]; let Some(li) = real.iter().position(|l| *l == layer) else { continue };- let Some(x) = xs_for(li, w, Some(gap)) else { continue };- let zr = solve(&x);+ let segs: Vec<&crate::board::Track> = ts.iter().chain(ps.iter()).filter(|t| t.layer == layer && (t.w - w).abs() < 1e-6).copied().collect();+ let target0 = spec.impedance.get(&format!("{net}/{p}")).copied().map(|t| (t, "spec")).or_else(|| diff_target(net));+ let (bi, ai, refrec) = match reference(&segs, li) { Ok(x) => x, Err((why, seen)) => {+ pairs.push(json!({"pair": [net, p], "layer": layer, "widthMm": w, "gapMm": (gap * 1000.0).round() / 1000.0, "status": why, "layersUnder": seen, "zdiff": Value::Null, "targetOhm": target0.map(|t| t.0), "standard": target0.map(|t| t.1)}));+ if target0.is_some() { issues.push(Issue { severity: "low".into(), kind: "diff-impedance".into(), what: format!("{net}/{p}: impedance not evaluated on {layer}: {why}"), at: None, layer: Some(layer.clone()), net: Some(net.clone()), reference: None, fix: "Route the pair over continuous ground, or check it with a field solver that models the actual reference; review.impedance.pairs lists the copper under it.".into(), action: Some(json!({"type": "review", "pair": [net, p], "why": "impedance reference"})) }); }+ continue } };+ let zr = solve(&xs_for(li, w, Some(gap), bi, ai)); let target = spec.impedance.get(&format!("{net}/{p}")).copied().map(|t| (t, "spec")).or_else(|| diff_target(net)); let zd = zr.zdiff.unwrap_or(0.0);- pairs.push(json!({"pair": [net, p], "layer": layer, "widthMm": w, "gapMm": (gap * 1000.0).round() / 1000.0, "zdiff": r1(zd), "zodd": zr.zodd.map(r1), "zeven": zr.zeven.map(r1), "targetOhm": target.map(|t| t.0), "standard": target.map(|t| t.1)}));- if let Some((tz, why)) = target { if (zd - tz).abs() > 0.1 * tz { issues.push(Issue { severity: "medium".into(), kind: "diff-impedance".into(), what: format!("{}/{}: {:.0} Ω differential on {} ({:.3} mm wide, {:.3} mm gap), against {:.0} Ω ({})", net, p, zd, layer, w, gap, tz, why), at: None, layer: Some(layer.clone()), net: Some(net.clone()), reference: None, fix: format!("Adjust the pair's width and gap for {:.0} Ω (a narrower gap lowers it); keep the pair over unbroken ground.", tz), action: Some(json!({"type": "review", "pair": [net, p], "why": "differential impedance", "zdiff": r1(zd), "target": tz})) }); } }+ pairs.push(json!({"pair": [net, p], "layer": layer, "widthMm": w, "gapMm": (gap * 1000.0).round() / 1000.0, "status": "ok", "reference": refrec, "zdiff": r1(zd), "zodd": zr.zodd.map(r1), "zeven": zr.zeven.map(r1), "targetOhm": target.map(|t| t.0), "standard": target.map(|t| t.1)}));+ if let Some((tz, why)) = target { if (zd - tz).abs() > 0.1 * tz { issues.push(Issue { severity: "medium".into(), kind: "diff-impedance".into(), what: format!("{}/{}: {:.0} Ω differential on {} ({:.3} mm wide, {:.3} mm gap) over {} {} {:.3} mm away, against {:.0} Ω ({})", net, p, zd, layer, w, gap, refrec["below"]["layer"].as_str().unwrap_or("?"), refrec["below"]["net"].as_str().unwrap_or(""), refrec["below"]["distanceMm"].as_f64().unwrap_or(0.0), tz, why), at: None, layer: Some(layer.clone()), net: Some(net.clone()), reference: None, fix: format!("Adjust the pair's width and gap for {:.0} Ω (a narrower gap lowers it); keep the pair over unbroken ground.", tz), action: Some(json!({"type": "review", "pair": [net, p], "why": "differential impedance", "zdiff": r1(zd), "target": tz})) }); } } }- json!({"ran": true, "method": "2-D Laplace field solve of each cross-section (grid, conjugate gradient), dielectric and air; solder mask 20 µm εr 3.6 on outer layers unless rf.noSolderMask", "nets": nets_out, "pairs": pairs})+ json!({"ran": true, "method": "2-D Laplace field solve of each cross-section (grid, conjugate gradient), dielectric and air; solder mask 20 µm εr 3.6 on outer layers unless rf.noSolderMask. The reference is found under each segment: on each side the nearest layer whose ground or supply copper covers 90 % of it (centre and both edges, sampled every 0.25 mm); a nearer layer covering part of it marks the section mixed and a section with none has no reference, and neither gets an impedance", "nets": nets_out, "pairs": pairs}) } #[cfg(test)] mod tests {