this repo has no description
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);