this repo has no description
at v2 612 lines 31 kB view raw
1// Smoke tests for TLPhysics pure functions. 2// Requires Node 18+. Run with: node --test physics.test.js 3"use strict"; 4 5const { test } = require("node:test"); 6const assert = require("node:assert/strict"); 7const { readFileSync, existsSync } = require("node:fs"); 8const path = require("path"); 9 10// utils.js calls window.devicePixelRatio inside getDPR(); stub it so the IIFE 11// doesn't throw during load. Physics functions never call getDPR. 12globalThis.window = { devicePixelRatio: 1 }; 13 14// eval() scopes `const` to the eval block, so we append an explicit globalThis 15// assignment after each IIFE declaration to make the namespace visible. 16eval(readFileSync(path.join(__dirname, "utils.js"), "utf8") + "\nglobalThis.TLUtils = TLUtils;"); 17eval(readFileSync(path.join(__dirname, "physics.js"), "utf8") + "\nglobalThis.TLPhysics = TLPhysics;"); 18 19const { 20 computeWaveParams, 21 buildBounceSeries, 22 sumEventsAtTime, 23 sumEventsWithLinearRamp, 24 riseShape, 25 riseShapeLinear, 26 totalVoltageAt, 27 computeDynamicState, 28} = globalThis.TLPhysics; 29 30// Helper: assert two numbers are within `tol` of each other. 31function near(a, b, msg, tol = 1e-9) { 32 assert.ok( 33 Math.abs(a - b) <= tol, 34 `${msg}: expected ~${b}, got ${a} (diff ${(a - b).toExponential(2)})` 35 ); 36} 37 38// Build a minimal single-segment model and run buildBounceSeries. 39function makeModel(Vg, Rg, Z0, RL, reflectTol = 0.001) { 40 const model = { Vg, Rg, RL, segments: [{ Z0 }], reflectTol }; 41 const waves = computeWaveParams(model); 42 const bounce = buildBounceSeries(model, waves); 43 return { model, waves, bounce }; 44} 45 46// ──────────────────────────────────────────────────────────────────────────── 47// computeWaveParams 48// ──────────────────────────────────────────────────────────────────────────── 49 50test("computeWaveParams: matched line (Rg=Z0=RL=50)", () => { 51 const m = { Vg: 1, Rg: 50, RL: 50, segments: [{ Z0: 50 }] }; 52 const { V1, gL, gS } = computeWaveParams(m); 53 near(V1, 0.5, "V1"); 54 near(gL, 0, "gL"); 55 near(gS, 0, "gS"); 56}); 57 58test("computeWaveParams: open-circuit load (RL=Infinity)", () => { 59 const m = { Vg: 1, Rg: 50, RL: Infinity, segments: [{ Z0: 50 }] }; 60 const { V1, gL, gS } = computeWaveParams(m); 61 near(V1, 0.5, "V1"); 62 near(gL, 1, "gL"); 63 near(gS, 0, "gS"); 64}); 65 66test("computeWaveParams: short-circuit load (RL=0)", () => { 67 const m = { Vg: 1, Rg: 50, RL: 0, segments: [{ Z0: 50 }] }; 68 const { V1, gL, gS } = computeWaveParams(m); 69 near(V1, 0.5, "V1"); 70 near(gL, -1, "gL"); 71 near(gS, 0, "gS"); 72}); 73 74test("computeWaveParams: source Γ with Rg mismatch", () => { 75 // Rg=100, Z0=50 → gS = (100-50)/(100+50) = 50/150 = 1/3 76 const m = { Vg: 1, Rg: 100, RL: 50, segments: [{ Z0: 50 }] }; 77 const { gS } = computeWaveParams(m); 78 near(gS, 1 / 3, "gS", 1e-9); 79}); 80 81// ──────────────────────────────────────────────────────────────────────────── 82// buildBounceSeries: wave count and first-wave correctness 83// ──────────────────────────────────────────────────────────────────────────── 84 85test("buildBounceSeries: matched line produces exactly 1 wave", () => { 86 const { bounce } = makeModel(1, 50, 50, 50); 87 assert.equal(bounce.series.length, 1, "exactly one wave packet"); 88 assert.equal(bounce.series[0].dir, +1, "rightward"); 89 near(bounce.series[0].A, 0.5, "amplitude = V1 = 0.5"); 90}); 91 92test("buildBounceSeries: first wave geometry", () => { 93 const { bounce } = makeModel(1, 50, 50, 50); 94 const w = bounce.series[0]; 95 near(w.zStart, 0, "zStart = 0"); 96 near(w.zEnd, 1, "zEnd = 1"); 97 near(w.tBorn, 0, "tBorn = 0"); 98 near(w.tDie, 1, "tDie = 1"); 99}); 100 101test("buildBounceSeries: open-circuit — load event dV = 2·V1 at t=1", () => { 102 // gL = 1 → (1+gL)·A = 1.0. gS=0 → no further reflections → exactly 1 load event. 103 const { bounce } = makeModel(1, 50, 50, Infinity); 104 assert.equal(bounce.loadEvents.length, 1, "one load event"); 105 near(bounce.loadEvents[0].t, 1, "event time = 1τ"); 106 near(bounce.loadEvents[0].dV, 1.0, "dV = 1.0 (= 2·0.5)"); 107}); 108 109test("buildBounceSeries: short-circuit — load event dV = 0 at t=1", () => { 110 // gL = -1 → (1+gL)·A = 0 111 const { bounce } = makeModel(1, 50, 50, 0); 112 assert.equal(bounce.loadEvents.length, 1, "one load event"); 113 near(bounce.loadEvents[0].dV, 0, "dV = 0 at short circuit"); 114}); 115 116// ──────────────────────────────────────────────────────────────────────────── 117// DC steady state: VL(t→∞) = Vg · RL / (Rg + RL) 118// ──────────────────────────────────────────────────────────────────────────── 119 120function checkDCSteadyState(label, Vg, Rg, Z0, RL, tol = 1e-3) { 121 test(`DC steady state: ${label}`, () => { 122 const { bounce } = makeModel(Vg, Rg, Z0, RL, 0.001); 123 const VL_dc = isFinite(RL) ? Vg * RL / (Rg + RL) : Vg; 124 const VL_actual = sumEventsAtTime(bounce.loadEvents, bounce.tEnd + 10); 125 near(VL_actual, VL_dc, "VL", tol); 126 }); 127} 128 129// Matched source, various loads 130checkDCSteadyState("matched (50/50/50)", 1, 50, 50, 50); 131checkDCSteadyState("open circuit (50/50/∞)", 1, 50, 50, Infinity); 132checkDCSteadyState("short circuit (50/50/0)", 1, 50, 50, 0); 133// Mismatched source 134checkDCSteadyState("Rg=100 Z0=50 RL=150, Vg=5", 5, 100, 50, 150, 1e-3); 135checkDCSteadyState("Rg=25 Z0=75 RL=200, Vg=3.3", 3.3, 25, 75, 200, 1e-3); 136 137// ──────────────────────────────────────────────────────────────────────────── 138// Multi-segment: two identical segments should match single segment 139// ──────────────────────────────────────────────────────────────────────────── 140 141test("multi-segment: two identical Z0 segments == single segment", () => { 142 const single = makeModel(1, 50, 50, 100, 0.001); 143 const model2 = { Vg: 1, Rg: 50, RL: 100, segments: [{ Z0: 50 }, { Z0: 50 }], reflectTol: 0.001 }; 144 const waves2 = computeWaveParams(model2); 145 const bounce2 = buildBounceSeries(model2, waves2); 146 const t = single.bounce.tEnd + 10; 147 const VL_single = sumEventsAtTime(single.bounce.loadEvents, t); 148 const VL_double = sumEventsAtTime(bounce2.loadEvents, t); 149 near(VL_single, VL_double, "VL matches", 1e-3); 150}); 151 152test("multi-segment: two different Z0 — DC still converges to Vg·RL/(Rg+RL)", () => { 153 const model = { Vg: 1, Rg: 50, RL: 100, segments: [{ Z0: 50 }, { Z0: 75 }], reflectTol: 0.001 }; 154 const waves = computeWaveParams(model); 155 const bounce = buildBounceSeries(model, waves); 156 const VL_dc = 1 * 100 / (50 + 100); // ≈ 0.6667 157 const VL_actual = sumEventsAtTime(bounce.loadEvents, bounce.tEnd + 10); 158 near(VL_actual, VL_dc, "VL", 1e-3); 159}); 160 161// ──────────────────────────────────────────────────────────────────────────── 162// sumEventsAtTime 163// ──────────────────────────────────────────────────────────────────────────── 164 165test("sumEventsAtTime: empty events → 0", () => { 166 near(sumEventsAtTime([], 999), 0, "empty"); 167}); 168 169test("sumEventsAtTime: single event at t=0 seen immediately", () => { 170 near(sumEventsAtTime([{ t: 0, dV: 0.5 }], 0), 0.5, "at t=0"); 171}); 172 173test("sumEventsAtTime: event at t=1 not seen before it", () => { 174 near(sumEventsAtTime([{ t: 1, dV: 0.5 }], 0.5), 0, "before event"); 175 near(sumEventsAtTime([{ t: 1, dV: 0.5 }], 1.0), 0.5, "at event time"); 176 near(sumEventsAtTime([{ t: 1, dV: 0.5 }], 2.0), 0.5, "after event time"); 177}); 178 179test("sumEventsAtTime: accumulates multiple events", () => { 180 const evts = [{ t: 0, dV: 0.5 }, { t: 1, dV: 0.25 }, { t: 2, dV: 0.1 }]; 181 near(sumEventsAtTime(evts, 1.5), 0.75, "after first two events"); 182 near(sumEventsAtTime(evts, 2.0), 0.85, "after all events"); 183}); 184 185// ──────────────────────────────────────────────────────────────────────────── 186// riseShape 187// ──────────────────────────────────────────────────────────────────────────── 188 189test("riseShape: tr=0 (step) — hard step at dt=0", () => { 190 near(riseShape(0, 0), 0, "dt=0 tr=0"); 191 near(riseShape(-1, 0), 0, "dt<0 tr=0"); 192 near(riseShape(0.001, 0), 1, "tiny dt, tr=0"); 193 near(riseShape(100, 0), 1, "large dt, tr=0"); 194}); 195 196test("riseShape: tr>0 — causal shifted erf over [0, tr]", () => { 197 // Causal: dt <= 0 → exactly 0 198 near(riseShape(0, 1), 0, "dt=0 → 0", 1e-3); 199 near(riseShape(-1, 1), 0, "dt<0 → 0"); 200 // dt = tr → essentially 1 201 near(riseShape(1, 1), 1, "dt=tr → 1", 1e-3); 202 // Midpoint dt = tr/2 → 0.5 (erf(0) = 0) 203 near(riseShape(0.5, 1), 0.5, "dt=tr/2 → 0.5", 1e-7); 204 // Monotonically increasing 205 const v1 = riseShape(0.2, 1); 206 const v2 = riseShape(0.5, 1); 207 const v3 = riseShape(0.8, 1); 208 assert(v1 < v2, `monotonic: r(0.2)=${v1} < r(0.5)=${v2}`); 209 assert(v2 < v3, `monotonic: r(0.5)=${v2} < r(0.8)=${v3}`); 210 // Clamped to 1 for dt > tr 211 near(riseShape(2, 1), 1, "dt>tr → 1"); 212}); 213 214// ──────────────────────────────────────────────────────────────────────────── 215// riseShapeLinear 216// ──────────────────────────────────────────────────────────────────────────── 217 218test("riseShapeLinear: dt≤0 → 0", () => { 219 near(riseShapeLinear(0, 1), 0, "dt=0"); 220 near(riseShapeLinear(-1, 1), 0, "dt<0"); 221}); 222 223test("riseShapeLinear: tr=0 (step) → 1 for dt>0", () => { 224 near(riseShapeLinear(0.001, 0), 1, "tiny dt, tr=0"); 225 near(riseShapeLinear(100, 0), 1, "large dt, tr=0"); 226}); 227 228test("riseShapeLinear: linear ramp 0→1 over tr", () => { 229 near(riseShapeLinear(0.1, 1), 0.1, "10% of tr", 1e-12); 230 near(riseShapeLinear(0.5, 1), 0.5, "50% of tr", 1e-12); 231 near(riseShapeLinear(0.9, 1), 0.9, "90% of tr", 1e-12); 232 near(riseShapeLinear(1.0, 1), 1.0, "exactly tr", 1e-12); 233 near(riseShapeLinear(2.0, 1), 1.0, "past tr", 1e-12); 234}); 235 236test("riseShapeLinear: 10–90% rise time is exactly 0.8·tr", () => { 237 const tr = 0.3; 238 near(riseShapeLinear(0.1 * tr, tr), 0.1, "10%", 1e-12); 239 near(riseShapeLinear(0.9 * tr, tr), 0.9, "90%", 1e-12); 240}); 241 242// ──────────────────────────────────────────────────────────────────────────── 243// totalVoltageAt: no spike at segment boundaries (multi-segment plotting bug) 244// 245// In smooth mode, drawSampledWave evaluates totalVoltageAt at exact boundary 246// z values (e.g. z=0.5 for 2 segments). Without segment filtering, the parent 247// wave (dir=+1, zEnd=boundary) and the transmitted wave (dir=+1, zStart=boundary) 248// both pass the range check, doubling the voltage at the boundary. 249// ──────────────────────────────────────────────────────────────────────────── 250 251test("totalVoltageAt: no spike at segment boundary (2 segments, step mode)", () => { 252 // Z0=40 → Z0=60, Rg=50, RL=∞. 253 // Γ_bound = (60-40)/(60+40) = 0.2. V1 = 1 * 40/(50+40) = 4/9. 254 // At tNorm=0.6 the boundary crossing (tNorm=0.5) is complete: 255 // seg-0 has incident(4/9) + reflected(0.2·4/9), seg-1 has transmitted(1.2·4/9). 256 // Correct V at z=0.5 = (1+0.2)·(4/9) = 4.8/9 = 8/15. 257 // Buggy code (no segIdx filter) would give 2·(8/15) = 16/15 — a visible spike. 258 const model = { Vg: 1, Rg: 50, RL: Infinity, 259 segments: [{ Z0: 40 }, { Z0: 60 }], reflectTol: 0.001 }; 260 const waves = computeWaveParams(model); 261 const bounce = buildBounceSeries(model, waves); 262 const dyn = computeDynamicState(0.6, bounce); 263 const N = model.segments.length; 264 const eps = 1e-4; 265 266 const vL = totalVoltageAt(0.5 - eps, dyn.launchedWaves, 0, "step", N); 267 const vB = totalVoltageAt(0.5, dyn.launchedWaves, 0, "step", N); 268 const vR = totalVoltageAt(0.5 + eps, dyn.launchedWaves, 0, "step", N); 269 270 // Correct value: (1 + 0.2) * (4/9) = 8/15 271 near(vB, 8 / 15, "V at boundary = (1+Γ)·V1", 1e-6); 272 near(vB, vL, "no spike: V(boundary) ≈ V(boundary-ε)", 1e-6); 273 near(vB, vR, "no spike: V(boundary) ≈ V(boundary+ε)", 1e-6); 274}); 275 276test("totalVoltageAt: no spike at boundaries with 4 segments", () => { 277 // 4 equal segments: boundaries at 0.25, 0.5, 0.75. 278 // tNorm=0.3 puts the front in seg 1; all three boundary z-values should be spike-free. 279 const model = { Vg: 1, Rg: 50, RL: 100, 280 segments: [{ Z0: 50 }, { Z0: 75 }, { Z0: 50 }, { Z0: 75 }], 281 reflectTol: 0.001 }; 282 const waves = computeWaveParams(model); 283 const bounce = buildBounceSeries(model, waves); 284 const N = model.segments.length; 285 const eps = 1e-4; 286 287 for (const tNorm of [0.3, 0.6, 1.2, 2.0]) { 288 const dyn = computeDynamicState(tNorm, bounce); 289 for (const zB of [0.25, 0.5, 0.75]) { 290 const vL = totalVoltageAt(zB - eps, dyn.launchedWaves, 0, "step", N); 291 const vB = totalVoltageAt(zB, dyn.launchedWaves, 0, "step", N); 292 const vR = totalVoltageAt(zB + eps, dyn.launchedWaves, 0, "step", N); 293 // No spike: boundary value must lie between its two neighbours (within tolerance). 294 const lo = Math.min(vL, vR) - 1e-6; 295 const hi = Math.max(vL, vR) + 1e-6; 296 assert.ok(vB >= lo && vB <= hi, 297 `spike at z=${zB}, tNorm=${tNorm}: V=${vB}, neighbours ${vL}..${vR}`); 298 } 299 } 300}); 301 302// ──────────────────────────────────────────────────────────────────────────── 303 304// ──────────────────────────────────────────────────────────────────────────── 305// SPICE golden-reference comparisons 306// 307// Common circuit (all netlists in sim/): 308// Z0=50Ω (LTRA l=50nH/m c=20pF/m len=1m → τ_d=1ns) 309// Rg=10Ω (Rs1), Vg=1V PULSE(0 1 0 100p 100p 1u 2u) → TR=100ps=0.1·τ_d 310// 311// TSV columns: t[s] v(a) t[s] v(b) 312// Run simulations: cd sim && sh run.sh (or ngspice <netlist>.sp individually) 313// ──────────────────────────────────────────────────────────────────────────── 314 315// Compare every row of a SPICE TSV against the physics model. 316// Skips automatically if the TSV has not been generated yet. 317function spiceCompare(label, tsvFile, RL, TOL = 1e-4) { 318 const TAU_D = 1e-9; 319 const TR_NORM = 0.1; 320 const tsvPath = path.join(__dirname, "sim", tsvFile); 321 const skip = !existsSync(tsvPath); 322 323 test(`SPICE comparison: ${label}`, { skip: skip ? "TSV not found — run: cd sim && ngspice" : false }, () => { 324 const model = { Vg: 1, Rg: 10, RL, segments: [{ Z0: 50 }], reflectTol: 0.001 }; 325 const waves = computeWaveParams(model); 326 const bounce = buildBounceSeries(model, waves); 327 328 const rows = readFileSync(tsvPath, "utf8").trim().split("\n") 329 .map((line) => line.trim().split(/\s+/).map(Number)) 330 .filter((cols) => cols.length >= 4); 331 assert.ok(rows.length > 10, "TSV should have many rows"); 332 333 for (const [t_s, va_spice, , vb_spice] of rows) { 334 const tn = t_s / TAU_D; 335 const va_model = sumEventsWithLinearRamp(bounce.srcEvents, tn, TR_NORM); 336 const vb_model = sumEventsWithLinearRamp(bounce.loadEvents, tn, TR_NORM); 337 near(va_model, va_spice, `v(a) at t=${t_s.toExponential(3)}s`, TOL); 338 near(vb_model, vb_spice, `v(b) at t=${t_s.toExponential(3)}s`, TOL); 339 } 340 }); 341} 342 343// RL=∞ (open circuit) ΓL=+1, ΓS=−2/3 344spiceCompare("open circuit (tline-oc.sp)", "results-tline-oc.tsv", Infinity); 345// RL=0 (short circuit) ΓL=−1, ΓS=−2/3 346spiceCompare("short circuit (tline-sc.sp)", "results-tline-sc.tsv", 0); 347// RL=100 (resistive load) ΓL=+1/3, ΓS=−2/3 348spiceCompare("resistive load 100Ω (tline-rl.sp)", "results-tline-rl.tsv", 100); 349 350// ──────────────────────────────────────────────────────────────────────────── 351// simulateTimeDomain: validate against buildBounceSeries (resistive cases) 352// 353// Strategy: compare node voltages at the source (node 0) and load (node N) at 354// several tNorm values. We sample at half-integer multiples of τ_d so that 355// we land between bounce events, where both models agree on a constant value. 356// With integer delay steps the MoC simulation is exact for resistive circuits. 357// ──────────────────────────────────────────────────────────────────────────── 358 359const { simulateTimeDomain, voltageAt, segmentsToBlocks } = globalThis.TLPhysics; 360 361// Helper: build a blocks-format model from old-style params and run the sim. 362function makeSim(Vg, Rg, Z0, RL, tEnd = 12, oversample = 16) { 363 const oldModel = { Vg, Rg, RL, segments: [{ Z0 }], reflectTol: 0.001 }; 364 const newModel = segmentsToBlocks(oldModel); 365 return { sim: simulateTimeDomain(newModel, { tEnd, oversample }), oldModel }; 366} 367 368// Compare VS (node 0) and VL (node N) between MoC sim and bounce series. 369function checkSimVsBounce(label, Vg, Rg, Z0, RL, tNorms, tol = 1e-9) { 370 test(`simulateTimeDomain vs bounce: ${label}`, () => { 371 const { sim, oldModel } = makeSim(Vg, Rg, Z0, RL); 372 const waves = computeWaveParams(oldModel); 373 const bounce = buildBounceSeries(oldModel, waves); 374 const { dt, nSteps, nodeV } = sim; 375 for (const tn of tNorms) { 376 const step = Math.round(tn / dt); 377 if (step >= nSteps) continue; 378 near(nodeV[0][step], sumEventsAtTime(bounce.srcEvents, tn), `${label} VS @ tNorm=${tn}`, tol); 379 near(nodeV[1][step], sumEventsAtTime(bounce.loadEvents, tn), `${label} VL @ tNorm=${tn}`, tol); 380 } 381 }); 382} 383 384// Sample at half-integer τ values: between bounce events both models are constant. 385const tSamples = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5]; 386 387checkSimVsBounce("matched (50/50/50)", 1, 50, 50, 50, tSamples); 388checkSimVsBounce("open circuit (50/50/∞)", 1, 50, 50, Infinity, tSamples); 389checkSimVsBounce("short circuit (50/50/0)", 1, 50, 50, 0, tSamples); 390checkSimVsBounce("mismatched Rg=100 RL=200", 1, 100, 50, 200, tSamples); 391checkSimVsBounce("Vg=5 Rg=20 Z0=50 RL=100", 5, 20, 50, 100, tSamples); 392 393test("simulateTimeDomain: 2 equal segments match bounce series VL", () => { 394 const oldModel = { Vg: 1, Rg: 50, RL: 100, segments: [{ Z0: 50 }, { Z0: 50 }], reflectTol: 0.001 }; 395 const newModel = segmentsToBlocks(oldModel); 396 const sim = simulateTimeDomain(newModel, { tEnd: 12, oversample: 16 }); 397 const bounce = buildBounceSeries(oldModel, computeWaveParams(oldModel)); 398 const { dt, nSteps, nodeV } = sim; 399 for (const tn of tSamples) { 400 const step = Math.round(tn / dt); 401 if (step >= nSteps) continue; 402 near(nodeV[2][step], sumEventsAtTime(bounce.loadEvents, tn), `VL @ tNorm=${tn}`, 1e-9); 403 } 404}); 405 406test("simulateTimeDomain: 2 segments Z0=50/75, DC = Vg·RL/(Rg+RL)", () => { 407 const oldModel = { Vg: 1, Rg: 50, RL: 100, segments: [{ Z0: 50 }, { Z0: 75 }], reflectTol: 0.001 }; 408 const newModel = segmentsToBlocks(oldModel); 409 const sim = simulateTimeDomain(newModel, { tEnd: 20, oversample: 8 }); 410 const step = Math.min(Math.round(18 / sim.dt), sim.nSteps - 1); 411 near(sim.nodeV[2][step], 1 * 100 / (50 + 100), "VL DC steady state", 1e-3); 412}); 413 414test("voltageAt: z=0 and z=1 match source/load node voltages", () => { 415 const { sim } = makeSim(1, 50, 50, Infinity); 416 const { dt, nSteps } = sim; 417 for (const tn of [0.5, 1.5, 2.5]) { 418 const step = Math.round(tn / dt); 419 if (step >= nSteps) continue; 420 near(voltageAt(0, step, sim), sim.nodeV[0][step], `z=0 @ tNorm=${tn}`, 1e-9); 421 near(voltageAt(1, step, sim), sim.nodeV[1][step], `z=1 @ tNorm=${tn}`, 1e-9); 422 } 423}); 424 425test("voltageAt: midpoint of matched line = V1 once front has passed (tNorm=0.7)", () => { 426 // Matched line Rg=Z0=RL=50: single rightward wave V1=0.5, no reflections. 427 // At tNorm=0.7 the front is past z=0.5, so V(0.5) = V1 = 0.5. 428 const { sim } = makeSim(1, 50, 50, 50, 12, 16); 429 const step = Math.round(0.7 / sim.dt); 430 near(voltageAt(0.5, step, sim), 0.5, "V(0.5) = 0.5", 1e-9); 431}); 432 433test("voltageAt: midpoint = 0 before wave arrives (tNorm=0.3)", () => { 434 const { sim } = makeSim(1, 50, 50, 50, 12, 16); 435 const step = Math.round(0.3 / sim.dt); 436 near(voltageAt(0.5, step, sim), 0, "V(0.5) = 0 before wave", 1e-9); 437}); 438 439// ──────────────────────────────────────────────────────────────────────────── 440// SPICE comparison: shunt-R at midpoint between two T-line segments 441// 442// Circuit: Rg=10Ω → T1(Z0=50,τ=1ns) → Rshunt=100Ω → T2(Z0=50,τ=1ns) → open 443// Netlist: sim/tline-shunt-r.sp TSV columns: t v(a) t v(b) t v(c) 444// 445// Tolerance is 1e-2 (vs 1e-4 for the bounce-series SPICE tests) because the 446// MoC simulator is discrete-time: linear interpolation between steps introduces 447// a bounded error at ramp knees proportional to dt × slope ≈ 8 mV at oversample=256. 448// This confirms the physics is correct, not floating-point exact. 449// ──────────────────────────────────────────────────────────────────────────── 450// SPICE comparison: shunt-C at midpoint between two T-line segments 451// 452// Circuit: Rg=10Ω → T1(Z0=50,τ=1ns) → C1=2pF → T2(Z0=50,τ=1ns) → open 453// Netlist: sim/tline-shunt-c.sp TSV columns: t v(a) t v(b) t v(c) 454// 455// The 2pF cap creates an RC time constant τ_RC = C * Z_eq = 2pF * 25Ω = 50ps. 456// The SPICE netlist uses tmax=2ps so the adaptive stepper resolves the 50ps RC 457// transient accurately (SPICE error <1mV). The dominant error source is the 458// backward-Euler ODE in the MoC sim: dt_SI/(2·τ_RC) ≈ 1ps/100ps ≈ 1%. 459// With oversample=1024 (dt≈1ps) this bounds the MoC error to ~10mV. 460// ──────────────────────────────────────────────────────────────────────────── 461{ 462 const tsvPath = path.join(__dirname, "sim", "results-tline-shunt-c.tsv"); 463 const skip = !existsSync(tsvPath); 464 465 test("SPICE comparison: shunt-C at midpoint (tline-shunt-c.sp)", 466 { skip: skip ? "TSV not found — run: cd sim && ngspice tline-shunt-c.sp" : false }, 467 () => { 468 const TAU_D = 1e-9; 469 const TR_NORM = 0.1; 470 const TOL = 2e-2; 471 const model = { 472 Vg: 1, Rg: 10, 473 tau_d: TAU_D, 474 riseTimeTr: TR_NORM, riseShape: "linear", 475 blocks: [ 476 { type: "tl", Z0: 50, tau: 1 }, 477 { type: "C", value: 2e-12 }, 478 { type: "tl", Z0: 50, tau: 1 }, 479 ], 480 terminal: { type: "open" }, 481 }; 482 const sim = simulateTimeDomain(model, { tEnd: 14, oversample: 1024 }); 483 const { dt, nSteps, nodeV } = sim; 484 485 // Linear interpolation between adjacent sim steps to reduce quantisation error. 486 function interp(arr, tn) { 487 const f = tn / dt; 488 const k0 = Math.floor(f); 489 const k1 = Math.min(k0 + 1, nSteps - 1); 490 return arr[k0] * (1 - (f - k0)) + arr[k1] * (f - k0); 491 } 492 493 const rows = readFileSync(tsvPath, "utf8").trim().split("\n") 494 .map((line) => line.trim().split(/\s+/).map(Number)) 495 .filter((cols) => cols.length >= 6); 496 assert.ok(rows.length > 10, "TSV should have many rows"); 497 498 // SPICE produced ~7000 rows at 2ps spacing; sample every 25th to keep the 499 // test fast while still covering the full 14ns window. 500 for (let ri = 0; ri < rows.length; ri += 25) { 501 const [t_s, va_spice, , vb_spice, , vc_spice] = rows[ri]; 502 const tn = t_s / TAU_D; 503 near(interp(nodeV[0], tn), va_spice, `v(a) at t=${t_s.toExponential(3)}s`, TOL); 504 near(interp(nodeV[1], tn), vb_spice, `v(b) at t=${t_s.toExponential(3)}s`, TOL); 505 near(interp(nodeV[2], tn), vc_spice, `v(c) at t=${t_s.toExponential(3)}s`, TOL); 506 } 507 } 508 ); 509} 510 511// ──────────────────────────────────────────────────────────────────────────── 512// SPICE comparison: shunt-L at midpoint between two T-line segments 513// 514// Circuit: Rg=10Ω → T1(Z0=50,τ=1ns) → L1=10nH → T2(Z0=50,τ=1ns) → open 515// Netlist: sim/tline-shunt-l.sp TSV columns: t v(a) t v(b) t v(c) 516// 517// L=10nH, Z_eq=25Ω → τ_L = L/Z_eq = 400ps. V_B rises then decays toward 0 518// as the inductor builds up current and shunts the node. 519// ──────────────────────────────────────────────────────────────────────────── 520{ 521 const tsvPath = path.join(__dirname, "sim", "results-tline-shunt-l.tsv"); 522 const skip = !existsSync(tsvPath); 523 524 test("SPICE comparison: shunt-L at midpoint (tline-shunt-l.sp)", 525 { skip: skip ? "TSV not found — run: cd sim && ngspice tline-shunt-l.sp" : false }, 526 () => { 527 const TAU_D = 1e-9; 528 const TR_NORM = 0.1; 529 const TOL = 2e-2; 530 const model = { 531 Vg: 1, Rg: 10, 532 tau_d: TAU_D, 533 riseTimeTr: TR_NORM, riseShape: "linear", 534 blocks: [ 535 { type: "tl", Z0: 50, tau: 1 }, 536 { type: "L", value: 10e-9 }, 537 { type: "tl", Z0: 50, tau: 1 }, 538 ], 539 terminal: { type: "open" }, 540 }; 541 const sim = simulateTimeDomain(model, { tEnd: 14, oversample: 1024 }); 542 const { dt, nSteps, nodeV } = sim; 543 544 function interp(arr, tn) { 545 const f = tn / dt; 546 const k0 = Math.floor(f); 547 const k1 = Math.min(k0 + 1, nSteps - 1); 548 return arr[k0] * (1 - (f - k0)) + arr[k1] * (f - k0); 549 } 550 551 const rows = readFileSync(tsvPath, "utf8").trim().split("\n") 552 .map((line) => line.trim().split(/\s+/).map(Number)) 553 .filter((cols) => cols.length >= 6); 554 assert.ok(rows.length > 10, "TSV should have many rows"); 555 556 for (let ri = 0; ri < rows.length; ri += 25) { 557 const [t_s, va_spice, , vb_spice, , vc_spice] = rows[ri]; 558 const tn = t_s / TAU_D; 559 near(interp(nodeV[0], tn), va_spice, `v(a) at t=${t_s.toExponential(3)}s`, TOL); 560 near(interp(nodeV[1], tn), vb_spice, `v(b) at t=${t_s.toExponential(3)}s`, TOL); 561 near(interp(nodeV[2], tn), vc_spice, `v(c) at t=${t_s.toExponential(3)}s`, TOL); 562 } 563 } 564 ); 565} 566 567// ──────────────────────────────────────────────────────────────────────────── 568{ 569 const tsvPath = path.join(__dirname, "sim", "results-tline-shunt-r.tsv"); 570 const skip = !existsSync(tsvPath); 571 572 test("SPICE comparison: shunt-R at midpoint (tline-shunt-r.sp)", 573 { skip: skip ? "TSV not found — run: cd sim && ngspice tline-shunt-r.sp" : false }, 574 () => { 575 const TAU_D = 1e-9; 576 const TR_NORM = 0.1; 577 const TOL = 1e-2; 578 const model = { 579 Vg: 1, Rg: 10, 580 riseTimeTr: TR_NORM, riseShape: "linear", 581 blocks: [ 582 { type: "tl", Z0: 50, tau: 1 }, 583 { type: "R", value: 100 }, 584 { type: "tl", Z0: 50, tau: 1 }, 585 ], 586 terminal: { type: "open" }, 587 }; 588 const sim = simulateTimeDomain(model, { tEnd: 14, oversample: 256 }); 589 const { dt, nSteps, nodeV } = sim; 590 591 // Linear interpolation between adjacent sim steps to reduce quantisation error. 592 function interp(arr, tn) { 593 const f = tn / dt; 594 const k0 = Math.floor(f); 595 const k1 = Math.min(k0 + 1, nSteps - 1); 596 return arr[k0] * (1 - (f - k0)) + arr[k1] * (f - k0); 597 } 598 599 const rows = readFileSync(tsvPath, "utf8").trim().split("\n") 600 .map((line) => line.trim().split(/\s+/).map(Number)) 601 .filter((cols) => cols.length >= 6); 602 assert.ok(rows.length > 10, "TSV should have many rows"); 603 604 for (const [t_s, va_spice, , vb_spice, , vc_spice] of rows) { 605 const tn = t_s / TAU_D; 606 near(interp(nodeV[0], tn), va_spice, `v(a) at t=${t_s.toExponential(3)}s`, TOL); 607 near(interp(nodeV[1], tn), vb_spice, `v(b) at t=${t_s.toExponential(3)}s`, TOL); 608 near(interp(nodeV[2], tn), vc_spice, `v(c) at t=${t_s.toExponential(3)}s`, TOL); 609 } 610 } 611 ); 612}