Step 9 — Full application
Goal: see how Steps 1–8 compose into a complete FES application, and understand what to copy when you start your own. You'll use: everything from the previous 8 steps. Builds on: all previous steps.
What you're adding#
Nothing new. This step is a guided tour of the application you've built across the previous 8 steps, plus pointers to where each concern lives in the code. Use it as a checklist when you start your own application.
If you've been writing code along with the steps, your project should look something like this:
my_fes_app/
├── app/
│ ├── __init__.py
│ ├── strategy.py (Steps 1–6) ControlStrategyBase subclass
│ ├── messages.py (Step 5) MyControlMessage subclass
│ ├── engine.py (Steps 4–5) CalibratedParamEngine subclass
│ └── gui/ (Step 8)
│ ├── __init__.py
│ ├── main_window.py
│ ├── data_handler.py
│ └── widgets/
└── main.py (Steps 1–7) launch()End-to-end flow#
Walk through what happens when the operator launches the application:
Process 1 — Launcher (main.py)#
launch()is called withMyStrategy,engine_class,lsl_enabled,external_input_streams, etc.multiprocessing.Queueinstances are created (control_queue,utility_queue).- A backend subprocess is forked running
run_engine_process(MyStrategy, ...). - (If a GUI is configured) a GUI process is forked too.
- The launcher's main thread waits on Ctrl-C / process exit.
Process 2 — Backend (run_engine_process)#
ExternalInputManageris built in-process for any registered LSL streams (Step 6).MyEngine(yourClosedLoopEnginesubclass) is instantiated.- The engine auto-creates
SuitHandler(which connects to the suit and loadsMuscleMap),DataStreamer,Stimulator,LSLStreamer,QueueHandler. engine.run()enters the main loop:strategy.setup(muscles, suit)is called once.on_start()fires — calibration gate runs (Step 4).- The 10-step per-cycle pipeline runs at ~100 Hz.
- On Ctrl-C or
STOP_SENTINEL:on_stop()runs (try/except — wrapped)._cleanup()stops mocap, mutes EMS, closes inlets.
Process 3 — GUI (gui_runner, optional)#
- PyQt5
QApplicationis created. MainWindowbuilds the widgets.- A
DataHandlerattaches to theSharedRingBufferto read sensor frames at 30 Hz. - A
QueueHandlerlistens onutility_queueand pushes ontocontrol_queue. - The Qt event loop runs until the window is closed.
Cycle-by-cycle (inside engine.run())#
1. data_streamer.run_cycle() ── collect/process/distribute
2. control_strategy.external_data = ext.pull_all() ── LSL inlets (Step 6)
3. queue_handler.poll_queues() ── new ControlMessage / UtilityMessage
4. control_strategy.run_strategy(...) ── your process(); FES kill switch applied
5. stimulator.run_stimulator(...) ── EMS output to SDK
6. (if haptic_library)
library_stimulator.run_stimulator(...) ── custom haptic playables
7. lsl_streamer.stream_all_data(...) ── 7 outlets pushed
8. on_cycle_complete() ── your hook; write SharedRingBufferWhere each concern lives in your code#
| Concern | File | Style |
|---|---|---|
| Control logic | app/strategy.py | ControlStrategyBase subclass |
| Application parameters | app/messages.py | ControlMessage subclass |
| Lifecycle hooks (calibration, init) | app/engine.py | ClosedLoopEngine subclass |
| GUI widgets | app/gui/ | PyQt5; reads SharedRingBuffer, writes queues |
| Launcher | main.py | orchestrator.launch(...) |
| Hardware config | teslasuit_rapidkit/config/muscle_map_4R.json | (don't edit unless you have to) |
This is the canonical layout. Every full example
(examples/elbow_flexion/, examples/haptic_navigation/,
examples/haptic_proximity_radar/, examples/vestibular_training/)
follows it. When you start a new application,
copy examples/elbow_flexion/ and
modify in place — it's the closest thing to a project template.
What you can change without breaking things#
| Change | Impact |
|---|---|
Add a field to MyControlMessage | LSL channel count grows; GUI gains an extra knob |
Add a muscle to muscle_map_4R.json | Add the muscle to EmsData too; new EMSParamData slot becomes available |
Switch from lsl_enabled=False to True | 7 outlets start broadcasting; LabRecorder picks them up |
| Add an external LSL inlet | self.external_data["new_stream"] becomes available |
Change sample_rate (default 100) | LSL outlet metadata only — does NOT regulate timing |
Subclass DataStreamer | Custom signal processing; pass data_streamer=... to engine |
Override on_cycle_complete() | Per-cycle visualisation, logging, metrics |
What you should NOT change without thinking carefully#
| Change | Risk |
|---|---|
Override engine.run() | Re-implements the 10-step pipeline; easy to drop a step |
Override ControlStrategyBase.run_strategy() | Bypasses the FES kill switch |
Call time.sleep() in process() | Drops cycles, breaks pacing |
Write to self.ems_output outside process() | Ignored by the framework |
Modify self.joints etc. | Read-only; mutation has no effect on next cycle |
| Pickle pylsl objects across the process boundary | Won't work; build inlets in-process |
A debugging checklist#
If your full application is misbehaving, walk through this list in order:
- Is the framework running at all? Look for
[MyStrategy] cycle Nlines incrementing. - Is calibration succeeding? Look for the
[CalibratedEngine] quality=...line. If quality is low, see Calibration → Quality checks. - Are sensors changing? Print
self.joints.KneeFlexExtRand confirm it moves with the user. If not, checkset_biomech_collection(True). - Is the strategy seeing the right inputs? Print
self.paramson the first cycle to confirm yourMyControlMessageis wired up. - Is stimulation being delivered? Print
self.ems_output.<muscle>after assignment. IfIsMuted=Falsebut you feel nothing, checkUtilityMessage.FesIsActive(the kill switch) and the device's enable state. - Is the LSL streamer running? Run
pylsl.resolve_streams()in another shell and confirm the 7 outlets appear. - Is the GUI talking to the backend? Move a slider and look
for
[engine] on_control_messagein the log. If silent, the queue isn't being drained — check that you passedcontrol_queue=tolaunch().
Reading the example applications#
Each of these is a complete working application built on the patterns from this guide:
| Example | What it shows |
|---|---|
examples/atomic/*.py | Steps 1–4, 6–7 in their cleanest form |
examples/elbow_flexion/ | PID control, antagonist muscle pair, GUI with auto-tuner |
examples/haptic_navigation/ | HapticLibrary with directional cues |
examples/haptic_proximity_radar/ | Continuous direction and distance haptic cues |
examples/vestibular_training/ | Haptic biofeedback driven by motion capture (postural sway) |
examples/generic_gui/ | Reference PyQt5 template (no real strategy) |
Read them in that order. By the time you've read them all, you'll have seen every framework feature in use. All examples are in the RapidKit repository.
Where the framework grows from here#
Things you might find yourself wanting:
- More muscles — extend
MuscleMapconfig +EmsDatadataclass. - Custom sensors — register them as LSL inlets (Step 6) or
subclass
DataStreamerto read them in-process. - Different hardware — swap the
MuscleMapJSON config (the framework supports Teslasuit 4.x and XR5; ship the JSON that matches the suit on the bench). - Custom haptic patterns —
HapticLibrarysubclass +setup()factories. See Messaging — HapticLibrary. - Real-time analytics — override
on_cycle_complete()to push to a metrics sink. Don't block the loop. - Multi-session orchestration — the framework runs one engine
per process; for parallel sessions, fork additional backends with
distinct LSL
source_ids.
What you've learned#
- The full per-cycle pipeline, from collect → through process → to stimulate.
- Where each concern lives in a typical project layout.
- What you can safely change vs. what to leave alone.
- A debugging order to follow when something goes wrong.
Beyond this guide#
You're done with the linear path. From here:
- Browse the Examples — pick one closest to your application and start from it.
- Use the API Reference as your daily lookup.
Build something with it.
