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 | fes_framework/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/walking_fes/) 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/generic_gui/ | Reference PyQt5 template (no real strategy) |
examples/walking_fes/ | The full walking-FES research application |
Read them in that order. By the time you've read the Walking FES example, you'll have seen every framework feature in production.
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.
