this repo has no description
at canon 329 lines 12 kB view raw
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 return { 315 BOUNCE_EPS, 316 MAX_BOUNCES, 317 computeWaveParams, 318 buildBounceSeries, 319 sumEventsAtTime, 320 sumEventsWithRise, 321 sumEventsWithLinearRamp, 322 riseShape, 323 riseShapeLinear, 324 waveVoltageAt, 325 totalVoltageAt, 326 segmentForZ, 327 computeDynamicState, 328 }; 329})();