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.2: the energy cycle of a switching converter, the board's silkscreen over every view, part values, subt…
7d9e35b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
// Adom Fields: the energy cycle of a switching converter.
// One switching period, slowed about a million times: the input capacitors' electric field giving energy
// while the switch is on, the inductor's magnetic field growing on the on-time and collapsing on the
// off-time to push the current on, the output capacitors smoothing it, the heat drained every pass.
// The model is the ideal continuous-conduction buck solved exactly (no AI, no SPICE): duty D = Vout/Vin,
// inductor ripple dI = (Vin - Vout) D T / L, the capacitor currents and their charge integrated over the
// period. L and C come from the parts' values on the board; the parts from the nets they join.
export async function attach(ctx) {
const { BAB, scene, V, camera, state, Z, LAYERS, frameDt, root } = ctx;
const f = state.fields, meta = f.flow, board = f.board;
if (!meta) return null;
const nets = meta.nets, vals = f.partValues || {}, pads = f.pads || [];
const bx = (x) => x - board.x0 - board.widthMm / 2, by = (y) => board.y0 + board.heightMm / 2 - y;
const PREFIX = { p: 1e-12, n: 1e-9, u: 1e-6, 'µ': 1e-6, m: 1e-3, k: 1e3, '': 1 };
// "15uH", "22uF 10V", "4u7", "100nF", "0.1uF" -> SI, or null
function parseValue(s, unit) {
for (const tok of String(s || '').replace(',', '.').split(/\s+/)) {
const m = tok.match(/^(\d+(?:\.\d+)?)([pnuµmk]?)(\d*)([FH]?)$/i); if (!m) continue;
const u = (m[4] || '').toUpperCase(); if (u && u !== unit) continue; if (!u && !m[2]) continue;
const num = parseFloat(m[1] + (m[3] ? '.' + m[3] : '')); const mul = PREFIX[m[2]] ?? PREFIX[m[2].toLowerCase()] ?? 1;
return num * mul;
}
return null;
}
const padsOf = (ref) => pads.filter(p => p.ref === ref);
const netsOf = (ref) => [...new Set(padsOf(ref).map(p => p.net))];
const centre = (ref) => { const it = ((f.parts || {}).items || []).find(p => p.ref === ref); if (it) return { x: it.x, y: it.y }; const ps = padsOf(ref); if (!ps.length) return null; return { x: ps.reduce((s, p) => s + p.x, 0) / ps.length, y: ps.reduce((s, p) => s + p.y, 0) / ps.length }; };
// ---- find the converter: the switching node, its inductor, its switch, its input and output -------
const sw = nets.find(n => n.switching && n.loaded) || nets.find(n => n.switching);
const why = [];
let model = null;
if (!sw) why.push('no switching node in this solve (the spec\'s switching, or a net named SW, LX or PH)');
else {
const refs = [...new Set(pads.map(p => p.ref))];
const Lref = refs.find(r => /^L\d/i.test(r) && netsOf(r).includes(sw.name));
const outNet = Lref ? netsOf(Lref).find(n => n !== sw.name) : null;
const swRef = ((sw.terminals || []).find(t => t.role === 'source') || {}).ref || refs.find(r => /^U\d/i.test(r) && netsOf(r).includes(sw.name));
const gnd = nets.find(n => n.ground);
const rail = (name) => (nets.find(n => n.name === name) || {}).railV;
const inNet = swRef ? netsOf(swRef).filter(n => n !== sw.name && !(gnd && n === gnd.name)).sort((a, b) => (rail(b) ?? -1) - (rail(a) ?? -1))[0] : null;
const capsOn = (net) => refs.filter(r => /^C\d/i.test(r) && netsOf(r).includes(net) && gnd && netsOf(r).includes(gnd.name)).map(r => ({ ref: r, C: parseValue(vals[r], 'F') })).filter(c => c.C);
const L = Lref ? parseValue(vals[Lref], 'H') : null;
const Vin = inNet ? rail(inNet) : null, Vout = outNet ? rail(outNet) : null;
const loadOut = outNet ? (nets.find(n => n.name === outNet) || {}).amps : null;
const fsw = sw.switching.freqHz || 500e3;
if (!Lref || !L) why.push('no inductor with a value on the switching node');
if (!Vin || !Vout) why.push('the input and output rails need voltages');
if (!loadOut) why.push('the output net needs a load current');
if (!why.length && Vout < Vin) {
const cin = capsOn(inNet), cout = capsOn(outNet);
const Cin = cin.reduce((s, c) => s + c.C, 0) || 1e-6, Cout = cout.reduce((s, c) => s + c.C, 0) || 1e-6;
const T = 1 / fsw, D = Vout / Vin, Iout = loadOut, dI = (Vin - Vout) * D * T / L, Iin = D * Iout;
const heatW = (f.chips || []).filter(c => c.reference === swRef || c.reference === Lref).reduce((s, c) => s + c.watts, 0);
const S = 240, iL = new Float64Array(S), iSw = new Float64Array(S), vCin = new Float64Array(S), vCout = new Float64Array(S);
for (let k = 0; k < S; k++) { const ph = k / S; iL[k] = ph < D ? Iout - dI / 2 + dI * ph / D : Iout + dI / 2 - dI * (ph - D) / (1 - D); iSw[k] = ph < D ? iL[k] : 0; }
// the capacitor charge over the period, zero mean: the input caps give while the switch is on, refill while it is off
let qi = 0, qo = 0; const qin = new Float64Array(S), qout = new Float64Array(S);
for (let k = 0; k < S; k++) { qi += (Iin - iSw[k]) * T / S; qo += (iL[k] - Iout) * T / S; qin[k] = qi; qout[k] = qo; }
const mi = qin.reduce((s, x) => s + x, 0) / S, mo = qout.reduce((s, x) => s + x, 0) / S;
for (let k = 0; k < S; k++) { vCin[k] = Vin + (qin[k] - mi) / Cin; vCout[k] = Vout + (qout[k] - mo) / Cout; }
const EL = Array.from(iL, i => 0.5 * L * i * i), ECin = Array.from(vCin, v => 0.5 * Cin * v * v), ECout = Array.from(vCout, v => 0.5 * Cout * v * v);
model = { Lref, swRef, inNet, outNet, gnd: gnd && gnd.name, cin, cout, Cin, Cout, L, Vin, Vout, Iout, Iin, D, T, fsw, dI, heatW, S, iL, iSw, vCin, vCout, EL, ECin, ECout,
perCycle: Vout * Iout * T, heatPerCycle: heatW * T, rippleIn: Math.max(...vCin) - Math.min(...vCin), rippleOut: Math.max(...vCout) - Math.min(...vCout) };
} else if (!why.length) why.push('only a step-down (buck) converter is modelled so far');
}
const at = (arr, ph) => { const x = ((ph % 1) + 1) % 1 * model.S, k = Math.floor(x), t = x - k; return arr[k] * (1 - t) + arr[(k + 1) % model.S] * t; };
const span = (arr) => { let lo = Infinity, hi = -Infinity; for (const v of arr) { lo = Math.min(lo, v); hi = Math.max(hi, v); } return [lo, hi]; };
// ---- the picture: instanced streaks (the flow view's own shader) ---------------------------------
const CAP = 2400;
const IP = new Float32Array(CAP * 4), IV = new Float32Array(CAP * 4), IC = new Float32Array(CAP * 4);
const mesh = new BAB.Mesh('fields-cycle-streaks', scene);
{ const vd = new BAB.VertexData(); vd.positions = [-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]; vd.indices = [0, 1, 2, 0, 2, 3]; vd.applyToMesh(mesh, false); }
const engine = V.getEngine();
const vbP = new BAB.VertexBuffer(engine, IP, 'ip', true, false, 4, true), vbV = new BAB.VertexBuffer(engine, IV, 'iv', true, false, 4, true), vbC = new BAB.VertexBuffer(engine, IC, 'ic', true, false, 4, true);
mesh.setVerticesBuffer(vbP); mesh.setVerticesBuffer(vbV); mesh.setVerticesBuffer(vbC);
mesh.forcedInstanceCount = CAP; mesh.alwaysSelectAsActiveMesh = true; mesh.isPickable = false; mesh.parent = root; mesh.renderingGroupId = 1; mesh.setEnabled(false);
const mat = new BAB.ShaderMaterial('fields-cycle-streak', scene, { vertex: 'fieldsStreak', fragment: 'fieldsStreak' }, { attributes: ['position', 'ip', 'iv', 'ic'], uniforms: ['world', 'viewProjection', 'camPos', 'trail', 'width'], needAlphaBlending: true });
mat.alphaMode = BAB.Constants.ALPHA_ADD; mat.disableDepthWrite = true; mat.backFaceCulling = false; mat.setFloat('trail', 0.25); mat.setFloat('width', 1.0);
mesh.material = mat;
try { const g = scene.effectLayers && scene.effectLayers.find(l => l.name === 'fields-flow-glow'); if (g) { g.addIncludedOnlyMesh(mesh); g.referenceMeshToUseItsOwnMaterial(mesh); } } catch (e) { void e; }
const diag = Math.hypot(board.widthMm, board.heightMm), size = Math.max(0.06, diag * 0.004);
const top = () => Z['F.Cu'] + 0.05;
// the particles: kind 0 energy packet on a route, 1 magnetic loop around the inductor, 2 field between a capacitor's pads, 3 heat
const P = [];
let seed = 7; const rnd = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
function route(refs) { const pts = refs.map(centre).filter(Boolean); const len = []; let acc = 0; for (let i = 1; i < pts.length; i++) { acc += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); len.push(acc); } return { pts, len, total: acc || 1 }; }
function along(r, u) { const d = u * r.total; let i = 0; while (i < r.len.length - 1 && r.len[i] < d) i++; const d0 = i ? r.len[i - 1] : 0, seg = (r.len[i] - d0) || 1, t = Math.min(1, Math.max(0, (d - d0) / seg)); const a = r.pts[i], b = r.pts[i + 1] || a; return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }
let routes = null, magC = null, caps = [];
function build() {
if (!model) return;
const src = ((nets.find(n => n.name === model.inNet) || {}).terminals || []).find(t => t.role === 'source');
const sink = ((nets.find(n => n.name === model.outNet) || {}).terminals || []).find(t => t.role === 'sink');
const cinMain = model.cin.slice().sort((a, b) => b.C - a.C)[0], coutMain = model.cout.slice().sort((a, b) => b.C - a.C)[0];
routes = {
feed: route([src && src.ref, cinMain && cinMain.ref].filter(Boolean)), // the source refills the input caps
on: route([cinMain && cinMain.ref, model.swRef, model.Lref].filter(Boolean)), // on: the input caps through the switch into the inductor
out: route([model.Lref, coutMain && coutMain.ref, sink && sink.ref].filter(Boolean)), // always: the inductor into the output caps and the load
};
magC = centre(model.Lref);
caps = model.cin.map(c => ({ ...c, side: 'in' })).concat(model.cout.map(c => ({ ...c, side: 'out' }))).map(c => { const ps = padsOf(c.ref); const hot = ps.find(p => p.net !== model.gnd), cold = ps.find(p => p.net === model.gnd); return hot && cold ? { ...c, a: hot, b: cold } : null; }).filter(Boolean);
P.length = 0;
for (let i = 0; i < 260; i++) P.push({ kind: 0, r: i < 45 ? 'feed' : i < 150 ? 'on' : 'out', u: rnd(), s: 0.75 + 0.5 * rnd(), j: (rnd() - 0.5) * 0.5 });
for (let a = 0; a < 6; a++) for (const R of [0.9, 1.5, 2.3]) for (let k = 0; k < 14; k++) P.push({ kind: 1, a: a * Math.PI / 6 + (R > 1 ? 0.25 : 0), R, u: k / 14 + rnd() * 0.05 });
for (const c of caps) for (let k = 0; k < 26; k++) P.push({ kind: 2, cap: c, u: rnd(), j: (rnd() - 0.5) });
for (let k = 0; k < 140; k++) P.push({ kind: 3, ref: k % 3 ? model.swRef : model.Lref, u: rnd(), dx: (rnd() - 0.5) * 1.6, dy: (rnd() - 0.5) * 1.6 });
}
build();
// ---- the clock: one period over CYCLE_S seconds on screen ------------------------------------------
const CYCLE_S = 3.2;
const knob = { on: 0, to: 0, ph: 0 };
const [eLlo, eLhi] = model ? span(model.EL) : [0, 1], [ciLo, ciHi] = model ? span(model.ECin) : [0, 1], [coLo, coHi] = model ? span(model.ECout) : [0, 1];
const norm = (v, lo, hi) => hi > lo ? (v - lo) / (hi - lo) : 0.5;
function write(i, x, y, z, vx, vy, vz, a, c, s) { const o = i * 4; IP[o] = bx(x); IP[o + 1] = by(y); IP[o + 2] = z; IP[o + 3] = size * (s || 1); IV[o] = vx; IV[o + 1] = -vy; IV[o + 2] = vz; IV[o + 3] = a; IC[o] = c[0]; IC[o + 1] = c[1]; IC[o + 2] = c[2]; IC[o + 3] = 1; }
function frame(dt) {
knob.ph = (knob.ph + dt / CYCLE_S) % 1;
const ph = knob.ph, isOn = ph < model.D;
const iL = at(model.iL, ph), iSw = at(model.iSw, ph);
const eL = norm(at(model.EL, ph), eLlo, eLhi), eIn = norm(at(model.ECin, ph), ciLo, ciHi), eOut = norm(at(model.ECout, ph), coLo, coHi);
const z0 = top(), rates = { feed: model.Iin * model.Vin, on: iSw * model.Vin, out: iL * model.Vout }, maxRate = Math.max(model.Vin * (model.Iout + model.dI / 2), 1e-9);
const lTop = (() => { const n = scene.getTransformNodeByName('part-' + model.Lref); let h = 1.5; if (n) for (const m of n.getChildMeshes(false)) { const bb = m.getBoundingInfo().boundingBox; h = Math.max(h, bb.maximumWorld.z - z0); } return Math.min(h, 6); })();
for (let i = 0; i < CAP; i++) {
const p = P[i]; if (!p) { IV[i * 4 + 3] = 0; continue; }
if (p.kind === 0) {
const r = routes[p.r]; if (!r || r.pts.length < 2) { IV[i * 4 + 3] = 0; continue; }
const rate = rates[p.r] / maxRate;
p.u = (p.u + dt * 0.45 * p.s) % 1;
const q = along(r, p.u), q2 = along(r, Math.min(1, p.u + 0.02)), lift = 0.35 + 0.25 * Math.sin(p.u * Math.PI);
write(i, q.x + p.j * 0.3, q.y + p.j * 0.3, z0 + lift, (q2.x - q.x) * 40, (q2.y - q.y) * 40, 0, knob.on * Math.min(1, rate) * 0.32, [1.0, 0.35, 0.85], 0.8);
} else if (p.kind === 1) {
// a field line of a dipole: r = R sin² θ in a vertical plane through the inductor; the particles run
// along it as fast as the current, as bright as the field's stored energy
p.u = (p.u + dt * (0.15 + 0.5 * iL / (model.Iout + model.dI / 2))) % 1;
const th = 0.12 + p.u * (Math.PI - 0.24), sc = (0.75 + 0.45 * eL) * p.R * 1.6, rr = sc * Math.sin(th) ** 2;
const xx = rr * Math.sin(th), zz = rr * Math.cos(th);
const th2 = th + 0.05, rr2 = sc * Math.sin(th2) ** 2, xx2 = rr2 * Math.sin(th2), zz2 = rr2 * Math.cos(th2);
const ca = Math.cos(p.a), sa = Math.sin(p.a);
write(i, magC.x + xx * ca, magC.y + xx * sa, z0 + lTop * 0.5 + zz, (xx2 - xx) * ca * 30, (xx2 - xx) * sa * 30, (zz2 - zz) * 30, knob.on * (0.3 + 0.7 * eL), [0.4, 0.68, 1.0], 1.25);
} else if (p.kind === 2) {
// the field between a capacitor's two pads: an arch from the rail pad to the ground pad
const c = p.cap, e = c.side === 'in' ? eIn : eOut;
p.u = (p.u + dt * 0.6) % 1;
const x = c.a.x + (c.b.x - c.a.x) * p.u, y = c.a.y + (c.b.y - c.a.y) * p.u, hz = 0.25 + 0.6 * Math.sin(p.u * Math.PI) * (0.6 + p.j * 0.4);
write(i, x + p.j * 0.25, y + p.j * 0.25, z0 + hz, (c.b.x - c.a.x) * 2, (c.b.y - c.a.y) * 2, Math.cos(p.u * Math.PI) * 2, knob.on * (0.15 + 0.85 * e), [1.0, 0.3, 0.25], 1.0);
} else {
// heat leaving the switch and the inductor every pass
const cx = centre(p.ref); if (!cx) { IV[i * 4 + 3] = 0; continue; }
p.u = (p.u + dt * 0.35) % 1;
write(i, cx.x + p.dx, cx.y + p.dy, z0 + 0.4 + p.u * 4, 0, 0, 1.5, knob.on * 0.35 * (1 - p.u) * Math.min(1, model.heatW / 0.3 + 0.3), [1.0, 0.55, 0.15], 0.8);
}
}
vbP.update(IP); vbV.update(IV); vbC.update(IC);
mat.setVector3('camPos', camera.globalPosition || camera.position);
meter(ph, isOn, iL, eL, eIn, eOut);
}
// ---- the meter: where the energy is, this instant of the period --------------------------------------
const panel = document.createElement('div'); panel.className = 'cyclemeter'; panel.hidden = true;
document.querySelector('.stage').appendChild(panel);
const uJ = (j) => (j * 1e6).toFixed(j * 1e6 < 10 ? 2 : 1) + ' µJ';
if (model) panel.innerHTML = `<div class="ch" title="Click to fold">Energy cycle · ${(model.fsw / 1000).toFixed(0)} kHz, shown ${Math.round(CYCLE_S * model.fsw).toLocaleString()}× slower</div>
<div class="body"><div class="row sw"><span>${model.swRef} switch</span><b id="cySw"></b></div>
<canvas id="cyWave" width="440" height="64"></canvas>
<div class="row"><span class="dot in"></span>Input caps (${(model.Cin * 1e6).toFixed(0)} µF) <i id="cyIn"></i></div><div class="bar"><div id="cyInB" class="in"></div></div>
<div class="row"><span class="dot l"></span>${model.Lref} magnetic field (${(model.L * 1e6).toFixed(0)} µH) <i id="cyL"></i></div><div class="bar"><div id="cyLB" class="l"></div></div>
<div class="row"><span class="dot out"></span>Output caps (${(model.Cout * 1e6).toFixed(0)} µF) <i id="cyOut"></i></div><div class="bar"><div id="cyOutB" class="out"></div></div>
<div class="row"><span class="dot heat"></span>Heat, each cycle <i>${uJ(model.heatPerCycle)}</i></div>
<div class="row"><span class="dot pk"></span>To the load, each cycle <i>${uJ(model.perCycle)}</i></div></div>`;
panel.addEventListener('click', (e) => { if (e.target.closest('.ch')) panel.classList.toggle('folded'); });
function meter(ph, isOn, iL, eL, eIn, eOut) {
const q = (id) => panel.querySelector('#' + id); if (!q('cySw')) return;
q('cySw').textContent = isOn ? 'ON: input → inductor' : 'OFF: inductor → output'; q('cySw').className = isOn ? 'on' : 'off';
q('cyIn').textContent = `${(at(model.vCin, ph)).toFixed(3)} V`; q('cyInB').style.width = (10 + 90 * eIn) + '%';
q('cyL').textContent = `${iL.toFixed(2)} A · ${uJ(at(model.EL, ph))}`; q('cyLB').style.width = (10 + 90 * eL) + '%';
q('cyOut').textContent = `${(at(model.vCout, ph)).toFixed(4)} V`; q('cyOutB').style.width = (10 + 90 * eOut) + '%';
const cv = q('cyWave'), g = cv.getContext('2d'), W = cv.width, H = cv.height, [lo, hi] = span(model.iL);
g.clearRect(0, 0, W, H); g.fillStyle = 'rgba(255,90,210,.10)'; g.fillRect(0, 0, W * model.D, H);
g.strokeStyle = '#64abff'; g.lineWidth = 3; g.beginPath();
for (let k = 0; k <= 120; k++) { const t = k / 120, v = at(model.iL, t), yy = H - 8 - (v - lo) / (hi - lo || 1) * (H - 16); k ? g.lineTo(t * W, yy) : g.moveTo(t * W, yy); } g.stroke();
g.strokeStyle = '#ffffff'; g.lineWidth = 2; g.beginPath(); g.moveTo(ph * W, 0); g.lineTo(ph * W, H); g.stroke();
g.fillStyle = '#8b949e'; g.font = '20px JetBrains Mono, monospace'; g.fillText(`${model.Lref} current ${lo.toFixed(2)}–${hi.toFixed(2)} A`, 8, 22);
}
// ---- the frame, the view, the words ---------------------------------------------------------------
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 && model; mesh.setEnabled(!!live); panel.hidden = !(knob.to > 0.5);
if (live) frame(dt * (+state.view.speed || 1));
});
function apply(view) {
knob.to = view.quantity === 'cycle' ? 1 : 0;
if (view.quantity !== 'cycle') return;
if (!model) { panel.innerHTML = `<div class="ch">Energy cycle</div><div class="body"><div class="row">Not modelled: ${why.join('; ')}.</div></div>`; }
for (const Lname of LAYERS) { const m = ctx.layerMeshes[Lname]; if (m && m.material) m.material.alpha = Lname === 'F.Cu' ? 0.35 : 0; }
const leg = document.getElementById('legend'); if (leg) leg.hidden = true;
}
function describe() {
if (!model) return ['<b>Energy cycle</b>', 'Not modelled: ' + why.join('; ')];
return ['<b>Energy cycle</b>: where the energy is during one switching period',
'<b>Pink</b> = energy moving: from the input caps through ' + model.swRef + ' into ' + model.Lref + ' while the switch is on; from ' + model.Lref + ' to the output caps and the load all the time',
'<b>Blue</b> = ' + model.Lref + '\'s magnetic field, growing while the switch is on and collapsing while it is off: that collapse keeps the current flowing',
'<b>Red</b> = the electric field in each capacitor: the input caps give while the switch is on and refill while it is off',
'<b>Orange</b> = heat lost every cycle',
'Ideal buck model, D = Vout/Vin, from the parts\' values; brightness shows each store\'s swing, not its total'];
}
function narration() {
if (!model) return 'The energy cycle needs a switching converter: ' + why.join('; ') + '.';
const pct = Math.round(model.D * 100), lo = model.Iout - model.dI / 2, hi = model.Iout + model.dI / 2;
return `This is the energy cycle, one switching period of the buck converter, slowed down about ${Math.round(CYCLE_S * model.fsw / 1e6 * 10) / 10} million times. For ${pct} percent of each cycle, the switch in ${model.swRef} is on. Energy, in pink, leaves the input capacitors, which you see dim in red as they give it up, and flows through ${model.swRef} into the inductor ${model.Lref}. The inductor stores it in its magnetic field, in blue, which grows as its current climbs to ${hi.toFixed(2)} amps. Then the switch turns off. The magnetic field collapses, and that collapse keeps pushing current into the output capacitors and the load, even with the input cut off, until the current falls back to ${lo.toFixed(2)} amps. Meanwhile the input capacitors refill from the supply. Every cycle, about ${(model.perCycle * 1e6).toFixed(0)} microjoules reach the load, and a little is lost as heat, in orange. Electric field, magnetic field, and back: that is how a switching converter moves energy.`;
}
console.log(model ? `cycle: ${model.swRef} switching ${model.inNet} ${model.Vin} V to ${model.outNet} ${model.Vout} V at ${(model.fsw / 1000).toFixed(0)} kHz, D ${(model.D * 100).toFixed(1)} %, ${model.Lref} ${(model.L * 1e6).toFixed(1)} µH ripple ${model.dI.toFixed(3)} A, Cin ${(model.Cin * 1e6).toFixed(1)} µF (${model.cin.map(c => c.ref).join(' ')}), Cout ${(model.Cout * 1e6).toFixed(1)} µF (${model.cout.map(c => c.ref).join(' ')}), ripple in ${(model.rippleIn * 1000).toFixed(1)} mV out ${(model.rippleOut * 1000).toFixed(2)} mV` : 'cycle: not modelled: ' + why.join('; '));
return { apply, describe, narration, model: () => model };
}