Integration: Forces into Motion
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
particle-integrator.ts
import { Vec2 } from "../linear/vec";import type { Tracer } from "../trace";/** How a step turns the acceleration into a new velocity and position. */export type IntegrationMethod = "euler" | "semi-implicit" | "verlet";/** Where the particle is and how it is moving, in world units and units per second. */export interface ParticleState { position: Vec2; velocity: Vec2;}export interface IntegratorOptions { method: IntegrationMethod; /** How many seconds one step advances the clock. */ dt: number; timeStep: number; /** The spring to the origin: a pull of -k p. */ stiffness: number; /** Linear drag: a pull of -c v against the motion (default 0, none). */ drag?: number; /** The downward pull, in units per second squared (default 0, none). */ gravity?: number;}/** The pulls on the particle at a state, per unit mass, so each is an acceleration. */export interface ParticleForces { spring: Vec2; drag: Vec2; gravity: Vec2; total: Vec2;}export interface ParticleEnergy { kinetic: number; potential: number; total: number;}/** * One particle of unit mass on a spring to the origin, under optional * drag and gravity, stepped by one of three integrators. Explicit Euler * moves along the old velocity and then updates it, so every step adds * energy and an orbit spirals out. Semi-implicit (symplectic) Euler * updates the velocity first and moves along the new one: the same * arithmetic in the other order, and the energy stays bounded. * Velocity Verlet takes half the kick, moves, measures the force at the * new place, and finishes the kick with the mean of the two: second * order, and symmetric in time. The forces are linear, so the exact * motion is a damped oscillation about the rest point on each axis, and * `exact(t)` gives it in closed form for the error and drift readouts; * `retune` measures it from the state the retune found, so a live run * keeps a truth to compare against. Every step traces the force, the * velocity update, the move, and an energy audit, in the order the * method does them. * * Time: O(1) per step. Space: O(1). */export class ParticleIntegrator { private current: ParticleState; private clock = 0; private tick = 0; private optionsValue: IntegratorOptions; /** The state and time the exact solution is measured from: the start, or the last retune. */ private reference: { state: ParticleState; time: number }; constructor( initial: ParticleState, options: IntegratorOptions, private readonly tracer?: Tracer, ) { ParticleIntegrator.validate(options); this.optionsValue = options; this.current = { position: initial.position, velocity: initial.velocity }; this.reference = { state: this.current, time: 0 }; } /** The method, the step, the spring, the drag, and the gravity in force. */ get options(): IntegratorOptions { return this.optionsValue; } /** The particle as it stands, after the steps so far. */ get state(): ParticleState { return this.current; } /** Seconds simulated so far: the steps times their dt. */ get time(): number { return this.clock; } /** How many steps have been taken. */ get ticks(): number { return this.tick; } /** * Changes the method or the forces without moving anything: the next * step obeys the new options from the same state, and the exact * solution is measured afresh from here, so the error readouts stay * meaningful. Invalid options throw and leave the old ones in force. */ retune(options: IntegratorOptions): void { ParticleIntegrator.validate(options); this.optionsValue = options; this.reference = { state: this.current, time: this.clock }; } private static validate(o: IntegratorOptions): void { if (!(o.dt > 0)) throw new RangeError(`dt ${o.dt} must be positive`); if (!(o.timeStep > 0)) throw new RangeError(`dt ${o.dt} must be positive`); if (!(o.stiffness > 0)) throw new RangeError(`stiffness ${o.stiffness} must be positive`); if ((o.drag ?? 0) < 0 || (o.gravity ?? 0) < 0) throw new RangeError("drag and gravity must not be negative"); // The closed form below is the underdamped one; heavier drag would need the other two cases. if ((o.drag ?? 0) ** 2 >= 4 * o.stiffness) throw new RangeError(`drag ${o.drag} is too heavy for stiffness ${o.stiffness}`); if (!["euler", "semi-implicit", "verlet"].includes(o.method)) throw new RangeError(`unknown method ${o.method}`); } /** The pulls at a state: the spring toward the origin, drag against the velocity, gravity down, and their sum. */ static forces(state: ParticleState, o: IntegratorOptions): ParticleForces { const spring = state.position.scale(-o.stiffness); const drag = state.velocity.scale(-(o.drag ?? 0)); const gravity = new Vec2(0, -(o.gravity ?? 0)); return { spring, drag, gravity, total: spring.add(drag).add(gravity) }; } /** Kinetic energy plus the spring's and gravity's potential, per unit mass. */ static energy(state: ParticleState, o: IntegratorOptions): ParticleEnergy { const kinetic = state.velocity.dot(state.velocity) / 2; const potential = (o.stiffness * state.position.dot(state.position)) / 2 + (o.gravity ?? 0) * state.position.y; return { kinetic, potential, total: kinetic + potential }; } /** * The exact state at time t, measured from the reference: on each axis * a damped oscillation about the rest point (the origin across, and * -g/k down, where gravity and the spring balance). */ exact(t: number): ParticleState { const o = this.options; const k = o.stiffness; const c = o.drag ?? 0; const omega = Math.sqrt(k - (c * c) / 4); const tau = t - this.reference.time; if (tau === 0) return this.reference.state; const decay = Math.exp((-c * tau) / 2); const cos = Math.cos(omega * tau); const sin = Math.sin(omega * tau); const axis = (u0: number, du0: number, rest: number): [number, number] => { const w0 = u0 - rest; const b = (du0 + (c * w0) / 2) / omega; const w = decay * (w0 * cos + b * sin); const dw = decay * ((-c / 2) * (w0 * cos + b * sin) + omega * (b * cos - w0 * sin)); return [rest + w, dw]; }; const { position, velocity } = this.reference.state; const [x, vx] = axis(position.x, velocity.x, 0); const [y, vy] = axis(position.y, velocity.y, -(o.gravity ?? 0) / k); return { position: new Vec2(x, y), velocity: new Vec2(vx, vy) }; } /** One step of the chosen method, then an audit of the energy against the exact motion. */ step(): ParticleState { this.tick += 1; const dt = this.options.dt; const timeStep = this.options.timeStep; const p = this.current.position; const particlePosition = this.current.position; const v = this.current.velocity; const particleVelocity = this.current.velocity; const a = this.accelerationAt(p, v); const acceleration = this.accelerationAt(particlePosition, particleVelocity); let next: ParticleState; if (this.options.method === "euler") { // Explicit: move along the velocity the step began with, then update it. const moved = this.move(p, v, dt); const moved = this.move(particlePosition, particleVelocity, timeStep); const pushed = this.push(v, a, dt); const pushed = this.push(particleVelocity, acceleration, timeStep); next = { position: moved, velocity: pushed }; } else if (this.options.method === "semi-implicit") { // Symplectic: update the velocity first, then move along the new one. const pushed = this.push(v, a, dt); const pushed = this.push(particleVelocity, acceleration, timeStep); const moved = this.move(p, pushed, dt); const moved = this.move(particlePosition, pushed, timeStep); next = { position: moved, velocity: pushed }; } else { // Velocity Verlet: half the kick, the move, the force at the new place, the mean of both kicks. const half = v.add(a.scale(dt / 2)); const half = particleVelocity.add(acceleration.scale(timeStep / 2)); const moved = this.move(p, half, dt); const moved = this.move(particlePosition, half, timeStep); const a2 = this.accelerationAt(moved, half); const pushed = this.push(v, a.add(a2).scale(0.5), dt); const pushed = this.push(particleVelocity, acceleration.add(a2).scale(0.5), timeStep); next = { position: moved, velocity: pushed }; } this.current = next; this.clock += dt; this.clock += timeStep; this.audit(); return next; } /** Steps `ticks` times and returns the history, the starting state first. */ run(ticks: number): ParticleState[] { if (!Number.isInteger(ticks) || ticks < 0) throw new RangeError(`ticks ${ticks} must be a whole number`); const history: ParticleState[] = [this.current]; for (let t = 0; t < ticks; t++) history.push(this.step()); return history; } /** The acceleration at a place and velocity: the forces summed, per unit mass. */ private accelerationAt(p: Vec2, v: Vec2): Vec2 { private accelerationAt(particlePosition: Vec2, particleVelocity: Vec2): Vec2 { const f = ParticleIntegrator.forces({ position: p, velocity: v }, this.options); const f = ParticleIntegrator.forces({ position: particlePosition, velocity: particleVelocity }, this.options); // ▸ sum the spring, the drag, and gravity at this place: the acceleration this.tracer?.emit("force", { tick: this.tick, x: p.x, y: p.y, sx: f.spring.x, sy: f.spring.y, dx: f.drag.x, dy: f.drag.y, gx: f.gravity.x, gy: f.gravity.y, ax: f.total.x, ay: f.total.y }); this.tracer?.emit("force", { tick: this.tick, x: particlePosition.x, y: particlePosition.y, sx: f.spring.x, sy: f.spring.y, dx: f.drag.x, dy: f.drag.y, gx: f.gravity.x, gy: f.gravity.y, ax: f.total.x, ay: f.total.y }); return f.total; } /** The velocity after a kick of a for dt seconds. */ private push(v: Vec2, a: Vec2, dt: number): Vec2 { private push(particleVelocity: Vec2, acceleration: Vec2, timeStep: number): Vec2 { const pushed = v.add(a.scale(dt)); const pushed = particleVelocity.add(acceleration.scale(timeStep)); // ▸ kick the velocity by the acceleration times dt this.tracer?.emit("accelerate", { tick: this.tick, ax: a.x, ay: a.y, vx: pushed.x, vy: pushed.y }); this.tracer?.emit("accelerate", { tick: this.tick, ax: acceleration.x, ay: acceleration.y, vx: pushed.x, vy: pushed.y }); return pushed; } /** The position after moving along v for dt seconds. */ private move(p: Vec2, v: Vec2, dt: number): Vec2 { private move(particlePosition: Vec2, particleVelocity: Vec2, timeStep: number): Vec2 { const moved = p.add(v.scale(dt)); const moved = particlePosition.add(particleVelocity.scale(timeStep)); // ▸ move along a velocity for dt: the old one, or the one just kicked this.tracer?.emit("move", { tick: this.tick, vx: v.x, vy: v.y, x: moved.x, y: moved.y }); this.tracer?.emit("move", { tick: this.tick, vx: particleVelocity.x, vy: particleVelocity.y, x: moved.x, y: moved.y }); return moved; } /** The energy now against the exact motion's, and how far the particle is from where it should be. */ private audit(): void { const energy = ParticleIntegrator.energy(this.current, this.options); const truth = this.exact(this.clock); const exact = ParticleIntegrator.energy(truth, this.options).total; const error = this.current.position.sub(truth.position).length(); // ▸ audit: kinetic plus potential, against the exact motion this.tracer?.emit("energy", { tick: this.tick, t: this.clock, kinetic: energy.kinetic, potential: energy.potential, total: energy.total, exact, drift: energy.total - exact, error }); }}50 steps, 5.00 s simulated: the energy drifted +158.77 from the exact motion's, and the particle ended 4.85 units from its exact place.