← Commit history

adom-fields 0.10.8: capacitor dielectric from the MPN's vendor code (issue 31); Murata C0G in the PDN model

Colby Knox ·f01bf13617 · ·parent 98ac05c
Co-Authored-By: Claude Opus 5.5 <[email protected]>
8 files changed +99−16
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.10.7"+version = "0.10.8" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.10.7"+version = "0.10.8" 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."
docs/release-0.10.8.mdadded+26
@@ -0,0 +1,26 @@+# adom-fields 0.10.8++- **A capacitor's dielectric from its MPN (issue 31).** The capacitor-temperature check knew a part's rating only when the text "X7R", "C0G" or a °C rating appeared in its value, footprint or MPN. Samsung, Murata, KEMET and Walsin encode the dielectric as a letter or two inside the part number, so a Samsung CL05B104KO5NNNC (X7R, 125 °C) was taken as X5R (85 °C), and an LED strip with 44 such caps at about 78 °C got a false finding.+  - The MPN field's vendor codes are now read:+    - Samsung CL (A X5R, B X7R, C C0G, F Y5V);+    - Murata GRM, GCM, GCJ, GRT and GJM (5C C0G, R6 X5R, R7 X7R, C7 X7S, C8 X6S, D7 X7T, F5 Y5V);+    - KEMET C… (R X7R, P X5R, G C0G, U Z5U, V Y5V);+    - Walsin (N C0G, B X7R, X X5R, F Y5V).+  - TDK and Yageo spell the dielectric out (X7R, NPO), and that was already read. X8R and X8L now count as 150 °C.+  - A code not in the table is never guessed: the part stays at the assumed 85 °C, as before.+  - Each entry in `checks.capacitors` now has `dielectric`, `ratingFrom` (the value, or "MPN CL05B104KO5NNNC (Samsung B code)") and `mpn`.+  - The finding for capacitors whose rating is still assumed now says why: the dielectric couldn't be read from the value or the MPN field, or how many have no MPN field. Before, it said the dielectric "isn't in the part number". The fix it suggests starts with filling in the MPN field.+- **Murata C0G in the PDN model.** The C0G check read the wrong two characters of a Murata part number. A GRM1885C… above 1 nF was modelled as a class-II part that loses capacitance under bias. It now uses the same decoder.++**Checked:**++- A test LED board with six 100 nF 0402 caps at about 80 °C:+  - 0.10.7 flagged all six against an assumed 85 °C.+  - 0.10.8 reads the three Samsung CL05B and the one Murata GRM155R7 as X7R (125 °C), and flags only the cap with no MPN field and the one whose MPN has no known code. The message counts the one with no MPN field.+- Unit tests: 25 part numbers across Samsung, Murata, TDK, Yageo, KEMET and Walsin, including unknown codes that must stay unread.+- Real boards, every module, 0.10.7 against 0.10.8:+  - The decoder read the real MPNs right: StickHub's Murata GRM188R61A226 as X5R, the 12 V buck's Walsin 0603B104 as X7R and Samsung CL21A226 as X5R, and the tinytapeout demo's Samsung CL10A as X5R.+  - No finding changed, except the reason wording on the BQ25792 board, whose parts have no MPN field.+  - PDN output is identical apart from solve time.++**Not checked:** noah's LED strip board itself (no file).
package.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.10.7",-  "changelog": "0.10.7: the board's ground is the net named GND (or spec ground, else the ground with the most pads), not the first ground name in the alphabet, and a rail decoupled to another ground gets a PDN verdict (issue 25); coincident lands of one pad number are one terminal named by at (issue 26); verify's stitching vias keep their copper 0.5 mm from the board edge.",+  "version": "0.10.8",+  "changelog": "0.10.8: a ceramic capacitor's dielectric is read from the vendor's code in its MPN field (Samsung CL05B is X7R, Murata GRM155R7 is X7R, KEMET, Walsin, TDK and Yageo too), so X7R parts aren't flagged against an assumed 85 °C rating, and the finding says why a rating was assumed (issue 31); Murata C0G parts above 1 nF are seen as C0G in the PDN model.",   "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+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.10.7",-  "changelog": "0.10.7: the board's ground is the net named GND (or spec ground, else the ground with the most pads), not the first ground name in the alphabet, and a rail decoupled to another ground gets a PDN verdict (issue 25); coincident lands of one pad number are one terminal named by at (issue 26); verify's stitching vias keep their copper 0.5 mm from the board edge.",+  "version": "0.10.8",+  "changelog": "0.10.8: a ceramic capacitor's dielectric is read from the vendor's code in its MPN field (Samsung CL05B is X7R, Murata GRM155R7 is X7R, KEMET, Walsin, TDK and Yageo too), so X7R parts aren't flagged against an assumed 85 °C rating, and the finding says why a rating was assumed (issue 31); Murata C0G parts above 1 nF are seen as C0G in the PDN model.",   "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.",
skills/adom-fields-review/SKILL.md+1−1
@@ -50,7 +50,7 @@ A hot part with a tab net is read at its tab: the pad named in `hot.<ref>.tabPad - **Board checks** (0.9.0, `review.checks`), on the solved fields where one exists:   - `ampacity`: each loaded net's narrowest neck against IPC-2221's 10 °C rating, beside the solved rise there.   - `junctions` / `junction-temperature`: ambient (spec `ambientC`, 25) + the solved rise (+ `hot.<ref>.thetaJcKPerW`·W) against `hot.<ref>.tjMaxC` (125).-  - `capacitors` / `capacitor-temperature`: each capacitor's solved temperature against its type's rating (read from value or part number; electrolytic life by the 10 °C rule; ceramics with only an assumed rating grouped into one finding).+  - `capacitors` / `capacitor-temperature`: each capacitor's solved temperature against its type's rating (a °C rating or dielectric in the value, else the vendor's code in the footprint's MPN field: Samsung CL, Murata GRM/GCM, TDK, Yageo, KEMET, Walsin; each entry gives `dielectric` and `ratingFrom`, and `ratingAssumed` when neither says; electrolytic life by the 10 °C rule; ceramics with only an assumed rating grouped into one finding).   - `emcRules`: `clock-outer-layer`, `clock-near-connector`, `edge-signal`, `io-unfiltered`, `input-filter-cutoff` (a converter's input LC against fsw/10).   - `stackup`: which layers are planes, plus `stackup-far-reference`, `stackup-adjacent-signals` and plane-pair pF/cm².   - `viaStubs` / `via-stub`: the quarter-wave resonance of each signal via's unused barrel against its clock.
src/caps.rs+57−5
@@ -61,13 +61,39 @@ pub fn classify(value: &str, mpn: &str, footprint: &str) -> &'static str {     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's dielectric and its rated maximum temperature (°C), from the text first (X7R, C0G, NP0 in the Value or the+/// footprint), then from the vendor's code inside the MPN (issue 31). Returns (dielectric, rating, where it was read).+/// None when neither says: an unknown code is never guessed.+pub fn dielectric(text: &str, mpn: &str) -> Option<(&'static str, f64, String)> {+    const CODES: [(&str, &str, f64); 14] = [("C0G", "C0G", 125.0), ("COG", "C0G", 125.0), ("NP0", "C0G", 125.0), ("NPO", "C0G", 125.0), ("X8R", "X8R", 150.0), ("X8L", "X8L", 150.0),+        ("X7R", "X7R", 125.0), ("X7S", "X7S", 125.0), ("X7T", "X7T", 125.0), ("X6S", "X6S", 105.0), ("X5R", "X5R", 85.0), ("X5S", "X5S", 85.0), ("Y5V", "Y5V", 85.0), ("Z5U", "Z5U", 85.0)];+    let (t, m) = (text.to_ascii_uppercase(), mpn.trim().to_ascii_uppercase());+    if let Some((_, d, r)) = CODES.iter().find(|(k, ..)| t.contains(k)) { return Some((d, *r, "the value".into())); }+    if let Some((_, d, r)) = CODES.iter().find(|(k, ..)| m.contains(k)) { return Some((d, *r, format!("MPN {mpn}"))); }+    let by = |d: &'static str, vendor: &str| CODES.iter().find(|c| c.1 == d).map(|c| (c.1, c.2, format!("MPN {mpn} ({vendor} code)")));+    let b = m.as_bytes(); let digits = |r: std::ops::Range<usize>| m.len() >= r.end && b[r].iter().all(|c| c.is_ascii_digit());+    // Samsung CL: CL + size (05, 10, 21…) + the dielectric letter: CL05B104KO5NNNC is X7R+    if m.starts_with("CL") && digits(2..4) && m.len() > 4 {+        return match b[4] { b'A' => by("X5R", "Samsung A"), b'B' => by("X7R", "Samsung B"), b'C' => by("C0G", "Samsung C"), b'F' => by("Y5V", "Samsung F"), _ => None };+    }+    // Murata GRM/GCM/GCJ/GRT/GJM: series + size (18) + height (8) + the two-character dielectric: GRM188R71H104KA93D is X7R+    if ["GRM", "GCM", "GCJ", "GRT", "GJM"].iter().any(|s| m.starts_with(s)) && digits(3..5) && m.len() >= 8 {+        return match &m[6..8] { "5C" => by("C0G", "Murata 5C"), "R7" => by("X7R", "Murata R7"), "R6" => by("X5R", "Murata R6"), "C7" => by("X7S", "Murata C7"), "C8" => by("X6S", "Murata C8"), "D7" => by("X7T", "Murata D7"), "F5" => by("Y5V", "Murata F5"), _ => None };+    }+    // KEMET: C + size (0402) + C + capacitance (104) + tolerance + voltage + the dielectric letter: C0402C104K4RAC is X7R+    if b.first() == Some(&b'C') && digits(1..5) && b.get(5) == Some(&b'C') && digits(6..9) && m.len() > 11 && b[9].is_ascii_alphabetic() {+        return match b[11] { b'R' => by("X7R", "KEMET R"), b'P' => by("X5R", "KEMET P"), b'G' => by("C0G", "KEMET G"), b'U' => by("Z5U", "KEMET U"), b'V' => by("Y5V", "KEMET V"), _ => None };+    }+    // Walsin: size (0402) + the dielectric letter + capacitance: 0402B104K500CT is X7R+    if digits(0..4) && digits(5..8) && m.len() > 8 {+        return match b[4] { b'N' => by("C0G", "Walsin N"), b'B' => by("X7R", "Walsin B"), b'X' => by("X5R", "Walsin X"), b'F' => by("Y5V", "Walsin F"), _ => None };+    }+    None+}+ /// 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…+    if let Some((d, ..)) = dielectric(value, mpn) { return d == "C0G"; }     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.@@ -110,6 +136,32 @@ pub fn info(m: &CapModel, value: &str, mpn: &str) -> BTreeMap<&'static str, Valu         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));+        // a Murata C0G above 1 nF: its code is at characters 7 and 8, which 0.10.7 missed+        assert!(class_one("10n", "GRM1885C1H103JA01D", 10e-9));+        assert!(!class_one("10n", "GRM188R71H103KA01D", 10e-9));+    }+    // issue 31: the dielectric from the vendor's code in the MPN, and nothing guessed+    #[test] fn dielectric_from_the_mpn() {+        let d = |v: &str, m: &str| dielectric(v, m).map(|x| (x.0, x.1));+        assert_eq!(d("100n", "CL05B104KO5NNNC"), Some(("X7R", 125.0)));      // the reported part+        assert_eq!(d("22u", "CL21A226MPQNNNE"), Some(("X5R", 85.0)));+        assert_eq!(d("100p", "CL10C101JB8NNNC"), Some(("C0G", 125.0)));+        assert_eq!(d("100n", "GRM155R71C104KA88D"), Some(("X7R", 125.0)));+        assert_eq!(d("10u", "GRM188R61A106KE69D"), Some(("X5R", 85.0)));+        assert_eq!(d("1n", "GCM1555C1H102JA16D"), Some(("C0G", 125.0)));+        assert_eq!(d("4.7u", "GRM21BC71E475KE11L"), Some(("X7S", 125.0)));+        assert_eq!(d("100n", "C1608X7R1H104K080AA"), Some(("X7R", 125.0)));   // TDK spells it out+        assert_eq!(d("100n", "CGA3E2X8R1H104K080AA"), Some(("X8R", 150.0)));+        assert_eq!(d("100n", "CC0402KRX7R9BB104"), Some(("X7R", 125.0)));     // Yageo+        assert_eq!(d("10p", "CC0402JRNPO9BN100"), Some(("C0G", 125.0)));+        assert_eq!(d("100n", "C0402C104K4RACTU"), Some(("X7R", 125.0)));      // KEMET+        assert_eq!(d("1u", "C0603C105K8PACTU"), Some(("X5R", 85.0)));+        assert_eq!(d("100n", "0402B104K500CT"), Some(("X7R", 125.0)));        // Walsin+        // the value wins over the MPN, and says so+        assert_eq!(dielectric("100n X5R", "CL05B104KO5NNNC").unwrap().2, "the value");+        assert!(dielectric("100n", "CL05B104KO5NNNC").unwrap().2.contains("Samsung B"));+        // nothing to read, or a code not in the table: not guessed+        for (v, m) in [("100n", ""), ("100n", "CL05Q104KO5NNNC"), ("100n", "GRM155Z71C104KA88D"), ("100n", "885012205037"), ("100n", "CAP-0402-100N"), ("100n", "C0402")] { assert_eq!(d(v, m), None, "{m}"); }     }     #[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
src/checks.rs+9−4
@@ -83,11 +83,14 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, rev: &Value, issues: &mut Vec<         if tmax < 5.0 { continue; }         let fp = b.footprints.get(r).cloned().unwrap_or_default(); let mpn = b.mpns.get(r).cloned().unwrap_or_default(); let all = format!("{v} {fp} {mpn}").to_ascii_uppercase();         let electrolytic = all.contains("CP_") || all.contains("ELEC") || all.contains("POLARIZED");-        let rating = if all.contains("125C") || all.contains("X7R") || all.contains("X7S") || all.contains("C0G") || all.contains("NP0") { 125.0 } else if all.contains("105C") || all.contains("X6S") { 105.0 } else if electrolytic { 85.0 } else { 85.0 };+        // a rating in °C wins; else the dielectric, from the value or the code inside the MPN (issue 31); else 85 °C, assumed+        let stated = if all.contains("150C") { Some(150.0) } else if all.contains("125C") { Some(125.0) } else if all.contains("105C") { Some(105.0) } else if all.contains("85C") { Some(85.0) } else { None };+        let diel = if electrolytic { None } else { crate::caps::dielectric(&format!("{v} {fp}"), &mpn) };+        let rating = stated.or(diel.as_ref().map(|d| d.1)).unwrap_or(85.0);         let t = ambient + tmax;         let life = if electrolytic { Some(2f64.powf((rating - t) / 10.0)) } else { None };-        caps.push(json!({"ref": r, "value": v, "kind": if electrolytic { "electrolytic" } else { "ceramic or other" }, "tempC": r1(t), "ratingC": rating, "ratingAssumed": !(all.contains("125C") || all.contains("105C") || all.contains("X7R") || all.contains("X5R") || all.contains("C0G")), "lifeFactor": life.map(r2), "at": at}));-        let assumed = !(all.contains("125C") || all.contains("105C") || all.contains("85C") || all.contains("X7R") || all.contains("X5R") || all.contains("X7S") || all.contains("X6S") || all.contains("C0G") || all.contains("NP0"));+        caps.push(json!({"ref": r, "value": v, "kind": if electrolytic { "electrolytic" } else { "ceramic or other" }, "tempC": r1(t), "ratingC": rating, "ratingAssumed": stated.is_none() && diel.is_none(), "dielectric": diel.as_ref().map(|d| d.0), "ratingFrom": if stated.is_some() { Some("a rating in °C in the value or part number".to_string()) } else { diel.as_ref().map(|d| d.2.clone()) }, "mpn": if mpn.is_empty() { None } else { Some(mpn.clone()) }, "lifeFactor": life.map(r2), "at": at}));+        let assumed = stated.is_none() && diel.is_none();         if assumed && !electrolytic { if t > rating - 10.0 { hot_assumed.push((r.clone(), t, at)); } continue; }         if t > rating - 10.0 {             issues.push(issue(if t > rating { "medium" } else { "low" }, "capacitor-temperature", format!("{} ({}): about {:.0} °C where it sits, against a {:.0} °C rating{}", r, v, t, rating, life.map(|l| format!("; electrolytic life about {:.1}× its rated hours", l)).unwrap_or_default()), Some(at), None, Some(r.clone()), format!("Move {} away from the hot part, or use a part rated for {:.0} °C or more (the rating is read from the value or part number; give it there if it is assumed).", r, (t + 20.0).ceil()), json!({"type": "move", "ref": r, "why": "capacitor temperature", "tempC": r1(t), "ratingC": rating})));@@ -98,7 +101,9 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, rev: &Value, issues: &mut Vec<         hot_assumed.sort_by(|a, z| z.1.partial_cmp(&a.1).unwrap());         let (r0, t0, at0) = hot_assumed[0].clone();         let names: Vec<String> = hot_assumed.iter().take(8).map(|x| format!("{} {:.0} °C", x.0, x.1)).collect();-        issues.push(issue(if t0 > 85.0 { "medium" } else { "low" }, "capacitor-temperature", format!("{} ceramic capacitor(s) sit within 10 °C of an 85 °C rating, or over it ({}{}); their dielectric isn't in the value or part number, so 85 °C (X5R) is assumed", hot_assumed.len(), names.join(", "), if hot_assumed.len() > 8 { ", …" } else { "" }), Some(at0), None, Some(r0.clone()), "Give each part's dielectric (X7R: 125 °C) in its value or part number, use X7R parts near the hot spots, or move them away from the heat.".into(), json!({"type": "review", "refs": hot_assumed.iter().map(|x| x.0.clone()).collect::<Vec<_>>(), "why": "capacitor temperature (rating assumed)"})));+        let no_mpn = hot_assumed.iter().filter(|x| b.mpns.get(&x.0).is_none_or(|m| m.trim().is_empty())).count();+        let why = match no_mpn { 0 => "the dielectric could not be read from the value or the MPN field".to_string(), n if n == hot_assumed.len() => "the dielectric isn't in the value and they have no MPN field".to_string(), n => format!("the dielectric could not be read from the value or the MPN field ({n} {} no MPN field)", if n == 1 { "has" } else { "have" }) };+        issues.push(issue(if t0 > 85.0 { "medium" } else { "low" }, "capacitor-temperature", format!("{} ceramic capacitor(s) sit within 10 °C of an 85 °C rating, or over it ({}{}); {why}, so 85 °C (X5R) is assumed", hot_assumed.len(), names.join(", "), if hot_assumed.len() > 8 { ", …" } else { "" }), Some(at0), None, Some(r0.clone()), "Put each part's MPN in the footprint's MPN field (Samsung CL, Murata GRM and GCM, TDK, Yageo, KEMET and Walsin codes are read), or its dielectric in the value (X7R: 125 °C). If the parts really are X5R, use X7R near the hot spots or move them away from the heat.".into(), json!({"type": "review", "refs": hot_assumed.iter().map(|x| x.0.clone()).collect::<Vec<_>>(), "why": "capacitor temperature (rating assumed)"})));     }     caps.sort_by(|a, z| z["tempC"].as_f64().partial_cmp(&a["tempC"].as_f64()).unwrap());     out["capacitors"] = json!(caps);