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.7.14: hot-plug overshoot. Plugging in the supply solved in time on every connector-fed rail (ngspice cross-check), checked against the lowest voltage rating on the rail, with a damping fix
10 files changed
+73−20
Cargo.lock+1−1@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-fields"-version = "0.7.13"+version = "0.7.14" dependencies = [ "clap", "png",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.13"+version = "0.7.14" 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+1@@ -157,6 +157,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright). - **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR. - **Capacitors as they sit on the rail** (0.7.13, `src/caps.rs`, used by the PDN check and the converter's ripple): the technology comes from the Value, the MPN and the footprint (EEE-F…/CP_Elec_… electrolytic, ECAS…/EEF-…/APX… polymer, T520/T530 polymer tantalum, CP_Tantalum_… tantalum, else ceramic; C0G/NP0 by Value or vendor code), with ESR by technology. Class-II ceramics are taken at the rail's DC bias: from a measured curve when one matches the MPN (a folder of Murata SimSurfing JSON, spec `capCurves` or ADOM_FIELDS_CAP_CURVES: C at the bias, ESR at self-resonance, ESL from |Z| a decade above), else an estimate, retained = 1/(1+(V/V0)^1.6), V0 = 9.1 V·(case mm³ / µF)^0.38 (a fit to Murata's 0603 to 1206 X5R/X7R curves, about ±0.05). Each branch reports `uF`, `uFAtBias`, `type` and `biasModel`; new ceramics in the fix search are derated too, and polymer bulk parts are candidates. Ripple adds each side's ESR term.+- **Hot-plug** (0.7.14): on a connector-fed rail, the supply leads (R, L; `pdn.<net>.sourceOhm`/`sourceNh`, default 30 mΩ and 0.7 µH) are switched onto every capacitor branch at its rail bias and solved in time (backward Euler, ESL ignored), with the same step run in ngspice as a cross-check (`hotPlug.spicePeakV`). The peak is checked against the lowest voltage rating on the rail (a "35V" in a Value, or Murata's code in an ECAS/GRM MPN). Over that rating is high, over 50 % overshoot is medium: `hot-plug-overshoot`, whose action is a damping bulk capacitor (value, ESR) or a TVS. It catches a low-ESR bulk part with no damping, which an impedance peak under its target does not show. - `review.pdn` holds each rail's branches, |Z(f)| (`fHz` is shared), the target and band, the fixes and the curve after them; `pdn-<net>.cir` is the same circuit as an ngspice deck, run as a cross-check when ngspice is installed (`spice.maxErrPct`). `ui view --quantity pdn` (the Power button): the frequency sweeps; each branch stands a column at its place, its height its share of the load's current (over 100 % is two branches ringing); arcs carry the shares to the load pin; at the load pin |Z| stands in a glass sleeve, the target (green under the rim, red over, steel blue outside the band); the panel draws |Z(f)| per rail with its target, band and fix curve, and a frequency slider.
bin/adom-fields⋯ 1 unchanged line ⋯
docs/release-0.7.14.mdadded+3@@ -0,0 +1,3 @@+# adom-fields 0.7.14++- **Hot-plug overshoot.** On a connector-fed rail, plugging in the supply is solved in time: the leads' resistance and inductance switched onto every capacitor at its rail bias, cross-checked in ngspice. A peak over the lowest voltage rating on the rail, or more than 50 % over the rail, is `hot-plug-overshoot`, with the damping capacitor (value and ESR) or TVS that fixes it. On the buck it is 13.8 V as built (the input electrolytic damps it) and 19.8 V with a bare polymer bulk part in its place, which is flagged although the rail's impedance stays under target.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.13",+ "version": "0.7.14", "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−15@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.13",+ "version": "0.7.14", "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,12 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew"+ "ngspice pdn" ], "sample_prompts": [ {@@ -131,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
skills/adom-fields/SKILL.md+1@@ -157,6 +157,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **The target**: V·tolPct/100 / stepA (5 %; the step is half the rail's spec load, or the converter's output current on its input rail), over a band: up to the switching edge's knee on a converter input, 100 MHz at a chip pin, 1 MHz at a connector (`fminHz`, `fmaxHz`, or `targetOhm` outright). - **The fix**: a peak over the target is `pdn-impedance`, with an `add-part` capacitor (value, package, pin) chosen by re-solving with each candidate, up to three, each with the ratio it reaches; still over after three is a `pour` action (a plane pair). A converter's input rail also gets Middlebrook's check: under 10 dB below the converter's negative input resistance V²/P is `input-filter-stability`, with a damping capacitor and its ESR. - **Capacitors as they sit on the rail** (0.7.13, `src/caps.rs`, used by the PDN check and the converter's ripple): the technology comes from the Value, the MPN and the footprint (EEE-F…/CP_Elec_… electrolytic, ECAS…/EEF-…/APX… polymer, T520/T530 polymer tantalum, CP_Tantalum_… tantalum, else ceramic; C0G/NP0 by Value or vendor code), with ESR by technology. Class-II ceramics are taken at the rail's DC bias: from a measured curve when one matches the MPN (a folder of Murata SimSurfing JSON, spec `capCurves` or ADOM_FIELDS_CAP_CURVES: C at the bias, ESR at self-resonance, ESL from |Z| a decade above), else an estimate, retained = 1/(1+(V/V0)^1.6), V0 = 9.1 V·(case mm³ / µF)^0.38 (a fit to Murata's 0603 to 1206 X5R/X7R curves, about ±0.05). Each branch reports `uF`, `uFAtBias`, `type` and `biasModel`; new ceramics in the fix search are derated too, and polymer bulk parts are candidates. Ripple adds each side's ESR term.+- **Hot-plug** (0.7.14): on a connector-fed rail, the supply leads (R, L; `pdn.<net>.sourceOhm`/`sourceNh`, default 30 mΩ and 0.7 µH) are switched onto every capacitor branch at its rail bias and solved in time (backward Euler, ESL ignored), with the same step run in ngspice as a cross-check (`hotPlug.spicePeakV`). The peak is checked against the lowest voltage rating on the rail (a "35V" in a Value, or Murata's code in an ECAS/GRM MPN). Over that rating is high, over 50 % overshoot is medium: `hot-plug-overshoot`, whose action is a damping bulk capacitor (value, ESR) or a TVS. It catches a low-ESR bulk part with no damping, which an impedance peak under its target does not show. - `review.pdn` holds each rail's branches, |Z(f)| (`fHz` is shared), the target and band, the fixes and the curve after them; `pdn-<net>.cir` is the same circuit as an ngspice deck, run as a cross-check when ngspice is installed (`spice.maxErrPct`). `ui view --quantity pdn` (the Power button): the frequency sweeps; each branch stands a column at its place, its height its share of the load's current (over 100 % is two branches ringing); arcs carry the shares to the load pin; at the load pin |Z| stands in a glass sleeve, the target (green under the rim, red over, steel blue outside the band); the panel draws |Z(f)| per rail with its target, band and fix curve, and a frequency slider.
src/pdn.rs+56−1@@ -243,6 +243,61 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec } } }+ // hot-plug: the supply leads (R, L) switched onto the rail's capacitors at once. A low-ESR bulk part with no+ // damping rings the rail up toward twice its voltage; solved in time (backward Euler), ESL ignored+ let mut hot = Value::Null;+ if bs.first().map_or(false, |x| x.kind == "cable") {+ let src = &bs[0]; let br: Vec<&Branch> = bs.iter().filter(|x| x.c > 0.0).collect();+ let ctot: f64 = br.iter().map(|x| x.c).sum();+ if ctot > 0.0 && src.l > 0.0 {+ let t_lc = 2.0 * PI * (src.l * ctot).sqrt(); let dt = t_lc / 400.0; let steps = 1600;+ let g: Vec<f64> = br.iter().map(|x| 1.0 / (x.r.max(1e-5) + dt / x.c)).collect(); let gsum: f64 = g.iter().sum();+ let mut vc = vec![0.0; br.len()]; let (mut il, mut peak, mut tpk) = (0.0f64, 0.0f64, 0.0f64);+ let a_l = src.l / dt + src.r;+ for k in 0..steps {+ let ssum: f64 = vc.iter().zip(&g).map(|(v, gi)| v * gi).sum();+ let vn = (ssum + (v + src.l * il / dt) / a_l) / (gsum + 1.0 / a_l);+ il = (v - vn + src.l * il / dt) / a_l;+ for (i, x) in br.iter().enumerate() { let ib = (vn - vc[i]) * g[i]; vc[i] += dt * ib / x.c; }+ if vn > peak { peak = vn; tpk = (k + 1) as f64 * dt; }+ }+ // the lowest voltage rating on the rail: "35V" in a Value, or Murata's code in the MPN (ECASD41E, GRM31CR71E: 1E = 25 V)+ let eia = |c: &str| match c { "0G" => Some(4.0), "0J" => Some(6.3), "1A" => Some(10.0), "1C" => Some(16.0), "1E" => Some(25.0), "YA" => Some(35.0), "1V" => Some(35.0), "1H" => Some(50.0), "2A" => Some(100.0), _ => None };+ let rating = |r: &str| -> Option<f64> {+ let m = b.mpns.get(r).map(|x| x.to_ascii_uppercase()).unwrap_or_default();+ let by_mpn = if m.starts_with("ECAS") && m.len() > 8 { eia(&m[6..8]) } else if m.starts_with("GRM") && m.len() > 10 { eia(&m[8..10]) } else { None };+ by_mpn.or_else(|| b.values.get(r).and_then(|val| val.split_whitespace().find_map(|t| t.strip_suffix('V').or_else(|| t.strip_suffix('v')).and_then(|n| n.parse::<f64>().ok()))))+ };+ let rated: Vec<(String, f64)> = caps.iter().filter_map(|r| rating(r).map(|x| (r.clone(), x))).collect();+ let low = rated.iter().cloned().min_by(|a, z| a.1.partial_cmp(&z.1).unwrap());+ let over = (peak / v - 1.0) * 100.0;+ // the same step in ngspice, when it is there+ let mut spice_pk = Value::Null;+ if let Some(dir) = out_dir {+ let mut d = format!("* adom-fields hot-plug: {} through the supply leads\nV1 vin 0 PWL(0 0 10n {})\nRs vin s1 {:e}\nLs s1 rail {:e}\n", name, v, src.r.max(1e-6), src.l);+ for (k, x) in br.iter().enumerate() { d.push_str(&format!("R{k} rail c{k} {:e}\nC{k} c{k} 0 {:e}\n", x.r.max(1e-6), x.c)); }+ let out = dir.join(format!("hotplug-{}.txt", name.replace(|c: char| !c.is_ascii_alphanumeric(), "_")));+ d.push_str(&format!(".tran {:e} {:e}\n.control\nset wr_singlescale\nrun\nwrdata {} v(rail)\n.endc\n.end\n", dt, dt * steps as f64, out.display()));+ let deck = dir.join(format!("hotplug-{}.cir", name.replace(|c: char| !c.is_ascii_alphanumeric(), "_")));+ if std::fs::write(&deck, &d).is_ok() {+ if let Ok(r) = std::process::Command::new("timeout").args(["30", "ngspice", "-b"]).arg(&deck).output() { if r.status.success() {+ let pk = std::fs::read_to_string(&out).unwrap_or_default().lines().filter_map(|l| l.split_whitespace().nth(1).and_then(|x| x.parse::<f64>().ok())).fold(0.0f64, f64::max);+ let _ = std::fs::remove_file(&out); if pk > 0.0 { spice_pk = json!(r2(pk)); }+ } }+ }+ }+ hot = json!({"spicePeakV": spice_pk, "sourceOhm": src.r, "sourceNh": sig(src.l * 1e9), "peakV": r2(peak), "overshootPct": r2(over), "atUs": r2(tpk * 1e6), "lowestRating": low.as_ref().map(|x| json!({"ref": x.0, "volts": x.1})), "model": "supply leads (R, L) switched onto every capacitor branch at their rail bias, ESL ignored; backward-Euler transient"});+ let breach = low.as_ref().map_or(false, |x| peak > x.1);+ if breach || over > 50.0 {+ let cer: f64 = br.iter().filter(|x| x.r < 0.05).map(|x| x.c).sum(); let lsrc = src.l;+ let cd = { let c = (4.0 * cer.max(1e-6)).max(10e-6); let dec = 10f64.powf(c.log10().floor()); let m = c / dec; dec * [1.0, 1.5, 2.2, 3.3, 4.7, 6.8, 10.0].iter().copied().find(|e| *e >= m - 1e-9).unwrap_or(10.0) }; let rd = (lsrc / cer.max(1e-9)).sqrt();+ let sev = if breach { "high" } else { "medium" };+ issues.push(Issue { severity: sev.into(), kind: "hot-plug-overshoot".into(), what: format!("{}: plugging in the supply rings the rail to {:.1} V ({:.0} % over {} V){}", name, peak, over, v, match &low { Some(x) if breach => format!(", over {}'s {} V rating", x.0, x.1), Some(x) => format!(" (lowest rating on the rail: {} at {} V)", x.0, x.1), None => String::new() }), at: Some([r2(load.x), r2(load.y)]), layer: Some(LAYERS[k0].into()), net: Some(name.clone()), reference: low.as_ref().map(|x| x.0.clone()),+ fix: format!("Damp the input: add at least {} of bulk capacitance with about {} of ESR (an electrolytic, or a polymer part plus a series resistor) near the input connector, or a TVS rated just above {} V.", fmt_c(cd), fmt_ohm(rd), v),+ action: Some(json!({"type": "add-part", "what": "capacitor", "value": fmt_c(cd), "dielectric": "electrolytic", "esrOhm": sig(rd), "between": [name, gnd], "nearPin": bs[0].name.replace(" cable", ""), "maxDistMm": 10.0, "why": "hot-plug overshoot damping", "peakV": r2(peak)})) });+ }+ }+ } // the ngspice cross-check of the same branches let mut spice = Value::Null; if let Some(dir) = out_dir {@@ -254,7 +309,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, "spice": spice}));+ "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})); } 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@@ -104,7 +104,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("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") => "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") => "current", Some("hot-loop") | Some("emissions") | Some("no-reference") => "emc", 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(); } }