Step 3 — Stimulating muscles by name
Goal: stimulate a muscle when a sensor condition is met.
You'll use: MuscleMap, EMSParamData, EmsData
Builds on: Step 2
Anchored example: examples/atomic/semantic_muscle_control.py
What you're adding#
This is where the suit starts to do something. You'll write
EMSParamData to self.ems_output whenever a sensor condition is met.
The framework's stimulator will pick it up, look up the SDK channels
via MuscleMap, and deliver pulses to the muscle.
⚠️ Safety: stimulation is real and immediate. The suit must be calibrated to the wearer in Control Center before any stimulation — amplitude is a percentage of that calibrated range. Test on yourself first at low amplitude (20–30%), keep your hand on Ctrl-C throughout, and never run on someone else's body without their explicit consent and the Safety checklist understood.
Code#
# app/strategy.py
from fes_framework.control.strategy_base import ControlStrategyBase
from fes_framework.data.types import EMSParamData
class StanceQuadStrategy(ControlStrategyBase):
"""Stimulate right quadriceps during right-foot stance."""
AMPLITUDE = 30 # % — start low
PULSE_WIDTH = 120 # μs
PERIOD = 20.0 # ms = 50 Hz
def process(self) -> None:
if self.contacts.right_foot_contact:
self.ems_output.quadriceps_right = EMSParamData(
IsMuted=False,
Amplitude=self.AMPLITUDE,
PulseWidth=self.PULSE_WIDTH,
Period=self.PERIOD,
)
else:
self.ems_output.quadriceps_right = EMSParamData(IsMuted=True)# app/main.py
from fes_framework.orchestrator import launch
from app.strategy import StanceQuadStrategy
if __name__ == "__main__":
launch(StanceQuadStrategy)(No engine subclass needed this time. Foot-contact data is collected
automatically — only the biomechanical-angle collection from Step 2
is opt-in. If you need both, add a JointReadingEngine-style
subclass.)
Walkthrough#
self.ems_output.quadriceps_right = EMSParamData(...) — the only
output the strategy is allowed to write. The slot name comes from
the MuscleMap config (muscle_map_4R.json); see
Data types reference → EmsData.
EMSParamData(...) — four fields:
| Field | Unit | Range | Meaning |
|---|---|---|---|
IsMuted | bool | — | True = no pulses delivered this cycle |
Amplitude | % | 0–100 | Stimulation intensity (% of device max) |
PulseWidth | μs | 10–140 | Pulse duration (device range: 10–140) |
Period | ms | > 0 | Time between pulses; 1000 / Period = Hz |
Period=20.0 → 50 Hz, the typical FES pulse rate.
Why the else branch. When the foot is in swing (no contact),
you want the quadriceps to stop. Setting
EMSParamData(IsMuted=True) mutes the looped playable. Without the
else, the muscle would stay stimulated indefinitely from the last
stance pulse — the framework only reads what process() writes.
Why no SDK calls. You never call haptic.play_touch() or look
up channel IDs. The framework's Stimulator does that, using the
MuscleMap lookup, after every process() returns.
Available muscles#
The default muscle_map_4R.json defines 20:
# Lower body
self.ems_output.quadriceps_left
self.ems_output.quadriceps_right
self.ems_output.hamstring_left
self.ems_output.hamstring_right
self.ems_output.gastrocnemius_left
self.ems_output.gastrocnemius_right
self.ems_output.tibialis_anterior_left
self.ems_output.tibialis_anterior_right
self.ems_output.gluteus_left
self.ems_output.gluteus_right
# Upper body
self.ems_output.deltoid_left
self.ems_output.deltoid_right
self.ems_output.biceps_left
self.ems_output.biceps_right
self.ems_output.triceps_left
self.ems_output.triceps_right
self.ems_output.wrist_flexors_left
self.ems_output.wrist_flexors_right
self.ems_output.wrist_extensors_left
self.ems_output.wrist_extensors_rightIterate by side or region using self.muscles:
def process(self) -> None:
for muscle in self.muscles.by_side("left"):
setattr(self.ems_output, muscle.name, EMSParamData(
IsMuted=False,
Amplitude=muscle.default_amplitude_pct,
PulseWidth=muscle.default_pulse_width_us,
Period=muscle.default_period_ms,
))The MuscleInfo.default_* fields come from the JSON config and give
you sensible starting parameters per muscle.
Verify#
Wear the suit. Stand still. Pick up your right foot — stimulation should stop. Plant it again — stimulation resumes. The transition should be near-instantaneous (within ~20 ms).
If nothing happens:
- Check Control Center says "stimulation enabled" or equivalent. Some SDK versions require an explicit enable.
- Confirm
IsMuted=Falsein your code (typo-easy mistake). - Print
self.contacts.right_foot_contactto confirm the sensor is flipping. - See Troubleshooting → Stimulation not activating.
If stimulation feels too weak: increase AMPLITUDE by 5 each run
until you feel a clear contraction. Stop increasing immediately if
the user (yourself or others) reports discomfort.
Things you should not do#
- Don't write to
self.ems_outputoutsideprocess(). The framework reads it once per cycle, right afterprocess()returns. - Don't call
time.sleep()to "ramp" amplitude. Ramp by writing small per-cycle deltas instead. - Don't allocate huge arrays per cycle. The 100 Hz loop is tight.
- Don't forget the FES kill switch — your strategy doesn't need to
check it (the framework enforces
UtilityMessage.FesIsActive), but you should still build an emergency-stop path into your GUI later.
What you've learned#
- The strategy writes one
EMSParamDataper muscle toself.ems_outputper cycle. - The framework's
Stimulatortranslates that to SDK haptic calls viaMuscleMap. You never see channel IDs. - The FES kill switch is enforced by the framework after every
process().
Next#
Step 4 — Calibration gate. You'll refuse to start the loop until calibration quality is acceptable.
