PointController.kt
src/jvmMain/kotlin/afield/simulation/physics/PointController.kt · 562 lines
package afield.simulation.physics
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector3fc
import afield.math.linalg.minus
import afield.math.linalg.plus
import afield.math.linalg.times
import afield.math.linalg.vec
import afield.math.linalg.x
import kotlin.math.*
PointDriver -- position in, force out.
val drv = PointDriver(DriverConfig(dt = 1.0 / 120.0, worldScale = 1.0))
drv.grab(target) // may be called every frame; target can move
val f = drv.update(pos) // per frame, fixed dt
drv.releaseGrab() // then keep calling update() -- see below
Vectors are Field's ordinary Vector3f; every scalar the controller reasons about -- gains, timescales, the identified plant -- stays Double. The file is otherwise standalone.
The whole controller state lives in the instance, so a rune-keyed slot holding one PointDriver per particle survives hot-reload with the learned plant model intact. Nothing needs re-priming except omega, which re-converges in ~1s.
What it does, in four coupled timescales
- ~dt model-inverting PD+I on acceleration, output through a bounded-curvature force limiter (magnitude, magnitude rate, and angular rate/acceleration on the direction).
- ~100ms discrete-time RLS identification of the host's own integrator: x(k+1) - 2x(k) + x(k-1) = dt^2 (b F(k) + c v(k) + d) exact under semi-implicit Euler, so b^ absorbs the host's numerical damping and dispersion, not just the mass. Gains are synthesised from (b,c,d); the only free knobs are physical.
- ~seconds omega hunting driven by sin(lagAngle) -- the direction-limiter's turn-rate deficit. Slow rise, fast knockback: self-organised criticality that pins omega to the capture/orbit bifurcation.
- ~10s winding memory in the limiter's rotation axis. Chirality flips become rare punctuated events with heavy-tailed dwell times.
Position-only input, done properly
- Velocity comes from the host's delta-position, which is the average over the interval and therefore centred at t - dt/2. Corrected by extrapolating dt/2 forward through the identified model -- pure phase recovery, no noise amplification. Uncorrected this donates omega*dt/2 of lag straight to the limiter and lowers the bandwidth ceiling.
- TWO velocity signals, never crossed: vHat (model observer) -> control law only vPrev (raw, prefiltered) -> identifier only Feeding the observer estimate to the identifier is circular -- it was built from the force and the current model, so the RLS would confirm whatever it already believes. That failure looks stable for minutes, then diverges.
- Jump guard: a single-frame teleport or collision reads as enormous velocity. Rejected on a running speed estimate; the identifier skips two frames while the second-difference window refills, and the model is left untouched.
- Identification freezes while the limiter is saturated. If the host solves springs as constraints (PBD/XPBD, fixed iterations) the effective stiffness depends on how hard we are pushing, which makes b^ a function of our own aggression -- a self-referential loop that can actually diverge. Freezing plus a slow forgetting factor breaks it.
- Release ramps magnitude to zero and FREEZES the aim, and keeps identifying through the ringdown. Free modal data with no control input to confound it, and the retained aim couples successive grabs.
vec
Vector3f is mutable and most of its methods write into the receiver, so the
rules here are: only the allocating operators from afield.math.linalg are
used, v / s is always written v * (1.0 / s) (the / operator on a
concrete Vector3f is the in-place member and would clobber its left operand),
and anything crossing the API boundary in either direction is copied. Length
and dot come back as Double so they compose with the scalar maths.
private fun Vector3fc.copy(): Vector3f = Vector3f(this)
private val Vector3fc.len: Double get() = length().toDouble()
private val Vector3fc.len2: Double get() = lengthSquared().toDouble()
private infix fun Vector3fc.dotD(o: Vector3fc): Double = dot(o).toDouble()
private fun Vector3fc.clampLen(m: Double): Vector3f {
val l = len
return if (l > m && l > 0.0) this * (m / l) else copy()
}
private fun Vector3fc.unitOr(f: Vector3fc): Vector3f {
val l = len
return if (l > 1e-12 && isFinite()) this * (1.0 / l) else f.copy()
}
config
data class DriverConfig
body — 71 lines, from line 98
data class DriverConfig(
/** MUST be fixed. Substep internally if your frame time varies -- this is what
* makes the SOC layer and the RNG stream bit-reproducible. */
val dt: Double = 1.0 / 120.0,
/** Characteristic length of your scene. All length-dependent thresholds derive
* from this, so it is the only world-scale number you need to set. */
val worldScale: Double = 1.0,
// --- inner loop -------------------------------------------------------
val zeta: Double = 0.85,
val omega0: Double = 4.0,
val omegaMin: Double = 0.5,
val omegaMax: Double = 80.0,
val integralFrac: Double = 0.08, // ki = integralFrac * omega^3
val integralLeak: Double = 0.25, // 1/s; 0.0 stores more energy for the release
// --- actuator ---------------------------------------------------------
val maxForce: Double = 40.0,
val magRateMax: Double = 2000.0, // N/s
/** rad/s. THE dial. This is a scale-free constraint, so unlike maxForce it
* never stops binding as the error shrinks. It sets a hard ceiling on
* closed-loop bandwidth: omega_d > angRateMax means capture is impossible at
* any amplitude and you get marginal spirals instead. */
val angRateMax: Double = 6.0,
/** Finite gives the aim its own inertia -- overshoot and precession, a gimbal
* with a torque limit. Reads as intention. Also adds the two state
* dimensions that push the grab-to-grab map from quasi-periodic to chaotic. */
val angAccelMax: Double = Double.POSITIVE_INFINITY,
val axisMemoryTau: Double = 0.25, // s, winding-sense hysteresis
val antipodalGuard: Double = 0.02, // rad
val releaseTau: Double = 0.03, // s
// --- identification ---------------------------------------------------
val lambda: Double = 0.997, // keep slow relative to the grab period
val p0: Double = 1e3,
val pTraceMax: Double = 1e4,
val filterRatio: Double = 6.0, // prefilter cutoff = filterRatio * omega
val deadzone: Double = 1e-3,
val bMin: Double = 1e-3, val bMax: Double = 1e4,
val cMax: Double = 60.0, val dMax: Double = 500.0,
val freezeIdWhenSaturated: Boolean = true,
val observerGain: Double = 0.4, // 0 = pure model, 1 = pure measurement
val jumpFactor: Double = 8.0, // teleport/collision rejection
// --- layer 3: SOC bandwidth hunting -----------------------------------
/** Chosen distance from the capture/orbit bifurcation, in units of turn-rate
* deficit. 0 = strictly convergent servo. >0.3 = permanent orbit. The
* interesting band is narrow. Put this on a fader. */
val lagTarget: Double = 0.10,
val gammaUp: Double = 0.35, // 1/s
val gammaDown: Double = 6.0, // 1/s -- asymmetry makes the avalanches
// --- layer 4: frustration (largely superseded by the limiter) ---------
/** The angle limiter generates orbits on its own, from an actuator constraint
* rather than a heuristic. Leave muOrbit at 0 unless angRateMax is high
* enough that the limiter never binds. */
val muOrbit: Double = 0.0,
val muDamp: Double = -0.35,
val rhoMax: Double = 1.4,
val rhoRate: Double = 2.0,
val stallTol: Double = 0.02,
val frustrationRise: Double = 0.5,
val frustrationFall: Double = 1.5,
// --- exploration ------------------------------------------------------
/** Radial only. Tangential dither is eaten by the angle limiter, and radial
* excitation is exactly what separates b from d. */
val ditherFrac: Double = 0.03,
val ditherTau: Double = 0.08,
val seed: Long = 0x51ED5EEDL,
)
data class Telemetry
data class Telemetry(
val omega: Double, // current closed-loop bandwidth, rad/s
val lag: Double, // rad, commanded vs applied force direction
val chatter: Double, // EMA of sin(lag); compare against lagTarget
val massEst: Double, val dragEst: Double, val biasEst: Vector3f,
val winding: Double, // accumulated signed aim rotation, rad
val aimDir: Vector3f, val forceMag: Double,
val frustration: Double, val rho: Vector3f,
val uncertainty: Double, // 0..1, drives radial dither
val saturated: Boolean, val idFrozen: Boolean, val jumped: Boolean,
val errMag: Double, val speed: Double,
)
driver
class PointDriver(val cfg: DriverConfig = DriverConfig())
class PointDriver(val cfg: DriverConfig = DriverConfig()) {
private val dt = cfg.dt
private val captureRadius = 0.01 * cfg.worldScale
private val minSpeed = 1e-4 * cfg.worldScale
targets
private var grabbed = false
private var target = Vector3f()
private var targetVel = Vector3f()
private var targetAcc = Vector3f()
measurement / observer
private var samples = 0
private var xPrev = Vector3f()
private var xPrev2 = Vector3f()
private var vPrev = Vector3f() // raw, for the identifier
private var vHat = Vector3f() // observer, for the control law
private var speedEma = 0.0
private var skipId = 0
private var jumped = false
identification
private val rls = Rls(5, cfg.lambda, cfg.p0).also { it.theta[0] = 1.0 }
private val phi = DoubleArray(5)
private var yF = Vector3f()
private var fFlt = Vector3f()
private var vFlt = Vector3f()
private var idFrozen = false
var omega = cfg.omega0; private set
slow layers
private var chatter = 0.0
private var lag = 0.0
private var errEma = 0.0
private var frustration = 0.0
private var rho = Vector3f()
private var iErr = Vector3f()
private var ou = Vector3f()
private val rng = Rng(cfg.seed)
actuator state
private var aim = Vector3f(0f, 1f, 0f)
private var mag = 0.0
private var axisMem = Vector3f(0f, 0f, 1f)
private var angVel = Vector3f()
var winding = 0.0; private set
private var fPrev = Vector3f()
private var saturated = false
---------------------------------------------------------------- api --
fun grab(target: Vector3f, targetVel: Vector3f = Vector3f(), targetAcc: Vector3f = Vector3f())
Call every frame while holding. The target may move; targetVel/Acc are feedforward and optional. The three vectors are copied, so the caller stays free to mutate whatever it passed in.
fun grab(target: Vector3f, targetVel: Vector3f = Vector3f(), targetAcc: Vector3f = Vector3f()) {
grabbed = true
this.target = target.copy(); this.targetVel = targetVel.copy(); this.targetAcc = targetAcc.copy()
}
fun releaseGrab() { grabbed = false }
Let go. Keep calling update() -- the magnitude ramps out, the aim freezes, and identification continues through the ringdown.
val isGrabbed get() = grabbed
fun update(position: Vector3f): Vector3f
Per frame. Position in, force out. The returned force is a fresh vector
the caller owns; position is only read.
body — 134 lines, from line 253
fun update(position: Vector3f): Vector3f {
if (!position.isFinite()) { reset(keepModel = true); return Vector3f() }
// ---- 1. velocity from delta position, with jump guard -------------
jumped = false
if (samples == 0) {
xPrev = position.copy(); xPrev2 = position.copy(); aim = Vector3f(0f, 1f, 0f)
samples = 1; fPrev = Vector3f()
return Vector3f()
}
val dx = position - xPrev
var vMeas = dx * (1.0 / dt)
val sp = vMeas.len
if (sp > cfg.jumpFactor * (speedEma + minSpeed) && samples > 2) {
// teleport or collision: reject the sample, hold velocity, refill the
// second-difference window before trusting the identifier again
jumped = true
vMeas = vPrev.copy()
skipId = 2
xPrev2 = position.copy(); xPrev = position.copy()
} else {
speedEma += (sp - speedEma) * (1.0 - exp(-dt * 4.0))
}
if (samples < 3) samples++
// ---- 2. discrete-time identification -----------------------------
idFrozen = skipId > 0 || (cfg.freezeIdWhenSaturated && saturated)
if (samples >= 3 && !jumped) {
// (x[k]-x[k-1]) - (x[k-1]-x[k-2]), not x[k] - 2x[k-1] + x[k-2]:
// algebraically the same, but it differences neighbours instead of
// cancelling two large f32 terms against each other.
val y = (dx - (xPrev - xPrev2)) * (1.0 / (dt * dt))
val wf = (cfg.filterRatio * omega).coerceIn(1.0, 0.45 / dt)
val a = 1.0 - exp(-wf * dt)
// filter both sides identically -- preserves the linear relation
yF = yF + (y - yF) * a
fFlt = fFlt + (fPrev - fFlt) * a
vFlt = vFlt + (vPrev - vFlt) * a
if (!idFrozen && yF.isFinite() && fFlt.isFinite() && vFlt.isFinite()) {
for (ax in 0 until 3) {
phi[0] = fFlt[ax].toDouble(); phi[1] = vFlt[ax].toDouble()
phi[2] = 0.0; phi[3] = 0.0; phi[4] = 0.0
phi[2 + ax] = 1.0
rls.update(phi, yF[ax].toDouble(), cfg.deadzone)
}
rls.boundTrace(cfg.pTraceMax)
}
}
if (skipId > 0) skipId--
// parameter projection: keeps the model inversion well-posed regardless
rls.theta[0] = rls.theta[0].guard(1.0).coerceIn(cfg.bMin, cfg.bMax)
rls.theta[1] = rls.theta[1].guard(0.0).coerceIn(-cfg.cMax, cfg.cMax)
for (i in 2..4) rls.theta[i] = rls.theta[i].guard(0.0).coerceIn(-cfg.dMax, cfg.dMax)
val b = rls.theta[0]; val c = rls.theta[1]
val d = vec(rls.theta[2], rls.theta[3], rls.theta[4])
// ---- 3. half-step extrapolation + observer ------------------------
// vMeas is centred at t - dt/2. Push it forward through the model: phase
// recovery with no noise amplification.
val aPred = fPrev * b + vMeas * c + d
val vExtrap = vMeas + aPred * (0.5 * dt)
vHat = vHat + (fPrev * b + vHat * c + d) * dt
vHat = vHat + (vExtrap - vHat) * cfg.observerGain.coerceIn(0.0, 1.0)
if (!vHat.isFinite()) vHat = vExtrap.copy()
// bookkeeping for the next frame's identifier window
xPrev2 = xPrev; xPrev = position.copy(); vPrev = vMeas
// ---- released: ramp out, freeze aim, keep identifying -------------
if (!grabbed) {
mag *= exp(-dt / max(cfg.releaseTau, 1e-6))
if (mag < 1e-9) mag = 0.0
iErr = Vector3f()
val f = aim * mag
fPrev = f.copy()
return f
}
// ---- 4. SOC bandwidth hunting, driven by the turn-rate deficit ----
val x = chatter / max(cfg.lagTarget, 1e-6)
val rate = if (x < 1.0) cfg.gammaUp * (1.0 - x) else -cfg.gammaDown * (x - 1.0)
omega = (omega * exp(rate * dt)).coerceIn(cfg.omegaMin, cfg.omegaMax)
val e = target - position
val ed = targetVel - vHat
val eMag = e.len
// ---- 5. frustration -> chirality (gated on !saturated) -----------
val prevErr = errEma
errEma += (eMag - errEma) * (1.0 - exp(-0.5 * dt))
val relProgress = ((prevErr - errEma) / dt) / (errEma * omega + 1e-12)
val stuck = eMag > captureRadius && relProgress < cfg.stallTol && !saturated
frustration += dt * if (stuck) cfg.frustrationRise * (1.0 - frustration)
else -cfg.frustrationFall * frustration
frustration = frustration.coerceIn(0.0, 1.0)
val s = frustration * frustration * (3.0 - 2.0 * frustration)
val mu = cfg.muDamp + (cfg.muOrbit - cfg.muDamp) * s
val omErr = (e x ed) * (1.0 / (e.len2 + 1e-12))
rho = rho + ((omErr * (mu / omega)).clampLen(cfg.rhoMax) - rho) * (1.0 - exp(-cfg.rhoRate * dt))
// ---- 6. control law ----------------------------------------------
val ki = cfg.integralFrac * omega * omega * omega
iErr = (iErr + e * dt) * exp(-cfg.integralLeak * dt)
val iLimit = 0.35 * b * cfg.maxForce / max(ki, 1e-9)
iErr = iErr.clampLen(iLimit)
var aCmd = (e + (rho x e)) * (omega * omega) +
ed * (2.0 * cfg.zeta * omega) +
iErr * ki + targetAcc
// radial dither, amplitude annealed by identification confidence
val decay = exp(-dt / cfg.ditherTau)
ou = ou * decay + vec(rng.gauss(), rng.gauss(), rng.gauss()) * sqrt(max(0.0, 1 - decay * decay))
val drive = (uncertainty() + 0.5 * s).coerceAtMost(1.5)
aCmd = aCmd + aCmd.unitOr(Vector3f()) * (aCmd.len * cfg.ditherFrac * drive * ou.x.toDouble())
// model inversion: cancel identified drag and field, normalise mass
val want = (aCmd - vHat * c - d) * (1.0 / b)
// ---- 7. bounded-curvature limiter --------------------------------
val f = limit(want)
// ---- 8. anti-windup against the actually-applied force -----------
if (saturated && ki > 1e-12) {
val aReal = f * b + vHat * c + d
iErr = (iErr - (aCmd - aReal) * (dt * omega / ki)).clampLen(iLimit)
}
// ---- 9. chatter = EMA of sin(lag) --------------------------------
chatter += (sin(lag) - chatter) * (1.0 - exp(-dt / (4.0 / omega)))
fPrev = f.copy()
return f
}
fun telemetry()
fun telemetry() = Telemetry(
omega = omega, lag = lag, chatter = chatter,
massEst = 1.0 / max(rls.theta[0], 1e-9), dragEst = rls.theta[1],
biasEst = vec(rls.theta[2], rls.theta[3], rls.theta[4]),
winding = winding, aimDir = aim.copy(), forceMag = mag,
frustration = frustration, rho = rho.copy(), uncertainty = uncertainty(),
saturated = saturated, idFrozen = idFrozen, jumped = jumped,
errMag = errEma, speed = speedEma,
)
fun reset(keepModel: Boolean = false)
fun reset(keepModel: Boolean = false) {
samples = 0; xPrev = Vector3f(); xPrev2 = Vector3f()
vPrev = Vector3f(); vHat = Vector3f(); speedEma = 0.0; skipId = 0
yF = Vector3f(); fFlt = Vector3f(); vFlt = Vector3f()
omega = cfg.omega0; chatter = 0.0; lag = 0.0; errEma = 0.0
frustration = 0.0; rho = Vector3f(); iErr = Vector3f(); ou = Vector3f()
aim = Vector3f(0f, 1f, 0f); mag = 0.0; axisMem = Vector3f(0f, 0f, 1f)
angVel = Vector3f(); winding = 0.0; fPrev = Vector3f(); saturated = false
if (!keepModel) { rls.resetAll(cfg.p0); rls.theta[0] = 1.0 }
}
private fun uncertainty() = sqrt(rls.trace() / (5.0 * cfg.p0)).coerceIn(0.0, 1.0)
------------------------------------------------------------ limiter --
Magnitude, magnitude-rate, and angular-rate (or angular-acceleration) limiting. The direction is a state on S^2 with bounded angular velocity: a Dubins problem on the sphere. Reversal costs pi/angRateMax seconds regardless of magnitude, so the controller must commit to a rotation sense.
The near-antipodal case is where the memory lives: u x uDes degenerates and the handedness is undetermined, so we fall back to a low-passed record of the recent rotation axis. Once winding one way, continuing is cheaper than unwinding -- flips become barrier crossings.
source — 51 lines, from line 424
private fun limit(desired: Vector3f): Vector3f {
val dMag = desired.len
val uDes = desired.unitOr(aim)
val ang = acos((aim dotD uDes).coerceIn(-1.0, 1.0))
var axis = aim x uDes
val axisLen = axis.len
axis = if (axisLen > cfg.antipodalGuard) axis * (1.0 / axisLen)
else (axisMem - aim * (axisMem dotD aim)).unitOr(anyPerp(aim))
val step: Double
if (cfg.angAccelMax.isInfinite()) {
step = min(ang, cfg.angRateMax * dt)
if (ang > 1e-12) aim = rotate(aim, axis, step)
} else {
val wTarget = axis * min(cfg.angRateMax, sqrt(2.0 * cfg.angAccelMax * ang))
angVel = angVel + (wTarget - angVel).clampLen(cfg.angAccelMax * dt)
val wMag = angVel.len
if (wMag > 1e-12) {
aim = rotate(aim, angVel * (1.0 / wMag), wMag * dt)
angVel = angVel - aim * (angVel dotD aim)
}
step = wMag * dt
}
aim = aim.unitOr(uDes)
winding += step * (if ((axis dotD axisMem) >= 0.0) 1.0 else -1.0)
axisMem = (axisMem + (axis - axisMem) * (1.0 - exp(-dt / max(cfg.axisMemoryTau, 1e-6)))).unitOr(axisMem)
val wantMag = min(dMag, cfg.maxForce)
mag = (mag + (wantMag - mag).coerceIn(-cfg.magRateMax * dt, cfg.magRateMax * dt))
.coerceIn(0.0, cfg.maxForce)
lag = acos((aim dotD uDes).coerceIn(-1.0, 1.0))
saturated = abs(wantMag - mag) > 1e-9 || lag > 1e-6
return aim * mag
}
private fun rotate(v: Vector3f, axis: Vector3f, theta: Double): Vector3f {
val par = axis * (axis dotD v)
val perp = v - par
return par + perp * cos(theta) + (axis x perp) * sin(theta)
}
private fun anyPerp(v: Vector3f): Vector3f {
val a = if (abs(v.x) < 0.9f) Vector3f(1f, 0f, 0f) else Vector3f(0f, 1f, 0f)
return (a - v * (a dotD v)).unitOr(Vector3f(0f, 0f, 1f))
}
}
private fun Double.guard(fallback: Double) = if (isFinite()) this else fallback
rls
source — 45 lines, from line 478
private class Rls(val n: Int, val lambda: Double, p0: Double) {
val theta = DoubleArray(n)
private val p = DoubleArray(n * n).also { for (i in 0 until n) it[i * n + i] = p0 }
private val pphi = DoubleArray(n)
private val kk = DoubleArray(n)
fun trace(): Double { var s = 0.0; for (i in 0 until n) s += p[i * n + i]; return s }
fun resetAll(p0: Double) {
p.fill(0.0); theta.fill(0.0)
for (i in 0 until n) p[i * n + i] = p0
}
fun update(phi: DoubleArray, y: Double, deadzone: Double): Double {
var nrm = 0.0
for (i in 0 until n) nrm += phi[i] * phi[i]
if (nrm < 1e-12 || !y.isFinite()) return 0.0
var pred = 0.0
for (i in 0 until n) pred += phi[i] * theta[i]
val err = y - pred
if (abs(err) < deadzone * (1.0 + sqrt(nrm))) return err
for (i in 0 until n) {
var s = 0.0
for (j in 0 until n) s += p[i * n + j] * phi[j]
pphi[i] = s
}
var pd = 0.0
for (i in 0 until n) pd += phi[i] * pphi[i]
val den = lambda + pd
if (den < 1e-12 || !den.isFinite()) return err
for (i in 0 until n) kk[i] = pphi[i] / den
for (i in 0 until n) theta[i] += kk[i] * err
for (i in 0 until n) for (j in 0 until n) p[i * n + j] = (p[i * n + j] - kk[i] * pphi[j]) / lambda
for (i in 0 until n) for (j in i + 1 until n) {
val m = 0.5 * (p[i * n + j] + p[j * n + i]); p[i * n + j] = m; p[j * n + i] = m
}
return err
}
fun boundTrace(maxTr: Double) {
val t = trace()
if (!t.isFinite()) { resetAll(maxTr / n); return }
if (t > maxTr) { val s = maxTr / t; for (i in p.indices) p[i] *= s }
}
}
rng
private class Rng(seed: Long) {
private var s = if (seed == 0L) -0x61c8864680b583ebL else seed
private var spare = Double.NaN
fun nextDouble(): Double {
var x = s; x = x xor (x shl 13); x = x xor (x ushr 7); x = x xor (x shl 17); s = x
return (x ushr 11).toDouble() * (1.0 / 9007199254740992.0)
}
fun gauss(): Double {
if (!spare.isNaN()) { val v = spare; spare = Double.NaN; return v }
var u: Double; var v: Double; var q: Double
do { u = 2 * nextDouble() - 1; v = 2 * nextDouble() - 1; q = u * u + v * v } while (q >= 1.0 || q == 0.0)
val f = sqrt(-2.0 * ln(q) / q); spare = v * f; return u * f
}
}
Tuning
Servo: angRateMax 30, lagTarget 0.02, gammaUp 0.2, integralLeak 0.5
Edge: angRateMax 6, lagTarget 0.12, gammaUp 0.5, gammaDown 3.0
-> omega pinned to the capture/orbit bifurcation, heavy-tailed
knockbacks. Log them and fit a slope.
Creature: angRateMax 4, angAccelMax 25, lagTarget 0.25, integralLeak 0.0
-> aim has inertia, precesses, orbits, punctuated chirality
flips. Log winding for dwell-time statistics.
Signals worth sonifying: omega (slow drifting pitch), chatter (burst envelope), winding rate (a pan or a chirality-keyed timbre). All three are causally locked to the visible motion.
On a spring lattice: set angRateMax above the phonon band edge (~2*sqrt(coordination * k_s / m)) to keep the lattice mode-avoidance behaviour, below it and the limiter becomes the binding constraint and the lattice structure drops out of the omega statistics. Multiple driven nodes will spontaneously spread their omega into non-overlapping bands.