← Commit history

0.9.6: inputs a solve assumed (datasheet fields, spec patch), reflections and TDR

John Lauer ·4271eaeaf1 · ·parent b10759a
13 files changed +352−28
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.9.5"+version = "0.9.6" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.9.5"+version = "0.9.6" 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."
README.md+4
@@ -169,6 +169,10 @@ Every solve now ends with a design review an AI flow can act on without looking  Every solve predicts each emission test (radiated, conducted, a receiver's antenna port) with an uncertainty band. It ranks the fixes by the dB they buy, finds the cheapest set that gets every source to 6 dB, and writes a bench plan that says what a LISN, a current clamp or a near-field probe should read. Bench readings go back into the spec and move the prediction onto them. On the buck: radiated at risk on J2's cable, conducted a likely fail without an input filter; the cheapest set is an input filter, spread spectrum and a slower edge. `adom-fields report` folds that, Adom Safety's creepage check and Adom Mechanics' reliability screen into one readiness report: each area ready, needs work or blocked, with its findings. +### What it assumed, and the edges++Every solve lists the inputs it had to assume, most important first. For each it gives the part's MPN, the datasheet field that replaces it, and the spec key it goes in, so an AI can read the datasheets and close the gaps. Nets where edges matter (impedance targets, clocks, length groups) are walked from driver to receiver, each segment a line with the cross-section solve's impedance, for a TDR profile and the overshoot in use. On the ESC, the SWD clock overshoots 40 % with a fast edge, and the report gives the 38 Ω series resistor that fixes it.+ ## The app  `adom-fields serve` puts the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, vendored and served by the binary: its camera, ViewCube, studio lighting, framing and Z-up frame, as 3d-viewer-design asks): the picked field on the picked layer, the other layers faded, the vias as dots, the hot parts labelled, the legend, the number under the cursor. On the left the nets by what they carry and the hot parts hottest first; on the right the issues and the method. Every click is a command: the state lives on the server and goes out over a live channel, so `adom-fields ui view --quantity heat --layer B.Cu` does exactly what the button does, and `adom-fields tour` walks the whole board for the AI Flow video.
SKILL.md+34
@@ -211,6 +211,40 @@ On the TPS54202 buck (12 V to 5 V, 1 A, 1 m cables, FCC B):   - the evidence: tool versions and the board and spec hashes. - **"Ready" is not "certified".** It means nothing found stands in the way of booking the lab. +## The inputs a solve assumed (0.9.6)++`review.inputs` lists every default or estimate the results lean on, most important first (`matters`: high, medium, low). Each item gives:+- `what` and `used`;+- `from`: default, estimate or missing;+- `part`: its ref and MPN;+- `datasheetField`: the exact datasheet entry that replaces it;+- `specKey`: where the number goes;+- `affects`: what moves, with a sensitivity where one can be computed. For example: "half the edge time costs 6 dB on cable:J2".++The items covered:+- **The converter:** the switch edge and frequency, each capacitor's capacitance at its DC bias and an electrolytic's ESR.+- **Heat:** junction or operating temperature limits, θJC (asked for semiconductors only), and dissipating parts with no watts.+- **Components:** capacitor temperature ratings, and a TDR driver's output resistance and edge.+- **The board:** stackup, laminate Dk, copper weight.+- **The product's world:** ambient, cable lengths, PDN load steps, and a receiver's transistor capacitances.++`specPatch` is the spec fragment to fill in, each value a placeholder naming its source (`<TPS54202DDCR datasheet: switch-node rise time, ns>`). An AI reads the datasheets (the MPN tells it which), fills the patch, and re-runs; an item leaves the list once the spec gives it. `analyze` prints a `Hint: Inputs assumed` line with the high ones.++## Reflections and TDR (0.9.6)++`review.reflections` checks the nets where edges matter: spec `impedance` targets, `clocks`, `lengthGroups` members, and any net in spec `tdr: {net: {driverOhm, riseNs, loadPf, from, to}}`.+- **The model:**+  - **The path:** the net's copper is walked from the driver pad (`from`, else the first IC pin) to the receiver (`to`, else the farthest pad), segment by segment and through vias.+  - **The line:** each segment is a lossless line with the cross-section solve's Z0 and delay, and each via adds its pad capacitance (Johnson & Graham).+  - **What is not modelled:** branches off the path are counted, not simulated.+- **Two ngspice runs:**+  - **TDR:** a 50 Ω, 35 ps step gives `tdr.points` (ps, Ω), the impedance profile along the path.+  - **In use:** the real driver (25 Ω and 1 ns by default; a clock's edge is 7 % of its period) into a 3 pF receiver gives `inUse`: overshoot, undershoot, settling time, and the series termination (line Z0 less the driver's resistance).+- **Short nets:** a path shorter than a sixth of its edge is `electricallyShort` and only says so.+- **Issues:** overshoot over 15 % raises a `reflection` issue, with an `add-part` action for the series resistor at the driver pin.+- **On the ESC's SWD clock** (73 mm, 449 ps of 58 Ω line, a 0.5 ns edge from 20 Ω): 40 % overshoot, fixed by a 38 Ω series resistor. The DShot input (2 ns edge) is electrically short.+- `impedance` segments' `psPerMm` is now in ps/mm (it read a million times too high before 0.9.6).+ ## The ground view (0.7.6)  `ui view --quantity ground`: the ground copper lifted by its solved millivolts (rings every step), each part's ground pin tagged with its offset, dashed arcs for the possible loops through the ground connectors' cables, floating ground islands in red, and the stitching fence: a ring per ground via (λ/40 reach) with the uncovered stretches of the edge in red.
docs/release-0.9.6.mdadded+12
@@ -0,0 +1,12 @@+# adom-fields 0.9.6++- **The inputs a solve assumed (`review.inputs`).**+  - **What it lists:** every default or estimate the results lean on, most important first, each with the part and MPN, the datasheet field that replaces it, the spec key, and what moves (with a sensitivity where one can be computed).+  - **`specPatch`:** the spec fragment for an AI to fill from the datasheets.+  - **On the TPS54202 buck:** 14 medium items (the capacitors' DC-bias capacitance, the electrolytic's ESR, U1's θJC, the laminate Dk, cable lengths, PDN load steps) and 4 low.+- **Reflections and TDR (`review.reflections`).**+  - **The model:** each checked net's path from driver to receiver, its segments as lines with the cross-section solve's Z0 and delay, and its vias as pad capacitance.+  - **Two ngspice runs:** a 50 Ω TDR profile, and the real driver's overshoot, ringing and series termination.+  - **Which nets:** impedance targets, clocks, length groups, and spec `tdr`.+  - **On the ESC's SWD clock:** 40 % overshoot with a 0.5 ns edge, so a 38 Ω series resistor goes at the MCU pin.+- **Fix:** impedance segments' `psPerMm` is in ps/mm (it was a factor of 10⁶ off).
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.9.5",+  "version": "0.9.6",   "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.9.5",+  "version": "0.9.6",   "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]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+34
@@ -211,6 +211,40 @@ On the TPS54202 buck (12 V to 5 V, 1 A, 1 m cables, FCC B):   - the evidence: tool versions and the board and spec hashes. - **"Ready" is not "certified".** It means nothing found stands in the way of booking the lab. +## The inputs a solve assumed (0.9.6)++`review.inputs` lists every default or estimate the results lean on, most important first (`matters`: high, medium, low). Each item gives:+- `what` and `used`;+- `from`: default, estimate or missing;+- `part`: its ref and MPN;+- `datasheetField`: the exact datasheet entry that replaces it;+- `specKey`: where the number goes;+- `affects`: what moves, with a sensitivity where one can be computed. For example: "half the edge time costs 6 dB on cable:J2".++The items covered:+- **The converter:** the switch edge and frequency, each capacitor's capacitance at its DC bias and an electrolytic's ESR.+- **Heat:** junction or operating temperature limits, θJC (asked for semiconductors only), and dissipating parts with no watts.+- **Components:** capacitor temperature ratings, and a TDR driver's output resistance and edge.+- **The board:** stackup, laminate Dk, copper weight.+- **The product's world:** ambient, cable lengths, PDN load steps, and a receiver's transistor capacitances.++`specPatch` is the spec fragment to fill in, each value a placeholder naming its source (`<TPS54202DDCR datasheet: switch-node rise time, ns>`). An AI reads the datasheets (the MPN tells it which), fills the patch, and re-runs; an item leaves the list once the spec gives it. `analyze` prints a `Hint: Inputs assumed` line with the high ones.++## Reflections and TDR (0.9.6)++`review.reflections` checks the nets where edges matter: spec `impedance` targets, `clocks`, `lengthGroups` members, and any net in spec `tdr: {net: {driverOhm, riseNs, loadPf, from, to}}`.+- **The model:**+  - **The path:** the net's copper is walked from the driver pad (`from`, else the first IC pin) to the receiver (`to`, else the farthest pad), segment by segment and through vias.+  - **The line:** each segment is a lossless line with the cross-section solve's Z0 and delay, and each via adds its pad capacitance (Johnson & Graham).+  - **What is not modelled:** branches off the path are counted, not simulated.+- **Two ngspice runs:**+  - **TDR:** a 50 Ω, 35 ps step gives `tdr.points` (ps, Ω), the impedance profile along the path.+  - **In use:** the real driver (25 Ω and 1 ns by default; a clock's edge is 7 % of its period) into a 3 pF receiver gives `inUse`: overshoot, undershoot, settling time, and the series termination (line Z0 less the driver's resistance).+- **Short nets:** a path shorter than a sixth of its edge is `electricallyShort` and only says so.+- **Issues:** overshoot over 15 % raises a `reflection` issue, with an `add-part` action for the series resistor at the driver pin.+- **On the ESC's SWD clock** (73 mm, 449 ps of 58 Ω line, a 0.5 ns edge from 20 Ω): 40 % overshoot, fixed by a 38 Ω series resistor. The DShot input (2 ns edge) is electrically short.+- `impedance` segments' `psPerMm` is now in ps/mm (it read a million times too high before 0.9.6).+ ## The ground view (0.7.6)  `ui view --quantity ground`: the ground copper lifted by its solved millivolts (rings every step), each part's ground pin tagged with its offset, dashed arcs for the possible loops through the ground connectors' cables, floating ground islands in red, and the stitching fence: a ring per ground via (λ/40 reach) with the uncovered stretches of the edge in red.
src/fields.rs+2
@@ -47,6 +47,8 @@ pub struct Spec {     /// groups of nets whose delays must match: {name: {nets, maxSkewPs | maxSkewMm | protocol}}     /// bench readings that calibrate the compliance prediction: [{source: conducted | loop | cable:J2 | lo:<net>, fMHz, value, unit: dBuV | dBuA | dBuVm}]     #[serde(default)] pub measurements: Vec<crate::compliance::Measurement>,+    /// reflection / TDR settings per net: {driverOhm (25), riseNs (1, a clock 7 % of its period), loadPf (3), from: "U1.5", to: "J2.3"}+    #[serde(default)] pub tdr: BTreeMap<String, crate::tdr::TdrCfg>,     #[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>,
src/inputs.rsadded+93
@@ -0,0 +1,93 @@+//! The inputs a solve assumed (0.9.6): every default or estimate a result leans on, with the part and its MPN, the+//! datasheet field that replaces it, the spec key it goes in, and what moves if it is wrong (a sensitivity where+//! one can be computed). Sorted by how much it matters, so the caller's AI reads the datasheets that change the+//! answer first. `specPatch` is the spec fragment to fill in, with each value a placeholder naming its source.+use crate::board::Board;+use crate::fields::Spec;+use serde_json::{json, Value};+use std::f64::consts::PI;++fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }+fn has(raw: &Value, path: &[&str]) -> bool { let mut c = raw; for k in path { c = &c[*k]; } !c.is_null() }+fn set(patch: &mut Value, path: &[String], v: Value) { let mut c = patch; for k in &path[..path.len() - 1] { if !c[k.as_str()].is_object() { c[k.as_str()] = json!({}); } c = c.get_mut(k.as_str()).unwrap(); } c[path[path.len() - 1].as_str()] = v; }++pub fn review(b: &Board, spec: &Spec, raw: &Value, rev: &Value, chips: &Value) -> Value {+    let mut items: Vec<(u8, Value)> = Vec::new(); // (rank: 0 high, 1 medium, 2 low)+    let mut patch = json!({});+    let mpn = |r: &str| b.mpns.get(r).cloned().or_else(|| b.values.get(r).cloned()).unwrap_or_default();+    let mut item = |rank: u8, what: String, used: Value, from: &str, part: Option<&str>, field: &str, key: Vec<String>, affects: String, patch_v: Option<String>| {+        let p = part.map(|r| json!({"ref": r, "mpn": mpn(r)}));+        if let Some(v) = patch_v { set(&mut patch, &key, json!(format!("<{v}>"))); }+        items.push((rank, json!({"what": what, "used": used, "from": from, "part": p, "datasheetField": field, "specKey": key.join("."), "affects": affects})));+    };+    let c = &rev["converter"];+    // ---- the switching converter: frequency and edge set every emission number+    if let (Some(sw), Some(swr)) = (c["switchNet"].as_str(), c["switch"].as_str()) {+        let worst = rev["compliance"]["sources"].as_array().and_then(|s| s.iter().filter(|x| x["test"] == "radiated").min_by(|a, z| a["marginDb"].as_f64().partial_cmp(&z["marginDb"].as_f64()).unwrap()).cloned());+        if !has(raw, &["switching", sw, "riseNs"]) {+            let tr = c["riseNs"].as_f64().unwrap_or(8.0) * 1e-9;+            let sens = worst.as_ref().and_then(|w| w["fMHz"].as_f64()).map(|f| { let f = f * 1e6; let e = |t: f64| (1.0 / (PI * f * t)).min(1.0); (r1(20.0 * (e(tr / 2.0) / e(tr)).log10()), r1(20.0 * (e(tr) / e(tr * 2.0)).log10())) });+            item(if worst.as_ref().map_or(false, |w| w["marginDb"].as_f64().unwrap_or(99.0) < 12.0) { 0 } else { 1 }, format!("the switch edge on {sw}"), json!(format!("{} ns (default)", tr * 1e9)), "default", Some(swr), "switch-node rise and fall time (or the slew rate; the switching waveform figure)", vec!["switching".into(), sw.into(), "riseNs".into()],+                match (&worst, sens) { (Some(w), Some((up, dn))) => format!("every radiated margin above {:.0} MHz: half the edge time costs {up} dB on {}, twice it gains {dn} dB", 1e3 / (PI * tr * 1e9), w["id"].as_str().unwrap_or("")), _ => "the emission harmonics above 1/(π·tr)".into() }, Some(format!("{} datasheet: switch-node rise time, ns", mpn(swr))));+        }+        if !has(raw, &["switching", sw, "freqHz"]) {+            item(0, format!("the switching frequency on {sw}"), json!(format!("{} kHz (default)", c["fswHz"].as_f64().unwrap_or(500e3) / 1e3)), "default", Some(swr), "switching frequency (or the RT resistor's equation and its value on this board)", vec!["switching".into(), sw.into(), "freqHz".into()], "ripple, the hot loop's harmonics, conducted emissions and the input filter: every converter number".into(), Some(format!("{} datasheet: fSW, Hz", mpn(swr))));+        }+        for cap in c["inCaps"].as_array().into_iter().chain(c["outCaps"].as_array()).flatten() {+            let bm = cap["biasModel"].as_str().unwrap_or("");+            let r = cap["ref"].as_str().unwrap_or("");+            if bm.starts_with("estimate") { item(1, format!("{r}'s capacitance at its DC bias"), json!(format!("{} µF of {} µF ({bm})", cap["uFAtBias"], cap["uF"])), "estimate", Some(r), "capacitance vs DC bias curve (the vendor's simulator: SimSurfing, K-SIM, KSIM3)", vec!["capCurves".into()], "converter ripple, the hot loop's charge, PDN impedance and conducted emissions; class II ceramics vary ±30 % between vendors at the same bias".into(), None); }+            if cap["type"] == "electrolytic" || cap["type"] == "polymer" { item(1, format!("{r}'s ESR"), json!(format!("{} mΩ (typical for the type)", cap["esrMohm"])), "estimate", Some(r), "ESR at 100 kHz (the ripple-current table)", vec!["capCurves".into()], "ripple, damping of the input filter and the PDN's low-frequency impedance".into(), None); }+        }+    }+    // ---- heat: junction limits and θJC+    for j in rev["checks"]["junctions"].as_array().into_iter().flatten() {+        let r = j["reference"].as_str().unwrap_or("");+        // a passive (inductor, resistor, capacitor) has a maximum operating temperature, no junction and no θJC+        let passive = r.starts_with('L') || r.starts_with('R') || r.starts_with('C') || r.starts_with("FB");+        if passive { if !has(raw, &["hot", r, "tjMaxC"]) { item(if j["marginC"].as_f64().unwrap_or(99.0) < 25.0 { 0 } else { 2 }, format!("{r}'s maximum operating temperature"), json!("125 °C (default)"), "default", Some(r), "operating temperature range, including the part's own rise (inductors often 125 or 150 °C, some 105 °C)", vec!["hot".into(), r.into(), "tjMaxC".into()], format!("{r}'s temperature margin ({} °C now)", j["marginC"]), Some(format!("{} datasheet: maximum operating temperature, °C", mpn(r)))); } continue; }+        if !has(raw, &["hot", r, "tjMaxC"]) { item(if j["marginC"].as_f64().unwrap_or(99.0) < 25.0 { 0 } else { 2 }, format!("{r}'s maximum junction temperature"), json!("125 °C (default)"), "default", Some(r), "absolute maximum ratings: operating junction temperature", vec!["hot".into(), r.into(), "tjMaxC".into()], format!("{r}'s junction margin ({} °C now): many power parts allow 150 °C, many passives 105 °C", j["marginC"]), Some(format!("{} datasheet: TJ max, °C", mpn(r)))); }+        if j["thetaJcIncluded"] == false {+            let w = j["watts"].as_f64().unwrap_or(0.0);+            item(if w * 30.0 > j["marginC"].as_f64().unwrap_or(99.0) { 0 } else { 1 }, format!("{r}'s junction-to-case resistance"), json!("not included: the junction is taken at the tab's solved temperature"), "missing", Some(r), "thermal information: RθJC (bottom, or top for a part cooled from above)", vec!["hot".into(), r.into(), "thetaJcKPerW".into()], format!("{r}'s junction temperature is low by {w} W × θJC: +{:.0} to +{:.0} °C for 2 to 30 K/W", w * 2.0, w * 30.0), Some(format!("{} datasheet: RθJC, K/W", mpn(r))));+        }+    }+    // the dissipating parts with no watts in the spec: power devices by reference letter, inductors and regulators+    let hot_given: Vec<&String> = spec.hot.keys().collect();+    for (r, v) in &b.values { if (r.starts_with('Q') || (r.starts_with('U') && b.pads.iter().filter(|p| p.reference == *r).count() <= 8) || r.starts_with('L')) && !hot_given.contains(&r) && chips.as_array().map_or(true, |a| !a.iter().any(|c| c["reference"] == r.as_str())) {+        if spec.hot.is_empty() && r.starts_with('U') { continue; }+        item(2, format!("{r}'s dissipation"), json!("0 W: not a heat source in this solve"), "missing", Some(r), "power dissipation at the operating point (efficiency curve, RDS(on) × I², DCR × I², or quiescent current × V)", vec!["hot".into(), r.into(), "watts".into()], format!("the board's temperatures, if {r} ({v}) dissipates more than a few tens of mW"), None);+    } }+    // capacitors whose temperature rating was assumed+    let assumed: Vec<String> = rev["checks"]["capacitors"].as_array().into_iter().flatten().filter(|c| c["ratingAssumed"] == true).filter_map(|c| c["ref"].as_str().map(String::from)).collect();+    if !assumed.is_empty() { item(2, format!("the temperature rating of {} capacitor(s): {}", assumed.len(), assumed.iter().take(8).cloned().collect::<Vec<_>>().join(", ")), json!("85 °C for ceramics and others, 105 °C for electrolytics (assumed from the value text)"), "estimate", None, "operating temperature range (X5R 85 °C, X7R 125 °C; electrolytic life at its rated temperature)", vec!["values: put the dielectric or rating in the part's value or MPN".into()], "the capacitor-temperature findings".into(), None); }+    // ---- the board and its world+    let sf = rev["stackupFrom"].as_str().unwrap_or("");+    if sf == "default" { item(0, "the dielectric thicknesses".into(), json!(spec.real_gaps.clone()), "default", None, "the fab's stackup drawing (prepreg and core thicknesses)", vec!["stackupMm".into()], "plane capacitance, impedances, hot-loop and plane-to-plane coupling, heat through the board".into(), Some("fab stackup: dielectric mm per gap".into())); }+    if !has(raw, &["epsilonR"]) && b.stack_er.is_none() { item(1, "the laminate's dielectric constant".into(), json!(4.3), "default", None, "the laminate datasheet's Dk at 1 GHz (FR-4 runs 3.8 to 4.6)", vec!["epsilonR".into()], "every impedance: Z0 moves about ±3 % across FR-4's range, and the plane resonances with it".into(), Some("laminate datasheet: Dk at 1 GHz".into())); }+    if !has(raw, &["copperUm"]) && spec.copperUmByLayer.is_empty() { item(0, "the copper thickness".into(), json!(format!("{} µm", spec.copperUm)), "default", None, "the fab order: outer and inner copper weight (1 oz is 35 µm before plating)", vec!["copperUm".into()], "current density, IR drop, copper loss and the heat spreading: twice the copper halves the density".into(), Some("fab: finished copper, µm".into())); }+    if !has(raw, &["ambientC"]) { item(2, "the ambient temperature".into(), json!("25 °C"), "default", None, "the product's environment (an enclosure is often 40 to 50 °C inside)", vec!["ambientC".into()], "every absolute temperature and junction margin moves one for one".into(), Some("product: worst-case ambient, °C".into())); }+    for row in rev["cableEmissions"]["connectors"].as_array().into_iter().flatten() { let cj = row["connector"].as_str().unwrap_or(""); if !has(raw, &["cables", cj]) {+        let f = row["atMHz"].as_f64().unwrap_or(100.0) * 1e6; let q = 3e8 / f / 4.0; let d2 = r1(20.0 * ((2f64).min(q) / (1f64).min(q)).log10());+        let m = row["worstMarginDb"].as_f64().unwrap_or(99.0);+        item(if m < 6.0 && d2 >= 3.0 { 0 } else if m < 6.0 { 1 } else { 2 }, format!("the cable length on {cj}"), json!("1 m (default)"), "default", Some(cj), "the product: the cable that plugs into it in use and in the lab", vec!["cables".into(), cj.into()], format!("{cj}'s common-mode emission: a 2 m cable reads {d2} dB higher at {:.0} MHz (no gain past a quarter wave)", f / 1e6), Some("product: cable length, m".into()));+    } }+    for rail in rev["pdn"]["rails"].as_array().into_iter().flatten().filter(|r| r["skipped"].is_null()) {+        let n = rail["net"].as_str().unwrap_or("");+        if !has(raw, &["pdn", n, "stepA"]) && !has(raw, &["pdn", n, "targetOhm"]) { item(1, format!("{n}'s load step and tolerance"), json!(format!("{} A step, {} % (defaults)", rail["stepA"].as_f64().map(r1).map_or("?".into(), |v| v.to_string()), rail["tolPct"].as_f64().unwrap_or(5.0))), "default", rail["load"].as_str().map(|l| l.split('.').next().unwrap_or(l)).filter(|r| !(r.starts_with('J') || r.starts_with('P') || r.starts_with("MP"))), "the load's datasheet: supply current transient (or its max current) and its supply tolerance", vec!["pdn".into(), n.into(), "stepA".into()], format!("{n}'s target impedance: the whole PDN pass or fail scales with it"), Some("load datasheet: worst current step, A".into())); }+    }+    if rev["rf"]["loLeak"].as_array().map_or(false, |a| !a.is_empty()) {+        if !has(raw, &["rf", "cbcPf"]) { item(0, "the transistors' collector-base capacitance".into(), json!("0.7 pF (default)"), "default", None, "Cob or Ccb (output capacitance at the bias, from the small-signal table)", vec!["rf".into(), "cbcPf".into()], "the LO leak out of the antenna: leak current through Ccb scales with it, about 20·log(Cnew/0.7) dB".into(), Some("transistor datasheet: Cob, pF".into())); }+        if !has(raw, &["rf", "cbePf"]) { item(1, "the transistors' base-emitter capacitance".into(), json!("2 pF (default)"), "default", None, "Cib or Cbe (input capacitance)", vec!["rf".into(), "cbePf".into()], "the LO leak's divider into the antenna".into(), Some("transistor datasheet: Cib, pF".into())); }+    }+    for n in rev["reflections"]["nets"].as_array().into_iter().flatten().filter(|n| n["ran"] == true && n["electricallyShort"] == false) {+        let net = n["net"].as_str().unwrap_or(""); let drv = n["driver"].as_str().unwrap_or("").split('.').next().unwrap_or("").to_string();+        let over = n["inUse"]["overshootPct"].as_f64().unwrap_or(0.0);+        if !has(raw, &["tdr", net, "driverOhm"]) || !has(raw, &["tdr", net, "riseNs"]) { item(if over > 15.0 { 0 } else { 1 }, format!("{net}'s driver: output resistance and edge"), json!(format!("{} Ω, {} ns", n["driverOhm"], n["edgeNs"])), "default", Some(drv.as_str()), "the pin's output impedance and rise time at its drive-strength setting (the IBIS model, or VOH/IOH and the slew setting)", vec!["tdr".into(), net.into(), "driverOhm".into()], format!("{net}'s overshoot ({over:.0} % now) and the series resistor it needs"), Some(format!("{} datasheet or IBIS: output impedance, Ω", mpn(&drv)))); }+    }+    items.sort_by_key(|x| x.0);+    let rank = |r: u8| match r { 0 => "high", 1 => "medium", _ => "low" };+    let list: Vec<Value> = items.iter().map(|(r, v)| { let mut v = v.clone(); v["matters"] = json!(rank(*r)); v }).collect();+    json!({"items": list, "counts": {"high": items.iter().filter(|x| x.0 == 0).count(), "medium": items.iter().filter(|x| x.0 == 1).count(), "low": items.iter().filter(|x| x.0 == 2).count()}, "specPatch": patch,+        "howTo": "Read each part's datasheet for its datasheetField (the MPN is in part.mpn), put the number at specKey (specPatch has the shape, each value a placeholder naming its source), and re-run; the item leaves this list once the spec gives it."})+}
src/main.rs+6−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 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 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;@@ -189,6 +189,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     let cmv = cm::review(&f, &b, &spec, &review, &mut iss);     let cev = conducted::review(&b, &spec, &review["converter"], &mut iss);     let xsv = xsec::review(&b, &spec, &chk["stackup"], &mut iss);+    let tdv = tdr::review(&b, &spec, &xsv, out.as_path(), &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);@@ -245,6 +246,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     review["cableEmissions"] = cmv;     review["conductedEmissions"] = cev;     review["impedance"] = xsv;+    review["reflections"] = tdv;     review["compliance"] = compliance::review(&b, &spec, &review);     summary["fragments"] = fragments;     summary["review"] = review;@@ -252,6 +254,8 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     if !f.fan.is_empty() { summary["airStates"] = json!({"model": "fan level s: h = (h_still³ + (h_forced·sⁿ)³)^⅓ on every exposed face (board, bodies, a package sink), s = 0 is still air and s = 1 the spec's moving air; prescribed, not CFD", "exponent": spec.fanExponent.unwrap_or(0.5), "levels": f.fan.iter().map(|l| json!({"name": fan_name(l.level), "level": l.level, "hBoardWM2k": (l.h_board_w_m2k * 100.0).round() / 100.0, "residual": l.residual, "chips": l.chips.iter().map(|(r, t)| json!({"reference": r, "riseC": t})).collect::<Vec<_>>(), "solids": l.solids})).collect::<Vec<_>>()}); }     summary["flow"] = flow::write(out, &f, &b, &spec)?;     summary["board"]["stackupFrom"] = serde_json::json!(stack_from); if !auto_switch.is_empty() { summary["switchingLoads"] = serde_json::json!(auto_switch.iter().map(|(n, a)| serde_json::json!({"net": n, "amps": a, "note": "a switching node with no load in the spec: solved with the current of the net across its inductor (else the largest load); set loads.<net> to change it"})).collect::<Vec<_>>()); } if let Some((g, a)) = &auto_ground { summary["groundReturn"] = serde_json::json!({"net": g, "amps": a, "note": "not in the spec: solved with the largest supply load as the return current (set loads.<ground> to change it)"}); }+    { let raw: Value = serde_json::from_slice(&spec_bytes).unwrap_or(Value::Null); let mut rv = summary["review"].clone(); rv["stackupFrom"] = json!(if stack_from == "default" { "default" } else { stack_from });+      summary["review"]["inputs"] = inputs::review(&b, &spec, &raw, &rv, &summary["chips"]); }     summary["board"]["name"] = serde_json::json!(spec.board.split('(').next().unwrap_or(&spec.board).trim());     summary["board"]["description"] = serde_json::json!(spec.board);     summary["log"] = serde_json::json!(lines);@@ -283,6 +287,7 @@ fn main() {                         { let r = &s["review"]; let c = &r["converter"]; let g = &r["ground"]; format!("Review: {}{}{}.", if c.is_null() { "no switching converter modelled".to_string() } else { format!("hot loop {} {} mm² (best {} mm²), emissions margin {} dB at {} MHz (FCC B, loop only), output ripple {:.1} mV", c["hotLoop"]["cap"].as_str().unwrap_or("?"), c["hotLoop"]["mm2"], c["idealMm2"], c["emissions"]["worstMarginDb"], c["emissions"]["atMHz"], c["rippleOutMv"].as_f64().unwrap_or(0.0)) }, if g.is_null() { String::new() } else { format!("; {}{} island(s), edge stitched {} %", if g["offsetMv"].is_number() { format!("ground offset {} mV, ", g["offsetMv"]) } else { "ground offset not solved (ground is not a loaded net), ".to_string() }, g["islands"].as_array().map_or(0, |x| x.len()), g["stitching"]["edgeCoveredPct"]) }, r["reference"].as_array().map(|a| a.iter().filter(|x| x["unreferencedPct"].as_f64().unwrap_or(0.0) > 5.0).take(5).map(|x| format!("; {} {}% without ground under it", x["net"].as_str().unwrap_or(""), x["unreferencedPct"])).collect::<String>()).unwrap_or_default()) },                         { let all = s["review"]["pdn"]["rails"].as_array().cloned().unwrap_or_default(); let rs: Vec<Value> = all.iter().filter(|r| r["skipped"].is_null()).cloned().collect(); let sk: String = all.iter().filter(|r| !r["skipped"].is_null()).map(|r| format!("; {} not checked: {}", r["net"].as_str().unwrap_or(""), r["skipped"].as_str().unwrap_or(""))).collect(); if rs.is_empty() { format!("PDN: no rail checked{}.", sk) } else { format!("PDN (|Z| seen from each load pin against V·tol/step): {}{}.", rs.iter().map(|r| format!("{} {}× target at {} Hz (from {})", r["net"].as_str().unwrap_or(""), r["worst"]["ratio"], r["worst"]["hz"], r["load"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "), if rs.iter().all(|r| r["spice"]["ran"] == true) { format!("; ngspice agrees within {} %{}", rs.iter().map(|r| r["spice"]["maxErrPct"].as_f64().unwrap_or(0.0)).fold(0.0, f64::max), sk) } else { sk }) } },                         { let c = &s["review"]["compliance"]; let v = |t: &str| { let x = &c[t.split(' ').next().unwrap_or(t)]; if x["verdict"] == "not modelled" { format!("{t} not modelled") } else { format!("{t} {} (worst {} {}…{} dB)", x["verdict"].as_str().unwrap_or(""), x["worst"].as_str().unwrap_or(""), x["worstBand"][0], x["worstBand"][1]) } }; let f: Vec<String> = c["toSixDb"][0]["fixes"].as_array().into_iter().flatten().map(|x| x["id"].as_str().unwrap_or("").to_string()).collect(); format!("Compliance ({}): {}; {}{}.{} review.compliance has every source, the fixes by dB and a bench plan.", c["limit"].as_str().unwrap_or(""), v("radiated"), v("conducted"), if c["antenna"]["verdict"] == "not modelled" { String::new() } else { format!("; {}", v("antenna port")) }, if f.is_empty() { String::new() } else { format!(" Cheapest set to {}: {}.", if c["toSixDb"][0]["reaches6Db"] == true { "6 dB everywhere" } else { "get closest to 6 dB" }, f.join(", ")) }) },+                        { let i = &s["review"]["inputs"]; let top: Vec<String> = i["items"].as_array().into_iter().flatten().filter(|x| x["matters"] == "high").take(4).map(|x| format!("{}{}", x["what"].as_str().unwrap_or(""), x["part"]["mpn"].as_str().filter(|m| !m.is_empty()).map(|m| format!(" ({m})")).unwrap_or_default())).collect(); format!("Inputs assumed: {} that matter most, {} medium, {} low{}. review.inputs names each datasheet field and spec key; specPatch is the fragment to fill.", i["counts"]["high"], i["counts"]["medium"], i["counts"]["low"], if top.is_empty() { String::new() } else { format!(": {}", top.join("; ")) }) },                         { let p = s["fixPlan"].as_array().cloned().unwrap_or_default(); if p.is_empty() { "Fix plan: nothing to change.".to_string() } else { format!("Fix plan ({} step(s), fields.json fixPlan has the actions): {}", p.len(), p.iter().take(5).map(|x| format!("{}) [{}] {}", x["step"], x["kind"].as_str().unwrap_or(""), x["do"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(" ")) } },                         if s["parts"].is_null() { "Parts: skipped (--no-parts).".to_string() } else { format!("Parts: {} with 3D models, {} as boxes, {} bare{}.", s["parts"]["modelled"], s["parts"]["boxed"], s["parts"]["bare"], if s["parts"]["failed"].as_array().map_or(false, |a| !a.is_empty()) { "; some models did not convert (see the log above; --models-dir adds a folder to search)" } else { "" }) },                         { let mut v: Vec<String> = s["switchingLoads"].as_array().into_iter().flatten().map(|x| format!("{} (switching, {} A)", x["net"].as_str().unwrap_or(""), x["amps"])).collect(); if let Some(g) = s["groundReturn"].as_object() { v.push(format!("{} (return, {} A)", g["net"].as_str().unwrap_or(""), g["amps"])); } if v.is_empty() { "Loads: all from the spec.".to_string() } else { format!("Loads filled in (not in the spec): {}; set loads.<net> to change them.", v.join(", ")) } },
src/tdr.rsadded+155
@@ -0,0 +1,155 @@+//! Reflections and TDR (0.9.6): a net's copper walked from its driver pad to its receiver pad, each segment a lossless+//! transmission line with the cross-section solve's own Z0 and delay (crate::xsec), each via on the path its pad+//! capacitance (Johnson & Graham: C ≈ 1.41·εr·T·D1/(D2 − D1) pF, inches), the receiver a capacitance. Two ngspice runs:+//!   TDR      a 50 Ω step source (35 ps edge) into the path, the far end at the receiver: Z(t) = 50·(1+ρ)/(1−ρ), ρ = 2·V − 1+//!   in use   the net's real driver (its output resistance and edge) into the same path: overshoot, undershoot, ringing+//! A net shorter than a sixth of its edge (round trip under a third) is electrically short and only says so.+//! Branches off the path are counted, not modelled. The nets: spec impedance targets, clocks, length groups, spec tdr.+use crate::board::Board;+use crate::fields::{Issue, Spec};+use serde::Deserialize;+use serde_json::{json, Value};+use std::collections::{BTreeMap, BTreeSet, BinaryHeap};+use std::cmp::Reverse;++#[allow(non_snake_case)]+#[derive(Deserialize, Clone, Debug, Default)]+pub struct TdrCfg { #[serde(default)] pub driverOhm: Option<f64>, #[serde(default)] pub riseNs: Option<f64>, #[serde(default)] pub loadPf: Option<f64>, #[serde(default)] pub from: Option<String>, #[serde(default)] pub to: Option<String> }++fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }+type P = (i64, i64);+fn key(x: f64, y: f64) -> P { ((x * 20.0).round() as i64, (y * 20.0).round() as i64) } // 0.05 mm++enum El { Line { z0: f64, td: f64, len: f64, layer: String, w: f64 }, Via { c: f64 } }++/// the path from one pad to another over the net's segments and vias: Dijkstra on length+fn path(b: &Board, net: &str, from: (f64, f64), to: (f64, f64)) -> Option<(Vec<(usize, bool)>, Vec<P>, usize)> {+    let ts: Vec<(usize, &crate::board::Track)> = b.tracks.iter().enumerate().filter(|(_, t)| t.net == net).collect();+    // nodes: (layer index, point); a via joins its point on every layer+    let mut id: BTreeMap<(String, P), usize> = BTreeMap::new(); let mut pts: Vec<(String, P)> = Vec::new();+    let mut node = |l: &str, p: P, id: &mut BTreeMap<(String, P), usize>| -> usize { let k = (l.to_string(), p); if let Some(i) = id.get(&k) { return *i; } let i = pts.len(); pts.push(k.clone()); id.insert(k, i); i };+    let mut adj: Vec<Vec<(usize, f64, Option<(usize, bool)>)>> = Vec::new();+    let mut edge = |a: usize, c: usize, w: f64, e: Option<(usize, bool)>, adj: &mut Vec<Vec<(usize, f64, Option<(usize, bool)>)>>| { while adj.len() <= a.max(c) { adj.push(Vec::new()); } adj[a].push((c, w, e)); adj[c].push((a, w, e.map(|(i, f)| (i, !f)))); };+    for (i, t) in &ts { let a = node(&t.layer, key(t.a.0, t.a.1), &mut id); let c = node(&t.layer, key(t.b.0, t.b.1), &mut id); edge(a, c, (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1), Some((*i, true)), &mut adj); }+    let mut vias = Vec::new();+    for v in b.vias.iter().filter(|v| v.net == net) { let k = key(v.x, v.y); let ls: Vec<usize> = (0..pts.len()).filter(|i| pts[*i].1 == k).collect(); for w in ls.windows(2) { edge(w[0], w[1], 0.0, None, &mut adj); } if ls.len() > 1 { vias.push(k); } }+    // a pad: every segment end within 0.6 mm on any layer+    let near = |p: (f64, f64)| -> Vec<usize> { (0..pts.len()).filter(|i| { let q = pts[*i].1; ((q.0 as f64 / 20.0 - p.0).hypot(q.1 as f64 / 20.0 - p.1)) < 0.6 }).collect() };+    let (s, g) = (near(from), near(to)); if s.is_empty() || g.is_empty() { return None; }+    let n = pts.len(); while adj.len() < n { adj.push(Vec::new()); }+    let mut dist = vec![f64::MAX; n]; let mut prev: Vec<Option<(usize, Option<(usize, bool)>)>> = vec![None; n];+    let mut h = BinaryHeap::new(); for &i in &s { dist[i] = 0.0; h.push(Reverse(((0.0f64 * 1000.0) as i64, i))); }+    let mut done = vec![false; n];+    while let Some(Reverse((_, u))) = h.pop() { if done[u] { continue; } done[u] = true; for &(v, w, e) in &adj[u] { let nd = dist[u] + w; if nd < dist[v] - 1e-9 { dist[v] = nd; prev[v] = Some((u, e)); h.push(Reverse(((nd * 1e6) as i64, v))); } } }+    let end = *g.iter().min_by(|a, z| dist[**a].partial_cmp(&dist[**z]).unwrap())?; if dist[end] == f64::MAX { return None; }+    let mut segs = Vec::new(); let mut via_hits = Vec::new(); let mut cur = end; let mut seen_nodes = BTreeSet::new();+    while let Some((p, e)) = prev[cur] { if !seen_nodes.insert(cur) || seen_nodes.len() > n { break; } match e { Some(x) => segs.push(x), None => via_hits.push(pts[cur].1) } cur = p; }+    seen_nodes.insert(cur); segs.reverse(); via_hits.reverse(); via_hits.dedup();+    // branches: segment ends on the path with more edges than the path uses+    let branches = seen_nodes.iter().filter(|i| adj[**i].iter().filter(|x| x.2.is_some() && !seen_nodes.contains(&x.0)).count() > 0).count();+    Some((segs, via_hits, branches))+}++fn pad_of(b: &Board, net: &str, name: Option<&str>, prefer_ic: bool) -> Option<(String, (f64, f64))> {+    let ps: Vec<_> = b.pads.iter().filter(|p| p.net == net).collect();+    if let Some(n) = name { let (r, pd) = n.split_once('.').unwrap_or((n, "")); return ps.iter().find(|p| p.reference == r && (pd.is_empty() || p.pad == pd)).map(|p| (format!("{}.{}", p.reference, p.pad), (p.x, p.y))); }+    let pick = if prefer_ic { ps.iter().find(|p| p.reference.starts_with('U')).or(ps.first()) } else { ps.first() };+    pick.map(|p| (format!("{}.{}", p.reference, p.pad), (p.x, p.y)))+}++fn spice(deck: &str, dir: &std::path::Path, tag: &str) -> Option<Vec<(f64, f64, f64)>> {+    let cir = dir.join(format!("tdr-{tag}.cir")); let out = dir.join(format!("tdr-{tag}.txt"));+    std::fs::write(&cir, deck.replace("@OUT@", &out.display().to_string())).ok()?;+    let r = std::process::Command::new("timeout").args(["30", "ngspice", "-b"]).arg(&cir).output().ok()?; if !r.status.success() { return None; }+    let t = std::fs::read_to_string(&out).ok()?; let _ = std::fs::remove_file(&out); let _ = std::fs::remove_file(&cir);+    Some(t.lines().filter_map(|l| { let v: Vec<f64> = l.split_whitespace().filter_map(|x| x.parse().ok()).collect(); if v.len() >= 4 { Some((v[0], v[1], v[3])) } else { None } }).collect())+}+fn deck(els: &[El], rs: f64, tr: f64, cl: f64, tstop: f64) -> String {+    let mut d = format!("tdr\nV1 in 0 PULSE(0 1 0 {tr:e} {tr:e} 1 2)\nRs in n0 {rs}\n"); let mut k = 0;+    for (i, e) in els.iter().enumerate() { match e { El::Line { z0, td, .. } => { d += &format!("T{i} n{k} 0 n{} 0 Z0={z0} TD={td:e}\n", k + 1); k += 1; } El::Via { c } => { d += &format!("C{i} n{k} 0 {c:e}\n"); } } }+    d += &format!("Cl n{k} 0 {cl:e}\nRl n{k} 0 1e7\n.tran {:e} {tstop:e}\n.control\nrun\nwrdata @OUT@ v(n0) v(n{k})\n.endc\n.end\n", (tr / 20.0).min(2e-12).max(2e-13)); d+}++pub fn review(b: &Board, spec: &Spec, xs: &Value, out: &std::path::Path, issues: &mut Vec<Issue>) -> Value {+    let mut nets: BTreeSet<String> = spec.tdr.keys().cloned().collect();+    for k in spec.impedance.keys() { for n in k.split('/') { nets.insert(n.to_string()); } }+    for k in spec.clocks.keys() { nets.insert(k.clone()); }+    for g in spec.lengthGroups.values() { for n in &g.nets { nets.insert(n.clone()); } }+    if nets.is_empty() { return json!({"ran": false, "why": "no net to check: give spec impedance targets, clocks, lengthGroups or tdr {net: {driverOhm, riseNs, loadPf, from, to}}"}); }+    let has_spice = std::process::Command::new("ngspice").arg("-v").output().map(|o| o.status.success()).unwrap_or(false);+    let rows_of = |net: &str| xs["nets"].as_array().and_then(|a| a.iter().find(|x| x["net"] == net)).and_then(|x| x["segments"].as_array().cloned()).unwrap_or_default();+    let thick: f64 = if spec.real_gaps.is_empty() { 1.6 } else { spec.real_gaps.iter().sum::<f64>() };+    let er = spec.epsilonR.unwrap_or(4.3);+    let mut res = Vec::new();+    for net in nets.iter().take(24) {+        let cfg = spec.tdr.get(net).cloned().unwrap_or_default();+        let Some((dn, dp)) = pad_of(b, net, cfg.from.as_deref(), true) else { res.push(json!({"net": net, "ran": false, "why": "no pads on this net"})); continue };+        let to = match cfg.to.as_deref() { Some(t) => pad_of(b, net, Some(t), false), None => b.pads.iter().filter(|p| p.net == *net && format!("{}.{}", p.reference, p.pad) != dn).max_by(|a, z| (a.x - dp.0).hypot(a.y - dp.1).partial_cmp(&(z.x - dp.0).hypot(z.y - dp.1)).unwrap()).map(|p| (format!("{}.{}", p.reference, p.pad), (p.x, p.y))) };+        let Some((rn, rp)) = to else { res.push(json!({"net": net, "ran": false, "why": "one pad only"})); continue };+        let Some((segs, vias, branches)) = path(b, net, dp, rp) else { res.push(json!({"net": net, "ran": false, "why": format!("no copper path from {dn} to {rn} over segments and vias (a pour or an unrouted net)")})); continue };+        let rows = rows_of(net);+        let mut els: Vec<El> = Vec::new(); let mut vi = 0; let mut last_layer = String::new();+        for (si, _) in &segs {+            let t = &b.tracks[*si];+            if !last_layer.is_empty() && last_layer != t.layer && vi < vias.len() {+                let v = b.vias.iter().filter(|v| v.net == *net).min_by(|a, z| (key(a.x, a.y).0 - vias[vi].0).abs().cmp(&(key(z.x, z.y).0 - vias[vi].0).abs())).map(|v| (v.size, v.drill)).unwrap_or((0.6, 0.3));+                let (d1, d2, ti) = (v.0 / 25.4, (v.0 + 0.5) / 25.4, thick / 25.4); els.push(El::Via { c: 1.41 * er * ti * d1 / (d2 - d1) * 1e-12 }); vi += 1;+            }+            last_layer = t.layer.clone();+            let len = (t.a.0 - t.b.0).hypot(t.a.1 - t.b.1); if len < 1e-6 { continue; }+            let row = rows.iter().find(|r| r["layer"] == t.layer.as_str() && (r["widthMm"].as_f64().unwrap_or(0.0) - t.w).abs() < 1e-3);+            let (z0, ps) = row.map(|r| (r["z0"].as_f64().unwrap_or(50.0), r["psPerMm"].as_f64().unwrap_or(6.7))).unwrap_or((50.0, 6.7));+            // adjacent segments of one width and layer are one line+            if let Some(El::Line { z0: z, td, len: l, layer, w }) = els.last_mut() { if *layer == t.layer && (*w - t.w).abs() < 1e-6 && (*z - z0).abs() < 1e-9 { *td += len * ps * 1e-12; *l += len; continue; } }+            els.push(El::Line { z0, td: len * ps * 1e-12, len, layer: t.layer.clone(), w: t.w });+        }+        let td: f64 = els.iter().map(|e| if let El::Line { td, .. } = e { *td } else { 0.0 }).sum();+        let len: f64 = els.iter().map(|e| if let El::Line { len, .. } = e { *len } else { 0.0 }).sum();+        let clock_tr = spec.clocks.get(net).map(|c| 0.07 / c.top_hz().max(1.0)); // a clock's edge: about 7 % of its period+        let tr = cfg.riseNs.map(|x| x * 1e-9).or(clock_tr).unwrap_or(1e-9).max(2e-11);+        let (rs, cl) = (cfg.driverOhm.unwrap_or(25.0), cfg.loadPf.unwrap_or(3.0) * 1e-12);+        let zs: Vec<f64> = els.iter().filter_map(|e| if let El::Line { z0, .. } = e { Some(*z0) } else { None }).collect();+        let zline = if zs.is_empty() { 50.0 } else { els.iter().filter_map(|e| if let El::Line { z0, len, .. } = e { Some(z0 * len) } else { None }).sum::<f64>() / len.max(1e-9) };+        let target = spec.impedance.get(net).copied();+        let short = td < tr / 6.0;+        let mut row = json!({"net": net, "ran": true, "driver": dn, "receiver": rn, "lengthMm": r1(len), "delayPs": r1(td * 1e12), "segments": els.iter().filter(|e| matches!(e, El::Line { .. })).count(), "vias": els.iter().filter(|e| matches!(e, El::Via { .. })).count(), "branches": branches, "edgeNs": (tr * 1e12).round() / 1e3, "driverOhm": rs, "loadPf": cl * 1e12, "zMin": zs.iter().cloned().fold(f64::MAX, f64::min), "zMax": zs.iter().cloned().fold(0.0, f64::max), "targetOhm": target, "electricallyShort": short});+        if short { row["note"] = json!(format!("{:.0} ps of line against a {:.0} ps edge: shorter than a sixth of it, so reflections stay inside the edge", td * 1e12, tr * 1e12)); }+        if has_spice && !els.is_empty() {+            // TDR: 50 Ω, a 35 ps step, long enough to see the far end+            if let Some(w) = spice(&deck(&els, 50.0, 35e-12, cl, (2.0 * td + 400e-12).max(1e-9)), out, &format!("z-{}", res.len())) {+                let prof: Vec<Value> = w.iter().step_by((w.len() / 160).max(1)).filter(|p| p.0 >= 35e-12 && p.0 <= 2.0 * td + 100e-12).map(|p| { let rho = (2.0 * p.1 - 1.0).clamp(-0.99, 0.99); json!([r1(p.0 * 1e12), r1(50.0 * (1.0 + rho) / (1.0 - rho))]) }).collect();+                row["tdr"] = json!({"columns": ["ps", "ohm"], "points": prof, "source": "50 Ω, 35 ps step, ngspice lossless lines"});+            }+            // in use: the driver's edge into the receiver+            if let Some(w) = spice(&deck(&els, rs, tr, cl, (6.0 * td + 4.0 * tr).max(2e-9)), out, &format!("u-{}", res.len())) {+                let vmax = w.iter().map(|p| p.2).fold(0.0, f64::max); let after: Vec<f64> = w.iter().filter(|p| p.0 > tr).map(|p| p.2).collect();+                let vmin = after.iter().cloned().skip_while(|v| *v < 0.9).fold(1.0, f64::min);+                let over = (vmax - 1.0).max(0.0) * 100.0; let under = (1.0 - vmin).max(0.0) * 100.0;+                let settle = w.iter().rev().find(|p| (p.2 - 1.0).abs() > 0.05).map(|p| p.0).unwrap_or(0.0);+                let r_series = (zline - rs).max(0.0);+                row["receiver"] = json!(rn); row["inUse"] = json!({"overshootPct": r1(over), "undershootPct": r1(under), "settleNs": (settle * 1e12).round() / 1e3, "seriesTerminationOhm": r_series.round()});+                if over > 15.0 && !short {+                    issues.push(Issue { severity: if over > 30.0 { "medium".into() } else { "low".into() }, kind: "reflection".into(), what: format!("{net}: {:.0} % overshoot and {:.0} % ringing back at {rn} ({:.0} mm, {:.0} ps of {:.0} Ω line driven by {rs:.0} Ω with a {:.2} ns edge)", over, under, len, td * 1e12, zline, tr * 1e9), at: Some([dp.0, dp.1]), layer: None, net: Some(net.clone()), reference: Some(dn.split('.').next().unwrap_or("").into()), fix: format!("Add a {:.0} Ω series resistor at {dn} (the line's {:.0} Ω less the driver's {rs:.0} Ω), or slow the edge; set spec tdr.{net}.driverOhm and riseNs from the driver's datasheet first.", r_series.max(10.0), zline), action: Some(json!({"type": "add-part", "what": "series termination resistor", "net": net, "nearPin": dn, "valueOhm": r_series.max(10.0).round(), "why": "reflection"})) });+                }+            }+        }+        if let Some(tz) = target { let dev = zs.iter().map(|z| (z - tz).abs() / tz).fold(0.0, f64::max); if dev > 0.1 { row["worstDeviationPct"] = json!(r1(dev * 100.0)); } }+        res.push(row);+    }+    json!({"ran": true, "ngspice": has_spice, "model": "each path segment a lossless line with the cross-section solve's Z0 and delay, vias as pad capacitance, receiver as a capacitance; 50 Ω TDR and the real driver in ngspice", "defaults": "driver 25 Ω, edge 1 ns (a clock: 7 % of its period), receiver 3 pF: set spec tdr.<net> {driverOhm, riseNs, loadPf, from, to}", "nets": res})+}++#[cfg(test)] mod tests {+    use super::*;+    #[test] fn matched_line_is_clean() {+        // a 50 Ω line from a 50 Ω source into a tiny load: the TDR reads 50 Ω along the line+        if std::process::Command::new("ngspice").arg("-v").output().is_err() { return; }+        let dir = std::env::temp_dir();+        let els = vec![El::Line { z0: 50.0, td: 200e-12, len: 30.0, layer: "F.Cu".into(), w: 0.3 }];+        let w = spice(&deck(&els, 50.0, 35e-12, 1e-15, 1e-9), &dir, "test").unwrap();+        let mid: Vec<f64> = w.iter().filter(|p| p.0 > 100e-12 && p.0 < 350e-12).map(|p| p.1).collect();+        assert!(mid.iter().all(|v| (v - 0.5).abs() < 0.02), "{:?}", &mid[..3]);+        // the open far end doubles: 1 V at the receiver after the delay+        assert!(w.iter().any(|p| p.0 > 300e-12 && (p.2 - 1.0).abs() < 0.05));+    }+}
src/xsec.rs+1−1
@@ -168,7 +168,7 @@ pub fn review(b: &Board, spec: &Spec, stack: &Value, issues: &mut Vec<Issue>) ->             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 let Some(zr) = cache.get(&key) { rows.push(json!({"layer": layer, "widthMm": *wum as f64 / 1000.0, "lengthMm": r1(*len), "z0": r1(zr.z0), "eeff": (zr.eeff * 100.0).round() / 100.0, "psPerMm": (zr.eeff.sqrt() / C0 * 1e9 * 100.0).round() / 100.0, "pfPerM": r1(zr.cpf_m), "nhPerM": r1(zr.lnh_m)})); }         }         if rows.is_empty() { continue; }         let target = targets.get(net).copied();