app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
main
Colby Knox
adom-fields 0.10.8: capacitor dielectric from the MPN's vendor code (issue 31); Murata C0G in the PDN model
f01bf13
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
//! 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'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 {
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.
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));
// 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
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);
}
}