Owen Ackerman


Robotic Ribbons — FLOWMOTION

A kinetic installation of 6 robotic arms, each animating a ribbon through coordinated motion and synchronized lighting. Commissioned by Florian Goerlitz for the Light Art Festival in Heidenheim, Germany. Designed, engineered, and built at MotionLab Berlin.


Table of Contents

  1. Project Overview
  2. Motion Design & Concept
  3. Mechanical Design
  4. Electronics Architecture
  5. Software Architecture
  6. Show Design: Motion & Lighting Timecode
  7. Transportation & Setup
  8. Lessons Learned & Next Steps
  9. Credits

1. Project Overview

The piece was inspired by synchronized ribbon dancers — the way a group of performers can move as a single organism, each ribbon tracing its own path through space while contributing to a shared visual field. FLOWMOTION translates that choreographic logic into a machine: six robotic arms, each carrying a ribbon, moving in spherical coordinates under a unified control system.

FLOMOTION in a linear configuration in a castle at the Light Art Festival in Heidenheim, Germany.



FLOMOTION in a circular configuration in a theatre at the Aux Actes Citoyens in Tomblaine, France.



2. Motion Design & Concept

2.1 Starting Point — What Motion Do We Want?

We started with understanding the types of circular and rhythmic motions people have used to control ribbons. How does the human arm work in coordination with a ribbon's natural movement?

The design process began with an analysis of how humans dance with ribbons. We started to understand the relationship between how a human arm navigates a ribbon which then naturally flows through the air. We wanted the movement to be continuous, three-dimensional sweeps. The goal was to translate that quality into motorized motion: organic, wave-like, but also powerful enough to make sharp changes.

2.2 Identifying the Right Degrees of Freedom

Early explorations considered several mechanical configurations, but a spherical two-motor design was chosen because it directly maps to the mathematics of smooth orbital motion and flow patterns. It also allowed for a robust configuration of powerful motors for controlled motion.

The following animations were made in Desmos 3D to test what kind of motion was possible along a unit sphere using this spherical coordinate motor system. All of these animations were used by programming different functions onto the theta and phi angles.

Rotation

Figure 8

High Frequency

Complex Motion

2.3 Spherical Coordinate Model

Each robot arm's position is defined by two angles:

  • Theta (θ) — azimuthal rotation in the XZ plane, around the Y axis. Range: 0–360°, continuous and wrapping. Controlled by velocity (rotations per second), driving a stepper motor at the corresponding pulse rate.
  • Phi (φ) — elevation from Y+ (straight up). Range: −135° to +135°. Controlled by direct angle, sent as a PWM command to the servo.
Y+ = phi 0°         (arm pointing straight up)
XZ plane = phi 90°  (arm horizontal)
Y- = phi ±135°      (servo hardware limit)

Theta: azimuth in XZ plane, 0–360°, wraps continuously

Cartesian mapping:
  y = cos(phi)
  x = sin(phi) * sin(theta)
  z = sin(phi) * cos(theta)
  theta = atan2(x, z)

The choice of velocity control for theta (rather than position) was deliberate: it allows the stepper to spin continuously without needing to track absolute position, and makes wave behaviors — where robots rotate at slightly different speeds or with phase offsets — natural to express.

Spherical coordinate system diagram


2.4 TouchDesigner Motion Controller Preview

Exploration of TouchDesigner UI for motion controller. Constant velocity on theta motor, sine wave placed on phi motor with variable parameters. In the following video I explore the different patterns that can emerge from altering the sine wave parameters for each of the robots.




3. Mechanical Design

3.1 Robot Arm Structure

Each robot consists of:

  • A stepper motor (theta axis) — continuous azimuthal rotation, driven by a DM860 driver at 800 pulses/revolution
  • A servo motor (phi axis) — 270° range, 25.2V supply, direct angle control
  • A rigid arm of length 1 m — 12mm wide hollow aluminum tubing
  • A ribbon attachment point at the tip with bearing for natural rotation during flight
  • A proximity sensor to establish a known reference position (theta = 315°) on startup — sensor signal voltage-divided from 25.2V to ~4.2V via a 1MΩ / 200kΩ divider for safe Arduino input

Note on ribbon behavior: at low speed near phi ≈ 0° (arm pointing straight up), the ribbon can become caught around the arm. This is a known mechanical constraint that shaped how behaviors approach the vertical.


Design Sketch

Mechanism


Mechanism Innards

Full Length Robots

3.2 Physical Parts List (Per Robot)

Part Specification Notes
Stepper motor NEMA 34, 8.5 Nm holding torque Theta axis
Stepper driver DM860 800 ppr DIP switch setting required
Servo motor 270° servo, 25.2V supply Phi axis, −135° to +135°
Arm 1 m aluminum rod, 12 mm diameter, 2 mm wall thickness
Homing sensor Metal proximity sensor 25.2V supply, signal divided to ~4.2V
Power module 60V stepper supply + 25.2V DC-DC buck One per robot
Ribbon 75 mm width, 4 m length
Mounting hardware M6 rods and nuts

3.4 Design Files

[TODO: Link CAD files, 3D print files, and laser cut files.]


4. Electronics System Architecture

4.1 System Overview

The system starts with TouchDesigner running on a MiniPC as the central brain where the ribbon motion is designed and sequenced. TD sends out live motor values via serial to an Arduino which connects to each robot's power box. This power box holds a voltage transformer and a motor driver which powers each robot. Each robot also sends back sensor data to the Arduino to monitor its position.


Hardware and electronics overview diagram

The electronics form a two-tier hierarchy: a central control computer runs all show logic and communicates to a single Arduino, which fans out to six robot nodes via individual Ethernet links. Lighting is handled by a dedicated ESP32 DMX controller on the same network.

4.2 Control Computer → Arduino (Serial)

TouchDesigner communicates with the Arduino over USB serial. Per-robot data sent each frame:

Data Encoding Notes
Theta velocity (-theta_velocity × 800) + 100000 100000 = stopped; >100000 = forward; <100000 = reverse
Phi angle Degrees, range −135 to +125 Direct servo angle
LED color R, G, B values Per-robot RGB fixture

The offset encoding for theta means the Arduino receives a single unsigned integer that encodes both direction and speed — values above 100000 are forward rotation, values below are reverse, and the magnitude of the offset is the pulse rate.

The Arduino sends 'HOMED' over serial when the homing sequence completes.

4.3 Arduino → Robot Nodes (Ethernet)

Each of the six robots has its own dedicated Ethernet connection from the Arduino, allowing independent simultaneous command delivery. The Ethernet carries two data lines to the stepper motor, one to the servo, and one back from the homing sensor. Ethernet was chosen for its twisted-pair wiring, which rejects noise and keeps signal integrity over the cable runs between the central box and each robot.

4.4 Motor Control

Stepper Motor — Theta Axis (DM860 Driver)

The DM860 accepts standard PUL / DIR / ENA signals with built-in optocouplers.

Critical: the Arduino ground must be fully isolated from the 60V system to protect the DM860 optocouplers.

SignalNotes
PULPulse input — rate sets velocity
DIRDirection
ENAEnable (active low)
Resolution800 pulses/revolution (DM860 DIP switch)
Supply voltage60V

Servo Motor — Phi Axis

ParameterValue
ControlStandard PWM
Supply voltage25.2V (DC-DC buck, non-isolated from 60V rail)
Range−135° to +135°

Homing Sensor

ParameterValue
Supply voltage25.2V
Signal conditioningVoltage divider: 1MΩ + 200kΩ → ~4.2V at Arduino input
Homing referencetheta = 315°

4.5 Power Architecture

Each robot has its own dedicated power module, isolating load and simplifying fault isolation.

RailVoltagePowersNotes
Main supply60VDM860 stepper driver
DC-DC buck25.2VServo motor, homing sensorNon-isolated from 60V rail
Logic supply5V isolatedArduino Mega 2560Must be fully isolated from 60V

4.6 Lighting — ESP32 DMX Controller

Each robot has one RGB DMX fixture (3 channels: R, G, B). A dedicated ESP32 receives Art-Net UDP from TouchDesigner and outputs DMX512 via a MAX485 RS-485 transceiver.

ComponentDetail
MicrocontrollerESP32
LibraryDmx_ESP32
Protocol inArt-Net (UDP)
Protocol outDMX512 via MAX485 transceiver
Channels used18 total (6 robots × 3 RGB channels)

The Dmx_ESP32 library was adopted after resolving persistent DMX break-timing issues with the initial implementation, which caused fixture flicker and dropout. Correct break timing is handled automatically by the library.


5. Software Architecture

5.1 Stack Overview

LayerTool / Language
Show control & orchestrationTouchDesigner (Python extensions)
Motion mathPython (spherical coordinates, phase accumulators)
Serial communicationTouchDesigner Serial DAT
Microcontroller firmwareArduino C++ (Mega 2560)
Lighting outputArt-Net DAT → ESP32 → DMX512
Inter-machine communication for hand controlOSC over direct Ethernet

5.2 TouchDesigner Node Structure

/project1
    /controller_comp    Base COMP — RobotControllerEXT
    /sequencer_comp     Base COMP — ShowSequencerEXT
    /recorder_comp      Base COMP — MotionRecorderEXT
    /Robot1 .../Robot6  Base COMP — RobotEXT
        const_robot     Constant CHOP — motor output values for each robot

The Execute DAT calls all three extensions in order every frame:

op('sequencer_comp').ext.ShowSequencerEXT.Update()    # sets params first
op('controller_comp').ext.RobotControllerEXT.Update() # computes motion
op('recorder_comp').ext.MotionRecorderEXT.Update()    # captures state last

5.3 RobotEXT — Per-Robot State Container

RobotEXT is a pure data container — one instance per robot arm. It tracks current position, velocity, and hardware limits, and writes motor commands to a Constant CHOP each frame.

State

AttributeUnitDefault
thetadegrees0.0 (range 0–360°)
phidegrees90.0 (range −135° to +135°)
theta_velocityrps0.0
phi_velocitydeg/s0.0

Hardware Limits

LimitDefaultNotes
pulses_per_revolution800Must match DM860 DIP switch
max_theta_velocity1.5 rps
max_phi_velocity540.0 deg/sEquivalent to 1.5 rps
max_theta_acceleration2.3 rps/s0 = unlimited
max_phi_acceleration1.0 deg/s²0 = unlimited

CHOP Output

Each frame, PushToCHOP() writes three values to the robot's Constant CHOP:

const0value = theta (deg)
const1value = phi (deg)
const2value = (-theta_velocity × 800) + 100000
              100000 = stopped
              > 100000 = forward rotation
              < 100000 = reverse rotation

Key Methods

SetState(theta, phi, ledMatrix, dt=None)
    # If dt > 0: derives velocity from delta/dt, applies accel clamping.
    # If dt is None or 0: snaps to position, zeroes velocity.

Halt()
    # Zeros velocity and pushes to CHOP.
    # Always use this from outside — never assign r.theta_velocity directly
    # (TD extension promotion does not intercept attribute assignment)

5.4 RobotControllerEXT — Motion Orchestration

RobotControllerEXT owns all motion logic: behavior evaluation, weighted blending, smooth transitions, stop/resume ramps, and the homing sequence.

Update Loop (Every Frame)

1. Skip if dt < 0.001  (double-cook guard)
2. Read rotation axis from CHOP if quaternion behavior active
3. Advance homing state machine if running
4. If paused: return early
5. Scale effective_dt by stop/resume ramp factor
6. Accumulate internal show time
7. Exponential-lag smooth all behavior parameters (τ ≈ 1/6 s)
8. Evaluate all active behaviors; blend by normalized weight
9. Apply blended states to each RobotEXT via SetState + PushToCHOP

Behavior System

Multiple behaviors can run simultaneously. Each has an independent weight and parameters that are exponentially smoothed each frame before evaluation:

alpha = 1 - exp(-6.0 * dt)
smoothed = smoothed + (target - smoothed) * alpha

Blending is normalized weighted: if sine has weight 0.7 and wave has weight 0.3, the output is a 70/30 blend of what each behavior would produce. This enables smooth crossfades with no discontinuities.

Built-in Behaviors

BehaviorDescription
sineTheta at constant velocity; phi oscillates sinusoidally with per-robot phase offsets
waveBoth theta and phi oscillate sinusoidally from absolute show time
noiseSame as wave at higher frequency, smaller amplitude — good for subtle organic perturbation
circularAll robots trace circles in a tilted plane using per-robot angle accumulators
quaternionRotates arm vectors around an arbitrary 3D axis (Rodrigues' formula); supports live CHOP input for the rotation axis
circle_zyTraces a circle on the unit sphere in a plane offset from the ZY axis
figure8Lissajous figure-8: theta at frequency f, phi at 2f
figure9Inverse Lissajous: theta at 2f, phi at f
flipBinary phi toggle — all robots snap to ±135°
stepper_speed_controlTheta spins continuously; phi set directly with cascading delay ripple between robots
stepper_direct_controlDirect position control for both axes with independent cascading delays

Behaviors involving continuous accumulation (sine, circular, figure8, etc.) maintain per-robot phase accumulators so parameter changes — e.g. changing frequency mid-show — are seamless rather than causing a position jump.

Fade System

ctrl.fadeTo('wave', duration=2.0)

Transitions use cubic ease-in-out interpolation. For sine and circular, the endpoint is predicted duration seconds ahead, so the incoming behavior lands without a visible seam. On completion, all other weights are zeroed and the target is set to 1.0.

Stop / Resume

ctrl.stop()        # Smooth decel over decel_duration (default 0.5s)
ctrl.resume()      # Smooth accel back up
ctrl.hardStop()    # Instant — zeroes all velocities immediately

Stop and resume work by scaling effective_dt over the ramp window, so velocity reaches zero naturally through the same acceleration clamping used during normal motion.

5.5 Homing Sequence

On startup (or whenever the installation needs to re-reference), homing runs through a four-state machine:

None → 'decelling' → 'waiting' → 'resuming' → None
  1. All robots decelerate to a stop
  2. Controller pulses a configurable trigger op to activate the Arduino homing routine
  3. Each robot drives toward its homing sensor; on contact, theta is zeroed and the reference is set to 315°
  4. Arduino sends 'HOMED' over serial when all robots are referenced
  5. Controller resumes motion, restoring each robot's saved phi value
ctrl.startHoming(
    trigger_op  = 'button_home',
    trigger_par = 'Pulse',
    serial_dat  = 'serial1',
    done_string = 'HOMED'
)

5.6 MotionRecorderEXT — Live Motion Capture

The recorder captures live robot states into named clips at a configurable sample rate (default 30fps), then registers each clip as a dynamic behavior on the controller — meaning recorded motion can be weighted, blended, and faded identically to any built-in behavior.

rec.startRecording('take_1')          # begin capture
rec.stopRecording()                   # finalize and register on controller

ctrl.fadeTo('take_1', 2.0)            # crossfade into recorded motion
rec.setClipSpeed('take_1', 0.5)       # half speed playback
rec.setClipLoop('take_1', False)      # one-shot (holds last frame)
rec.saveClips('show/clips.json')      # persist to disk
rec.loadClips('show/clips.json')      # restore and re-register on load

This was particularly useful during choreography development — interesting moments discovered through live manipulation could be captured immediately and played back precisely.

5.7 ShowSequencerEXT — Timecoded Show Playback

The show is authored as a Table DAT — one row per segment — which the sequencer reads to drive the controller over time. The sequencer must update before the controller each frame so parameters land in the same frame they're intended for.

Table Format

time_start duration behavior blend_start blend_end params_start params_end
0.010.0sine theta_velocity=0.1 bias_phi=-90 theta_velocity=0.3 bias_phi=-45
10.04.0sine/wave0.01.0 theta_velocity=0.2
14.08.0figure8 frequency=0.25 amplitude_theta=60 frequency=0.5 amplitude_theta=30
  • behavior — single name (sine) or crossfade pair (sine/wave); in crossfade mode, blend_start/blend_end control the weight of the second behavior across the segment duration
  • params_start / params_end — space-separated key=value pairs; the sequencer interpolates linearly between them over duration seconds
  • The sequencer fully owns weight state during playback — it zeros all behavior weights every frame
  • Segments are sorted by time_start automatically on load

Playback Controls

seq.loadFromDAT('sequencer_table')
seq.play()           # start / resume
seq.pause()          # freeze; controller holds current state
seq.seek(30.0)       # jump to 30s; immediately applies that segment's state
seq.setLoop(True)
seq.seq_time         # current playhead position in seconds (read-only)

5.8 Key TouchDesigner Implementation Notes

These patterns were non-obvious and important to get right:

  • Frame-persistent statescriptOp.store() / fetch() used for values that must survive TD cook cycles; instance variables alone are not reliable
  • Absolute timeabsTime.seconds used for show timing; me.time.seconds resets on component resets and is unsuitable for a running installation
  • Extension promotion — methods on RobotEXT are promoted to the operator level (r.Halt() works), but attribute assignment (r.theta_velocity = x) does not go through the extension — always use setter methods
  • Double-cook guard — TD fires Execute DATs multiple times per frame with dt≈0; the guard if dt < 0.001: return prevents the second cook from zeroing velocities

5.9 Source Files

FileRole
DATS/RobotEXT.pyPer-robot state container and CHOP output
DATS/RobotControllerEXT.pyAll motion logic: behaviors, blending, fading, stop/resume, homing
DATS/MotionRecorderEXT.pyRecords robot motion into named clips; registers clips as behaviors
DATS/ShowSequencerEXT.pyTimecoded show: Table DAT-driven parameter sequences

6. Show Design: Motion & Lighting Timecode

6.1 Approach

Both motion and lighting are driven from a single timecoded Table DAT sequence in TouchDesigner, read by ShowSequencerEXT. The sequencer owns a single internal clock and drives RobotControllerEXT with interpolated behavior parameters each frame — including both motor targets and LED colors. Because everything shares one clock and one update loop, motion and lighting are structurally guaranteed to stay in sync across playback, seek, pause, and loop operations.

6.2 Motion Authoring

Motion is authored by writing segments into the sequencer_table Table DAT. Each segment specifies which behavior is active, its parameters at the start and end of the segment (with linear interpolation between), and optionally a crossfade to a second behavior.

time_start duration behavior params_start params_end
0.08.0sine theta_velocity=0.1 bias_phi=-90 theta_velocity=0.2 bias_phi=-60
8.04.0sine/wave theta_velocity=0.2
12.010.0wave frequency_phi=0.5 amplitude_phi=30 frequency_phi=1.5 amplitude_phi=60
22.06.0figure8 frequency=0.25 amplitude_theta=60 frequency=0.5 amplitude_theta=30

Certain moments in the show were developed through live performance — moving the robots in real time through parameter manipulation — and captured using MotionRecorderEXT. These clips were then incorporated into the sequence as named behaviors alongside the procedural ones.

6.3 Lighting

Each robot has one RGB DMX fixture, addressed as 3 consecutive DMX channels. Lighting is controlled by the same Table DAT / ShowSequencer system as motion — there is no separate lighting timeline.

Every behavior function returns a led: [r, g, b] value per robot alongside theta and phi. These values flow through the same normalized weighted blend as the motion state, so lighting crossfades automatically whenever behaviors crossfade. Lighting cues are authored directly in the sequencer_table using the same params_start / params_end syntax:

time_start duration behavior params_start params_end
0.08.0sine led_r=0 led_g=50 led_b=255 led_r=0 led_g=100 led_b=255
8.04.0wave led_r=255 led_g=80 led_b=0 led_r=255 led_g=255 led_b=0

6.4 Show Sections

The show was built through a sequence of timecoded commands. In this short example, we first start the show by cutting the lights and homing all of the motors. Then the music starts and robot one shoots to life along with its lights, then after 5 seconds, robot 2 joins. We did this for about 300 different timecodes along a 7 minute song to create a choreographed light-controlled show.

TimeSectionMotion BehaviorLightingNotes
0:00Startup / HomingAll robots home at theta=315°DarkSilent calibration
0:05Song beginsOnly Robot 1 movesOnly Robot 1 lights up
0:10Robot 2 joinsRobot 2 light joins
~300 timecoded cues over 7 minutes

7. Transportation & Setup


The installation was transported in custom shipping containers. 


8. Lessons Learned & Next Steps

This project was a full end-to-end build under real installation pressure — hardware, electronics, firmware, and software all developed in parallel against a hard ship date. Below are the post-mortems from each layer.

8.1 Hardware

Vulnerability: stepper-to-servo coupling screw

The most consequential mechanical issue was the M_ screw connecting the 12mm stepper motor axle into the servo motor flange mount. Under repeated rotation, this screw would occasionally snap. When it did, the servo motor wires became the only thing supporting the rotational load — causing them to twist, short, and in several cases blow out digital pins on the Arduino.

The root cause is mechanical: without a tight interference fit between the axle and the flange bore, all rotational strain concentrates on the fastener. The correct fix is proper milling of both the flange and the axle to achieve a tight press or keyed fit that transfers torque through the metal geometry rather than the screw. This was identified during the build and during the installation we drilled through and expanded the holes, filling it with a larger bolt. This came after failures that resulted in several hours of repair time.

Next time: prioritize machined fits for any joint that sees continuous cyclic load. A screw should be a safety fastener, not the primary torque path.

Wiring to the servo motor

Because the servo wires travel a very short, constrained distance, they were extremely difficult to repair after the coupling screw broke — the failure cascade (screw snaps → wires take load → wires rip or short) often happened faster than it could be caught, and repair required significant disassembly.

Next time: use breakaway connectors — connectors designed to release under tension — on the servo wiring. If the coupling fails, the connector separates cleanly before the wires are damaged or a short reaches the electronics. In addition, if the first hardware fastener issue was fixed, this issue wouldn't be a problem.
Takeaway: beware of cascading failures.

8.2 Electronics

No per-robot isolation

The current architecture has all six robots sharing data lines back to a single Arduino with no electrical isolation between them. When a robot node fails — especially where a mechanical failure causes a short or voltage spike — the fault can propagate up the data line and damage the Arduino, potentially taking down the entire installation.

Next time: add optocouplers on every data line between the Arduino and the robot nodes. An optocoupler breaks the electrical path entirely — a short or voltage spike on the robot side can't reach the Arduino. The failure mode becomes "that robot stops working" rather than "the whole system goes down."
Takeaway: beware of cascading failures.

8.3 Software

Developing inside a larger TD network without full context

The Python extension classes (RobotEXT, RobotControllerEXT, ShowSequencerEXT, MotionRecorderEXT) were developed using Claude Code, which worked well for the self-contained logic. The difficulty was that a significant amount of the TouchDesigner network — the wiring, the CHOPs that feed into the extensions, the DATs that read out of them — existed outside the codebase and therefore outside Claude's context. Claude Code couldn't see or reason about the full system, which meant bugs at the boundary between the Python extensions and the TD network were harder to catch and took longer to debug.

Next time: have Claude Code implement unit tests for every extension class that simulate the TD environment — mocking absTime, scriptOp.store/fetch, CHOP reads and writes — and verify that each public method produces the exact expected output. This would have caught boundary bugs much earlier, shortened the debugging cycle considerably, and produced a more robust system overall. Closing the loop on AI-assisted development requires giving the AI a way to verify its own work against the real integration surface.


9. Credits

RoleName
Concept, artistic direction, design, fabricationFlorian Goerlitz
Artistic direction, design, engineering, fabricationOwen Ackerman
Development studioMotionLab Berlin

Exhibitions:
Light Art Festival, Heidenheim, Germany — 4/24–5/2/2026
Aux Actes Citoyens Theatre Festival, Tomblaine, France — 5/9–16/2026


Documentation last updated: 6/15/2026