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.15: oscillators count five harmonics and their own circuit is not a crosstalk victim, ESD arrays by par…
2c6b424
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
// Adom Fields: power delivery. Each rail's impedance seen from its load pin, from the solve's own review
// (fields.json review.pdn: the branches, |Z(f)|, the target, the fixes), drawn on the board while the
// frequency sweeps from 1 kHz to 1 GHz:
// - every branch (the regulator or the cable, each capacitor, the plane) stands a column at its place, its
// height its share of the load's current at this frequency: the bulk capacitors carry the low end, the
// small ceramics the high end, the source only the slow part. At an anti-resonance two branches swap
// current with each other and their columns overshoot 100 %: the rail rings;
// - arcs carry each branch's share to the load pin;
// - at the load pin, the rail's wobble for the step current stands inside a glass sleeve, the tolerance:
// under the rim it is green, over it red. That is the impedance against its target;
// - the rail's copper glows the same colour.
// The panel draws |Z(f)| per rail on log axes with its target and band, the fix curve dashed, and a slider.
export async function attach(ctx) {
const { BAB, scene, V, camera, state, LAYERS, frameDt, root, flow } = ctx;
if (!flow || !flow.data) return null;
const { lay, real, W, H, d, meta, nets, zOf, bx, by } = flow.data;
const pd = (state.fields.review || {}).pdn || { rails: [], fHz: [] };
const rails = (pd.rails || []).filter(r => r.branches && r.branches.length);
const fHz = pd.fHz || [];
const group = new BAB.TransformNode('fields-pdn', scene); group.parent = root; group.setEnabled(false);
const k0 = real[0];
// ---- the model, the same as the solve: R + jωL + (jωLp ∥ Rp) + 1/(jωC) per branch, all in parallel ----
const cx = { add: (a, b) => [a[0] + b[0], a[1] + b[1]], mul: (a, b) => [a[0] * b[0] - a[1] * b[1], a[0] * b[1] + a[1] * b[0]], inv: (a) => { const q = a[0] * a[0] + a[1] * a[1]; return [a[0] / q, -a[1] / q]; }, abs: (a) => Math.hypot(a[0], a[1]) };
function zb(b, f) { const w = 2 * Math.PI * f; let z = [b.r, w * b.l]; if (b.lp > 0) { const zl = [0, w * b.lp]; z = cx.add(z, b.rp > 0 ? cx.mul(cx.mul(zl, [b.rp, 0]), cx.inv(cx.add(zl, [b.rp, 0]))) : zl); } if (b.c > 0) z = cx.add(z, [0, -1 / (w * b.c)]); return z; }
function solve(bs, f) { const zs = bs.map(b => zb(b, f)); const y = zs.reduce((a, z) => cx.add(a, cx.inv(z)), [0, 0]); const zt = cx.inv(y); return { z: cx.abs(zt), share: zs.map(z => cx.abs(cx.mul(zt, cx.inv(z)))) }; }
// ---- one set of meshes per rail ------------------------------------------------------------------------
const colOf = (kind) => kind === 'regulator' || kind === 'cable' || kind === 'filter' ? [0.35, 0.6, 1] : kind === 'plane' ? [0.75, 0.45, 1] : [0.3, 1, 0.75];
const host = document.getElementById('labels');
const mkTag = (cls) => { const el = document.createElement('div'); el.className = 'lbl pdn ' + (cls || ''); el.hidden = true; host.appendChild(el); return el; };
const idOf = (name) => nets.findIndex(n => n.name === name);
const R = rails.map((r, ri) => {
const g = new BAB.TransformNode('pdn-rail-' + r.net, scene); g.parent = group; g.setEnabled(false);
const [lx, ly] = r.loadAt;
const cols = r.branches.concat(r.added || []).map((b, bi) => {
const added = bi >= r.branches.length;
const [x, y] = b.at || r.loadAt;
// a branch sitting on the load pin (the plane, a new capacitor) steps aside so it does not hide it
const off = Math.hypot(x - lx, y - ly) < 0.6 ? (b.kind === 'plane' ? [1.4, -1.1] : [-1.4, -1.1 - 0.9 * bi % 3]) : [0, 0];
const px = x + off[0], py = y + off[1];
const c = BAB.MeshBuilder.CreateCylinder('pdn-col-' + r.net + bi, { height: 1, diameter: b.kind === 'plane' ? 0.9 : 0.7, tessellation: 18 }, scene);
c.rotation.x = Math.PI / 2; c.position.x = bx(px); c.position.y = by(py);
const m = new BAB.StandardMaterial('pdn-col-m' + r.net + bi, scene); m.emissiveColor = new BAB.Color3(...colOf(b.kind)); m.disableLighting = true; m.alpha = added ? 0.45 : 0.85;
c.material = m; c.parent = g; c.renderingGroupId = 2; c.isPickable = false;
// the arc from the branch to the load pin
const pts = []; const span = Math.hypot(px - lx, py - ly);
for (let a = 0; a <= 20; a++) { const t = a / 20; pts.push(bx(px + (lx - px) * t), by(py + (ly - py) * t), 0.2 + Math.min(3, 0.35 * span) * Math.sin(Math.PI * t)); }
let arc = null;
if (span > 0.3) try { arc = BAB.CreateGreasedLine('pdn-arc-' + r.net + bi, { points: pts }, { color: new BAB.Color3(...colOf(b.kind)), width: 0.16, useDash: true, dashCount: 10, dashRatio: 0.45, sizeAttenuation: true }, scene); arc.parent = g; arc.renderingGroupId = 2; arc.isPickable = false; } catch (e) { void e; }
const tag = mkTag(); const nm = b.kind === 'capacitor' ? `${added ? '<i>+</i> ' : ''}${b.name.replace(/^new /, '')}` : b.kind === 'plane' ? 'plane' : b.name;
const val = b.kind === 'capacitor' ? (b.info.value || '').split(' ')[0] : b.kind === 'plane' ? (b.info.pF + ' pF') : '';
return { b, c, m, arc, tag, px, py, nm, val, added, h: 0 };
});
// the load pin: the wobble column in its tolerance sleeve
const sleeveH = 3.2;
const sleeve = BAB.MeshBuilder.CreateCylinder('pdn-sleeve-' + r.net, { height: sleeveH, diameter: 1.6, tessellation: 32, cap: BAB.Mesh.NO_CAP, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
sleeve.rotation.x = Math.PI / 2; sleeve.position.x = bx(lx); sleeve.position.y = by(ly); sleeve.position.z = sleeveH / 2;
const sm = new BAB.StandardMaterial('pdn-sleeve-m' + r.net, scene); sm.emissiveColor = new BAB.Color3(0.85, 0.9, 1); sm.disableLighting = true; sm.alpha = 0.16; sm.backFaceCulling = false; sleeve.material = sm; sleeve.parent = g; sleeve.renderingGroupId = 2; sleeve.isPickable = false;
const rim = BAB.MeshBuilder.CreateTorus('pdn-rim-' + r.net, { diameter: 1.6, thickness: 0.07, tessellation: 40 }, scene); rim.rotation.x = Math.PI / 2; rim.position.x = bx(lx); rim.position.y = by(ly); rim.position.z = sleeveH;
const rmm = new BAB.StandardMaterial('pdn-rim-m' + r.net, scene); rmm.emissiveColor = new BAB.Color3(1, 1, 1); rmm.disableLighting = true; rim.material = rmm; rim.parent = g; rim.renderingGroupId = 2; rim.isPickable = false;
const lc = BAB.MeshBuilder.CreateCylinder('pdn-load-' + r.net, { height: 1, diameter: 1.1, tessellation: 28 }, scene); lc.rotation.x = Math.PI / 2; lc.position.x = bx(lx); lc.position.y = by(ly);
const lm = new BAB.StandardMaterial('pdn-load-m' + r.net, scene); lm.disableLighting = true; lm.alpha = 0.92; lc.material = lm; lc.parent = g; lc.renderingGroupId = 2; lc.isPickable = false;
const loadTag = mkTag('big');
// the rail's copper, tinted by how it stands against the target
const id = idOf(r.net), tex = new Uint8Array(W * H * 4);
for (const k of real) for (let i = 0; i < W * H; i++) if (lay[k].net[i] === id) { tex[i * 4] = tex[i * 4 + 1] = tex[i * 4 + 2] = 255; tex[i * 4 + 3] = 255; }
const rt = new BAB.RawTexture(tex, W, H, BAB.Constants.TEXTUREFORMAT_RGBA, scene, false, false, BAB.Texture.NEAREST_SAMPLINGMODE, BAB.Constants.TEXTURETYPE_UNSIGNED_BYTE);
const p = BAB.MeshBuilder.CreatePlane('pdn-cu-' + r.net, { width: W * d, height: H * d, sideOrientation: BAB.Mesh.DOUBLESIDE }, scene);
{ const uvs = p.getVerticesData(BAB.VertexBuffer.UVKind); for (let i = 1; i < uvs.length; i += 2) uvs[i] = 1 - uvs[i]; p.setVerticesData(BAB.VertexBuffer.UVKind, uvs); }
p.position.x = bx(meta.x0 + W * d / 2); p.position.y = by(meta.y0 + H * d / 2);
const pm = new BAB.StandardMaterial('pdn-cu-m' + r.net, scene); pm.diffuseTexture = rt; rt.hasAlpha = true; pm.useAlphaFromDiffuseTexture = true; pm.disableLighting = true; pm.backFaceCulling = false; pm.diffuseColor = new BAB.Color3(0, 0, 0);
p.material = pm; p.parent = g; p.renderingGroupId = 1; p.isPickable = false;
const hue = ['#5cc8ff', '#ffb84d', '#c38bff', '#7dff9a'][ri % 4];
return { r, g, cols, sleeveH, lc, lm, loadTag, pm, hue, all: r.branches.concat(r.added || []) };
});
// ---- the panel ----------------------------------------------------------------------------------------
const fmtHz = (f) => f >= 1e9 ? (f / 1e9).toFixed(2) + ' GHz' : f >= 1e6 ? (f / 1e6).toFixed(f >= 1e8 ? 0 : 1) + ' MHz' : (f / 1e3).toFixed(f >= 1e5 ? 0 : 1) + ' kHz';
const fmtO = (z) => z >= 1 ? z.toFixed(2) + ' Ω' : (z * 1e3).toFixed(z >= 0.01 ? 0 : 1) + ' mΩ';
const panel = document.createElement('div'); panel.className = 'cyclemeter pdnpanel'; panel.hidden = true; document.querySelector('.stage').appendChild(panel);
const sel = { i: rails.length ? rails.reduce((b, r, i) => r.worst.ratio > rails[b].worst.ratio ? i : b, 0) : 0, sweep: true, lf: 3, picked: false };
if (!rails.length) panel.innerHTML = `<div class="ch">Power delivery</div><div class="body"><div class="row">No rail with a load pin and capacitors to check.</div></div>`;
else {
panel.innerHTML = `<div class="ch" title="Click to fold">Power delivery</div><div class="body">
${rails.map((r, i) => `<div class="row rail" data-i="${i}" style="cursor:pointer"><span class="dot" style="background:${R[i].hue}"></span>${r.net} from ${r.load}<i style="color:${r.worst.ratio > 1 ? '#ff6b5e' : '#5cf08f'}">${r.worst.ratio.toFixed(2)}× target</i></div>`).join('')}
${(pd.rails || []).filter(r => r.skipped).map(r => `<div class="row" title="${String(r.skipped).replace(/"/g, '"')}" style="color:var(--text-2)"><span class="dot" style="background:#6e7681"></span>${r.net}<i>not checked: no known source</i></div>`).join('')}
<canvas id="pdnChart" width="460" height="210"></canvas>
<div class="row"><button id="pdnPlay" class="pdnbtn" title="Sweep the frequency">❚❚</button><input id="pdnF" type="range" min="3" max="9" step="0.005" value="3" style="flex:1"><i id="pdnFv">1 kHz</i></div>
<div id="pdnNow" style="color:var(--text-2);margin-top:5px;font-family:'JetBrains Mono';font-size:11px;line-height:1.45"></div>
<div class="row" style="color:var(--text-2)">${rails.every(r => r.spice && r.spice.ran) ? `ngspice agrees within ${Math.max(...rails.map(r => r.spice.maxErrPct)).toFixed(2)} %` : 'ngspice cross-check not run'}</div>
</div>`;
panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); const row = e.target.closest('.rail'); if (row) { sel.i = +row.dataset.i; sel.picked = true; } });
panel.querySelector('#pdnPlay').addEventListener('click', () => { sel.sweep = !sel.sweep; panel.querySelector('#pdnPlay').textContent = sel.sweep ? '❚❚' : '▶'; });
panel.querySelector('#pdnF').addEventListener('input', (e) => { sel.lf = +e.target.value; sel.sweep = false; panel.querySelector('#pdnPlay').textContent = '▶'; });
}
function chart(f) {
const cv = panel.querySelector('#pdnChart'); if (!cv) return; const g = cv.getContext('2d'), w = cv.width, h = cv.height, L = 40, B = 22, T = 8, Rr = 8;
g.clearRect(0, 0, w, h);
const zs = rails.flatMap(r => r.zOhm.concat([r.targetOhm])); const zlo = Math.pow(10, Math.floor(Math.log10(Math.max(1e-4, Math.min(...zs))))), zhi = Math.pow(10, Math.ceil(Math.log10(Math.max(...zs))));
const X = (fq) => L + (Math.log10(fq) - 3) / 6 * (w - L - Rr), Y = (z) => T + (1 - (Math.log10(z) - Math.log10(zlo)) / (Math.log10(zhi) - Math.log10(zlo))) * (h - T - B);
g.font = '10px JetBrains Mono'; g.fillStyle = '#8b949e'; g.strokeStyle = 'rgba(255,255,255,.08)'; g.lineWidth = 1;
for (let e = 3; e <= 9; e++) { g.beginPath(); g.moveTo(X(10 ** e), T); g.lineTo(X(10 ** e), h - B); g.stroke(); g.fillText(['1k', '10k', '100k', '1M', '10M', '100M', '1G'][e - 3], X(10 ** e) - 8, h - 7); }
for (let e = Math.log10(zlo); e <= Math.log10(zhi) + 1e-9; e++) { const z = 10 ** e; g.beginPath(); g.moveTo(L, Y(z)); g.lineTo(w - Rr, Y(z)); g.stroke(); g.fillText(z >= 1 ? z + 'Ω' : (z * 1e3) + 'm', 2, Y(z) + 3); }
rails.forEach((r, i) => {
const on = i === sel.i, col = R[i].hue;
// the band where the target holds, and the target
if (on) { g.fillStyle = 'rgba(255,255,255,.04)'; g.fillRect(X(r.bandHz[0]), T, X(r.bandHz[1]) - X(r.bandHz[0]), h - T - B); }
g.strokeStyle = col; g.globalAlpha = on ? 0.9 : 0.35; g.setLineDash([5, 4]); g.beginPath(); g.moveTo(X(r.bandHz[0]), Y(r.targetOhm)); g.lineTo(X(r.bandHz[1]), Y(r.targetOhm)); g.stroke(); g.setLineDash([]);
g.lineWidth = on ? 2.2 : 1.2; g.beginPath(); fHz.forEach((fq, k) => { const p = [X(fq), Y(Math.max(zlo, r.zOhm[k]))]; k ? g.lineTo(...p) : g.moveTo(...p); }); g.stroke();
if (on && r.zAfterOhm) { g.setLineDash([2, 3]); g.strokeStyle = '#5cf08f'; g.beginPath(); fHz.forEach((fq, k) => { const p = [X(fq), Y(Math.max(zlo, r.zAfterOhm[k]))]; k ? g.lineTo(...p) : g.moveTo(...p); }); g.stroke(); g.setLineDash([]); }
g.lineWidth = 1; g.globalAlpha = 1;
});
// the cursor
const r = rails[sel.i], z = solve(R[sel.i].r.branches, f).z;
g.strokeStyle = '#fff'; g.beginPath(); g.moveTo(X(f), T); g.lineTo(X(f), h - B); g.stroke();
g.fillStyle = z > r.targetOhm ? '#ff6b5e' : '#5cf08f'; g.beginPath(); g.arc(X(f), Y(Math.max(zlo, z)), 4, 0, 7); g.fill();
}
// ---- frame ------------------------------------------------------------------------------------------
const knob = { on: 0, to: 0, t: 0 };
let lastChart = 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 && rails.length; group.setEnabled(!!live); group.position.z = zOf(k0) + 0.1;
if (!live) { for (const x of R) { x.loadTag.hidden = true; for (const c of x.cols) c.tag.hidden = true; } return; }
const sp = +state.view.speed || 1; knob.t += dt * sp;
// one decade every 2.5 s, rail after rail unless one was picked
if (sel.sweep) { sel.lf += dt * sp / 2.5; if (sel.lf > 9) { sel.lf = 3; if (!sel.picked && rails.length > 1) sel.i = (sel.i + 1) % rails.length; } const s = panel.querySelector('#pdnF'); if (s) s.value = sel.lf; }
const f = Math.pow(10, sel.lf);
const e = V.getEngine(), w = e.getRenderWidth(), h = e.getRenderHeight(), hostEl = document.getElementById('host'), wm = group.getWorldMatrix();
const place = (el, x, y, z) => { const p = BAB.Vector3.Project(BAB.Vector3.TransformCoordinates(new BAB.Vector3(bx(x), by(y), z), wm), BAB.Matrix.Identity(), scene.getTransformMatrix(), camera.viewport.toGlobal(w, h)); el.hidden = p.z > 1 || p.z < 0; el.style.left = (p.x / (w / hostEl.clientWidth)) + 'px'; el.style.top = (p.y / (h / hostEl.clientHeight)) + 'px'; };
R.forEach((x, i) => {
const on = i === sel.i; x.g.setEnabled(on);
if (!on) { x.loadTag.hidden = true; for (const c of x.cols) c.tag.hidden = true; return; }
// with the fixes added only when they exist; the columns of new parts stay ghosted
const s = solve(x.all, f), base = solve(x.r.branches, f), ratio = base.z / x.r.targetOhm;
x.cols.forEach((c, bi) => {
const share = c.added ? 0 : base.share[bi]; const hh = Math.min(9, 0.15 + 3.2 * share);
c.h += (hh - c.h) * (1 - Math.exp(-dt * 10));
c.c.scaling.y = c.h; c.c.position.z = c.h / 2; c.m.alpha = c.added ? 0.25 : (0.35 + 0.6 * Math.min(1, share)) * knob.on;
if (c.arc) { c.arc.visibility = c.added ? 0 : Math.min(1, 0.1 + share) * knob.on; if (c.arc.greasedLineMaterial) c.arc.greasedLineMaterial.dashOffset = -knob.t * (0.2 + 1.6 * Math.min(1.5, share)); }
c.tag.innerHTML = `<b>${c.nm}</b> ${c.val} <span class="${share > 1.05 ? 'v' : 'g'}">${c.added ? 'fix' : (share * 100).toFixed(0) + ' %'}</span>`;
place(c.tag, c.px, c.py, c.h + 0.5); if (!c.added && share < 0.03) c.tag.hidden = true;
});
// the wobble in its sleeve: under the rim green, over it red, bobbing with the frequency (slowed)
const lh = Math.min(x.sleeveH * 3, x.sleeveH * ratio) * (0.88 + 0.12 * Math.sin(knob.t * 6));
x.lc.scaling.y = Math.max(0.05, lh); x.lc.position.z = Math.max(0.05, lh) / 2;
// the target holds only inside the rail's band: outside it the column turns a neutral steel blue
const inBand = f >= x.r.bandHz[0] * 0.999 && f <= x.r.bandHz[1] * 1.001;
const over = inBand && ratio > 1, t = Math.min(1, ratio);
x.lm.emissiveColor = !inBand ? new BAB.Color3(0.45, 0.55, 0.7) : over ? new BAB.Color3(1, 0.3, 0.25) : new BAB.Color3(0.25 + 0.6 * t, 1 - 0.2 * t, 0.45 - 0.3 * t);
x.pm.emissiveColor = x.lm.emissiveColor; x.pm.alpha = knob.on * (0.25 + 0.35 * Math.min(1.5, ratio) / 1.5);
x.loadTag.innerHTML = `<b>${x.r.net}</b> at ${x.r.load} · ${fmtHz(f)} ${inBand ? `<span class="${over ? 'v' : 'g'}">${fmtO(base.z)} · ${ratio.toFixed(2)}×</span>` : `${fmtO(base.z)} · outside its band`}${x.r.zAfterOhm ? ` <span class="g">→ ${(s.z / x.r.targetOhm).toFixed(2)}× fixed</span>` : ''}`;
place(x.loadTag, x.r.loadAt[0], x.r.loadAt[1], Math.max(x.sleeveH, lh) + 1.0);
// tags over clustered parts would stack: push each one below the one it collides with
const vis = [x.loadTag, ...x.cols.map(c => c.tag)].filter(el => !el.hidden).map(el => ({ el, x: parseFloat(el.style.left), y: parseFloat(el.style.top), w: el.offsetWidth, h: el.offsetHeight })).sort((a, b) => a.y - b.y);
for (let a = 0; a < vis.length; a++) for (let b = 0; b < a; b++) { const A = vis[a], B = vis[b]; if (Math.abs(A.x - B.x) < (A.w + B.w) / 2 && A.y < B.y + B.h + 2) { A.y = B.y + B.h + 2; A.el.style.top = A.y + 'px'; } }
if (performance.now() - lastChart > 90) {
lastChart = performance.now(); chart(f);
const fv = panel.querySelector('#pdnFv'); if (fv) fv.textContent = fmtHz(f);
const top = x.cols.map((c, bi) => ({ c, s: c.added ? 0 : base.share[bi] })).sort((a, b) => b.s - a.s)[0];
const now = panel.querySelector('#pdnNow'); if (now && top) now.innerHTML = `${fmtHz(f)}: ${fmtO(base.z)} ${inBand ? 'against ' + fmtO(x.r.targetOhm) : '(outside the band the target covers)'}; <b style="color:var(--text)">${top.c.nm}</b> carries ${(top.s * 100).toFixed(0)} %${top.s > 1.05 ? ' (ringing)' : ''}`;
}
});
});
function apply(view) {
knob.to = view.quantity === 'pdn' ? 1 : 0; panel.hidden = view.quantity !== 'pdn';
if (view.quantity !== 'pdn') return;
if (view.net) { const i = rails.findIndex(r => r.net === view.net); if (i >= 0) { sel.i = i; sel.picked = true; } }
for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.3 : 0; }
const leg = document.getElementById('legend'); if (leg) leg.hidden = true;
}
function describe() {
return ['<b>Power delivery</b>: each rail\'s impedance from its load pin, the frequency sweeping 1 kHz to 1 GHz',
'<b>Columns</b> = each branch\'s share of the load\'s current at this frequency: <span style="color:#5cf08f">green</span> capacitors, <span style="color:#5a9aff">blue</span> the regulator or the supply cable, <span style="color:#c38bff">violet</span> the plane',
'Over 100 % = two branches ringing against each other (an anti-resonance)',
'<b>The glass sleeve</b> at the load pin = the target impedance (the rail\'s tolerance for its step current); the column inside is |Z|, green under the rim, red over',
'A peak over the target is in the fix plan with the capacitor that brings it under; ngspice runs the same circuit'];
}
function narration() {
if (!rails.length) return 'This is the Power view, but this board has no rail with a load pin and capacitors to check.';
const say = (z) => z >= 1 ? `${z.toFixed(1)} ohms` : `${Math.round(z * 1000)} milliohms`;
const hz = (f) => f >= 1e6 ? `${(f / 1e6).toFixed(f >= 1e7 ? 0 : 1)} megahertz` : `${Math.round(f / 1e3)} kilohertz`;
const parts = rails.map(r => `${r.net}, seen from ${r.load.replace('.', ' pin ')}, peaks at ${say(r.worst.ohm)} near ${hz(r.worst.hz)}, ${r.worst.ratio > 1 ? `${r.worst.ratio.toFixed(1)} times its target, so the fix plan adds ${r.fixes.map(x => x.value.replace('uF', ' microfarad').replace('nF', ' nanofarad')).join(' and ')} right at that pin` : `${Math.round(r.worst.ratio * 100)} percent of its target of ${say(r.targetOhm)}`}${r.worst.inductive && r.worst.capacitive ? `; that peak is ${who(r.worst.inductive, r, r.worst.capacitive)}` : ''}.`);
const mb = rails.find(r => r.middlebrook);
function who(name, r, cap) { const b = r.branches.find(x => x.name === name); if (b && b.kind === 'cable') return `the supply leads into ${name.replace(' cable', '')} ringing against ${cap}`; if (b && b.kind === 'regulator') return `where the regulator's control loop hands the rail over to ${cap}`; return `${name} ringing against ${cap}`; }
return `This is the Power view: how stiff each rail is, from slow changes to fast ones. A chip pulls current in steps, and the rail's impedance decides how far its voltage wobbles. Watch the frequency sweep: at the low end the regulator or the supply holds the rail, then the big capacitors take over, then the small ones nearest the pin. Each column is one part's share of the current. When a column shoots past a hundred percent, two parts are passing current back and forth: the rail is ringing. The glass sleeve at the load pin is the target; the column inside it is the impedance, green under the rim, red over it. ${parts.join(' ')}${mb && mb.middlebrook ? (mb.middlebrook.ok ? ` The input also passes the stability check: its network stays ${Math.round(mb.middlebrook.marginDb)} decibels under the converter's negative input resistance, so the converter cannot ring with its own input.` : ` The input fails the stability check: its network comes within ${Math.round(mb.middlebrook.marginDb)} decibels of the converter's negative input resistance, so the fix plan adds a damping capacitor.`) : ''} ngspice runs the same circuit as a cross-check, and every finding goes into the fix plan for the AI.`;
}
console.log(`pdn: ${rails.map(r => `${r.net} ${r.worst.ratio}× at ${r.worst.hz} Hz`).join(', ')}`);
return { apply, describe, narration };
}