this repo has no description
0
fork

Configure Feed

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

Add linear ramp (trapezoidal) rise time mode

Adds a third signal shape alongside step and exponential:
- riseShapeLinear(dt, tr): clamp(dt/tr, 0, 1) — matches SPICE PULSE source behaviour
- sumEventsWithLinearRamp: applies linear ramp to each bounce event
- computeDynamicState: accepts riseTimeTr alongside existing riseTimeTau
- waveVoltageAt / totalVoltageAt: tr parameter selects linear over exponential
- UI: new "Linear ramp (trapezoidal)" option in Signal shape select;
rise time input now enabled for both exp and linear modes
- Tests: riseShapeLinear unit tests + SPICE golden-reference comparison
against results-tline-oc.tsv (ngspice LTRA, TR=100ps, τ_d=1ns, open circuit),
validated to <0.1mV across all ~300 time points

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

+121 -22
+8 -4
app.js
··· 42 42 secPerTau: 2.5, 43 43 reflectTol: 1, 44 44 riseTimeTau: 0, 45 + riseTimeTr: 0, 45 46 }; 46 47 47 48 // ---- segment input management ---- ··· 90 91 model.RL = rlIsOpen ? Infinity : parseFloat(el.RL.value); 91 92 model.secPerTau = parseFloat(el.secPerTau.value); 92 93 model.reflectTol = Math.max(0, parseFloat(el.reflectTol.value)); 93 - model.riseTimeTau = el.riseMode.value === "exp" 94 + model.riseTimeTau = el.riseMode.value === "exp" 95 + ? Math.max(0.001, parseFloat(el.riseTau.value) || 0.1) 96 + : 0; 97 + model.riseTimeTr = el.riseMode.value === "linear" 94 98 ? Math.max(0.001, parseFloat(el.riseTau.value) || 0.1) 95 99 : 0; 96 100 model.segments = segZ0Inputs.map((inp) => ({ ··· 152 156 syncModelFromInputs(); 153 157 const waves = computeWaveParams(model); 154 158 const bounce = buildBounceSeries(model, waves); 155 - const dyn = computeDynamicState(tNorm, bounce, model.riseTimeTau); 159 + const dyn = computeDynamicState(tNorm, bounce, model.riseTimeTau, model.riseTimeTr); 156 160 timeHorizon = Math.max(2.2, Math.min(16, bounce.tEnd + 0.4)); 157 161 158 162 el.secPerTauRead.textContent = fmt(model.secPerTau, 1); ··· 172 176 173 177 ensurePlotCanvasHeight(el.plot, 2); 174 178 const p = resizeCanvasToCSS(el.plot); 175 - drawPlot(p.ctx, p.w, p.h, tNorm, dyn, theme, model.riseTimeTau, model.segments); 179 + drawPlot(p.ctx, p.w, p.h, tNorm, dyn, theme, model.riseTimeTau, model.riseTimeTr, model.segments); 176 180 177 181 if (!hasStarted) { 178 182 el.pauseBtn.textContent = "Pause"; ··· 258 262 }); 259 263 260 264 el.riseMode.addEventListener("change", () => { 261 - el.riseTau.disabled = el.riseMode.value !== "exp"; 265 + el.riseTau.disabled = el.riseMode.value === "step"; 262 266 if (!running) render(); 263 267 }); 264 268
+41 -14
physics.js
··· 178 178 return v; 179 179 } 180 180 181 - // ---- rise-time wave shape ---- 181 + // ---- rise-time wave shapes ---- 182 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). 183 + 184 + // Exponential: tau = 0 → hard step. tau > 0 → 1 − exp(−dt/tau). 184 185 function riseShape(dt, tau) { 185 186 if (dt <= 0) return 0; 186 187 if (tau <= 0) return 1; 187 188 return 1 - Math.exp(-dt / tau); 188 189 } 189 190 191 + // Linear ramp (trapezoidal / SPICE PULSE): tr = 0 → hard step. tr > 0 → dt/tr clamped to [0,1]. 192 + function riseShapeLinear(dt, tr) { 193 + if (dt <= 0) return 0; 194 + if (tr <= 0 || dt >= tr) return 1; 195 + return dt / tr; 196 + } 197 + 190 198 // Voltage contribution of a single wave packet wf at position z. 191 199 // wf must carry the fields added by computeDynamicState: u, front. 192 200 // Returns 0 if z is outside this packet's z-range or ahead of the front. 193 - function waveVoltageAt(wf, z, tau) { 201 + // Pass either tau (exponential) or tr (linear); whichever is > 0 takes priority (tr first). 202 + function waveVoltageAt(wf, z, tau, tr = 0) { 194 203 const { dir, zStart, A, u, front } = wf; 195 204 if (dir > 0) { 196 205 // Rightward: nonzero in [zStart, front]. 197 206 // Time since front passed z: u − (z − zStart). 198 207 if (z < zStart - 1e-9 || z > front + 1e-9) return 0; 199 - return A * riseShape(u - (z - zStart), tau); 208 + const dt = u - (z - zStart); 209 + return A * (tr > 0 ? riseShapeLinear(dt, tr) : riseShape(dt, tau)); 200 210 } else { 201 211 // Leftward: nonzero in [front, zStart]. 202 212 // Time since front passed z: u − (zStart − z). 203 213 if (z > zStart + 1e-9 || z < front - 1e-9) return 0; 204 - return A * riseShape(u - (zStart - z), tau); 214 + const dt = u - (zStart - z); 215 + return A * (tr > 0 ? riseShapeLinear(dt, tr) : riseShape(dt, tau)); 205 216 } 206 217 } 207 218 208 219 // Sum of all wave contributions at position z. 209 - function totalVoltageAt(z, launchedWaves, tau) { 220 + function totalVoltageAt(z, launchedWaves, tau, tr = 0) { 210 221 let V = 0; 211 - for (const wf of launchedWaves) V += waveVoltageAt(wf, z, tau); 222 + for (const wf of launchedWaves) V += waveVoltageAt(wf, z, tau, tr); 212 223 return V; 213 224 } 214 225 ··· 221 232 return v; 222 233 } 223 234 235 + // Node voltage as smooth sum of linear-ramp events (matches SPICE PULSE with finite TR). 236 + function sumEventsWithLinearRamp(events, tn, tr) { 237 + let v = 0; 238 + for (const e of events) { 239 + const dt = tn - e.t; 240 + if (dt > 0) v += e.dV * riseShapeLinear(dt, tr); 241 + } 242 + return v; 243 + } 244 + 224 245 // ---- dynamic state at a given tNorm ---- 225 246 // Annotates each launched wave with: 226 247 // u: tNorm − tBorn 227 248 // front: current leading-edge position 228 249 // A wave is "active" while u < |zEnd − zStart| (front still in transit). 229 - function computeDynamicState(tn, bounce, riseTimeTau = 0) { 250 + function computeDynamicState(tn, bounce, riseTimeTau = 0, riseTimeTr = 0) { 230 251 const { clamp } = TLUtils; 231 252 const launchedWaves = []; 232 253 const activeWaves = []; ··· 249 270 return { 250 271 launchedWaves, 251 272 activeWaves, 252 - VS: riseTimeTau > 0 253 - ? sumEventsWithRise(bounce.srcEvents, tn, riseTimeTau) 254 - : sumEventsAtTime(bounce.srcEvents, tn), 255 - VL: riseTimeTau > 0 256 - ? sumEventsWithRise(bounce.loadEvents, tn, riseTimeTau) 257 - : sumEventsAtTime(bounce.loadEvents, tn), 273 + VS: riseTimeTr > 0 274 + ? sumEventsWithLinearRamp(bounce.srcEvents, tn, riseTimeTr) 275 + : riseTimeTau > 0 276 + ? sumEventsWithRise(bounce.srcEvents, tn, riseTimeTau) 277 + : sumEventsAtTime(bounce.srcEvents, tn), 278 + VL: riseTimeTr > 0 279 + ? sumEventsWithLinearRamp(bounce.loadEvents, tn, riseTimeTr) 280 + : riseTimeTau > 0 281 + ? sumEventsWithRise(bounce.loadEvents, tn, riseTimeTau) 282 + : sumEventsAtTime(bounce.loadEvents, tn), 258 283 }; 259 284 } 260 285 ··· 265 290 buildBounceSeries, 266 291 sumEventsAtTime, 267 292 sumEventsWithRise, 293 + sumEventsWithLinearRamp, 268 294 riseShape, 295 + riseShapeLinear, 269 296 waveVoltageAt, 270 297 totalVoltageAt, 271 298 computeDynamicState,
+67
physics.test.js
··· 20 20 computeWaveParams, 21 21 buildBounceSeries, 22 22 sumEventsAtTime, 23 + sumEventsWithLinearRamp, 23 24 riseShape, 25 + riseShapeLinear, 24 26 } = globalThis.TLPhysics; 25 27 26 28 // Helper: assert two numbers are within `tol` of each other. ··· 201 203 const small = riseShape(0.001, 1); 202 204 near(small, 0.001, "linear regime approx", 1e-5); 203 205 }); 206 + 207 + // ──────────────────────────────────────────────────────────────────────────── 208 + // riseShapeLinear 209 + // ──────────────────────────────────────────────────────────────────────────── 210 + 211 + test("riseShapeLinear: dt≤0 → 0", () => { 212 + near(riseShapeLinear(0, 1), 0, "dt=0"); 213 + near(riseShapeLinear(-1, 1), 0, "dt<0"); 214 + }); 215 + 216 + test("riseShapeLinear: tr=0 (step) → 1 for dt>0", () => { 217 + near(riseShapeLinear(0.001, 0), 1, "tiny dt, tr=0"); 218 + near(riseShapeLinear(100, 0), 1, "large dt, tr=0"); 219 + }); 220 + 221 + test("riseShapeLinear: linear ramp 0→1 over tr", () => { 222 + near(riseShapeLinear(0.1, 1), 0.1, "10% of tr", 1e-12); 223 + near(riseShapeLinear(0.5, 1), 0.5, "50% of tr", 1e-12); 224 + near(riseShapeLinear(0.9, 1), 0.9, "90% of tr", 1e-12); 225 + near(riseShapeLinear(1.0, 1), 1.0, "exactly tr", 1e-12); 226 + near(riseShapeLinear(2.0, 1), 1.0, "past tr", 1e-12); 227 + }); 228 + 229 + test("riseShapeLinear: 10–90% rise time is exactly 0.8·tr", () => { 230 + const tr = 0.3; 231 + near(riseShapeLinear(0.1 * tr, tr), 0.1, "10%", 1e-12); 232 + near(riseShapeLinear(0.9 * tr, tr), 0.9, "90%", 1e-12); 233 + }); 234 + 235 + // ──────────────────────────────────────────────────────────────────────────── 236 + // SPICE golden-reference comparison 237 + // 238 + // Netlist: tline-oc.sp 239 + // Rs1=10Ω, Z0=50Ω (L=50nH/m C=20pF/m len=1m → τ_d=1ns), RL=∞, Vg=1V 240 + // PULSE( 0 1 0 100p 100p 1u 2u ) → linear ramp, TR = 100ps = 0.1·τ_d 241 + // 242 + // TSV columns: t[s] v(a) t[s] v(b) 243 + // ──────────────────────────────────────────────────────────────────────────── 244 + 245 + test("SPICE comparison: linear ramp, open-circuit load (tline-oc.sp)", () => { 246 + const TAU_D = 1e-9; // one-way delay in seconds 247 + const TR_NORM = 0.1; // rise time normalised to τ_d (100 ps / 1 ns) 248 + 249 + const model = { Vg: 1, Rg: 10, RL: Infinity, segments: [{ Z0: 50 }], reflectTol: 0.001 }; 250 + const waves = computeWaveParams(model); 251 + const bounce = buildBounceSeries(model, waves); 252 + 253 + // Parse TSV produced by ngspice "wrdata results-tline-oc.tsv v(a) v(b)" 254 + const tsv = readFileSync(path.join(__dirname, "results-tline-oc.tsv"), "utf8"); 255 + const rows = tsv.trim().split("\n") 256 + .map((line) => line.trim().split(/\s+/).map(Number)) 257 + .filter((cols) => cols.length >= 4); 258 + 259 + assert.ok(rows.length > 10, "TSV should have many rows"); 260 + 261 + const TOL = 1e-4; // 0.1 mV — well above floating-point noise, well below any physics error 262 + 263 + for (const [t_s, va_spice, , vb_spice] of rows) { 264 + const tn = t_s / TAU_D; 265 + const va_model = sumEventsWithLinearRamp(bounce.srcEvents, tn, TR_NORM); 266 + const vb_model = sumEventsWithLinearRamp(bounce.loadEvents, tn, TR_NORM); 267 + near(va_model, va_spice, `v(a) at t=${t_s.toExponential(3)}s`, TOL); 268 + near(vb_model, vb_spice, `v(b) at t=${t_s.toExponential(3)}s`, TOL); 269 + } 270 + });
+4 -4
render.js
··· 325 325 326 326 // ---- plot canvas ---- 327 327 // segments = model.segments (used to draw boundary tick marks) 328 - function drawPlot(ctx, w, h, tn, dyn, theme, riseTimeTau = 0, segments = [{ Z0: 50 }]) { 328 + function drawPlot(ctx, w, h, tn, dyn, theme, riseTimeTau = 0, riseTimeTr = 0, segments = [{ Z0: 50 }]) { 329 329 ctx.clearRect(0, 0, w, h); 330 330 ctx.fillStyle = theme.panel; 331 331 ctx.fillRect(0, 0, w, h); ··· 431 431 432 432 const panelH = PLOT_PANEL_H; 433 433 const { waveVoltageAt, totalVoltageAt } = TLPhysics; 434 - const smooth = riseTimeTau > 0; 434 + const smooth = riseTimeTau > 0 || riseTimeTr > 0; 435 435 436 436 // Panel 1 — sum of all waves 437 437 const top0 = PLOT_PAD_T; ··· 439 439 const y0 = (V) => top0 + 4 + (vHi - V) / (vHi - vLo) * (panelH - 8); 440 440 drawPanelFrame(top0, bot0, y0, "Sum (all waves)", vLo, vHi); 441 441 if (smooth) { 442 - drawSampledWave(ctx, xOfZ, y0, (z) => totalVoltageAt(z, launched, riseTimeTau), theme.ok, 2.4); 442 + drawSampledWave(ctx, xOfZ, y0, (z) => totalVoltageAt(z, launched, riseTimeTau, riseTimeTr), theme.ok, 2.4); 443 443 } else { 444 444 drawPiecewise(ctx, xOfZ, y0, sumSegs, theme.ok, 2.4); 445 445 } ··· 475 475 ctx.globalAlpha = alpha; 476 476 ctx.setLineDash(style.dash); 477 477 if (smooth) { 478 - drawSampledWave(ctx, xOfZ, y1, (z) => waveVoltageAt(wf, z, riseTimeTau), style.color, 2.0); 478 + drawSampledWave(ctx, xOfZ, y1, (z) => waveVoltageAt(wf, z, riseTimeTau, riseTimeTr), style.color, 2.0); 479 479 } else { 480 480 drawPiecewise(ctx, xOfZ, y1, segmentsForWave(wf), style.color, 2.0); 481 481 }
+1
tline_viz.html
··· 44 44 <label>Signal shape 45 45 <select id="riseMode"> 46 46 <option value="step">Step (instantaneous)</option> 47 + <option value="linear">Linear ramp (trapezoidal)</option> 47 48 <option value="exp">Exponential rise (RC)</option> 48 49 </select> 49 50 </label>