Streaming Control of a Rotary Joint

Joint control with one Elevate neuron per controller, reciprocal inhibition and excitatory sensory feedback in ROS2 Humble.

Abstract

Two single-neuron Elevate controllers drive opposing muscles on a rotary joint with a 2 g tip mass under gravity. Motor spikes activate muscles and inhibit the peer; angle feedback excites each controller. Three ROS2 Humble nodes exchange 1 ms spike windows in Docker on separate CPUs.

Results

Dynamics
Callback Timings
Figure 1. Recorded joint control. Blue/pink denote flexor/extensor; green denotes joint timing. Activation bars (%) share a fixed scale: zero to the recording's peak rounded up to a whole percentage point. Arrows show inhibitory, sensory and motor paths. The downward reference is 0°; mass depiction and particle travel times are schematic. Dynamics shows angle (°) and neuron voltage (mV). Timings shows callback duration (ms), excluding input waiting, against elapsed wall time (s); dashed lines mark the 1 ms period. The default 0–2 ms view marks larger callbacks with triangles and reports their count and maximum per node; select Full range to show every peak. All three nodes share the same vertical scale. The cursor marks replay time; click either graph to seek.

Control Approach

The parameters were selected empirically by varying background excitation, sensory feedback strength and muscle response per spike. They remain fixed throughout the displayed recording.

Timings Summary

QuantityValueMeaning
Internal integration step0.025 msIntegrates neuron equations and detects upward voltage crossings.
External simulation step1 ms model time40 internal steps; fixed input conductances.
Exported spike flag0 or 1 per neuron per 1 msAny upward crossing sets the flag; multiple crossings collapse to one. Finer spike times are discarded.
Mechanical step1 ms model timeUpdates muscle activation, velocity and angle.
Model window1 ms model timeModel time advanced per callback.
CallbackOne node updateAdvances the model, publishes outputs and records diagnostics.
Callback durationVariable wall timeExecution time, excluding input waiting.
Control period1 ms wall timeInterval between scheduled releases.
DeadlineScheduled release + 1 msRequired finish time, even after a late start.
Logical transport delay1 ms model timeWindow nn's spikes enter window n+1n+1.

Mean callback duration (95th percentile): flexor/extensor 0.534 ms (0.702 ms); joint 0.046 ms (0.073 ms).

Of 30,000 callbacks per node, deadline misses were 2,154 flexor (7.18%), 2,183 extensor (7.28%) and 1,480 joint (4.93%). Callbacks over 1 ms: 195 flexor, 218 extensor, 0 joint. Short callbacks can miss deadlines through late starts.

Neuron model

Elevate is a phenomenological model with sodium, potassium, calcium, slow potassium and leak currents:

CdVdt=I0+ge(Ee−V)+gi(Ei−V)+∑k∈{Na,K,Ca,KCa,L}gk(Ek−V).C\frac{dV}{dt}=I_0+g_e(E_e-V)+g_i(E_i-V) +\sum_{k\in\{\mathrm{Na},\mathrm{K},\mathrm{Ca},\mathrm{KCa},L\}}g_k(E_k-V).

V,t,C,I0V,t,C,I_0 denote voltage, time, capacitance and applied current. Excitatory and inhibitory conductances ge,gig_e,g_i have reversals Ee,EiE_e,E_i; intrinsic conductance gkg_k has reversal EkE_k. Units are mV, ms, nF, µS and nA; no cell-area calibration is assumed.

For gates j∈{m,h,n,c,q}j\in\{m,h,n,c,q\},

τjdUjdt=V−Uj,xj=σ ⁣[aj(Uj−bj)],σ(z)=11+exp⁡(−z).\tau_j\frac{dU_j}{dt}=V-U_j,\qquad x_j=\sigma\!\left[a_j(U_j-b_j)\right],\qquad \sigma(z)=\frac{1}{1+\exp(-z)}.

UjU_j is filtered voltage (mV), τj\tau_j its timescale (ms), xjx_j a dimensionless gate, aja_j the slope (mV−1^{-1}), bjb_j the midpoint (mV), and zz the dimensionless sigmoid argument. In that order, slopes are (0.2,−0.1,0.1,0.2,0.1)(0.2,-0.1,0.1,0.2,0.1) and midpoints (−25,−40,−15,−45,−30)(-25,-40,-15,-45,-30) mV:

gNa=gˉNaxmxh,gK=gˉKxn,gCa=gˉCaxc,gKCa=gˉKCaxq,gL=gˉL.g_{\mathrm{Na}}=\bar g_{\mathrm{Na}}x_mx_h,\quad g_{\mathrm{K}}=\bar g_{\mathrm{K}}x_n,\quad g_{\mathrm{Ca}}=\bar g_{\mathrm{Ca}}x_c,\quad g_{\mathrm{KCa}}=\bar g_{\mathrm{KCa}}x_q,\quad g_L=\bar g_L.

gˉk\bar g_k is maximum conductance. Both potassium currents use EKE_{\mathrm K}. KCa depends on filtered voltage, with no calcium-concentration state. All six states, VV and the five UjU_j, start at −60-60 mV.

Numerical update and spike output

Each 1 ms step uses 40 split substeps of h=0.025h=0.025 ms with fixed input conductances. First update the filters using the old voltage:

Uj+=V+(Uj−V)exp⁡(−h/τj).U_j^+=V+(U_j-V)\exp(-h/\tau_j).

The superscript ++ denotes an updated value. Evaluate gates and conductances from Uj+U_j^+, then update the membrane with conductances frozen:

G=ge+gi+∑kgk,V∞=I0+geEe+giEi+∑kgkEkG,V+=V∞+(V−V∞)exp⁡(−hG/C).G=g_e+g_i+\sum_k g_k,\qquad V_\infty=\frac{I_0+g_eE_e+g_iE_i+\sum_k g_kE_k}{G}, \qquad V^+=V_\infty+(V-V_\infty)\exp(-hG/C).

GG is total conductance (µS), V∞V_\infty the equilibrium voltage (mV), and kk indexes the five intrinsic currents. Each frozen sub-update is exact; their composition approximates the coupled dynamics. A crossing V≤0<V+V\leq0<V^+ mV sets the window’s spike flag without resetting state.

Synaptic and local drive

Let r∈{f,e}r\in\{f,e\} denote flexor or extensor, rˉ\bar r its peer, and n=0,1,…n=0,1,\ldots the control window. Synapses decay, then receive spike increments:

gs,rn=e−Δt/τsgs,rn−1+wsSrn−1,gp,rn=e−Δt/τpgp,rn−1+wpMrˉn−1.\begin{aligned} g_{\mathrm{s},r}^{n} &=e^{-\Delta t/\tau_{\mathrm{s}}}g_{\mathrm{s},r}^{n-1} +w_{\mathrm{s}}S_r^{n-1},\\ g_{\mathrm{p},r}^{n} &=e^{-\Delta t/\tau_{\mathrm{p}}}g_{\mathrm{p},r}^{n-1} +w_{\mathrm{p}}M_{\bar r}^{n-1}. \end{aligned}

gs,rg_{\mathrm{s},r} and gp,rg_{\mathrm{p},r} are sensory AMPA and peer GABA conductances (µS). Srn−1S_r^{n-1} and Mrˉn−1M_{\bar r}^{n-1} count the preceding window’s sensory and peer motor spikes. With Δt=1\Delta t=1 ms, decay times are (τs,τp)=(10,50)(\tau_{\mathrm{s}},\tau_{\mathrm{p}})=(10,50) ms and per-spike weights are (ws,wp)=(0.035,0.2)(w_{\mathrm{s}},w_{\mathrm{p}})=(0.035,0.2) µS. Initial conductances and input counts are zero; increments are added in full, without timestep scaling.

Unfiltered local excitation switches from starting to tonic drive:

glocal,r(t)={gstart,r,0≤t<Tstart,gtonic,t≥Tstart.g_{\mathrm{local},r}(t)= \begin{cases} g_{\mathrm{start},r}, & 0\leq t<T_{\mathrm{start}},\\ g_{\mathrm{tonic}}, & t\geq T_{\mathrm{start}}. \end{cases}

tt is model time from startup, Tstart=20T_{\mathrm{start}}=20 ms, (gstart,f,gstart,e)=(0.5,0)(g_{\mathrm{start},f},g_{\mathrm{start},e})=(0.5,0) µS and gtonic=0.04g_{\mathrm{tonic}}=0.04 µS. At tn=nΔtt_n=n\Delta t, the membrane inputs are

ge,rn=glocal,r(tn)+gs,rn,gi,rn=gp,rn.g_{e,r}^{n}=g_{\mathrm{local},r}(t_n)+g_{\mathrm{s},r}^{n}, \qquad g_{i,r}^{n}=g_{\mathrm{p},r}^{n}.

ge,rng_{e,r}^{n} and gi,rng_{i,r}^{n} are total excitatory and inhibitory conductances. Recorded settings appear in the parameter table.

Joint dynamics model

Two muscles drive a spring-damped joint. Zero points downward; positive angles point right. Only the tip mass contributes gravity:

(J0+mL2)θ¨+bθ˙+k(θ−θr)+mgLsin⁡θ=τmax⁡(af−ae).(J_0+mL^2)\ddot{\theta}+b\dot{\theta}+k(\theta-\theta_r)+mgL\sin\theta =\tau_{\max}(a_f-a_e).

θ\theta is angle (rad); dots denote derivatives in seconds. J0J_0 is base inertia (kg m2^2), mm tip mass (kg), LL arm length (m), gg gravity (m/s2^2), θr\theta_r spring rest angle (rad), bb damping (N m s/rad), and kk stiffness (N m/rad). af,ae∈[0,1]a_f,a_e\in[0,1] are flexor/extensor activations; τmax⁡\tau_{\max} is maximum muscle torque (N m).

At mechanical step nn, muscle i∈{f,e}i\in\{f,e\} updates as

ai,n+1=min⁡ ⁣(1,ai,nexp⁡[−Δt/τa]+q si,n).a_{i,n+1}=\min\!\left(1, a_{i,n}\exp[-\Delta t/\tau_a]+q\,s_{i,n}\right).

Here Δt=0.001\Delta t=0.001 s, τa\tau_a is activation decay time (s), qq the dimensionless spike increment, and si,ns_{i,n} the preceding window’s motor spike count per neuron. Updated activations set torque. Semi-implicit Euler updates velocity, then angle; stops at θr±1\theta_r\pm1 rad clamp angle and zero velocity.

The joint starts at θr=π/2\theta_r=\pi/2 (90°), with zero velocity and activation. Total inertia is 0.005080.00508 kg m2^2; initial gravity torque is −0.003924-0.003924 N m. Flexor raises the mass against gravity; extensor lowers it.

Before each update, sensory spikes excite flexor for θ<θr−θs\theta<\theta_r-\theta_s and extensor for θ>θr+θs\theta>\theta_r+\theta_s. The sensory centre is θr\theta_r, threshold θs=0.25\theta_s=0.25 rad, and minimum interspike interval 10 ms. Each channel repeats while beyond threshold.

Architecture Pseudocode

1. Overall

ROS2 Humble runs flexor, extensor and plant as separate processes on separate CPUs. All three receive the same resolved recipe configuration. Each controller owns an independent SNNLab stream containing one Elevate neuron; the plant integrates joint mechanics without a neural network. These Python-like blocks omit boilerplate. ROS2 scheduling and message helpers are schematic; prepare_stream and stream.advance denote SNNLab’s streaming interface.

from snnlab.sim import prepare_stream
from torch import float64

cfg = recorded_configuration
dt = cfg["dt_ms"] * ms
nodes = launch_ros2_processes("flexor", "extensor", "plant")
wait_for_ros2_discovery_and_controller_warmup(nodes)
arm_shared_start(nodes, future_ros_time())


def prepare_controller():
    bundle = author_network(cfg)
    stream = prepare_stream(
        bundle,
        window_steps=1,
        batch_size=1,
        device="cpu",
        dtype=float64,
        performance=recorded_performance,
    )
    warm_up_then_reset(stream)
    return stream


def windows(node):
    for n in range(total_windows):
        release = node.monotonic_origin + n * dt
        spin_ros2_until(node, release)
        frames = wait_for_ros2_frames(node, index=n - 1)
        enforce_lag_limit(release)
        with measure_callback(node, deadline=release + dt):
            yield n, frames
            publish_ros2_frame(node, n, node.output_events)

Each node consumes both other nodes’ complete preceding windows from /frames/flexor, /frames/extensor or /frames/plant. The initial inputs are empty; every completed window is published, including silence. Late nodes catch up without dropping windows or shifting deadlines. Callback timing includes model execution, spike publication and diagnostic buffering, but excludes input waiting. Buffered diagnostics are exported after control finishes.

2. Flexor node

The flexor receives excitatory sensory spikes from the plant and inhibitory motor spikes from the extensor. Its local excitation is 0.5 µS for the first 20 ms, then 0.04 µS. SNNLab retains membrane and synaptic state between steps.

node = create_ros2_node("flexor")
stream = prepare_controller()

for n, frames in windows(node):
    drive = cfg["flexor_initial_us"] if n * dt < 20 * ms else cfg["tonic_us"]
    inputs = snnlab_inputs(
        tonic=drive,
        sensory=count_sensory_spikes(frames["plant"], channel="flexor"),
        peer=count_spikes(frames["extensor"]),
    )
    result = stream.advance(inputs)
    node.output_events = motor_spike_events(result)
    publish_ros2_spikes("/motor/flexor", node.output_events)
    buffer_voltage_and_spikes(node, result)

3. Extensor node

The extensor receives excitatory sensory spikes from the plant and inhibitory motor spikes from the flexor. Its SNNLab stream is separate from the flexor’s. Local excitation starts at 0 µS for 20 ms, then switches to the same 0.04 µS tonic drive.

node = create_ros2_node("extensor")
stream = prepare_controller()

for n, frames in windows(node):
    drive = cfg["extensor_initial_us"] if n * dt < 20 * ms else cfg["tonic_us"]
    inputs = snnlab_inputs(
        tonic=drive,
        sensory=count_sensory_spikes(frames["plant"], channel="extensor"),
        peer=count_spikes(frames["flexor"]),
    )
    result = stream.advance(inputs)
    node.output_events = motor_spike_events(result)
    publish_ros2_spikes("/motor/extensor", node.output_events)
    buffer_voltage_and_spikes(node, result)

4. Plant/Joint node

The plant receives both controllers’ motor windows. Before advancing mechanics, it encodes the current angle as sensory spikes; motor spikes then update muscle activation and joint motion. Sensory output returns through ROS2 and enters the controllers in the next logical window. This node does not execute SNNLab.

node = create_ros2_node("plant")
joint = Joint(cfg["joint"])

for n, frames in windows(node):
    sensory = joint.encode(n * dt / second)
    motor = [count_spikes(frames["flexor"]), count_spikes(frames["extensor"])]
    joint.advance(motor, dt / second)
    node.output_events = sensory_spike_events(sensory)
    publish_ros2_spikes("/sensory/flexor", sensory[0])
    publish_ros2_spikes("/sensory/extensor", sensory[1])
    buffer_angle_velocity_and_activation(node, joint)

Network diagram

Saved single-cell controller graph with peer inhibition, sensory excitation and tonic conductance input.
Figure 2.

Controller circuit from the saved flexor bundle; both sides use this architecture. MM is the motor cell, PP peer spikes, SS sensory spikes and TT tonic conductance. Each connection is labelled WXYW_{XY}, with target XX first and source YY second. Arrowheads denote excitation; bars denote inhibition. Recording aliases are omitted.

Parameters

Values come from the selected recording’s native snapshot and configuration.

ParameterRecorded value
Network
Motor cells per controller1
Controller populationsmotor
Neuron modelelevate
Ionic membrane dynamics
Capacitance (nF)1
Sodium conductance (µS)100
Potassium conductance (µS)65
Calcium conductance (µS)2
Slow potassium conductance (µS)10
Leak conductance (µS)2
Sodium reversal (mV)40
Potassium reversal (mV)-90
Calcium reversal (mV)120
Leak reversal (mV)-50
Excitation reversal (mV)0
Inhibition reversal (mV)-80
Initial voltage (mV)-60
Constant applied current (nA)-5
Sodium activation filter (ms)0.1
Sodium inactivation filter (ms)1
Potassium activation filter (ms)1
Calcium activation filter (ms)1
Slow potassium activation filter (ms)75
Maximum internal step (ms)0.025
Synaptic decay (ms)
local_biasDirect conductance
peer_inhibition50
sensory_drive10
Synapses and weights
local_bias1
peer_inhibition0.2
sensory_drive0.035
Input and simulation
Duration (s)30
Numerical timestep (ms)1
Control period (ms)1
Seed0
Controller warm-up simulated duration (ms)1000
Starting pulse duration (ms)20
Starting flexor / extensor drive (µS)0.5 / 0
Ongoing tonic drive (µS)0.04
Spike transportlogical
SNNLab executiondefault
Lag stop / delivery budget (ms)8000 / 5
Analysis
Seed count1 (selected acquisition)
Assessed complete cycles10
Cycle burn-in (s)5
Cycle relative tolerance0.01
Phase samples per cycle128
Joint mechanics
Activation increment per normalized spike0.003
Activation decay (s)0.08
Angle conventionzero_downward_positive_right
Arm length (m)0.2
Damping (N m s/rad)0.02
Gravity (m/s²)9.81
Hard stop relative to centre (rad)1
Base inertia (kg m²)0.005
Initial angle (rad)1.5707963267948966
Maximum muscle torque (N m)0.8
Sensory and stop centre (rad)1.5707963267948966
Minimum sensory interval (s)0.01
Sensory threshold relative to centre (rad)0.25
Spring rest angle (rad)1.5707963267948966
Stiffness (N m/rad)0.02
Tip point mass (kg)0.002
Total inertia including tip mass (kg m²)0.00508

Dataset

Upstream: —

Downstream: —

Current runset

RunDateDurationSizeEvidenceStorageRan onVerification
exp152-r116-present1s19.4 MBCurrentBothLocalLocal: byte-verified; R2: catalog-verified
exp152-r115-analyse<1s4.8 MBCurrentBothLocalLocal: byte-verified; R2: catalog-verified
exp152-r114-compute41s68.4 MBCurrentBothLocalLocal: byte-verified; R2: catalog-verified
exp152-r113-analyse<1s127.9 KBCurrentBothLocalLocal: byte-verified; R2: catalog-verified
exp152-r112-compute36s1.9 MBCurrentBothLocalLocal: byte-verified; R2: catalog-verified
Other stored runs (12)

These runs are outside this experiment's current runset. Their evidence status explains why they remain stored.

RunDateDurationSizeEvidenceStorageRan onVerification
exp152-r155-present<1s13 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r154-analyse<1s2 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r153-compute41s57.4 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r152-present<1s3.5 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r151-analyse<1s692.5 KBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r150-compute19s15.4 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r149-present1s5.1 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r148-present1s20.4 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r147-analyse<1s3 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r146-compute45s2.6 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r136-analyse<1s1.6 MBUnusedR2LocalLocal: absent; R2: catalog-verified
exp152-r135-compute22s18.1 MBUnusedR2LocalLocal: absent; R2: catalog-verified