← Commit history

0.10.2: PEEC decoupling-loop inductance in the pdn module (where the stackup allows)

John Lauer ·6a8f94a8d3 · ·parent ac4daa6
10 files changed +251−11
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.10.1"+version = "0.10.2" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.10.1"+version = "0.10.2" 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.2.mdadded+8
@@ -0,0 +1,8 @@+# adom-fields 0.10.2++- **Decoupling loops solved from the copper (PEEC, in `--with pdn`).**+  - **What it solves:** each capacitor's mounting inductance, as the loop from its pads through the board's copper and vias to the load pin and the load part's nearest ground pin.+  - **The method:** a partial-element equivalent circuit over every copper layer in a window around the loop, with three dielectric heights of margin and the cells coarsened to fit about 1,500 branches.+  - **What it replaces:** a distance estimate. `mountModel` says which was used and why, and `mountNhEstimate` keeps the estimate beside it.+  - **Validation:** a strip over a plane matches the 2-D field solve within 3 %. On the TPS54202 buck the loops are within 5 % of a large-window, fine-cell solve: the hot-loop capacitor C4 is about 2.0 nH where the estimate said 0.78 nH, and the input capacitors run 1.5 to 3 times the estimate. Four loops take about 7 s.+  - **Where it does not solve yet:** thin four-layer stackups (0.2 mm to the plane) need cells finer than this dense solver can afford, so their loops keep the estimate and say so.
package.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.10.1",-  "changelog": "0.10.1: the skill's topics install as sub-skills (adom-fields-review, -spec, -emc, -pdn, -si, -noise-rf, -verify, -views); 0.10.0: lean by default with optional modules the output says when to turn on.",+  "version": "0.10.2",+  "changelog": "0.10.2: in --with pdn, each decoupling capacitor's loop inductance is solved from the copper (PEEC) where the stackup allows, with the estimate and the reason kept beside it.",   "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.1",-  "changelog": "0.10.1: the skill's topics install as sub-skills (adom-fields-review, -spec, -emc, -pdn, -si, -noise-rf, -verify, -views); 0.10.0: lean by default with optional modules the output says when to turn on.",+  "version": "0.10.2",+  "changelog": "0.10.2: in --with pdn, each decoupling capacitor's loop inductance is solved from the copper (PEEC) where the stackup allows, with the estimate and the reason kept beside it.",   "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-pdn/SKILL.md+17
@@ -10,6 +10,23 @@ Parent skill: **adom-fields**  Turn on `pdn` when a rail feeds a digital or RF chip with fast current edges, or the board has a converter input or a connector-fed rail (hot-plug overshoot, input-filter stability). +## Mounting inductance solved from the copper (PEEC, 0.10.2)++Each capacitor's mounting inductance is the loop from its pads, through the board's copper and vias, to the load pin and the load part's nearest ground pin, where the loop closes inside the part. It is solved by PEEC, not estimated from distance:+- **The model:** the two nets' copper in a window around the loop is cut into cells on every copper layer, as bars and vias with partial self and mutual inductances.+- **The solve:** the inductive network is solved for the port's inductance.+- **The window:** three dielectric heights of margin, for the return spreading under the loop.+- **The cell size:** coarsened until the window fits about 1,500 branches (a second or two per loop).+- **What each branch records:** `mountModel` says how its `mountNh` was found; `mountNhEstimate` keeps the old distance estimate beside it; `review.pdn.peec` counts the solved and estimated loops and the seconds.+- **When it falls back:** a loop that cannot be solved keeps the estimate and says why:+  - a load pin with no ground pin on its part (a single-pin connector);+  - a loop that does not close inside the window;+  - a window over budget;+  - more than 40 s spent;+  - **a plane closer than the cells can resolve.** The cells must be no larger than the dielectric's thickness (at 2.5 times it the loop reads 37 % low), so a thin four-layer stackup (0.2 mm to the plane) stays on the estimate until a faster solver lands. PEEC today suits two-layer boards and thick dielectrics.+- **Validation:** a strip over a plane matches the 2-D field solve within 3 % (tests). On the buck, the product's loops are within 5 % of a large-window, fine-cell solve.+- **On the TPS54202 buck:** the hot-loop capacitor C4 measures about 2.0 nH where the estimate said 0.78 nH. The loops run 1.5 to 3 times the estimate, which moves each capacitor's self-resonance and the rail's impedance with it.+ ## Power delivery: rail impedance (0.7.9)  `analyze` also checks each rail's impedance seen from its load pin (a pin of the chip with the most pins on the rail, else an outgoing connector; `pdn.<net>.load` names another), 1 kHz to 1 GHz. What feeds the rail comes from the spec's `sources`, the modelled converter, an on-board regulator (named like one: TLV7…, LDO, AMS1117…, bridging the rail and a higher one), a chip whose pin on the rail is named as an output (VOUT, OUT: KiCad's pin function), or the connector the solve found the current entering by; a rail with none of these is listed `skipped` with the reason, never guessed:
src/main.rs+1−1
@@ -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 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 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;
src/modules.rs+1−1
@@ -15,7 +15,7 @@ pub fn adds(m: &str) -> &'static str {     match m {         "emc" => "radiated emissions of the hot loop and of each cable (common mode, the usual chamber failure), conducted emissions through a LISN, the stitching fence and return paths, and a compliance prediction (FCC/CISPR) with the cheapest fixes by dB and a bench plan",         "esd" => "each connector pin's protection: a TVS within 3 mm and a filter on every line that faces the outside world",-        "pdn" => "each rail's impedance from its load pin against a target (1 kHz to 1 GHz, ngspice-checked), the cheapest decoupling set, hot-plug overshoot and input-filter stability",+        "pdn" => "each rail's impedance from its load pin against a target (1 kHz to 1 GHz, ngspice-checked) with every decoupling capacitor's loop inductance solved from the copper (PEEC), the cheapest decoupling set, hot-plug overshoot and input-filter stability",         "si" => "every signal trace's impedance (2-D field solve), TDR and overshoot with the series resistor that fixes it, length matching, via stubs and the stackup's reference planes",         "noise" => "plane-cavity resonances against the switching and clock harmonics, and crosstalk into quiet nets",         "rf" => "a receiver's oscillator leaking out of the antenna (FCC 15.111), isolation between stages, the frequency plan's in-band tones and the oscillator's own emission",
src/pdn.rs+23−3
@@ -80,6 +80,10 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec     let gid = f.net_names.iter().position(|x| *x == gnd).map(|i| i as u16);     let fs = freqs();     let lib = crate::caps::Library::load(spec.capCurves.as_deref());+    // PEEC (crate::peec): each capacitor's loop to its load pin solved from the copper, every copper layer at its height+    let peec_layers: Vec<(String, f64)> = { let cu: Vec<String> = if spec.real_copper.is_empty() { b.copper.clone() } else { spec.real_copper.clone() }; let gaps = spec.real_gaps.clone(); let top: f64 = gaps.iter().sum(); let mut z = top; cu.iter().enumerate().map(|(i, l)| { let zi = z; if i < gaps.len() { z -= gaps[i]; } (l.clone(), zi) }).collect() };+    let peec_t0 = std::time::Instant::now();+    let mut peec_count = (0usize, 0usize); // (solved, fell back)     let mut rails = Vec::new();     for (name, nid) in f.net_names.iter().enumerate().map(|(i, x)| (x.clone(), i as u16)) {         let Some(v) = rail(&name) else { continue }; if v <= 0.0 || is_ground(&name) || spec.switching.contains_key(&name) { continue; }@@ -155,9 +159,25 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec             let m = crate::caps::model(&lib, val, mpn, fpn, c, v, span);             let (esr, esl, c) = (m.esr, m.esl, m.c_eff);             let dist = (cv.x - load.x).hypot(cv.y - load.y);-            let mount = (lpm * dist + 0.2e-9).max(0.2e-9);+            let estimate = (lpm * dist + 0.2e-9).max(0.2e-9);+            // the solved loop: the cap's pads to the load pin and the load part's nearest ground pin (the loop closes+            // inside the part), in a window around them; the estimate stays when the window is too big or the loop open+            let lg = b.pads.iter().filter(|p| p.reference == load.reference && p.net == gnd).min_by(|a, z| (a.x - load.x).hypot(a.y - load.y).partial_cmp(&(z.x - load.x).hypot(z.y - load.y)).unwrap());+            let solved: Result<(f64, usize, f64, String), String> = match lg { None => Err(format!("{} has no ground pin to close the loop", load.reference)), Some(lg) => {+                if peec_t0.elapsed().as_secs_f64() > 40.0 || peec_layers.len() < 2 { Err("the 40 s PEEC budget is spent".into()) } else {+                let xs = [cv.x, cg.x, load.x, lg.x]; let ys = [cv.y, cg.y, load.y, lg.y];+                // the return current spreads under the loop over a few dielectric heights: the window keeps three+                let mg = (3.0 * h).clamp(2.0, 5.0);+                let win = [xs.iter().cloned().fold(f64::MAX, f64::min) - mg, ys.iter().cloned().fold(f64::MAX, f64::min) - mg, xs.iter().cloned().fold(f64::MIN, f64::max) + mg, ys.iter().cloned().fold(f64::MIN, f64::max) + mg];+                let area = (win[2] - win[0]) * (win[3] - win[1]);+                let pitch = (area * peec_layers.len() as f64 / 6000.0).sqrt().clamp(0.25, 0.6);+                crate::peec::loop_nh(b, &peec_layers, spec.copperUm * 1e-3, [name.as_str(), gnd.as_str()], [(cv.x, cv.y), (cg.x, cg.y)], [(load.x, load.y), (lg.x, lg.y)], win, pitch, 1500, h).map(|(l, nb, pitch)| (l, nb, pitch, format!("{}.{} and {}.{}", load.reference, load.pad, lg.reference, lg.pad)))+            } } };+            let mount = match &solved { Ok((l, ..)) => { peec_count.0 += 1; l * 1e-9 } Err(_) => { peec_count.1 += 1; estimate } };             cap_total += c;-            let mut extra = json!({"esrMohm": sig(esr * 1e3), "eslNh": sig(esl * 1e9), "mountNh": sig(mount * 1e9), "distMm": r2(dist), "srfHz": sig(1.0 / (2.0 * PI * (c * (esl + mount)).sqrt()))});+            let mut extra = json!({"esrMohm": sig(esr * 1e3), "eslNh": sig(esl * 1e9), "mountNh": sig(mount * 1e9), "distMm": r2(dist), "srfHz": sig(1.0 / (2.0 * PI * (c * (esl + mount)).sqrt())),+                "mountModel": match &solved { Ok((_, nb, pitch, pins)) => format!("solved (PEEC): the loop from the capacitor's pads to {pins} over the copper ({nb} branches, {pitch:.2} mm cells)"), Err(why) => format!("estimate: a microstrip over the plane from the capacitor to the load pin; not solved because {why}") },+                "mountNhEstimate": sig(estimate * 1e9)});             for (k, x) in crate::caps::info(&m, val, mpn) { extra[k] = x; }             bs.push(Branch { name: r.clone(), kind: "capacitor", r: esr, l: esl + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(cv.x), r2(cv.y)]), extra });         }@@ -397,7 +417,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec             "worst": {"hz": sig(fs[wi]), "ohm": sig(z0[wi]), "ratio": sig(wr), "inductive": bl, "capacitive": bc}, "traceWidthMm": wmm, "nHPerMm": sig(lpm * 1e9),             "branches": br, "added": added, "fixes": fixes, "decapPlan": plan, "zOhm": z0.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "zAfterOhm": if fixes.is_empty() { Value::Null } else { json!(zf.iter().map(|x| sig(*x)).collect::<Vec<_>>()) }, "middlebrook": mb, "hotPlug": hot, "spice": spice}));     }-    json!({"fHz": fs.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "rails": rails, "model": "each branch R + L + (Lp ∥ Rp) + C in parallel at the load pin; each capacitor at the rail's DC bias (a measured curve for its MPN when one is loaded, else an estimate for class-II ceramics), its technology from the Value, MPN and footprint; mounting inductance from a microstrip over the plane", "capCurvesLoaded": lib.len()})+    json!({"fHz": fs.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "rails": rails, "model": "each branch R + L + (Lp ∥ Rp) + C in parallel at the load pin; each capacitor at the rail's DC bias (a measured curve for its MPN when one is loaded, else an estimate for class-II ceramics), its technology from the Value, MPN and footprint; mounting inductance solved by PEEC from the copper (the loop from each capacitor's pads to the load pin and its ground pin), a microstrip estimate where that cannot be solved", "capCurvesLoaded": lib.len(), "peec": {"solved": peec_count.0, "estimated": peec_count.1, "seconds": r2(peec_t0.elapsed().as_secs_f64())}}) }  /// The same branches as an ngspice deck: a 1 A AC current into the rail, so |V| is |Z|.
src/peec.rsadded+195
@@ -0,0 +1,195 @@+//! Partial-element equivalent circuit (PEEC) loop inductance from the board's copper (0.10.2, used by the pdn module).+//! The copper of two nets in a window is cut into cells on every copper layer; neighbouring cells of one net are+//! bars (x- or y-directed branches) and vias are z-directed branches between layers. Each branch has a partial self+//! inductance and a partial mutual inductance with every parallel branch (Ruehli: parallel filaments, the self at the+//! bar's geometric mean distance). At the frequencies where mounting inductance matters, jωL dwarfs the resistance,+//! so the inductive network is solved alone: [L Aᵀ; A 0][I; −φ] = [0; J], 1 A in at the port; the port's inductance+//! is its potential difference. A part's two pins can be shorted (the chip's rail pin to its ground pin, where the+//! decoupling loop closes inside the package). Validated against the 2-D field solve of a strip over a plane (tests).+use crate::board::Board;++const MU0_4PI: f64 = 1e-7; // H/m++/// mutual inductance of two parallel filaments along one axis: [a1, a2] and [b1, b2] (m), d apart (m)+pub fn m_par(a1: f64, a2: f64, b1: f64, b2: f64, d: f64) -> f64 {+    let f = |z: f64| z * (z / d).asinh() - (z * z + d * d).sqrt();+    MU0_4PI * (f(a2 - b1) - f(a2 - b2) - f(a1 - b1) + f(a1 - b2))+}+/// partial self inductance of a bar of length l, width w, thickness t (m): a filament at the cross-section's GMD+pub fn l_self(l: f64, w: f64, t: f64) -> f64 { m_par(0.0, l, 0.0, l, 0.2235 * (w + t)) }++#[derive(Clone, Copy, PartialEq)] pub enum Dir { X, Y, Z }+#[derive(Clone)] pub struct Branch { pub n1: usize, pub n2: usize, pub dir: Dir, pub a: (f64, f64, f64), pub len: f64, pub w: f64, pub t: f64 }++/// the loop inductance seen at a port (node p, node q) of a branch network: dense LU on the MNA system+pub fn solve(_nn: usize, br: &[Branch], shorts: &[(usize, usize)], p: usize, q: usize) -> Option<f64> {+    // only the nodes a branch or a short touches are unknowns (empty cells would leave the system singular)+    // keep only the piece of copper the port is on: floating islands leave their potentials undefined+    let mut adj: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();+    for (a, b) in br.iter().map(|b| (b.n1, b.n2)).chain(shorts.iter().copied()) { adj.entry(a).or_default().push(b); adj.entry(b).or_default().push(a); }+    let mut keep: std::collections::BTreeSet<usize> = Default::default(); let mut stack = vec![p];+    while let Some(u) = stack.pop() { if keep.insert(u) { for v in adj.get(&u).into_iter().flatten() { if !keep.contains(v) { stack.push(*v); } } } }+    if !keep.contains(&q) { return None; }+    let br: Vec<Branch> = br.iter().filter(|b| keep.contains(&b.n1)).cloned().collect();+    let shorts: Vec<(usize, usize)> = shorts.iter().filter(|s| keep.contains(&s.0)).copied().collect();+    let mut used: std::collections::BTreeMap<usize, usize> = Default::default();+    for (a, b) in br.iter().map(|b| (b.n1, b.n2)).chain(shorts.iter().copied()) { let k = used.len(); used.entry(a).or_insert(k); let k = used.len(); used.entry(b).or_insert(k); }+    let (Some(&p), Some(&q)) = (used.get(&p), used.get(&q)) else { return None };+    let br: Vec<Branch> = br.iter().map(|b| Branch { n1: used[&b.n1], n2: used[&b.n2], ..b.clone() }).collect();+    let shorts: Vec<(usize, usize)> = shorts.iter().map(|(a, b)| (used[a], used[b])).collect();+    let (br, shorts, nn) = (&br[..], &shorts[..], used.len());+    let nb = br.len(); let ns = shorts.len();+    // unknowns: branch currents (nb), short currents (ns), node potentials except node q (ground) (nn - 1)+    let idx = |n: usize| -> Option<usize> { if n == q { None } else if n < q { Some(nb + ns + n) } else { Some(nb + ns + n - 1) } };+    let m = nb + ns + nn - 1;+    let mut a = vec![0.0f64; m * m]; let mut rhs = vec![0.0f64; m];+    for i in 0..nb { for j in i..nb {+        let (bi, bj) = (&br[i], &br[j]); if bi.dir != bj.dir { continue; }+        let v = if i == j { l_self(bi.len, bi.w, bi.t) } else {+            // the axis coordinate and the perpendicular distance of two parallel bars+            let (s1, s2, perp) = match bi.dir { Dir::X => (bi.a.0, bj.a.0, ((bi.a.1 - bj.a.1).powi(2) + (bi.a.2 - bj.a.2).powi(2)).sqrt()), Dir::Y => (bi.a.1, bj.a.1, ((bi.a.0 - bj.a.0).powi(2) + (bi.a.2 - bj.a.2).powi(2)).sqrt()), Dir::Z => (bi.a.2, bj.a.2, ((bi.a.0 - bj.a.0).powi(2) + (bi.a.1 - bj.a.1).powi(2)).sqrt()) };+            let d = perp.max(0.2235 * (bi.w + bi.t));+            m_par(s1, s1 + bi.len, s2, s2 + bj.len, d)+        };+        a[i * m + j] = v; a[j * m + i] = v;+    } }+    // KVL rows: L·I − (φ(n1) − φ(n2)) = 0 → L·I + φ(n2) − φ(n1); signs folded into the incidence+    let inc = |a: &mut Vec<f64>, row: usize, n1: usize, n2: usize| { if let Some(k) = idx(n1) { a[row * m + k] -= 1.0; } if let Some(k) = idx(n2) { a[row * m + k] += 1.0; } };+    for (i, b) in br.iter().enumerate() { inc(&mut a, i, b.n1, b.n2); }+    for (s, (n1, n2)) in shorts.iter().enumerate() { inc(&mut a, nb + s, *n1, *n2); }+    // KCL rows (one per node except q): currents leaving through branches and shorts = injected+    for (i, b) in br.iter().enumerate() { if let Some(r) = idx(b.n1) { a[r * m + i] += 1.0; } if let Some(r) = idx(b.n2) { a[r * m + i] -= 1.0; } }+    for (s, (n1, n2)) in shorts.iter().enumerate() { let c = nb + s; if let Some(r) = idx(*n1) { a[r * m + c] += 1.0; } if let Some(r) = idx(*n2) { a[r * m + c] -= 1.0; } }+    let rp = idx(p)?; rhs[rp] = 1.0; // 1 A into p, out at q+    let x = lu_solve(&mut a, &mut rhs, m)?;+    Some(x[rp])+}++fn lu_solve(a: &mut [f64], b: &mut [f64], m: usize) -> Option<Vec<f64>> {+    for k in 0..m {+        let piv = (k..m).max_by(|i, j| a[i * m + k].abs().partial_cmp(&a[j * m + k].abs()).unwrap())?;+        if a[piv * m + k].abs() < 1e-30 { return None; }+        if piv != k { for c in 0..m { a.swap(k * m + c, piv * m + c); } b.swap(k, piv); }+        let d = a[k * m + k];+        let (top, rest) = a.split_at_mut((k + 1) * m); let rowk = &top[k * m..(k + 1) * m];+        for i in 0..(m - k - 1) {+            let row = &mut rest[i * m..(i + 1) * m]; let f = row[k] / d; if f == 0.0 { continue; }+            row[k] = 0.0; for c in (k + 1)..m { row[c] -= f * rowk[c]; } b[k + 1 + i] -= f * b[k];+        }+    }+    let mut x = vec![0.0; m];+    for k in (0..m).rev() { let mut s = b[k]; for c in (k + 1)..m { s -= a[k * m + c] * x[c]; } x[k] = s / a[k * m + k]; }+    Some(x)+}++/// A grid of copper cells: for each layer and cell, the net there (or none). Builds the branch network.+pub struct Grid { pub x0: f64, pub y0: f64, pub d: f64, pub w: usize, pub h: usize, pub z: Vec<f64>, pub t: f64, pub cell: Vec<Vec<Option<u16>>> }+impl Grid {+    pub fn node(&self, k: usize, r: usize, c: usize) -> usize { (k * self.h + r) * self.w + c }+    /// branches between neighbouring cells of one net on one layer, and z branches at the given via points (layer pairs)+    pub fn network(&self, vias: &[(f64, f64, u16)]) -> Vec<Branch> {+        let mut br = Vec::new(); let (d, t) = (self.d * 1e-3, self.t);+        for k in 0..self.cell.len() { for r in 0..self.h { for c in 0..self.w {+            let Some(n) = self.cell[k][r * self.w + c] else { continue };+            let (x, y, z) = ((self.x0 + (c as f64 + 0.5) * self.d) * 1e-3, (self.y0 + (r as f64 + 0.5) * self.d) * 1e-3, self.z[k] * 1e-3);+            if c + 1 < self.w && self.cell[k][r * self.w + c + 1] == Some(n) { br.push(Branch { n1: self.node(k, r, c), n2: self.node(k, r, c + 1), dir: Dir::X, a: (x, y, z), len: d, w: d, t }); }+            if r + 1 < self.h && self.cell[k][(r + 1) * self.w + c] == Some(n) { br.push(Branch { n1: self.node(k, r, c), n2: self.node(k, r + 1, c), dir: Dir::Y, a: (x, y, z), len: d, w: d, t }); }+        } } }+        for (vx, vy, n) in vias {+            let (c, r) = (((vx - self.x0) / self.d) as isize, ((vy - self.y0) / self.d) as isize); if c < 0 || r < 0 || c as usize >= self.w || r as usize >= self.h { continue; }+            let (r, c) = (r as usize, c as usize);+            let ks: Vec<usize> = (0..self.cell.len()).filter(|k| self.cell[*k][r * self.w + c] == Some(*n)).collect();+            for p in ks.windows(2) { let (za, zb) = (self.z[p[0]], self.z[p[1]]); br.push(Branch { n1: self.node(p[0], r, c), n2: self.node(p[1], r, c), dir: Dir::Z, a: ((self.x0 + (c as f64 + 0.5) * self.d) * 1e-3, (self.y0 + (r as f64 + 0.5) * self.d) * 1e-3, za.min(zb) * 1e-3), len: (za - zb).abs() * 1e-3, w: 0.3e-3, t: 0.3e-3 }); }+        }+        br+    }+}++/// Rasterise the board's copper for the named nets inside a window (mm) at pitch d (mm).+pub fn grid_from_board(b: &Board, nets: &[String], win: [f64; 4], d: f64, z_layers: &[(String, f64)], t_mm: f64) -> Grid {+    let w = ((win[2] - win[0]) / d).ceil() as usize; let h = ((win[3] - win[1]) / d).ceil() as usize;+    let inside = |p: (f64, f64), poly: &[(f64, f64)]| { let mut c = false; let n = poly.len(); for i in 0..n { let (a, bb) = (poly[i], poly[(i + n - 1) % n]); if (a.1 > p.1) != (bb.1 > p.1) && p.0 < (bb.0 - a.0) * (p.1 - a.1) / (bb.1 - a.1) + a.0 { c = !c; } } c };+    let seg = |p: (f64, f64), a: (f64, f64), bb: (f64, f64)| { let (dx, dy) = (bb.0 - a.0, bb.1 - a.1); let l2 = dx * dx + dy * dy; let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) }; (p.0 - a.0 - t * dx).hypot(p.1 - a.1 - t * dy) };+    let mut cell: Vec<Vec<Option<u16>>> = vec![vec![None; w * h]; z_layers.len()];+    for (k, (ln, _)) in z_layers.iter().enumerate() { for r in 0..h { for c in 0..w {+        let p = (win[0] + (c as f64 + 0.5) * d, win[1] + (r as f64 + 0.5) * d);+        for (ni, n) in nets.iter().enumerate() {+            let hit = b.polys.iter().chain(b.pad_polys.iter()).any(|q| q.net == *n && (q.layer == *ln || q.layer == "*.Cu") && q.pts.len() > 2 && inside(p, &q.pts))+                || b.tracks.iter().any(|t| t.net == *n && t.layer == *ln && seg(p, t.a, t.b) <= t.w / 2.0);+            if hit { cell[k][r * w + c] = Some(ni as u16); break; }+        }+    } } }+    // copper narrower than a cell must not vanish (a 0.25 mm trace at 0.6 mm cells would break the loop): every cell a+    // track passes through, and the cell under every pad and via, belongs to its net unless another net holds it+    let mut mark = |k: usize, x: f64, y: f64, ni: u16, cell: &mut Vec<Vec<Option<u16>>>| { let (c, r) = (((x - win[0]) / d).floor(), ((y - win[1]) / d).floor()); if c >= 0.0 && r >= 0.0 && (c as usize) < w && (r as usize) < h { let i = r as usize * w + c as usize; if cell[k][i].is_none() { cell[k][i] = Some(ni); } } };+    for (k, (ln, _)) in z_layers.iter().enumerate() { for (ni, n) in nets.iter().enumerate() {+        for t in b.tracks.iter().filter(|t| t.net == *n && t.layer == *ln) { let len = (t.b.0 - t.a.0).hypot(t.b.1 - t.a.1); let steps = (len / (d / 3.0)).ceil().max(1.0) as usize; for i in 0..=steps { let f = i as f64 / steps as f64; mark(k, t.a.0 + f * (t.b.0 - t.a.0), t.a.1 + f * (t.b.1 - t.a.1), ni as u16, &mut cell); } }+        for p in b.pads.iter().filter(|p| p.net == *n && (p.layers.iter().any(|l| l == ln || l == "*.Cu"))) { mark(k, p.x, p.y, ni as u16, &mut cell); }+        for v in b.vias.iter().chain(b.plated_holes.iter()).filter(|v| v.net == *n) { mark(k, v.x, v.y, ni as u16, &mut cell); }+    } }+    Grid { x0: win[0], y0: win[1], d, w, h, z: z_layers.iter().map(|x| x.1).collect(), t: t_mm * 1e-3, cell }+}++/// The loop inductance (nH) between two nets: 1 A in at port_a (net a), out at port_b (net b), with a short from+/// short_a (net a) to short_b (net b) closing the loop, over the copper in the window. None when the loop is not+/// closed by the copper in the window, or the window would take more than max_branches.+pub fn loop_nh(b: &Board, layers: &[(String, f64)], t_mm: f64, nets: [&str; 2], port: [(f64, f64); 2], short: [(f64, f64); 2], win: [f64; 4], pitch: f64, max_branches: usize, resolve_mm: f64) -> Result<(f64, usize, f64), String> {+    let nets_v = vec![nets[0].to_string(), nets[1].to_string()];+    let vias: Vec<(f64, f64, u16)> = b.vias.iter().chain(b.plated_holes.iter()).filter_map(|v| Some((v.x, v.y, nets_v.iter().position(|q| *q == v.net)? as u16))).collect();+    // the dense solve grows as the cube of the branches: coarsen the cells until the window fits the budget (the+    // rasterising is cheap; at 0.5 mm the buck's loops are within about 5 % of their converged values)+    let (mut pitch, mut tries) = (pitch, 0);+    let (g, br) = loop {+        let g = grid_from_board(b, &nets_v, win, pitch, layers, t_mm); let br = g.network(&vias);+        if br.len() <= max_branches || tries >= 4 { break (g, br); }+        pitch *= ((br.len() as f64 / max_branches as f64).sqrt() * 1.05).max(1.1); tries += 1;+    };+    if br.is_empty() { return Err("no copper of the two nets in the window".into()); }+    // a plane closer than the cells can resolve gives a wrong loop (the validation: −37 % at cells 2.5 times the dielectric, −17 % at+    // 1.25, a few % at 1 or under): a thin four-layer stackup needs a faster solver than this dense one+    if pitch > resolve_mm { return Err(format!("the nearest plane is {resolve_mm:.2} mm away, closer than the {pitch:.2} mm cells this solve can afford in the window can resolve (thin stackups need a faster solver, not yet built)")); }+    if br.len() > max_branches || pitch > 1.0 { return Err(format!("the window needs {} branches even at {pitch:.2} mm cells", br.len())); }+    // a point's node: the cell under it on the first layer (top down) where that net's copper is+    let at = |p: (f64, f64), ni: u16| -> Option<usize> { let (c, r) = (((p.0 - g.x0) / g.d) as isize, ((p.1 - g.y0) / g.d) as isize); if c < 0 || r < 0 || c as usize >= g.w || r as usize >= g.h { return None; } let (r, c) = (r as usize, c as usize); (0..g.cell.len()).find(|k| g.cell[*k][r * g.w + c] == Some(ni)).map(|k| g.node(k, r, c)) };+    let (Some(p), Some(q)) = (at(port[0], 0), at(port[1], 1)) else { return Err("a capacitor pad is not on its net's copper in the grid".into()) };+    let (Some(sa), Some(sz)) = (at(short[0], 0), at(short[1], 1)) else { return Err("a load or ground pin is not on its net's copper in the grid".into()) };+    let l = solve(2 * g.w * g.h * layers.len(), &br, &[(sa, sz)], p, q).ok_or("the loop does not close through the copper in the window")?;+    if !l.is_finite() || l <= 0.0 { return Err("the solve gave no inductance".into()); }+    Ok((l * 1e9, br.len(), pitch))+}++#[cfg(test)]+/// Validation: a strip (wc cells wide, lc cells long) over a full plane h below, shorted at the far end, port at the near+/// end. Returns (PEEC nH, 2-D field-solve nH from the strip's L per metre over the same length).+pub fn validate_strip(d_mm: f64, wc: usize, lc: usize, h_mm: f64, plane_cells: usize) -> (f64, f64) {+    let w = lc + 2; let hh = plane_cells.max(wc + 2);+    let r0 = (hh - wc) / 2;+    let mut cell = vec![vec![None; w * hh]; 2];+    for r in 0..hh { for c in 0..w { cell[1][r * w + c] = Some(0u16); } }+    for r in r0..r0 + wc { for c in 1..1 + lc { cell[0][r * w + c] = Some(0u16); } }+    let g = Grid { x0: 0.0, y0: 0.0, d: d_mm, w, h: hh, z: vec![h_mm, 0.0], t: 35e-6, cell };+    // the far-end short: one via per strip row at the last strip column+    let vias: Vec<(f64, f64, u16)> = (r0..r0 + wc).map(|r| ((lc as f64 + 0.5) * d_mm, (r as f64 + 0.5) * d_mm, 0u16)).collect();+    let br = g.network(&vias);+    // the port: the near-end strip column against the plane under it, all rows tied by shorts to one node each side+    let mut shorts = Vec::new(); let p = g.node(0, r0, 1); let q = g.node(1, r0, 1);+    for r in r0 + 1..r0 + wc { shorts.push((p, g.node(0, r, 1))); shorts.push((q, g.node(1, r, 1))); }+    let nn = 2 * w * hh;+    let l = solve(nn, &br, &shorts, p, q).unwrap_or(f64::NAN) * 1e9;+    let x = crate::xsec::Xs { w: wc as f64 * d_mm * 1e-3, t: 35e-6, gap: None, below: h_mm * 1e-3, above: None, er: 1.0, mask: None };+    let z = crate::xsec::solve(&x); let lpm = z.z0 / 299_792_458.0;+    (l, lpm * (lc as f64 - 1.0) * d_mm * 1e-3 * 1e9)+}++#[cfg(test)] mod tests {+    use super::*;+    #[test] fn wire_mutual_closed_form() {+        let (l, d) = (0.01, 0.001); let m = m_par(0.0, l, 0.0, l, d);+        let want = 2e-7 * l * ((l / d).asinh() - (1.0 + d * d / (l * l)).sqrt() + d / l);+        assert!((m - want).abs() / want < 1e-12);+    }+    #[test] fn strip_over_plane_matches_the_field_solve() {+        // a 1 mm strip 0.5 mm over a plane, shorted at the far end: within 3 % of the 2-D solve's L per metre × length+        let (pe, fs) = validate_strip(0.5, 2, 20, 0.5, 12); assert!((pe / fs - 1.0).abs() < 0.03, "PEEC {pe} nH, field solve {fs} nH");+    }+}