Stimulator
The output layer. Reads EmsData after every process() and turns
it into Teslasuit SDK haptic calls — one looped playable per muscle
channel. The FES kill switch is enforced here.
Source: fes_framework/io/stimulator.py
API reference: api_reference.md → Stimulator
What it is#
Stimulator is the bridge between the strategy's EmsData output and
the Teslasuit SDK's haptic subsystem. Each cycle it walks every muscle
field on EmsData, looks up the SDK channel IDs via MuscleMap, and
issues the appropriate haptic call:
- if
IsMuted=True→ mute the looped playable for that muscle, - if
IsMuted=Falseand the parameters changed since last cycle → destroy the old looped playable and create a new one, - if
IsMuted=Falseand the parameters are unchanged → leave the looped playable running (no SDK call).
This caching matters: at 100 Hz, recreating 20 looped playables every cycle would saturate the SDK and create haptic discontinuities. The stimulator only re-creates a playable when the strategy actually changes its parameters.
The framework also creates a sister LibraryStimulator that handles
custom HapticLibrary slots. It only runs if the strategy populated
self.haptic_library in setup(). See Messaging — HapticLibrary.
Why it exists#
Without the stimulator, every strategy would have to manage SDK playables directly — creating, looping, muting, recreating on parameter change, cleaning up on shutdown. That's a lot of bookkeeping for what is conceptually "play these stim parameters on this muscle." The stimulator does the bookkeeping once.
It also enforces the FES kill switch. The framework guarantees that
when UtilityMessage.FesIsActive is False, no stimulation is
delivered — regardless of what the strategy's process() wrote. The
guarantee is implemented as a pre-stimulator step in
ControlStrategyBase.run_strategy() that overwrites every muscle with
muted parameters before the stimulator runs.
When to use it#
Implicitly, always. The engine creates a Stimulator for you and
calls it after every process(). You don't import or instantiate it
in normal use.
You almost never subclass it. If you need custom haptic behaviour
beyond EmsData, use the HapticLibrary extension point instead —
it's the supported way to deliver custom playables.
How a single cycle looks#
strategy.process() writes self.ems_output.quadriceps_right = EMSParamData(
IsMuted=False, Amplitude=40, PulseWidth=120, Period=20.0
)
│
▼ framework copies to ems_data, applies FES kill switch
│
▼
Stimulator.run_stimulator(suit_handler, ems_data)
for each muscle in EmsData.__dataclass_fields__:
new_params = getattr(ems_data, muscle)
if new_params.IsMuted and old_params.IsMuted:
continue # already muted, nothing to do
if new_params == old_params:
continue # no change, looped playable continues
suit_handler.haptic.create_touch(...) # recreate playable
suit_handler.haptic.set_playable_looped(...)
suit_handler.haptic.set_playable_muted(playable_id, IsMuted)
old_params = new_paramsThe "params changed?" comparison is what keeps the SDK call rate down. A stable strategy creating one stim event per stance phase issues roughly one SDK call per phase boundary, not 100 per second.
How the FES kill switch is enforced#
In ControlStrategyBase.run_strategy():
self.process() # user writes ems_output
_apply_ems_output(fes_active, self.ems_output, ems_data) # framework copies / mutes_apply_ems_output checks fes_active:
True→ copyself.ems_output.<muscle>→ems_data.<muscle>False→ write a pre-allocated_MUTEDEMSParamDatato every muscle
By the time the stimulator sees ems_data, every muscle has either
the strategy's intent or a guaranteed-muted value. The strategy never
needs to check FesIsActive.
_MUTED is module-scoped and reused every cycle so the framework
doesn't allocate 20 new dataclass instances at 100 Hz.
Minimal example — interaction from a strategy#
You don't call the stimulator directly. You write to ems_output and
the framework calls the stimulator on your behalf:
class StanceQuadStrategy(ControlStrategyBase):
def process(self) -> None:
if self.contacts.right_foot_contact:
self.ems_output.quadriceps_right = EMSParamData(
IsMuted=False, Amplitude=40, PulseWidth=120, Period=20.0,
)
else:
self.ems_output.quadriceps_right = EMSParamData(IsMuted=True)Stimulation parameters interpretation:
Amplitude=40— 40% of the device's current limit. Start conservatively (20–30) and increase only with the user's consent.PulseWidth=120— 120 μs per pulse. The suit's supported pulse-width range is 10–140 μs.Period=20.0— pulses 20 ms apart, so 50 Hz pulse frequency. Typical FES range is 20–50 Hz (period 50.0–20.0 ms).
Things to be aware of#
- Mute is "stop emitting", not "delete the playable". A muted
looped playable is still alive in the SDK; it just isn't producing
any output. The next change to
IsMuted=Falsere-uses the same playable. - Parameter changes recreate the playable. Going from
Amplitude=30toAmplitude=40destroys and re-creates the SDK playable. There is a brief silent gap. If you need smooth ramping, ramp in your strategy and write small per-cycle deltas. - Don't write to
ems_outputoutsideprocess(). The framework reads it once afterprocess()returns. Writes fromon_cycle_complete()are ignored.
See also#
- API Reference → Stimulator
- Data Types → EmsData / EMSParamData
- MuscleMap — what the stimulator looks up against
- Messaging — HapticLibrary — for custom haptic playables
- Safety — the kill switch contract
- Implementation Guide → Step 3 — semantic muscle output
- Source:
fes_framework/io/stimulator.py
