app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
adom-fields 0.9.0: cable CM emissions, cross-section impedance solver, limit sets, board checks
15 files changed
+703−35
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.8.8"+version = "0.9.0" 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."
SKILL.md+16@@ -177,6 +177,22 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Board checks** (0.9.0, `review.checks`), on the solved fields where one exists:+ - `ampacity`: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there.+ - `junctions` / `junction-temperature`: ambient (spec `ambientC`, 25) + the solved rise (+ `hot.<ref>.thetaJcKPerW`·W) against `hot.<ref>.tjMaxC` (125).+ - `capacitors` / `capacitor-temperature`: each capacitor's solved temperature against its type's rating (read from value or part number; electrolytic life by the 10 °C rule; ceramics with only an assumed rating grouped into one finding).+ - `emcRules`: `clock-outer-layer`, `clock-near-connector`, `edge-signal`, `io-unfiltered`, `input-filter-cutoff` (a converter's input LC against fsw/10).+ - `stackup`: which layers are planes, plus `stackup-far-reference`, `stackup-adjacent-signals` and plane-pair pF/cm².+ - `viaStubs` / `via-stub`: the quarter-wave resonance of each signal via's unused barrel against its clock.+ - `lengthGroups` / `length-skew`: spec `lengthGroups` {name: {nets, maxSkewPs | maxSkewMm | protocol: usb2|usb3|pcie|ethernet|ddr|hdmi|mipi}}.+- **Emission limits** (0.9.0): spec `emcLimit` picks the set the issues use: `fcc-b` (default), `fcc-a`, `cispr32-b`, `cispr32-a`, or a custom name. Spec `emcLimits` adds lines (CISPR 25, MIL-STD-461 RE102, a customer's) as {name, distanceM, points [[MHz, dBµV/m]]}. Every emission estimate also reports its margin against every set.+- **Cable emissions** (0.9.0, `review.cableEmissions`, `cable-emissions`): common mode, the usual chamber failure.+ - The switching loop's harmonic current across the ground copper's inductance between the loop and each connector's ground pin (μ0·h × the solved squares) drives that connector's cable (spec `cables` {J1: metres}, 1 m) as a monopole.+ - It is an upper bound with no ferrite.+- **Trace impedance** (0.9.0, `review.impedance`): a 2-D Laplace field solve of every signal net's cross-section on its own layer (microstrip or stripline, solder mask unless `rf.noSolderMask`).+ - It gives Z0, εeff, ps/mm, pF/m and nH/m, plus Zodd, Zeven and Zdiff for pairs found by name (_P/_N, +/-, DP/DM).+ - Targets come from spec `impedance` {net: Ω, "P/N": Ω differential} or by protocol (USB 90, PCIe 85, Ethernet/LVDS/HDMI/MIPI 100).+ - It is validated within about 2 % of Hammerstad-Jensen and Cohn. - **Receiver screens** (0.8.8, `review.rf`): the LO level at the antenna (each circuit path from a `clocks` oscillator to a `sensitiveNets` net, solved as an AC network with transistors as their junction capacitances, cross-checked in ngspice, against FCC 15.111's -57 dBm: `lo-leak`), isolation between stages through a shared rail at the oscillator's frequency (`stage-isolation`, target 40 dB), the LO's own radiated emission against FCC class B at 3 m (`lo-emission`), and USB edge and FM IF-limiter odd harmonics in the frequency plan (`tone-in-band`). Tune with spec `rf`: `cbcPf`, `cbePf`, `antennaOhm`, `loLeakLimitDbm`, `stageIsolationDb`, `usbHighSpeed`, `limiter`. Screening estimates, not measurements. - **Field views frame themselves** (0.8.6): entering Energy (stack open 0.8, parts off, low front camera), Flow (0.6, parts ghosted, low side) or Magnetic (parts off, low side) sets the stack, parts and camera so the field shows; values the command sets win, `ui view --keep-camera` keeps everything as it is, and leaving for another view restores what was there. Camera presets: top, iso, front, low, bottom, fit, zoom. - **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them.
docs/release-0.9.0.mdadded+23@@ -0,0 +1,23 @@+# adom-fields 0.9.0++The first release of the PCB-analysis roadmap: the checks other PCB review tools make by rule of thumb, done here on the solved copper wherever a field exists, plus three new solvers.++- **Common-mode cable emissions (`review.cableEmissions`, `cable-emissions`).** This is the usual reason a board fails the chamber.+ - **The model:** the switching loop's harmonic current flows across the ground copper's inductance between the loop and each connector's ground pin. That inductance is μ0·h × the squares of ground, taken from the solved DC ground field. The voltage it raises drives the connector's cable (spec `cables`, 1 m by default) as a monopole, checked against the chosen limit at 3 m.+ - **Status:** an upper bound with no ferrite.+ - **Example:** on the TPS54202 buck with 1 m cables, it lands 2–7 dB over FCC class B at 63 MHz, where the loop's own differential-mode estimate is 55 dB under.+- **Trace impedance by a 2-D field solve (`review.impedance`, `impedance`, `diff-impedance`).**+ - **The solve:** every signal net's cross-section on its own layer, microstrip or stripline, with solder mask, on a non-uniform grid. It is solved in the dielectric and in air.+ - **Results:** Z0, εeff, ps/mm, pF/m and nH/m; Zodd, Zeven and Zdiff for pairs found by name.+ - **Targets:** from spec `impedance`, or by protocol (USB 90 Ω, PCIe 85 Ω, Ethernet/LVDS/HDMI/MIPI 100 Ω differential).+ - **Accuracy:** within 1 % of Hammerstad-Jensen (microstrip) and Wadell (stripline). Delays may read about 3 % fast.+- **Emission limit sets.** Spec `emcLimit` picks the set the issues use: `fcc-b` (default), `fcc-a`, `cispr32-b`, `cispr32-a`, or a custom line from spec `emcLimits` (CISPR 25, MIL-STD-461 RE102, a customer's). Every emission estimate also reports its margin against every set.+- **Board checks (`review.checks`):**+ - **Ampacity:** each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there.+ - **Junction temperature:** ambient + the solved rise (+ θjc × W) against each hot part's `tjMaxC`.+ - **Capacitor temperature:** against each part's rating read from its value or part number. Electrolytic life uses the 10 °C rule. Ceramics with an assumed rating are grouped into one finding.+ - **EMC layout rules:** clocks on outer layers or near connectors, signals along the board edge, unfiltered connector I/O, and a converter's input filter against fsw/10.+ - **Stackup:** detects which layers are planes; flags signal layers far from a plane and adjacent signal layers; gives plane-pair capacitance.+ - **Via stubs:** quarter-wave resonance against the net's clock.+ - **Length groups:** delay skew against `maxSkewPs`, `maxSkewMm` or a protocol limit.+- **The app opens the right view for each new finding** (current, temperature, EMC, signals or noise).
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.8.8",+ "version": "0.9.0", "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+8−23@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.8.8",+ "version": "0.9.0", "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.",@@ -17,10 +17,7 @@ "vias", "3d", "analysis",- "eda",- "current flow animation",- "voltage",- "river"+ "eda" ], "status": "live", "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",@@ -40,10 +37,11 @@ "fields", "flow of current", "river view",- "voltage drop in 3d",- "ground bounce",+ "energy flow on a board",+ "poynting vector",+ "speed of light on a pcb",+ "electron drift speed", "exploded layer stack",- "current through vias", "explain the view out loud", "narrate the fields", "energy cycle",@@ -85,20 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew",- "six layer board",- "receiver frequency plan",- "lo leakage",- "tone in band",- "rf crosstalk",- "return path over supply plane",- "antenna ground loop",- "stage feed bypass"+ "ngspice pdn" ], "sample_prompts": [ {@@ -139,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+16@@ -177,6 +177,22 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **Explicit pin currents** (PR 6, 10): `loads.<net>.terminals: [{reference, pad, amps, at?}]`, positive into the copper, negative out, each side summing to `amps`; `at: [x, y]` picks one physical land where a pin number repeats. Wrong-net, missing, duplicate and unbalanced pins are refused; a loaded net the spec names that cannot be solved fails the run instead of publishing a partial report. `terminalAssignments` says explicit or heuristic per net. - **Peak current density is the modeled maximum** (PR 8), before the display texture's 99.5th-percentile clip and unrounded (`peakJDefinition`). - **`dcMaxIterations`** (PR 5, default 20000, at most 100000) bounds each net's solve; the target is unchanged.+- **Board checks** (0.9.0, `review.checks`), on the solved fields where one exists:+ - `ampacity`: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there.+ - `junctions` / `junction-temperature`: ambient (spec `ambientC`, 25) + the solved rise (+ `hot.<ref>.thetaJcKPerW`·W) against `hot.<ref>.tjMaxC` (125).+ - `capacitors` / `capacitor-temperature`: each capacitor's solved temperature against its type's rating (read from value or part number; electrolytic life by the 10 °C rule; ceramics with only an assumed rating grouped into one finding).+ - `emcRules`: `clock-outer-layer`, `clock-near-connector`, `edge-signal`, `io-unfiltered`, `input-filter-cutoff` (a converter's input LC against fsw/10).+ - `stackup`: which layers are planes, plus `stackup-far-reference`, `stackup-adjacent-signals` and plane-pair pF/cm².+ - `viaStubs` / `via-stub`: the quarter-wave resonance of each signal via's unused barrel against its clock.+ - `lengthGroups` / `length-skew`: spec `lengthGroups` {name: {nets, maxSkewPs | maxSkewMm | protocol: usb2|usb3|pcie|ethernet|ddr|hdmi|mipi}}.+- **Emission limits** (0.9.0): spec `emcLimit` picks the set the issues use: `fcc-b` (default), `fcc-a`, `cispr32-b`, `cispr32-a`, or a custom name. Spec `emcLimits` adds lines (CISPR 25, MIL-STD-461 RE102, a customer's) as {name, distanceM, points [[MHz, dBµV/m]]}. Every emission estimate also reports its margin against every set.+- **Cable emissions** (0.9.0, `review.cableEmissions`, `cable-emissions`): common mode, the usual chamber failure.+ - The switching loop's harmonic current across the ground copper's inductance between the loop and each connector's ground pin (μ0·h × the solved squares) drives that connector's cable (spec `cables` {J1: metres}, 1 m) as a monopole.+ - It is an upper bound with no ferrite.+- **Trace impedance** (0.9.0, `review.impedance`): a 2-D Laplace field solve of every signal net's cross-section on its own layer (microstrip or stripline, solder mask unless `rf.noSolderMask`).+ - It gives Z0, εeff, ps/mm, pF/m and nH/m, plus Zodd, Zeven and Zdiff for pairs found by name (_P/_N, +/-, DP/DM).+ - Targets come from spec `impedance` {net: Ω, "P/N": Ω differential} or by protocol (USB 90, PCIe 85, Ethernet/LVDS/HDMI/MIPI 100).+ - It is validated within about 2 % of Hammerstad-Jensen and Cohn. - **Receiver screens** (0.8.8, `review.rf`): the LO level at the antenna (each circuit path from a `clocks` oscillator to a `sensitiveNets` net, solved as an AC network with transistors as their junction capacitances, cross-checked in ngspice, against FCC 15.111's -57 dBm: `lo-leak`), isolation between stages through a shared rail at the oscillator's frequency (`stage-isolation`, target 40 dB), the LO's own radiated emission against FCC class B at 3 m (`lo-emission`), and USB edge and FM IF-limiter odd harmonics in the frequency plan (`tone-in-band`). Tune with spec `rf`: `cbcPf`, `cbePf`, `antennaOhm`, `loLeakLimitDbm`, `stageIsolationDb`, `usbHighSpeed`, `limiter`. Screening estimates, not measurements. - **Field views frame themselves** (0.8.6): entering Energy (stack open 0.8, parts off, low front camera), Flow (0.6, parts ghosted, low side) or Magnetic (parts off, low side) sets the stack, parts and camera so the field shows; values the command sets win, `ui view --keep-camera` keeps everything as it is, and leaving for another view restores what was there. Camera presets: top, iso, front, low, bottom, fit, zoom. - **Terminals** (0.8.1): without explicit `loads.<net>.terminals`, the solve picks them. The named `sources` feed the net, and every other connector and part on it is a sink, so a board-to-board connector that a rail feeds counts as a load. Otherwise connectors feed and parts draw. A part listed in `loads.<net>.draws` carries its own amps, the rest share what is left (sinks by pad area, sources evenly), and each part's share splits evenly over its pins. A pin with no copper near it is left out and its share goes to the other pins on its side, so the injection always balances. Every net in fields.json lists `terminalCurrents` (pin, amps, + into the copper) and `terminalBasis`, so a reviewer can check them.
src/checks.rsadded+246@@ -0,0 +1,246 @@+//! Board checks that read the solved fields (0.9.0): what other PCB review tools check by rule of thumb, done here on the+//! solved copper where a field exists.+//! - ampacity: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there;+//! - ratings: each hot part's junction against its maximum, each capacitor's solved temperature against its type's rating+//! (electrolytic life by the 10 °C rule);+//! - EMC layout rules: clocks on outer layers or near connectors, signals along the board edge, connector pins with no+//! filtering, a converter's input filter against its switching frequency;+//! - the stackup: which layers are planes, adjacent signal layers, signals far from their reference, interplane+//! capacitance;+//! - via stubs: the quarter-wave resonance of each signal via's unused barrel;+//! - length groups: each group's delay skew against its limit (the spec's, or a protocol's).+use crate::board::{Board, LAYERS};+use crate::fields::{Fields, Issue, Spec};+use crate::flow::{is_ground, rail_from_name};+use serde::Deserialize;+use serde_json::{json, Value};++#[derive(Deserialize, Clone, Debug, Default)]+#[allow(non_snake_case)]+pub struct LengthGroup { pub nets: Vec<String>, #[serde(default)] pub maxSkewPs: Option<f64>, #[serde(default)] pub maxSkewMm: Option<f64>, /** usb2, usb3, pcie, ethernet, ddr, hdmi, mipi */ #[serde(default)] pub protocol: Option<String> }++fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }+const C_MM_PS: f64 = 0.299792458; // mm per ps in vacuum++fn issue(sev: &str, kind: &str, what: String, at: Option<[f64; 2]>, net: Option<String>, reference: Option<String>, fix: String, action: Value) -> Issue {+ Issue { severity: sev.into(), kind: kind.into(), what, at: at.map(|a| [r2(a[0]), r2(a[1])]), layer: None, net, reference, fix, action: Some(action) }+}++/// protocol skew limits within a group, ps (common design-guide values; the spec's maxSkewPs wins)+fn protocol_ps(p: &str) -> Option<f64> {+ match p.to_ascii_lowercase().as_str() { "usb2" | "usb" => Some(25.0), "usb3" => Some(5.0), "pcie" => Some(5.0), "ethernet" | "eth" => Some(50.0), "ddr" | "ddr3" | "ddr4" => Some(10.0), "hdmi" => Some(15.0), "mipi" => Some(10.0), _ => None }+}++pub fn review(f: &Fields, b: &Board, spec: &Spec, rev: &Value, issues: &mut Vec<Issue>) -> Value {+ let mut out = json!({});+ let n = f.w * f.h;+ let cell = |x: f64, y: f64| -> Option<usize> { let (c, r) = (((x - f.x0) / f.d).floor(), ((y - f.y0) / f.d).floor()); if c < 0.0 || r < 0.0 || c as usize >= f.w || r as usize >= f.h { None } else { Some(r as usize * f.w + c as usize) } };+ let lay = |name: &str| LAYERS.iter().position(|l| *l == name);+ let real: Vec<String> = if spec.real_copper.is_empty() { b.copper.clone() } else { spec.real_copper.clone() };+ let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() };+ let er = spec.epsilonR.unwrap_or(4.3);+ let ambient = spec.ambientC.unwrap_or(25.0);+ let signal = |net: &str| !net.is_empty() && !is_ground(net) && rail_from_name(net).is_none() && !spec.voltages.contains_key(net) && !spec.loads.contains_key(net) && !net.starts_with("unconnected-");++ // ---- ampacity at each loaded net's narrowest neck ----------------------------------------------------------------------+ let mut amp = Vec::new();+ for x in &f.nets {+ if x.peakJ <= 0.0 || x.amps <= 0.0 { continue; }+ let t_um = spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm);+ let w = x.amps / (x.peakJ * t_um / 1000.0);+ let area_mil2 = w * t_um / 1000.0 / 0.00064516;+ let external = x.peakLayer == "F.Cu" || x.peakLayer == "B.Cu";+ let k = if external { 0.048 } else { 0.024 };+ let i10 = k * 10f64.powf(0.44) * area_mil2.powf(0.725);+ let rise = lay(&x.peakLayer).and_then(|k| cell(x.peakAt[0], x.peakAt[1]).map(|i| f.t[k].v[i] as f64));+ amp.push(json!({"net": x.net, "amps": x.amps, "neckMm": r2(w), "layer": x.peakLayer, "copperUm": t_um, "ipc2221_10C_A": r2(i10), "solvedRiseC": rise.map(r1), "at": x.peakAt}));+ if x.amps > i10 {+ issues.push(issue(if x.amps > 1.5 * i10 { "medium" } else { "low" }, "ampacity", format!("{}: {} A through a neck about {:.2} mm wide on {} ({:.0} µm), over IPC-2221's {:.2} A for a 10 °C rise; the solved rise there is {}", x.net, x.amps, w, x.peakLayer, t_um, i10, rise.map(|r| format!("+{:.0} °C", r)).unwrap_or_else(|| "unknown".into())), Some(x.peakAt), Some(x.net.clone()), None, format!("Widen {} at the neck to about {:.2} mm, or add a parallel path on another layer; IPC-2152's charts are less conservative than IPC-2221, and the solved temperature is the better guide.", x.net, w * (x.amps / i10).powf(1.0 / 0.725)), json!({"type": "widen", "net": x.net, "at": x.peakAt, "layer": x.peakLayer, "toMm": r2(w * (x.amps / i10).powf(1.0 / 0.725)), "why": "ampacity"})));+ }+ }+ out["ampacity"] = json!(amp);++ // ---- ratings: junctions and capacitors ---------------------------------------------------------------------------------+ let mut tj = Vec::new();+ for c in &f.chips {+ let h = spec.hot.get(&c.reference);+ let tmax = h.and_then(|h| h.tjMaxC).unwrap_or(125.0);+ let t = ambient + c.riseC + h.and_then(|h| h.thetaJcKPerW).map_or(0.0, |th| th * c.watts);+ let tm = ambient + c.riseMovingC + h.and_then(|h| h.thetaJcKPerW).map_or(0.0, |th| th * c.watts);+ tj.push(json!({"reference": c.reference, "watts": c.watts, "tjStillC": r1(t), "tjMovingC": r1(tm), "tjMaxC": tmax, "marginC": r1(tmax - t), "ambientC": ambient, "thetaJcIncluded": h.and_then(|h| h.thetaJcKPerW).is_some()}));+ if t > tmax - 10.0 {+ issues.push(issue(if t > tmax { "high" } else { "medium" }, "junction-temperature", format!("{}: junction about {:.0} °C in still air ({:.0} °C ambient), against its {:.0} °C maximum", c.reference, t, ambient, tmax), Some([c.x, c.y]), Some(c.tabNet.clone()), Some(c.reference.clone()), "More copper and vias under the part, a heatsink or airflow, or a part with a higher rating; give hot.<ref>.thetaJcKPerW from the datasheet for the junction itself.".into(), json!({"type": "review", "ref": c.reference, "why": "junction temperature", "tjC": r1(t), "tjMaxC": tmax})));+ }+ }+ out["junctions"] = json!(tj);+ let mut caps = Vec::new();+ let mut hot_assumed: Vec<(String, f64, [f64; 2])> = Vec::new();+ for (r, v) in b.values.iter().filter(|(r, _)| r.starts_with('C') && !r.starts_with("CN")) {+ let pads: Vec<&crate::board::Pad> = b.pads.iter().filter(|p| p.reference == *r).collect();+ let mut tmax: f64 = 0.0; let mut at = [0.0, 0.0];+ for p in &pads { let k = if p.layers.iter().any(|l| l == "B.Cu") && !p.layers.iter().any(|l| l == "F.Cu") { LAYERS.len() - 1 } else { 0 }; if let Some(i) = cell(p.x, p.y) { let t = f.t[k].v[i] as f64; if t > tmax { tmax = t; at = [p.x, p.y]; } } }+ if tmax < 5.0 { continue; }+ let fp = b.footprints.get(r).cloned().unwrap_or_default(); let mpn = b.mpns.get(r).cloned().unwrap_or_default(); let all = format!("{v} {fp} {mpn}").to_ascii_uppercase();+ let electrolytic = all.contains("CP_") || all.contains("ELEC") || all.contains("POLARIZED");+ let rating = if all.contains("125C") || all.contains("X7R") || all.contains("X7S") || all.contains("C0G") || all.contains("NP0") { 125.0 } else if all.contains("105C") || all.contains("X6S") { 105.0 } else if electrolytic { 85.0 } else { 85.0 };+ let t = ambient + tmax;+ let life = if electrolytic { Some(2f64.powf((rating - t) / 10.0)) } else { None };+ caps.push(json!({"ref": r, "value": v, "kind": if electrolytic { "electrolytic" } else { "ceramic or other" }, "tempC": r1(t), "ratingC": rating, "ratingAssumed": !(all.contains("125C") || all.contains("105C") || all.contains("X7R") || all.contains("X5R") || all.contains("C0G")), "lifeFactor": life.map(r2), "at": at}));+ let assumed = !(all.contains("125C") || all.contains("105C") || all.contains("85C") || all.contains("X7R") || all.contains("X5R") || all.contains("X7S") || all.contains("X6S") || all.contains("C0G") || all.contains("NP0"));+ if assumed && !electrolytic { if t > rating - 10.0 { hot_assumed.push((r.clone(), t, at)); } continue; }+ if t > rating - 10.0 {+ issues.push(issue(if t > rating { "medium" } else { "low" }, "capacitor-temperature", format!("{} ({}): about {:.0} °C where it sits, against a {:.0} °C rating{}", r, v, t, rating, life.map(|l| format!("; electrolytic life about {:.1}× its rated hours", l)).unwrap_or_default()), Some(at), None, Some(r.clone()), format!("Move {} away from the hot part, or use a part rated for {:.0} °C or more (the rating is read from the value or part number; give it there if it is assumed).", r, (t + 20.0).ceil()), json!({"type": "move", "ref": r, "why": "capacitor temperature", "tempC": r1(t), "ratingC": rating})));+ }+ }+ // ceramics whose rating is only assumed: one finding for the lot, the hottest named, so a hot board is not a wall of issues+ if !hot_assumed.is_empty() {+ hot_assumed.sort_by(|a, z| z.1.partial_cmp(&a.1).unwrap());+ let (r0, t0, at0) = hot_assumed[0].clone();+ let names: Vec<String> = hot_assumed.iter().take(8).map(|x| format!("{} {:.0} °C", x.0, x.1)).collect();+ issues.push(issue(if t0 > 85.0 { "medium" } else { "low" }, "capacitor-temperature", format!("{} ceramic capacitor(s) sit within 10 °C of an 85 °C rating, or over it ({}{}); their dielectric isn't in the value or part number, so 85 °C (X5R) is assumed", hot_assumed.len(), names.join(", "), if hot_assumed.len() > 8 { ", …" } else { "" }), Some(at0), None, Some(r0.clone()), "Give each part's dielectric (X7R: 125 °C) in its value or part number, use X7R parts near the hot spots, or move them away from the heat.".into(), json!({"type": "review", "refs": hot_assumed.iter().map(|x| x.0.clone()).collect::<Vec<_>>(), "why": "capacitor temperature (rating assumed)"})));+ }+ caps.sort_by(|a, z| z["tempC"].as_f64().partial_cmp(&a["tempC"].as_f64()).unwrap());+ out["capacitors"] = json!(caps);++ // ---- EMC layout rules ------------------------------------------------------------------------------------------------------+ let seg_len = |t: &crate::board::Track| (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1);+ let outer = |l: &str| l == "F.Cu" || l == "B.Cu";+ let connectors: Vec<&crate::board::Pad> = b.pads.iter().filter(|p| p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS"))).collect();+ let dist_seg = |px: f64, py: f64, t: &crate::board::Track| -> f64 { let (ax, ay, bx, by) = (t.a.0, t.a.1, t.b.0, t.b.1); let (dx, dy) = (bx - ax, by - ay); let l2 = dx * dx + dy * dy; let u = if l2 > 0.0 { (((px - ax) * dx + (py - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; (px - ax - u * dx).hypot(py - ay - u * dy) };+ let mut rules = Vec::new();+ let multilayer = real.len() >= 4;+ for (cnet, ck) in &spec.clocks {+ let tr: Vec<&crate::board::Track> = b.tracks.iter().filter(|t| t.net == *cnet).collect();+ let out_len: f64 = tr.iter().filter(|t| outer(&t.layer)).map(|t| seg_len(t)).sum();+ if multilayer && out_len > 10.0 {+ rules.push(json!({"rule": "clock-outer-layer", "net": cnet, "outerMm": r1(out_len)}));+ issues.push(issue("low", "clock-outer-layer", format!("{}: {:.0} mm of this {:.1} MHz clock runs on an outer layer, where it radiates more than between planes", cnet, out_len, ck.mid_hz() / 1e6), None, Some(cnet.clone()), None, "Route the clock on an inner layer between ground planes, or keep it short and over unbroken ground with a stitching via at each layer change.".into(), json!({"type": "review", "net": cnet, "why": "clock on an outer layer"})));+ }+ let near = connectors.iter().filter(|p| p.net != *cnet).filter_map(|p| tr.iter().map(|t| dist_seg(p.x, p.y, t)).fold(None, |a: Option<f64>, d| Some(a.map_or(d, |x| x.min(d)))).map(|d| (d, p))).min_by(|a, z| a.0.partial_cmp(&z.0).unwrap());+ if let Some((d, p)) = near { if d < 10.0 {+ rules.push(json!({"rule": "clock-near-connector", "net": cnet, "connector": p.reference, "mm": r2(d)}));+ issues.push(issue("low", "clock-near-connector", format!("{}: the clock passes {:.1} mm from connector {}, whose cable can carry it off the board", cnet, d, p.reference), Some([p.x, p.y]), Some(cnet.clone()), Some(p.reference.clone()), format!("Keep {} at least 10 mm from {} and its pins, or guard it with ground.", cnet, p.reference), json!({"type": "review", "net": cnet, "near": p.reference, "why": "clock near a connector"})));+ } }+ }+ // signals along the board edge (the outline's bounding box)+ let edge_mm = 1.0;+ let mut edge: std::collections::BTreeMap<String, f64> = Default::default();+ for t in b.tracks.iter().filter(|t| signal(&t.net) && outer(&t.layer)) {+ let ed = |x: f64, y: f64| (x - b.x0).min(b.x1 - x).min(y - b.y0).min(b.y1 - y);+ if ed(t.a.0, t.a.1) < edge_mm && ed(t.b.0, t.b.1) < edge_mm { *edge.entry(t.net.clone()).or_default() += seg_len(t); }+ }+ for (net, l) in edge.iter().filter(|(_, l)| **l > 5.0) {+ rules.push(json!({"rule": "edge-signal", "net": net, "mm": r1(*l)}));+ issues.push(issue("low", "edge-signal", format!("{}: {:.0} mm runs within {:.0} mm of the board edge, where the field fringes off the board and radiates", net, l, edge_mm), None, Some(net.clone()), None, "Pull the trace in from the edge (about 3 times its height over the plane) and run a stitched ground ring along the edge.".into(), json!({"type": "review", "net": net, "why": "signal along the board edge"})));+ }+ // connector signal pins with nothing within 5 mm that filters them+ for p in connectors.iter().filter(|p| signal(&p.net) && !spec.sensitiveNets.iter().any(|x| *x == p.net)) {+ let parts: Vec<&crate::board::Pad> = b.pads.iter().filter(|q| q.net == p.net && q.reference != p.reference && (q.x - p.x).hypot(q.y - p.y) < 5.0).collect();+ let filt = parts.iter().any(|q| { let pr: String = q.reference.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); ["R", "L", "FB", "C", "D", "TVS", "ESD"].contains(&pr.as_str()) });+ if !filt {+ rules.push(json!({"rule": "io-unfiltered", "connector": p.reference, "pin": p.pad, "net": p.net}));+ issues.push(issue("low", "io-unfiltered", format!("{} pin {} ({}): no series resistor, ferrite or capacitor within 5 mm, so board noise rides out on its cable and outside noise comes in", p.reference, p.pad, p.net), Some([p.x, p.y]), Some(p.net.clone()), Some(p.reference.clone()), format!("Add a series resistor or ferrite and, if the signal allows, a small capacitor to ground right at {}.", p.reference), json!({"type": "add-part", "what": "series R or ferrite + shunt C", "nearPin": format!("{}.{}", p.reference, p.pad), "maxDistMm": 5.0, "why": "I/O filtering"})));+ }+ }+ // a converter's input filter against its switching frequency+ let conv = &rev["converter"];+ if let (Some(inn), Some(fsw)) = (conv["inNet"].as_str(), conv["fswHz"].as_f64()) {+ let cin = conv["cinUf"].as_f64().unwrap_or(0.0) * 1e-6;+ let lref = conv["inductor"].as_str().unwrap_or("");+ let filt = b.values.iter().filter(|(r, _)| (r.starts_with('L') || r.starts_with("FB")) && r.as_str() != lref).find(|(r, _)| b.pads.iter().any(|p| p.reference == **r && p.net == inn));+ match filt {+ Some((r, v)) => { if let (Some(l), true) = (crate::rf::value(v).filter(|_| r.starts_with('L')), cin > 0.0) {+ let fc = 1.0 / (2.0 * std::f64::consts::PI * (l * cin).sqrt());+ rules.push(json!({"rule": "input-filter-cutoff", "filter": r, "cutoffHz": fc.round(), "fswHz": fsw}));+ if fc > fsw / 10.0 { issues.push(issue("low", "input-filter-cutoff", format!("{} with the input capacitors cuts off at {:.0} kHz, less than a decade below the {:.0} kHz switching frequency: little attenuation of the conducted noise", r, fc / 1e3, fsw / 1e3), None, Some(inn.to_string()), Some(r.clone()), "Lower the filter's corner to a tenth of the switching frequency or less (a larger inductor or more filter capacitance), and check it against the converter's input impedance (the PDN view's stability check).".into(), json!({"type": "review", "ref": r, "why": "input filter cutoff vs switching frequency"}))); }+ } }+ None => { rules.push(json!({"rule": "input-filter-missing", "net": inn})); }+ }+ }+ out["emcRules"] = json!(rules);++ // ---- the stackup ---------------------------------------------------------------------------------------------------------+ let mut layers = Vec::new();+ for l in &real {+ let Some(k) = lay(l) else { continue };+ let mut by: std::collections::BTreeMap<u16, usize> = Default::default();+ for i in 0..n { let id = f.net_ids[k][i]; if id != u16::MAX { *by.entry(id).or_default() += 1; } }+ let (top, cnt) = by.iter().max_by_key(|x| x.1).map(|(a, c)| (*a, *c)).unwrap_or((u16::MAX, 0));+ let name = if top != u16::MAX { f.net_names[top as usize].clone() } else { String::new() };+ let frac = cnt as f64 / n as f64;+ let plane = frac > 0.5 && (is_ground(&name) || rail_from_name(&name).is_some() || spec.voltages.contains_key(&name));+ let sig_len: f64 = b.tracks.iter().filter(|t| t.layer == *l && signal(&t.net)).map(|t| seg_len(t)).sum();+ layers.push(json!({"layer": l, "plane": if plane { Value::from(name.clone()) } else { Value::Null }, "planeCover": r2(frac), "signalMm": r1(sig_len)}));+ }+ let mut stack_notes = Vec::new();+ if real.len() >= 4 {+ let z: Vec<f64> = { let mut acc = 0.0; let mut v = vec![0.0]; for g in gaps.iter().take(real.len() - 1) { acc += g; v.push(acc); } v };+ for i in 0..layers.len() {+ let is_plane = !layers[i]["plane"].is_null(); let sig = layers[i]["signalMm"].as_f64().unwrap_or(0.0);+ if !is_plane && sig > 20.0 {+ // nearest plane+ let d = (0..layers.len()).filter(|j| !layers[*j]["plane"].is_null()).map(|j| (z[i] - z[j]).abs()).fold(f64::MAX, f64::min);+ if d > 0.3 && d < f64::MAX { stack_notes.push(json!({"rule": "far-reference", "layer": layers[i]["layer"], "planeMm": r2(d)})); issues.push(issue("low", "stackup-far-reference", format!("{}: its {:.0} mm of signals sit {:.2} mm from the nearest plane: wider loops and more crosstalk than over a close plane", layers[i]["layer"].as_str().unwrap_or(""), sig, d), None, None, None, "Put a ground plane next to every signal layer (thin prepreg between them), or move fast signals to a layer next to a plane.".into(), json!({"type": "review", "why": "signal layer far from its reference plane"}))); }+ if i + 1 < layers.len() && layers[i + 1]["plane"].is_null() && layers[i + 1]["signalMm"].as_f64().unwrap_or(0.0) > 20.0 { stack_notes.push(json!({"rule": "adjacent-signals", "layers": [layers[i]["layer"], layers[i + 1]["layer"]]})); issues.push(issue("low", "stackup-adjacent-signals", format!("{} and {} are both signal layers with no plane between them: broadside crosstalk and no clean return", layers[i]["layer"].as_str().unwrap_or(""), layers[i + 1]["layer"].as_str().unwrap_or("")), None, None, None, "Route them orthogonally at minimum, or put a plane between them.".into(), json!({"type": "review", "why": "adjacent signal layers"}))); }+ }+ if is_plane && i + 1 < layers.len() && !layers[i + 1]["plane"].is_null() {+ let h = z[i + 1] - z[i]; let pf_cm2 = 8.854e-14 * er / (h / 10.0) * 1e12;+ stack_notes.push(json!({"rule": "plane-pair", "layers": [layers[i]["layer"], layers[i + 1]["layer"]], "gapMm": r2(h), "pfPerCm2": r2(pf_cm2)}));+ }+ }+ }+ out["stackup"] = json!({"layers": layers, "notes": stack_notes});++ // ---- via stubs -------------------------------------------------------------------------------------------------------------+ let z_of: Vec<f64> = { let mut acc = 0.0; let mut v = vec![0.0]; for g in gaps.iter().take(real.len().saturating_sub(1)) { acc += g; v.push(acc); } v };+ let thick = *z_of.last().unwrap_or(&1.6);+ let mut stubs = Vec::new();+ if real.len() >= 3 {+ for v in b.vias.iter().filter(|v| signal(&v.net)) {+ let used: Vec<usize> = real.iter().enumerate().filter(|(_, l)| b.tracks.iter().any(|t| t.net == v.net && t.layer == **l && (dist_seg(v.x, v.y, t) < v.size / 2.0 + 0.05))).map(|(i, _)| i).collect();+ if used.len() < 2 { continue; }+ let (lo, hi) = (*used.iter().min().unwrap(), *used.iter().max().unwrap());+ let stub = (z_of[lo]).max(thick - z_of[hi]);+ if stub < 0.1 { continue; }+ let fr = C_MM_PS * 1e12 / (4.0 * stub * er.sqrt());+ let sig_hz = spec.clocks.get(&v.net).map(|c| c.top_hz());+ stubs.push(json!({"net": v.net, "at": [r2(v.x), r2(v.y)], "stubMm": r2(stub), "resonanceGHz": r2(fr / 1e9), "signalHz": sig_hz}));+ if let Some(fs) = sig_hz { if fr < 5.0 * fs { issues.push(issue("medium", "via-stub", format!("{}: a {:.2} mm via stub resonates at {:.1} GHz, within 5× of its {:.0} MHz signal", v.net, stub, fr / 1e9, fs / 1e6), Some([v.x, v.y]), Some(v.net.clone()), None, "Change layers between the outer layers (no stub), back-drill the via, or use a blind/buried via.".into(), json!({"type": "review", "net": v.net, "at": [r2(v.x), r2(v.y)], "why": "via stub resonance"}))); } }+ }+ }+ stubs.sort_by(|a, z| z["stubMm"].as_f64().partial_cmp(&a["stubMm"].as_f64()).unwrap());+ out["viaStubs"] = json!(stubs.into_iter().take(40).collect::<Vec<_>>());++ // ---- length groups ------------------------------------------------------------------------------------------------------+ let eeff_outer = 0.475 * er + 0.67; // a microstrip's effective permittivity on FR4 (common approximation)+ let mut groups = Vec::new();+ for (gname, g) in &spec.lengthGroups {+ let limit_ps = g.maxSkewPs.or_else(|| g.maxSkewMm.map(|mm| mm * er.sqrt() / C_MM_PS)).or_else(|| g.protocol.as_deref().and_then(protocol_ps)).unwrap_or(25.0);+ let mut rows = Vec::new();+ for net in &g.nets {+ let (mut mm, mut ps) = (0.0, 0.0);+ for t in b.tracks.iter().filter(|t| t.net == *net) { let l = seg_len(t); mm += l; ps += l * if outer(&t.layer) { eeff_outer } else { er }.sqrt() / C_MM_PS; }+ rows.push(json!({"net": net, "mm": r2(mm), "ps": r1(ps)}));+ }+ let ps: Vec<f64> = rows.iter().filter_map(|r| r["ps"].as_f64()).filter(|x| *x > 0.0).collect();+ let skew = if ps.len() >= 2 { ps.iter().cloned().fold(f64::MIN, f64::max) - ps.iter().cloned().fold(f64::MAX, f64::min) } else { 0.0 };+ groups.push(json!({"group": gname, "nets": rows, "skewPs": r1(skew), "limitPs": limit_ps, "protocol": g.protocol}));+ if skew > limit_ps { issues.push(issue("medium", "length-skew", format!("{}: {:.0} ps of skew across its {} nets, over the {:.0} ps limit", gname, skew, g.nets.len(), limit_ps), None, None, None, format!("Lengthen the shorter nets in {} (serpentine on the same layer) until they match within {:.0} ps (about {:.1} mm on an inner layer).", gname, limit_ps, limit_ps * C_MM_PS / er.sqrt()), json!({"type": "review", "group": gname, "why": "length matching"}))); }+ }+ out["lengthGroups"] = json!(groups);+ out+}++#[cfg(test)] mod tests {+ use super::*;+ #[test] fn ipc2221_known_point() {+ // 1 oz (35 µm), 1 mm wide external trace: about 2.5–3 A for a 10 °C rise per the IPC-2221 chart+ let area: f64 = 1.0 * 0.035 / 0.00064516; let i = 0.048 * 10f64.powf(0.44) * area.powf(0.725);+ assert!(i > 2.3 && i < 3.2, "{i}");+ }+ #[test] fn stub_quarter_wave() {+ // a 1.0 mm stub in εr 4.3: c / (4 · 1 mm · √4.3) ≈ 36 GHz+ let f = C_MM_PS * 1e12 / (4.0 * 1.0 * 4.3f64.sqrt()); assert!((f / 1e9 - 36.1).abs() < 0.5, "{}", f / 1e9);+ }+}
src/cm.rsadded+71@@ -0,0 +1,71 @@+//! Common-mode cable radiation (0.9.0): the usual reason a board fails the chamber. A switching converter's loop current+//! returns through the ground copper; the copper's inductance between the loop and a connector's ground pin turns each+//! harmonic into a voltage between that connector's ground and the rest of the board, and the cable on that connector,+//! driven by it, radiates as a monopole (Ott, Electromagnetic Compatibility Engineering, ch. 12; Paul):+//! V_cm(f) = I(f) · 2πf · L_g, L_g = μ0 · h · n_sq,+//! n_sq = the solved DC resistance of the ground copper between the loop's ground and the connector's ground,+//! over the copper's sheet resistance (the same squares carry the inductance of a plane at height h),+//! I_cm = V_cm / 150 Ω (a cable's common-mode impedance),+//! E = 1.257e-6 · f · I_cm · L / r, L capped at λ/4 (the cable is resonant above that).+//! An upper-bound screen: it assumes the loop's whole return current crosses that copper, and ignores ferrites and+//! filtering on the cable. Compared with the chosen limit set (crate::limits) at 3 m.+use crate::board::{Board, LAYERS};+use crate::fields::{Fields, Issue, Spec};+use crate::flow::is_ground;+use serde_json::{json, Value};+use std::f64::consts::PI;++fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }++pub fn review(f: &Fields, b: &Board, spec: &Spec, rev: &Value, issues: &mut Vec<Issue>) -> Value {+ let conv = &rev["converter"];+ let Some(fsw) = conv["fswHz"].as_f64() else { return json!({"ran": false, "why": "no switching converter found"}) };+ let Some(gname) = f.net_names.iter().find(|n| is_ground(n)).cloned() else { return json!({"ran": false, "why": "no ground net"}) };+ let Some(nf) = f.flow.get(&gname) else { return json!({"ran": false, "why": "the ground current was not solved"}) };+ let ig = nf.amps.max(1e-6);+ let (iout, ripple, duty, tr) = (conv["iout"].as_f64().unwrap_or(0.0), conv["rippleA"].as_f64().unwrap_or(0.0), conv["duty"].as_f64().unwrap_or(0.5), conv["riseNs"].as_f64().unwrap_or(8.0) * 1e-9);+ let ipk = iout + ripple / 2.0; let t = 1.0 / fsw;+ // the loop's ground point: the hot-loop capacitor's ground pad (else the switch's)+ let cap = conv["hotLoop"]["cap"].as_str().or_else(|| conv["switch"].as_str()).unwrap_or("");+ let Some(lp) = b.pads.iter().find(|p| p.reference == cap && p.net == gname) else { return json!({"ran": false, "why": "no ground pad on the hot loop"}) };+ let n = f.w * f.h;+ let v_at = |x: f64, y: f64| -> Option<f64> {+ let (c0, r0) = (((x - f.x0) / f.d) as isize, ((y - f.y0) / f.d) as isize); let mut best: Option<f64> = None;+ for rad in 0..6isize { for dr in -rad..=rad { for dc in -rad..=rad { let (r, c) = (r0 + dr, c0 + dc); if r < 0 || c < 0 || r as usize >= f.h || c as usize >= f.w { continue; } let i = r as usize * f.w + c as usize; for k in 0..LAYERS.len() { if nf.on[k].v[i] != 0.0 { best = Some(nf.v[k].v[i] as f64); } } } } if best.is_some() { break; } }+ let _ = n; best+ };+ let Some(v_loop) = v_at(lp.x, lp.y) else { return json!({"ran": false, "why": "the loop's ground pad is off the solved copper"}) };+ let t_um = spec.copperUmByLayer.get("F.Cu").copied().unwrap_or(spec.copperUm);+ let r_sheet = 1.72e-8 / (t_um * 1e-6); // Ω per square+ let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() };+ let h = gaps.first().copied().unwrap_or(1.5) * 1e-3; // the loop's layer to the plane under it, m+ let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());+ let sinc = |x: f64| if x == 0.0 { 1.0 } else { x.sin() / x };+ let mut rows = Vec::new();+ let conns: Vec<String> = { let mut v: Vec<String> = b.pads.iter().filter(|p| (p.reference.starts_with('J') || (p.reference.starts_with('P') && !p.reference.starts_with("PS"))) && p.net == gname).map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };+ for cj in &conns {+ let gp = b.pads.iter().find(|p| p.reference == *cj && p.net == gname).unwrap();+ let Some(vc) = v_at(gp.x, gp.y) else { continue };+ let r_eff = (vc - v_loop).abs() / ig;+ let nsq = (r_eff / r_sheet).max(0.05);+ let lg = 4e-7 * PI * h * nsq; // H+ let len = spec.cables.get(cj).copied().unwrap_or(1.0);+ let (mut worst, mut wf, mut wdb) = (f64::MAX, 0.0, 0.0);+ let mut k = (30e6 / fsw).ceil().max(1.0) as u64; let step = ((1e9 - 30e6) / fsw / 3000.0).max(1.0) as u64;+ while (k as f64) * fsw <= 1e9 {+ let fr = k as f64 * fsw;+ let ih = 2.0 * ipk * duty * sinc(PI * k as f64 * duty).abs() * sinc(PI * k as f64 * tr / t).abs();+ let vcm = ih * 2.0 * PI * fr * lg; let icm = vcm / 150.0;+ let leff = len.min(3e8 / fr / 4.0);+ let e = 1.257e-6 * fr * icm * leff / 3.0;+ let db = 20.0 * (e * 1e6).max(1e-30).log10();+ if let Some(l) = crate::limits::at(&chosen, &spec.emcLimits, fr) { if l - db < worst { worst = l - db; wf = fr; wdb = db; } }+ k += step;+ }+ rows.push(json!({"connector": cj, "groundOhm": r_eff, "squares": r1(nsq), "groundNh": r1(lg * 1e9), "cableM": len, "worstMarginDb": r1(worst), "atMHz": (wf / 1e6).round(), "dBuVm": r1(wdb), "limit": crate::limits::label(&chosen)}));+ if worst < 6.0 {+ issues.push(Issue { severity: if worst < 0.0 { "medium".into() } else { "low".into() }, kind: "cable-emissions".into(), what: format!("{}'s cable ({} m) is estimated to radiate {:.0} dBµV/m at {:.0} MHz at 3 m, {:.1} dB {} {} (an upper-bound common-mode screen with no ferrite: the switching loop's return current across {:.1} nH of ground between the loop and {}'s ground pin)", cj, len, wdb, wf / 1e6, worst.abs(), if worst < 0.0 { "over" } else { "under" }, crate::limits::label(&chosen), lg * 1e9, cj), at: Some([gp.x, gp.y]), layer: None, net: Some(gname.clone()), reference: Some(cj.clone()), fix: format!("Keep the switching loop's return off the path to {cj}: put {cj}'s ground next to the converter's input capacitor's ground (or the converter far from {cj}), stitch the ground layers between them, and add a common-mode choke or ferrite on {cj}'s cable."), action: Some(json!({"type": "review", "ref": cj, "why": "common-mode cable radiation", "marginDb": r1(worst), "atMHz": (wf / 1e6).round()})) });+ }+ }+ json!({"ran": true, "model": "Ott common-mode cable monopole driven by the ground inductance between the switching loop and each connector's ground (upper bound; no ferrites)", "loopGroundPad": format!("{}.{}", lp.reference, lp.pad), "heightMm": h * 1e3, "sheetOhm": r_sheet, "connectors": rows})+}
src/emc.rs+11−4@@ -76,12 +76,19 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va 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 };- let lim = |fr: f64| if fr < 88e6 { 40.0 } else if fr < 216e6 { 43.5 } else if fr < 960e6 { 46.0 } else { 54.0 };+ // 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; } n += stepn; }+ 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) {@@ -91,7 +98,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va 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 FCC class B at {:.0} MHz (differential mode, 3 m)", worst, 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"}))); }+ 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}))); }@@ -99,7 +106,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va 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": "FCC 15 class B, 3 m", "model": "Ott differential-mode loop; no cables"}))}));+ "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}))})); } } }
src/fields.rs+14−2@@ -34,6 +34,18 @@ pub struct Spec { #[serde(default)] pub solids: Option<crate::solids::Solids>, /// receiver screens (crate::rf, issue 9): junction capacitances, the antenna port, the LO leak limit, the stage isolation target #[serde(default)] pub rf: RfCfg,+ /// the radiated-emission limit the issues use: fcc-b (default), fcc-a, cispr32-b, cispr32-a, or a name from emcLimits+ #[serde(default)] pub emcLimit: Option<String>,+ /// extra limit lines (CISPR 25, MIL-STD-461 RE102, a customer's): {name, distanceM, points [[MHz, dBµV/m], ...]}+ #[serde(default)] pub emcLimits: Vec<crate::limits::Custom>,+ /// ambient for absolute temperatures, °C (25 by default)+ #[serde(default)] pub ambientC: Option<f64>,+ /// cable length on each connector, m (1 m when not given), for the common-mode radiation screen+ #[serde(default)] pub cables: BTreeMap<String, f64>,+ /// impedance targets, Ω: a net's single-ended target by its name, a pair's differential target as "P/N"+ #[serde(default)] pub impedance: BTreeMap<String, f64>,+ /// groups of nets whose delays must match: {name: {nets, maxSkewPs | maxSkewMm | protocol}}+ #[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>, /// forced-convection h grows as speed^n (0.5 laminar, 0.8 turbulent); 0.5 when not given@@ -75,7 +87,7 @@ fn d60() -> f64 { 60.0 } #[derive(Deserialize, Clone)] pub struct Load { pub amps: f64, #[serde(default)] pub maxRiseC: Option<f64>, /** the IR drop this net may have from its source to its farthest sink, mV (issue 14) */ #[serde(default)] pub maxDropMv: Option<f64>, /** each sink part's own draw on this net, amps, e.g. {"U3": 0.3, "J2": 4.0} (issue 17); the rest of the net's amps splits over the other sinks by pad area */ #[serde(default)] pub draws: BTreeMap<String, f64>, #[serde(default)] pub terminals: Option<Vec<crate::terminals::Terminal>> } /// a part's dissipation: through its exposed tab's net (tabNet), or, for a leaded part with no tab, through all its leads, /// shared by pad area (issue 8)-#[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, #[serde(default)] pub tabNet: String, /** this part's own rise limit, °C; spec hotMaxRiseC when not given (issue 16) */ #[serde(default)] pub maxRiseC: Option<f64> }+#[derive(Deserialize, Clone)] pub struct Hot { pub watts: f64, #[serde(default)] pub tabNet: String, /** this part's own rise limit, °C; spec hotMaxRiseC when not given (issue 16) */ #[serde(default)] pub maxRiseC: Option<f64>, /** absolute maximum junction temperature, °C (125 when not given) */ #[serde(default)] pub tjMaxC: Option<f64>, /** junction-to-case (tab), K/W, added on top of the solved tab rise */ #[serde(default)] pub thetaJcKPerW: Option<f64> } #[derive(Serialize, Clone)] pub struct NetResult { /** the current on every terminal pin the solve used: + into the copper, - out (issue 17) */ pub terminalCurrents: Vec<Value>, /** heuristic or explicit, and how the sources and sinks were chosen */ pub terminalBasis: String, pub residual: f64, pub converged: bool, pub platedPads: usize, pub platedConnections: Vec<Value>, pub net: String, pub amps: f64, pub layers: Vec<String>, pub peakJ: f64, pub peakAt: [f64; 2], pub peakLayer: String, pub copperMm2: f64, pub jouleW: f64, pub vias: usize, pub iterations: usize }@@ -628,7 +640,7 @@ impl Clock { pub fn harmonics(&self) -> Option<usize> { match self { Clock::Cfg { harmonics, .. } => *harmonics, _ => None } } pub fn amplitude(&self) -> Option<f64> { match self { Clock::Cfg { amplitudeV, .. } => *amplitudeV, _ => None } } }-#[derive(Deserialize, Default, Clone)] pub struct RfCfg { #[serde(default)] pub cbcPf: Option<f64>, #[serde(default)] pub cbePf: Option<f64>, #[serde(default)] pub antennaOhm: Option<f64>, #[serde(default)] pub loLeakLimitDbm: Option<f64>, #[serde(default)] pub stageIsolationDb: Option<f64>, #[serde(default)] pub usbHighSpeed: Option<bool>, #[serde(default)] pub limiter: Option<bool> }+#[derive(Deserialize, Default, Clone)] pub struct RfCfg { #[serde(default)] pub cbcPf: Option<f64>, #[serde(default)] pub cbePf: Option<f64>, #[serde(default)] pub antennaOhm: Option<f64>, #[serde(default)] pub loLeakLimitDbm: Option<f64>, #[serde(default)] pub stageIsolationDb: Option<f64>, #[serde(default)] pub usbHighSpeed: Option<bool>, #[serde(default)] pub limiter: Option<bool>, #[serde(default)] pub noSolderMask: Option<bool> } #[derive(Deserialize, Clone, Debug)] pub struct Band { pub minHz: f64, pub maxHz: f64, #[serde(default)] pub ifHz: Option<f64>, #[serde(default)] pub lowSideLo: bool }
src/limits.rsadded+53@@ -0,0 +1,53 @@+//! Radiated-emission limit lines, all expressed at 3 m in dBµV/m so one estimate compares with any of them.+//! Built in (well-established values; quasi-peak below 1 GHz):+//! - FCC Part 15.109 class B (3 m) and class A (10 m, +10.5 dB to 3 m);+//! - CISPR 32 / EN 55032 class B and class A (10 m below 1 GHz, +10.5 dB to 3 m; above 1 GHz the 3 m average limits).+//! Others (CISPR 25, MIL-STD-461 RE102, a customer's own) come from spec `emcLimits`: points in MHz and dBµV/m at+//! their own distance, applied as steps and scaled to 3 m by 20·log10(d/3).+use serde::Deserialize;++#[derive(Deserialize, Clone, Debug)]+#[allow(non_snake_case)]+pub struct Custom { pub name: String, #[serde(default = "d3")] pub distanceM: f64, /** [MHz, dBµV/m] steps, ascending */ pub points: Vec<[f64; 2]> }+fn d3() -> f64 { 3.0 }++const D10: f64 = 10.457574905606752; // 20·log10(10/3)++/// the limit at f (Hz) for a named set, at 3 m; None above the set's range+pub fn at(set: &str, custom: &[Custom], f: f64) -> Option<f64> {+ let m = f / 1e6;+ match set {+ "fcc-b" => Some(if m < 88.0 { 40.0 } else if m < 216.0 { 43.5 } else if m < 960.0 { 46.0 } else { 54.0 }),+ "fcc-a" => Some(D10 + if m < 88.0 { 39.0 } else if m < 216.0 { 43.5 } else if m < 960.0 { 46.4 } else { 49.5 }),+ "cispr32-b" => Some(if m < 230.0 { 30.0 + D10 } else if m < 1000.0 { 37.0 + D10 } else if m < 3000.0 { 50.0 } else { 54.0 }),+ "cispr32-a" => Some(if m < 230.0 { 40.0 + D10 } else if m < 1000.0 { 47.0 + D10 } else if m < 3000.0 { 56.0 } else { 60.0 }),+ name => {+ let c = custom.iter().find(|c| c.name == name)?;+ let p = c.points.iter().filter(|p| p[0] <= m).last().or_else(|| c.points.first())?;+ Some(p[1] + 20.0 * (c.distanceM / 3.0).log10())+ }+ }+}++pub fn label(set: &str) -> String {+ match set { "fcc-b" => "FCC 15.109 class B".into(), "fcc-a" => "FCC 15.109 class A".into(), "cispr32-b" => "CISPR 32 class B".into(), "cispr32-a" => "CISPR 32 class A".into(), x => x.to_string() }+}++/// every set the board is screened against: the chosen one first, then the other built-ins, then the custom ones+pub fn sets(chosen: &str, custom: &[Custom]) -> Vec<String> {+ let mut v = vec![chosen.to_string()];+ for s in ["fcc-b", "fcc-a", "cispr32-b", "cispr32-a"] { if s != chosen { v.push(s.into()); } }+ for c in custom { if c.name != chosen { v.push(c.name.clone()); } }+ v+}++#[cfg(test)] mod tests {+ use super::*;+ #[test] fn known_points() {+ assert_eq!(at("fcc-b", &[], 100e6), Some(43.5));+ assert!((at("cispr32-b", &[], 100e6).unwrap() - 40.46).abs() < 0.01); // 30 dBµV/m at 10 m+ assert!((at("fcc-a", &[], 100e6).unwrap() - 53.96).abs() < 0.01);+ let c = vec![Custom { name: "re102".into(), distanceM: 1.0, points: vec![[2.0, 24.0], [100.0, 30.0]] }];+ assert!((at("re102", &c, 150e6).unwrap() - (30.0 - 9.54)).abs() < 0.01);+ }+}
src/main.rs+7−1@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod caps; 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 cm; 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;@@ -161,6 +161,9 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck let mut nz = noise::review(&f, &b, &spec, &mut iss); let pd = pdn::review(&f, &b, &spec, &review["converter"], &mut iss, Some(out.as_path())); let rfv = rf::review(&b, &spec, &json!({"noise": nz.clone(), "stageFeeds": review["stageFeeds"].clone()}), out.as_path(), &mut iss);+ let chk = checks::review(&f, &b, &spec, &review, &mut iss);+ let cmv = cm::review(&f, &b, &spec, &review, &mut iss);+ let xsv = xsec::review(&b, &spec, &chk["stackup"], &mut iss); // a current solve that did not converge is an issue, not a result (issue 9) for nr in f.nets.iter().filter(|x| !x.converged) { iss.push(fields::Issue { severity: "high".into(), kind: "solve-unconverged".into(), what: format!("{}: the current solve stopped at residual {:.1e} (target 1e-7); its current density and voltage drop are not reliable", nr.net, nr.residual), at: None, layer: None, net: Some(nr.net.clone()), reference: None, fix: "Re-run at a coarser --pitch, or name this net's terminals in the spec; copper islands with terminals are referenced on their own.".into(), action: Some(json!({"type": "review", "net": nr.net, "why": "solver did not converge"})) }); } emc::act_on_core(&mut iss, &f);@@ -212,6 +215,9 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck review["noise"] = nz; review["pdn"] = pd; review["rf"] = rfv;+ review["checks"] = chk;+ review["cableEmissions"] = cmv;+ review["impedance"] = xsv; summary["fragments"] = fragments; summary["review"] = review; summary["fixPlan"] = emc::fix_plan(&f.issues);
src/rf.rs+3−2@@ -172,7 +172,8 @@ pub fn review(b: &Board, spec: &Spec, rev: &Value, work: &std::path::Path, issue // ---- 3. the LO's own radiated emission: its copper as a small loop over the plane --------------------------------------- let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() }; let er = spec.epsilonR.unwrap_or(4.3); let e0 = 8.854e-15; // F/mm- let lim = |f: f64| if f < 88e6 { 40.0 } else if f < 216e6 { 43.5 } else if f < 960e6 { 46.0 } else { 54.0 };+ let chosen = spec.emcLimit.clone().unwrap_or_else(|| "fcc-b".into());+ let lim = |f: f64| crate::limits::at(&chosen, &spec.emcLimits, f).unwrap_or(54.0); let mut rad = Vec::new(); for (cnet, clock) in &spec.clocks { let Some(amp) = clock.amplitude() else { continue };@@ -189,7 +190,7 @@ pub fn review(b: &Board, spec: &Spec, rev: &Value, work: &std::path::Path, issue let db = 20.0 * (e * 1e6).max(1e-30).log10(); let margin = lim(f) - db; rad.push(json!({"net": cnet, "atHz": f, "copperMm": r1(len), "loopMm2": r1(area), "currentMa": (i * 1e6).round() / 1e3, "dBuVm": r1(db), "limitDbuVm": lim(f), "marginDb": r1(margin)})); if margin < 6.0 {- issues.push(Issue { severity: if margin < 0.0 { "medium".into() } else { "low".into() }, kind: "lo-emission".into(), what: format!("{cnet} at {:.1} MHz is estimated to radiate {:.1} dBµV/m at 3 m from {:.0} mm² of loop, {:.1} dB under FCC class B", f / 1e6, db, area, margin), at: None, layer: None, net: Some(cnet.clone()), reference: None, fix: "Shorten the oscillator's traces, keep them on the layer next to the ground plane, and keep the tank's loop small; then measure with a near-field probe.".into(), action: Some(json!({"type": "review", "net": cnet, "why": "LO radiated emission", "marginDb": r1(margin)})) });+ issues.push(Issue { severity: if margin < 0.0 { "medium".into() } else { "low".into() }, kind: "lo-emission".into(), what: format!("{cnet} at {:.1} MHz is estimated to radiate {:.1} dBµV/m at 3 m from {:.0} mm² of loop, {:.1} dB under {}", f / 1e6, db, area, margin, crate::limits::label(&chosen)), at: None, layer: None, net: Some(cnet.clone()), reference: None, fix: "Shorten the oscillator's traces, keep them on the layer next to the ground plane, and keep the tank's loop small; then measure with a near-field probe.".into(), action: Some(json!({"type": "review", "net": cnet, "why": "LO radiated emission", "marginDb": r1(margin)})) }); } } out["loEmission"] = json!(rad);
src/serve.rs+1−1@@ -111,7 +111,7 @@ fn handle(mut req: tiny_http::Request, app: Arc<Mutex<App>>) { let Some(issue) = a.fields["issues"].get(i).cloned() else { drop(a); reply(req, 404, json!({"status": "error", "error": "no such issue", "hint": "GET /fields.json .issues lists them, 0-based"})); return; }; a.view["issue"] = json!(i); a.view["net"] = issue["net"].clone(); if let Some(l) = issue["layer"].as_str() { a.view["layer"] = json!(l); } // each finding opens the view that shows it- a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") => "current", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", Some("pour-fragmented") | Some("ground-offset") | Some("ground-island") | Some("stitching-gap") | Some("ground-loop-risk") => "ground", Some("cavity-resonance") | Some("crosstalk") => "noise", Some("pdn-impedance") | Some("input-filter-stability") | Some("hot-plug-overshoot") => "pdn", Some("decoupling-far") | Some("decoupling-missing") | Some("esd-unprotected") => "emc", Some("ir-drop") => "river", Some("output-ripple") | Some("inductor-ripple") => "cycle", _ => "heat" });+ a.view["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") | Some("ampacity") => "current", Some("junction-temperature") | Some("capacitor-temperature") => "temperature", Some("cable-emissions") | Some("clock-outer-layer") | Some("clock-near-connector") | Some("edge-signal") | Some("io-unfiltered") | Some("input-filter-cutoff") => "emc", Some("impedance") | Some("diff-impedance") | Some("via-stub") | Some("length-skew") | Some("stackup-far-reference") | Some("stackup-adjacent-signals") => "signals", Some("lo-leak") | Some("stage-isolation") | Some("lo-emission") => "noise", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", Some("pour-fragmented") | Some("ground-offset") | Some("ground-island") | Some("stitching-gap") | Some("ground-loop-risk") => "ground", Some("cavity-resonance") | Some("crosstalk") => "noise", Some("pdn-impedance") | Some("input-filter-stability") | Some("hot-plug-overshoot") => "pdn", Some("decoupling-far") | Some("decoupling-missing") | Some("esd-unprotected") => "emc", Some("ir-drop") => "river", Some("output-ripple") | Some("inductor-ripple") => "cycle", _ => "heat" }); a.view["camera"] = json!("zoom"); a.view["focus"] = issue["at"].clone(); } else { for k in ["quantity", "net", "layer", "camera", "air", "chip", "drift", "parts", "focus", "explode", "speed", "ghost", "silk", "probe"] { if !v[k].is_null() { a.view[k] = v[k].clone(); } }
src/xsec.rsadded+232@@ -0,0 +1,232 @@+//! Trace impedance by a 2-D field solve of the cross-section (0.9.0): the trace (or pair) between its planes, Laplace's+//! equation on a grid with the stackup's dielectric, solved once with the dielectric and once in air:+//! C = Σ ε·ΔV² over the grid's edges (the field energy at 1 V), C0 = the same in air,+//! Z0 = 1 / (c·√(C·C0)), εeff = C / C0, delay = √εeff / c, L = 1 / (c²·C0)+//! For a pair the even and odd modes give Zeven, Zodd and Zdiff = 2·Zodd. Any layer: a microstrip (one plane under, air or+//! solder mask over) or a stripline (planes on both sides). Validated against the closed forms in the tests.+use crate::board::Board;+use crate::fields::{Issue, Spec};+use crate::flow::{is_ground, rail_from_name};+use serde_json::{json, Value};++const E0: f64 = 8.854187817e-12; const C0: f64 = 299_792_458.0;++/// a cross-section: widths and positions in metres; `below`/`above` = distance to a plane under/over the trace bottom+#[derive(Clone, Debug)]+pub struct Xs { pub w: f64, pub t: f64, pub gap: Option<f64>, pub below: f64, pub above: Option<f64>, pub er: f64, pub mask: Option<(f64, f64)> }++#[derive(Clone, Debug)] pub struct Zr { pub z0: f64, pub eeff: f64, pub zodd: Option<f64>, pub zeven: Option<f64>, pub zdiff: Option<f64>, pub cpf_m: f64, pub lnh_m: f64 }++/// a tensor-product grid, fine at the copper edges and growing outward (finite volume: each edge's weight is+/// ε · (the dual cell's width across it) / (the node spacing along it))+struct Grid { xs: Vec<f64>, ys: Vec<f64>, eps: Vec<f64>, fixed: Vec<Option<f64>> }+impl Grid {+ fn nx(&self) -> usize { self.xs.len() }+ fn ny(&self) -> usize { self.ys.len() }+ /// the edges: (a, b, weight without ε0)+ fn edges(&self) -> Vec<(usize, usize, f64)> {+ let (nx, ny) = (self.nx(), self.ny()); let mut e = Vec::with_capacity(2 * nx * ny);+ for j in 0..ny { for i in 0..nx { let k = j * nx + i;+ // ε lives on the cells between the nodes: an edge's weight sums the two cells it borders, each by its half+ let ec = |ci: usize, cj: usize| self.eps[cj * (nx - 1) + ci];+ if i + 1 < nx { let m = k + 1; let mut w = 0.0; if j > 0 { w += ec(i, j - 1) * (self.ys[j] - self.ys[j - 1]) / 2.0; } if j + 1 < ny { w += ec(i, j) * (self.ys[j + 1] - self.ys[j]) / 2.0; } e.push((k, m, w / (self.xs[i + 1] - self.xs[i]))); }+ if j + 1 < ny { let m = k + nx; let mut w = 0.0; if i > 0 { w += ec(i - 1, j) * (self.xs[i] - self.xs[i - 1]) / 2.0; } if i + 1 < nx { w += ec(i, j) * (self.xs[i + 1] - self.xs[i]) / 2.0; } e.push((k, m, w / (self.ys[j + 1] - self.ys[j]))); }+ } }+ e+ }+ /// conjugate gradient (Jacobi preconditioned) on the free nodes+ fn solve(&self) -> (Vec<f64>, Vec<(usize, usize, f64)>) {+ let n = self.nx() * self.ny(); let edges = self.edges();+ let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];+ for &(a, b, w) in &edges { adj[a].push((b, w)); adj[b].push((a, w)); }+ let free = |k: usize| self.fixed[k].is_none();+ let mut rhs = vec![0.0; n]; let mut diag = vec![1.0; n];+ for k in 0..n { if !free(k) { continue; } diag[k] = adj[k].iter().map(|x| x.1).sum::<f64>().max(1e-300); for &(m, w) in &adj[k] { if let Some(v) = self.fixed[m] { rhs[k] += w * v; } } }+ let apply = |x: &[f64], y: &mut [f64]| { for k in 0..n { if !free(k) { y[k] = 0.0; continue; } let mut s = diag[k] * x[k]; for &(m, w) in &adj[k] { if free(m) { s -= w * x[m]; } } y[k] = s; } };+ let mut x = vec![0.0; n]; let mut r = rhs.clone(); let b2: f64 = rhs.iter().map(|v| v * v).sum::<f64>().max(1e-300);+ let mut z: Vec<f64> = (0..n).map(|k| r[k] / diag[k]).collect(); let mut p = z.clone(); let mut rz: f64 = (0..n).map(|k| r[k] * z[k]).sum();+ let mut ap = vec![0.0; n];+ for _ in 0..30000 {+ apply(&p, &mut ap); let pap: f64 = (0..n).map(|k| p[k] * ap[k]).sum(); if pap.abs() < 1e-300 { break; }+ let a = rz / pap; for k in 0..n { x[k] += a * p[k]; r[k] -= a * ap[k]; }+ if r.iter().map(|v| v * v).sum::<f64>() / b2 < 1e-22 { break; }+ for k in 0..n { z[k] = r[k] / diag[k]; }+ let rz2: f64 = (0..n).map(|k| r[k] * z[k]).sum(); let bt = rz2 / rz; rz = rz2; for k in 0..n { p[k] = z[k] + bt * p[k]; }+ }+ ((0..n).map(|k| self.fixed[k].unwrap_or(x[k])).collect(), edges)+ }+ /// C·V² per unit length = Σ w·ε0·ΔV²+ fn energy2(v: &[f64], edges: &[(usize, usize, f64)]) -> f64 { edges.iter().map(|&(a, b, w)| w * (v[a] - v[b]).powi(2)).sum::<f64>() * E0 }+}++/// nodes from lo to hi: every key point, and between them spacing that starts at `fine` at each key point and grows by+/// 25 % per cell up to `coarse`+fn axis(lo: f64, hi: f64, mut keys: Vec<f64>, fine: f64, coarse: f64) -> Vec<f64> {+ keys.push(lo); keys.push(hi); keys.retain(|k| *k >= lo - 1e-15 && *k <= hi + 1e-15);+ keys.sort_by(|a, b| a.partial_cmp(b).unwrap()); keys.dedup_by(|a, b| (*a - *b).abs() < fine * 0.25);+ let mut out = vec![keys[0]];+ for w in keys.windows(2) {+ let (a, b) = (w[0], w[1]); let len = b - a;+ // steps growing from both ends+ let mut left = Vec::new(); let mut step = fine; let mut pos = 0.0;+ while pos + step < len / 2.0 { pos += step; left.push(pos); step = (step * 1.25).min(coarse); }+ let mut pts: Vec<f64> = left.iter().map(|p| a + p).collect();+ let right: Vec<f64> = left.iter().rev().map(|p| b - p).filter(|q| *q > a + pos + fine * 0.5).collect();+ pts.extend(right); if pts.is_empty() && len > 2.0 * fine { pts.push(a + len / 2.0); }+ out.extend(pts); out.push(b);+ }+ out.sort_by(|a, b| a.partial_cmp(b).unwrap()); out.dedup_by(|a, b| (*a - *b).abs() < 1e-15); out+}++/// the grid for a cross-section; `air` replaces every dielectric by 1; `v1`, `v2` are the conductors' potentials+fn build(x: &Xs, air: bool, v1: f64, v2: f64) -> Grid {+ let s = x.gap.unwrap_or(0.0);+ let spans: Vec<(f64, f64, f64)> = if x.gap.is_some() { vec![(-(s / 2.0 + x.w), -s / 2.0, v1), (s / 2.0, s / 2.0 + x.w, v2)] } else { vec![(-x.w / 2.0, x.w / 2.0, v1)] };+ let reach = x.below.max(x.above.unwrap_or(0.0));+ let side = (8.0 * reach).max(4.0 * x.w); let half = spans.last().unwrap().1 + side;+ let (yb, yt) = (x.below, x.below + x.t);+ let top = match x.above { Some(a) => yt + a, None => yt + 10.0 * x.below.max(x.w) };+ let fine_x = (x.w / 24.0).min(if x.gap.is_some() { s / 8.0 } else { f64::MAX }).min(x.t);+ let fine_y = (x.t / 3.0).min(x.below / 16.0);+ let mut kx = vec![0.0]; for sp in &spans { kx.push(sp.0); kx.push(sp.1); }+ let mut ky = vec![yb, yt]; if let Some((mt, _)) = x.mask { ky.push(yt + mt); }+ let xs = axis(-half, half, kx, fine_x, reach.max(x.w) / 3.0);+ let ys = axis(0.0, top, ky, fine_y, reach.max(x.w) / 3.0);+ let (nx, ny) = (xs.len(), ys.len());+ let mut g = Grid { xs: xs.clone(), ys: ys.clone(), eps: vec![1.0; (nx - 1) * (ny - 1)], fixed: vec![None; nx * ny] };+ for cj in 0..ny - 1 { for ci in 0..nx - 1 { let yc = (ys[cj] + ys[cj + 1]) / 2.0;+ let in_diel = yc < yb || x.above.is_some();+ let in_mask = x.above.is_none() && x.mask.is_some_and(|(mt, _)| yc > yb && yc < yt + mt);+ g.eps[cj * (nx - 1) + ci] = if air { 1.0 } else if in_diel { x.er } else if in_mask { x.mask.unwrap().1 } else { 1.0 };+ } }+ for j in 0..ny { for i in 0..nx { let k = j * nx + i; let (xx, y) = (xs[i], ys[j]);+ if j == 0 || j == ny - 1 || i == 0 || i == nx - 1 { g.fixed[k] = Some(0.0); continue; }+ if y >= yb - 1e-15 && y <= yt + 1e-15 { for sp in &spans { if xx >= sp.0 - 1e-15 && xx <= sp.1 + 1e-15 { g.fixed[k] = Some(sp.2); } } }+ } }+ g+}++pub fn solve(x: &Xs) -> Zr {+ let c_of = |air: bool, v1: f64, v2: f64| { let g = build(x, air, v1, v2); let (v, e) = g.solve(); Grid::energy2(&v, &e) };+ if x.gap.is_none() {+ let (c, c0) = (c_of(false, 1.0, 0.0), c_of(true, 1.0, 0.0));+ let z0 = 1.0 / (C0 * (c * c0).sqrt());+ return Zr { z0, eeff: c / c0, zodd: None, zeven: None, zdiff: None, cpf_m: c * 1e12, lnh_m: 1.0 / (C0 * C0 * c0) * 1e9 };+ }+ // a pair: energy at (1,0), (1,1) and (1,-1); per line Codd = W(1,-1)/1 /2 ... with W = ½VᵀCV doubled: e = VᵀCV+ let e10 = c_of(false, 1.0, 0.0); let e11 = c_of(false, 1.0, 1.0); let e1m = c_of(false, 1.0, -1.0);+ let a10 = c_of(true, 1.0, 0.0); let a11 = c_of(true, 1.0, 1.0); let a1m = c_of(true, 1.0, -1.0);+ let (ce, co) = (e11 / 2.0, e1m / 2.0); let (ae, ao) = (a11 / 2.0, a1m / 2.0);+ let ze = 1.0 / (C0 * (ce * ae).sqrt()); let zo = 1.0 / (C0 * (co * ao).sqrt());+ let z0 = 1.0 / (C0 * (e10 * a10).sqrt());+ Zr { z0, eeff: co / ao, zodd: Some(zo), zeven: Some(ze), zdiff: Some(2.0 * zo), cpf_m: co * 1e12, lnh_m: 1.0 / (C0 * C0 * ao) * 1e9 }+}++fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }++/// protocol differential targets by net name (Ω): USB 90, PCIe 85, Ethernet/LVDS/HDMI/MIPI 100+fn diff_target(a: &str) -> Option<(f64, &'static str)> {+ let u = a.to_ascii_uppercase();+ if u.contains("USB") { Some((90.0, "USB")) } else if u.contains("PCIE") || u.contains("PCI_E") { Some((85.0, "PCIe")) } else if u.contains("ETH") || u.contains("MDI") || u.contains("LVDS") || u.contains("HDMI") || u.contains("TMDS") || u.contains("MIPI") || u.contains("CSI") || u.contains("DSI") { Some((100.0, "100 Ω differential")) } else { None }+}+/// the partner of a pair member by name: _P/_N, +/-, DP/DM, _H/_L+fn partner(n: &str) -> Option<String> {+ for (a, b) in [("_P", "_N"), ("+", "-"), ("DP", "DM"), ("_H", "_L"), ("P", "N")] { if let Some(s) = n.strip_suffix(a) { return Some(format!("{s}{b}")); } }+ None+}++pub fn review(b: &Board, spec: &Spec, stack: &Value, issues: &mut Vec<Issue>) -> Value {+ let real: Vec<String> = if spec.real_copper.is_empty() { b.copper.clone() } else { spec.real_copper.clone() };+ let gaps = if spec.real_gaps.is_empty() { crate::fields::slot_gaps(spec) } else { spec.real_gaps.clone() };+ 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 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> {+ 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 } })+ };+ 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();+ let mut nets_out = Vec::new();+ let mut by_net: std::collections::BTreeMap<String, Vec<&crate::board::Track>> = Default::default();+ 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); }+ let mut rows = Vec::new();+ for ((layer, wum), len) 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": r1(zr.eeff.sqrt() / C0 * 1e15), "pfPerM": r1(zr.cpf_m), "nhPerM": r1(zr.lnh_m)})); }+ }+ if rows.is_empty() { 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}));+ }+ // 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();+ for (net, ts) in &by_net {+ let Some(p) = partner(net) else { continue }; let Some(ps) = by_net.get(&p) else { continue };+ if seen.contains(net) || seen.contains(&p) { continue; } seen.insert(net.clone()); seen.insert(p.clone());+ let mut gaps_mm = Vec::new(); let mut layer = String::new(); let mut w = 0.0;+ for a in ts { for c in ps.iter().filter(|c| c.layer == a.layer && (c.w - a.w).abs() < 1e-6) {+ let (dx, dy) = (a.b.0 - a.a.0, a.b.1 - a.a.1); let (ex, ey) = (c.b.0 - c.a.0, c.b.1 - c.a.1);+ let la = dx.hypot(dy); let lc = ex.hypot(ey); if la < 0.5 || lc < 0.5 { continue; }+ if ((dx * ey - dy * ex) / (la * lc)).abs() > 0.05 { continue; }+ let mx = (c.a.0 + c.b.0) / 2.0 - a.a.0; let my = (c.a.1 + c.b.1) / 2.0 - a.a.1;+ let dist = (mx * dy - my * dx).abs() / la; let gap = dist - a.w;+ if gap > 0.0 && gap < 5.0 * a.w { gaps_mm.push(gap); layer = a.layer.clone(); w = a.w; }+ } }+ 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 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})) }); } }+ }+ 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})+}++#[cfg(test)] mod tests {+ use super::*;+ /// Hammerstad-Jensen microstrip with Wheeler's thickness correction (w, h, t in mm)+ fn hammerstad(w: f64, h: f64, t: f64, er: f64) -> f64 {+ let dw = t / std::f64::consts::PI * (1.0 + (2.0 * h / t).ln()); let u = (w + dw) / h;+ let z_air = if u >= 1.0 { 376.73 / (u + 1.393 + 0.667 * (u + 1.444).ln()) } else { 60.0 * (8.0 / u + u / 4.0).ln() };+ let eeff = (er + 1.0) / 2.0 + (er - 1.0) / 2.0 / (1.0 + 12.0 / u).sqrt();+ z_air / eeff.sqrt()+ }+ #[test] fn stripline_matches_closed_form() {+ // a centred stripline: w 0.2 mm, t 0.035 mm, b = 0.5 mm plane to plane, εr 4.3.+ // Wadell's thickness-corrected stripline: 60/√εr · ln(4b / (0.67π w (0.8 + t/w))) ≈ 45.8 Ω+ let x = Xs { w: 0.2e-3, t: 0.035e-3, gap: None, below: 0.2325e-3, above: Some(0.2325e-3), er: 4.3, mask: None };+ let z = solve(&x); eprintln!("stripline z0 {:.2} eeff {:.3}", z.z0, z.eeff); assert!(z.z0 > 44.5 && z.z0 < 47.5, "{}", z.z0); assert!((z.eeff - 4.3).abs() < 0.05, "{}", z.eeff);+ }+ #[test] fn microstrip_matches_closed_form() {+ // w 0.3 mm over 0.2 mm FR4 (εr 4.2), t 35 µm, no mask: Hammerstad-Jensen with Wheeler's thickness correction gives+ // 55.1 Ω and εeff 3.16 (the IPC-2141 formula in signals.rs says 54.0 but is known to be off by 5-10 %)+ let x = Xs { w: 0.3e-3, t: 0.035e-3, gap: None, below: 0.2e-3, above: None, er: 4.2, mask: None };+ let z = solve(&x); let hj = hammerstad(0.3, 0.2, 0.035, 4.2); eprintln!("microstrip z0 {:.2} (Hammerstad-Jensen {:.2}) eeff {:.3}", z.z0, hj, z.eeff);+ assert!((z.z0 - hj).abs() / hj < 0.03, "{} vs {}", z.z0, hj); assert!(z.eeff > 2.9 && z.eeff < 3.3, "{}", z.eeff);+ }+ #[test] fn pair_odd_below_single() {+ let x = Xs { w: 0.15e-3, t: 0.035e-3, gap: Some(0.15e-3), below: 0.1e-3, above: None, er: 4.2, mask: None };+ let z = solve(&x); eprintln!("pair z0 {:.1} zodd {:?} zeven {:?} zdiff {:?}", z.z0, z.zodd, z.zeven, z.zdiff); let zo = z.zodd.unwrap(); assert!(zo < z.z0 && z.zeven.unwrap() > z.z0, "{zo} {} {:?}", z.z0, z.zeven);+ assert!(z.zdiff.unwrap() > 70.0 && z.zdiff.unwrap() < 120.0, "{:?}", z.zdiff);+ }+}