diff --git a/software/contrib/README.md b/software/contrib/README.md
index d3a0ed281..0a91c0805 100644
--- a/software/contrib/README.md
+++ b/software/contrib/README.md
@@ -91,6 +91,16 @@ source with multiple wave shapes, optional quantization, euclidean rhythm output
Author: [chrisib](https://github.com/chrisib)
Labels: clock, euclidean, gate, lfo, quantizer, random, trigger
+### Particle Physics \[ [documentation](/software/contrib/particle_physics.md) | [script](/software/contrib/particle_physics.py) \]
+An irregular LFO based on a basic 1-dimensional physics simulation. Outputs triggers when a particle bounces under the effects of gravity. Outputs control signals
+based on the particle's position and velocity.
+
+While not technically random, the effects of changing the particle's initial conditions, gravity, and elasticity coefficient can create unpreditable rhythms.
+
+Author: [chrisib](https://github.com/chrisib)
+
Labels: gate, lfo, sequencer, random, trigger
+
+
### Poly Square \[ [documentation](/software/contrib/poly_square.md) | [script](/software/contrib/poly_square.py) \]
Six independent oscillators which output on CVs 1-6.
diff --git a/software/contrib/menu.py b/software/contrib/menu.py
index 877bd793f..4669f47a3 100644
--- a/software/contrib/menu.py
+++ b/software/contrib/menu.py
@@ -41,6 +41,7 @@
["MasterClock", "contrib.master_clock.MasterClock"],
["NoddyHolder", "contrib.noddy_holder.NoddyHolder"],
["Pam's Workout", "contrib.pams.PamsWorkout"],
+ ["Particle Phys.", "contrib.particle_physics.ParticlePhysics"],
["Piconacci", "contrib.piconacci.Piconacci"],
["PolyrhythmSeq", "contrib.polyrhythmic_sequencer.PolyrhythmSeq"],
["PolySquare", "contrib.poly_square.PolySquare"],
diff --git a/software/contrib/particle_physics.md b/software/contrib/particle_physics.md
new file mode 100644
index 000000000..8ec179904
--- /dev/null
+++ b/software/contrib/particle_physics.md
@@ -0,0 +1,63 @@
+# Particle Physics
+
+This program implements a very basic particle physics model where an object falls under gravity and bounces. Every
+bounce reduces the velocity proportional to an elasticity constant.
+
+## I/O Mapping
+
+| I/O | Usage
+|---------------|-------------------------------------------------------------------|
+| `din` | Releases the particle from initial conditions |
+| `ain` | Unused |
+| `b1` | Releases the particle from initial conditions |
+| `b2` | Alt button to be held while turning `k1` or `k2` |
+| `k1` | Edit the drop height (alt: edit gravity) |
+| `k2` | Edit elasticity coefficient (alt: edit initial velocity) |
+| `cv1` - `cv5` | Output signals. Configuration is explained below |
+| `cv6` | Unused |
+
+## CV Outputs
+
+`cv1` outputs a 5V trigger every time the particle touches the ground. When at rest this trigger becomes a gate,
+indicating that the particle is always touching the ground.
+
+`cv2` outputs a 5V trigger every time the particle reaches its peak altitude for the bounce and begins falling
+again. When at rest this becomes a gate, indicating that the particle is always at peak altitude.
+
+`cv3` outputs a gate when the particle is at rest. This can be patched into `din` to automatically reset the
+particle when it comes to rest.
+
+`cv4` outputs a control signal in the range `[0, 10]V`, proportional to the particles height.
+
+`cv5` outputs a control signal in the range `[0, 10]V`, proportional to the particles absolute velocity. (Because
+EuroPi can only output positive voltages this output will be high when the particle is moving quickly up or down.)
+
+## Physics, Explained
+
+For clarity, positive values are up and negative values are down. Units don't matter, but if it helps assume
+everything is SI units (meters, m/s, m/s^2).
+
+- let `(y, dy)` be a 1-D particle, representing its height `y` and velocity `dy`
+- let `h` be the particle's initial height above the ground
+- let `v` be the particle's initial velocity in the vertical direction
+- let `g` be the acceleration due to gravity
+- let `e` be the elasticity coefficient, such that `0 < e < 1`
+- when released, `(y, dy) = (h, v)`
+
+At every tick of the main loop, the particle's position and velocity are updated:
+```
+dt = the time between this tick and the previous one
+
+dy' = dy - g * dt
+y' = y + dy * dt
+```
+
+If `y'` is less than or equal to zero we assume the particle has it the ground and further modify `dy'`:
+
+```
+dy' = |dy| * e
+```
+
+To avoid floating point rounding causing the particle to bounce forever, we assume it has come to rest if its
+peak height for any bounce is `0.002`. When this occurs, `y` and `dy` are both set to zero and the simulation
+effectively stops.
diff --git a/software/contrib/particle_physics.py b/software/contrib/particle_physics.py
new file mode 100644
index 000000000..9d3fe09c2
--- /dev/null
+++ b/software/contrib/particle_physics.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+"""A 1D physics simulating LFO, inspired by the ADDAC503
+(https://www.addacsystem.com/en/products/modules/addac500-series/addac503)
+"""
+
+from europi import *
+from europi_script import EuroPiScript
+
+from experimental.knobs import KnobBank
+
+import math
+import time
+
+
+EARTH_GRAVITY = 9.8
+MIN_GRAVITY = 0.1
+MAX_GRAVITY = 20
+
+MIN_HEIGHT = 0.1
+MAX_HEIGHT = 10.0
+
+MIN_SPEED = -5.0
+MAX_SPEED = 5.0
+
+MIN_ELASTICITY = 0.0
+MAX_ELASTICITY = 0.9
+
+## If a bounce reaches no higher than this, assume we've come to rest
+ASSUME_STOP_PEAK = 0.002
+
+def rescale(x, old_min, old_max, new_min, new_max):
+ if x <= old_min:
+ return new_min
+ elif x >= old_max:
+ return new_max
+ else:
+ return (x - old_min) / (old_max - old_min) * (new_max - new_min) + new_min
+
+
+class Particle:
+ def __init__(self):
+ self.y = 0.0
+ self.dy = 0.0
+
+ self.last_update_at = time.ticks_ms()
+
+ self.hit_ground = False
+ self.reached_apogee = False
+ self.stopped = True
+
+ self.peak_height = 0.0
+
+ def set_initial_position(self, height, velocity):
+ self.peak_height = height
+ self.y = height
+ self.dy = velocity
+ self.last_update_at = time.ticks_ms()
+
+ def update(self, g, elasticity):
+ """Update the particle position based on the ambient gravity & elasticy of the particle
+ """
+ now = time.ticks_ms()
+ delta_t = time.ticks_diff(now, self.last_update_at) / 1000.0
+
+ new_dy = self.dy - delta_t * g
+ new_y = self.y + self.dy * delta_t
+
+ # if we were going up, but now we're going down we've reached apogee
+ self.reached_apogee = new_dy <= 0 and self.dy >= 0
+
+ if self.reached_apogee:
+ self.peak_height = self.y
+
+ # if the vertical position is zero or negative, we've hit the ground
+ self.hit_ground = new_y <= 0
+
+ if self.hit_ground:
+ #new_y = 0
+ new_dy = abs(self.dy * elasticity) # bounce upwards, reduding the velocity by our elasticity modifier
+
+ self.stopped = self.peak_height <= ASSUME_STOP_PEAK
+
+ if self.stopped:
+ new_y = 0
+ new_dy = 0
+
+ self.dy = new_dy
+ self.y = new_y
+ self.last_update_at = now
+
+class ParticlePhysics(EuroPiScript):
+ def __init__(self):
+ settings = self.load_state_json()
+
+ self.gravity = settings.get("gravity", 9.8)
+ self.initial_velocity = settings.get("initial_velocity", 0.0)
+ self.release_height = settings.get("height", 10.0)
+ self.elasticity = settings.get("elasticity", 0.75)
+
+ self.k1_bank = (
+ KnobBank.builder(k1)
+ .with_locked_knob("height", initial_percentage_value=rescale(self.release_height, MIN_HEIGHT, MAX_HEIGHT, 0, 1))
+ .with_locked_knob("gravity", initial_percentage_value=rescale(self.gravity, MIN_GRAVITY, MAX_GRAVITY, 0, 1))
+ .build()
+ )
+
+ self.k2_bank = (
+ KnobBank.builder(k2)
+ .with_locked_knob("elasticity", initial_percentage_value=rescale(self.elasticity, MIN_ELASTICITY, MAX_ELASTICITY, 0, 1))
+ .with_locked_knob("speed", initial_percentage_value=rescale(self.initial_velocity, MIN_SPEED, MAX_SPEED, 0, 1))
+
+ .build()
+ )
+
+ self.particle = Particle()
+
+ self.release_before_next_update = False
+
+ self.alt_knobs = False
+
+ @din.handler
+ def on_din_rising():
+ self.reset()
+
+ @b1.handler
+ def on_b1_press():
+ self.reset()
+
+ @b2.handler
+ def on_b2_press():
+ self.alt_knobs = True
+ self.k1_bank.next()
+ self.k2_bank.next()
+
+ @b2.handler_falling
+ def on_b2_release():
+ self.alt_knobs = False
+ self.k1_bank.next()
+ self.k2_bank.next()
+
+
+ @classmethod
+ def display_name(cls):
+ return "ParticlePhysics"
+
+ def save(self):
+ state = {
+ "gravity" : self.gravity,
+ "initial_velocity" : self.initial_velocity,
+ "height" : self.release_height,
+ "elasticity" : self.elasticity
+ }
+ self.save_state_json(state)
+
+ def reset(self):
+ self.release_before_next_update = True
+
+ def draw(self):
+ oled.fill(0)
+ row_1_color = 1
+ row_2_color = 2
+ if self.alt_knobs:
+ oled.fill_rect(0, CHAR_HEIGHT+1, OLED_WIDTH, CHAR_HEIGHT+1, 1)
+ row_2_color = 0
+ else:
+ oled.fill_rect(0, 0, OLED_WIDTH, CHAR_HEIGHT+1, 1)
+ row_1_color = 0
+
+
+ oled.text(f"h: {self.release_height:0.2f} e: {self.elasticity:0.2f}", 0, 0, row_1_color)
+ oled.text(f"g: {self.gravity:0.2f} v: {self.initial_velocity:0.2f}", 0, CHAR_HEIGHT+1, row_2_color)
+
+ # a horizontal representation of the particle bouncing off the left edge of the screen
+ oled.pixel(int(rescale(self.particle.y, 0, self.release_height, 0, OLED_WIDTH)), 3 * CHAR_HEIGHT, 1)
+
+ oled.show()
+
+ def main(self):
+ while True:
+ g = round(rescale(self.k1_bank["gravity"].percent(), 0, 1, MIN_GRAVITY, MAX_GRAVITY), 2)
+ h = round(rescale(self.k1_bank["height"].percent(), 0, 1, MIN_HEIGHT, MAX_HEIGHT), 2)
+ v = round(rescale(self.k2_bank["speed"].percent(), 0, 1, MIN_SPEED, MAX_SPEED), 2)
+ e = round(rescale(self.k2_bank["elasticity"].percent(), 0, 1, MIN_ELASTICITY, MAX_ELASTICITY), 2)
+
+ # the maximum veliocity we can attain, given the current parameters
+ # d = 1/2 aT^2 -> T = sqrt(2d/a)
+ h2 = 0
+ v2 = 0
+ if v > 0:
+ # initial upward velocity; add this to the initial height
+ t = v / g
+ h2 = v * t
+ else:
+ v2 = abs(v)
+ t = math.sqrt(2 * (h+h2) / g)
+ max_v = g * t + v2
+
+ if g != self.gravity or \
+ h != self.release_height or \
+ v != self.initial_velocity or \
+ e != self.elasticity:
+ self.gravity = g
+ self.initial_velocity = v
+ self.release_height = h
+ self.elasticity = e
+ self.save()
+
+ self.draw()
+
+ if self.release_before_next_update:
+ self.particle.set_initial_position(self.release_height, self.initial_velocity)
+ self.release_before_next_update = False
+
+ self.particle.update(self.gravity, self.elasticity)
+
+ # CV 1 outputs a gate whenever we hit the ground
+ if self.particle.hit_ground:
+ cv1.voltage(5)
+ else:
+ cv1.voltage(0)
+
+ # CV 2 outputs a trigger whenever we reach peak altitude and start falling again
+ if self.particle.reached_apogee:
+ cv2.voltage(5)
+ else:
+ cv2.voltage(0)
+
+ # CV 3 outputs a gate when the particle comes to rest
+ if self.particle.stopped:
+ cv3.voltage(5)
+ else:
+ cv3.voltage(0)
+
+ # CV 4 outputs control voltage based on the height of the particle
+ cv4.voltage(rescale(self.particle.y, 0, MAX_HEIGHT, 0, MAX_OUTPUT_VOLTAGE))
+
+ # CV 5 outputs control voltage based on the speed of the particle
+ cv5.voltage(rescale(abs(self.particle.dy), 0, max_v, 0, MAX_OUTPUT_VOLTAGE))
+
+ # TODO: I don't know what to use CV6 for. But hopefully I'll think of something
+ cv6.off()
+
+
+if __name__ == "__main__":
+ ParticlePhysics().main()
diff --git a/software/firmware/experimental/knobs.py b/software/firmware/experimental/knobs.py
index 6952705f8..b8efa2c39 100644
--- a/software/firmware/experimental/knobs.py
+++ b/software/firmware/experimental/knobs.py
@@ -203,6 +203,17 @@ def set_current(self, name):
# if the name isn't found, just silently trap the exception
pass
+ def __getitem__(self, name):
+ """Get the LockableKnob in this bank with the given name
+
+ @param name The name of the knob to return, or None if the name isn't found
+ """
+ try:
+ index = self.names.index(name)
+ return self.knobs[index]
+ except ValueError:
+ return None
+
class Builder:
"""A convenient interface for creating a :class:`KnobBank` with consistent initial state."""