TeslasuitDocumentation
Frameworks

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)#

  1. launch() is called with MyStrategy, engine_class, lsl_enabled, external_input_streams, etc.
  2. multiprocessing.Queue instances are created (control_queue, utility_queue).
  3. A backend subprocess is forked running run_engine_process(MyStrategy, ...).
  4. (If a GUI is configured) a GUI process is forked too.
  5. The launcher's main thread waits on Ctrl-C / process exit.

Process 2 — Backend (run_engine_process)#

  1. ExternalInputManager is built in-process for any registered LSL streams (Step 6).
  2. MyEngine (your ClosedLoopEngine subclass) is instantiated.
  3. The engine auto-creates SuitHandler (which connects to the suit and loads MuscleMap), DataStreamer, Stimulator, LSLStreamer, QueueHandler.
  4. 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.
  5. 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)#

  1. PyQt5 QApplication is created.
  2. MainWindow builds the widgets.
  3. A DataHandler attaches to the SharedRingBuffer to read sensor frames at 30 Hz.
  4. A QueueHandler listens on utility_queue and pushes onto control_queue.
  5. 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 SharedRingBuffer

Where each concern lives in your code#

ConcernFileStyle
Control logicapp/strategy.pyControlStrategyBase subclass
Application parametersapp/messages.pyControlMessage subclass
Lifecycle hooks (calibration, init)app/engine.pyClosedLoopEngine subclass
GUI widgetsapp/gui/PyQt5; reads SharedRingBuffer, writes queues
Launchermain.pyorchestrator.launch(...)
Hardware configfes_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#

ChangeImpact
Add a field to MyControlMessageLSL channel count grows; GUI gains an extra knob
Add a muscle to muscle_map_4R.jsonAdd the muscle to EmsData too; new EMSParamData slot becomes available
Switch from lsl_enabled=False to True7 outlets start broadcasting; LabRecorder picks them up
Add an external LSL inletself.external_data["new_stream"] becomes available
Change sample_rate (default 100)LSL outlet metadata only — does NOT regulate timing
Subclass DataStreamerCustom signal processing; pass data_streamer=... to engine
Override on_cycle_complete()Per-cycle visualisation, logging, metrics

What you should NOT change without thinking carefully#

ChangeRisk
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 boundaryWon't work; build inlets in-process

A debugging checklist#

If your full application is misbehaving, walk through this list in order:

  1. Is the framework running at all? Look for [MyStrategy] cycle N lines incrementing.
  2. Is calibration succeeding? Look for the [CalibratedEngine] quality=... line. If quality is low, see Calibration → Quality checks.
  3. Are sensors changing? Print self.joints.KneeFlexExtR and confirm it moves with the user. If not, check set_biomech_collection(True).
  4. Is the strategy seeing the right inputs? Print self.params on the first cycle to confirm your MyControlMessage is wired up.
  5. Is stimulation being delivered? Print self.ems_output.<muscle> after assignment. If IsMuted=False but you feel nothing, check UtilityMessage.FesIsActive (the kill switch) and the device's enable state.
  6. Is the LSL streamer running? Run pylsl.resolve_streams() in another shell and confirm the 7 outlets appear.
  7. Is the GUI talking to the backend? Move a slider and look for [engine] on_control_message in the log. If silent, the queue isn't being drained — check that you passed control_queue= to launch().

Reading the example applications#

Each of these is a complete working application built on the patterns from this guide:

ExampleWhat it shows
examples/atomic/*.pySteps 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 MuscleMap config + EmsData dataclass.
  • Custom sensors — register them as LSL inlets (Step 6) or subclass DataStreamer to read them in-process.
  • Different hardware — swap the MuscleMap JSON config (the framework supports Teslasuit 4.x and XR5; ship the JSON that matches the suit on the bench).
  • Custom haptic patternsHapticLibrary subclass + 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.