Skip to content

alife_vehicles.kt

src/jvmMain/kotlin/afield/simulation/agents/alife_vehicles.kt · 706 lines

package afield.simulation.agents

import afield.media.image.Img
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tanh

Braitenberg vehicles whose neurons have internal state: two light sensors, two motor neurons, two wheels, and between them a small dynamical system rather than a wire.

In Braitenberg's book a vehicle's wiring is its personality: crossed excitatory wires make an "aggressor" that speeds into a light, uncrossed inhibitory wires make a "lover" that slows down and parks in front of it. Those vehicles are memoryless — the wheel speed is a function of what the sensors read now. Here each motor neuron carries three kinds of memory on three timescales:

  • membrane (a few steps): a leaky integrator, so the vehicle responds to a smoothed input;
  • hysteresis (state-dependent): self-excitation, which makes a steep activation bistable, so a neuron that switched on at one input level switches off at a lower one;
  • homeostasis (hundreds of steps, NeuronParams.adaptationRate): the threshold integrates the difference between the neuron's rate and a target rate, so a vehicle held in constant light gets used to it — a parked lover eventually drives off;
  • plasticity (thousands of steps, PlasticityParams): the four sensor→motor weights learn from reward-modulated eligibility traces of the motor noise, so a randomly wired population can discover, on its own, that crossed wiring brings it into the light.

Each of these is switched off by one parameter, which is how the measurements on the docs page make their claims: same seed, one ingredient removed, a number that changes.

Mutable, like BoidFlock: VehicleWorld.step advances the population in place.

Neurons

enum class Activation

The nonlinearity a motor neuron applies to its membrane potential. Each maps to roughly 0..1, so the wheel speed reads the same way whichever one is chosen.

enum class Activation {

The logistic 1 / (1 + e^-x): a smooth switch.

    SIGMOID,

(tanh x + 1) / 2: the logistic at twice the slope.

    TANH,

clamp(x, 0, 1): dead below zero, linear, saturating.

    RELU,

e^(-x²): tuned to a preferred level, quiet both above and below it. Wired to light it makes a vehicle that circles at a set distance instead of approaching or fleeing.

    BUMP;

fun apply(x: Double): Double

Apply this activation to x.

    fun apply(x: Double): Double = when (this) {
        SIGMOID -> 1.0 / (1.0 + exp(-x))
        TANH -> 0.5 * (tanh(x) + 1.0)
        RELU -> x.coerceIn(0.0, 1.0)
        BUMP -> exp(-x * x)
    }
}

data class NeuronParams

The internal dynamics of one motor neuron.

Per step, with input I, rate r and threshold θ:

u += (−u + I + selfExcitation·r − θ − bias) / tauMembrane
r  = activation(gain · u)
θ += adaptationRate · (r − targetRate),  clamped to ±thresholdLimit

data class NeuronParams(
    /** The nonlinearity; see [Activation]. */
    val activation: Activation = Activation.SIGMOID,
    /** Slope applied before the activation. */
    val gain: Double = 6.0,
    /** Membrane time constant in steps; `1.0` is no smoothing at all. */
    val tauMembrane: Double = 4.0,
    /**
     * Weight of the neuron's own output fed back into its membrane. For the sigmoid the neuron is
     * bistable — and so has a hysteresis loop — once `selfExcitation · gain / 4 > 1`. Zero is the
     * memoryless control.
     */
    val selfExcitation: Double = 0.0,
    /** A fixed offset subtracted from the membrane drive. */
    val bias: Double = 0.0,
    /**
     * How fast the threshold integrates `r − targetRate`, per step. Zero switches homeostasis off; a
     * typical value is `0.002`, a time constant of a few hundred steps.
     */
    val adaptationRate: Double = 0.0,
    /** The firing rate homeostasis pulls the neuron toward. */
    val targetRate: Double = 0.35,
    /** How far the threshold may wander either way. */
    val thresholdLimit: Double = 3.0,
) {

init

    init {
        require(tauMembrane >= 1.0) { "tauMembrane must be >= 1 step, got $tauMembrane" }
        require(adaptationRate >= 0.0) { "adaptationRate must be >= 0, got $adaptationRate" }
        require(thresholdLimit >= 0.0) { "thresholdLimit must be >= 0, got $thresholdLimit" }
    }
}

data class PlasticityParams

The slow learning level: reward-modulated plasticity of the four sensor→motor weights.

Each motor neuron's output carries exploration noise ξ. A trace e[m](../../../../../../reference/source/jvmMain/afield/ui/notation/StrokeSketch.md#L178) low-passes ξ_m · s_s — "this neuron fired more than it meant to while that sensor was lit" — over tauTrace steps. The reward is the light at the vehicle's body, R, and R̄ is its running average over tauBaseline steps. The update is w += learningRate · (R − R̄) · e: noise that was followed by more light than usual is written into the wiring. This is node perturbation (Williams' REINFORCE with a baseline), the simplest rule that needs neither a gradient nor a model of the world.

data class PlasticityParams(
    /** Zero is the control: the weights never move. Around `3.0` learns in ten thousand steps; much above `10` overshoots. */
    val learningRate: Double = 0.0,
    /** Standard deviation of the motor noise, which is both the exploration and what is learned from. */
    val noise: Double = 0.15,
    /** Eligibility-trace time constant in steps: how long after a twitch its consequence still counts. */
    val tauTrace: Double = 30.0,
    /** Reward-baseline time constant in steps. */
    val tauBaseline: Double = 50.0,
    /** Weights are clamped to `±maxWeight`. */
    val maxWeight: Double = 3.0,
    /**
     * Price of speed in the reward: `R = light − speedCost · speed`. Zero is the plain light reward;
     * with a cost, a vehicle that holds the same light while driving slower is the better one.
     */
    val speedCost: Double = 0.0,
) {

init

    init {
        require(learningRate >= 0.0) { "learningRate must be >= 0, got $learningRate" }
        require(noise >= 0.0) { "noise must be >= 0, got $noise" }
        require(tauTrace >= 1.0 && tauBaseline >= 1.0) { "time constants must be >= 1 step" }
        require(speedCost >= 0.0) { "speedCost must be >= 0, got $speedCost" }
    }
}

data class VehicleParams

Body, sensors and wheels of a vehicle, plus its two NeuronParams layers of dynamics.

data class VehicleParams(
    /** Angle of each sensor off the heading, in radians. */
    val sensorAngle: Double = 0.6,
    /** Distance of each sensor ahead of the body centre. */
    val sensorDistance: Double = 3.0,
    /** Wheel speed with the motor neuron silent. */
    val baseSpeed: Double = 0.15,
    /** Extra wheel speed at a rate of 1. */
    val motorSpeed: Double = 1.2,
    /** Distance between the wheels; smaller turns harder. */
    val axle: Double = 2.5,
    /** Motor-neuron dynamics. */
    val neuron: NeuronParams = NeuronParams(),
    /** Weight learning. */
    val plasticity: PlasticityParams = PlasticityParams(),
)

fun hysteresisSweep

The memoryless input→output steady state is the wrong question for a bistable neuron, so this measures what the neuron actually does: sweep the input from lo to hi and back over steps steps each way, slowly enough for the membrane to follow, and record the rate.

Returns the inputs and rates of the up-sweep and the down-sweep, each steps long.

fun hysteresisSweep(
    p: NeuronParams,
    lo: Double = -2.5,
    hi: Double = 2.0,
    steps: Int = 2000,
): HysteresisSweep {
    require(steps >= 2) { "steps must be >= 2, got $steps" }
    val noAdapt = p.copy(adaptationRate = 0.0)
    val state = DoubleArray(3)
    val inUp = DoubleArray(steps) { lo + (hi - lo) * it / (steps - 1) }
    val inDown = inUp.reversedArray()
    state[0] = lo
    state[1] = noAdapt.activation.apply(noAdapt.gain * lo)
    repeat(200) { neuronStep(noAdapt, state, lo) }
    val up = DoubleArray(steps) { neuronStep(noAdapt, state, inUp[it]) }
    val down = DoubleArray(steps) { neuronStep(noAdapt, state, inDown[it]) }
    return HysteresisSweep(inUp, up, inDown, down)
}

class HysteresisSweep

The result of hysteresisSweep: the same neuron driven up and then back down.

class HysteresisSweep(
    /** Inputs of the rising sweep. */
    val inputsUp: DoubleArray,
    /** Rates on the rising sweep. */
    val ratesUp: DoubleArray,
    /** Inputs of the falling sweep (the rising ones reversed). */
    val inputsDown: DoubleArray,
    /** Rates on the falling sweep. */
    val ratesDown: DoubleArray,
) {

val switchOn: Double get() = crossing(inputsUp, ratesUp) { it >= 0.5 }

The input at which the rising sweep first crosses a rate of 0.5, or NaN if it never does.

val switchOff: Double get() = crossing(inputsDown, ratesDown) { it < 0.5 }

The input at which the falling sweep first drops below 0.5, or NaN if it never does.

val width: Double get() = switchOn - switchOff

switchOn − switchOff: zero for a memoryless neuron, the loop's width for a bistable one.

val area: Double

The area between the two branches, in input × rate units.

    val area: Double
        get() {
            var a = 0.0
            val n = inputsUp.size
            for (i in 1 until n) {
                val dx = inputsUp[i] - inputsUp[i - 1]
                a += dx * (ratesDown[n - 1 - i] - ratesUp[i])
            }
            return abs(a)
        }

    private fun crossing(x: DoubleArray, r: DoubleArray, past: (Double) -> Boolean): Double {
        for (i in r.indices) if (past(r[i])) return x[i]
        return Double.NaN
    }

fun toImg(width: Int = 300, height: Int = 200): Img

Both branches plotted into a small RGB image: rising in orange, falling in blue, input across, rate up.

    fun toImg(width: Int = 300, height: Int = 200): Img {
        val out = Img.filled(width, height, 3, 0.06f)
        val lo = inputsUp.first()
        val hi = inputsUp.last()
        fun plot(x: DoubleArray, r: DoubleArray, c: FloatArray, dy: Int) {
            for (i in x.indices) {
                val px = ((x[i] - lo) / (hi - lo) * (width - 9) + 4).roundToInt()
                val py = ((1.0 - r[i]) * (height - 9) + 4).roundToInt() + dy
                for (oy in -1..1) for (ox in -1..1) {
                    val xx = (px + ox).coerceIn(0, width - 1)
                    val yy = (py + oy).coerceIn(0, height - 1)
                    for (k in 0 until 3) out.data[(yy * width + xx) * 3 + k] = c[k]
                }
            }
        }
        plot(inputsUp, ratesUp, floatArrayOf(1f, 0.55f, 0.15f), -1)
        plot(inputsDown, ratesDown, floatArrayOf(0.3f, 0.65f, 1f), 1)
        return out
    }
}

internal fun neuronStep(p: NeuronParams, state: DoubleArray, input: Double, noise: Double = 0.0): Double

One neuron step on state = [u, r, θ]; returns the new rate.

internal fun neuronStep(p: NeuronParams, state: DoubleArray, input: Double, noise: Double = 0.0): Double {
    val u = state[0] + (-state[0] + input + p.selfExcitation * state[1] - state[2] - p.bias) / p.tauMembrane
    val r = (p.activation.apply(p.gain * u) + noise).coerceIn(0.0, 1.0)
    var theta = state[2]
    if (p.adaptationRate > 0.0) {
        theta = (theta + p.adaptationRate * (r - p.targetRate)).coerceIn(-p.thresholdLimit, p.thresholdLimit)
    }
    state[0] = u
    state[1] = r
    state[2] = theta
    return r
}

The world

data class Light

A light: brightness intensity / (1 + d²/radius²) at distance d. A light with a velocity (vx, vy, in distance per step) drifts across the torus as the world steps.

data class Light(
    val x: Double,
    val y: Double,
    val intensity: Double = 1.0,
    val radius: Double = 18.0,
    val vx: Double = 0.0,
    val vy: Double = 0.0,
) {

fun brightnessAt(d: Double): Double = intensity / (1.0 + d * d / (radius * radius))

Brightness at distance d from this light.

val moving: Boolean get() = vx != 0.0 || vy != 0.0

Whether this light moves.

val speed: Double get() = sqrt(vx * vx + vy * vy)

Speed in distance per step.

fun orbitBias(distance: Double, weight: Double = 2.0, light: Light = Light(0.0, 0.0)): Double

The NeuronParams.bias that makes an Activation.BUMP neuron fire hardest when a sensor wired with weight reads the brightness distance away from light. Wired crossed and excitatory (VehicleWorld.AGGRESSOR), such a vehicle turns toward the light when it is farther than distance and away from it when nearer, so it circles at about that radius.

fun orbitBias(distance: Double, weight: Double = 2.0, light: Light = Light(0.0, 0.0)): Double {
    require(distance > 0.0) { "distance must be > 0, got $distance" }
    return weight * light.brightnessAt(distance)
}

class VehicleWorld

A population of vehicles among lights on a torus.

Per-vehicle state is in flat arrays. Weight w[4i + 2m + s] connects sensor s (0 left, 1 right) to motor neuron m (0 drives the left wheel, 1 the right), so crossed wiring is m ≠ s.

@param seed seed for the motor noise; the run is deterministic given it

class VehicleWorld(
    /** Domain width. */
    val width: Double,
    /** Domain height. */
    val height: Double,
    lights: List<Light>,
    /** Body x positions. */
    val x: DoubleArray,
    /** Body y positions. */
    val y: DoubleArray,
    /** Headings in radians. */
    val heading: DoubleArray,
    /** Sensor→motor weights, four per vehicle. */
    val w: DoubleArray,
    /** Neuron state, three per motor neuron (`u`, `r`, `θ`), six per vehicle. */
    val neurons: DoubleArray,
    /** Eligibility traces, four per vehicle. */
    val traces: DoubleArray,
    /** Running reward average, one per vehicle. */
    val baseline: DoubleArray,
    seed: Long = 1L,
) {

init

    init {
        require(width > 0.0 && height > 0.0) { "domain must be positive, got ${width}x$height" }
        val n = x.size
        require(n >= 1) { "a world needs at least one vehicle" }
        require(y.size == n && heading.size == n && baseline.size == n) { "per-vehicle arrays must match" }
        require(w.size == 4 * n && traces.size == 4 * n && neurons.size == 6 * n) { "per-synapse arrays must match" }
    }

    private val rng = kotlin.random.Random(seed)

var lights: List<Light>

The light sources, at their current positions; lights with a velocity move every step.

    var lights: List<Light> = lights
        private set

val speed: DoubleArray = DoubleArray(x.size)

Each vehicle's forward speed on the last step.

val count: Int get() = x.size

How many vehicles.

var steps: Int

Steps taken so far.

    var steps: Int = 0
        private set

fun light(px: Double, py: Double): Double

Brightness at a point, summed over lights under the toroidal metric.

    fun light(px: Double, py: Double): Double {
        var s = 0.0
        for (l in lights) {
            val dx = wrapDelta(px - l.x, width)
            val dy = wrapDelta(py - l.y, height)
            s += l.intensity / (1.0 + (dx * dx + dy * dy) / (l.radius * l.radius))
        }
        return s
    }

fun nearestLightWithin(i: Int, within: Double): Int

The index of the light within within of vehicle i, or -1.

    fun nearestLightWithin(i: Int, within: Double): Int {
        var best = -1
        var bestD = within * within
        for ((k, l) in lights.withIndex()) {
            val dx = wrapDelta(x[i] - l.x, width)
            val dy = wrapDelta(y[i] - l.y, height)
            val d = dx * dx + dy * dy
            if (d <= bestD) { bestD = d; best = k }
        }
        return best
    }

fun nearestLightDistance(i: Int): Double

Distance from vehicle i to the nearest light, under the toroidal metric.

    fun nearestLightDistance(i: Int): Double {
        var best = Double.MAX_VALUE
        for (l in lights) {
            val dx = wrapDelta(x[i] - l.x, width)
            val dy = wrapDelta(y[i] - l.y, height)
            best = min(best, dx * dx + dy * dy)
        }
        return sqrt(best)
    }

fun trailAlong(within: Double = 20.0): Double

How far vehicles near moving lights lag behind them: for every vehicle within within of a moving light, its offset from that light projected on the light's direction of travel, averaged. Negative is trailing, positive leading. Returns NaN when no vehicle is near a moving light. Divide by the light's speed for the lag in steps.

    fun trailAlong(within: Double = 20.0): Double {
        var sum = 0.0
        var n = 0
        for (i in 0 until count) {
            val k = nearestLightWithin(i, within)
            if (k < 0) continue
            val l = lights[k]
            if (!l.moving) continue
            val dx = wrapDelta(x[i] - l.x, width)
            val dy = wrapDelta(y[i] - l.y, height)
            sum += (dx * l.vx + dy * l.vy) / l.speed
            n++
        }
        return if (n == 0) Double.NaN else sum / n
    }

fun meanSpeed(): Double = speed.average()

Mean forward speed over the population on the last step.

fun rate(i: Int, m: Int): Double = neurons[6 * i + 3 * m + 1]

The rate of motor neuron m of vehicle i.

fun threshold(i: Int, m: Int): Double = neurons[6 * i + 3 * m + 2]

The threshold of motor neuron m of vehicle i.

fun crossing(i: Int): Double = (w[4 * i + 1] + w[4 * i + 2] - w[4 * i] - w[4 * i + 3]) / 2.0

How crossed-excitatory vehicle i is: (w_LR + w_RL − w_LL − w_RR) / 2. Positive steers toward light (Braitenberg's 2b), negative away (2a).

fun meanCrossing(): Double = (0 until count).sumOf { crossing(it) } / count

Mean crossing over the population.

fun meanLight(): Double = (0 until count).sumOf { light(x[it], y[it]) } / count

Mean light at the vehicles' bodies right now.

fun step(p: VehicleParams)

Advance every vehicle by one step.

body — 46 lines, from line 444
    fun step(p: VehicleParams) {
        val pl = p.plasticity
        val learning = pl.learningRate > 0.0
        val sense = DoubleArray(2)
        val xi = DoubleArray(2)
        val st = DoubleArray(3)
        for (i in 0 until count) {
            val h = heading[i]
            for (s in 0..1) {
                val a = h + if (s == 0) p.sensorAngle else -p.sensorAngle
                sense[s] = light(x[i] + cos(a) * p.sensorDistance, y[i] + sin(a) * p.sensorDistance)
            }
            val rates = DoubleArray(2)
            for (m in 0..1) {
                val input = w[4 * i + 2 * m] * sense[0] + w[4 * i + 2 * m + 1] * sense[1]
                xi[m] = if (pl.noise > 0.0) gaussian() * pl.noise else 0.0
                val o = 6 * i + 3 * m
                st[0] = neurons[o]; st[1] = neurons[o + 1]; st[2] = neurons[o + 2]
                rates[m] = neuronStep(p.neuron, st, input, xi[m])
                neurons[o] = st[0]; neurons[o + 1] = st[1]; neurons[o + 2] = st[2]
            }
            // Left wheel from motor 0, right wheel from motor 1; a faster left wheel turns right (−).
            val vl = p.baseSpeed + p.motorSpeed * rates[0]
            val vr = p.baseSpeed + p.motorSpeed * rates[1]
            val v = 0.5 * (vl + vr)
            speed[i] = v
            heading[i] = h + (vr - vl) / p.axle
            x[i] = wrap(x[i] + cos(heading[i]) * v, width)
            y[i] = wrap(y[i] + sin(heading[i]) * v, height)

            if (learning) {
                val reward = light(x[i], y[i]) - pl.speedCost * v
                val adv = reward - baseline[i]
                baseline[i] += (reward - baseline[i]) / pl.tauBaseline
                for (m in 0..1) for (s in 0..1) {
                    val k = 4 * i + 2 * m + s
                    traces[k] += (xi[m] * sense[s] - traces[k]) / pl.tauTrace
                    w[k] = (w[k] + pl.learningRate * adv * traces[k]).coerceIn(-pl.maxWeight, pl.maxWeight)
                }
            }
        }
        if (lights.any { it.moving }) {
            lights = lights.map { if (it.moving) it.copy(x = wrap(it.x + it.vx, width), y = wrap(it.y + it.vy, height)) else it }
        }
        steps++
    }

fun stepTimes(p: VehicleParams, n: Int) { repeat(n) { step(p) } }

step n times.

fun copy(seed: Long = 1L): VehicleWorld

An independent copy with the same noise seed, so a run can be forked into two arms.

    fun copy(seed: Long = 1L): VehicleWorld = VehicleWorld(
        width, height, lights, x.copyOf(), y.copyOf(), heading.copyOf(), w.copyOf(),
        neurons.copyOf(), traces.copyOf(), baseline.copyOf(), seed,
    )

fun withLights(lights: List<Light>, seed: Long = 1L): VehicleWorld

An independent copy of the vehicles (positions, wiring, neuron state) among a different set of lights — a trained population moved onto moving lamps, say.

    fun withLights(lights: List<Light>, seed: Long = 1L): VehicleWorld = VehicleWorld(
        width, height, lights, x.copyOf(), y.copyOf(), heading.copyOf(), w.copyOf(),
        neurons.copyOf(), traces.copyOf(), baseline.copyOf(), seed,
    )

    private fun gaussian(): Double {
        val u1 = max(1e-12, rng.nextDouble())
        val u2 = rng.nextDouble()
        return sqrt(-2.0 * kotlin.math.ln(u1)) * cos(2.0 * PI * u2)
    }

override fun toString(): String

    override fun toString(): String =
        "VehicleWorld($count vehicles, ${lights.size} lights, step $steps, mean light %.3f)".format(meanLight())

companion object

    companion object {

val AGGRESSOR = doubleArrayOf(0.0, 2.0, 2.0, 0.0)

Crossed excitatory: Braitenberg's aggressor, 2b.

val COWARD = doubleArrayOf(2.0, 0.0, 0.0, 2.0)

Uncrossed excitatory: Braitenberg's coward, 2a.

val LOVER = doubleArrayOf(-2.0, 0.0, 0.0, -2.0)

Uncrossed inhibitory: Braitenberg's lover, 3a — slows down facing a light and parks.

val EXPLORER = doubleArrayOf(0.0, -2.0, -2.0, 0.0)

Crossed inhibitory: Braitenberg's explorer, 3b — slows near a light but turns away from it.

fun random

count vehicles at random positions and headings, all wired with wiring (four weights), or each wired uniformly at random in ±randomWeight when wiring is null.

        fun random(
            count: Int,
            lights: List<Light>,
            width: Double = 240.0,
            height: Double = 240.0,
            wiring: DoubleArray? = null,
            randomWeight: Double = 2.0,
            seed: Long = 1L,
        ): VehicleWorld {
            require(count >= 1) { "count must be >= 1, got $count" }
            require(wiring == null || wiring.size == 4) { "wiring must be four weights" }
            val rng = kotlin.random.Random(seed)
            val x = DoubleArray(count) { rng.nextDouble() * width }
            val y = DoubleArray(count) { rng.nextDouble() * height }
            val h = DoubleArray(count) { rng.nextDouble() * 2.0 * PI }
            val w = DoubleArray(4 * count) {
                wiring?.get(it % 4) ?: ((rng.nextDouble() * 2.0 - 1.0) * randomWeight)
            }
            return VehicleWorld(
                width, height, lights, x, y, h, w,
                DoubleArray(6 * count), DoubleArray(4 * count), DoubleArray(count), seed + 1,
            )
        }
    }
}

private fun wrap(v: Double, size: Double): Double {
    val r = v % size
    return if (r < 0) r + size else r
}

private fun wrapDelta(d: Double, size: Double): Double {
    var r = d % size
    if (r > size / 2) r -= size
    if (r < -size / 2) r += size
    return r
}

class LightVisits(world: VehicleWorld, private val within: Double = 8.0)

Counts, per vehicle, how many times it arrives at a light other than the last one it was at — the number that tells a vehicle that parks from one that forages. Call observe once per step.

class LightVisits(world: VehicleWorld, private val within: Double = 8.0) {
    private val last = IntArray(world.count) { -1 }

val visits = IntArray(world.count)

Arrivals so far, per vehicle.

fun observe(world: VehicleWorld)

Record the current positions.

    fun observe(world: VehicleWorld) {
        for (i in 0 until world.count) {
            val k = world.nearestLightWithin(i, within)
            if (k >= 0 && k != last[i]) { visits[i]++; last[i] = k }
        }
    }

fun mean(): Double = visits.average()

Mean arrivals per vehicle.

class OrbitRadii

Collects every vehicle's distance to its nearest light, once per observe, for the distribution of radii an orbiting population keeps.

class OrbitRadii {
    private val samples = ArrayList<Double>()

fun observe(world: VehicleWorld)

Record each vehicle's current distance to its nearest light.

    fun observe(world: VehicleWorld) {
        for (i in 0 until world.count) samples += world.nearestLightDistance(i)
    }

val size: Int get() = samples.size

How many distances have been recorded.

fun quantile(q: Double): Double

The q quantile (0..1) of the recorded distances.

    fun quantile(q: Double): Double {
        require(samples.isNotEmpty()) { "no samples yet" }
        val sorted = samples.sorted()
        return sorted[((sorted.size - 1) * q.coerceIn(0.0, 1.0)).roundToInt()]
    }

fun median(): Double = quantile(0.5)

Median recorded distance.

fun fractionWithin(lo: Double, hi: Double): Double

Fraction of recorded distances inside lo..hi.

    fun fractionWithin(lo: Double, hi: Double): Double = samples.count { it in lo..hi }.toDouble() / max(1, samples.size)

fun distances(every: Int = 1): List<Double>

The recorded distances, every everyth one, in recording order — for handing to a distribution plot such as the named violin overload, which serialises every value it is given.

    fun distances(every: Int = 1): List<Double> {
        require(every >= 1) { "every must be at least 1, got $every" }
        return samples.filterIndexed { i, _ -> i % every == 0 }
    }
}

class VehicleCanvas(val world: VehicleWorld, val scale: Double = 1.5, val fade: Float = 0.992f)

A fading trail buffer for drawing a VehicleWorld: the light field as a dim backdrop, each vehicle's recent path as a streak coloured by its motor rates (red the left neuron, green the right, so a vehicle driving straight at full tilt is yellow), and the bodies on top.

Call record once per step and image whenever a frame is wanted.

class VehicleCanvas(val world: VehicleWorld, val scale: Double = 1.5, val fade: Float = 0.992f) {

val w = max(1, (world.width * scale).roundToInt())

Pixels across.

val h = max(1, (world.height * scale).roundToInt())

Pixels down.

    private val trails = FloatArray(w * h * 3)
    private var backdropFor: List<Light>? = null
    private var backdropCache = FloatArray(0)

The light field, recomputed whenever the lights have moved since it was last drawn.

    private val backdrop: FloatArray
        get() {
            if (backdropFor != world.lights) {
                backdropCache = drawBackdrop()
                backdropFor = world.lights
            }
            return backdropCache
        }

    private fun drawBackdrop(): FloatArray {
        val b = FloatArray(w * h * 3)
        for (py in 0 until h) for (px in 0 until w) {
            val l = min(1.0, world.light(px / scale, py / scale)).toFloat()
            val o = (py * w + px) * 3
            b[o] = 0.05f + 0.30f * l * l
            b[o + 1] = 0.05f + 0.22f * l * l
            b[o + 2] = 0.08f + 0.10f * l
        }
        return b
    }

fun record()

Fade the trails and add the vehicles' current positions.

    fun record() {
        for (k in trails.indices) trails[k] *= fade
        for (i in 0 until world.count) {
            val px = (world.x[i] * scale).roundToInt()
            val py = (world.y[i] * scale).roundToInt()
            val r = world.rate(i, 0).toFloat()
            val g = world.rate(i, 1).toFloat()
            splat(trails, px, py, 0.25f + 0.75f * r, 0.25f + 0.75f * g, 0.55f, 0.5f, 0)
        }
    }

fun image(): Img

Backdrop + trails + bodies.

    fun image(): Img {
        val out = FloatArray(w * h * 3)
        val bd = backdrop
        for (k in out.indices) out[k] = min(1f, bd[k] + trails[k])
        for (i in 0 until world.count) {
            splat(out, (world.x[i] * scale).roundToInt(), (world.y[i] * scale).roundToInt(), 1f, 1f, 1f, 1f, 1)
        }
        return Img(w, h, 3, out)
    }

    private fun splat(buf: FloatArray, px: Int, py: Int, r: Float, g: Float, b: Float, a: Float, radius: Int) {
        for (dy in -radius..radius) for (dx in -radius..radius) {
            val xx = Math.floorMod(px + dx, w)
            val yy = Math.floorMod(py + dy, h)
            val o = (yy * w + xx) * 3
            buf[o] = min(1f, buf[o] + r * a)
            buf[o + 1] = min(1f, buf[o + 1] + g * a)
            buf[o + 2] = min(1f, buf[o + 2] + b * a)
        }
    }
}