this repo has no description
1"use strict";
2
3// Transmission line physics — pure functions, no DOM or canvas dependencies.
4//
5// Spatial convention: z ∈ [0, 1] maps to [0, ℓ].
6// Time convention: tNorm is dimensionless time in units of τ_d (one-way delay).
7//
8// Single-segment model (N=1):
9// model.segments = [{Z0: 50}] — one segment, the whole line.
10// Degenerates exactly to the original two-terminal bounce series.
11//
12// Multi-segment model (N>1):
13// model.segments = [{Z0: Z1}, {Z0: Z2}, …] — N equal-length segments.
14// Each internal boundary produces a reflected and a transmitted wave (lattice diagram).
15// buildBounceSeries uses a BFS priority queue ordered by tDie.
16//
17// Wave packet struct (new):
18// { n, A, dir, zStart, zEnd, tBorn, tDie, segIdx }
19// n — sequential index (for display)
20// A — voltage amplitude
21// dir — +1 rightward, -1 leftward
22// zStart — z where this packet's front begins its journey
23// zEnd — z where this packet's front ends (next boundary, or 0/1)
24// tBorn — tNorm when the front was at zStart
25// tDie — tNorm when the front reaches zEnd (= tBorn + |zEnd − zStart|)
26// segIdx — index into model.segments for the segment this packet lives in
27//
28// After computeDynamicState, each wave also carries:
29// u — tNorm − tBorn (age of the packet)
30// front — current position of the leading edge ∈ [zStart, zEnd] (or reversed for leftward)
31//
32// Extension points:
33// • Rise time (causal shifted erf or linear ramp): model.riseTimeTr > 0 — see riseShape/waveVoltageAt.
34// • Non-equal segment lengths: store lengths[] summing to 1; the BFS solver already works
35// by position, so changing zBnds is all that's needed.
36
37const TLPhysics = (() => {
38 const BOUNCE_EPS = 1e-6;
39 const MAX_BOUNCES = 5000;
40
41 // ---- reflection coefficients and initial voltage step ----
42 // Uses the first segment's Z0 for the source divider and source-end Γ,
43 // and the last segment's Z0 for the load-end Γ.
44 function computeWaveParams(model) {
45 const { Vg, Rg, RL } = model;
46 const Z0_src = model.segments[0].Z0;
47 const Z0_load = model.segments[model.segments.length - 1].Z0;
48 const V1 = Vg * Z0_src / (Rg + Z0_src);
49 const gL = isFinite(RL) ? (RL - Z0_load) / (RL + Z0_load) : 1; // RL=∞ → open circuit
50 const gS = (Rg - Z0_src) / (Rg + Z0_src);
51 return { V1, gL, gS };
52 }
53
54 // ---- BFS lattice-diagram solver ----
55 // Builds the full set of wave packets produced by successive reflections and
56 // transmissions at every impedance boundary (source, internal, load).
57 //
58 // Returns {
59 // series: [{n, A, dir, zStart, zEnd, tBorn, tDie, segIdx}]
60 // srcEvents: [{t, dV}] — cumulative voltage steps at z=0 (VS)
61 // loadEvents: [{t, dV}] — cumulative voltage steps at z=ℓ (VL)
62 // gSEffective, tolPct, ampTol, tEnd
63 // }
64 function buildBounceSeries(model, waves) {
65 const segs = model.segments;
66 const N = segs.length;
67 const segLen = 1 / N;
68 // z positions of all N+1 boundaries: [0, 1/N, 2/N, ..., 1]
69 const zBnds = Array.from({ length: N + 1 }, (_, i) => i * segLen);
70
71 const gS = waves.gS;
72 const gL = waves.gL;
73 const tolPct = Number.isFinite(model.reflectTol) ? model.reflectTol : 1;
74 const ampTol = Math.max(BOUNCE_EPS, Math.abs(waves.V1) * (tolPct / 100));
75
76 const series = [];
77 const srcEvents = [{ t: 0, dV: waves.V1 }]; // initial step seen at source at t=0
78 const loadEvents = [];
79
80 // Queue of pending packets; each entry is a wave packet description.
81 // We process in order of tDie (earliest-completing first).
82 const queue = [];
83
84 function enqueue(pkt) {
85 if (Math.abs(pkt.A) <= ampTol) return;
86 if (series.length + queue.length >= MAX_BOUNCES) return;
87 queue.push(pkt);
88 }
89
90 // Seed: first incident wave in segment 0, rightward from z=0 to z=zBnds[1].
91 enqueue({ A: waves.V1, dir: +1, zStart: 0, zEnd: zBnds[1], tBorn: 0, segIdx: 0 });
92
93 while (queue.length > 0) {
94 // Pop earliest-completing packet (smallest tDie = tBorn + |zEnd − zStart|).
95 queue.sort((a, b) =>
96 (a.tBorn + Math.abs(a.zEnd - a.zStart)) -
97 (b.tBorn + Math.abs(b.zEnd - b.zStart))
98 );
99 const pkt = queue.shift();
100 const { A, dir, zStart, zEnd, tBorn, segIdx } = pkt;
101 const tDie = tBorn + Math.abs(zEnd - zStart);
102 const n = series.length + 1;
103 series.push({ n, A, dir, zStart, zEnd, tBorn, tDie, segIdx });
104
105 const atSource = zEnd < 1e-9;
106 const atLoad = zEnd > 1 - 1e-9;
107
108 if (atLoad) {
109 // Load terminal: add load event, spawn reflected wave back in last segment.
110 loadEvents.push({ t: tDie, dV: (1 + gL) * A });
111 enqueue({
112 A: gL * A, dir: -1,
113 zStart: 1, zEnd: zBnds[N - 1],
114 tBorn: tDie, segIdx: N - 1,
115 });
116 } else if (atSource) {
117 // Source terminal: add source event, spawn reflected wave into first segment.
118 srcEvents.push({ t: tDie, dV: (1 + gS) * A });
119 enqueue({
120 A: gS * A, dir: +1,
121 zStart: 0, zEnd: zBnds[1],
122 tBorn: tDie, segIdx: 0,
123 });
124 } else {
125 // Internal boundary between two segments.
126 // For a rightward wave in segIdx: left=segIdx, right=segIdx+1.
127 // For a leftward wave in segIdx: left=segIdx-1, right=segIdx.
128 const leftSeg = dir > 0 ? segIdx : segIdx - 1;
129 const rightSeg = dir > 0 ? segIdx + 1 : segIdx;
130 const Zl = segs[leftSeg].Z0;
131 const Zr = segs[rightSeg].Z0;
132 // Γ for a wave arriving from the left side of this boundary:
133 const gBound = (Zr - Zl) / (Zr + Zl);
134
135 if (dir > 0) {
136 // Rightward hitting boundary from left:
137 // reflected → Γ·A leftward, back into leftSeg
138 // transmitted → (1+Γ)·A rightward, into rightSeg
139 enqueue({
140 A: gBound * A, dir: -1,
141 zStart: zEnd, zEnd: zBnds[leftSeg],
142 tBorn: tDie, segIdx: leftSeg,
143 });
144 enqueue({
145 A: (1 + gBound) * A, dir: +1,
146 zStart: zEnd, zEnd: zBnds[rightSeg + 1],
147 tBorn: tDie, segIdx: rightSeg,
148 });
149 } else {
150 // Leftward hitting boundary from right:
151 // reflected → −Γ·A rightward, back into rightSeg
152 // transmitted → (1−Γ)·A leftward, into leftSeg
153 enqueue({
154 A: -gBound * A, dir: +1,
155 zStart: zEnd, zEnd: zBnds[rightSeg + 1],
156 tBorn: tDie, segIdx: rightSeg,
157 });
158 enqueue({
159 A: (1 - gBound) * A, dir: -1,
160 zStart: zEnd, zEnd: zBnds[leftSeg],
161 tBorn: tDie, segIdx: leftSeg,
162 });
163 }
164 }
165 }
166
167 const tEnd = series.reduce((m, w) => Math.max(m, w.tDie), 2);
168 return { series, srcEvents, loadEvents, gSEffective: gS, tolPct, ampTol, tEnd };
169 }
170
171 // ---- time query: node voltage (step model) ----
172 function sumEventsAtTime(events, tn) {
173 const eps = 1e-9;
174 let v = 0;
175 for (const e of events) {
176 if (tn >= e.t - eps) v += e.dV;
177 }
178 return v;
179 }
180
181 // ---- rise-time wave shapes ----
182 // Fraction of final amplitude reached after time dt since the wave front passed.
183
184 // Abramowitz & Stegun 7.1.26 rational approximation (max error < 1.5e-7).
185 function erf(x) {
186 const sign = x < 0 ? -1 : 1;
187 x = Math.abs(x);
188 const t = 1 / (1 + 0.3275911 * x);
189 const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x);
190 return sign * y;
191 }
192
193 // Causal erf S-curve: tr = 0 → hard step. tr > 0 → smooth S from 0 to 1 over [0, tr].
194 // Uses shifted erf: 0.5·(1 + erf(k·(2·dt/tr − 1))) with k chosen so that
195 // riseShape(0, tr) ≈ 0 and riseShape(tr, tr) ≈ 1. k = 1.8 gives < 0.1% undershoot.
196 // Causal: strictly 0 for dt ≤ 0, strictly 1 for dt ≥ tr.
197 const ERF_K = 1.8;
198 function riseShape(dt, tr) {
199 if (dt <= 0) return 0;
200 if (tr <= 0 || dt >= tr) return 1;
201 return 0.5 * (1 + erf(ERF_K * (2 * dt / tr - 1)));
202 }
203
204 // Linear ramp (trapezoidal / SPICE PULSE): tr = 0 → hard step. tr > 0 → dt/tr clamped to [0,1].
205 function riseShapeLinear(dt, tr) {
206 if (dt <= 0) return 0;
207 if (tr <= 0 || dt >= tr) return 1;
208 return dt / tr;
209 }
210
211 // Voltage contribution of a single wave packet wf at position z.
212 // wf must carry the fields added by computeDynamicState: u, front.
213 // Returns 0 if z is outside this packet's z-range or ahead of the front.
214 // shape: "linear" → riseShapeLinear, "erf" → riseShape (causal shifted erf).
215 // tr: rise time in τ_d units (used by both shapes).
216 function waveVoltageAt(wf, z, tr = 0, shape = "erf") {
217 const { dir, zStart, A, u, front } = wf;
218 const eps = 1e-9;
219 const rise = shape === "linear" ? riseShapeLinear : riseShape;
220 if (dir > 0) {
221 // Rightward: nonzero in [zStart, front].
222 if (z < zStart - eps || z > front + eps) return 0;
223 const dt = u - (z - zStart);
224 return A * rise(dt, tr);
225 } else {
226 // Leftward: nonzero in [front, zStart].
227 if (z > zStart + eps || z < front - eps) return 0;
228 const dt = u - (zStart - z);
229 return A * rise(dt, tr);
230 }
231 }
232
233 // Map z ∈ [0,1] to the index of the segment that owns it.
234 // Boundaries are assigned to the right-hand (higher-index) segment; z=1 goes to the last.
235 // The +1e-9 nudge handles floating-point imprecision when z is exactly i/N.
236 function segmentForZ(z, N) {
237 if (z >= 1 - 1e-9) return N - 1;
238 return Math.min(N - 1, Math.floor(z * N + 1e-9));
239 }
240
241 // Sum of all wave contributions at position z.
242 // For multi-segment models (N > 1), only waves whose segIdx matches the segment
243 // that owns z are summed. This prevents double-counting at segment boundaries
244 // where adjacent waves share a z value (a plotting artifact, not a physics error).
245 function totalVoltageAt(z, launchedWaves, tr = 0, shape = "erf", N = 1) {
246 const segIdx = N > 1 ? segmentForZ(z, N) : 0;
247 let V = 0;
248 for (const wf of launchedWaves) {
249 if (N > 1 && wf.segIdx !== segIdx) continue;
250 V += waveVoltageAt(wf, z, tr, shape);
251 }
252 return V;
253 }
254
255 // Node voltage as smooth sum of causal erf-rise events.
256 function sumEventsWithRise(events, tn, tr) {
257 let v = 0;
258 for (const e of events) {
259 const dt = tn - e.t;
260 if (dt > 0) v += e.dV * riseShape(dt, tr);
261 }
262 return v;
263 }
264
265 // Node voltage as smooth sum of linear-ramp events (matches SPICE PULSE with finite TR).
266 function sumEventsWithLinearRamp(events, tn, tr) {
267 let v = 0;
268 for (const e of events) {
269 const dt = tn - e.t;
270 if (dt > 0) v += e.dV * riseShapeLinear(dt, tr);
271 }
272 return v;
273 }
274
275 // ---- dynamic state at a given tNorm ----
276 // Annotates each launched wave with:
277 // u: tNorm − tBorn
278 // front: current leading-edge position
279 // A wave is "active" while u < |zEnd − zStart| (front still in transit).
280 // shape: "step" | "linear" | "erf"
281 function computeDynamicState(tn, bounce, riseTimeTr = 0, riseShape_ = "erf") {
282 const { clamp } = TLUtils;
283 const launchedWaves = [];
284 const activeWaves = [];
285
286 for (const w of bounce.series) {
287 const u = tn - w.tBorn;
288 if (u < 0) continue;
289 let front;
290 if (w.dir > 0) {
291 front = clamp(w.zStart + u, w.zStart, w.zEnd);
292 } else {
293 front = clamp(w.zStart - u, w.zEnd, w.zStart);
294 }
295 const ww = { ...w, u, front };
296 launchedWaves.push(ww);
297 if (u < Math.abs(w.zEnd - w.zStart) - 1e-9) activeWaves.push(ww);
298 }
299
300 const sumNode = (events) => {
301 if (riseTimeTr <= 0) return sumEventsAtTime(events, tn);
302 if (riseShape_ === "linear") return sumEventsWithLinearRamp(events, tn, riseTimeTr);
303 return sumEventsWithRise(events, tn, riseTimeTr);
304 };
305
306 return {
307 launchedWaves,
308 activeWaves,
309 VS: sumNode(bounce.srcEvents),
310 VL: sumNode(bounce.loadEvents),
311 };
312 }
313
314 // ---- Method of Characteristics time-domain simulator ----
315 //
316 // model.blocks = [
317 // { type: 'tl', Z0, tau }, // T-line segment; tau = one-way delay in τ_d units
318 // { type: 'R', value }, // shunt resistor to ground (Ω)
319 // { type: 'C', value }, // shunt capacitor (F, SI) — Step 3
320 // { type: 'L', value }, // shunt inductor (H, SI) — Step 4
321 // { type: 'short' }, // shunt short to ground
322 // ]
323 // model.terminal = { type: 'R'|'open'|'short'|'C'|'L', value }
324 // model.Rg, model.Vg, model.riseTimeTr, model.riseShape: unchanged
325 //
326 // Consecutive shunt blocks (no 'tl' between them) share one electrical node;
327 // their admittances accumulate in the node equation.
328 // Consecutive 'tl' blocks share a pure impedance-mismatch boundary (no shunt).
329 //
330 // opts: { tEnd, oversample }
331 // tEnd — simulation end time in τ_d units (default 10)
332 // oversample — delay steps per shortest segment (default 8)
333 //
334 // Returns { dt, nSteps, tGrid, segs, nDelay, nodeV, vPlusHist, vMinusHist }
335 // dt — time step in τ_d units
336 // tGrid — Float64Array of length nSteps: tGrid[k] = k * dt
337 // segs — [{Z0, tau}] after parsing (T-line segments only)
338 // nDelay — Int32Array: nDelay[i] = delay of segment i in time steps
339 // nodeV — (N+1) × nSteps: nodeV[j][k] = voltage at node j at step k
340 // vPlusHist — N × nSteps: V⁺ written into left end of segment i at step k
341 // vMinusHist — N × nSteps: V⁻ written into right end of segment i at step k
342 //
343 // Spatial query — see voltageAt().
344 function simulateTimeDomain(model, opts) {
345 const { Vg: Vg_final, Rg } = model;
346 const tEnd_ = (opts && opts.tEnd) != null ? opts.tEnd : 10;
347 const oversample = (opts && opts.oversample) != null ? opts.oversample : 8;
348
349 // --- parse model.blocks into T-line segments + per-node shunt lists ---
350 // nodeShunts[j] are the shunt elements sitting at node j.
351 // Node j sits between segment j-1 (on the left) and segment j (on the right).
352 // Node 0 = source side; node N = load side.
353 const segs = []; // {Z0, tau}
354 const nodeShunts = []; // nodeShunts[j] = [{type, value?}]
355 let pending = []; // shunts accumulating until the next 'tl' block
356 for (const blk of model.blocks) {
357 if (blk.type === 'tl') {
358 nodeShunts.push(pending);
359 pending = [];
360 segs.push({ Z0: blk.Z0, tau: blk.tau });
361 } else {
362 pending.push(blk);
363 }
364 }
365 nodeShunts.push(pending); // shunts at load-side node (right of last T-line)
366 const N = segs.length;
367 if (N === 0) throw new Error('simulateTimeDomain: no tl blocks in model.blocks');
368
369 // --- time step and per-segment delay lengths ---
370 // dt chosen so the shortest segment spans exactly `oversample` steps.
371 const minTau = segs.reduce((m, s) => Math.min(m, s.tau), Infinity);
372 const dt = minTau / oversample;
373 const nSteps = Math.ceil(tEnd_ / dt) + 1;
374 // dt_SI: real-time duration of one time step (seconds).
375 // model.tau_d must be provided (SI seconds per tau unit) when any shunt C or L
376 // is present; for purely resistive circuits it is unused (default 1 is harmless).
377 const tau_d_SI = model.tau_d != null ? model.tau_d : 1;
378 const dt_SI = dt * tau_d_SI;
379 // Each segment rounded to the nearest integer number of steps (≥ 1).
380 const nDelay = new Int32Array(N);
381 for (let i = 0; i < N; i++) nDelay[i] = Math.max(1, Math.round(segs[i].tau / dt));
382
383 // --- ring buffers ---
384 // delayFwd[i]: V⁺ entering left end of segment i.
385 // Written at step k, read (= arrives at right end) at step k + nDelay[i].
386 // delayBwd[i]: V⁻ entering right end of segment i.
387 // Written at step k, arrives at left end at step k + nDelay[i].
388 // Both use the same ring-buffer idiom: fwdIdx[i] / bwdIdx[i] is the current
389 // write position; that same position holds the oldest (= arriving) value.
390 const delayFwd = Array.from({ length: N }, (_, i) => new Float64Array(nDelay[i]));
391 const delayBwd = Array.from({ length: N }, (_, i) => new Float64Array(nDelay[i]));
392 const fwdIdx = new Int32Array(N);
393 const bwdIdx = new Int32Array(N);
394
395 // --- reactive state per node (C: vCap; L: iL) ---
396 // Each element of nodeShuntState[j] is a mutable object parallel to nodeShunts[j].
397 const nodeShuntState = nodeShunts.map(shunts =>
398 shunts.map(sh => ({ vCap: 0, iL: 0 }))
399 );
400 const termState = { vCap: 0, iL: 0 };
401
402 // --- output storage ---
403 const nodeV = Array.from({ length: N + 1 }, () => new Float64Array(nSteps));
404 const vPlusHist = Array.from({ length: N }, () => new Float64Array(nSteps));
405 const vMinusHist = Array.from({ length: N }, () => new Float64Array(nSteps));
406
407 // --- source waveform ---
408 const riseTimeTr_ = model.riseTimeTr || 0;
409 const riseMode_ = model.riseShape || 'step';
410 function vgAt(tn) {
411 if (riseTimeTr_ <= 0) return tn > -1e-12 ? Vg_final : 0;
412 if (riseMode_ === 'linear') return Vg_final * riseShapeLinear(tn, riseTimeTr_);
413 return Vg_final * riseShape(tn, riseTimeTr_);
414 }
415
416 // --- helper: accumulate shunt contributions into node equation ---
417 // Each shunt contributes conductance G and Norton current I_src such that:
418 // V * (G_base + ΣG) = I_base + ΣI_src
419 // where G_base / I_base come from the Thevenin equivalent(s) of adjacent T-lines.
420 // Returns true if the node is hard-shorted to ground.
421 function accumulateShunts(shunts, states, Vnode_prev_fn) {
422 let G = 0, I = 0, shorted = false;
423 for (let s = 0; s < shunts.length; s++) {
424 const sh = shunts[s], st = states[s];
425 if (sh.type === 'R') {
426 G += 1 / sh.value;
427 } else if (sh.type === 'C') {
428 const Gc = sh.value / dt_SI;
429 G += Gc;
430 I += Gc * st.vCap;
431 } else if (sh.type === 'L') {
432 const Gl = dt_SI / sh.value;
433 G += Gl;
434 I -= st.iL; // see derivation: inductor history current subtracts
435 } else if (sh.type === 'short') {
436 shorted = true;
437 }
438 }
439 return { G, I, shorted };
440 }
441
442 function updateShuntStates(shunts, states, V) {
443 for (let s = 0; s < shunts.length; s++) {
444 const sh = shunts[s], st = states[s];
445 if (sh.type === 'C') st.vCap = V;
446 if (sh.type === 'L') st.iL += (dt_SI / sh.value) * V;
447 }
448 }
449
450 // --- main simulation loop ---
451 const vArr = {
452 right: new Float64Array(N), // V⁺ arriving at right end of each segment
453 left: new Float64Array(N), // V⁻ arriving at left end of each segment
454 };
455 const Vnodes = new Float64Array(N + 1);
456
457 for (let step = 0; step < nSteps; step++) {
458 const tn = step * dt;
459
460 // 1. Read arriving waves from delay lines.
461 for (let i = 0; i < N; i++) {
462 vArr.right[i] = delayFwd[i][fwdIdx[i]];
463 vArr.left[i] = delayBwd[i][bwdIdx[i]];
464 }
465
466 // 2. Solve node voltages.
467
468 // Source node (j=0): Thevenin from {Vg, Rg} on left; segment 0 on right.
469 // KCL: (vg - V)/Rg + (2·aR - V)/Z0 = 0 → V = (vg·Z0 + 2·aR·Rg)/(Rg + Z0)
470 // (Source-node shunts, if any, are rare but handled.)
471 {
472 const vg = vgAt(tn);
473 const aR = vArr.left[0]; // V⁻ arriving at left end of seg 0
474 const Z0 = segs[0].Z0;
475 let G = 1 / Rg + 1 / Z0;
476 let I = vg / Rg + 2 * aR / Z0;
477 const { G: Gs, I: Is, shorted } = accumulateShunts(
478 nodeShunts[0], nodeShuntState[0]);
479 if (shorted) {
480 Vnodes[0] = 0;
481 } else {
482 Vnodes[0] = (I + Is) / (G + Gs);
483 }
484 updateShuntStates(nodeShunts[0], nodeShuntState[0], Vnodes[0]);
485 }
486
487 // Internal nodes (j = 1 … N-1).
488 for (let j = 1; j < N; j++) {
489 const ZL = segs[j - 1].Z0;
490 const ZR = segs[j].Z0;
491 const aL = vArr.right[j - 1]; // V⁺ arriving at right end of seg j-1
492 const aR = vArr.left[j]; // V⁻ arriving at left end of seg j
493 let G = 1 / ZL + 1 / ZR;
494 let I = 2 * aL / ZL + 2 * aR / ZR;
495 const { G: Gs, I: Is, shorted } = accumulateShunts(
496 nodeShunts[j], nodeShuntState[j]);
497 if (shorted) {
498 Vnodes[j] = 0;
499 } else {
500 Vnodes[j] = (I + Is) / (G + Gs);
501 }
502 updateShuntStates(nodeShunts[j], nodeShuntState[j], Vnodes[j]);
503 }
504
505 // Load node (j=N): segment N-1 on left; terminal on right.
506 {
507 const aL = vArr.right[N - 1]; // V⁺ arriving at right end of last seg
508 const Z0 = segs[N - 1].Z0;
509 const term = model.terminal;
510 // First apply any mid-node shunts at the load-side node (nodeShunts[N]).
511 let G = 1 / Z0;
512 let I = 2 * aL / Z0;
513 const { G: Gs, I: Is, shorted: sh0 } = accumulateShunts(
514 nodeShunts[N], nodeShuntState[N]);
515 if (sh0) {
516 Vnodes[N] = 0;
517 } else {
518 // Then add the terminal element.
519 let Gt = 0, It = 0, shorted = false;
520 if (term.type === 'open') {
521 // no terminal conductance
522 } else if (term.type === 'short') {
523 shorted = true;
524 } else if (term.type === 'R') {
525 Gt = 1 / term.value;
526 } else if (term.type === 'C') {
527 Gt = term.value / dt_SI;
528 It = Gt * termState.vCap;
529 } else if (term.type === 'L') {
530 Gt = dt_SI / term.value;
531 It = -termState.iL;
532 }
533 Vnodes[N] = shorted ? 0 : (I + Is + It) / (G + Gs + Gt);
534 }
535 updateShuntStates(nodeShunts[N], nodeShuntState[N], Vnodes[N]);
536 if (term.type === 'C') termState.vCap = Vnodes[N];
537 if (term.type === 'L') termState.iL += (dt_SI / term.value) * Vnodes[N];
538 }
539
540 // 3. Compute outgoing waves and write into delay lines.
541 // At node j: V = V⁺_out + V⁻_in → V⁺_out = V - V⁻_in (outgoing into seg j rightward)
542 // At node j: V = V⁺_in + V⁻_out → V⁻_out = V - V⁺_in (outgoing into seg j-1 leftward)
543 for (let i = 0; i < N; i++) {
544 const vOutFwd = Vnodes[i] - vArr.left[i]; // V⁺ leaving node i into seg i
545 const vOutBwd = Vnodes[i + 1] - vArr.right[i]; // V⁻ leaving node i+1 into seg i
546 vPlusHist[i][step] = vOutFwd;
547 vMinusHist[i][step] = vOutBwd;
548 delayFwd[i][fwdIdx[i]] = vOutFwd;
549 delayBwd[i][bwdIdx[i]] = vOutBwd;
550 fwdIdx[i] = (fwdIdx[i] + 1) % nDelay[i];
551 bwdIdx[i] = (bwdIdx[i] + 1) % nDelay[i];
552 }
553
554 // 4. Record node voltages.
555 for (let j = 0; j <= N; j++) nodeV[j][step] = Vnodes[j];
556 }
557
558 const tGrid = Float64Array.from({ length: nSteps }, (_, k) => k * dt);
559 return { dt, nSteps, tGrid, segs, nDelay, nodeV, vPlusHist, vMinusHist };
560 }
561
562 // ---- Spatial voltage query for simulateTimeDomain output ----
563 //
564 // Returns V(z, tIdx) by reading from vPlusHist / vMinusHist at the appropriate
565 // delay offsets. z ∈ [0, 1] is normalized by total T-line delay (sum of taus).
566 //
567 // Within segment i, at fractional position f ∈ [0, 1]:
568 // V⁺ that is at position f at step tIdx was written into the segment
569 // kFwd = round(f * D) steps ago → look up vPlusHist[i][tIdx - kFwd]
570 // V⁻ that is at position f at step tIdx was written into the segment
571 // kBwd = round((1-f) * D) steps ago → look up vMinusHist[i][tIdx - kBwd]
572 function voltageAt(z, tIdx, sim) {
573 const { segs, nDelay, vPlusHist, vMinusHist, nodeV } = sim;
574 const N = segs.length;
575 const totalTau = segs.reduce((s, seg) => s + seg.tau, 0);
576 let cumZ = 0;
577 for (let i = 0; i < N; i++) {
578 const segZ = segs[i].tau / totalTau;
579 if (z <= cumZ + segZ + 1e-9) {
580 const f = Math.min(1, Math.max(0, (z - cumZ) / segZ));
581 const D = nDelay[i];
582 const kFwd = Math.round(f * D);
583 const kBwd = D - kFwd;
584 const tF = tIdx - kFwd;
585 const tB = tIdx - kBwd;
586 const vP = tF >= 0 ? vPlusHist[i][tF] : 0;
587 const vM = tB >= 0 ? vMinusHist[i][tB] : 0;
588 return vP + vM;
589 }
590 cumZ += segZ;
591 }
592 // z at or past the load end: return load node voltage
593 return nodeV[N][tIdx];
594 }
595
596 // ---- Convenience: convert old-style model (model.segments, model.RL) to blocks ----
597 // This lets existing callers construct a blocks-based model without rewriting app.js.
598 function segmentsToBlocks(model) {
599 const N = model.segments.length;
600 const tau = 1 / N; // each segment = 1/N of total normalized delay
601 const blocks = model.segments.map(seg => ({ type: 'tl', Z0: seg.Z0, tau }));
602 const RL = model.RL;
603 const terminal = isFinite(RL)
604 ? { type: 'R', value: RL }
605 : { type: 'open' };
606 return { ...model, blocks, terminal };
607 }
608
609 return {
610 BOUNCE_EPS,
611 MAX_BOUNCES,
612 computeWaveParams,
613 buildBounceSeries,
614 sumEventsAtTime,
615 sumEventsWithRise,
616 sumEventsWithLinearRamp,
617 riseShape,
618 riseShapeLinear,
619 waveVoltageAt,
620 totalVoltageAt,
621 segmentForZ,
622 computeDynamicState,
623 simulateTimeDomain,
624 voltageAt,
625 segmentsToBlocks,
626 };
627})();