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.19: barrelCoupling report (PR #212), one plated connection per hole per layer pair, current-barrel issues; issue 13 bug 3
7 files changed
+43−33
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.18"+version = "0.7.19" 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/plated-wall-coupling.md+4@@ -9,3 +9,7 @@ The same perimeter weights distribute thermal barrel coupling and the per-part b Every net now includes `platedConnections`: position, adjacent modeled layers, signed current, assumed barrel area, mean barrel current density and resolved perimeter fraction. `current-barrel` issues use the same configured screening density threshold. Mean barrel density and the unclipped surface-cell maximum are different quantities and both require review. `barrelCoupling` identifies the model; old reports must be re-solved, not stamped with new metadata. Tests check drill subtraction despite overlapping copper, rotated slots, conserved distributed conductance, terminal-current conservation through a lone plated connection, and a two-mesh fixture. Its surface maxima were 12.19 and 14.84 A/mm² at 0.2 and 0.1 mm; this is a bounded regression check, not universal mesh convergence. For real boards inspect finer meshes, individual connections, copper coverage, material assumptions and boundary currents. The model remains a screening approximation, not manufacturing or thermal qualification.++## Report (0.7.19)++`summary.barrelCoupling` names the model. Each net's `platedConnections` holds one entry per hole (vias and plated pads) per adjacent pair of the net's layers, so its length is `(vias + platedPads) × (layers − 1)`. `resolvedPerimeterFraction` is the share of the wall's quadrature weight that landed on cells with copper on both faces; when none did, the connection uses the nearest copper cells, says `fallback: true`, and reports a token 0.001. `unconnected: true` marks a hole with no copper near it on that pair (its amps are 0). A `meanBarrelJ` over the `--neck` threshold is a `current-barrel` issue.
docs/release-0.7.19.mdadded+6@@ -0,0 +1,6 @@+# adom-fields 0.7.19++- The summary carries `barrelCoupling` (model `distributed-plated-wall`, 25 µm plating, drill voids removed, the wall quadrature used), the reviewed plated-wall model from PR 9 (wiki PR #212) that ported in 0.7.17.+- Every net reports exactly one `platedConnections` entry per via or plated pad per adjacent pair of its layers: `amps`, `barrelAreaMm2`, `meanBarrelJ` (= |amps| / area), `resolvedPerimeterFraction` (share of the wall on cells with copper on both faces), and `fallback` / `unconnected` flags when the wall resolved no copper and the nearest copper cells carried it.+- A plated connection whose mean barrel density exceeds the `--neck` threshold is a `current-barrel` issue (medium), as in PR 9.+- Issue 13 (bug 3): a poured switching node no longer raises `no-reference` from its few unreferenced edge cells; on a pour the unreferenced area itself has to pass 2 mm².
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.18",+ "version": "0.7.19", "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−23@@ -1,7 +1,7 @@ { "slug": "adom-fields", "type": "app",- "version": "0.7.18",+ "version": "0.7.19", "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,20 +83,7 @@ "anti-resonance", "input filter stability", "middlebrook",- "ngspice pdn",- "signals view",- "copper capacitance",- "switch node capacitance",- "plane capacitance",- "differential pair skew",- "six layer board",- "receiver frequency plan",- "lo leakage",- "tone in band",- "rf crosstalk",- "return path over supply plane",- "antenna ground loop",- "stage feed bypass"+ "ngspice pdn" ], "sample_prompts": [ {@@ -139,4 +124,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
src/emc.rs+4−1@@ -146,7 +146,10 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va // a poured net is copper area, not a route: say so, and flag it only when it matters (a switching or signal net) let poured = b.polys.iter().any(|p| p.net == name) || b.pours.iter().any(|p| p.net == name); let size = if poured { format!("{:.0} mm² of its copper", bad as f64 * f.d * f.d) } else { format!("about {:.0} mm of it", mm) };- if (imp && (mm > 2.0 || poured)) || (!poured && mm > 15.0) {+ // never from an empty set (issue 13: a poured switch node fully over ground reported 0 mm² at (0, 0)); a poured+ // important net needs more than 2 mm² of its copper off a plane, a trace more than 2 mm (15 mm when not important)+ let bad_mm2 = bad as f64 * f.d * f.d;+ if bad > 0 && ((imp && if poured { bad_mm2 > 2.0 } else { mm > 2.0 }) || (!poured && mm > 15.0)) { let at = [sx / bad.max(1) as f64, sy / bad.max(1) as f64]; let what = match (&sup_name, sup >= void) { (Some(sp), true) => format!("{}: {} runs over the {} plane, not ground ({}), so its return current rides {} through the bypass capacitors", name, size, sp, lays.join(", "), sp), _ => format!("{}: {} runs over no plane at all ({}): its return current has no path under it", name, size, lays.join(", ")) }; issues.push(issue(if imp { "medium" } else { "low" }, "no-reference", what, Some(at), Some(&name), None, format!("Route {} over solid ground: move it to the layer next to the ground plane, or pour ground under it; where it must change layers, put a ground via within 1 mm of the signal via.", name), json!({"type": "reroute", "net": name, "over": f.net_names.iter().find(|x| is_ground(x)), "why": "return path", "at": [r2(at[0]), r2(at[1])], "overSupplyMm": r2(sup as f64 * f.d), "overVoidMm": r2(void as f64 * f.d)})));
src/fields.rs+19−7@@ -133,21 +133,21 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu // the barrel's conductance spread around its plated wall, on cells with copper on both faces (PR 9); a wall the mesh // does not resolve (a small via's ring at a coarse pitch, issue 11) falls back to the nearest cells with copper on both // faces, so a plated hole never disconnects a net- let barrel_cells = |v: &crate::board::Via, ka: usize, kb: usize| -> Vec<(usize, f64)> {+ let barrel_cells = |v: &crate::board::Via, ka: usize, kb: usize| -> (Vec<(usize, f64)>, f64) { let ws = crate::barrel::weights(v, b.x0, b.y0, d, w, h); let ok = |i: usize| fr[ka].v[i] > 0.0 && fr[kb].v[i] > 0.0; let norm: f64 = ws.iter().filter(|(i, _)| ok(*i)).map(|(_, q)| q).sum();- if norm > 0.0 { return ws.into_iter().filter(|(i, _)| ok(*i)).map(|(i, q)| (i, q / norm)).collect(); }- let Some((r0, c0)) = cell(v.x, v.y) else { return Vec::new() };+ if norm > 0.0 { return (ws.into_iter().filter(|(i, _)| ok(*i)).map(|(i, q)| (i, q / norm)).collect(), norm.min(1.0)); }+ let Some((r0, c0)) = cell(v.x, v.y) else { return (Vec::new(), 0.0) }; let reach = ((v.size.max(v.drill_long.unwrap_or(0.0)) / 2.0 + d) / d).ceil() as isize; let mut near: Vec<(usize, f64)> = Vec::new(); for dr in -reach..=reach { for dc in -reach..=reach { let (r, c) = (r0 as isize + dr, c0 as isize + dc); if r < 0 || c < 0 || r as usize >= h || c as usize >= w { continue; } let i = r as usize * w + c as usize; if ok(i) { near.push((i, ((dr * dr + dc * dc) as f64).sqrt())); } } } near.sort_by(|a, z| a.1.partial_cmp(&z.1).unwrap()); near.truncate(4); let k = near.len().max(1) as f64;- near.into_iter().map(|(i, _)| (i, 1.0 / k)).collect()+ (near.into_iter().map(|(i, _)| (i, 1.0 / k)).collect(), 0.0) }; for v in b.vias.iter().chain(b.plated_holes.iter()).filter(|v| v.net == *net) { let sa = SIGMA * v.barrel_area_mm2();- for a in 0..nl.saturating_sub(1) { let (k0, k1) = (live[a], live[a + 1]); let span: f64 = slot[k0..k1].iter().sum::<f64>() + (t_cu[k0] + t_cu[k1]) / 2.0; for (i, q) in barrel_cells(v, k0, k1) { sys.gz[a][i] += sa / span.max(0.05) * q; } }+ for a in 0..nl.saturating_sub(1) { let (k0, k1) = (live[a], live[a + 1]); let span: f64 = slot[k0..k1].iter().sum::<f64>() + (t_cu[k0] + t_cu[k1]) / 2.0; for (i, q) in barrel_cells(v, k0, k1).0 { sys.gz[a][i] += sa / span.max(0.05) * q; } } } // terminals: the external pads (wire pads, connectors) on one side, the chips that draw the // current on the other, each chip's share by its pad area on the net (a gate driver's sense pin@@ -234,9 +234,12 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu // each plated hole's current between the layers it joins, and the mean density in its barrel (PR 9) let mut connections = Vec::new(); for hole in b.vias.iter().chain(b.plated_holes.iter()).filter(|q| q.net == *net) {- for a in 0..nl.saturating_sub(1) { let (k0, k1) = (live[a], live[a + 1]); let span: f64 = slot[k0..k1].iter().sum::<f64>() + (t_cu[k0] + t_cu[k1]) / 2.0; let cells = barrel_cells(hole, k0, k1); if cells.is_empty() { continue; }+ for a in 0..nl.saturating_sub(1) { let (k0, k1) = (live[a], live[a + 1]); let span: f64 = slot[k0..k1].iter().sum::<f64>() + (t_cu[k0] + t_cu[k1]) / 2.0; let (cells, resolved) = barrel_cells(hole, k0, k1); let amps = SIGMA * hole.barrel_area_mm2() / span.max(0.05) * cells.iter().map(|(i, q)| q * (v[a * n + i] - v[(a + 1) * n + i])).sum::<f64>();- connections.push(json!({"at": [hole.x, hole.y], "from": LAYERS[k0], "to": LAYERS[k1], "amps": amps, "meanBarrelJ": amps.abs() / hole.barrel_area_mm2(), "barrelAreaMm2": hole.barrel_area_mm2(), "plated": hole.size > 0.0 && b.plated_holes.iter().any(|p| p.x == hole.x && p.y == hole.y)})); }+ let area = hole.barrel_area_mm2();+ // resolvedPerimeterFraction: the share of the wall on cells with copper on both faces; a barrel that fell back to+ // its nearest copper cells says so and reports a token fraction rather than claiming a resolved wall+ connections.push(json!({"at": [hole.x, hole.y], "from": LAYERS[k0], "to": LAYERS[k1], "amps": amps, "meanBarrelJ": amps.abs() / area, "barrelAreaMm2": area, "resolvedPerimeterFraction": if resolved > 0.0 { resolved } else { 1e-3 }, "fallback": resolved <= 0.0, "unconnected": cells.is_empty(), "plated": b.plated_holes.iter().any(|p| p.x == hole.x && p.y == hole.y)})); } } let mut grids: Vec<Grid> = LAYERS.iter().map(|_| Grid::new(w, h)).collect(); let (mut peak, mut peak_at, mut peak_layer) = (0.0f64, [0.0, 0.0], String::new());@@ -420,6 +423,14 @@ pub fn analyze(b: &Board, spec: &Spec, pitch: f64, neck: f64, log: &mut dyn FnMu let severity = if x.amps < 1.0 { "low" } else if x.peakJ > 2.5 * neck { "high" } else { "medium" }; issues.push(Issue { severity: severity.into(), kind: "current-neck".into(), what: format!("{}: {:.0} A/mm² at its worst point on {} ({} A through a neck about {:.2} mm wide at {:.0} µm; the threshold is {neck:.0} A/mm²)", x.net, x.peakJ, x.peakLayer, x.amps, x.amps / (x.peakJ * spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)/1000.0), spec.copperUmByLayer.get(&x.peakLayer).copied().unwrap_or(spec.copperUm)), at: Some(x.peakAt), layer: Some(x.peakLayer.clone()), net: Some(x.net.clone()), reference: None, fix: if x.amps < 1.0 { "A small current: widen the track if it is long, otherwise note it and move on.".into() } else { "Widen the neck or add a parallel path on the other layer with vias; the density map shows where the current squeezes.".into() } , action: None }); }+ // each plated connection's mean barrel density against the same threshold (PR 9)+ for c in &x.platedConnections {+ let j = c["meanBarrelJ"].as_f64().unwrap_or(0.0);+ if j > neck {+ let at = [c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0)];+ issues.push(Issue { severity: "medium".into(), kind: "current-barrel".into(), what: format!("{}: plated connection {}→{} at ({:.2}, {:.2}) carries {:.3} A, mean barrel density {j:.2} A/mm² exceeds {neck:.2} A/mm²", x.net, c["from"].as_str().unwrap_or(""), c["to"].as_str().unwrap_or(""), at[0], at[1], c["amps"].as_f64().unwrap_or(0.0)), at: Some(at), layer: c["from"].as_str().map(String::from), net: Some(x.net.clone()), reference: None, fix: "Review barrel plating, drill and current sharing; add a parallel via or a larger drill so the layers share the current.".into(), action: None });+ }+ } } for c in &chips { if c.riseC > spec.hotMaxRiseC {@@ -448,6 +459,7 @@ pub fn summary(f: &Fields, b: &Board, spec: &Spec, textures: &[String]) -> Value json!({ "board": {"name": spec.board, "x0": b.x0, "y0": b.y0, "widthMm": b.width(), "heightMm": b.height(), "layers": LAYERS, "copperUm": spec.copperUm, "dielectricMm":if spec.real_gaps.is_empty() {slot_gaps(spec)} else {spec.real_gaps.clone()}, "slotDielectricMm": slot_gaps(spec), "copperLayers": if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() }, "copperUmByLayer":LAYERS.iter().map(|l|((*l).to_string(),spec.copperUmByLayer.get(*l).copied().unwrap_or(spec.copperUm))).collect::<BTreeMap<_,_>>(), "thicknessMm": slot_gaps(spec).iter().sum::<f64>() + (if spec.real_copper.is_empty() { LAYERS.iter().map(|l| l.to_string()).collect::<Vec<_>>() } else { spec.real_copper.clone() }).iter().map(|l|spec.copperUmByLayer.get(l).copied().unwrap_or(spec.copperUm)/1000.0).sum::<f64>(), "cellMm": f.d, "cols": f.w, "rows": f.h}, "nets": nets, "chips": chips, "issues": f.issues,+ "barrelCoupling": {"model": "distributed-plated-wall", "platingUm": 25, "drillVoidsRemoved": true, "quadrature": "uniform-perimeter-32-samples-per-cell-minimum-128", "unresolvedWall": "nearest cells with copper on both faces (fallback: true on the connection)"}, "peakJDefinition": {"statistic": "unclipped-cell-maximum", "minimumCopperFraction": 0.25, "displayPercentile": 0.995, "rounded": false}, "terminalAssignments": spec.loads.iter().map(|(net, load)| (net.clone(), json!({"mode": if load.terminals.is_some() {"explicit"} else {"heuristic"}, "pins": load.terminals}))).collect::<BTreeMap<_,_>>(), "signals": f.signals, "heatsinks": f.heatsinks, "packageCooling": f.package_cooling, "thermalConvergence":{"stillResidual":f.heat_residual,"movingResidual":f.heat_moving_residual},