Beam: curvature (rise h of a stress-free curved rest shape, default 0); stepFor safe for k3 = 0
4 changed files+80−33
src/beam/README.mdmodified+4−2View file
@@ -1,8 +1,8 @@
11 # beam
22
3-Struck beam with bending stiffness, step 3 of "Listening for chaos" (after double-pendulum and fput-chain). Work in progress: step 1 (physics, animation, sped-up note with spectrogram), step 2 (Lyapunov exponent), step 3 (mode bars and mode histories), step 4 (ghost), step 5 (λ vs E).
3+Struck beam with bending stiffness, step 3 of "Listening for chaos" (after double-pendulum and fput-chain). Work in progress: step 1 (physics, animation, sped-up note with spectrogram), step 2 (Lyapunov exponent), step 3 (mode bars and mode histories), step 4 (ghost), step 5 (λ vs E), curvature (rise h of a curved rest shape).
44
5-Current state: N masses (2 to 64, default 8) on vertical rails, moving sideways only, with tension k, bending κ and a stretching nonlinearity k3 (below). Ends (a select next to Masses; changing it returns the beam to rest): clamped–clamped (default), pinned–pinned, or cantilever (clamped at the left, free at the right). Strike gives energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, on one mass alone (default mass 2) or in the shape of one normal mode. The card draws the beam from the side, displacements scaled to fit (scale given), with rails as faint lines, a hatched wall for a clamp and a triangle for a pin, plus the time, energy and relative energy error. The Ghost chip (as in fput-chain; controls in `../common/ghost.ts`) adds a second beam started from the same state with every mass moved by 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost", when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started. Default strike: the separation stays near 1e-10 m for ~10 s, then grows to 1.7e-8 m by 39.5 s, e^5.1, matching λ since the strike (0.13 /s × 39.5 s). A "Real time" card on the right has chips: Mode bars (on; the current harmonic energy of each mode relative to the strike, log scale over 12 decades, with the number of modes sharing the energy, 1 / sum (E_n / sum E)^2, and the mode frequency range; `../common/modebars.ts`), Mode history (off; each mode's share of the harmonic energy over the last 300 s as a heat map, mode 1 at the bottom; `LiveModeHistory` in `../common/modehistory.ts`), and charts (`../common/traces.ts`): Lyapunov (on; the largest Lyapunov exponent over the last 10 s and since the strike, last 60 s shown, the first 5 s after a strike left out of the y range) Mode energies (off; log, last 60 s, values below 1e-12 of the strike left out), Energy error (off) and Separation (log, last 60 s; offered only with the ghost on). The exponent is the two-trajectory method of `../common/lyapunov.ts` with the beam's shadow in `lyapunov.ts`: a hidden second beam 1e-8 away, displacements in units of A / sqrt(N) and momenta in units of m omega_1 A / sqrt(N), A the mode-1 amplitude at the nonlinear scale. Speed 0.1x to 20x real time; Pause or space. Parameters (m, k, κ, k3, a, τ, stretching form) fold out with derived values (mode frequencies, ratios to mode 1, the nonlinear scale, integrator step); Apply returns the beam to rest.
5+Current state: N masses (2 to 64, default 8) on vertical rails, moving sideways only, with tension k, bending κ and a stretching nonlinearity k3 (below). Ends (a select next to Masses; changing it returns the beam to rest): clamped–clamped (default), pinned–pinned, or cantilever (clamped at the left, free at the right). Strike gives energy E (log slider, 10 µJ to 100 J, default 2.51 J) as velocity, on one mass alone (default mass 2) or in the shape of one normal mode. The card draws the beam from the side, displacements scaled to fit (scale given), with rails as faint lines, a hatched wall for a clamp and a triangle for a pin, plus the time, energy and relative energy error. The Ghost chip (as in fput-chain; controls in `../common/ghost.ts`) adds a second beam started from the same state with every mass moved by 10^k / sqrt(N) m (total offset 10^k m, k = -12 to -1, default -10), run in lockstep without renormalization and drawn in translucent orange underneath; restarted on Strike, on "Restart ghost", when switched on or when the offset changes; a readout gives the separation (root sum of squared displacement differences, m) and the time since the ghost started. Default strike: the separation stays near 1e-10 m for ~10 s, then grows to 1.7e-8 m by 39.5 s, e^5.1, matching λ since the strike (0.13 /s × 39.5 s). A "Real time" card on the right has chips: Mode bars (on; the current harmonic energy of each mode relative to the strike, log scale over 12 decades, with the number of modes sharing the energy, 1 / sum (E_n / sum E)^2, and the mode frequency range; `../common/modebars.ts`), Mode history (off; each mode's share of the harmonic energy over the last 300 s as a heat map, mode 1 at the bottom; `LiveModeHistory` in `../common/modehistory.ts`), and charts (`../common/traces.ts`): Lyapunov (on; the largest Lyapunov exponent over the last 10 s and since the strike, last 60 s shown, the first 5 s after a strike left out of the y range) Mode energies (off; log, last 60 s, values below 1e-12 of the strike left out), Energy error (off) and Separation (log, last 60 s; offered only with the ghost on). The exponent is the two-trajectory method of `../common/lyapunov.ts` with the beam's shadow in `lyapunov.ts`: a hidden second beam 1e-8 away, displacements in units of A / sqrt(N) and momenta in units of m omega_1 A / sqrt(N), A the mode-1 amplitude at the nonlinear scale. Speed 0.1x to 20x real time; Pause or space. Parameters (m, k, κ, k3, rise h, a, τ, stretching form) fold out with derived values (mode frequencies, ratios to mode 1, the nonlinear scale, integrator step); Apply returns the beam to rest.
66
77 "Sped up · listen" (as in fput-chain): each Strike renders the same strike in a worker (`render-worker.ts`), sped up so the lowest mode sounds at the Pitch (5 to 1000 Hz, default 100), and plays it; the page starts muted. A damped note is the whole decay to -60 dB. A red warning shows when the top mode is above half the sample rate (it would alias). Signals (`signals.ts`): velocity (default), displacement or acceleration of mass j (default 2), or the sum of accelerations (the net force of the supports). Replay, Save WAV (named e.g. beam-clamped-local-N8-mass2-2.51J-v2-100Hz.wav), spectrogram over the whole note with dashed lines at the mode pitches. Chips: Spectrogram (on; frequency axis logarithmic by default, from a round number at or below half the lowest mode pitch, with a "log f" chip to switch to linear from 0; each pixel row shows the loudest FFT bin in its band; mode lines each drawn only while at least 6 px from their neighbours: at the default note the six lowest are drawn, modes 7 and 8 are 2 px apart; on the old linear axis to 10 kHz the modes filled the bottom eighth), Modes (off; the note's mode-energy shares over the whole note, one column per 256 samples, from the worker; `ModeHistory` in `../common/modehistory.ts`), Lyapunov (on; the note's exponent over the last 10 s of physical time, from a shadow run in the worker; the first 5 s of physical time left out of the y range; the shadow runs only while this chart or λ vs E is shown, as it doubles the cost) Energy (log) and λ vs E (off; `../common/scatter.ts` as in fput-chain: one point per 256 samples, accumulated over notes, log energy axis, colored by time into the note, earlier notes faded, hover for values, Clear; not cleared when parameters change). All but λ vs E span the whole note and share one cursor (playhead while playing; hover, click to pin). The render loop is shared with fput-chain (`../common/note-render.ts`). The status line gives the render time (default note 1.0 to 1.4 s with the shadow, 0.6 s without).
88
@@ -14,6 +14,8 @@ Live site: https://chaos-apps--listening-for-chaos.vault1-sites.magland.org/beam
1414 - Masses on rails: only sideways motion u_j, so every axial force is taken by the rails and tension and stretching are defined at a free end too.
1515 - Potential: springs between neighbours (and to each support), k d^2 / 2 with d = u_j - u_{j-1}; bending κ c^2 / 2 at each mass with both neighbours, c = u_{j-1} - 2 u_j + u_{j+1} (supports at u = 0). Pinned support: no extra term (ghost u_{-2} = -u_0 gives c = 0 there). Clamped: a joint at the support with ghost u_{-2} = u_0, c = 2 u_0, energy 2 κ u_0^2. Free end: no spring or joint beyond the last mass.
1616 - Stretching: "local", k3 d^4 / 4 per spring (FPUT); or "Kirchhoff", k3 S^2 / (4 nS) with S the sum of d^2 over the nS springs, which acts as one tension k3 S / nS shared by all springs. They agree when all springs are stretched alike.
17+- Curvature (toward gongs and cymbals): rise h (m, default 0 = straight) of a stress-free rest shape w0, a half sine between two supports, 1 - cos(pi x / 2L) for a cantilever (flat at the clamp). Stretching is measured from it: d^2 becomes e = d^2 + 2 D0 d (D0 the spring's rest offset) in both forms; bending is unchanged (linear). This adds linear stiffness and a quadratic nonlinearity (softening) to the quartic one (hardening). The animation still draws the displacement u from the rest shape, not w0 + u. The nonlinear scale in the derived values counts only the quartic part. Checked (Node): h = 0 bit-identical to before (all ends, both forms, k = 0 and 10, 50000 damped steps); forces = -grad V to 5e-11 and the linear stiffness = the Hessian at rest to 7e-9 for h = 0.05 and 0.3; energy error <= 7e-5 over 200 T1 up to 100 J.
18+- Pitch glide (mode-1 frequency from zero crossings, clamped, local, τ 540 s, cents from the linear mode 1): straight beam hardening, pitch falls as the note decays (mode-1 strike 2.51 J: +1237 c at 2 J, +352 at 0.1 J, +25 at 4 mJ). Curvature flips it: h = 0.1 m hardening at high energy, softening below ~0.1 J (pitch falls, then rises slightly); h = 0.2 m +302 c at 2 J, -232 at 0.5 J, -27 at 20 mJ (mode 2 at 1.98 x mode 1, near 2:1); h = 0.3 m softening throughout (-358 c at 2 J, -49 at 0.1 J), pitch rises as the note decays. Kirchhoff is similar. Default mass-2 strike, h = 0.3: lowest line rises from ~78 to 100 Hz over ~3 s of sound (17 s note, x219: mode 1 is 0.457 Hz, so the same τ covers fewer periods per second of sound), with steady difference tones (e.g. mode 2 - mode 1 at 79 Hz) from the quadratic term.
1719 - Normal modes of the linear beam from the stiffness matrix (assembled from the force routine) by Jacobi; mode energies as in fput-chain. Checked: pinned frequencies equal the closed form omega_n^2 = (4 k s^2 + 16 κ s^4) / m, s = sin(pi n / (2 (N + 1))), to 1e-14; N = 32 ratios clamped 2.75, 5.35, 8.78 (beam theory 2.76, 5.40, 8.93), cantilever 6.26, 17.48, 34.09 (6.27, 17.55, 34.39). At N = 8: clamped 1, 2.59, 4.68, 6.97, top 13.2; pinned 1, 3.88, 8.29, top 32.2; cantilever 1, 6.13, 16.4, top 75.
1820 - Nonlinear scale: the energy of mode 1 alone at the amplitude where its stretching energy equals its linear energy (N = 8, local: clamped 176 mJ, pinned 8.7 mJ, cantilever 1.1 mJ).
1921 - Damping: drag proportional to mass, every mode's amplitude decays as exp(-t / τ); applied exactly in half steps around each Verlet step, dissipated energy in W.
src/beam/beam.tsmodified+71−28View file
@@ -12,6 +12,14 @@
1212 // set by the total stretch. The two agree when every spring is stretched
1313 // alike, so switching between them changes how the modes are coupled, not
1414 // how strong the nonlinearity is.
15+// Curvature: the beam may rest in a curved shape w0 (an arch), with rise h
16+// (m) at its top (see restShape). Stretching is measured from that shape:
17+// d^2 above becomes e = (D0 + d)^2 - D0^2 = d^2 + 2 D0 d, with D0 the spring's
18+// offset at rest, so the rest shape is free of stress. Bending is measured
19+// from it too, which leaves the bending term as it is (it is linear). The
20+// stretching then has a quadratic part (forces ~ k3 D0 d^2) besides the
21+// quartic one, and adds linear stiffness k3 D0^2 per spring (local) or along
22+// D0 as a whole (Kirchhoff).
1523 // Ends: "pinned" (both ends: u = 0, no moment), "clamped" (both: u = 0, zero
1624 // slope) or "cantilever" (clamped at the left, free at the right). The
1725 // supports are nodes -1 and N at u = 0. A pinned support has no joint term
@@ -35,6 +43,7 @@ export interface Params {
3543 k3: number; // N/m^3, stretching
3644 stretch: Stretch;
3745 ends: Ends;
46+ rise: number; // m, height of the curved rest shape; 0 = straight
3847 a: number; // m, rest spacing (for drawing only; it does not enter the equations)
3948 tau: number; // s, amplitude decay time from drag (all modes); 0 = no damping
4049 }
@@ -63,23 +72,51 @@ export const copyState = (s: State): State => ({ u: Float64Array.from(s.u), p: F
6372 // Displacement of node i: a mass, or a support (0).
6473 const at = (u: Float64Array, i: number) => (i >= 0 && i < u.length ? u[i] : 0);
6574
66-// Sum of squared spring offsets (for the Kirchhoff form).
67-function stretchSum(P: Params, u: Float64Array): number {
68- let S = 0;
69- for (let j = 0, nS = springCount(P); j < nS; j++) S += (at(u, j) - at(u, j - 1)) ** 2;
70- return S;
75+// Rest shape of mass j (m), with x its distance from the left support in
76+// spacings and L the span: between two supports a half sine, rise sin(pi x / L)
77+// with L = N + 1; for a cantilever 1 - cos(pi x / (2 L)) times the rise with
78+// L = N, flat at the clamp and highest at the free end.
79+export function restShape(P: Params, j: number): number {
80+ if (P.rise === 0) return 0;
81+ const x = j + 1;
82+ return freeRight(P) ? P.rise * (1 - Math.cos((Math.PI * x) / (2 * P.N))) : P.rise * Math.sin((Math.PI * x) / (P.N + 1));
7183 }
7284
73-// Force on each mass, written into F. With k3 = 0 this is linear, -K u.
74-export function forces(P: Params, u: Float64Array, F: Float64Array): void {
85+// Rest offsets D0 of the springs (spring j joins nodes j - 1 and j), kept for
86+// the last parameters seen (forces runs every step).
87+let restKey: { N: number; ends: Ends; rise: number } | null = null;
88+let restD0 = new Float64Array(0);
89+function restOffsets(P: Params): Float64Array {
90+ if (!restKey || restKey.N !== P.N || restKey.ends !== P.ends || restKey.rise !== P.rise) {
91+ const w0 = (i: number) => (i >= 0 && i < P.N ? restShape(P, i) : 0);
92+ restD0 = Float64Array.from({ length: springCount(P) }, (_, j) => w0(j) - w0(j - 1));
93+ restKey = { N: P.N, ends: P.ends, rise: P.rise };
94+ }
95+ return restD0;
96+}
97+
98+// Force on each mass, written into F. With linear set, only the part linear
99+// in u, -K u (as with k3 = 0 for a straight beam).
100+export function forces(P: Params, u: Float64Array, F: Float64Array, linear = false): void {
75101 const { N, k, kappa, k3 } = P;
76102 F.fill(0);
77103 const nS = springCount(P);
104+ const D0 = restOffsets(P);
105+ // Stretch e of each spring: d^2 + 2 D0 d, or its linear part 2 D0 d.
106+ const stretch = (j: number, d: number) => (linear ? 2 * D0[j] * d : d * d + 2 * D0[j] * d);
78107 // Kirchhoff: one extra tension coefficient for all springs.
79- const kK = P.stretch === "kirchhoff" ? (k3 * stretchSum(P, u)) / nS : 0;
108+ let kK = 0;
109+ if (P.stretch === "kirchhoff") {
110+ let S = 0;
111+ for (let j = 0; j < nS; j++) S += stretch(j, at(u, j) - at(u, j - 1));
112+ kK = (k3 * S) / nS;
113+ }
80114 for (let j = 0; j < nS; j++) {
81115 const d = at(u, j) - at(u, j - 1);
82- const f = (P.stretch === "local" ? k + k3 * d * d : k + kK) * d; // spring j pulls its ends together
116+ // Spring j pulls its ends together with force dV/dd = (k + k3 e) d + k3 e D0
117+ // (local; Kirchhoff: kK for k3 e); the linear part drops k3 e d.
118+ const kn = P.stretch === "local" ? (linear ? 0 : k3 * d * d) + 2 * k3 * D0[j] * d : kK;
119+ const f = (linear ? k : k + kn) * d + kn * D0[j];
83120 if (j > 0) F[j - 1] += f;
84121 if (j < N) F[j] -= f;
85122 }
@@ -106,11 +143,14 @@ export function potential(P: Params, s: State): number {
106143 const { N, k, kappa, k3 } = P;
107144 const u = s.u;
108145 const nS = springCount(P);
146+ const D0 = restOffsets(P);
109147 let V = 0, S = 0;
110148 for (let j = 0; j < nS; j++) {
111- const d2 = (at(u, j) - at(u, j - 1)) ** 2;
112- V += (k * d2) / 2 + (P.stretch === "local" ? (k3 * d2 * d2) / 4 : 0);
113- S += d2;
149+ const d = at(u, j) - at(u, j - 1);
150+ const d2 = d ** 2;
151+ const e = d2 + 2 * D0[j] * d; // stretch from the rest shape
152+ V += (k * d2) / 2 + (P.stretch === "local" ? (k3 * e * e) / 4 : 0);
153+ S += e;
114154 }
115155 if (P.stretch === "kirchhoff") V += (k3 * S * S) / (4 * nS);
116156 for (let i = 0, nJ = jointCount(P); i < nJ; i++) V += (kappa * (at(u, i - 1) - 2 * u[i] + at(u, i + 1)) ** 2) / 2;
@@ -207,9 +247,10 @@ function jacobi(A: Float64Array[]): { values: Float64Array; V: Float64Array[] }
207247 return { values: Float64Array.from({ length: n }, (_, i) => A[i][i]), V };
208248 }
209249
210-// Normal modes of the linear beam (k3 = 0), mode n = 1..N stored at index
211-// n - 1 in order of frequency, from the stiffness matrix K (assembled from
212-// forces(), so it matches the dynamics exactly). Shapes S[n - 1] have unit
250+// Normal modes of the beam at small amplitude (the linear part of the forces,
251+// which includes the stiffness the curvature adds), mode n = 1..N stored at
252+// index n - 1 in order of frequency, from the stiffness matrix K (assembled
253+// from forces(), so it matches the dynamics exactly). Shapes S[n - 1] have unit
213254 // norm and start upward at the left; mode amplitudes are Q = S u, P = S p.
214255 export class Modes {
215256 readonly N: number;
@@ -221,13 +262,12 @@ export class Modes {
221262 const { N, m } = P;
222263 this.N = N;
223264 this.m = m;
224- const lin = { ...P, k3: 0 };
225265 const K: Float64Array[] = [];
226266 const e = new Float64Array(N), F = new Float64Array(N);
227267 for (let i = 0; i < N; i++) {
228268 e.fill(0);
229269 e[i] = 1;
230- forces(lin, e, F);
270+ forces(P, e, F, true);
231271 K.push(F.map((f) => -f)); // column i of K (K is symmetric)
232272 }
233273 const { values, V } = jacobi(K);
@@ -242,7 +282,7 @@ export class Modes {
242282 });
243283 }
244284 // Harmonic energy of each mode (J), P_n^2 / 2m + m omega_n^2 Q_n^2 / 2, into
245- // out. Their sum is the energy without the stretching (quartic) part.
285+ // out. Their sum is the energy without the nonlinear part of the stretching.
246286 energies(s: State, out: Float64Array): void {
247287 const m = this.m;
248288 for (let n = 0; n < this.N; n++) {
@@ -259,14 +299,16 @@ export class Modes {
259299 }
260300
261301 // Scale of the nonlinearity: the amplitude A of mode 1 alone (its shape has
262-// unit norm) at which its stretching (quartic) energy equals its linear
263-// energy, and the energy of mode 1 at that amplitude. Below that energy the
264-// beam rings as independent modes; above it the modes trade energy.
302+// unit norm) at which its quartic stretching energy (that of a straight beam;
303+// the quadratic part from curvature is left out) equals its linear energy,
304+// and the energy of mode 1 at that amplitude. Below that energy the beam
305+// rings as independent modes; above it the modes trade energy.
265306 function unitEnergies(P: Params, modes: Modes): { V2: number; V4: number } {
266307 const s = newState(P.N);
267308 s.u.set(modes.S[0]); // unit amplitude
268- const V2 = potential({ ...P, k3: 0 }, s);
269- return { V2, V4: potential(P, s) - V2 };
309+ const straight = { ...P, rise: 0 };
310+ const V2 = P.rise === 0 ? potential({ ...straight, k3: 0 }, s) : 0.5 * P.m * modes.omega[0] ** 2;
311+ return { V2, V4: potential(straight, s) - potential({ ...straight, k3: 0 }, s) };
270312 }
271313 export function nonlinearAmplitude(P: Params, modes: Modes): number {
272314 const { V2, V4 } = unitEnergies(P, modes);
@@ -279,12 +321,13 @@ export function nonlinearScale(P: Params, modes: Modes): number {
279321
280322 // Integrator step (s) for motion with energy E: 0.03 / omega, with omega from
281323 // the stiffest the beam can be. Linear part: the top mode, Kmax. Stretching
282-// adds at most 12 k3 d^2 with d = (4 E / k3)^(1/4), the largest offset a
283-// spring holding all of E could reach (Kirchhoff spreads it over nS springs,
284-// sqrt(nS) less).
324+// adds at most 12 k3 d^2 + 24 k3 D d with d = (4 E / k3)^(1/4), the largest
325+// offset a spring holding all of E could reach, and D the largest rest offset
326+// (Kirchhoff spreads it over nS springs, sqrt(nS) less).
285327 export function stepFor(P: Params, modes: Modes, E: number): number {
286- const d2 = Math.sqrt((4 * Math.max(E, 0)) / P.k3);
287- const nl = (12 * P.k3 * d2) / (P.stretch === "kirchhoff" ? Math.sqrt(springCount(P)) : 1);
328+ const d2 = P.k3 > 0 ? Math.sqrt((4 * Math.max(E, 0)) / P.k3) : 0;
329+ const D = restOffsets(P).reduce((a, x) => Math.max(a, Math.abs(x)), 0);
330+ const nl = (12 * P.k3 * d2 + 24 * P.k3 * D * Math.sqrt(d2)) / (P.stretch === "kirchhoff" ? Math.sqrt(springCount(P)) : 1);
288331 return 0.03 * Math.sqrt(P.m / (modes.Kmax + nl));
289332 }
290333
src/beam/index.htmlmodified+1−0View file
@@ -66,6 +66,7 @@
6666 <label>k <input id="p-k" type="number" step="any" /> <span class="unit">N/m (tension)</span></label>
6767 <label>κ <input id="p-kappa" type="number" step="any" /> <span class="unit">N/m (bending)</span></label>
6868 <label>k3 <input id="p-k3" type="number" step="any" /> <span class="unit">N/m³ (stretching)</span></label>
69+ <label>h <input id="p-rise" type="number" step="any" /> <span class="unit">m (rise of the rest shape, 0 = straight)</span></label>
6970 <label>a <input id="p-a" type="number" step="any" /> <span class="unit">m (spacing)</span></label>
7071 <label>τ <input id="p-tau" type="number" step="any" /> <span class="unit">s (decay, 0 = off)</span></label>
7172 <label class="wide">stretching <select id="p-stretch">
src/beam/main.tsmodified+4−3View file
@@ -39,7 +39,7 @@ const pauseButton = document.getElementById("pause") as HTMLButtonElement;
3939 // clamped at both ends, with the FPUT chain's stretching coefficient.
4040 // tau: amplitude decay time from drag; 540 s makes the default strike ring
4141 // down in about 8 s of sound at the default pitch.
42-let P: Params = { N: 8, m: 1, k: 0, kappa: 20, k3: 4000, stretch: "local", ends: "clamped", a: 0.5, tau: 540 };
42+let P: Params = { N: 8, m: 1, k: 0, kappa: 20, k3: 4000, stretch: "local", ends: "clamped", rise: 0, a: 0.5, tau: 540 };
4343 const SAMPLE_DT = 0.01; // s between plotted samples
4444
4545 // The beam. It sits at rest until the first Strike; changing parameters puts
@@ -205,6 +205,7 @@ const paramInputs = {
205205 k: document.getElementById("p-k") as HTMLInputElement,
206206 kappa: document.getElementById("p-kappa") as HTMLInputElement,
207207 k3: document.getElementById("p-k3") as HTMLInputElement,
208+ rise: document.getElementById("p-rise") as HTMLInputElement,
208209 a: document.getElementById("p-a") as HTMLInputElement,
209210 tau: document.getElementById("p-tau") as HTMLInputElement,
210211 };
@@ -460,10 +461,10 @@ endsSelect.addEventListener("change", () => setParams({ ...P, ends: endsSelect.v
460461 document.getElementById("param-apply")!.addEventListener("click", () => {
461462 const v = Object.fromEntries(Object.entries(paramInputs).map(([key, el]) => [key, Number(el.value)]));
462463 const ok = Object.entries(v).every(
463- ([key, x]) => Number.isFinite(x) && (x > 0 || (["tau", "k", "kappa"].includes(key) && x === 0)),
464+ ([key, x]) => Number.isFinite(x) && (x > 0 || (["tau", "k", "kappa", "rise"].includes(key) && x === 0)),
464465 );
465466 if (!ok || !(v.k > 0 || v.kappa > 0)) {
466- paramStatus.textContent = "parameters must be positive (tau, k or κ may be 0, not both k and κ)";
467+ paramStatus.textContent = "parameters must be positive (tau, rise, k or κ may be 0, not both k and κ)";
467468 return;
468469 }
469470 paramStatus.textContent = "";