← Commit history

adom-fields 0.9.2: decoupling optimiser, conducted emissions

John Lauer ·087519acdc · ·parent fb1e2e9
10 files changed +156−28
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.9.1"+version = "0.9.2" edition = "2021" license = "MIT" description = "Adom Fields: where the current and the heat go on your board. Current density, copper heat flow and board temperature from the board file itself, in 3D, for the AI to drive and the human to explore."
SKILL.md+8
@@ -177,6 +177,14 @@ 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.+- **Decoupling optimiser** (0.9.2, PDN rail `decapPlan`).+  - A beam search over sets of up to six new capacitors at the load pin (MLCC values in 0402/0603/0805 at their DC bias, polymer) for the cheapest set by estimated cost, then area, that meets the rail's target (`cheapest`, `optionsMeetingTarget`).+  - When none can, the lowest-|Z| set (`bestPossible`).+  - On a rail that already passes, the existing capacitors it can lose (`removable`; never the converter's own input or output capacitors).+- **Conducted emissions** (0.9.2, `review.conductedEmissions`, `conducted-emissions`).+  - The converter's trapezoid input current split between its input capacitors (at bias) and any input filter into a LISN (50 Ω ∥ 50 µH), against CISPR 32 / FCC 15.107 class B (or A with `emcLimit` *-a), 150 kHz to 30 MHz.+  - The fix sizes the input-filter inductor for the attenuation needed.+  - Differential mode only. - **Electro-thermal** (0.9.1, `review.electroThermal`). Copper's resistance rises 0.393 %/°C, so every run reports the first-order extra Joule heat at the solved temperatures, and an `electro-thermal` issue when it is over 10 %. With spec `electroThermal: true`, a second full pass solves the current and heat with each copper cell at its solved temperature. On the ESC: +33 % Joule, peak rise 109 → 119 °C. - **Heating transient** (0.9.1, `transient`). Spec `transient` {durationS, steps (24), powerScale (1; a stall or surge at 3× is 3), fromSteady}: implicit Euler on the still-air heat system with copper, FR4 and the solid bodies' heat capacities.   - It gives the board peak and each hot part's rise over time, and each part's time constant (`t63S`, to 63 % of its steady rise).
docs/release-0.9.2.mdadded+13
@@ -0,0 +1,13 @@+# adom-fields 0.9.2++- **Decoupling optimiser (`decapPlan` on each PDN rail).**+  - **The search:** a beam search over sets of up to six new capacitors at the load pin, using MLCC values in 0402, 0603 and 0805 (each at its DC bias) and polymer parts.+  - **Results:** the cheapest set that meets the rail's target impedance, ranked by estimated part cost then board area, and up to five options. When no set can meet it, the set that gets lowest, with the note that the copper's inductance sets the floor.+  - **On a rail that already passes:** the existing capacitors it could lose. The converter's own input and output capacitors are never offered.+  - **Prices and footprints:** typical catalogue values, for ranking.+- **Conducted emissions (`review.conductedEmissions`, `conducted-emissions`).**+  - **The model:** a switching converter's input current pulses split between its input capacitors (at their bias, with ESR and ESL) and any input filter, and into a LISN (50 Ω ∥ 50 µH).+  - **The check:** the voltage per harmonic against CISPR 32 / FCC 15.107 class B quasi-peak (class A with `emcLimit` set to a class-A set), from 150 kHz to 30 MHz, with the spectrum in the report.+  - **The fix:** the input-filter inductor that gains the missing attenuation.+  - **On the TPS54202 buck with no input filter:** 89 dBµV at 500 kHz, 33 dB over class B. A worked hand check gives the same number.+  - **Scope:** differential mode only.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.9.1",+  "version": "0.9.2",   "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.1",+  "version": "0.9.2",   "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+8
@@ -177,6 +177,14 @@ 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.+- **Decoupling optimiser** (0.9.2, PDN rail `decapPlan`).+  - A beam search over sets of up to six new capacitors at the load pin (MLCC values in 0402/0603/0805 at their DC bias, polymer) for the cheapest set by estimated cost, then area, that meets the rail's target (`cheapest`, `optionsMeetingTarget`).+  - When none can, the lowest-|Z| set (`bestPossible`).+  - On a rail that already passes, the existing capacitors it can lose (`removable`; never the converter's own input or output capacitors).+- **Conducted emissions** (0.9.2, `review.conductedEmissions`, `conducted-emissions`).+  - The converter's trapezoid input current split between its input capacitors (at bias) and any input filter into a LISN (50 Ω ∥ 50 µH), against CISPR 32 / FCC 15.107 class B (or A with `emcLimit` *-a), 150 kHz to 30 MHz.+  - The fix sizes the input-filter inductor for the attenuation needed.+  - Differential mode only. - **Electro-thermal** (0.9.1, `review.electroThermal`). Copper's resistance rises 0.393 %/°C, so every run reports the first-order extra Joule heat at the solved temperatures, and an `electro-thermal` issue when it is over 10 %. With spec `electroThermal: true`, a second full pass solves the current and heat with each copper cell at its solved temperature. On the ESC: +33 % Joule, peak rise 109 → 119 °C. - **Heating transient** (0.9.1, `transient`). Spec `transient` {durationS, steps (24), powerScale (1; a stall or surge at 3× is 3), fromSteady}: implicit Euler on the still-air heat system with copper, FR4 and the solid bodies' heat capacities.   - It gives the board peak and each hot part's rise over time, and each part's time constant (`t63S`, to 63 % of its steady rise).
src/conducted.rsadded+70
@@ -0,0 +1,70 @@+//! Conducted emissions (0.9.2): a switching converter's input current is a pulse train; its harmonics split between the+//! input capacitors and the supply cable, and the test lab's LISN (50 Ω ∥ 50 µH) reads the cable's share as a voltage.+//!   I_h(n) = 2·Iin_pk·D·|sinc(πnD)|·|sinc(πn·tr/T)|          (the trapezoid's line amplitude)+//!   I_lisn = I_h · Z_cin / (Z_cin + Z_filter + Z_lisn),      V = I_lisn · |Z_lisn|  (the 50 Ω port)+//! Z_cin: the converter's input capacitors at their DC bias, each with its ESR and about 1.5 nH of ESL and mounting.+//! Z_filter: an inductor (or ferrite) found in series on the input, else nothing. Differential mode only: common-mode+//! conducted noise needs the board's parasitic capacitance to the chassis, which is not modelled.+//! Compared with CISPR 32 / FCC 15.107 class B (or A) quasi-peak, 150 kHz to 30 MHz (a harmonic's amplitude stands in for+//! its quasi-peak: conservative).+use crate::board::Board;+use crate::fields::{Issue, Spec};+use serde_json::{json, Value};+use std::f64::consts::PI;++#[derive(Clone, Copy)] struct Cx(f64, f64);+impl Cx { fn add(self, o: Cx) -> Cx { Cx(self.0 + o.0, self.1 + o.1) } fn mul(self, o: Cx) -> Cx { Cx(self.0 * o.0 - self.1 * o.1, self.0 * o.1 + self.1 * o.0) } fn inv(self) -> Cx { let d = self.0 * self.0 + self.1 * self.1; Cx(self.0 / d, -self.1 / d) } fn abs(self) -> f64 { self.0.hypot(self.1) } }++/// quasi-peak limit, dBµV, 150 kHz to 30 MHz+pub fn limit(class_a: bool, f: f64) -> f64 {+    let m = f / 1e6;+    if class_a { if m < 0.5 { 79.0 } else { 73.0 } }+    else if m < 0.5 { 66.0 - 10.0 * ((m / 0.15).log10() / (0.5f64 / 0.15).log10()) } else if m < 5.0 { 56.0 } else { 60.0 }+}++fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }++pub fn review(b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec<Issue>) -> Value {+    let Some(fsw) = conv["fswHz"].as_f64() else { return json!({"ran": false, "why": "no switching converter found"}) };+    let (iout, duty, tr) = (conv["iout"].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 ripple = conv["rippleA"].as_f64().unwrap_or(0.0);+    let ipk = iout + ripple / 2.0; let t = 1.0 / fsw;+    let class_a = spec.emcLimit.as_deref().is_some_and(|s| s.ends_with("-a"));+    let inn = conv["inNet"].as_str().unwrap_or("");+    // the input filter: an inductor or ferrite with one pad on the converter's input net, not the converter's own inductor+    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)).map(|(r, v)| (r.clone(), if r.starts_with('L') { crate::rf::value(v) } else { None }));+    let caps: Vec<(f64, f64)> = conv["inCaps"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| Some((c["uFAtBias"].as_f64()? * 1e-6, c["esrMohm"].as_f64().unwrap_or(10.0) * 1e-3))).collect();+    if caps.is_empty() { return json!({"ran": false, "why": "no input capacitors found"}); }+    let sinc = |x: f64| if x == 0.0 { 1.0 } else { x.sin() / x };+    let (mut worst, mut wf, mut wdb) = (f64::MAX, 0.0, 0.0); let mut pts = Vec::new();+    let mut k = (150e3 / fsw).ceil().max(1.0) as u64;+    while (k as f64) * fsw <= 30e6 {+        let fr = k as f64 * fsw; let w = 2.0 * PI * fr;+        let ih = 2.0 * ipk * duty * sinc(PI * k as f64 * duty).abs() * sinc(PI * k as f64 * tr / t).abs();+        let mut ycin = Cx(0.0, 0.0); for (c, esr) in &caps { let z = Cx(*esr, w * 1.5e-9 - 1.0 / (w * c)); ycin = ycin.add(z.inv()); }+        let zcin = ycin.inv();+        let zf = match &filt { Some((_, Some(l))) => Cx(0.05, w * l), Some((_, None)) => Cx(60.0, 0.0), None => Cx(0.0, 0.0) };+        let zl = { let a = Cx(50.0, 0.0); let bz = Cx(0.0, w * 50e-6); a.mul(bz).mul(a.add(bz).inv()) };+        let i_lisn = ih * (zcin.abs() / zcin.add(zf).add(zl).abs());+        let v = i_lisn * zl.abs(); let db = 20.0 * (v * 1e6).max(1e-30).log10();+        let m = limit(class_a, fr) - db;+        if m < worst { worst = m; wf = fr; wdb = db; }+        if pts.len() < 400 { pts.push(json!([r1(fr / 1e3), r1(db), r1(limit(class_a, fr))])); }+        k += 1;+    }+    let lim_name = if class_a { "CISPR 32 / FCC 15.107 class A" } else { "CISPR 32 / FCC 15.107 class B" };+    if worst < 6.0 {+        // the extra attenuation needed, and an LC that gives it at the worst frequency (a second-order filter: 40 dB/decade above its corner)+        let need = (6.0 - worst).max(0.0); let fc = wf / 10f64.powf(need / 40.0);+        let ctot: f64 = caps.iter().map(|c| c.0).sum();+        let l_needed = 1.0 / ((2.0 * PI * fc).powi(2) * ctot);+        issues.push(Issue { severity: if worst < 0.0 { "medium".into() } else { "low".into() }, kind: "conducted-emissions".into(), what: format!("The converter's input ripple reaches the supply cable at about {:.0} dBµV at {:.0} kHz, {:.1} dB {} {} (differential mode, through the LISN)", wdb, wf / 1e3, worst.abs(), if worst < 0.0 { "over" } else { "under" }, lim_name), at: None, layer: None, net: Some(inn.to_string()), reference: filt.as_ref().map(|f| f.0.clone()), fix: format!("Add (or enlarge) an input filter: about {:.1} µH in series on {} with the input capacitors after it puts the corner near {:.0} kHz and gains about {:.0} dB at {:.0} kHz; damp it (a bulk capacitor with some ESR) and re-check the input-filter stability in the PDN view.", l_needed * 1e6, inn, fc / 1e3, need, wf / 1e3), action: Some(json!({"type": "add-part", "what": "input filter inductor", "valueUh": (l_needed * 1e7).round() / 10.0, "net": inn, "why": "conducted emissions", "marginDb": r1(worst), "atKHz": (wf / 1e3).round()})) });+    }+    json!({"ran": true, "limit": lim_name, "model": "differential-mode trapezoid input current into the input capacitors and the LISN (50 Ω ∥ 50 µH); harmonic amplitude as quasi-peak; no common mode", "inputFilter": filt.as_ref().map(|f| json!({"ref": f.0, "uH": f.1.map(|l| l * 1e6)})), "worstMarginDb": r1(worst), "atKHz": (wf / 1e3).round(), "dBuV": r1(wdb), "spectrum": {"columns": ["kHz", "dBuV", "limit"], "points": pts}})+}++#[cfg(test)] mod tests {+    use super::*;+    #[test] fn limits() { assert!((limit(false, 150e3) - 66.0).abs() < 1e-9); assert!((limit(false, 500e3) - 56.0).abs() < 1e-6); assert_eq!(limit(false, 10e6), 60.0); assert_eq!(limit(true, 1e6), 73.0); }+}
src/main.rs+3−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 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;+mod caps; mod checks; 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;@@ -178,6 +178,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     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 cev = conducted::review(&b, &spec, &review["converter"], &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"})) }); }@@ -233,6 +234,7 @@ pub fn run_analyze(board: &str, spec_path: &str, out: &PathBuf, pitch: f64, neck     review["checks"] = chk;     review["electroThermal"] = et_report.clone();     review["cableEmissions"] = cmv;+    review["conductedEmissions"] = cev;     review["impedance"] = xsv;     summary["fragments"] = fragments;     summary["review"] = review;
src/pdn.rs+43−1
@@ -342,6 +342,48 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec                 }             }         }+        // ---- the decoupling optimiser: the cheapest set of up to six new capacitors that meets the target (beam search over+        // values and kinds, by estimated part cost then board area), the lowest-|Z| set when none can, and the existing+        // capacitors the rail can do without. Typical catalogue prices and footprints, for ranking, not a quote.+        let mount = lpm * 1.0 + 0.2e-9;+        let cand_list: Vec<(Branch, f64, f64)> = {+            let rail_v = v; let mut cl: Vec<(Branch, f64, f64)> = Vec::new();+            for &c in &[1e-9, 2.2e-9, 4.7e-9, 10e-9, 22e-9, 47e-9, 100e-9, 220e-9, 470e-9, 1e-6, 2.2e-6, 4.7e-6, 10e-6, 22e-6] {+                let (pkg, span, cost, area) = if c <= 1e-6 { ("0402", 1.0, 0.004, 0.5) } else if c <= 4.7e-6 { ("0603", 1.6, 0.01, 1.3) } else { ("0805", 2.0, 0.025, 2.5) };+                let m = crate::caps::model(&lib, "", "", &format!("C_{pkg}"), c, rail_v, span);+                cl.push((Branch { name: format!("{} {}", fmt_c(c), pkg), kind: "capacitor", r: m.esr, l: m.esl + mount, lp: 0.0, rp: 0.0, c: m.c_eff, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": pkg, "type": "ceramic", "new": true}) }, cost, area));+            }+            for (c, cost) in [(47e-6, 0.25), (100e-6, 0.35)] { cl.push((Branch { name: format!("{} polymer", fmt_c(c)), kind: "capacitor", r: 0.03, l: 1.5e-9 + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": "polymer chip", "type": "polymer", "new": true}) }, cost, 12.0)); }+            cl+        };+        let mut plan = json!({});+        {+            // beam search: states are multisets of candidate indices+            let score = |set: &[usize]| -> (f64, f64, f64) { let mut t = bs.clone(); for &i in set { t.push(cand_list[i].0.clone()); } let (_, r) = worst(&curve(&t)); let cost: f64 = set.iter().map(|&i| cand_list[i].1).sum(); let area: f64 = set.iter().map(|&i| cand_list[i].2).sum(); (r, cost, area) };+            let mut beam: Vec<Vec<usize>> = vec![Vec::new()]; let mut meeting: Vec<(Vec<usize>, f64, f64, f64)> = Vec::new(); let mut lowest: (Vec<usize>, f64) = (Vec::new(), wr);+            if wr > 1.0 { for _depth in 0..6 {+                let mut next: Vec<(Vec<usize>, f64, f64, f64)> = Vec::new();+                for st in &beam { let from = st.last().copied().unwrap_or(0); for i in from..cand_list.len() { let mut ns = st.clone(); ns.push(i); let (r, c, a) = score(&ns); if r < lowest.1 { lowest = (ns.clone(), r); } if r <= 1.0 { meeting.push((ns, r, c, a)); } else { next.push((ns, r, c, a)); } } }+                if !meeting.is_empty() { break; }+                next.sort_by(|x, y| x.1.partial_cmp(&y.1).unwrap()); next.truncate(8); beam = next.into_iter().map(|x| x.0).collect();+                if beam.is_empty() { break; }+            } }+            meeting.sort_by(|x, y| x.2.partial_cmp(&y.2).unwrap().then(x.3.partial_cmp(&y.3).unwrap()));+            let desc = |set: &[usize]| -> Vec<Value> { set.iter().map(|&i| json!({"value": cand_list[i].0.extra["value"], "package": cand_list[i].0.extra["package"], "type": cand_list[i].0.extra["type"]})).collect() };+            if let Some((set, r, c, a)) = meeting.first() { plan["cheapest"] = json!({"add": desc(set), "ratioAfter": sig(*r), "costUsd": (c * 1000.0).round() / 1000.0, "areaMm2": r2(*a), "nearPin": load_name}); }+            plan["optionsMeetingTarget"] = json!(meeting.iter().take(5).map(|(set, r, c, a)| json!({"add": desc(set), "ratioAfter": sig(*r), "costUsd": (c * 1000.0).round() / 1000.0, "areaMm2": r2(*a)})).collect::<Vec<_>>());+            if meeting.is_empty() && wr > 1.0 { plan["bestPossible"] = json!({"add": desc(&lowest.0), "ratioAfter": sig(lowest.1), "note": "no set of up to six capacitors at this pin meets the target: the copper's inductance sets the floor"}); }+            // the existing capacitors this rail can lose and still meet its target (greedy, largest saving first)+            if wr <= 1.0 {+                let mut keep = bs.clone(); let mut removable = Vec::new();+                // never the converter's own input or output capacitors: ripple, the hot loop and the loop's stability need them+                let conv_caps: Vec<String> = ["inCaps", "outCaps"].iter().flat_map(|k| conv[*k].as_array().cloned().unwrap_or_default()).filter_map(|c| c["ref"].as_str().map(String::from)).collect();+                loop { let mut best: Option<usize> = None; for (j, x) in keep.iter().enumerate() { if x.kind != "capacitor" || conv_caps.iter().any(|c| x.name == *c || x.name.starts_with(&format!("{c} "))) { continue; } let mut t = keep.clone(); t.remove(j); let (_, r) = worst(&curve(&t)); if r <= 0.9 { best = Some(j); break; } } match best { Some(j) => { removable.push(keep[j].name.clone()); keep.remove(j); } None => break } }+                plan["removable"] = json!(removable);+                plan["removableNote"] = json!("judged on this rail's impedance target alone (the converter's own input and output capacitors are never offered); check the regulator's datasheet minimum and any filtering role before removing");+            }+            plan["costModel"] = json!("typical catalogue prices (0402 $0.004, 0603 $0.01, 0805 $0.025, polymer $0.25-0.35) and footprints, for ranking only");+        }         // the ngspice cross-check of the same branches         let mut spice = Value::Null;         if let Some(dir) = out_dir {@@ -353,7 +395,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec         let added: Vec<Value> = cur.iter().skip(bs.len()).map(|x| json!({"name": x.name, "kind": x.kind, "at": x.at, "r": x.r, "l": x.l, "lp": 0.0, "rp": 0.0, "c": x.c, "info": x.extra})).collect();         rails.push(json!({"net": name, "railV": v, "load": load_name, "loadAt": [r2(load.x), r2(load.y)], "targetOhm": sig(zt), "stepA": sig(step), "tolPct": tol, "bandHz": [flo, sig(fhi)],             "worst": {"hz": sig(fs[wi]), "ohm": sig(z0[wi]), "ratio": sig(wr), "inductive": bl, "capacitive": bc}, "traceWidthMm": wmm, "nHPerMm": sig(lpm * 1e9),-            "branches": br, "added": added, "fixes": fixes, "zOhm": z0.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "zAfterOhm": if fixes.is_empty() { Value::Null } else { json!(zf.iter().map(|x| sig(*x)).collect::<Vec<_>>()) }, "middlebrook": mb, "hotPlug": hot, "spice": spice}));+            "branches": br, "added": added, "fixes": fixes, "decapPlan": plan, "zOhm": z0.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "zAfterOhm": if fixes.is_empty() { Value::Null } else { json!(zf.iter().map(|x| sig(*x)).collect::<Vec<_>>()) }, "middlebrook": mb, "hotPlug": hot, "spice": spice}));     }     json!({"fHz": fs.iter().map(|x| sig(*x)).collect::<Vec<_>>(), "rails": rails, "model": "each branch R + L + (Lp ∥ Rp) + C in parallel at the load pin; each capacitor at the rail's DC bias (a measured curve for its MPN when one is loaded, else an estimate for class-II ceramics), its technology from the Value, MPN and footprint; mounting inductance from a microstrip over the plane", "capCurvesLoaded": lib.len()}) }
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") | 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["quantity"] = json!(match issue["kind"].as_str() { Some("current-neck") | Some("ampacity") => "current", Some("junction-temperature") | Some("capacitor-temperature") => "temperature", Some("cable-emissions") | Some("conducted-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(); } }