← Commit history

adom-fields 0.7.7: decoupling and ESD checks, each with an action, in the review and the EMC view

John Lauer ·f5df51521c · ·parent 4b08dc3
13 files changed +76−20
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-fields"-version = "0.7.6"+version = "0.7.7" dependencies = [  "clap",  "png",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-fields"-version = "0.7.6"+version = "0.7.7" 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."
README.md+1−1
@@ -107,7 +107,7 @@ The field every current makes: field lines looping around the copper, and a prob  ### EMC screen -Where the board could radiate: the switching hot loop and the smallest it could be, copper with no ground under it, and the loop's estimated emissions against the FCC class B limit.+Where the board could radiate: the switching hot loop and the smallest it could be, copper with no ground under it, the loop's estimated emissions against the FCC class B limit, each chip power pin's decoupling capacitor, and the connector pins with no ESD protection.  <video controls preload="metadata" width="1080" poster="docs/videos/emc-poster.jpg" src="docs/videos/emc.mp4"></video> 
SKILL.md+2−2
@@ -128,9 +128,9 @@ A screen, not a chamber: differential mode only; cables (common mode), which oft ## For the AI that called the solve: the review and the fix plan (0.7.6)  `analyze` reviews the design as well as solving it, and says what to change, without anyone looking at the app:-- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop`, `inductor-ripple`, `output-ripple`.+- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop`, `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside). - **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line.-- **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail).+- **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it). - Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).  After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle).
bin/adom-fields
⋯ 1 unchanged line ⋯
docs/release-0.7.7.mdadded+5
@@ -0,0 +1,5 @@+# adom-fields 0.7.7++- **Decoupling.** Each chip power pin and its nearest small capacitor: over 3 mm is a `decoupling-far` issue (move it, with the pin), none is `decoupling-missing` (add a 100 nF at the pin). Drawn as arcs in the EMC view; `review.decoupling` holds them.+- **ESD.** Each connector pin on a power or signal net, protected by a diode to ground or not: `esd-unprotected` asks for a TVS within 3 mm if the connector faces the outside. Lightning tags in the EMC view; `review.esd` holds them.+- The EMC video re-recorded with both.
docs/videos/emc-poster.jpg
⋯ 1 unchanged line ⋯
docs/videos/emc.mp4
⋯ 1 unchanged line ⋯
package.json+5−2
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.6",+  "version": "0.7.7",   "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.",@@ -69,7 +69,10 @@     "ground offset",     "stitching vias",     "design review of a pcb",-    "fix plan for a board layout"+    "fix plan for a board layout",+    "decoupling capacitor placement",+    "esd protection check",+    "tvs diode"   ],   "sample_prompts": [     {
page.json+11−10
@@ -1,7 +1,7 @@ {   "slug": "adom-fields",   "type": "app",-  "version": "0.7.6",+  "version": "0.7.7",   "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",@@ -69,7 +67,10 @@     "ground offset",     "stitching vias",     "design review of a pcb",-    "fix plan for a board layout"+    "fix plan for a board layout",+    "decoupling capacitor placement",+    "esd protection check",+    "tvs diode"   ],   "sample_prompts": [     {@@ -110,4 +111,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
skills/adom-fields/SKILL.md+2−2
@@ -128,9 +128,9 @@ A screen, not a chamber: differential mode only; cables (common mode), which oft ## For the AI that called the solve: the review and the fix plan (0.7.6)  `analyze` reviews the design as well as solving it, and says what to change, without anyone looking at the app:-- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop`, `inductor-ripple`, `output-ripple`.+- **`fields.json` `issues`**: every finding with `severity`, `kind`, `what`, `at` (board mm), `net`, `reference`, a plain `fix`, and an **`action`** to carry out: `move` (`ref`, `toPins`, `targetMm2`), `add-vias` (`net`, `points` or `at`, `pitchMm`), `widen` (`net`, `at`), `pour` (`net`, `layer`, `under`, `at`), `add-copper`, `add-part`, `review`. Kinds: `current-neck`, `tab-rise`, `thermal-vias` (the solve's own), and from the review `hot-loop`, `emissions`, `no-reference`, `ground-offset`, `ground-island`, `stitching-gap`, `ground-loop-risk`, `ir-drop`, `inductor-ripple`, `output-ripple`, `decoupling-far`, `decoupling-missing` (each chip power pin's nearest small capacitor: over 3 mm moves it, none adds a 100 nF), `esd-unprotected` (a connector pin with no diode to ground: add a TVS within 3 mm if it faces the outside). - **`fields.json` `fixPlan`**: the actions, worst first and concrete edits before reviews, numbered for the caller to work down. `analyze` prints the first five as a `Hint: Fix plan` line and the review as a `Hint: Review` line.-- **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail).+- **`fields.json` `review`**: the numbers behind them: `converter` (duty, ripple, hot loops per input capacitor and the best possible, the emissions margin), `reference` (each net's copper with no ground under it), `ground` (offset, connectors to the outside, islands, the stitching fence and its gaps), `rails` (IR drop per rail), `decoupling` (pin, cap, distance, loop), `esd` (each connector pin and what protects it). - Budgets in the spec: `groundOffsetMv` (10), `dropPct` (1 % of the rail), `emcFmaxHz` (1 GHz, for the stitching pitch), `switching.<net>.riseNs` (8).  After applying a step, run `analyze` again: the issue goes away or says how far it still is. In the app, clicking an issue opens the view that shows it (EMC, Ground, River, Cycle).
src/emc.rs+38
@@ -159,6 +159,44 @@ pub fn review(f: &Fields, b: &Board, spec: &Spec, issues: &mut Vec<Issue>) -> Va                 json!({"type": "add-vias", "net": gname, "points": pts, "from": [r2(gp[0].0), r2(gp[0].1)], "to": [r2(gp[gp.len() - 1].0), r2(gp[gp.len() - 1].1)], "pitchMm": (pitch * 10.0).round() / 10.0, "count": pts.len().max(cnt), "insetMm": 0.6}) })); }         out["ground"] = gout;     }+    // ---- decoupling: every chip power pin and its nearest capacitor ----------------------------------------+    let mut dec = Vec::new();+    if let Some(gname) = gnd.clone() {+        for u in refs.iter().filter(|r| r.starts_with('U')) {+            let gpins = pads_of(u, &gname);+            for net in nets_of(u) {+                let Some(rv) = rail(&net) else { continue }; if rv <= 0.0 || is_ground(&net) || spec.switching.contains_key(&net) || is_switch_name(&net) { continue; }+                for pin in pads_of(u, &net) {+                    // the capacitors bridging this rail to ground, nearest first+                    let mut cands: Vec<(String, f64, f64, f64)> = refs.iter().filter(|r| r.starts_with('C') && nets_of(r).contains(&net) && nets_of(r).contains(&gname)).filter_map(|r| { let cv = pads_of(r, &net); let cg = pads_of(r, &gname); let (cv, cg) = (cv.first()?, cg.first()?); let c = b.values.get(r).and_then(|v| parse_value(v, 'F')).unwrap_or(f64::NAN); let gp = near(&gpins, cg.x, cg.y); let area = gp.map(|g| shoelace(&[(pin.x, pin.y), (cv.x, cv.y), (cg.x, cg.y), (g.x, g.y)])).unwrap_or(f64::NAN); Some((r.clone(), (cv.x - pin.x).hypot(cv.y - pin.y), area, c)) }).collect();+                    cands.sort_by(|a, z| a.1.partial_cmp(&z.1).unwrap());+                    let hf = cands.iter().filter(|c| c.3.is_finite() && c.3 <= 1.1e-6).min_by(|a, z| a.1.partial_cmp(&z.1).unwrap()).or(cands.first()).cloned();+                    let entry = json!({"chip": u, "pin": pin.pad, "net": net, "at": [r2(pin.x), r2(pin.y)], "cap": hf.as_ref().map(|c| c.0.clone()), "distMm": hf.as_ref().map(|c| r2(c.1)), "loopMm2": hf.as_ref().map(|c| r2(c.2)), "capAt": hf.as_ref().and_then(|c| pads_of(&c.0, &net).first().map(|p| [r2(p.x), r2(p.y)]))});+                    dec.push(entry);+                    match &hf {+                        None => issues.push(issue("medium", "decoupling-missing", format!("{} pin {} ({}): no capacitor from {} to {} anywhere on the board", u, pin.pad, net, net, gname), Some([pin.x, pin.y]), Some(&net), Some(u), format!("Add a 100 nF ceramic from {} to {} right at {} pin {}, its ground pad straight to the chip's ground pin.", net, gname, u, pin.pad), json!({"type": "add-part", "what": "capacitor", "value": "100nF", "between": [net, gname], "nearPin": format!("{}.{}", u, pin.pad), "maxDistMm": 1.0}))),+                        Some(c) if c.1 > 3.0 => issues.push(issue(if c.1 > 6.0 { "medium" } else { "low" }, "decoupling-far", format!("{} pin {} ({}): the nearest small capacitor, {}, is {:.1} mm away (loop {:.1} mm²)", u, pin.pad, net, c.0, c.1, c.2), Some([pin.x, pin.y]), Some(&net), Some(u), format!("Move {} to within 1 mm of {} pin {}, its ground pad straight to the chip's ground pin, or add a 100 nF there.", c.0, u, pin.pad), json!({"type": "move", "ref": c.0, "toPins": [format!("{}.{}", u, pin.pad)], "maxDistMm": 1.0, "currentDistMm": r2(c.1)}))),+                        _ => {}+                    }+                }+            }+        }+    }+    out["decoupling"] = json!(dec);+    // ---- ESD: each connector pin to the outside, protected or not ----------------------------------------+    let mut esd = Vec::new();+    if let Some(gname) = gnd.clone() {+        let protector = |net: &str| refs.iter().find(|r| (r.starts_with('D') || r.starts_with("TVS") || r.starts_with("ESD")) && nets_of(r).contains(&net.to_string()) && nets_of(r).contains(&gname)).cloned();+        for jref in refs.iter().filter(|r| r.starts_with('J') || (r.starts_with('P') && !r.starts_with("PS"))) {+            for p in b.pads.iter().filter(|p| p.reference == *jref && !p.net.is_empty() && !is_ground(&p.net)) {+                let prot = protector(&p.net);+                let sig = rail(&p.net).is_none();+                esd.push(json!({"connector": jref, "pin": p.pad, "net": p.net, "at": [r2(p.x), r2(p.y)], "protectedBy": prot, "signal": sig}));+                if prot.is_none() { issues.push(issue("low", "esd-unprotected", format!("{} pin {} ({}): no protection diode from {} to {}", jref, p.pad, p.net, p.net, gname), Some([p.x, p.y]), Some(&p.net), Some(jref), format!("If {} faces the outside world, add a TVS{} from {} to {} within 3 mm of the pin, before any chip.", jref, if sig { " (low capacitance, for a signal)" } else { " rated above the rail" }, p.net, gname), json!({"type": "add-part", "what": "TVS diode", "between": [p.net.clone(), gname.clone()], "nearPin": format!("{}.{}", jref, p.pad), "maxDistMm": 3.0, "signal": sig}))); }+            }+        }+    }+    out["esd"] = json!(esd);     // ---- the rails: IR drop against a budget ------------------------------------------------------------     let mut rails = Vec::new();     for (name, nf) in &f.flow {
src/ui/emc.js+10−1
@@ -93,11 +93,17 @@ export async function attach(ctx) {   refPlane.position.x = bx(meta.x0 + W * d / 2); refPlane.position.y = by(meta.y0 + H * d / 2);   const rm = new BAB.StandardMaterial('emc-ref-m', scene); rm.diffuseTexture = rt; rt.hasAlpha = true; rm.useAlphaFromDiffuseTexture = true; rm.emissiveColor = new BAB.Color3(1, 1, 1); rm.disableLighting = true; rm.backFaceCulling = false;   refPlane.material = rm; refPlane.parent = group; refPlane.renderingGroupId = 1; refPlane.isPickable = false;+  // decoupling arcs and ESD markers, from the solve's own review (fields.json review), so the app and the+  // AI caller see the same findings+  const rv = state.fields.review || {}, dec = rv.decoupling || [], esd = rv.esd || [];+  for (const dc of dec) { if (!dc.capAt) continue; const pts = []; for (let a = 0; a <= 16; a++) { const t = a / 16, x = dc.at[0] + (dc.capAt[0] - dc.at[0]) * t, y = dc.at[1] + (dc.capAt[1] - dc.at[1]) * t; pts.push({ x, y, z: 0.6 * Math.sin(Math.PI * t) }); } try { const far = dc.distMm > 3; const m = BAB.CreateGreasedLine('emc-dec-' + dc.chip + dc.pin, { points: pts.flatMap(p => [bx(p.x), by(p.y), p.z]) }, { color: far ? new BAB.Color3(1, 0.7, 0.25) : new BAB.Color3(0.35, 1, 0.6), width: 0.12, sizeAttenuation: true }, scene); m.parent = group; m.renderingGroupId = 2; m.isPickable = false; } catch (e) { void e; } }   // tags: the loop's area where it is   const host = document.getElementById('labels'), tags = [];   const tag = (html, p) => { const el = document.createElement('div'); el.className = 'lbl emc'; el.innerHTML = html; el.hidden = true; host.appendChild(el); tags.push({ el, p }); };   const cen = (pts) => ({ x: pts.reduce((s, p) => s + p.x, 0) / pts.length, y: pts.reduce((s, p) => s + p.y, 0) / pts.length });   if (main) tag(`<b>hot loop</b> ${main.ref}→${model.swRef} <span class="v">${main.mm2.toFixed(1)} mm²</span>`, cen(main.pts));+  for (const dc of dec) if (dc.cap) tag(`<b>${dc.chip}.${dc.pin}</b> ${dc.net} → ${dc.cap} <span class="${dc.distMm > 3 ? 'v' : 'g'}">${dc.distMm} mm</span>`, { x: dc.at[0], y: dc.at[1] - 1.2 });+  for (const e of esd) tag(e.protectedBy ? `<b>${e.connector}</b> ${e.net} <span class="g">✓ ${e.protectedBy}</span>` : `<b>${e.connector}</b> ${e.net} <span class="v">⚡ no TVS</span>`, { x: e.at[0], y: e.at[1] });   if (ideal) tag(`<b>best possible</b> <span class="g">${ideal.mm2.toFixed(1)} mm²</span>`, { x: cen(ideal.pts).x, y: cen(ideal.pts).y + 1.2 });   // ---- the panel --------------------------------------------------------------------------------------   const panel = document.createElement('div'); panel.className = 'cyclemeter emcpanel'; panel.hidden = true; document.querySelector('.stage').appendChild(panel);@@ -107,6 +113,8 @@ export async function attach(ctx) {       <div class="row"><span class="dot in"></span>Hot loop (${main.ref}, ${(main.C * 1e9 >= 1000 ? (main.C * 1e6).toFixed(1) + ' µF' : (main.C * 1e9).toFixed(0) + ' nF')})<i>${main.mm2.toFixed(1)} mm²</i></div>       <div class="row"><span class="dot" style="background:#5cf08f"></span>Best this switch allows<i>${ideal ? ideal.mm2.toFixed(1) : '?'} mm²</i></div>       ${spec ? `<div class="row"><span class="dot pk"></span>Worst margin to FCC B<i style="color:${spec.margin < 0 ? '#ff6b5e' : spec.margin < 6 ? '#ffc04d' : '#5cf08f'}">${spec.margin >= 0 ? '+' : ''}${spec.margin.toFixed(1)} dB at ${(spec.worst.f / 1e6).toFixed(0)} MHz</i></div><canvas id="emcChart" width="460" height="190"></canvas><div class="row" style="color:var(--text-2)">A ${ideal ? 'tighter loop' : 'smaller loop'} would gain about ${spec.gain.toFixed(0)} dB</div>` : ''}+      ${dec.length ? `<div class="row"><span class="dot" style="background:${dec.some(x => !x.cap || x.distMm > 3) ? '#ffc04d' : '#5cf08f'}"></span>Decoupling (${dec.length} power pin${dec.length > 1 ? 's' : ''})<i>nearest ${Math.max(...dec.map(x => x.distMm || 99)).toFixed(1)} mm</i></div>` : ''}+      ${esd.length ? `<div class="row"><span class="dot" style="background:${esd.every(x => x.protectedBy) ? '#5cf08f' : '#ff6b5e'}"></span>ESD-protected connector pins<i>${esd.filter(x => x.protectedBy).length} of ${esd.length}</i></div>` : ''}       ${refRows.length ? `<div class="row" style="color:var(--text-2);margin-top:6px">Copper with no ground under it</div>` + refRows.slice(0, 4).map(r => `<div class="row"><span class="dot" style="background:${r.pct > 20 ? '#ff6b5e' : '#5cf08f'}"></span>${r.name}${r.switching ? ' ~' : ''}<i>${r.pct.toFixed(0)} % · ${r.mm2.toFixed(1)} mm²</i></div>`).join('') : ''}     </div>`;     panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); });@@ -150,12 +158,13 @@ export async function attach(ctx) {       '<b>Green dashed</b> = the smallest loop this switch allows (a capacitor straight across its pins)',       '<b>Green / red copper</b> = power and switching copper with ground under it / with no ground under it (the return current detours)',       '<b>The chart</b> = the hot loop\'s radiated field at 3 m against the FCC class B limit',+      '<b>Arcs</b> = each chip power pin to its nearest small capacitor (green within 3 mm); <b>⚡ tags</b> = connector pins with no protection diode',       'A screen, not a chamber: differential mode only (Ott), no cables; the edge time is ' + (spec ? (spec.tr * 1e9).toFixed(0) : '8') + ' ns'];   }   function narration() {     if (!main) return 'The EMC screen needs a switching converter it can model: ' + why.join('; ') + '.';     const r0 = refRows.find(r => r.switching) || refRows[0];-    return `This is the EMC screen: where this board could radiate radio noise. The red patch is the hot loop: every switching cycle, a sharp pulse of current runs from the input capacitor ${main.ref} into ${model.swRef} and back through ground. The area of that loop acts like an antenna. Here it is about ${main.mm2.toFixed(0)} square millimetres. The green dashed outline is the smallest loop this switch allows, about ${ideal ? ideal.mm2.toFixed(1) : 'a few'} square millimetres, with a capacitor placed straight across its pins.${spec ? ` The chart estimates what the loop radiates at three metres, against the FCC class B limit. The closest it comes is ${Math.abs(spec.margin).toFixed(0)} decibels ${spec.margin >= 0 ? 'under' : 'over'} the limit, near ${(spec.worst.f / 1e6).toFixed(0)} megahertz, and a tighter loop would gain about ${spec.gain.toFixed(0)} decibels.` : ''}${r0 ? ` The copper colours show the return path: green has solid ground underneath, red does not. ${r0.name} has ${r0.pct.toFixed(0)} percent of its copper with no ground beneath it.` : ''} This is a screen, not a test chamber: it leaves out cables, which often dominate.`;+    return `This is the EMC screen: where this board could radiate radio noise. The red patch is the hot loop: every switching cycle, a sharp pulse of current runs from the input capacitor ${main.ref} into ${model.swRef} and back through ground. The area of that loop acts like an antenna. Here it is about ${main.mm2.toFixed(0)} square millimetres. The green dashed outline is the smallest loop this switch allows, about ${ideal ? ideal.mm2.toFixed(1) : 'a few'} square millimetres, with a capacitor placed straight across its pins.${spec ? ` The chart estimates what the loop radiates at three metres, against the FCC class B limit. The closest it comes is ${Math.abs(spec.margin).toFixed(0)} decibels ${spec.margin >= 0 ? 'under' : 'over'} the limit, near ${(spec.worst.f / 1e6).toFixed(0)} megahertz, and a tighter loop would gain about ${spec.gain.toFixed(0)} decibels.` : ''}${r0 ? ` The copper colours show the return path: green has solid ground underneath, red does not. ${r0.name} has ${r0.pct.toFixed(0)} percent of its copper with no ground beneath it.` : ''} ${dec.length ? ` The arcs join each chip's power pin to its nearest decoupling capacitor${dec.every(x => x.cap && x.distMm <= 3) ? ', and all are within three millimetres' : ''}.` : ''}${esd.length ? ` ${esd.filter(x => !x.protectedBy).length} of the ${esd.length} connector pins have no protection diode against static discharge, marked with a lightning bolt; that matters if they face the outside world.` : ''} This is a screen, not a test chamber: it leaves out cables, which often dominate. Every finding here is also in the solve's fix plan, for the AI to act on.`;   }   console.log(main ? `emc: hot loop ${main.ref}→${model.swRef} ${main.mm2.toFixed(1)} mm² (best ${ideal ? ideal.mm2.toFixed(1) : '?'} mm²), loops ${loops.map(l => l.ref + ' ' + l.mm2.toFixed(1)).join(', ')}; ${spec ? `worst margin ${spec.margin.toFixed(1)} dB at ${(spec.worst.f / 1e6).toFixed(0)} MHz, tr ${(spec.tr * 1e9).toFixed(0)} ns, tighter loop +${spec.gain.toFixed(0)} dB; ` : ''}reference: ${refRows.map(r => r.name + ' ' + r.pct.toFixed(0) + '%').join(', ')}` : 'emc: not screened: ' + why.join('; '));   return { apply, describe, narration, stats: () => ({ loops, ideal, spec, refRows }) };