← Commit history

adom-fields 0.10.5: converter power ground and shunt values with ratings (issue 24)

John Lauer ·7169a99df0 · ·parent 7dc4906
7 files changed +46−18
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.10.4"+version = "0.10.5" 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.5.mdadded+9
@@ -0,0 +1,9 @@+# adom-fields 0.10.5++Two fixes for 4-switch buck-boost boards, both from issue 24 (thanks, Glenn):++- **Resistor values with a rating or a size.** A shunt's Value often carries its power rating or tolerance after the resistance: `3m 1W`, `3mR 1%`, `0R003 1W`, `3.0 0.75W`, `10k 0402`. The parser joined the tokens (`3m1W`) and gave up, so a 3 mΩ shunt between the switch node and the inductor was not seen as a series link, and the review came back `unsupported`. Rating tokens (ending in W, V, % or containing ppm) are now dropped, and the resistance is the first token. Spec `converter.series: ["R1"]` names a series part outright when its Value says something else.+- **The converter's ground is the power ground.** It was the first ground-named net in alphabetical order, which on a board with AGND and PGND is AGND, the controller's quiet ground. The review then saw only the small AGND decoupling caps (0.03 µF at bias, so tens of volts of ripple) and no hot loops. The ground is now the net the switch legs return to (the low-side switches' sources). Ties go to the net that more input and output capacitors share. Spec `converter.ground` overrides it, and `review.converter` records `ground`, `groundFrom` and every ground net, so a wrong pick is visible.+- **Checked:**+  - On a 4-switch test board, the shunt with Value `3m 1W` and a split AGND/PGND gave `unsupported` in 0.10.4. In 0.10.5 it gives the same numbers as the single-ground board: buck mode D 0.816, ΔI 2.31 A, hot loop C1; boost mode D 0.125, ΔI 1.37 A, hot loop C2.+  - On the 12 V buck, every finding and number is unchanged.
package.json+2−2
@@ -1,8 +1,8 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.10.4",-  "changelog": "0.10.4: 4-switch buck-boost converters (inductor through a current shunt, buck and boost modes, unsupported instead of null; issue 24); impedance reference found under each trace, reported per section, mixed or missing references not given an impedance (issue 23).",+  "version": "0.10.5",+  "changelog": "0.10.5: converter ground is the switch legs' return (PGND, not AGND), with spec converter.ground and the pick recorded; resistor Values with a rating or size (3m 1W, 0R003 1%) read as their resistance, spec converter.series (issue 24).",   "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.4",-  "changelog": "0.10.4: 4-switch buck-boost converters (inductor through a current shunt, buck and boost modes, unsupported instead of null; issue 24); impedance reference found under each trace, reported per section, mixed or missing references not given an impedance (issue 23).",+  "version": "0.10.5",+  "changelog": "0.10.5: converter ground is the switch legs' return (PGND, not AGND), with spec converter.ground and the pick recorded; resistor Values with a rating or size (3m 1W, 0R003 1%) read as their resistance, spec converter.series (issue 24).",   "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-spec/SKILL.md+6−4
@@ -57,10 +57,12 @@ chips sit over the fields. - **Package cooling** (PR 11): `packageCooling: [{reference, junctionCaseKPerW, caseSinkKPerW, sinkAmbientStillKPerW, sinkAmbientMovingKPerW, boardLinks, provenance}]` puts a hot part's watts at its junction and lets them split through the supplied case, sink and board links; junction, case and sink rises per air state and an energy balance in `packageCooling`. See docs/package-cooling.md. - **`adom-fields thermal3d --spec --out`** (PR 12): axis-aligned 3D solids with explicit materials, contacts and convection for an off-board package and finned sink, with a GLB of the surface temperatures. Not CFD. See docs/thermal3d.md. -## The converter block (0.10.4)+## The converter block (0.10.5) -`converter: {inductor: "L1", vinMin: 21}`, both optional:-- **`inductor`** names the inductor when its path cannot be traced. The tracing already follows current shunts, ferrites and 0 Ω links.+`converter: {inductor: "L1", vinMin: 21, ground: "PGND", series: ["R1"]}`, all optional:+- **`inductor`** names the inductor when its path cannot be traced. The tracing already follows current shunts (a Value such as `3m 1W` or `0R003 1%` reads as 3 mΩ), ferrites and 0 Ω links. - **`vinMin`** is the lowest input. For a 4-switch buck-boost (the inductor between two switch nodes, each listed in `switching` with its switches in `sources`), it evaluates the boost mode alongside the buck mode at the spec's input voltage.-- **The record:** `review.converter` has `topology`, `switchNodes`, `seriesParts` and `modes` (each with duty, inductor current, ripple, hot loop and emissions). It is `{"unsupported": why}` when the board cannot be modelled.+- **`ground`** is the power-stage ground. Without it, the ground is the net the switch legs return to (the low-side switches' sources), so a board with AGND and PGND uses PGND. Set it only when `review.converter.groundFrom` shows a wrong pick.+- **`series`** lists parts to treat as series links on the inductor's path when their Value does not read as a low resistance.+- **The record:** `review.converter` has `topology`, `switchNodes`, `seriesParts`, `ground`, `groundFrom`, `groundNets` and `modes` (each with duty, inductor current, ripple, hot loop and emissions). It is `{"unsupported": why}` when the board cannot be modelled. 
src/converter.rs+25−8
@@ -27,7 +27,12 @@ fn issue(sev: &str, kind: &str, what: String, at: Option<[f64; 2]>, net: Option<  /// A resistor value in ohms from its Value text: "3m", "3mR", "0R003", "0.003", "0", "10k", "4R7" pub fn ohms(s: &str) -> Option<f64> {-    let t = s.trim().replace(['Ω', ' '], "").replace("ohm", "").replace("Ohm", "");+    // the Value often carries a rating or a size after the resistance ("3m 1W", "3mR 1%", "10k 0402", "3.0 0.75W"):+    // drop rating tokens, then take the first token, joined with the next when that is a bare unit ("3 mΩ")+    let rating = |w: &str| { let l = w.to_ascii_lowercase(); l.ends_with('w') || l.ends_with('v') || l.ends_with('%') || l.contains("ppm") };+    let toks: Vec<&str> = s.split_whitespace().filter(|w| !rating(w)).collect();+    let first = match toks.as_slice() { [] => return None, [a, u, ..] if u.chars().all(|c| c.is_alphabetic()) => format!("{a}{u}"), [a, ..] => a.to_string() };+    let t = first.replace('Ω', "").replace("ohm", "").replace("Ohm", "");     let t = t.split(['/', '_', ',']).next().unwrap_or("").to_string();     if t.is_empty() { return None; }     // a multiplier or R in place of the decimal point: 4R7, 0R003, 4k7, 3m3@@ -41,14 +46,16 @@ pub fn ohms(s: &str) -> Option<f64> {  /// The parts that join two nets with almost no impedance, for following an inductor's path: resistors of 1 Ω or less /// (current shunts, 0 Ω links), ferrite beads, and 2-pad net ties and jumpers.-fn series(b: &Board, r: &str) -> bool {+fn series(b: &Board, spec: &Spec, r: &str) -> bool {+    if spec.converter.as_ref().is_some_and(|c| c.series.iter().any(|x| x == r)) { return true; }     let v = b.values.get(r).map(String::as_str).unwrap_or("");     (r.starts_with('R') && ohms(v).is_some_and(|x| x <= 1.0)) || r.starts_with("FB") || r.starts_with("NT") || r.starts_with("JP") || (r.starts_with('L') && v.to_ascii_lowercase().contains("ferrite")) }  pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Value {     let rail = |n: &str| spec.voltages.get(n).copied().or_else(|| rail_from_name(n));-    let Some(gnd) = f.net_names.iter().find(|n| is_ground(n)).cloned() else { return Value::Null };+    let grounds: Vec<String> = f.net_names.iter().filter(|n| is_ground(n)).cloned().collect();+    if grounds.is_empty() { return Value::Null; }     let pads_of = |r: &str, n: &str| -> Vec<&Pad> { b.pads.iter().filter(|p| p.reference == r && p.net == n).collect() };     let nets_of = |r: &str| -> Vec<String> { let mut v: Vec<String> = b.pads.iter().filter(|p| p.reference == r).map(|p| p.net.clone()).collect(); v.sort(); v.dedup(); v };     let refs: Vec<String> = { let mut v: Vec<String> = b.pads.iter().map(|p| p.reference.clone()).collect(); v.sort(); v.dedup(); v };@@ -61,7 +68,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va     // the nets a node reaches through series parts (two hops), and the parts on the way     let through = |start: &str| -> (Vec<String>, Vec<String>) {         let (mut nets, mut parts) = (vec![start.to_string()], Vec::new());-        for _ in 0..2 { let cur = nets.clone(); for n in &cur { for r in refs.iter().filter(|r| series(b, r) && nets_of(r).len() == 2 && nets_of(r).contains(n)) { for m in nets_of(r) { if !nets.contains(&m) && m != gnd { nets.push(m); if !parts.contains(r) { parts.push(r.clone()); } } } } } }+        for _ in 0..2 { let cur = nets.clone(); for n in &cur { for r in refs.iter().filter(|r| series(b, spec, r) && nets_of(r).len() == 2 && nets_of(r).contains(n)) { for m in nets_of(r) { if !nets.contains(&m) && !grounds.contains(&m) { nets.push(m); if !parts.contains(r) { parts.push(r.clone()); } } } } } }         (nets, parts)     };     // the inductor: named, else an L (not a ferrite) with a pad on a node or on a net the node reaches through series parts@@ -69,7 +76,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va     let mut found: Option<(String, String, String, Vec<String>)> = None; // (inductor, node, its far net, series parts)     for n in &nodes {         let (reach, parts) = through(n);-        let cand: Vec<&String> = match &named { Some(l) => refs.iter().filter(|r| *r == l).collect(), None => refs.iter().filter(|r| r.starts_with('L') && !series(b, r) && nets_of(r).len() == 2).collect() };+        let cand: Vec<&String> = match &named { Some(l) => refs.iter().filter(|r| *r == l).collect(), None => refs.iter().filter(|r| r.starts_with('L') && !series(b, spec, r) && nets_of(r).len() == 2).collect() };         if let Some(l) = cand.into_iter().find(|r| nets_of(r).iter().any(|x| reach.contains(x))) {             let ln = nets_of(l); let near_side = ln.iter().find(|x| reach.contains(*x)).cloned().unwrap(); let far = ln.into_iter().find(|x| *x != near_side).unwrap_or_default();             let used: Vec<String> = parts.into_iter().filter(|p| { let pn = nets_of(p); pn.contains(&near_side) || (near_side != *n && pn.contains(n)) }).collect();@@ -84,13 +91,23 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va     let leg = |n: &str| -> Vec<String> { spec.sources.get(n).cloned().filter(|v| !v.is_empty()).unwrap_or_else(|| refs.iter().filter(|r| (r.starts_with('Q') || r.starts_with('U')) && nets_of(r).contains(&n.to_string())).cloned().collect()) };     let leg_a = leg(&node_a); if leg_a.is_empty() { return unsupported(format!("no switch on {node_a}: give spec sources.{node_a}")); }     // a leg's rail: the highest-voltage net its parts reach besides the node and ground-    let leg_rail = |parts: &[String], node: &str| -> Option<String> { let mut c: Vec<String> = parts.iter().flat_map(|p| nets_of(p)).filter(|n| n != node && *n != gnd && rail(n).is_some()).collect(); c.sort(); c.dedup(); c.sort_by(|a, z| rail(z).partial_cmp(&rail(a)).unwrap()); c.into_iter().next() };+    let leg_rail = |parts: &[String], node: &str| -> Option<String> { let mut c: Vec<String> = parts.iter().flat_map(|p| nets_of(p)).filter(|n| n != node && !grounds.contains(n) && rail(n).is_some()).collect(); c.sort(); c.dedup(); c.sort_by(|a, z| rail(z).partial_cmp(&rail(a)).unwrap()); c.into_iter().next() };     let Some(in_net) = leg_rail(&leg_a, &node_a) else { return unsupported(format!("{}'s parts reach no rail with a known voltage: give its input in spec voltages", node_a)) };     let (topology, leg_b, out_net) = match &node_b {         Some(nb) => { let lb = leg(nb); let Some(o) = leg_rail(&lb, nb) else { return unsupported(format!("{nb}'s parts reach no rail with a known voltage: give the output in spec voltages")) }; ("buck-boost (4-switch)", lb, o) }         None => { // a buck's output: the inductor's far net, or the rail it reaches through series parts             let (reach, _) = through(&far); let o = if rail(&far).is_some() { far.clone() } else { reach.into_iter().find(|n| rail(n).is_some()).unwrap_or(far.clone()) }; ("buck", vec![], o) }     };+    // the power ground (issue 24): the spec's, else the ground net the switch legs return to (a low-side switch's+    // source), ties broken by the capacitors from the input and output to it; else the first ground net+    let leg_parts: Vec<String> = leg_a.iter().chain(leg_b.iter()).cloned().collect();+    let score = |g: &String| -> (usize, usize) { (leg_parts.iter().map(|p| pads_of(p, g).len()).sum(), refs.iter().filter(|r| r.starts_with('C') && { let n = nets_of(r); n.contains(g) && (n.contains(&in_net) || n.contains(&out_net)) }).count()) };+    let named_gnd = spec.converter.as_ref().and_then(|c| c.ground.clone());+    let (gnd, gnd_from) = match &named_gnd {+        Some(g) => (g.clone(), "spec converter.ground"),+        None => { let best = grounds.iter().max_by_key(|g| score(g)).cloned().unwrap(); if score(&best).0 > 0 { (best, "switch legs") } else if score(&best).1 > 0 { (best, "input and output capacitors") } else { (grounds[0].clone(), "first ground net") } }+    };+    if !f.net_names.contains(&gnd) { return unsupported(format!("spec converter.ground {gnd} is not a net on this board")); }     if node_b.is_none() && rail(&out_net).is_none() { return unsupported(format!("{lref}'s far side, {out_net}, has no known voltage: if {out_net} is a second switch node (a 4-switch buck-boost), add it to spec switching with its switches in spec sources; otherwise give the output's voltage in spec voltages")); }     let (Some(l), Some(vin), Some(vout)) = (b.values.get(&lref).and_then(|v| parse_value(v, 'H')), rail(&in_net), rail(&out_net)) else { return unsupported(format!("{lref}'s inductance, {in_net}'s or {out_net}'s voltage is unknown (part Value, spec voltages)")) };     let Some(iout) = spec.loads.get(&out_net).map(|x| x.amps) else { return unsupported(format!("no load on {out_net}: give spec loads.{out_net}")) };@@ -175,7 +192,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va             "emissions": em.as_ref().map(|(m, fr, sets)| json!({"worstMarginDb": (m * 10.0).round() / 10.0, "atMHz": (fr / 1e6).round(), "limit": format!("{label}, 3 m"), "model": "Ott differential-mode loop; no cables", "allLimits": sets}))});         if top.is_none() {             top = Some(json!({"topology": topology, "switchNodes": if let Some(nb) = &node_b { json!([node_a, nb]) } else { json!([node_a]) }, "seriesParts": via.iter().map(|r| json!({"ref": r, "value": b.values.get(r)})).collect::<Vec<_>>(),-                "inCaps": capj(&cin_m), "outCaps": capj(&cout_m), "switchNet": m.node, "switch": m.leg.first(), "switches": m.leg, "inductor": lref, "inNet": in_net, "outNet": out_net, "vin": m.vin, "vout": vout, "iout": iout, "fswHz": fsw, "riseNs": tr * 1e9,+                "ground": gnd, "groundFrom": gnd_from, "groundNets": grounds, "inCaps": capj(&cin_m), "outCaps": capj(&cout_m), "switchNet": m.node, "switch": m.leg.first(), "switches": m.leg, "inductor": lref, "inNet": in_net, "outNet": out_net, "vin": m.vin, "vout": vout, "iout": iout, "fswHz": fsw, "riseNs": tr * 1e9,                 "duty": m.duty, "rippleA": m.di, "rippleRatio": ratio, "lUh": l * 1e6, "cinUf": ccin * 1e6, "coutUf": ccout * 1e6, "rippleInMv": m.rip_in * 1e3, "rippleOutMv": m.rip_out * 1e3, "inputPulsed": m.caps_in,                 "hotLoops": rec["hotLoops"].clone(), "hotLoop": rec["hotLoop"].clone(), "idealMm2": rec["idealMm2"].clone(), "emissions": rec["emissions"].clone()}));         }@@ -188,7 +205,7 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va #[cfg(test)] mod tests {     use super::*;     #[test] fn resistor_values() {-        for (s, v) in [("3m", 0.003), ("3mR", 0.003), ("0R003", 0.003), ("0.003", 0.003), ("0", 0.0), ("10k", 10e3), ("4R7", 4.7), ("4k7", 4700.0), ("100", 100.0), ("3 mΩ", 0.003)] { assert!((ohms(s).unwrap() - v).abs() < 1e-12, "{s}"); }+        for (s, v) in [("3m", 0.003), ("3mR", 0.003), ("0R003", 0.003), ("0.003", 0.003), ("0", 0.0), ("10k", 10e3), ("4R7", 4.7), ("4k7", 4700.0), ("100", 100.0), ("3 mΩ", 0.003), ("3m 1W", 0.003), ("3mR 1%", 0.003), ("0R003 1W", 0.003), ("3.0 0.75W", 3.0), ("10k 0402", 10e3), ("0.003 1/2W", 0.003), ("10k 1% 50ppm", 10e3)] { assert!((ohms(s).unwrap() - v).abs() < 1e-12, "{s}"); }         assert!(ohms("DNP").is_none());     } }
src/fields.rs+1−1
@@ -761,4 +761,4 @@ fn current_peak_and_clip(grid: &mut Grid, fraction: &Grid) -> Option<(f64, usize  #[allow(non_snake_case)] #[derive(Deserialize, Clone, Debug, Default)]-pub struct ConverterCfg { #[serde(default)] pub inductor: Option<String>, #[serde(default)] pub vinMin: Option<f64> }+pub struct ConverterCfg { #[serde(default)] pub inductor: Option<String>, #[serde(default)] pub vinMin: Option<f64>, #[serde(default)] pub ground: Option<String>, #[serde(default)] pub series: Vec<String> }