ExoStack / Configure

Configuration

A complete setup is six small files that refer to each other by name. Configure them left to right; each one uses names defined by the one before it.

ESA sketch ──► sensors.yaml ──► gait_events.yaml ──► mplanner.yaml ──► profile.yaml ◄── lookup_table/*.json (per board) (hubs, channels) (event detectors) (planners) (profile source, cost, HIL)
This value …… must equal this value
sketch setupPublisher(topic, …)sensors.yaml → hubs.<hub>.topic
order and count of channels the board publishesorder and count of names in hubs.<hub>.sensors
gait_events.yaml → <event>.output_topicmplanner.yaml → <planner>.gait_event_topic
mplanner.yaml planner keyprofile.yaml → <profile>.planner_name
mplanner.yaml → <planner>.output_topicsketch setupActuation(topic, …) on the actuation board
algorithm names in any YAMLa file of that name (and a class of the same name) in its plug-in folder

A. ESA sketch: sensing hub

A board is declared, not programmed: list the devices, then call the set-up functions in the order shown. Highlighted values are the ones you change.

hub_right.ino
#include "ESA.h"
SoftExoWalk exo;

DeviceConfig deviceList[] = {
//  type       addr/pin   name       extra_info1     misc1         misc2
  { I2C1, 0x4A2, "IMU_T"3, MAIN4,   95 },                 // thigh IMU, 9 channels
  { I2C,       0x4A,       "IMU_S",       AUX1,         9 },                       // shank IMU (same address, 2nd bus)
  { I2C,       0x2A,       "LC_F",        MAIN,        -0.0243f,  -299.094f },     // load cell: slope, offset
  { I2C,       0x2A,       "LC_E",        AUX1,        -0.0242f,  -299.096f },
  { AI,        34,         "FSR",         MEDIAN,       3 },                       // median filter, window 3
};
const size_t deviceList_len = sizeof(deviceList) / sizeof(deviceList[0]);

void setup() {
  exo.setupCommunication("wifi", "SSID", "PASSWORD", "192.168.1.10", 88886);   // or: setupCommunication("usb")
  if (exo.initializeSystem()) {
    exo.setupNode("hub_right_node"7);
    exo.setupPublisher("/hub_right"8, 1009);                                     // topic, rate in Hz
    exo.bootSystem();                                                                    // always last
  }
}

void loop() {
  exo.readSensorsData();
  exo.publish(true);                       // true: rebuild the link automatically if the agent restarts
}
1device type: AI, DI or I2C
2GPIO pin (AI, DI) or 7-bit address (I²C)
3short label for your reference
4meaning depends on the type: see the table below
5misc1 / misc2: meaning depends on the type and driver
6Wi-Fi network, password, IP address of the host running the agent, UDP port
7ROS 2 node name of this board (unique per board)
8topic name: must equal hubs.<hub>.topic in sensors.yaml
9publication rate (Hz); also the sampling rate used to design the analog filters

What each field of a device row means

Table S1. Device descriptor. One row of deviceList[] declares one device. The same seven fields serve every device type; four of them are read according to the type.
FieldAnalog input AIDigital input DII²C device I2C
IP_typedevice typeAIDII2C
pin_or_addresswhere it is connectedADC pin; GPIO 32–39 (ADC1) when the board uses Wi-FiGPIO pin7-bit address of the chip, e.g. 0x4A
namelabelShort label for your reference, e.g. "FSR", "HEEL", "IMU_T"
extra_info1optionFilter: FILTER_NONE (default), MOV_AVG, MEDIAN, EXPONENTIAL, BUTTER2_LPF, BUTTER2_HPF, BUTTER2_BPF, BUTTER2_NOTCHPin mode: NORMAL (default), PDOWN (pull-down), PUP (pull-up)Bus: MAIN (default): SDA 21, SCL 22
AUX1: SDA 19, SCL 18
dev_misc_dataparameter 1MOV_AVG, MEDIAN: window, samples (≤ 50)
EXPONENTIAL: α, 0–1 (smaller = smoother)
BUTTER2_*: cut-off or centre frequency, Hz
n/aBNO085: channels 3, 6 or 9 (other values give 3)
NAU7802, AS5600: not used
dev_misc_data2parameter 2BUTTER2_*: Q, required (0.707 = Butterworth)
other filters: not used
n/an/a
data_per_deviceset by ESA: omit11set by the driver: NAU7802 1 · AS5600 3 · BNO085 3, 6 or 9
Example row{ AI, 34, "FSR", MEDIAN, 3 }{ DI, 26, "HEEL", PUP }{ I2C, 0x4A, "IMU_T", MAIN, 9 }

Fields are positional; trailing fields may be left out and are then 0, which gives the defaults marked above. n/a: not used.

Butterworth filters are designed for the publication rate given to setupPublisher().

Driver calibration (NAU7802 slope and zero offset, AS5600 direction pin) is not part of the row; it is set in the driver's registration line (Table S2).

The I²C drivers shipped with ESA:

I²C driverAddressChannels (in order)misc1misc2
NAU7802 load-cell amplifier0x2Aforce, N (tension only)n/a (slope and offset are set in the driver's registration line)n/a
BNO085 IMU0x4A, 0x4B3: roll, pitch, yaw (deg) · 6: + angular velocity x,y,z · 9: + linear acceleration x,y,z3, 6 or 9n/a
AS5600 magnetic encoder0x36angle (deg), angular speed (deg/s), cumulative position (counts)n/a (direction pin GPIO 4, set in the driver)n/a

B. ESA sketch: actuation board

actuator_board.ino
#include "ESA.h"
SoftExoWalk exo;

DeviceConfig deviceList[] = { };                      // no sensors on this board
const size_t deviceList_len = sizeof(deviceList) / sizeof(deviceList[0]);

void setup() {
  exo.setupCommunication("wifi", "SSID", "PASSWORD", "192.168.1.10", 8888);
  if (exo.initializeSystem()) {
    exo.setupNode("actuator_node");
    //                  command topic              actuator                   out    x1   x2
    exo.setupActuation("/right/hip_ppr_cmd"1, ACT_FESTO_VEAB_L262, 263, 174, 345);
    exo.setupActuation("/left/hip_ppr_cmd",        ACT_FESTO_VEAB_L26,        25,     5,  39);
    exo.bootSystem();
  }
}

void loop() {
  exo.actuate();              // all actuators; exo.actuate("/right/hip_ppr_cmd") drives only that one
}
1command topic: must equal output_topic of a planner in mplanner.yaml
2identifier of a registered actuator driver (table below)
3output: GPIO pin or I²C address
4first driver-specific value (omit if unused)
5second driver-specific value (omit if unused)
Actuatoroutx1x2Command (Float32)
ACT_FESTO_VEAB_L26 proportional pressure valveanalog output GPIO (25, 26)exhaust valve GPIOpressure-feedback ADC GPIO (omit if none)pressure set-point, kPa
ACT_SG90_SERVO servo via PCA9685PCA9685 I²C address (e.g. 0x40)PWM channel 0–15PWM frequency, Hz (50)angle, 0–180°
LED_TEST test outputGPIOn/an/a> 0 → on, otherwise off
Table S2. Driver registries. Each driver enters itself in a registry through one registration line in its header, before setup() runs. ESA then finds the driver for every device without any change to the library.
I²C sensor registryActuator registry
Registration linein the driver headerREGISTER_I2C_SENSOR(addr, bus, INIT, READ, info1, info2)REGISTER_ACTUATOR(ID, INIT, ACTUATE)
Keybus and 7-bit address, e.g. MAIN + 0x2Aidentifier of the ActuatorType enum in actuation.h, e.g. ACT_FESTO_VEAB_L26
Selected bya device row { I2C, addr, name, bus, … }, matched in initializeSystem()setupActuation(topic, ID, out, x1, x2), resolved in bootSystem()
Driver receivesinfo1, info2 of the matching registration lineout, x1, x2 of the call (values ≤ 0 arrive as 0)
No matching entrythe device fails; initializeSystem() returns falsethe actuator is skipped without an error
Capacity8 entries (MAX_I2C_SENS); the shipped lines use all 8one entry per identifier; up to 5 actuators per board
Entries in v1.0NAU7802: MAIN 0x2A, AUX1 0x2A; info1 slope, info2 zero offset, calibrated per bus
BNO085: MAIN 0x4A, MAIN 0x4B, AUX1 0x4A, AUX1 0x4B; no info values
AS5600: MAIN 0x36, AUX1 0x36; info1 = 4, the direction pin
ACT_FESTO_VEAB_L26: proportional pressure valve
ACT_SG90_SERVO: servo through a PCA9685
LED_TEST: test output
Adding a drivera header–source pair with its registration lines (§2.5)a header–source pair with its registration line, and the identifier added to ActuatorType (§2.6)

An I²C entry is looked up once and then called through a cached pointer; actuator entries are indexed directly by their identifier.

ESA RULES
  • Call order: setupCommunication → initializeSystem → setupNode → setupPublisher / setupActuation → bootSystem. The analog filters are designed when setupPublisher runs, so it must come after initializeSystem.
  • USB transport: micro-ROS uses the serial port, so do not call Serial.begin or Serial.print in the sketch.
  • Wi-Fi: analog inputs must use ADC1 pins (GPIO 32–39); ADC2 is unavailable while Wi-Fi is active.
  • I²C buses: MAIN = SDA 21 / SCL 22, AUX1 = SDA 19 / SCL 18, 400 kHz. A bus–address pair can be used once.
  • A board may publish and actuate at once: call both setupPublisher and setupActuation, and in loop() call readSensorsData(), publish(true) and actuate(). Up to 5 actuators per board.
  • An analog device whose name starts with BAT is treated as a battery monitor (×1.34 divider, clipped at 4.2 V).
  • Status LED on GPIO 23 blinks if a ROS entity cannot be created; the watchdog resets a stalled board after 3 s.

C. sensors.yaml: hubs and channel names

One entry per sensing board. List one name per published channel, in the exact order the board publishes them: devices in deviceList order, each expanded to its channel count. These names are how every algorithm refers to a signal.

~/exo_stack_resources/config/sensors.yaml
hubs:
  hubr1:
    topic: "/hub_right"2
    sensors: ["thigh_roll", "thigh_pitch", "thigh_yaw", "thigh_omega_x", "thigh_omega_y", "thigh_omega_z",3
              "thigh_acc_x", "thigh_acc_y", "thigh_acc_z",
              "shank_roll", "shank_pitch", "shank_yaw", "shank_omega_x", "shank_omega_y", "shank_omega_z",
              "shank_acc_x", "shank_acc_y", "shank_acc_z",
              "force_flexion", "force_extension", "fsr"]
  hubl:
    topic: "/hub_left"
    sensors: [ ... same 21 names ... ]
slop_time: 0.0104
output_topic: "/synced_sensors"
1hub key: used as [hub, name] in the other files
2the board's publisher topic
3channel names in publication order
4seconds; messages from different hubs closer than this are combined into one sample
Device row (sketch)ChannelsSlotsNames in sensors.yaml
IMU_T (BNO085, misc1 = 9)90–8thigh_roll … thigh_acc_z
IMU_S (BNO085, misc1 = 9)99–17shank_roll … shank_acc_z
LC_F (NAU7802)118force_flexion
LC_E (NAU7802)119force_extension
FSR (AI)120fsr
CHECK THE CHANNEL COUNTWith the board running, ros2 topic echo /hub_right --once shows range_max = number of channels and range_min = number of devices. The sensors list must have exactly range_max names. Keep output_topic: "/synced_sensors"; the cost node subscribes to that name.

D. gait_events.yaml: event detectors

~/exo_stack_resources/config/gait_events.yaml
events:
  right_heel_strike1:
    input_topic: "/synced_sensors"
    sensors: [[hubr, fsr2]]
    algorithm: "HeelStrike"3
    parameters: [2.0, 0.24]
    history_size: 55
    output_topic: "/heel_strike_right"6
1event key: becomes the node name; one detector node runs per key
2[hub, channel] pairs passed to the detector, in this order
3detector plug-in: file and class name in algo/gait_events/
4list handed to the detector; meaning per algorithm (table)
5sliding-window length in samples (PASE: Window Size)
6topic on which an Int32 pulse (value 1) is published at each event
DetectorparametersFires when
HeelStrike[threshold, fraction]the signal rises above threshold and stays there for fraction of the window, after a low window
ZeroCrossingPosToNeg[fraction]the signal crosses from positive to negative and stays negative for fraction of the window
ZeroCrossingNegToPos[fraction]the reverse crossing
Fig. S1. ExoCore elements (a)Gait-event detection with a confidence window HeelStrike · threshold 2.0 V · window N = 10 samples · fraction F = 0.3 → event after k = ⌈F·N⌉ = 3 consecutive samples above the threshold 0 1 2 3 FSR (V) 1 2 3 4 5 threshold event event 0 20 40 60 80 100 sample (100 Hz) detector armed disarmed armed disarmed 1 Spike: 2 samples above the threshold, fewer than k: rejected; the detector stays armed 2 Heel strike: k-th consecutive sample above while armed: event published; the detector disarms 3 Chatter: the signal re-crosses the threshold while disarmed: no event 4 Re-arm: a full window below the threshold, counted from the last sample above 5 Next heel strike: armed again, so the k-th sample above gives the next event ● above the threshold ○ below (b)From a lookup-table row to a motion command (Deployment mode) 1 Read the inputs every 1/rate = 20 ms /walking_speed Float32 = 3.8 (intent from a second topic, if required) 2 Select a row Lookup_Speed_Segmentwise lookup_table/<name>.json : keys are walking speeds; each row is [start, t1, v1, t2, v2, t3, v3, end] "3.5": [50, 180, 260, 380, 400, 580, 220, 800] "4.0": [50, 200, 300, 400, 450, 600, 250, 800] "4.5": [50, 220, 340, 420, 480, 620, 280, 800] ← nearest key to 3.8 (clamped at the first and last key) 3 Build the waypoints Builder_Nwaypoint_List adds the default value 20 at start and end → five (t ms, value) pairs: (50, 20) (200, 300) (400, 450) (600, 250) (800, 20) default 4 Publish the profile Float32MultiArray on /profile/right_hip_extension, every 20 ms 0 k 1 type 50 start 50 20 200 300 400 450 600 250 800 20 five waypoint pairs 800 end 0 e-stop k = 0: Deployment type 1: list profile 5 Play the profile rate 50 Hz /right_hip_extension_start = 1 starts the active profile: t = 0 t < start default_output start ≤ t ≤ end next_point(t) t > end default_output, profile ends A profile that arrives while one is playing waits in a buffer and is used from the next gait event on. 6 Drive the actuator Float32 /right/hip_ppr_cmd → actuation hub, ACT_FESTO_VEAB_L26 pressure set-point in kPa; its course over time is plotted in (c) profile node planner node (c)The five waypoints of (b), interpolated by two planners 0 100 200 300 400 500 set-point (kPa) start end default_output 0 100 200 300 400 500 600 700 800 900 time since the gait event, t (ms) LinearInterpolatedList straight segments, corners at waypoints PCHIPInterpolatedList smooth, no overshoot
Fig. S1. ExoCore elements, computed with the shipped plug-ins. (a) HeelStrike on an FSR signal (window 10 samples, fraction 0.3): an event needs k = 3 consecutive samples above the threshold while the detector is armed, and after an event it re-arms only once a full window lies below the threshold, so short spikes and chatter are rejected. The shipped gait_events.yaml uses history_size: 5 and fraction 0.2, for which one sample above the threshold is enough; the zero-crossing detectors apply the same rule to the sign of the signal. (b) One profile node and one motion planner node turn the walking speed into actuator set-points; the lookup table is an example in the row format of Builder_Nwaypoint_List. (c) The five waypoint pairs of (b) as interpolated by LinearInterpolatedList and PCHIPInterpolatedList; the PCHIP curve is the plug-in's interpolation with its inverted length check corrected (Known limitations of v1.0).

E. mplanner.yaml: motion planners

~/exo_stack_resources/config/mplanner.yaml
planners:
  right_hip_extension1:
    gait_event_topic: "/right_hip_extension_start"2
    planner_algorithm: "LinearInterpolatedList"3
    profile_type: "List"4
    profile_topic: "/profile/right_hip_extension"5
    output_topic: "/right/hip_ppr_cmd"6
    approach: "time_based"7
    rate: 508
    default_output: 20.09
1planner key: node name; referenced by planner_name in profile.yaml
2event that (re)starts the profile: an output_topic from gait_events.yaml
3planner plug-in in planner/; its name ends in List or Function; interpolation compared in Fig. S1c
4List or Function: must match the suffix of the planner and of the builder
5any unique name; the profile node publishes the profile here
6Float32 command to the actuator: same topic as setupActuation
7time_based: t = milliseconds since the event · gait_phase_based: t = latest value on gait_phase_topic (add that key)
8command publication rate, Hz
9command when idle, before the profile starts, after it ends, or on emergency stop
Topic (created per planner)TypeUse
/<planner>/manual_modeInt321 = manual mode on, 0 = off (profiles are reset)
/<planner>/manual_cmdFloat32in manual mode the output is default_output + manual_cmd
/<planner>/profile_statusFloat32MultiArray[profile number, gait-cycle number], at every gait event

F. profile.yaml: where the assistance profile comes from

Each profile feeds one planner. In Deployment mode the parameters come from a lookup table indexed by walking speed (and optionally intent); in HIL mode they come from an optimizer that is scored by a cost function.

KeyModeMeaning
planner_namebothplanner key in mplanner.yaml
modebothDeployment or HIL
profile_builder_algobothbuilder in algo/builder/ (table below); suffix must match the planner
speed_required, speed_topicDeploymentuse walking speed (Float32 topic) to index the table
intent_required, intent_topicDeploymentalso index by intent (Float32); intent 0 triggers an emergency stop
lookup_algo, lookup_tableDeploymentplug-in in algo/lookup_algo/; JSON file in lookup_table/ (name without .json)
rateDeploymentprofile republication rate, Hz (default 50)
hil_algoHILoptimizer in algo/hil_optimizer/ (upper-case name)
hil_output_topicHILtopic on which candidates are sent to the profile node (any unique name)
hil_hyperparamsHILoptimizer settings (next table)
cost_algo, cost_paramsHILcost plug-in in algo/cost/; list passed to its constructor
cost_topicHILtopic on which [profile number, cost] is returned (any unique name)
sensorsHIL[hub, channel] pairs given to the cost function, in this order
gait_cycles_to_skip, _to_considerHILcycles discarded after each new candidate, then cycles averaged into one cost
external_sensors_topicHIL, optionalFloat32MultiArray stream (e.g. metabolic, EMG) used instead of internal sensors
additional_gait_marker_topicHIL, optionalInt32 marker appended as an extra column for the cost function
Optimizerhil_hyperparams
OPT_CMA_ESmean (start point), bounds ([[min,max],…]), sigma, maximize, max_generations, population_size, n_max_resampling
OPT_BAYESIANpbounds ({name: [min,max], …}; key order = parameter order), maximize, max_iters, kind (ei, ucb, poi), xi, kappa, random_state
BuilderInput values (from lookup table or optimizer)Shape
Builder1_List[start, peak_t, peak_value, end]triangle
Builder2_List[start, t1, v1, t2, v2, end]trapezoid
Builder_Nwaypoint_List[start, t1, v1, …, tN, vN, end]any piecewise shape
Builder1_Function[start, c0, c1, …, cn, end]polynomial (Function planners); see App. A for v1.0
MATCH THE PARAMETER COUNTThe optimizer must produce exactly as many values as the builder expects, e.g. six for Builder2_List: six entries in mean and bounds, or six keys in pbounds. Times are in milliseconds for time_based planners and in gait-phase units for gait_phase_based ones. The List builders anchor the profile at their own default value (20) at the start and end times; this is independent of the planner's default_output.

G. Lookup tables

lookup_table/speed_list_lookuptable_2.json (speed only)
{ "3.5": [50.0, 200.0, 500.0, 600.0, 500.0, 800.0],          ← speed -> builder inputs
  "4.0": [200.0, 300.0, 100.0, 500.0, 150.0, 800.0] }
lookup_table/speed_intent_list_lookuptable_1.json (intent and speed)
{ "0.0": [500.0, 600.0, 100.0, 800.0],                         ← intent -> builder inputs ...
  "1.0": { "3.5": [500.0, 600.0, 100.0, 800.0],                ← ... or intent -> speed -> inputs
           "4.0": [400.0, 550.0, 150.0, 800.0] } }

Fig. S1b follows one row of such a table to the encoded profile. Lookup_Speed_Segmentwise uses the nearest speed key (clamped at the ends); Lookup_Speed_Interpolated interpolates between keys; Lookup_Speed_Intent_Segmentwise reads the two-level table. Write keys exactly as floats ("1.0", not "1"): incoming values are " "matched as text.

H. Configuring in PASE (Configuration tab)

The four panels edit the four YAML files, left to right. Lists of hubs, channels, events, planners and algorithms are filled from the saved files and from the plug-in folders, so you select rather than type.

  1. Sensors: + Add Hub; enter Hub Name, Topic Name and the channel names in Sensors (comma-separated, publication order); set Slop time and Output topic; press Save YAML.
  2. Gait Events: + Add Event; set Event Name, Input Topic Name; choose hub and channel under Select Sensors (+ Add Sensor for more); pick the algorithm; fill Parameters and Window Size; set Output Topic Name; Save YAML.
  3. Motion Planner: + Add Planner; choose the Gait Event Topic and Planner Algorithm from the lists; set Profile Type, Profile Topic, Output Topic, Approach (time_based; phase-based planners are set in the YAML file), Rate, Default Output; Save YAML.
  4. Profile: + Add Profile; choose Planner Name and Mode. For Deployment set the builder, Speed/Intent Required, Lookup Table, Lookup Algorithm, Rate. For HIL choose the optimizer, add one + Add Dimension row (Mean / Lower Bound / Upper Bound) per builder input, then under Define Cost Function set the cost algorithm, Cost Params, Cost Topic, Sensors for Cost and the gait cycles to skip and consider; Save YAML.
SAVE FROM LEFT TO RIGHTSaving a panel refreshes the lists of the panels to its right. A plug-in file you add to a folder appears in the corresponding list the next time PASE starts.
Sensors and Gait Events panels
Screenshot S2.2-1. Sensors and Gait Events panels: the two hubs with their channel names, and the heel-strike detectors, each reading one [hub, channel] pair.
Motion Planner and Profile panels (HIL)
(a) Motion Planners and Profiles
Motion Planner and Profile panels (HIL)
(b) cost function of the profile
Screenshot S2.2-2. (a) Motion Planners and Profiles panels with a HIL profile (optimizer OPT_CMA_ES, one row per optimized dimension); (b) the cost-function block of the same profile.