# Copyright 2022 Allen Synthesis
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from europi_script import EuroPiScript
from utime import ticks_diff, ticks_ms
from math import fabs, floor
from random import choice
author: Sean Bechhofer (github.com/seanbechhofer)
labels: gates, triggers, randomness
Strange Attractor is a source of chaotic modulation. It can use a variety of different implementations.
Outputs 1, 2 and 3 are based on the x, y and z values generated by the attractor.
Outputs 4, 5 and 6 are gates based on the values of x, y and z and relationships between them.
digital_in: Pause motion when HIGH
knob_2: Adjust threshold for triggers
button_1: Decrease output voltage range, change equation system
button_2: Increase output voltage range
# Maximum voltage output. Cranking this up may cause issues with some modules.
MAX_OUTPUT = MAX_OUTPUT_VOLTAGE
Implementation of strange attractors, providing chaotic values for modulation.
* Lorenz. Well known system of equations giving chaotic behaviour.
See https://en.wikipedia.org/wiki/Lorenz_system
See https://www.semanticscholar.org/paper/Controlling-a-Novel-Chaotic-Attractor-using-Linear-Pan-Xu/72f9c1b1f892b3aeea26af330d44011a20250f32
* Rikitake. Used to model the earth's geomagnetic field and explain the irregular switch in polarity.
See Llibre, Jaume & Messias, Marcelo. (2009). Global dynamics of the Rikitake system. Physica D: Nonlinear Phenomena. 238. 241-252. 10.1016/j.physd.2008.10.011.
* Rossler. Use with caution. The z co-ord sits around zero for long periods.
See https://en.wikipedia.org/wiki/R%C3%B6ssler_attractor
def __init__(self, point=(0.0, 1.0, 1.05), dt=0.01, name="Attractor"):
self.initial_state = point
# arbitrary initial range values
# The range of values produced depends on the parameters and the
# specifics of the equations. If we know the range, we can then
# normalise coordinates for use when generating CV. This method
# runs through a number of iterations to estimate ranges.
def estimate_ranges(self, steps=100000):
# Execute a number of steps to get upper and lower bounds.
self.x_max = max(self.x, self.x_max)
self.y_max = max(self.y, self.y_max)
self.z_max = max(self.z, self.z_max)
self.x_min = min(self.x, self.x_min)
self.y_min = min(self.y, self.y_min)
self.z_min = min(self.z, self.z_min)
self.set_range(self.x_min, self.x_max, self.y_min, self.y_max, self.z_min, self.z_max)
# Reset to initial parameters
self.x = self.initial_state[0]
self.y = self.initial_state[1]
self.z = self.initial_state[2]
def set_range(self, x_min, x_max, y_min, y_max, z_min, z_max):
self.x_range = self.x_max - self.x_min
self.y_range = self.y_max - self.y_min
self.z_range = self.z_max - self.z_min
return (100.0 * (self.x - self.x_min)) / self.x_range
return (100.0 * (self.y - self.y_min)) / self.y_range
return (100.0 * (self.z - self.z_min)) / self.z_range
return f"{self.name:>16} ({self.x:2.2f},{self.y:2.2f},{self.z:2.2f})({self.x_scaled():2.2f},{self.y_scaled():2.2f},{self.z_scaled():2.2f})"
Update the point. This needs to be implemented in subclasses.
Implementation of a simple Lorenz Attractor, see
https://en.wikipedia.org/wiki/Lorenz_system
Default uses well known values of s=10,r=28,b=2.667.
def __init__(self, point=(0.0, 1.0, 1.05), params=(10, 28, 2.667), dt=0.01):
super().__init__(point, dt, "Lorenz")
x_dot = self.s * (self.y - self.x)
y_dot = self.r * self.x - self.y - self.x * self.z
z_dot = self.x * self.y - self.b * self.z
self.x += x_dot * self.dt
self.y += y_dot * self.dt
self.z += z_dot * self.dt
Implementation of Pan-Xu-Zhou
class PanXuZhou(Attractor):
def __init__(self, point=(1.0, 1.0, 1.0), params=(10.0, 2.667, 16.0), dt=0.01):
super().__init__(point, dt, "Pan-Xu-Zhou")
x_dot = self.a * (self.y - self.x)
y_dot = self.c * self.x - self.x * self.z
z_dot = self.x * self.y - self.b * self.z
self.x += x_dot * self.dt
self.y += y_dot * self.dt
self.z += z_dot * self.dt
Implementation of Rossler. The z co-rd spends a lot of time around zero, so use with caution.
class Rossler(Attractor):
def __init__(self, point=(0.1, 0.0, -0.1), params=(0.13, 0.2, 6.5), dt=0.01):
super().__init__(point, dt, "Rossler")
x_dot = -(self.y + self.z)
y_dot = self.x + self.a * self.y
z_dot = self.b + self.z * (self.x - self.c)
self.x += x_dot * self.dt
self.y += y_dot * self.dt
self.z += z_dot * self.dt
Implementation of Rikitake.
class Rikitake(Attractor):
def __init__(self, point=(0.1, 0.0, -0.1), params=(5.0, 2.0), dt=0.01):
super().__init__(point, dt, "Rikitake")
x_dot = -(self.mu * self.x) + (self.z * self.y)
y_dot = -(self.mu * self.y) + self.x * (self.z - self.a)
z_dot = 1 - (self.x * self.y)
self.x += x_dot * self.dt
self.y += y_dot * self.dt
self.z += z_dot * self.dt
return [Lorenz(), PanXuZhou(), Rikitake(), Rossler()]
class StrangeAttractor(EuroPiScript):
# Initialise and calculate ranges.
# This will take around 30 seconds per unsaved attractor.
self.attractors = get_attractors()
# select a random attractor
self.selected_attractor = choice(range(0, len(self.attractors)))
self.a = self.attractors[self.selected_attractor]
# initial threshold for gates
# Triggered when button 1 is released
# Short press: decrease range
# Long press: change equation system
if ticks_diff(ticks_ms(), b1.last_pressed()) > 300:
# long press This will result in a jump in parameters
# as each attractor has its own x,y,z
# coordinates. Possible improvement is to share or set
self.selected_attractor = (self.selected_attractor + 1) % len(self.attractors)
self.a = self.attractors[self.selected_attractor]
# Triggered when button 2 is released.
# Short press: increase range
# Long press: toggle display
if ticks_diff(ticks_ms(), b2.last_pressed()) > 300:
self.show_detail = not self.show_detail
if self.range > MAX_OUTPUT:
# Freeze is triggered when din goes HIGH.
def init_estimates(self):
self.initialise_message()
state = self.load_state_json()
for att in self.attractors:
att_state = state.get(att.name)
self.initialise_message(att.name)
self.save_state_json(state)
# Set speed based on the knob.
# The range is piecewise linear from fully CCW to noon and noon to fully CW.
# TODO: allow speed adjustment via CV.
self.period = low - ((low - mid) * (val / 50))
self.period = mid - ((mid - high) * (val - 50) / 50)
def update_threshold(self):
self.threshold = k2.read_position(steps=41)
# Change the values and output
cv1.voltage((self.range * self.a.x_scaled()) / 100)
cv2.voltage((self.range * self.a.y_scaled()) / 100)
cv3.voltage((self.range * self.a.z_scaled()) / 100)
# gate 1 fires if x is divisible by 2 when considered an int
self.gate4 = floor(self.a.x_scaled()) % 2 == 0
# gates 2 and 3 look at the differences between the outputs.
fabs(self.a.y_scaled() + self.a.z_scaled() - 2 * self.a.x_scaled()) > self.threshold
fabs(self.a.z_scaled() + self.a.x_scaled() - 2 * self.a.y_scaled()) > self.threshold
self.checkpoint = ticks_ms()
if ticks_diff(ticks_ms(), self.checkpoint) > self.period:
def initialise_message(self, att_name=None):
oled.text("Strange", 0, 0, 1)
oled.text(f"Attractor v{VERSION}", 0, 8, 1)
oled.text("Initialising...", 0, 16, 1)
oled.text(att_name, 10, 24, 1)
oled.text("1:" + str(int(self.a.x_scaled())), 0, 0, 1)
oled.text("2:" + str(int(self.a.y_scaled())), 0, 8, 1)
oled.text("3:" + str(int(self.a.z_scaled())), 0, 16, 1)
oled.text("S:" + str(int(self.period)), 40, 0, 1)
oled.text("T:" + str(int(self.threshold)), 40, 8, 1)
oled.text("R:" + str(int(self.range)), 40, 16, 1)
oled.fill_rect(20, 0, int(0.75 * self.a.x_scaled()), 6, 1)
oled.rect(20, 0, 75, 6, 1)
oled.fill_rect(20, 8, int(0.75 * self.a.y_scaled() / 2), 6, 1)
oled.rect(20, 8, 75, 6, 1)
oled.text("3:", 0, 16, 1)
oled.fill_rect(20, 16, int(0.75 * self.a.z_scaled() / 2), 6, 1)
oled.rect(20, 16, 75, 6, 1)
oled.text("4", 100, 0, 1)
oled.text("5", 100, 8, 1)
oled.text("6", 100, 16, 1)
oled.text("FREEZE", 0, 24, 1)
oled.text(self.a.name, 55, 24, 1)
if __name__ == "__main__":
StrangeAttractor().main()