app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
main
John Lauer
adom-fields 0.7.7: decoupling and ESD checks, each with an action, in the review and the EMC view
f5df515
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
// Adom Fields: the EMC screen of a switching converter.
// Three checks, each drawn on the board:
// 1. The hot loop: input capacitor -> the switch's input pin -> its ground pin -> back to the capacitor.
// Its area is what radiates; drawn as a pulsing patch, with the smallest loop this placement could reach.
// 2. The reference plane: power and switching copper over solid ground (green) or over a gap (red), where
// the return current has to detour.
// 3. Radiated emissions: the hot loop as a small loop antenna (Ott: E = 1.316e-14 f² A I / r, V/m), its
// current's harmonics from the trapezoid (duty, edge time), at 3 m against the FCC Part 15 class B limit.
// A screen, not a chamber: differential mode only, no cable (common mode), the loop in the board's plane.
export async function attach(ctx) {
const { BAB, scene, V, camera, state, LAYERS, frameDt, root, flow, cycle } = ctx;
if (!flow || !flow.data) return null;
const { lay, real, W, H, d, meta, nets, zOf, bx, by } = flow.data;
const model = cycle && cycle.model ? cycle.model() : null;
const pads = state.fields.pads || [];
const why = [];
if (!model) why.push('no switching converter modelled (see the Cycle view)');
// ---- 1. the hot loops -------------------------------------------------------------------------------
const padOf = (ref, net) => pads.filter(p => p.ref === ref && p.net === net);
const near = (list, to) => list.slice().sort((a, b) => Math.hypot(a.x - to.x, a.y - to.y) - Math.hypot(b.x - to.x, b.y - to.y))[0];
const area = (pts) => { let a = 0; for (let i = 0; i < pts.length; i++) { const j = (i + 1) % pts.length; a += pts[i].x * pts[j].y - pts[j].x * pts[i].y; } return Math.abs(a) / 2; };
let loops = [], ideal = null;
if (model) {
const vinPins = padOf(model.swRef, model.inNet), gndPins = padOf(model.swRef, model.gnd);
if (!vinPins.length || !gndPins.length) why.push(`${model.swRef} has no pad on ${model.inNet} or ${model.gnd}`);
else {
for (const c of model.cin) {
const cv = padOf(c.ref, model.inNet)[0], cg = padOf(c.ref, model.gnd)[0]; if (!cv || !cg) continue;
const uv = near(vinPins, cv), ug = near(gndPins, cg);
const pts = [cv, uv, ug, cg].map(p => ({ x: p.x, y: p.y }));
loops.push({ ref: c.ref, C: c.C, pts, mm2: area(pts) });
}
loops.sort((a, b) => a.mm2 - b.mm2);
// the best this switch allows: a capacitor straight across its own input and ground pins
const uv = vinPins[0], ug = near(gndPins, uv), span = Math.hypot(uv.x - ug.x, uv.y - ug.y), w = 0.8;
const nx = -(ug.y - uv.y) / (span || 1), ny = (ug.x - uv.x) / (span || 1);
ideal = { pts: [{ x: uv.x, y: uv.y }, { x: ug.x, y: ug.y }, { x: ug.x + nx * w, y: ug.y + ny * w }, { x: uv.x + nx * w, y: uv.y + ny * w }], mm2: span * w };
}
}
const hf = loops.length ? loops.slice().sort((a, b) => a.C - b.C)[0] : null; // the smallest capacitor carries the fastest edges
const main = hf || loops[0] || null;
// ---- 2. the reference plane under power and switching copper ------------------------------------------
const k0 = real[0], k1 = real.length > 1 ? real[1] : -1;
const gid = nets.findIndex(n => n.ground);
const refStats = {};
const rtex = new Uint8Array(W * H * 4);
if (k1 >= 0 && gid >= 0) {
for (let i = 0; i < W * H; i++) {
const id = lay[k0].net[i]; if (id < 0 || id === gid) continue; const n = nets[id]; if (!n || !(n.loaded || n.switching)) continue;
const ok = lay[k1].net[i] === gid;
const st = refStats[n.name] = refStats[n.name] || { on: 0, off: 0 }; ok ? st.on++ : st.off++;
const o = i * 4; if (ok) { rtex[o] = 60; rtex[o + 1] = 200; rtex[o + 2] = 120; rtex[o + 3] = 70; } else { rtex[o] = 255; rtex[o + 1] = 70; rtex[o + 2] = 60; rtex[o + 3] = 200; }
}
}
const refRows = Object.entries(refStats).map(([name, s]) => ({ name, pct: s.off / (s.on + s.off) * 100, mm2: s.off * d * d, switching: !!(nets.find(n => n.name === name) || {}).switching })).sort((a, b) => b.pct - a.pct);
// ---- 3. radiated emissions of the hot loop ------------------------------------------------------------
const R = 3, FCC = (f) => f < 88e6 ? 40 : f < 216e6 ? 43.5 : f < 960e6 ? 46 : 54; // dBµV/m at 3 m, class B
let spec = null;
if (model && main) {
const sw = nets.find(n => n.switching) || {}, tr = ((sw.switching && sw.switching.riseNs) || 8) * 1e-9, T = model.T, D = model.D;
const I = model.Iout + model.dI / 2, A = main.mm2 * 1e-6;
const sinc = (x) => x === 0 ? 1 : Math.sin(x) / x;
const bins = []; const f0 = 30e6, f1 = 1e9, nb = 64;
for (let b = 0; b < nb; b++) { const fa = f0 * Math.pow(f1 / f0, b / nb), fb = f0 * Math.pow(f1 / f0, (b + 1) / nb); let mx = -999; for (let n = Math.max(1, Math.ceil(fa * T)); n * (1 / T) < fb; n += Math.max(1, Math.floor((fb - fa) * T / 40))) { const f = n / T, In = 2 * I * D * Math.abs(sinc(Math.PI * n * D)) * Math.abs(sinc(Math.PI * n * tr / T)); const E = 1.316e-14 * f * f * A * In / R; mx = Math.max(mx, 20 * Math.log10(E * 1e6 + 1e-30)); } bins.push({ f: Math.sqrt(fa * fb), db: mx, lim: FCC(Math.sqrt(fa * fb)) }); }
const worst = bins.reduce((w, b) => (b.lim - b.db) < (w.lim - w.db) ? b : w, bins[0]);
// the same board with the ideal loop, for the gain a tighter layout buys
const gain = ideal && main.mm2 > 0 ? 20 * Math.log10(main.mm2 / ideal.mm2) : 0;
spec = { bins, worst, margin: worst.lim - worst.db, tr, I, gain };
}
// ---- drawing: the loops, the reference overlay -----------------------------------------------------
const z0 = () => zOf(k0) + 0.08;
const group = new BAB.TransformNode('fields-emc', scene); group.parent = root; group.setEnabled(false);
const meshes = [];
function patch(pts, rgb, alpha, name) {
const m = new BAB.Mesh(name, scene); const vd = new BAB.VertexData();
vd.positions = pts.flatMap(p => [bx(p.x), by(p.y), 0]); vd.indices = pts.length === 4 ? [0, 1, 2, 0, 2, 3] : [0, 1, 2]; vd.applyToMesh(m, false);
const mt = new BAB.StandardMaterial(name + '-m', scene); mt.emissiveColor = new BAB.Color3(...rgb); mt.disableLighting = true; mt.alpha = alpha; mt.backFaceCulling = false;
m.material = mt; m.parent = group; m.renderingGroupId = 2; m.isPickable = false; meshes.push({ m, mt, base: alpha }); return m;
}
function outline(pts, rgb, width, name, dash) {
try {
const flat = [...pts, pts[0]].flatMap(p => [bx(p.x), by(p.y), 0]);
const m = BAB.CreateGreasedLine(name, { points: flat }, { color: new BAB.Color3(...rgb), width, useDash: !!dash, dashCount: 24, dashRatio: 0.55, sizeAttenuation: true }, scene);
m.parent = group; m.renderingGroupId = 2; m.isPickable = false; return m;
} catch (e) { return null; }
}
let mainPatch = null, mainLine = null;
for (const l of loops) { const isMain = l === main; const p = patch(l.pts, isMain ? [1, 0.25, 0.2] : [1, 0.55, 0.3], isMain ? 0.5 : 0.1, 'emc-loop-' + l.ref); const o = outline(l.pts, isMain ? [1, 0.45, 0.3] : [1, 0.7, 0.45], isMain ? 0.24 : 0.08, 'emc-loop-line-' + l.ref, !isMain); if (isMain) { mainPatch = p; mainLine = o; } }
if (ideal) { patch(ideal.pts, [0.3, 1, 0.55], 0.25, 'emc-ideal'); outline(ideal.pts, [0.4, 1, 0.6], 0.14, 'emc-ideal-line', true); }
const rt = new BAB.RawTexture(rtex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);
const refPlane = BAB.MeshBuilder.CreatePlane('emc-ref', { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
{ const uvs = refPlane.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; refPlane.setVerticesData(BAB.VertexBuffer.UVKind, uvs); }
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);
if (why.length && !main) panel.innerHTML = `<div class="ch">EMC screen</div><div class="body"><div class="row">Not screened: ${why.join('; ')}.</div></div>`;
else {
panel.innerHTML = `<div class="ch" title="Click to fold">EMC screen</div><div class="body">
<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'); });
}
function chart() {
const cv = panel.querySelector('#emcChart'); if (!cv || !spec) return; const g = cv.getContext('2d'), Wc = cv.width, Hc = cv.height, hi = 70, lo = Math.min(-20, Math.floor(Math.min(...spec.bins.map(b => b.db)) / 20) * 20);
g.clearRect(0, 0, Wc, Hc); g.fillStyle = 'rgba(13,17,23,.6)'; g.fillRect(0, 0, Wc, Hc);
const X = (f) => 34 + (Math.log(f / 30e6) / Math.log(1e9 / 30e6)) * (Wc - 42), Y = (db) => Hc - 22 - (db - lo) / (hi - lo) * (Hc - 34);
g.strokeStyle = 'rgba(255,255,255,.08)'; g.lineWidth = 1; const ticks = []; for (let db = lo; db <= hi; db += 20) ticks.push(db); for (const db of ticks) { g.beginPath(); g.moveTo(34, Y(db)); g.lineTo(Wc - 8, Y(db)); g.stroke(); }
g.fillStyle = '#8b949e'; g.font = '17px JetBrains Mono, monospace'; for (const db of ticks) g.fillText(db, 2, Y(db) + 5); for (const f of [30e6, 100e6, 300e6, 1e9]) g.fillText(f >= 1e9 ? '1G' : (f / 1e6) + 'M', X(f) - 12, Hc - 4);
g.strokeStyle = '#ffc04d'; g.lineWidth = 2.5; g.beginPath(); spec.bins.forEach((b, i) => { const x = X(b.f), y = Y(b.lim); i ? g.lineTo(x, y) : g.moveTo(x, y); }); g.stroke();
g.fillStyle = 'rgba(255,90,200,.8)'; for (const b of spec.bins) { const x = X(Math.max(30e6, b.f)), y = Y(Math.max(lo, b.db)); g.fillRect(x - 2.5, y, 5, Y(lo) - y); }
g.fillStyle = '#ffc04d'; g.fillText('FCC B, 3 m', Wc - 120, 18); g.fillStyle = '#ff7ae0'; g.fillText('hot loop, dBµV/m', 40, 18);
}
// ---- the frame and the view -------------------------------------------------------------------------
const knob = { on: 0, to: 0, t: 0 };
scene.onBeforeRenderObservable.add(() => {
const dt = frameDt();
knob.on += (knob.to - knob.on) * (1 - Math.exp(-dt * 2.5)); if (Math.abs(knob.to - knob.on) < 1e-3) knob.on = knob.to;
const live = knob.on > 0.01; group.setEnabled(live); group.position.z = z0();
if (!live) { for (const t of tags) t.el.hidden = true; return; }
knob.t += dt * (+state.view.speed || 1);
// the loop pulses with the switching: on for the duty, off for the rest, slowed to a beat the eye follows
const ph = (knob.t / 1.6) % 1, on = model && ph < model.D;
for (const s of meshes) s.mt.alpha = knob.on * s.base * (s.m === mainPatch ? (on ? 1.6 : 0.6) : 1);
if (mainLine && mainLine.greasedLineMaterial) mainLine.greasedLineMaterial.color = on ? new BAB.Color3(1, 0.55, 0.35) : new BAB.Color3(0.6, 0.25, 0.2);
rm.alpha = knob.on * 0.9;
const e = V.getEngine(), w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = group.getWorldMatrix();
for (const t of tags) { const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(new BAB.Vector3(bx(t.p.x), by(t.p.y), 1.2), wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); t.el.hidden = p.z > 1 || p.z < 0; t.el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; t.el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px'; }
});
function apply(view) {
knob.to = view.quantity === 'emc' ? 1 : 0; panel.hidden = view.quantity !== 'emc';
if (view.quantity !== 'emc') return;
chart();
for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.45 : 0; }
const leg = document.getElementById('legend'); if (leg) leg.hidden = true;
}
function describe() {
return ['<b>EMC screen</b>: where this board could radiate',
'<b>Red patch</b> = the hot loop: input capacitor → ' + (model ? model.swRef : 'switch') + ' → ground → back; its area is what radiates, pulsing with the switching',
'<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.` : ''} ${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 }) };
}