TeslasuitDocumentation
Frameworks

Design Principles

The architectural decisions behind RapidKit, and the reasoning that led to each. Read this when you want to understand why the framework looks the way it does.


1. The hardware is the clock#

Decision: the closed-loop runs at the rate the Teslasuit SDK produces samples (~100 Hz). There is no software throttle, no time.sleep, no software-paced timer.

Why: any software-managed clock would either drop hardware samples (if slower than the SDK) or busy-wait (if faster). Letting the SDK's blocking get_*_on_ready calls govern the cycle removes all timing complexity from user code, and guarantees that every cycle sees a fresh frame.

Consequence: sample_rate (default 100.0) on ClosedLoopEngine is metadata only — it labels LSL streams and informs diagnostics. It does not regulate execution.

Implication for users: never try to "slow the loop down" by sleeping inside process(). Move expensive work to on_cycle_complete() and gate it with cycle_count % N, or compute it in a worker thread.


2. One extension point, one method#

Decision: users extend the framework by subclassing ControlStrategyBase and implementing exactly one method, process(). Inputs are pre-populated on self. Outputs are written to self.ems_output. No arguments, no return value.

Why: the goal is for partner developers to express "what should the muscles do given the current sensor frame" without thinking about how the data got there or where the result goes. Method signatures with rich parameter lists and return-value contracts force users to learn the plumbing. Attribute slots don't.

Why not pass data as arguments? Because then a user adding new input sources (e.g. external LSL inlets) would have to either change every subclass's signature or read a **kwargs bag. The slot-based approach lets the framework grow more inputs without breaking existing strategies.

Consequence: the framework can add inputs (heart-rate, PPG, external LSL streams, custom signals from a DataStreamer override) by populating a new self.<name> attribute without ever changing the process() signature. Old strategies keep working.


3. Anatomical names everywhere#

Decision: the public API speaks in muscle names (quadriceps_left, biceps_right) and joint names (KneeFlexExtR, HipFlexExtL). It never exposes Teslasuit hardware identifiers like node indices, channel slices, or haptic layout structures.

Why: the original Teslasuit SDK exposes the hardware as it is — geometric arrays of nodes and channels. That representation is unworkable for clinicians and rehab researchers, who think in muscle groups and joint axes. The mapping from "biceps left" to "node 7, channels 14–17" is one-time configuration, not per-application code.

How: the MuscleMap (loaded from muscle_map_4R.json) is consulted at connection time to resolve every muscle name to its current SDK channel IDs. The resolution happens once; from then on, every EmsData write is a constant-time dispatch.

Consequence: swapping hardware versions (e.g. between Teslasuit 4.x and XR5) is a configuration change, not a code change. Partner code stays portable across hardware.

See: MuscleMap, fes_framework/config/muscle_map_4R.json.


4. Three layers, always#

Decision: every FES application is structured as three explicit layers.

INPUT LAYER     (DataStreamer, ExternalInputManager)
   ↓ typed dataclasses
CONTROL LAYER   (ControlStrategyBase.process)
   ↓ EmsData
OUTPUT LAYER    (Stimulator, LibraryStimulator)

Why: the three-layer split is the simplest mental model that captures what every FES application does, and it cleanly separates the parts the framework owns (input + output) from the parts the user owns (control). Code review, debugging, and architectural discussions all become cheaper when everyone agrees on the layer boundaries.

Consequence: the package layout, the data flow, and the testing boundaries all line up with the three layers. When something is unclear, ask: "which layer is this in?"

See: Architecture.


5. Multiprocessing is core, not optional#

Decision: the engine always runs in a backend subprocess. The GUI (when present) runs in the main process. They communicate via multiprocessing.Queue and SharedRingBuffer, never via shared in-memory state.

Why: Python's GIL prevents true parallel execution within a single process. PyQt5's main loop, in particular, blocks regularly. Running the control loop in the same process as a GUI risks every GUI event delaying a stimulation pulse. A subprocess is the only reliable isolation in CPython.

Why not threads? Threads share the GIL and would suffer the same priority inversions. They are also harder to kill cleanly when the GUI crashes — a subprocess can be terminated, with the OS guaranteeing that all its file handles, sockets, and (critically) Teslasuit SDK connections are released.

Consequence: even headless applications run with two processes (the launcher waits on the backend with Ctrl-C). The framework provides SharedRingBuffer and QueueHandler so users never write multiprocessing code by hand.

See: IPC and processes, fes_framework/orchestrator.py.


6. Two message types, two reasons#

Decision: there are two — and only two — kinds of messages between processes.

TypePurposeDirection
ControlMessageApplication-specific stimulation parametersGUI → backend
UtilityMessageSystem-wide state flags (FES on/off, recording, calibration)bidirectional

Why split them? Because they have different lifecycles. ControlMessage is what the user's GUI sends when the operator turns a knob — application code defines what fields it has. UtilityMessage is what the framework itself uses to mute stimulation, request calibration, or signal recording state — its fields are framework-defined and stable.

Why not one bag? Because the framework needs to enforce the FES kill switch (UtilityMessage.FesIsActive) regardless of what the application writes. Mixing the two would force the framework to know about application-specific fields.

Consequence: users subclass ControlMessage to add their own fields; they never subclass UtilityMessage. The framework's _apply_ems_output guard reads FesIsActive after every process() and zeroes all output if it is false.

See: Messaging, fes_framework/data/types.py.


7. LSL is off by default#

Decision: the LSL streamer ships with enabled=False. Users opt in explicitly via lsl_enabled=True on ClosedLoopEngine or orchestrator.launch().

Why: the framework streams seven LSL outlets including raw IMU (280 channels) and biomechanical joint angles (29 channels). Streaming this on a shared lab network without explicit consent is a GDPR concern. The default has to be safe; lab integration is opt-in.

Consequence: if a user reports "I don't see my streams in LabRecorder", the first question is always "did you pass lsl_enabled=True?". Streams are not auto-discoverable by accident.

See: LSL Streaming.


8. Calibration is a gate, not a step#

Decision: the framework warns when engine.run() is called without calibration, but it does not refuse. Calibration is the user's responsibility — most cleanly expressed as a gate inside on_start().

Why: different applications have different calibration requirements. A walking-FES study needs millimetre-grade joint accuracy; a haptic-cue demo may not need calibration at all. Forcing every application through the same gate would be either too strict for some or too loose for others.

Consequence: the canonical calibration pattern is in examples/atomic/calibration_gate.py — inherit ClosedLoopEngine, override on_start(), run self.calibration.calibrate() and check_quality(), call self.stop() if the result is not acceptable.

See: Calibration, Step 4.


9. The framework knows nothing about your application#

Decision: fes_framework/ does not import from examples/. There is no special-casing, no hardcoded application name, no "if it's Walking FES, then…" branch. The framework treats every application — including the Walking FES reference implementation — identically.

Why: the framework is the product; the examples are demonstrations. If the framework needed to know about its examples, the examples would not be a fair test of how a partner can use it.

Consequence: Walking FES-specific things (the ML step detector, the walking gait utilities, the WalkingControlMessage subclass with 80 channels) all live under examples/walking_fes/. The same applies to elbow_flexion and haptic_navigation. Users porting from one example to another know that nothing is shared by accident.

See: context/MODULE_BOUNDARIES.md.


10. Errors should not crash the loop#

Decision: the engine wraps on_stop() in a try/except so a developer's cleanup error cannot prevent framework cleanup (mocap stop, EMS mute, external-input close). Per-cleanup-step exceptions are logged and skipped.

Why: at the end of a session there can be a user wearing a powered suit with electrodes attached. Whatever happens, the framework must guarantee that mocap streams stop and EMS is muted. A noisy on_stop() must not be allowed to leave hardware in an active state.

Consequence: when engine.run() exits — for any reason, including exceptions inside process() or Ctrl-C — the suit returns to a safe state. This is non-negotiable.

See: _cleanup() in fes_framework/engine.py.


What we explicitly chose not to do#

ConsideredDecided againstReason
Multiple step detector strategies (TS API, VU, model-based)Single SDK detectorRemoved Walking FES's multi-detector switching after T2.4. Custom step detection lives in user DataStreamer.process() overrides.
process(joints, contacts, params, ...) signatureSlot-based self.joints, etc.Avoids breaking signatures when adding new inputs.
Hidden SharedMemoryManager over queuesDirect multiprocessing.QueueRemoved in T1.2. Queues are explicit, debuggable, and sufficient.
Auto-creating session foldersUser-controlledDifferent projects organise data differently; the framework should not impose a layout.
GUI-required architectureGUI is optionalMany applications run headless. Forcing PyQt5 would be wasteful.
PyPI publicationPartner-only distributionOpen IP question; revisit when the framework stabilises.