/ listening-for-chaos / chaos-apps
Sign in
listening-for-chaos / chaos-apps
Section: pendulum's own crossings, Follow, click to strike by default
- The animated pendulum's crossings are drawn on the Poincare section - Optional Follow: the section's energy tracks the decaying pendulum - Click to strike is on by default
Jeremy Magland <jeremy.magland@gmail.com> committed commit 7012d4dd124b parent ecb951f Browse files
4 changed files+62−3
src/double-pendulum/README.mdmodified+1−1View file
@@ -2,7 +2,7 @@
22
33 Interactive double pendulum, step 1 of "Listening for chaos". Work in progress.
44
5-Current state: the pendulum hangs at rest until Strike, which starts it from hanging rest with the energy on the slider (default 50 J), plus a random relative jitter of up to 1e-9 in each angle and momentum (so chaotic strikes differ from one another while regular ones sound alike); Pause freezes the animation. The Ghost toggle adds a second pendulum (translucent orange) started from the same state with the outer arm's angle offset by 10^-12 to 10^-1 rad (default 10^-10), restarted on Strike or Reset, with a readout of the angular separation and the time since the ghost started: it grows exponentially when the motion is chaotic and stays tiny when it is regular. Charts "Separation" (ghost separation, log scale, last 30 s) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s). Parameters (masses, lengths, g, decay time, presets) fold out underneath. Toggle chips choose the plots: a spectrogram of the selected signal (frequency axis auto-scaled, dashed lines at the normal modes) and live traces of energies, energy balance error, angles, momenta and bob positions. The Poincare section panel (closed by default) has its own energy slider and uses the same masses and lengths, without damping: each time the inner arm swings down through vertical moving right, the outer arm's angle and momentum are plotted. 42 orbits start on a grid inside the allowed region (outlined) and leave 300 dots each, computed in a Web Worker and drawn as they arrive; regular orbits trace closed curves, chaotic ones scatter. Hover to pick out an orbit (with its Lyapunov exponent), click inside the outline to start a new orbit there (with "Click to strike" on, this also strikes the pendulum above from that exact state, at the section's energy, without jitter), and optionally color orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The Sound panel at the bottom starts closed; opening it switches the sound engine on (closing it switches it off). While it is open, every Strike plays a note: a separate, sped-up copy of the motion from the same strike; a damped note ends once it has decayed by 60 dB. The mute button (speaker icon) starts muted: notes are still computed and shown, just silent. The panel also has "Sound of" (the signal, also used by the animation spectrogram), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz), and optional whole-note displays that do not scroll (strike at the left edge, end of the note at the right): a spectrogram of the sound, the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (from 0), and a scatter of lambda against energy accumulated over notes (colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Hovering over any chart (animation or sound) shows a time cursor and the values there; a click pins it. Charts in the same card share the cursor. The time axis spans the predicted note length ((tau/2) ln 1e6 of physical time, divided by the speedup), is extended if the note runs longer, and is fitted to the note when it ends.
5+Current state: the pendulum hangs at rest until Strike, which starts it from hanging rest with the energy on the slider (default 50 J), plus a random relative jitter of up to 1e-9 in each angle and momentum (so chaotic strikes differ from one another while regular ones sound alike); Pause freezes the animation. The Ghost toggle adds a second pendulum (translucent orange) started from the same state with the outer arm's angle offset by 10^-12 to 10^-1 rad (default 10^-10), restarted on Strike or Reset, with a readout of the angular separation and the time since the ghost started: it grows exponentially when the motion is chaotic and stays tiny when it is regular. Charts "Separation" (ghost separation, log scale, last 30 s) and "Lyapunov" (largest Lyapunov exponent over a 10 s window and since the strike, last 30 s). Parameters (masses, lengths, g, decay time, presets) fold out underneath. Toggle chips choose the plots: a spectrogram of the selected signal (frequency axis auto-scaled, dashed lines at the normal modes) and live traces of energies, energy balance error, angles, momenta and bob positions. The Poincare section panel (closed by default) has its own energy slider and uses the same masses and lengths, without damping: each time the inner arm swings down through vertical moving right, the outer arm's angle and momentum are plotted. 42 orbits start on a grid inside the allowed region (outlined) and leave 300 dots each, computed in a Web Worker and drawn as they arrive; regular orbits trace closed curves, chaotic ones scatter. Hover to pick out an orbit (with its Lyapunov exponent), click inside the outline to start a new orbit there (with "Click to strike" on, the default, this also strikes the pendulum above from that exact state, at the section's energy, without jitter). The animated pendulum's own crossings are drawn as dark rings (the latest larger), so you can see which orbit it is on; they clear on each strike. With "Follow" on, the section's energy tracks the pendulum's as it decays (recomputed after a 3% change, at most every 2 s), and optionally color orbits by Lyapunov exponent (log scale, blue regular to red chaotic). The Sound panel at the bottom starts closed; opening it switches the sound engine on (closing it switches it off). While it is open, every Strike plays a note: a separate, sped-up copy of the motion from the same strike; a damped note ends once it has decayed by 60 dB. The mute button (speaker icon) starts muted: notes are still computed and shown, just silent. The panel also has "Sound of" (the signal, also used by the animation spectrogram), volume, pitch (where the small-amplitude lower mode lands, 30 to 1000 Hz, default 100 Hz), and optional whole-note displays that do not scroll (strike at the left edge, end of the note at the right): a spectrogram of the sound, the largest Lyapunov exponent of the motion being heard (10 s window, physical time), its energy (from 0), and a scatter of lambda against energy accumulated over notes (colored by time into the note, earlier notes faded, Clear button). Spectrogram on by default. Hovering over any chart (animation or sound) shows a time cursor and the values there; a click pins it. Charts in the same card share the cursor. The time axis spans the predicted note length ((tau/2) ln 1e6 of physical time, divided by the speedup), is extended if the note runs longer, and is fitted to the note when it ends.
66
77 Live site: https://double-pendulum--listening-for-chaos.vault1-sites.magland.org/ (built and deployed by `.github/workflows/pages.yml` on every push to main).
88
src/double-pendulum/index.htmlmodified+2−1View file
@@ -204,7 +204,8 @@
204204 <input id="pc-energy" type="range" value="20" min="0" max="120" step="0.1" aria-label="Section energy" />
205205 <span id="pc-energy-value" class="num">20.0 J</span>
206206 <label class="chip"><input id="pc-lambda" type="checkbox" /><span>Color by λ</span></label>
207- <label class="chip" title="Clicking a point also strikes the pendulum from that exact state, at this energy"><input id="pc-strike" type="checkbox" /><span>Click to strike</span></label>
207+ <label class="chip" title="Clicking a point also strikes the pendulum from that exact state, at this energy"><input id="pc-strike" type="checkbox" checked /><span>Click to strike</span></label>
208+ <label class="chip" title="The section's energy follows the pendulum's as it decays"><input id="pc-follow" type="checkbox" /><span>Follow</span></label>
208209 <span id="pc-status" class="muted"></span>
209210 </div>
210211 <figure>
src/double-pendulum/main.tsmodified+10−0View file
@@ -421,6 +421,8 @@ function strikeFrom(y: Float64Array, E: number) {
421421
422422 // Restart everything that follows a strike.
423423 function afterStrike() {
424+ poincare.clearLive();
425+ poincare.follow(energy(P, sim.y), true);
424426 resetGhost();
425427 lyap.start(sim);
426428 setRunning(true);
@@ -589,13 +591,21 @@ function frame(now: number) {
589591 last = now;
590592 if (!running) pending = 0;
591593 while (pending >= H) {
594+ const a = sim.y[0], b = sim.y[1], q = sim.y[3];
592595 sim.step(H);
596+ // Inner arm down through vertical moving right: a point on the section.
597+ if (a < 0 && sim.y[0] >= 0 && sim.y[0] - a < 1) {
598+ const f = -a / (sim.y[0] - a);
599+ const t2 = b + f * Math.atan2(Math.sin(sim.y[1] - b), Math.cos(sim.y[1] - b));
600+ poincare.addLive(Math.atan2(Math.sin(t2), Math.cos(t2)), q + f * (sim.y[3] - q));
601+ }
593602 ghost?.step(H);
594603 lyap.step(sim, H);
595604 pending -= H;
596605 traces.sample(sim.t);
597606 if (++stepCount % SPEC_DECIM === 0) spectrogram.push(signal.value(P, sim.y));
598607 }
608+ if (running) poincare.follow(energy(P, sim.y));
599609 draw();
600610 traces.draw();
601611 if (!specFig.hidden) spectrogram.draw();
src/double-pendulum/poincare.tsmodified+49−1View file
@@ -2,7 +2,9 @@
22 // pendulum above, no damping), the allowed region's outline, orbits seeded on
33 // a grid and computed in a worker, click to add an orbit (or, with "Click to
44 // strike" on, also strike the pendulum above from that exact state), hover to
5-// highlight one, and coloring per orbit or by Lyapunov exponent.
5+// highlight one, and coloring per orbit or by Lyapunov exponent. The animated
6+// pendulum's own crossings are drawn as dark dots ("you are here"); with
7+// "Follow" on, the section's energy tracks the pendulum's as it decays.
68
79 import { topEnergy, type Params } from "./pendulum";
810 import { p2Max, sectionState, seedGrid, thetaMax } from "./poincare-core";
@@ -13,6 +15,9 @@ const TOP = 18; // legend row
1315 const PAD = 10;
1416 const FONT = "11px ui-monospace, SFMono-Regular, Menlo, monospace";
1517 const GRID = [6, 7]; // seed grid: across th2, across p2
18+const LIVE_MAX = 400; // pendulum crossings kept
19+const FOLLOW_CHANGE = 0.03; // relative energy change that triggers a recompute
20+const FOLLOW_MIN_MS = 2000; // and at most this often
1621 // Lambda coloring is logarithmic between these (1/s): regular orbits measure
1722 // ~0.01 or less over a run (lambda -> 0 as the run grows), weakly chaotic ones
1823 // ~0.2 to 0.3, strongly chaotic ones ~1 to 1.5.
@@ -44,6 +49,9 @@ export class PoincarePanel {
4449 private status: HTMLElement;
4550 private debounce = 0;
4651 private strikeBox: HTMLInputElement;
52+ private followBox: HTMLInputElement;
53+ private lastFollow = 0;
54+ private live: number[] = []; // the pendulum's crossings: th2, p2 pairs
4755 private onStrike: (y: Float64Array, E: number) => void;
4856 // Axes (data ranges), set from E.
4957 private thMax = 1;
@@ -53,6 +61,7 @@ export class PoincarePanel {
5361 this.P = P;
5462 this.onStrike = onStrike;
5563 this.strikeBox = document.getElementById("pc-strike") as HTMLInputElement;
64+ this.followBox = document.getElementById("pc-follow") as HTMLInputElement;
5665 this.energyInput = document.getElementById("pc-energy") as HTMLInputElement;
5766 this.energyValue = document.getElementById("pc-energy-value")!;
5867 this.status = document.getElementById("pc-status")!;
@@ -70,6 +79,7 @@ export class PoincarePanel {
7079 else this.send({ type: "stop" });
7180 });
7281 this.energyInput.addEventListener("input", () => {
82+ this.followBox.checked = false; // setting the energy by hand stops following
7383 this.E = Number(this.energyInput.value);
7484 this.showEnergy();
7585 // Recompute shortly after the slider stops moving.
@@ -105,6 +115,32 @@ export class PoincarePanel {
105115 if (this.open) this.run();
106116 }
107117
118+ // A crossing of the animated pendulum (inner arm down, moving right).
119+ addLive(th2: number, p2: number): void {
120+ this.live.push(th2, p2);
121+ if (this.live.length > 2 * LIVE_MAX) this.live.splice(0, this.live.length - 2 * LIVE_MAX);
122+ this.changed = true;
123+ }
124+
125+ clearLive(): void {
126+ this.live = [];
127+ this.changed = true;
128+ }
129+
130+ // The pendulum's current energy: with "Follow" on, recompute the section at
131+ // it once it has changed by FOLLOW_CHANGE (at most every FOLLOW_MIN_MS), or
132+ // at once when `now` is set (a new strike).
133+ follow(E: number, now = false): void {
134+ if (!this.followBox.checked || !(E > 0) || Math.abs(E - this.E) < 1e-9 * E) return;
135+ const t = performance.now();
136+ if (!now && (Math.abs(E - this.E) < FOLLOW_CHANGE * this.E || t - this.lastFollow < FOLLOW_MIN_MS)) return;
137+ this.lastFollow = t;
138+ this.E = E;
139+ this.energyInput.value = String(E);
140+ this.showEnergy();
141+ if (this.open) this.run();
142+ }
143+
108144 private showEnergy(): void {
109145 this.energyValue.textContent = `${this.E.toFixed(1)} J`;
110146 }
@@ -248,6 +284,18 @@ export class PoincarePanel {
248284 }
249285 }
250286
287+ // The pendulum's own crossings: dark rings on white, the latest larger.
288+ ctx.strokeStyle = "#23262b";
289+ ctx.fillStyle = "#fff";
290+ for (let i = 0; i < this.live.length; i += 2) {
291+ const latest = i === this.live.length - 2;
292+ ctx.beginPath();
293+ ctx.arc(this.px(this.live[i]), this.py(this.live[i + 1]), latest ? 6 : 3.5, 0, 2 * Math.PI);
294+ ctx.fill();
295+ ctx.lineWidth = latest ? 2.5 : 1.5;
296+ ctx.stroke();
297+ }
298+
251299 // Legend row.
252300 ctx.font = FONT;
253301 ctx.textBaseline = "top";
↑↓ move↵openescclose