← Commit history

adom-fields 0.7.13: capacitors as they sit on the rail. Technology from the MPN and footprint (electrolytic, polymer, tantalum), class-II ceramics derated at the rail's DC bias (measured Murata curves when the MPN matches, else a fitted estimate), used by the PDN check and the ripple estimate

John Lauer ·07a033fdc8 · ·parent 162bda1
15 files changed +173−37
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.7.12"+version = "0.7.13" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.12"+version = "0.7.13" 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
@@ -156,6 +156,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor. - **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. - `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.13.mdadded+6
@@ -0,0 +1,6 @@+# adom-fields 0.7.13++- **Capacitors as they really sit on the rail.** The technology is read from the MPN and the footprint as well as the Value, so a "47uF 35V" Panasonic EEE-FK is an aluminium electrolytic with about 0.3 Ω of ESR, not a 3 mΩ ceramic; polymer, polymer tantalum and tantalum parts are recognised the same way.+- **DC-bias derating for class-II ceramics.** A 10 µF 25 V X7R 1206 holds about 3.5 µF at 12 V, and a 22 µF 10 V X5R 0805 about 10 µF at 5 V. With a measured curve for the MPN (Murata SimSurfing JSON, spec `capCurves`), the measured C, ESR and ESL; otherwise an estimate fitted to those curves, labelled as such.+- PDN and the converter's ripple both use it; the fix search derates new ceramics and also offers polymer bulk parts; ripple includes the ESR term.+- On the buck: the input electrolytic's ESR now damps the supply-lead resonance (VIN's 24 kHz peak is gone), and the output ceramics count 9.7 µF each at 5 V, not 22.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.12",+  "version": "0.7.13",   "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.12",+  "version": "0.7.13",   "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]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+1
@@ -156,6 +156,7 @@ After applying a step, run `analyze` again: the issue goes away or says how far - **The model**: branches in parallel at the load pin. The source: a modelled converter's output is flat (5 mΩ) below its loop crossover (fsw/10, `crossoverHz`), then an inductance that resonates with the output capacitors there, damped to Q 1 (about a 50° phase margin), fading out a decade above; a linear regulator the same with a 100 kHz crossover; a connector-fed rail is about a metre of supply leads (30 mΩ, 0.7 µH; `sourceOhm`, `sourceNh`). Each capacitor: its board value (nominal, no DC-bias derating), ESR by type, body inductance from its pad span, and the mounting inductance of a microstrip over the plane from it to the load pin. The rail's copper over ground as a plate capacitor. - **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. - `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/board.rs+6
@@ -37,6 +37,10 @@ pub struct Board {     pub source: String,     /// each part's value as the board carries it ("15uH", "22uF 10V"): the energy cycle reads L and C from it     pub values: std::collections::BTreeMap<String, String>,+    /// each part's manufacturer part number and footprint name, when the file carries them (the capacitor model+    /// reads the technology from them: an electrolytic's Value is just "47uF 35V")+    pub mpns: std::collections::BTreeMap<String, String>,+    pub footprints: std::collections::BTreeMap<String, String>, }  fn rot(x: f64, y: f64, deg: f64) -> (f64, f64) { let a = deg.to_radians(); (x * a.cos() - y * a.sin(), x * a.sin() + y * a.cos()) }@@ -82,6 +86,8 @@ impl Board {         for fp in root.kids("footprint") {             let reference = fp.kids("property").iter().find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2)).unwrap_or("?").to_string();             if let Some(v) = fp.kids("property").iter().find(|p| p.atom(1) == Some("Value")).and_then(|p| p.atom(2)) { b.values.insert(reference.clone(), v.to_string()); }+            if let Some(v) = fp.kids("property").iter().find(|p| matches!(p.atom(1), Some("MPN") | Some("Manufacturer Part Number") | Some("MFR_PN") | Some("Mfr PN") | Some("PN"))).and_then(|p| p.atom(2)).filter(|v| !v.is_empty()) { b.mpns.insert(reference.clone(), v.to_string()); }+            if let Some(v) = fp.atom(1) { b.footprints.insert(reference.clone(), v.to_string()); }             let at = fp.kid("at");             let (fx, fy, fa) = (at.and_then(|a| a.num(1)).unwrap_or(0.0), at.and_then(|a| a.num(2)).unwrap_or(0.0), at.and_then(|a| a.num(3)).unwrap_or(0.0));             for p in fp.kids("pad") {
src/caps.rsadded+119
@@ -0,0 +1,119 @@+//! What a capacitor really is on its rail: its technology (from the Value text, the MPN and the footprint),+//! its ESR and body inductance, and its capacitance at the rail's DC bias. Class-II ceramics (X5R, X7R) lose+//! most of their capacitance under bias; polymer and electrolytic parts barely move.+//!  - With a measured curve for the part's MPN (a folder of Murata SimSurfing JSON: C-DC bias, |Z|(f), R(f);+//!    spec `capCurves` or ADOM_FIELDS_CAP_CURVES), the curve: C at the rail voltage, ESR at the self-resonance,+//!    ESL from |Z| a decade above it. (SimSurfing's |Z| and R x axes are in MHz although labelled Hz.)+//!  - Otherwise an estimate: retained = 1 / (1 + (V/V0)^1.6), V0 = 9.1 V × (case volume mm³ / C µF)^0.38,+//!    a fit to Murata's curves for 0603 to 1206 X5R/X7R parts (within about ±0.05 of the measured fraction,+//!    3.3 to 16 V). Labelled as an estimate wherever it is reported.+use serde_json::Value;+use std::collections::BTreeMap;++#[derive(Clone, Debug)]+pub struct CapModel { pub c_nom: f64, pub c_eff: f64, pub esr: f64, pub esl: f64, pub kind: &'static str, pub source: String }++pub struct Curve { pc: String, bias: Vec<(f64, f64)>, esr: Option<f64>, esl: Option<f64> }+#[derive(Default)]+pub struct Library { curves: Vec<Curve> }++fn arr(v: &Value) -> Vec<f64> { match v { Value::String(s) => serde_json::from_str::<Vec<f64>>(s).unwrap_or_default(), Value::Array(a) => a.iter().filter_map(|x| x.as_f64()).collect(), _ => Vec::new() } }++impl Library {+    /// Every *.json in the folder with a partnumber and curves (the spec's capCurves, else ADOM_FIELDS_CAP_CURVES).+    pub fn load(dir: Option<&str>) -> Library {+        let dir = dir.map(str::to_string).or_else(|| std::env::var("ADOM_FIELDS_CAP_CURVES").ok());+        let mut lib = Library::default();+        let Some(dir) = dir else { return lib };+        let Ok(rd) = std::fs::read_dir(&dir) else { return lib };+        for e in rd.flatten() {+            let p = e.path(); if p.extension().and_then(|x| x.to_str()) != Some("json") { continue; }+            let Ok(v) = std::fs::read_to_string(&p).map(|t| serde_json::from_str::<Value>(&t).unwrap_or(Value::Null)) else { continue };+            let Some(pn) = v["partnumber"].as_str() else { continue };+            let (bx, by) = (arr(&v["c_dcbias_capchange"]["x"]), arr(&v["c_dcbias_capchange"]["y"]));+            let bias: Vec<(f64, f64)> = bx.iter().zip(by.iter()).map(|(x, y)| (*x, 1.0 - y.abs() / 100.0)).collect();+            let (zx, zy, rx, ry) = (arr(&v["z"]["x"]), arr(&v["z"]["y"]), arr(&v["r"]["x"]), arr(&v["r"]["y"]));+            let (mut esr, mut esl) = (None, None);+            if !zx.is_empty() && zx.len() == zy.len() {+                let i0 = (0..zy.len()).min_by(|a, b| zy[*a].partial_cmp(&zy[*b]).unwrap()).unwrap();+                let f0 = zx[i0] * 1e6;   // MHz on the wire+                if let Some(j) = rx.iter().position(|x| *x * 1e6 >= f0) { esr = ry.get(j).copied(); }+                let k = zx.iter().position(|x| *x * 1e6 >= f0 * 10.0).unwrap_or(zx.len() - 1);+                if k > i0 { esl = Some(zy[k] / (2.0 * std::f64::consts::PI * zx[k] * 1e6)); }+            }+            lib.curves.push(Curve { pc: pn.to_ascii_uppercase(), bias, esr, esl });+        }+        lib+    }+    fn find(&self, mpn: &str) -> Option<&Curve> {+        let m = mpn.to_ascii_uppercase(); if m.len() < 6 { return None; }+        self.curves.iter().find(|c| m.starts_with(&c.pc) || c.pc.starts_with(&m))+    }+    pub fn len(&self) -> usize { self.curves.len() }+}++/// The technology, from the Value text first, then the MPN and the footprint.+pub fn classify(value: &str, mpn: &str, footprint: &str) -> &'static str {+    let (v, m, f) = (value.to_ascii_lowercase(), mpn.to_ascii_uppercase(), footprint.to_ascii_lowercase());+    if v.contains("poly") || m.starts_with("ECAS") || m.starts_with("EEF-") || m.starts_with("EEFS") || m.starts_with("APX") || m.starts_with("PSA") || m.starts_with("PSC") || m.starts_with("PSF") { return "polymer"; }+    if m.starts_with("T520") || m.starts_with("T530") || m.contains("TPE") || m.contains("TPF") { return "polymer tantalum"; }+    if v.contains("elec") || v.contains("alu") || m.starts_with("EEE-") || m.starts_with("UWT") || m.starts_with("UCD") || m.starts_with("UUD") || m.starts_with("EMVA") || f.contains("cp_elec") { return "electrolytic"; }+    if v.contains("tant") || f.contains("cp_tantalum") || m.starts_with("TAJ") || m.starts_with("T491") || m.starts_with("293D") { return "tantalum"; }+    "ceramic"+}+/// A ceramic that does not move with bias: C0G/NP0 by the Value or the vendor's dielectric code.+fn class_one(value: &str, mpn: &str, c: f64) -> bool {+    let (v, m) = (value.to_ascii_uppercase(), mpn.to_ascii_uppercase());+    if ["C0G", "COG", "NP0", "NPO"].iter().any(|k| v.contains(k) || m.contains(k)) { return true; }+    if m.starts_with("GRM") && m.len() > 8 && &m[7..9] == "5C" { return true; }                       // Murata GRM1885C…+    if m.starts_with("CL") && m.len() > 4 && m.as_bytes()[4] == b'C' { return true; }                 // Samsung CL21C…+    if m.len() > 4 && m[..4].chars().all(|c| c.is_ascii_digit()) && m.as_bytes()[4] == b'N' { return true; } // Walsin 0402N…+    c < 1e-9   // small values are almost always C0G+}+/// The case volume in mm³ from the footprint's size code, else from the pad span.+fn case_mm3(footprint: &str, span: f64) -> f64 {+    let f = footprint.to_ascii_uppercase();+    for (code, l, w) in [("0201", 0.6f64, 0.3f64), ("0402", 1.0, 0.5), ("0603", 1.6, 0.8), ("0805", 2.0, 1.25), ("1206", 3.2, 1.6), ("1210", 3.2, 2.5), ("1812", 4.5, 3.2), ("2220", 5.7, 5.0)] { if f.contains(&format!("_{code}")) || f.contains(&format!("C{code}")) { return l * w * w.min(2.5); } }+    let l = (span * 1.25).max(0.6); l * (l / 2.0) * (l / 2.0)+}+pub fn bias_estimate(c: f64, mm3: f64, v: f64) -> f64 { let v0 = 9.1 * (mm3 / (c * 1e6)).powf(0.38); 1.0 / (1.0 + (v.max(0.0) / v0).powf(1.6)) }++/// A part on a rail at `v` volts: value text, MPN, footprint, nominal C, the pad span (mm).+pub fn model(lib: &Library, value: &str, mpn: &str, footprint: &str, c: f64, v: f64, span: f64) -> CapModel {+    let kind = classify(value, mpn, footprint);+    let esl_span = (0.15 + 0.3 * span) * 1e-9 + if kind == "electrolytic" { 1.0e-9 } else { 0.0 };+    if let Some(cv) = lib.find(mpn) {+        let keep = if cv.bias.is_empty() { 1.0 } else { let mut k = cv.bias[cv.bias.len() - 1].1; for w in cv.bias.windows(2) { if v >= w[0].0 && v <= w[1].0 { let t = (v - w[0].0) / (w[1].0 - w[0].0).max(1e-9); k = w[0].1 + t * (w[1].1 - w[0].1); break; } } if v <= cv.bias[0].0 { k = cv.bias[0].1; } k.clamp(0.02, 1.0) };+        return CapModel { c_nom: c, c_eff: c * keep, esr: cv.esr.unwrap_or(0.01).max(1e-4), esl: cv.esl.unwrap_or(esl_span), kind, source: format!("measured curve ({})", cv.pc) };+    }+    let esr = match kind { "electrolytic" => if c < 100e-6 { 0.3 } else { 0.15 }, "polymer" => 0.03, "polymer tantalum" => 0.04, "tantalum" => 0.3, _ => if c >= 10e-6 { 0.003 } else if c >= 1e-6 { 0.006 } else if c >= 100e-9 { 0.015 } else if c >= 10e-9 { 0.04 } else { 0.1 } };+    if kind == "ceramic" && !class_one(value, mpn, c) {+        let keep = bias_estimate(c, case_mm3(footprint, span), v);+        return CapModel { c_nom: c, c_eff: c * keep, esr, esl: esl_span, kind, source: "estimate: class-II DC-bias fit".into() };+    }+    CapModel { c_nom: c, c_eff: c, esr, esl: esl_span, kind: if kind == "ceramic" { "ceramic C0G" } else { kind }, source: "nominal (no bias loss)".into() }+}++pub fn info(m: &CapModel, value: &str, mpn: &str) -> BTreeMap<&'static str, Value> {+    let r = |x: f64| (x * 1000.0).round() / 1000.0;+    let mut o = BTreeMap::new();+    o.insert("value", Value::from(value)); o.insert("mpn", Value::from(mpn)); o.insert("type", Value::from(m.kind));+    o.insert("uF", Value::from(r(m.c_nom * 1e6))); o.insert("uFAtBias", Value::from(r(m.c_eff * 1e6))); o.insert("biasModel", Value::from(m.source.clone()));+    o+}++#[cfg(test)] mod tests {+    use super::*;+    #[test] fn kinds() {+        assert_eq!(classify("47uF 35V", "EEE-FK1V470P", "Capacitor_SMD:CP_Elec_6.3x5.8"), "electrolytic");+        assert_eq!(classify("33uF", "ECASD41E336M040KA0", ""), "polymer");+        assert_eq!(classify("10uF 25V", "CC1206KKX7R8BB106", "Capacitor_SMD:C_1206_3216Metric"), "ceramic");+        assert!(class_one("68pF C0G", "0402N680J500CT", 68e-12));+        assert!(!class_one("22uF 10V", "CL21A226MPQNNNE", 22e-6));+    }+    #[test] fn bias_fit() {+        // Murata: 10 µF 25 V X7R 1206 keeps 35 % at 12 V; 22 µF 10 V X5R 0805 keeps 46 % at 5 V+        assert!((bias_estimate(10e-6, case_mm3("C_1206_3216Metric", 0.0), 12.0) - 0.35).abs() < 0.05);+        assert!((bias_estimate(22e-6, case_mm3("C_0805_2012Metric", 0.0), 5.0) - 0.46).abs() < 0.05);+    }+}
src/eagle.rs+2
@@ -94,6 +94,8 @@ pub fn load(text: &str) -> Result<Board, String> {     for e in board.descendants().filter(|n| n.has_tag_name("element")) {         let reference = e.attribute("name").unwrap_or("?").to_string();         if let Some(v) = e.attribute("value").filter(|v| !v.is_empty()) { b.values.insert(reference.clone(), v.to_string()); }+        if let Some(m) = e.descendants().find(|n| n.has_tag_name("attribute") && matches!(n.attribute("name").map(|x| x.to_ascii_uppercase()).as_deref(), Some("MPN") | Some("MFR_PN") | Some("MANUFACTURER_PART_NUMBER"))).and_then(|n| n.attribute("value")).filter(|v| !v.is_empty()) { b.mpns.insert(reference.clone(), m.to_string()); }+        if let Some(pk) = e.attribute("package") { b.footprints.insert(reference.clone(), pk.to_string()); }         let key = (e.attribute("library").unwrap_or("").to_string(), e.attribute("package").unwrap_or("").to_string());         let Some(pkg) = packages.get(&key) else { continue };         let (ex, ey) = (f(&e, "x"), f(&e, "y"));
src/emc.rs+11−5
@@ -48,8 +48,12 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va         let swref = spec.sources.get(&sw).and_then(|v| v.first().cloned()).or_else(|| refs.iter().find(|r| r.starts_with('U') && nets_of(r).contains(&sw)).cloned());         let in_net = swref.as_ref().and_then(|u| { let mut c: Vec<String> = nets_of(u).into_iter().filter(|n| *n != sw && *n != gnd && rail(n).is_some()).collect(); c.sort_by(|a, z| rail(z).partial_cmp(&rail(a)).unwrap()); c.into_iter().next() });         if let (Some(lref), Some(out_net), Some(swref), Some(in_net)) = (lref, out_net, swref, in_net) {-            let caps = |net: &str| -> Vec<(String, f64)> { refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net.to_string()) && nets_of(r).contains(&gnd)).filter_map(|r| b.values.get(r).and_then(|v| parse_value(v, 'F')).map(|c| (r.clone(), c))).collect() };-            let (cin, cout) = (caps(&in_net), caps(&out_net));+            // each capacitor at its rail's DC bias (crate::caps): an X5R loses most of its capacitance at 12 V+            let lib = crate::caps::Library::load(spec.capCurves.as_deref());+            let capm = |net: &str| -> Vec<(String, crate::caps::CapModel)> { let vr = rail(net).unwrap_or(0.0); refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net.to_string()) && nets_of(r).contains(&gnd)).filter_map(|r| { let val = b.values.get(r)?; let c = parse_value(val, 'F')?; let pv = pads_of(r, net); let pg = pads_of(r, &gnd); let span = match (pv.first(), pg.first()) { (Some(a), Some(z)) => (a.x - z.x).hypot(a.y - z.y), _ => 1.5 }; Some((r.clone(), crate::caps::model(&lib, val, b.mpns.get(r).map(String::as_str).unwrap_or(""), b.footprints.get(r).map(String::as_str).unwrap_or(""), c, vr, span))) }).collect() };+            let (cin_m, cout_m) = (capm(&in_net), capm(&out_net));+            let cin: Vec<(String, f64)> = cin_m.iter().map(|(r, m)| (r.clone(), m.c_eff)).collect(); let cout: Vec<(String, f64)> = cout_m.iter().map(|(r, m)| (r.clone(), m.c_eff)).collect();+            let esr_par = |ms: &[(String, crate::caps::CapModel)]| -> f64 { let g: f64 = ms.iter().map(|(_, m)| 1.0 / m.esr.max(1e-5)).sum(); if g > 0.0 { 1.0 / g } else { 0.0 } };             let l = b.values.get(&lref).and_then(|v| parse_value(v, 'H'));             let (vin, vout) = (rail(&in_net), rail(&out_net));             let iout = spec.loads.get(&out_net).map(|l| l.amps);@@ -60,8 +64,9 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va                 if vout < vin && vout > 0.0 {                     let t = 1.0 / fsw; let dcy = vout / vin; let di = (vin - vout) * dcy * t / l;                     let ccin: f64 = cin.iter().map(|c| c.1).sum(); let ccout: f64 = cout.iter().map(|c| c.1).sum();-                    let rip_out = if ccout > 0.0 { di / (8.0 * fsw * ccout) } else { f64::NAN };-                    let rip_in = if ccin > 0.0 { iout * dcy * (1.0 - dcy) / (fsw * ccin) } else { f64::NAN };+                    // the capacitive part plus the ESR part (added, a conservative upper bound): an electrolytic's ESR dominates+                    let rip_out = if ccout > 0.0 { di / (8.0 * fsw * ccout) + di * esr_par(&cout_m) } else { f64::NAN };+                    let rip_in = if ccin > 0.0 { iout * dcy * (1.0 - dcy) / (fsw * ccin) + iout * esr_par(&cin_m) } else { f64::NAN };                     let ratio = di / iout;                     // the hot loop of each input capacitor: its rail pad -> the switch's input pin -> its ground pin -> its ground pad                     let (vin_pins, g_pins) = (pads_of(&swref, &in_net), pads_of(&swref, &gnd));@@ -91,7 +96,8 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va                     // the converter itself: the inductor ripple ratio and the output ripple                     if !(0.15..=0.5).contains(&ratio) { issues.push(issue("low", "inductor-ripple", format!("{}: the ripple is {:.0} % of the load current (typical design: 20 to 40 %)", lref, ratio * 100.0), b.pads.iter().find(|p| p.reference == lref).map(|p| [p.x, p.y]), Some(&sw), Some(&lref), if ratio > 0.5 { format!("A larger inductance (about {:.0} µH for 30 %) or a higher switching frequency.", (vin - vout) * dcy * t / (0.3 * iout) * 1e6) } else { format!("A smaller inductance (about {:.0} µH for 30 %) responds faster and is smaller.", (vin - vout) * dcy * t / (0.3 * iout) * 1e6) }, json!({"type": "review", "what": "inductor value", "ref": lref, "suggestUh": ((vin - vout) * dcy * t / (0.3 * iout) * 1e7).round() / 10.0}))); }                     if rip_out.is_finite() && rip_out > 0.01 * vout { issues.push(issue("medium", "output-ripple", format!("{}: output ripple about {:.1} mV, over 1 % of {} V", out_net, rip_out * 1e3, vout), None, Some(&out_net), None, format!("Add output capacitance (about {:.0} µF more for 1 %) or low-ESR ceramics close to {}.", (di / (8.0 * fsw * 0.01 * vout) - ccout) * 1e6, lref), json!({"type": "add-part", "what": "output capacitance", "net": out_net, "uF": ((di / (8.0 * fsw * 0.01 * vout) - ccout) * 1e6).ceil()}))); }-                    conv = Some(json!({"switchNet": sw, "switch": swref, "inductor": lref, "inNet": in_net, "outNet": out_net, "vin": vin, "vout": vout, "iout": iout, "fswHz": fsw, "riseNs": tr * 1e9, "duty": dcy, "rippleA": di, "rippleRatio": ratio, "lUh": l * 1e6, "cinUf": ccin * 1e6, "coutUf": ccout * 1e6, "rippleInMv": rip_in * 1e3, "rippleOutMv": rip_out * 1e3,+                    let capj = |ms: &[(String, crate::caps::CapModel)]| -> Value { json!(ms.iter().map(|(r, m)| json!({"ref": r, "type": m.kind, "uF": (m.c_nom * 1e9).round() / 1e3, "uFAtBias": (m.c_eff * 1e9).round() / 1e3, "esrMohm": (m.esr * 1e4).round() / 10.0, "biasModel": m.source})).collect::<Vec<_>>()) };+                    conv = Some(json!({"inCaps": capj(&cin_m), "outCaps": capj(&cout_m), "switchNet": sw, "switch": swref, "inductor": lref, "inNet": in_net, "outNet": out_net, "vin": vin, "vout": vout, "iout": iout, "fswHz": fsw, "riseNs": tr * 1e9, "duty": dcy, "rippleA": di, "rippleRatio": ratio, "lUh": l * 1e6, "cinUf": ccin * 1e6, "coutUf": ccout * 1e6, "rippleInMv": rip_in * 1e3, "rippleOutMv": rip_out * 1e3,                         "hotLoops": loops.iter().map(|(r, c, a, _)| json!({"cap": r, "uF": c * 1e6, "mm2": (a * 100.0).round() / 100.0})).collect::<Vec<_>>(), "hotLoop": hf.as_ref().map(|h| json!({"cap": h.0, "mm2": (h.2 * 100.0).round() / 100.0})), "idealMm2": ideal.as_ref().map(|i| (i.0 * 100.0).round() / 100.0),                         "emissions": margin.map(|(m, fr)| json!({"worstMarginDb": (m * 10.0).round() / 10.0, "atMHz": (fr / 1e6).round(), "limit": "FCC 15 class B, 3 m", "model": "Ott differential-mode loop; no cables"}))}));                 }
src/fields.rs+2
@@ -37,6 +37,8 @@ pub struct Spec {     #[serde(default)] pub clocks: BTreeMap<String, f64>, #[serde(default)] pub crosstalkMv: Option<f64>,     /// the power-delivery check per rail: target impedance or tolerance and step, band, source, load pin     #[serde(default)] pub pdn: BTreeMap<String, crate::pdn::RailCfg>,+    /// a folder of measured capacitor curves (Murata SimSurfing JSON: C-DC bias, |Z|, R) keyed by MPN; else ADOM_FIELDS_CAP_CURVES+    #[serde(default)] pub capCurves: Option<String>, } fn d35() -> f64 { 35.0 } fn d60() -> f64 { 60.0 }
src/main.rs+1−1
@@ -1,6 +1,6 @@ //! adom-fields: where the current and the heat go on your board. use sha2::{Digest, Sha256};-mod 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 eagle; mod emc; mod flow; mod noise; mod pdn; mod parts; mod silk; mod signals; mod heatsink; mod board; mod fields; mod raster; mod sexp; mod solve; mod tex; mod serve; mod vendor; use clap::{Parser, Subcommand}; use serde_json::{json, Value}; use std::path::PathBuf;
src/pdn.rs+13−13
@@ -50,14 +50,6 @@ fn l_per_mm(w: f64, h: f64) -> f64 {     let z0 = if u <= 1.0 { 60.0 / ee.sqrt() * (8.0 / u + u / 4.0).ln() } else { 120.0 * PI / (ee.sqrt() * (u + 1.393 + 0.667 * (u + 1.444).ln())) };     z0 * ee.sqrt() / 2.99792458e8 * 1e-3 }-/// A capacitor's ESR by value and kind (MLCC unless the value says electrolytic, tantalum or polymer).-fn esr_of(c: f64, val: &str) -> (f64, &'static str) {-    let v = val.to_ascii_lowercase();-    if v.contains("poly") { return (0.015, "polymer"); }-    if v.contains("el") || v.contains("alu") { return (0.15, "electrolytic"); }-    if v.contains("tant") { return (0.1, "tantalum"); }-    (if c >= 10e-6 { 0.003 } else if c >= 1e-6 { 0.006 } else if c >= 100e-9 { 0.015 } else if c >= 10e-9 { 0.04 } else { 0.1 }, "ceramic")-} fn fmt_c(c: f64) -> String { if c >= 1e-6 { format!("{}uF", sig(c * 1e6)) } else if c >= 1e-9 { format!("{}nF", sig(c * 1e9)) } else { format!("{}pF", sig(c * 1e12)) } } fn fmt_hz(f: f64) -> String { if f >= 1e9 { format!("{:.2} GHz", f / 1e9) } else if f >= 1e6 { format!("{:.1} MHz", f / 1e6) } else { format!("{:.0} kHz", f / 1e3) } } fn fmt_ohm(z: f64) -> String { if z >= 1.0 { format!("{:.2} Ω", z) } else { format!("{:.0} mΩ", z * 1e3) } }@@ -86,6 +78,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec     let h = { let t: f64 = if spec.stackupMm.len() == 3 { spec.stackupMm.iter().sum() } else { 1.465 }; if real.len() <= 2 { t } else { spec.stackupMm.first().copied().unwrap_or(0.2) } };     let gid = f.net_names.iter().position(|x| *x == gnd).map(|i| i as u16);     let fs = freqs();+    let lib = crate::caps::Library::load(spec.capCurves.as_deref());     let mut rails = Vec::new();     for (name, nid) in f.net_names.iter().enumerate().map(|(i, x)| (x.clone(), i as u16)) {         let Some(v) = rail(&name) else { continue }; if v <= 0.0 || is_ground(&name) || spec.switching.contains_key(&name) { continue; }@@ -132,12 +125,16 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec             let Some(val) = b.values.get(r) else { continue }; let Some(c) = crate::emc::parse_value(val, 'F') else { continue };             let cv = pads_of(r, &name); let cg = pads_of(r, &gnd); let (Some(cv), Some(cg)) = (cv.first(), cg.first()) else { continue };             let span = (cv.x - cg.x).hypot(cv.y - cg.y);-            let (esr, kind) = esr_of(c, val);-            let esl = (0.15 + 0.3 * span) * 1e-9;+            // the part as it really sits on this rail: its technology, ESR, body inductance, and C at the rail's bias+            let mpn = b.mpns.get(r).map(String::as_str).unwrap_or(""); let fpn = b.footprints.get(r).map(String::as_str).unwrap_or("");+            let m = crate::caps::model(&lib, val, mpn, fpn, c, v, span);+            let (esr, esl, c) = (m.esr, m.esl, m.c_eff);             let dist = (cv.x - load.x).hypot(cv.y - load.y);             let mount = (lpm * dist + 0.2e-9).max(0.2e-9);             cap_total += c;-            bs.push(Branch { name: r.clone(), kind: "capacitor", r: esr, l: esl + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(cv.x), r2(cv.y)]), extra: json!({"value": val, "type": kind, "uF": sig(c * 1e6), "esrMohm": sig(esr * 1e3), "eslNh": sig(esl * 1e9), "mountNh": sig(mount * 1e9), "distMm": r2(dist), "srfHz": sig(1.0 / (2.0 * PI * (c * (esl + mount)).sqrt()))}) });+            let mut extra = json!({"esrMohm": sig(esr * 1e3), "eslNh": sig(esl * 1e9), "mountNh": sig(mount * 1e9), "distMm": r2(dist), "srfHz": sig(1.0 / (2.0 * PI * (c * (esl + mount)).sqrt()))});+            for (k, x) in crate::caps::info(&m, val, mpn) { extra[k] = x; }+            bs.push(Branch { name: r.clone(), kind: "capacitor", r: esr, l: esl + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(cv.x), r2(cv.y)]), extra });         }         // the plane: this rail's copper facing ground on the next real layer         let mut over = 0usize;@@ -195,7 +192,10 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec             let mount = lpm * 1.0 + 0.2e-9;             for _ in 0..3 {                 let mut best: Option<(f64, Branch)> = None;-                let mut cands: Vec<Branch> = mlcc.iter().map(|&c| { let (pkg, span) = if c <= 1e-6 { ("0402", 1.0) } else if c <= 4.7e-6 { ("0603", 1.6) } else { ("0805", 2.0) }; let (esr, _) = esr_of(c, ""); let esl = (0.15 + 0.3 * span) * 1e-9; Branch { name: format!("new {}", fmt_c(c)), kind: "capacitor", r: esr, l: esl + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": pkg, "type": "ceramic", "new": true}) } }).collect();+                // new ceramics are X5R/X7R, so they too lose capacitance at the rail's bias (the same estimate)+                let mut cands: Vec<Branch> = mlcc.iter().map(|&c| { let (pkg, span) = if c <= 1e-6 { ("0402", 1.0) } else if c <= 4.7e-6 { ("0603", 1.6) } else { ("0805", 2.0) }; let m = crate::caps::model(&lib, "", "", &format!("C_{pkg}"), c, v, span); Branch { name: format!("new {}", fmt_c(c)), 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 X5R/X7R", "uFAtBias": sig(m.c_eff * 1e6), "new": true}) } }).collect();+                // a polymer bulk part: low ESR, no bias loss+                for c in [47e-6, 100e-6] { cands.push(Branch { name: format!("new {} 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", "esrOhm": 0.03, "new": true}) }); }                 // a damping bulk capacitor for a low resonance: its ESR soaks up the LC ring                 for c in [47e-6, 100e-6, 220e-6] { cands.push(Branch { name: format!("new {}", fmt_c(c)), kind: "capacitor", r: 0.15, l: 3e-9 + mount, lp: 0.0, rp: 0.0, c, at: Some([r2(load.x), r2(load.y)]), extra: json!({"value": fmt_c(c), "package": "SMD can", "type": "electrolytic", "esrOhm": 0.15, "new": true}) }); }                 for cand in cands { let mut t = cur.clone(); t.push(cand.clone()); let z = curve(&t); let (_, r) = worst(&z); if best.as_ref().map_or(true, |b| r < b.0 * 0.98) { best = Some((r, cand)); } }@@ -256,7 +256,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, conv: &Value, issues: &mut Vec             "worst": {"hz": sig(fs[wi]), "ohm": sig(z0[wi]), "ratio": sig(wr), "inductive": bl, "capacitive": bc}, "traceWidthMm": wmm, "nHPerMm": sig(lpm * 1e9),             "branches": br, "added": added, "fixes": fixes, "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}));     }-    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; nominal capacitor values (no DC-bias derating); ESR by type; mounting inductance from a microstrip over the plane"})+    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()}) }  /// The same branches as an ngspice deck: a 1 A AC current into the rail, so |V| is |Z|.