this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Multi-segment T-line, termination rendering, tests

Physics:
- Refactor physics.js to BFS lattice-diagram solver supporting N equal-length
segments with different Z0 values; each internal boundary generates reflected
and transmitted waves
- Add exponential rise time (RC): model.riseTimeTau > 0 gives
V = V_final·(1−exp(−Δt/τ_r)) wave shape
- Guard gL against RL=Infinity so open circuit (gL=1) works correctly

UI:
- Segment count input + per-segment Z0 inputs (up to 10 segments)
- ∞ toggle button on RL field for open circuit; disables number input
and sets RL=Infinity in the model
- Remove scrub feature

Circuit diagram:
- Draw open-circuit terminals (two filled dots) when RL=Infinity
- Draw short-circuit wire when RL=0
- Add missing top terminal dot at VL port; consistent rail length
across all three termination types

Tests & docs:
- physics.test.js: smoke tests for pure physics functions
(22 cases, node --test, zero dependencies)
- README.md: usage and how to run tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+629 -220
+15
README.md
··· 1 + # tline-viz 2 + 3 + Interactive visualization of voltage waves on an ideal lossless transmission line. 4 + Open `tline_viz.html` directly in a browser — no build step, no server required. 5 + 6 + ## Running tests 7 + 8 + Requires Node.js 18+. 9 + 10 + ``` 11 + node --test physics.test.js 12 + ``` 13 + 14 + Tests cover the pure physics functions in `physics.js`: reflection coefficients, 15 + bounce series construction, DC steady-state convergence, and rise-time helpers.
+104 -52
app.js
··· 14 14 startBtn: document.getElementById("startBtn"), 15 15 pauseBtn: document.getElementById("pauseBtn"), 16 16 resetBtn: document.getElementById("resetBtn"), 17 - scrubToggle: document.getElementById("scrubToggle"), 18 17 Vg: document.getElementById("Vg"), 19 18 Rg: document.getElementById("Rg"), 20 - Z0: document.getElementById("Z0"), 21 19 RL: document.getElementById("RL"), 20 + RLOpen: document.getElementById("RLOpen"), 21 + segCount: document.getElementById("segCount"), 22 + segZ0List: document.getElementById("segZ0List"), 22 23 secPerTau: document.getElementById("secPerTau"), 23 24 secPerTauRead: document.getElementById("secPerTauRead"), 24 25 reflectTol: document.getElementById("reflectTol"), ··· 33 34 }; 34 35 35 36 // ---- model (physics parameters) ---- 36 - // All entries here correspond to user-adjustable controls. 37 37 const model = { 38 38 Vg: 5, 39 39 Rg: 20, 40 - Z0: 50, 40 + segments: [{ Z0: 50 }], // array of N equal-length segments, each with a Z0 41 41 RL: 30, 42 - secPerTau: 2.5, // animation speed: real seconds per τ_d 43 - reflectTol: 1, // % of |V1| below which reflections are dropped 44 - riseTimeTau: 0, // 0 = hard step; > 0 = RC rise time in units of τ_d 42 + secPerTau: 2.5, 43 + reflectTol: 1, 44 + riseTimeTau: 0, 45 45 }; 46 46 47 + // ---- segment input management ---- 48 + let segZ0Inputs = []; 49 + 50 + function buildSegmentInputs(n) { 51 + // Preserve existing values where possible. 52 + const prev = segZ0Inputs.map((inp) => parseFloat(inp.value) || 50); 53 + el.segZ0List.innerHTML = ""; 54 + segZ0Inputs = []; 55 + for (let i = 0; i < n; i++) { 56 + const val = (prev[i] != null) ? prev[i] : 50; 57 + const inp = document.createElement("input"); 58 + inp.type = "number"; 59 + inp.min = "0.1"; 60 + inp.step = "0.1"; 61 + inp.value = val; 62 + inp.title = `Z\u2080 of segment ${i + 1} (\u03A9)`; 63 + inp.addEventListener("input", () => { if (!running) render(); }); 64 + inp.addEventListener("change", () => { if (!running) render(); }); 65 + el.segZ0List.appendChild(inp); 66 + segZ0Inputs.push(inp); 67 + } 68 + } 69 + 70 + // Initialise with N=1. 71 + buildSegmentInputs(1); 72 + 73 + // ---- open-circuit toggle state ---- 74 + let rlIsOpen = false; 75 + 47 76 // ---- animation state ---- 48 77 let running = false; 49 78 let hasStarted = false; 50 - let tNorm = 0; // current time in units of τ_d 51 - let lastTS = null; // timestamp from previous rAF tick 52 - let timeHorizon = 2.2; // animation end time (updated each frame from bounce series) 79 + let tNorm = 0; 80 + let lastTS = null; 81 + let timeHorizon = 2.2; 53 82 54 83 let mathjaxTypesetDone = false; 55 84 let theme = getTheme(); ··· 58 87 function syncModelFromInputs() { 59 88 model.Vg = parseFloat(el.Vg.value); 60 89 model.Rg = parseFloat(el.Rg.value); 61 - model.Z0 = parseFloat(el.Z0.value); 62 - model.RL = parseFloat(el.RL.value); 90 + model.RL = rlIsOpen ? Infinity : parseFloat(el.RL.value); 63 91 model.secPerTau = parseFloat(el.secPerTau.value); 64 92 model.reflectTol = Math.max(0, parseFloat(el.reflectTol.value)); 65 93 model.riseTimeTau = el.riseMode.value === "exp" 66 94 ? Math.max(0.001, parseFloat(el.riseTau.value) || 0.1) 67 95 : 0; 96 + model.segments = segZ0Inputs.map((inp) => ({ 97 + Z0: Math.max(0.1, parseFloat(inp.value) || 50), 98 + })); 68 99 } 69 100 70 101 // ---- derived-value readout panel ---- 71 - function updateDerivedDisplays(waves, bounce) { 72 - const gSEffective = bounce.gSEffective; 73 - const V2calc = waves.gL * waves.V1; 74 - const V3calc = gSEffective * V2calc; 75 - const v3Suppressed = Math.abs(V3calc) <= bounce.ampTol; 76 - const v3Reason = v3Suppressed 77 - ? `suppressed since |V_3| ≤ ε (ε=${fmt(bounce.ampTol, 6)} V = ${fmt(bounce.tolPct, 3)}% of |V_1|)` 78 - : "nonzero, so re-reflection should appear"; 102 + function updateDerivedDisplays(model, waves, bounce) { 103 + const N = model.segments.length; 104 + const gS = bounce.gSEffective; 79 105 80 - el.derivedValues.innerHTML = [ 81 - `Γ_L = ${fmt(waves.gL, 6)}`, 82 - `Γ_S = ${fmt(gSEffective, 6)}`, 83 - `V_1 = ${fmt(waves.V1, 6)} V`, 84 - `Termination threshold = ${fmt(bounce.tolPct, 3)}% of |V_1| = ${fmt(bounce.ampTol, 6)} V`, 85 - `V_2 = Γ_L·V_1 = (${fmt(waves.gL, 6)})·(${fmt(waves.V1, 6)}) = ${fmt(V2calc, 6)} V`, 86 - `V_3 = Γ_S·V_2 = (${fmt(gSEffective, 6)})·(${fmt(V2calc, 6)}) = ${fmt(V3calc, 6)} V`, 87 - `V_3 status: ${v3Reason}`, 88 - `Total generated waves: ${bounce.series.length}`, 89 - ].map((x) => `<div>${x}</div>`).join(""); 106 + const lines = [ 107 + `\u0393<sub>S</sub> = ${fmt(gS, 6)}`, 108 + `\u0393<sub>L</sub> = ${fmt(waves.gL, 6)}`, 109 + ]; 90 110 91 - const waveLines = bounce.series.map((w) => { 92 - const kind = w.dir > 0 ? "incident (→ load)" : "reflected (→ source)"; 93 - return `<div>V<sub>${w.n}</sub> [${kind}] = ${fmt(w.A, 6)} V</div>`; 94 - }); 95 - if (bounce.series.length < 3) { 96 - waveLines.push(`<div>V<sub>3</sub> not launched: ${v3Reason}</div>`); 111 + if (N > 1) { 112 + for (let i = 0; i < N - 1; i++) { 113 + const Zl = model.segments[i].Z0; 114 + const Zr = model.segments[i + 1].Z0; 115 + const g = (Zr - Zl) / (Zr + Zl); 116 + lines.push(`\u0393<sub>${i + 1}\u2192${i + 2}</sub> = ${fmt(g, 6)} (${Zl}\u03A9\u2192${Zr}\u03A9)`); 117 + } 118 + } 119 + 120 + lines.push(`V<sub>1</sub> = ${fmt(waves.V1, 6)} V`); 121 + 122 + if (N === 1) { 123 + // Single-segment detail: show V2, V3, suppression reason. 124 + const V2calc = waves.gL * waves.V1; 125 + const V3calc = gS * V2calc; 126 + const v3Suppressed = Math.abs(V3calc) <= bounce.ampTol; 127 + const v3Reason = v3Suppressed 128 + ? `suppressed since |V<sub>3</sub>| \u2264 \u03B5 (\u03B5=${fmt(bounce.ampTol, 6)} V = ${fmt(bounce.tolPct, 3)}% of |V<sub>1</sub>|)` 129 + : "nonzero, so re-reflection should appear"; 130 + lines.push( 131 + `Termination threshold = ${fmt(bounce.tolPct, 3)}% of |V<sub>1</sub>| = ${fmt(bounce.ampTol, 6)} V`, 132 + `V<sub>2</sub> = \u0393<sub>L</sub>\u00B7V<sub>1</sub> = (${fmt(waves.gL, 6)})\u00B7(${fmt(waves.V1, 6)}) = ${fmt(V2calc, 6)} V`, 133 + `V<sub>3</sub> = \u0393<sub>S</sub>\u00B7V<sub>2</sub> = (${fmt(gS, 6)})\u00B7(${fmt(V2calc, 6)}) = ${fmt(V3calc, 6)} V`, 134 + `V<sub>3</sub> status: ${v3Reason}`, 135 + ); 136 + } else { 137 + lines.push(`Termination threshold = ${fmt(bounce.tolPct, 3)}% of |V<sub>1</sub>| = ${fmt(bounce.ampTol, 6)} V`); 97 138 } 98 - el.waveValues.innerHTML = waveLines.join(""); 139 + 140 + lines.push(`Total generated waves: ${bounce.series.length}`); 141 + el.derivedValues.innerHTML = lines.map((x) => `<div>${x}</div>`).join(""); 142 + 143 + el.waveValues.innerHTML = bounce.series.map((w) => { 144 + const arrow = w.dir > 0 ? "\u2192" : "\u2190"; 145 + const segStr = N > 1 ? ` [seg&nbsp;${w.segIdx + 1}]` : ""; 146 + return `<div>V<sub>${w.n}</sub> ${arrow}${segStr} = ${fmt(w.A, 6)} V</div>`; 147 + }).join(""); 99 148 } 100 149 101 150 // ---- render frame ---- ··· 111 160 el.gLRead.textContent = fmt(waves.gL, 3); 112 161 el.vsRead.textContent = `${fmt(dyn.VS, 3)} V`; 113 162 el.vlRead.textContent = `${fmt(dyn.VL, 3)} V`; 114 - updateDerivedDisplays(waves, bounce); 163 + updateDerivedDisplays(model, waves, bounce); 115 164 116 165 if (!mathjaxTypesetDone && window.MathJax?.typesetPromise) { 117 166 mathjaxTypesetDone = true; ··· 119 168 } 120 169 121 170 const c = resizeCanvasToCSS(el.circuit); 122 - drawCircuit(c.ctx, c.w, c.h, tNorm, dyn, theme); 171 + drawCircuit(c.ctx, c.w, c.h, tNorm, dyn, theme, model.segments, model.RL); 123 172 124 173 ensurePlotCanvasHeight(el.plot, 2); 125 174 const p = resizeCanvasToCSS(el.plot); 126 - drawPlot(p.ctx, p.w, p.h, tNorm, dyn, theme, model.riseTimeTau); 175 + drawPlot(p.ctx, p.w, p.h, tNorm, dyn, theme, model.riseTimeTau, model.segments); 127 176 128 177 if (!hasStarted) { 129 178 el.pauseBtn.textContent = "Pause"; ··· 169 218 render(); 170 219 return; 171 220 } 172 - // Resume; if already at the end, wrap back to t=0. 173 221 if (tNorm >= timeHorizon - 1e-9) tNorm = 0; 174 222 running = true; 175 223 lastTS = null; ··· 185 233 render(); 186 234 } 187 235 188 - // ---- scrubbing ---- 189 - function scrubFromEvent(ev) { 190 - if (!el.scrubToggle.checked || running) return; 191 - const rect = ev.target.getBoundingClientRect(); 192 - tNorm = clamp(ev.clientX - rect.left, 0, rect.width) / rect.width * timeHorizon; 193 - render(); 194 - } 236 + // ---- event wiring ---- 237 + el.RLOpen.addEventListener("click", () => { 238 + rlIsOpen = !rlIsOpen; 239 + el.RL.disabled = rlIsOpen; 240 + el.RLOpen.classList.toggle("active", rlIsOpen); 241 + if (!running) render(); 242 + }); 195 243 196 - // ---- event wiring ---- 197 244 el.startBtn.addEventListener("click", start); 198 245 el.pauseBtn.addEventListener("click", pause); 199 246 el.resetBtn.addEventListener("click", reset); 200 247 201 - for (const inp of [el.Vg, el.Rg, el.Z0, el.RL, el.secPerTau, el.reflectTol, el.riseTau]) { 248 + for (const inp of [el.Vg, el.Rg, el.RL, el.secPerTau, el.reflectTol, el.riseTau]) { 202 249 inp.addEventListener("input", () => { if (!running) render(); }); 203 250 inp.addEventListener("change", () => { if (!running) render(); }); 204 251 } 205 252 253 + el.segCount.addEventListener("input", () => { 254 + const n = Math.max(1, Math.min(10, parseInt(el.segCount.value) || 1)); 255 + el.segCount.value = n; 256 + buildSegmentInputs(n); 257 + if (!running) render(); 258 + }); 259 + 206 260 el.riseMode.addEventListener("change", () => { 207 261 el.riseTau.disabled = el.riseMode.value !== "exp"; 208 262 if (!running) render(); 209 263 }); 210 264 211 - el.plot.addEventListener("mousemove", scrubFromEvent); 212 - el.circuit.addEventListener("mousemove", scrubFromEvent); 213 265 window.addEventListener("resize", () => { theme = getTheme(); render(); }); 214 266 215 267 // Initial render
+165 -79
physics.js
··· 4 4 // 5 5 // Spatial convention: z ∈ [0, 1] maps to [0, ℓ]. 6 6 // Time convention: tNorm is dimensionless time in units of τ_d (one-way delay). 7 - // A wave launched at tNorm=launch reaches the far end at tNorm=launch+1. 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) 8 31 // 9 32 // Extension points: 10 - // • Rise time (RC/finite bandwidth): implemented — see riseShape, waveVoltageAt, totalVoltageAt. 11 - // model.riseTimeTau = 0 gives a hard step; > 0 gives V = V_final*(1−exp(−Δt/τ_r)). 12 - // 13 - // • Multi-segment lines: replace single Z0 with an array of segments [{Z0, length}]. 14 - // Each impedance boundary generates both a reflected wave (Γ) and a transmitted wave (T = 1+Γ). 15 - // buildBounceSeries() would be replaced by a segment-aware lattice diagram solver. 33 + // • Rise time (RC/finite bandwidth): model.riseTimeTau > 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. 16 36 17 37 const TLPhysics = (() => { 18 38 const BOUNCE_EPS = 1e-6; 19 - const MAX_BOUNCES = 5000; // safety cap; ordinary use stays well under 50 39 + const MAX_BOUNCES = 5000; 20 40 21 41 // ---- reflection coefficients and initial voltage step ---- 22 - // Returns { V1, gL, gS } 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 Γ. 23 44 function computeWaveParams(model) { 24 - const { Vg, Rg, Z0, RL } = model; 25 - const V1 = Vg * Z0 / (Rg + Z0); // voltage of the first incident wave 26 - const gL = (RL - Z0) / (RL + Z0); // reflection coefficient at load 27 - const gS = (Rg - Z0) / (Rg + Z0); // reflection coefficient at source 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); 28 51 return { V1, gL, gS }; 29 52 } 30 53 31 - // ---- bounce series ---- 32 - // Builds the sequence of travelling waves produced by successive reflections. 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 + // 33 58 // Returns { 34 - // series: [{n, A, dir, launch}] — all generated waves in order 35 - // srcEvents: [{t, dV}] — cumulative voltage steps at z=0 (VS) 36 - // loadEvents: [{t, dV}] — cumulative voltage steps at z=ℓ (VL) 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) 37 62 // gSEffective, tolPct, ampTol, tEnd 38 63 // } 39 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 + 40 71 const gS = waves.gS; 72 + const gL = waves.gL; 41 73 const tolPct = Number.isFinite(model.reflectTol) ? model.reflectTol : 1; 42 74 const ampTol = Math.max(BOUNCE_EPS, Math.abs(waves.V1) * (tolPct / 100)); 43 75 44 - // wave n=1: first incident, rightward, launched at tNorm=0 45 - const series = [{ n: 1, A: waves.V1, dir: +1, launch: 0 }]; 76 + const series = []; 77 + const srcEvents = [{ t: 0, dV: waves.V1 }]; // initial step seen at source at t=0 78 + const loadEvents = []; 46 79 47 - let A = waves.V1; 48 - let launch = 1; // each successive wave launches one τ_d later 49 - let reflectAtLoad = true; // V2 is the first reflection, from the load 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 = []; 50 83 51 - while (series.length < MAX_BOUNCES) { 52 - const g = reflectAtLoad ? waves.gL : gS; 53 - const nextA = A * g; 54 - if (Math.abs(nextA) <= ampTol) break; 55 - series.push({ n: series.length + 1, A: nextA, dir: reflectAtLoad ? -1 : +1, launch }); 56 - A = nextA; 57 - launch++; 58 - reflectAtLoad = !reflectAtLoad; 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); 59 88 } 60 89 61 - // Node voltage histories (cumulative step events). 62 - // VS: V source-side = sum of (1+Γ_S)·V_k for every left-going wave that arrives at source, 63 - // plus V1 at t=0 (the initial step seen at z=0 when the switch closes). 64 - // VL: V load-side = sum of (1+Γ_L)·V_k for every right-going wave that arrives at load. 65 - const srcEvents = [{ t: 0, dV: waves.V1 }]; 66 - const loadEvents = []; 67 - for (const w of series) { 68 - const arrive = w.launch + 1; 69 - if (w.dir > 0) { 70 - loadEvents.push({ t: arrive, dV: (1 + waves.gL) * w.A }); 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 + }); 71 124 } else { 72 - srcEvents.push({ t: arrive, dV: (1 + gS) * w.A }); 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 + } 73 164 } 74 165 } 75 166 76 - return { 77 - series, 78 - srcEvents, 79 - loadEvents, 80 - gSEffective: gS, 81 - tolPct, 82 - ampTol, 83 - tEnd: series.length > 0 ? series[series.length - 1].launch + 1 : 2, 84 - }; 167 + const tEnd = series.reduce((m, w) => Math.max(m, w.tDie), 2); 168 + return { series, srcEvents, loadEvents, gSEffective: gS, tolPct, ampTol, tEnd }; 85 169 } 86 170 87 171 // ---- time query: node voltage (step model) ---- 88 - // Sum all voltage step events that have occurred by time tn. 89 172 function sumEventsAtTime(events, tn) { 90 173 const eps = 1e-9; 91 174 let v = 0; ··· 96 179 } 97 180 98 181 // ---- rise-time wave shape ---- 99 - // Fraction of final amplitude reached after time dt has elapsed since the wave front passed. 100 - // tau = 0 → hard step (returns 1 immediately). 101 - // tau > 0 → exponential approach: 1 − exp(−dt / tau). 182 + // Fraction of final amplitude reached after time dt since the wave front passed. 183 + // tau = 0 → hard step. tau > 0 → 1 − exp(−dt/tau). 102 184 function riseShape(dt, tau) { 103 185 if (dt <= 0) return 0; 104 186 if (tau <= 0) return 1; 105 187 return 1 - Math.exp(-dt / tau); 106 188 } 107 189 108 - // Voltage contribution of a single wave wf at spatial position z. 109 - // wf must carry {dir, u, front, A} (fields added by computeDynamicState). 190 + // Voltage contribution of a single wave packet wf at position z. 191 + // wf must carry the fields added by computeDynamicState: u, front. 192 + // Returns 0 if z is outside this packet's z-range or ahead of the front. 110 193 function waveVoltageAt(wf, z, tau) { 111 - if (wf.dir > 0) { 112 - // Rightward wave: non-zero behind the front (z ≤ front). 113 - // Time since the front passed z: Δt = u − z (wave speed = 1 in normalised units). 114 - if (z > wf.front + 1e-9) return 0; 115 - return wf.A * riseShape(wf.u - z, tau); 194 + const { dir, zStart, A, u, front } = wf; 195 + if (dir > 0) { 196 + // Rightward: nonzero in [zStart, front]. 197 + // Time since front passed z: u − (z − zStart). 198 + if (z < zStart - 1e-9 || z > front + 1e-9) return 0; 199 + return A * riseShape(u - (z - zStart), tau); 116 200 } else { 117 - // Leftward wave: non-zero behind the front (z ≥ front). 118 - // Front started at z=1; time since it passed z: Δt = u − (1 − z). 119 - if (z < wf.front - 1e-9) return 0; 120 - return wf.A * riseShape(wf.u - (1 - z), tau); 201 + // Leftward: nonzero in [front, zStart]. 202 + // Time since front passed z: u − (zStart − z). 203 + if (z > zStart + 1e-9 || z < front - 1e-9) return 0; 204 + return A * riseShape(u - (zStart - z), tau); 121 205 } 122 206 } 123 207 ··· 128 212 return V; 129 213 } 130 214 131 - // Node voltage as a smooth sum of exponential-rise events. 132 - // Each event contributes dV * riseShape(tn − t, tau) once tn ≥ t. 133 - // At tau = 0 this is identical to sumEventsAtTime (minus the ε guard, but 134 - // the wave fronts are always at t + 0+, so the eps edge-case never matters here). 215 + // Node voltage as smooth sum of exponential-rise events. 135 216 function sumEventsWithRise(events, tn, tau) { 136 217 let v = 0; 137 218 for (const e of events) { ··· 141 222 } 142 223 143 224 // ---- dynamic state at a given tNorm ---- 144 - // Returns the set of waves currently propagating and the node voltages. 145 - // Each wave in launchedWaves gets two extra fields: 146 - // u: time elapsed since launch (tNorm − launch) 147 - // front: current position of the leading edge, ∈ [0,1] 148 - // riseTimeTau = 0 gives hard-step node voltages; > 0 gives smooth exponential rise. 225 + // Annotates each launched wave with: 226 + // u: tNorm − tBorn 227 + // front: current leading-edge position 228 + // A wave is "active" while u < |zEnd − zStart| (front still in transit). 149 229 function computeDynamicState(tn, bounce, riseTimeTau = 0) { 150 230 const { clamp } = TLUtils; 151 231 const launchedWaves = []; 152 - const activeWaves = []; // waves whose front is still in transit 232 + const activeWaves = []; 153 233 154 234 for (const w of bounce.series) { 155 - const u = tn - w.launch; 235 + const u = tn - w.tBorn; 156 236 if (u < 0) continue; 157 - const front = w.dir > 0 ? clamp(u, 0, 1) : clamp(1 - u, 0, 1); 237 + let front; 238 + if (w.dir > 0) { 239 + front = clamp(w.zStart + u, w.zStart, w.zEnd); 240 + } else { 241 + // zEnd < zStart for leftward 242 + front = clamp(w.zStart - u, w.zEnd, w.zStart); 243 + } 158 244 const ww = { ...w, u, front }; 159 245 launchedWaves.push(ww); 160 - if (u < 1) activeWaves.push(ww); 246 + if (u < Math.abs(w.zEnd - w.zStart) - 1e-9) activeWaves.push(ww); 161 247 } 162 248 163 249 return {
+203
physics.test.js
··· 1 + // Smoke tests for TLPhysics pure functions. 2 + // Requires Node 18+. Run with: node --test physics.test.js 3 + "use strict"; 4 + 5 + const { test } = require("node:test"); 6 + const assert = require("node:assert/strict"); 7 + const { readFileSync } = require("node:fs"); 8 + const 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. 12 + globalThis.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. 16 + eval(readFileSync(path.join(__dirname, "utils.js"), "utf8") + "\nglobalThis.TLUtils = TLUtils;"); 17 + eval(readFileSync(path.join(__dirname, "physics.js"), "utf8") + "\nglobalThis.TLPhysics = TLPhysics;"); 18 + 19 + const { 20 + computeWaveParams, 21 + buildBounceSeries, 22 + sumEventsAtTime, 23 + riseShape, 24 + } = globalThis.TLPhysics; 25 + 26 + // Helper: assert two numbers are within `tol` of each other. 27 + function near(a, b, msg, tol = 1e-9) { 28 + assert.ok( 29 + Math.abs(a - b) <= tol, 30 + `${msg}: expected ~${b}, got ${a} (diff ${(a - b).toExponential(2)})` 31 + ); 32 + } 33 + 34 + // Build a minimal single-segment model and run buildBounceSeries. 35 + function makeModel(Vg, Rg, Z0, RL, reflectTol = 0.001) { 36 + const model = { Vg, Rg, RL, segments: [{ Z0 }], reflectTol }; 37 + const waves = computeWaveParams(model); 38 + const bounce = buildBounceSeries(model, waves); 39 + return { model, waves, bounce }; 40 + } 41 + 42 + // ──────────────────────────────────────────────────────────────────────────── 43 + // computeWaveParams 44 + // ──────────────────────────────────────────────────────────────────────────── 45 + 46 + test("computeWaveParams: matched line (Rg=Z0=RL=50)", () => { 47 + const m = { Vg: 1, Rg: 50, RL: 50, segments: [{ Z0: 50 }] }; 48 + const { V1, gL, gS } = computeWaveParams(m); 49 + near(V1, 0.5, "V1"); 50 + near(gL, 0, "gL"); 51 + near(gS, 0, "gS"); 52 + }); 53 + 54 + test("computeWaveParams: open-circuit load (RL=Infinity)", () => { 55 + const m = { Vg: 1, Rg: 50, RL: Infinity, segments: [{ Z0: 50 }] }; 56 + const { V1, gL, gS } = computeWaveParams(m); 57 + near(V1, 0.5, "V1"); 58 + near(gL, 1, "gL"); 59 + near(gS, 0, "gS"); 60 + }); 61 + 62 + test("computeWaveParams: short-circuit load (RL=0)", () => { 63 + const m = { Vg: 1, Rg: 50, RL: 0, segments: [{ Z0: 50 }] }; 64 + const { V1, gL, gS } = computeWaveParams(m); 65 + near(V1, 0.5, "V1"); 66 + near(gL, -1, "gL"); 67 + near(gS, 0, "gS"); 68 + }); 69 + 70 + test("computeWaveParams: source Γ with Rg mismatch", () => { 71 + // Rg=100, Z0=50 → gS = (100-50)/(100+50) = 50/150 = 1/3 72 + const m = { Vg: 1, Rg: 100, RL: 50, segments: [{ Z0: 50 }] }; 73 + const { gS } = computeWaveParams(m); 74 + near(gS, 1 / 3, "gS", 1e-9); 75 + }); 76 + 77 + // ──────────────────────────────────────────────────────────────────────────── 78 + // buildBounceSeries: wave count and first-wave correctness 79 + // ──────────────────────────────────────────────────────────────────────────── 80 + 81 + test("buildBounceSeries: matched line produces exactly 1 wave", () => { 82 + const { bounce } = makeModel(1, 50, 50, 50); 83 + assert.equal(bounce.series.length, 1, "exactly one wave packet"); 84 + assert.equal(bounce.series[0].dir, +1, "rightward"); 85 + near(bounce.series[0].A, 0.5, "amplitude = V1 = 0.5"); 86 + }); 87 + 88 + test("buildBounceSeries: first wave geometry", () => { 89 + const { bounce } = makeModel(1, 50, 50, 50); 90 + const w = bounce.series[0]; 91 + near(w.zStart, 0, "zStart = 0"); 92 + near(w.zEnd, 1, "zEnd = 1"); 93 + near(w.tBorn, 0, "tBorn = 0"); 94 + near(w.tDie, 1, "tDie = 1"); 95 + }); 96 + 97 + test("buildBounceSeries: open-circuit — load event dV = 2·V1 at t=1", () => { 98 + // gL = 1 → (1+gL)·A = 1.0. gS=0 → no further reflections → exactly 1 load event. 99 + const { bounce } = makeModel(1, 50, 50, Infinity); 100 + assert.equal(bounce.loadEvents.length, 1, "one load event"); 101 + near(bounce.loadEvents[0].t, 1, "event time = 1τ"); 102 + near(bounce.loadEvents[0].dV, 1.0, "dV = 1.0 (= 2·0.5)"); 103 + }); 104 + 105 + test("buildBounceSeries: short-circuit — load event dV = 0 at t=1", () => { 106 + // gL = -1 → (1+gL)·A = 0 107 + const { bounce } = makeModel(1, 50, 50, 0); 108 + assert.equal(bounce.loadEvents.length, 1, "one load event"); 109 + near(bounce.loadEvents[0].dV, 0, "dV = 0 at short circuit"); 110 + }); 111 + 112 + // ──────────────────────────────────────────────────────────────────────────── 113 + // DC steady state: VL(t→∞) = Vg · RL / (Rg + RL) 114 + // ──────────────────────────────────────────────────────────────────────────── 115 + 116 + function checkDCSteadyState(label, Vg, Rg, Z0, RL, tol = 1e-3) { 117 + test(`DC steady state: ${label}`, () => { 118 + const { bounce } = makeModel(Vg, Rg, Z0, RL, 0.001); 119 + const VL_dc = isFinite(RL) ? Vg * RL / (Rg + RL) : Vg; 120 + const VL_actual = sumEventsAtTime(bounce.loadEvents, bounce.tEnd + 10); 121 + near(VL_actual, VL_dc, "VL", tol); 122 + }); 123 + } 124 + 125 + // Matched source, various loads 126 + checkDCSteadyState("matched (50/50/50)", 1, 50, 50, 50); 127 + checkDCSteadyState("open circuit (50/50/∞)", 1, 50, 50, Infinity); 128 + checkDCSteadyState("short circuit (50/50/0)", 1, 50, 50, 0); 129 + // Mismatched source 130 + checkDCSteadyState("Rg=100 Z0=50 RL=150, Vg=5", 5, 100, 50, 150, 1e-3); 131 + checkDCSteadyState("Rg=25 Z0=75 RL=200, Vg=3.3", 3.3, 25, 75, 200, 1e-3); 132 + 133 + // ──────────────────────────────────────────────────────────────────────────── 134 + // Multi-segment: two identical segments should match single segment 135 + // ──────────────────────────────────────────────────────────────────────────── 136 + 137 + test("multi-segment: two identical Z0 segments == single segment", () => { 138 + const single = makeModel(1, 50, 50, 100, 0.001); 139 + const model2 = { Vg: 1, Rg: 50, RL: 100, segments: [{ Z0: 50 }, { Z0: 50 }], reflectTol: 0.001 }; 140 + const waves2 = computeWaveParams(model2); 141 + const bounce2 = buildBounceSeries(model2, waves2); 142 + const t = single.bounce.tEnd + 10; 143 + const VL_single = sumEventsAtTime(single.bounce.loadEvents, t); 144 + const VL_double = sumEventsAtTime(bounce2.loadEvents, t); 145 + near(VL_single, VL_double, "VL matches", 1e-3); 146 + }); 147 + 148 + test("multi-segment: two different Z0 — DC still converges to Vg·RL/(Rg+RL)", () => { 149 + const model = { Vg: 1, Rg: 50, RL: 100, segments: [{ Z0: 50 }, { Z0: 75 }], reflectTol: 0.001 }; 150 + const waves = computeWaveParams(model); 151 + const bounce = buildBounceSeries(model, waves); 152 + const VL_dc = 1 * 100 / (50 + 100); // ≈ 0.6667 153 + const VL_actual = sumEventsAtTime(bounce.loadEvents, bounce.tEnd + 10); 154 + near(VL_actual, VL_dc, "VL", 1e-3); 155 + }); 156 + 157 + // ──────────────────────────────────────────────────────────────────────────── 158 + // sumEventsAtTime 159 + // ──────────────────────────────────────────────────────────────────────────── 160 + 161 + test("sumEventsAtTime: empty events → 0", () => { 162 + near(sumEventsAtTime([], 999), 0, "empty"); 163 + }); 164 + 165 + test("sumEventsAtTime: single event at t=0 seen immediately", () => { 166 + near(sumEventsAtTime([{ t: 0, dV: 0.5 }], 0), 0.5, "at t=0"); 167 + }); 168 + 169 + test("sumEventsAtTime: event at t=1 not seen before it", () => { 170 + near(sumEventsAtTime([{ t: 1, dV: 0.5 }], 0.5), 0, "before event"); 171 + near(sumEventsAtTime([{ t: 1, dV: 0.5 }], 1.0), 0.5, "at event time"); 172 + near(sumEventsAtTime([{ t: 1, dV: 0.5 }], 2.0), 0.5, "after event time"); 173 + }); 174 + 175 + test("sumEventsAtTime: accumulates multiple events", () => { 176 + const evts = [{ t: 0, dV: 0.5 }, { t: 1, dV: 0.25 }, { t: 2, dV: 0.1 }]; 177 + near(sumEventsAtTime(evts, 1.5), 0.75, "after first two events"); 178 + near(sumEventsAtTime(evts, 2.0), 0.85, "after all events"); 179 + }); 180 + 181 + // ──────────────────────────────────────────────────────────────────────────── 182 + // riseShape 183 + // ──────────────────────────────────────────────────────────────────────────── 184 + 185 + test("riseShape: dt≤0 → 0", () => { 186 + near(riseShape(0, 0), 0, "dt=0 tau=0"); 187 + near(riseShape(-1, 1), 0, "dt<0"); 188 + }); 189 + 190 + test("riseShape: tau=0 (step) → 1 for dt>0", () => { 191 + near(riseShape(0.001, 0), 1, "tiny dt, tau=0"); 192 + near(riseShape(100, 0), 1, "large dt, tau=0"); 193 + }); 194 + 195 + test("riseShape: tau>0 — known exponential values", () => { 196 + // riseShape(τ, τ) = 1 − e^{−1} ≈ 0.63212 197 + near(riseShape(1, 1), 1 - Math.exp(-1), "1 time constant", 1e-12); 198 + // riseShape(2τ, τ) = 1 − e^{−2} ≈ 0.86466 199 + near(riseShape(2, 1), 1 - Math.exp(-2), "2 time constants", 1e-12); 200 + // Small dt → roughly dt/tau (first-order approx) 201 + const small = riseShape(0.001, 1); 202 + near(small, 0.001, "linear regime approx", 1e-5); 203 + });
+127 -81
render.js
··· 4 4 // All draw functions are pure with respect to app state; they receive 5 5 // everything they need as parameters (ctx, dimensions, dynamic state, theme). 6 6 // 7 - // Extension points: 8 - // • drawPlot: riseTimeTau > 0 now handled — uses sampled continuous curves via TLPhysics. 9 - // • drawCircuit: for multi-segment lines, the single T-line box becomes N 10 - // abutting boxes, each labelled with its own Z0. 7 + // Multi-segment support: 8 + // drawCircuit takes a `segments` array and draws N abutting T-line boxes. 9 + // Wave voltage helpers respect each packet's z-range [zStart, zEnd]. 10 + // drawPlot draws light vertical tick marks at internal segment boundaries. 11 11 12 12 const TLRender = (() => { 13 13 // ---- plot layout constants ---- ··· 17 17 const PLOT_PAD_B = 26; 18 18 19 19 // ---- theme ---- 20 - // Read CSS custom properties into a plain object. 21 - // Call this once at init and again on resize/theme-change. 22 20 function getTheme() { 23 21 const get = (v) => getComputedStyle(document.documentElement).getPropertyValue(v).trim(); 24 22 return { ··· 71 69 const len = Math.hypot(dx, dy); 72 70 if (len < 1e-6) return; 73 71 const ux = dx / len, uy = dy / len; 74 - const px = -uy, py = ux; // unit perpendicular 72 + const px = -uy, py = ux; 75 73 76 74 const lead = 10; 77 75 const start = { x: x0 + ux * lead, y: y0 + uy * lead }; ··· 114 112 ctx.fillStyle = theme.muted; 115 113 ctx.font = "13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; 116 114 ctx.fillText("+", x + 6, yTop + 12); 117 - ctx.fillText("−", x + 6, yBot - 4); 118 - label(ctx, name, x + 10, (yTop + yBot) / 2 - 6, theme.muted); 115 + ctx.fillText("\u2212", x + 6, yBot - 4); 116 + label(ctx, name, x + 5, (yTop + yBot) / 2 - 6, theme.muted); 119 117 } 120 118 121 119 // ---- circuit canvas ---- 122 - function drawCircuit(ctx, w, h, tn, dyn, theme) { 120 + // segments = model.segments = [{Z0}, …] (N entries, equal-length) 121 + // RL = model.RL — pass Infinity for open circuit 122 + function drawCircuit(ctx, w, h, tn, dyn, theme, segments, RL) { 123 + const rlIsOpen = !isFinite(RL); 124 + const rlIsShort = RL === 0; 123 125 ctx.clearRect(0, 0, w, h); 124 126 ctx.fillStyle = theme.panel; 125 127 ctx.fillRect(0, 0, w, h); ··· 128 130 const yTop = 70; 129 131 const yBot = 190; 130 132 131 - // x key-points — keep these consistent with xPlot0/xPlot1 in drawPlot 132 133 const xSourceL = pad + 60; 133 134 const xSwitch = pad + 170; 134 135 const xTL0 = pad + 240; 135 136 const xTL1 = w - pad - 210; 136 - const xLoad = w - pad - 120; 137 - const xRight = w - pad - 40; 137 + const xLoad = w - pad - 160; 138 + const xRight = w - pad - 90; 138 139 139 140 ctx.lineWidth = 2; 140 141 ctx.strokeStyle = theme.ink; 141 142 142 - // Top and bottom wires 143 + // Top and bottom wires — always run to xRight regardless of termination 143 144 line(ctx, xSwitch + 18, yTop, xTL0, yTop); 144 - line(ctx, xTL1, yTop, xLoad, yTop); 145 - line(ctx, xLoad, yTop, xRight, yTop); 145 + line(ctx, xTL1, yTop, xRight, yTop); 146 146 line(ctx, xSourceL, yBot, xTL0, yBot); 147 147 line(ctx, xTL1, yBot, xRight, yBot); 148 148 149 - // Voltage source (circle + +/−) 149 + // Voltage source 150 150 const vsx = xSourceL, vsy = (yTop + yBot) / 2; 151 151 line(ctx, xSourceL, yTop, xSourceL, vsy - 20); 152 152 line(ctx, xSourceL, vsy + 20, xSourceL, yBot); ··· 154 154 ctx.fillStyle = theme.ink; 155 155 ctx.font = "14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; 156 156 ctx.fillText("+", vsx - 4, vsy - 6); 157 - ctx.fillText("−", vsx - 4, vsy + 14); 157 + ctx.fillText("\u2212", vsx - 4, vsy + 14); 158 158 label(ctx, "Vg", vsx - 38, vsy - 20, theme.muted); 159 159 160 160 // Source resistor Rg ··· 166 166 167 167 drawSwitch(ctx, xSwitch, yTop, tn, theme); 168 168 169 - // Transmission line boxes (top and bottom conductors) 170 - ctx.strokeStyle = theme.ink; 169 + // Transmission line boxes — N abutting pairs (top + bottom conductor) 170 + const N = segments.length; 171 + const tlW = xTL1 - xTL0; 172 + const segPxW = tlW / N; 171 173 ctx.lineWidth = 2; 172 - ctx.strokeRect(xTL0, yTop - 16, xTL1 - xTL0, 32); 173 - label(ctx, "Z0", (xTL0 + xTL1) / 2 - 10, yTop - 26, theme.muted); 174 - ctx.strokeRect(xTL0, yBot - 16, xTL1 - xTL0, 32); 174 + ctx.strokeStyle = theme.ink; 175 175 176 - // Load resistor RL (vertical) 176 + ctx.font = "13px ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial"; 177 + for (let i = 0; i < N; i++) { 178 + const bx = xTL0 + i * segPxW; 179 + ctx.strokeRect(bx, yTop - 16, segPxW, 32); 180 + ctx.strokeRect(bx, yBot - 16, segPxW, 32); 181 + 182 + // Label above top box: "Z₀" for single segment, "50Ω" etc. for multi 183 + const lbl = N === 1 184 + ? "Z\u2080" 185 + : `${segments[i].Z0}\u03A9`; 186 + const lblX = bx + segPxW / 2 - ctx.measureText(lbl).width / 2; 187 + label(ctx, lbl, lblX, yTop - 22, theme.muted); 188 + } 189 + 190 + // Load — resistor, short circuit wire, or open-circuit terminals 177 191 const rlTop = yTop + 22, rlBot = yBot - 22; 178 192 ctx.strokeStyle = theme.ink; 179 193 ctx.lineWidth = 2; 180 - line(ctx, xLoad, yTop, xLoad, rlTop); 181 - drawResistor(ctx, xLoad, rlTop, xLoad, rlBot, 5, 8, theme); 182 - line(ctx, xLoad, rlBot, xLoad, yBot); 183 - label(ctx, "RL", xLoad + 10, (yTop + yBot) / 2, theme.muted); 194 + if (rlIsOpen) { 195 + circleFill(ctx, xRight, yTop, 4.5, theme.ink); 196 + circleFill(ctx, xRight, yBot, 4.5, theme.ink); 197 + } else if (rlIsShort) { 198 + ctx.lineWidth = 3; 199 + line(ctx, xLoad, yTop, xLoad, yBot); 200 + ctx.lineWidth = 2; 201 + } else { 202 + line(ctx, xLoad, yTop, xLoad, rlTop); 203 + drawResistor(ctx, xLoad, rlTop, xLoad, rlBot, 5, 8, theme); 204 + line(ctx, xLoad, rlBot, xLoad, yBot); 205 + label(ctx, "RL", xLoad + 10, (yTop + yBot) / 2, theme.muted); 206 + } 184 207 185 208 // Voltage probes 186 209 drawVoltageProbe(ctx, xSwitch + 12, yTop, yBot, "VS", theme); 187 - drawVoltageProbe(ctx, xLoad + 18, yTop, yBot, "VL", theme); 210 + drawVoltageProbe(ctx, xLoad + 55, yTop, yBot, "VL", theme); 188 211 189 - // Ground reference node 190 - ctx.fillStyle = theme.ink; 191 - ctx.beginPath(); 192 - ctx.arc(xRight, yBot, 3.5, 0, Math.PI * 2); 193 - ctx.fill(); 212 + // Terminal dots at the VL port — top and bottom 213 + if (!rlIsOpen) { 214 + circleFill(ctx, xRight, yTop, 3.5, theme.ink); 215 + circleFill(ctx, xRight, yBot, 3.5, theme.ink); 216 + } 194 217 195 218 // Wavefront markers (dashed vertical lines inside T-line boxes) 196 - const tlW = xTL1 - xTL0; 197 219 const wfY0 = yTop - 16; 198 220 const wfY1 = yBot + 16; 199 221 for (const wf of dyn.activeWaves) { ··· 206 228 } 207 229 208 230 label(ctx, "z = 0", xTL0 - 20, yBot + 34, theme.muted); 209 - label(ctx, "z = ℓ", xTL1 - 18, yBot + 34, theme.muted); 231 + label(ctx, "z = \u2113", xTL1 - 18, yBot + 34, theme.muted); 210 232 } 211 233 212 - // ---- wave-shape helpers (piecewise-constant — used until rise time is implemented) ---- 234 + // ---- wave-shape helpers ---- 213 235 214 - // Voltage profile of a single wave: non-zero on the region behind the front. 236 + // Voltage profile of a single wave packet as piecewise-constant segments over [0,1]. 237 + // The packet only contributes amplitude in its own z-range [zStart, zEnd]. 215 238 function segmentsForWave(wf) { 216 - if (wf.dir > 0) return [ 217 - { a: 0, b: wf.front, V: wf.A }, 218 - { a: wf.front, b: 1, V: 0 }, 219 - ]; 220 - return [ 221 - { a: 0, b: wf.front, V: 0 }, 222 - { a: wf.front, b: 1, V: wf.A }, 223 - ]; 239 + const { dir, zStart, A, front } = wf; 240 + let segs; 241 + if (dir > 0) { 242 + // Nonzero in [zStart, front]; zero elsewhere. 243 + segs = [ 244 + { a: 0, b: zStart, V: 0 }, 245 + { a: zStart, b: front, V: A }, 246 + { a: front, b: 1, V: 0 }, 247 + ]; 248 + } else { 249 + // Leftward: zStart is the right edge; nonzero in [front, zStart]. 250 + segs = [ 251 + { a: 0, b: front, V: 0 }, 252 + { a: front, b: zStart, V: A }, 253 + { a: zStart, b: 1, V: 0 }, 254 + ]; 255 + } 256 + const valid = segs.filter(s => s.b > s.a + 1e-9); 257 + return valid.length ? valid : [{ a: 0, b: 1, V: 0 }]; 224 258 } 225 259 226 - // Sum of all launched waves, broken into piecewise-constant segments. 260 + // Sum of all launched waves as piecewise-constant segments over [0,1]. 261 + // Breakpoints include every wave's front, zStart, and zEnd so that the 262 + // midpoint probe correctly captures all z-range transitions. 227 263 function totalSegmentsForWaves(waves) { 228 264 const breakpoints = [0, 1]; 229 - for (const wf of waves) breakpoints.push(wf.front); 265 + for (const wf of waves) { 266 + breakpoints.push(wf.front); 267 + breakpoints.push(wf.zStart); 268 + breakpoints.push(wf.zEnd); 269 + } 230 270 breakpoints.sort((a, b) => a - b); 231 271 232 272 const pts = []; ··· 237 277 const segs = []; 238 278 for (let i = 0; i < pts.length - 1; i++) { 239 279 const a = pts[i], b = pts[i + 1]; 240 - const m = (a + b) / 2; // midpoint probe 280 + const m = (a + b) / 2; 241 281 let V = 0; 242 282 for (const wf of waves) { 243 - if (wf.dir > 0 && m <= wf.front) V += wf.A; 244 - if (wf.dir < 0 && m >= wf.front) V += wf.A; 283 + if (wf.dir > 0 && m >= wf.zStart - 1e-9 && m <= wf.front + 1e-9) V += wf.A; 284 + if (wf.dir < 0 && m >= wf.front - 1e-9 && m <= wf.zStart + 1e-9) V += wf.A; 245 285 } 246 286 segs.push({ a, b, V }); 247 287 } 248 288 return segs.length ? segs : [{ a: 0, b: 1, V: 0 }]; 249 289 } 250 290 251 - // Draw a piecewise-constant voltage profile (step / riseTimeTau = 0 case). 291 + // Draw a piecewise-constant voltage profile. 252 292 function drawPiecewise(ctx, xOfZ, yOfV, segments, color, width) { 253 293 ctx.strokeStyle = color; 254 294 ctx.lineWidth = width; ··· 262 302 } 263 303 264 304 // Draw a continuous voltage profile by sampling vFn(z) at N evenly-spaced z values. 265 - // Used for the riseTimeTau > 0 case; N=400 gives one sample per ~2 CSS pixels. 266 305 function drawSampledWave(ctx, xOfZ, yOfV, vFn, color, width, N = 400) { 267 306 ctx.strokeStyle = color; 268 307 ctx.lineWidth = width; ··· 276 315 } 277 316 278 317 // ---- plot canvas ---- 279 - // xPlot0/xPlot1 must match the T-line box extents in drawCircuit so that 280 - // z=0 and z=ℓ align vertically between the two canvases. 281 - // riseTimeTau = 0 → piecewise-constant (hard step); > 0 → sampled exponential curves. 282 - function drawPlot(ctx, w, h, tn, dyn, theme, riseTimeTau = 0) { 318 + // segments = model.segments (used to draw boundary tick marks) 319 + function drawPlot(ctx, w, h, tn, dyn, theme, riseTimeTau = 0, segments = [{ Z0: 50 }]) { 283 320 ctx.clearRect(0, 0, w, h); 284 321 ctx.fillStyle = theme.panel; 285 322 ctx.fillRect(0, 0, w, h); ··· 291 328 const xOfZ = (z) => xPlot0 + z * plotW; 292 329 293 330 const launched = [...dyn.launchedWaves].sort((a, b) => a.n - b.n); 294 - // Piecewise segments used for scale computation regardless of riseTimeTau 295 - // (they represent the asymptotic / fully-settled values, a safe upper bound). 296 331 const sumSegs = totalSegmentsForWaves(launched); 297 332 298 - // Shared vertical scale: symmetric, padded, fixed for the current parameter set. 299 333 const maxAbsWave = Math.max(1e-6, ...launched.map((wf) => Math.abs(wf.A))); 300 334 const maxAbsSum = Math.max(1e-6, sumSegs.reduce((m, s) => Math.max(m, Math.abs(s.V)), 0)); 301 335 const vScale = Math.max(maxAbsWave, maxAbsSum); ··· 306 340 ctx.font = "12px ui-sans-serif, system-ui"; 307 341 ctx.fillText("Voltage along the T-line", 12, 14); 308 342 309 - // Inner helper: draw axes/border for a panel 343 + // Draw axes/border for one panel 310 344 function drawPanelFrame(top, bot, yOfV, labelText) { 311 345 ctx.strokeStyle = theme.grid; 312 346 ctx.lineWidth = 1; ··· 322 356 ctx.fillText(labelText, xPlot0 + 8, top + 14); 323 357 ctx.fillText("0", xPlot0 - 14, yOfV(0) + 4); 324 358 ctx.fillText("z", xPlot1 + 6, (top + bot) / 2 + 4); 359 + 360 + // Segment boundary tick marks (for multi-segment lines) 361 + const N = segments.length; 362 + if (N > 1) { 363 + ctx.strokeStyle = theme.grid; 364 + ctx.lineWidth = 1; 365 + ctx.setLineDash([3, 4]); 366 + for (let i = 1; i < N; i++) { 367 + const xb = xOfZ(i / N); 368 + line(ctx, xb, top, xb, bot); 369 + } 370 + ctx.setLineDash([]); 371 + } 325 372 } 326 373 327 - // Inner helper: dashed front-position markers 374 + // Dashed wave-front position markers 328 375 function drawFrontMarkers(top, bot, fronts) { 329 376 if (!fronts.length) return; 330 377 ctx.strokeStyle = theme.warn; ··· 335 382 } 336 383 337 384 const panelH = PLOT_PANEL_H; 338 - 339 385 const { waveVoltageAt, totalVoltageAt } = TLPhysics; 340 386 const smooth = riseTimeTau > 0; 341 387 ··· 358 404 drawPanelFrame(top1, bot1, y1, "Components (all waves)"); 359 405 360 406 const waveStyles = [ 361 - { color: theme.accent, dash: [] }, // solid blue 362 - { color: theme.accent2, dash: [] }, // solid pink 363 - { color: theme.accent, dash: [9, 6] }, // dashed blue 364 - { color: theme.accent2, dash: [9, 6] }, // dashed pink 365 - { color: theme.accent, dash: [2, 5] }, // dotted blue 366 - { color: theme.accent2, dash: [2, 5] }, // dotted pink 367 - { color: theme.accent, dash: [16, 5] }, // long-dash blue 368 - { color: theme.accent2, dash: [16, 5] }, // long-dash pink 369 - { color: theme.accent, dash: [8, 4, 2, 4] }, // dash-dot blue 370 - { color: theme.accent2, dash: [8, 4, 2, 4] }, // dash-dot pink 407 + { color: theme.accent, dash: [] }, 408 + { color: theme.accent2, dash: [] }, 409 + { color: theme.accent, dash: [9, 6] }, 410 + { color: theme.accent2, dash: [9, 6] }, 411 + { color: theme.accent, dash: [2, 5] }, 412 + { color: theme.accent2, dash: [2, 5] }, 413 + { color: theme.accent, dash: [16, 5] }, 414 + { color: theme.accent2, dash: [16, 5] }, 415 + { color: theme.accent, dash: [8, 4, 2, 4] }, 416 + { color: theme.accent2, dash: [8, 4, 2, 4] }, 371 417 ]; 372 418 373 - const FADE_DURATION = 4; // τ_d units over which a completed wave fades 374 - const FADE_MIN = 0.08; // floor so ghost is still barely visible 419 + const FADE_DURATION = 4; 420 + const FADE_MIN = 0.08; 375 421 376 422 for (let i = 0; i < launched.length; i++) { 377 423 const wf = launched[i]; 378 424 const style = waveStyles[i % waveStyles.length]; 379 - const completedAt = wf.launch + 1; 380 - const alpha = TLUtils.clamp(1 - (tn - completedAt) / FADE_DURATION, FADE_MIN, 1); 425 + // Fade after the wave front has reached its endpoint (tDie). 426 + const alpha = TLUtils.clamp(1 - (tn - wf.tDie) / FADE_DURATION, FADE_MIN, 1); 381 427 ctx.globalAlpha = alpha; 382 428 ctx.setLineDash(style.dash); 383 429 if (smooth) { ··· 388 434 ctx.setLineDash([]); 389 435 ctx.globalAlpha = 1; 390 436 } 391 - drawFrontMarkers(top1, bot1, launched.filter((wf) => wf.u < 1).map((wf) => wf.front)); 437 + drawFrontMarkers(top1, bot1, launched.filter((wf) => wf.u < Math.abs(wf.zEnd - wf.zStart)).map((wf) => wf.front)); 392 438 393 - // z-axis endpoint labels (below both panels) 439 + // z-axis endpoint labels 394 440 ctx.fillStyle = theme.muted; 395 441 ctx.font = "12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; 396 442 ctx.fillText("0", xPlot0 - 4, h - 6); 397 - ctx.fillText("ℓ", xPlot1 - 4, h - 6); 443 + ctx.fillText("\u2113", xPlot1 - 4, h - 6); 398 444 } 399 445 400 - // Resize the plot canvas height attribute to fit the given number of panels. 446 + // Resize the plot canvas height to fit the given number of panels. 401 447 function ensurePlotCanvasHeight(plotEl, nPanels) { 402 448 const targetH = Math.round( 403 449 PLOT_PAD_T + PLOT_PAD_B +
+6 -2
style.css
··· 45 45 .cfg-vert { display: grid; gap: 8px; align-content: start; } 46 46 .cfg-row { 47 47 display: grid; 48 - grid-template-columns: auto 1fr auto; 48 + grid-template-columns: auto 1fr auto auto; 49 49 gap: 8px; 50 50 align-items: center; 51 51 font-size: 13px; ··· 55 55 border-radius: 10px; 56 56 padding: 8px 10px; 57 57 } 58 + #RLOpen { padding: 3px 8px; font-size: 15px; line-height: 1; border-radius: 7px; } 59 + #RLOpen.active { border-color: var(--accent); color: var(--accent); } 60 + #RL:disabled { opacity: 0.35; } 58 61 .cfg-extra { 59 62 display: grid; 60 63 gap: 8px; ··· 93 96 .swatch.sum { background: var(--ok); } 94 97 .divider { height: 1px; background: #1b2736; margin: 10px 0; } 95 98 .small { font-size: 12px; color: var(--muted); } 96 - .toggle { display:flex; align-items:center; gap:8px; margin-left: 4px; } 97 99 input[type="checkbox"]{ width: 16px; height: 16px; accent-color: var(--accent); } 98 100 .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-variant-numeric: tabular-nums; } 101 + .seg-z0-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; } 102 + .seg-z0-list input { width: 72px; flex: 0 0 72px; }
+9 -6
tline_viz.html
··· 25 25 <div class="cfg-vert"> 26 26 <div class="cfg-row"><span>V<sub>g</sub> =</span><input id="Vg" type="number" step="0.1" value="5.0" /><span>V</span></div> 27 27 <div class="cfg-row"><span>R<sub>g</sub> =</span><input id="Rg" type="number" step="0.1" value="20.0" /><span>(Ω)</span></div> 28 - <div class="cfg-row"><span>Z<sub>0</sub> =</span><input id="Z0" type="number" step="0.1" value="50.0" /><span>(Ω)</span></div> 29 - <div class="cfg-row"><span>R<sub>L</sub> =</span><input id="RL" type="number" step="0.1" value="30.0" /><span>(Ω)</span></div> 28 + <div class="cfg-row"><span>R<sub>L</sub> =</span><input id="RL" type="number" step="0.1" value="30.0" /><span>(Ω)</span><button id="RLOpen" type="button" title="Open circuit (R_L = ∞)">∞</button></div> 29 + <div class="cfg-extra"> 30 + <label>Segments (N) 31 + <input id="segCount" type="number" min="1" max="10" step="1" value="1" /> 32 + </label> 33 + <label>Z<sub>0</sub> per segment (Ω) 34 + <div id="segZ0List" class="seg-z0-list"></div> 35 + </label> 36 + </div> 30 37 <div class="cfg-extra"> 31 38 <label>Time scale — <span id="secPerTauRead" class="mono">2.5</span> s per τ<sub>d</sub> 32 39 <input id="secPerTau" type="range" min="0.5" max="6" step="0.1" value="2.5" /> ··· 64 71 <button id="startBtn">Start</button> 65 72 <button id="pauseBtn">Pause</button> 66 73 <button id="resetBtn">Reset</button> 67 - <div class="toggle pill" title="When paused, move your mouse over the plot or the line to scrub time."> 68 - <input id="scrubToggle" type="checkbox" /> 69 - <span>Scrub</span> 70 - </div> 71 74 </div> 72 75 73 76 <!-- Circuit canvas (top) -->