Step 4 — Calibration gate
Goal: refuse to start the control loop until calibration succeeds and meets a quality bar.
You'll use: CalibrationAPI, ClosedLoopEngine lifecycle hooks
Builds on: Step 3
Anchored example: examples/atomic/calibration_gate.py
What you're adding#
So far you've trusted the SDK's joint-angle data to be accurate. It
isn't, until calibration runs. The framework exposes calibration via
engine.calibration and adds a quality assessment so you can refuse
to start the loop on a bad calibration.
You'll subclass ClosedLoopEngine, override on_start(), run
calibration there, and call self.stop() if quality is too low. The
engine's main loop will see the stop request and exit cleanly before
ever delivering stimulation.
Code#
# app/engine.py
from fes_framework.engine import ClosedLoopEngine
class CalibratedEngine(ClosedLoopEngine):
"""Refuse to start until calibration succeeds and quality is acceptable."""
def on_start(self) -> None:
# Enable biomechanical-angle collection as well, since most
# calibrated applications use joint angles.
self.data_streamer.set_biomech_collection(True)
input("[CalibratedEngine] Stand in I-pose. Press Enter to calibrate... ")
result = self.calibration.calibrate()
print(f"[CalibratedEngine] {result.message}")
if not result.success:
print("[CalibratedEngine] Calibration failed — stopping.")
self.stop()
return
quality = self.calibration.check_quality()
print(
f"[CalibratedEngine] quality={quality.overall_score:.0%} "
f"({quality.sensors_reporting}/{quality.sensors_expected} sensors, "
f"symmetry={quality.symmetry_score:.0%})"
)
if quality.issues:
for issue in quality.issues:
print(f" ! {issue}")
if not quality.is_acceptable:
print("[CalibratedEngine] Quality below threshold — stopping.")
self.stop()
return
# Optional: persist the reference frame to disk.
self.calibration.export(path="./data")
print("[CalibratedEngine] Calibration OK. Running.")# app/main.py
from fes_framework.orchestrator import launch
from app.engine import CalibratedEngine
from app.strategy import StanceQuadStrategy # from Step 3
if __name__ == "__main__":
launch(StanceQuadStrategy, engine_class=CalibratedEngine)Walkthrough#
CalibratedEngine(ClosedLoopEngine) — subclass the engine when
you need lifecycle hooks. There's no separate "calibration manager"
class to plug in; the engine is the place.
on_start() — fires after strategy.setup() and before the
first cycle. Anything you do here runs while the suit is connected
and streaming, but no stimulation has happened yet. That makes it
the right place for any pre-flight check.
self.calibration.calibrate() — the SDK call. Blocks for
~1 second while the SDK captures the I-pose reference frame. The
returned CalibrationResult reports success and includes the raw
reference frame for offline analysis.
self.calibration.check_quality() — runs three heuristic
checks (sensor reporting, left-right symmetry, plausible IMU
ranges) and returns a CalibrationQuality with an overall score
0.0–1.0. Default acceptance threshold is 0.7. See
Calibration → Quality checks.
self.stop() — flips an internal flag the main loop checks at
the top of every iteration. The next cycle won't run; the engine
falls through to _cleanup(). No stimulation has been delivered.
self.calibration.export(path="./data") — writes a timestamped
CSV with the reference frame. Useful when you want to reproduce a
session offline.
Why on_start() and not __init__()?#
__init__() runs while the engine is being constructed — possibly
before the suit is fully streaming, definitely before the strategy
has been wired up. By the time on_start() runs, all the auto-built
components are in place and the suit is producing data. It's the
correct place for any operation that depends on hardware state.
What calibration.check_quality() looks for#
| Check | Common failure | What it means |
|---|---|---|
| Sensor reporting | One sensor returns all-zero | A suit segment is loose, unplugged, or damaged |
| Left-right symmetry | Bilateral cosine similarity < 0.7 | The user wasn't actually in I-pose (arms crossed, leaning) |
| Plausible ranges | Accelerometer magnitude not ~1 G | Gross IMU miscalibration |
quality.issues is a list of human-readable descriptions, suitable
for display in a GUI or printing to console.
Verify#
Run the app. Expected output:
[CalibratedEngine] Stand in I-pose. Press Enter to calibrate...
[CalibratedEngine] Calibration completed successfully
[CalibratedEngine] quality=92% (20/20 sensors, symmetry=97%)
[CalibratedEngine] Calibration OK. Running.
[Orchestrator] Running headless (Ctrl-C to stop)If quality is too low, simulate it: stand with your feet wide apart or arms folded when you press Enter. You should see:
[CalibratedEngine] quality=58% (20/20 sensors, symmetry=42%)
! Poor left-right symmetry (score: 0.42). Subject may not have been in proper I-pose.
[CalibratedEngine] Quality below threshold — stopping.
ClosedLoopEngine stopped after 0 cyclesThe "0 cycles" confirms no stimulation was delivered.
Tuning the threshold#
CalibrationAPI.QUALITY_THRESHOLD is 0.7 by default. To tighten or
loosen:
class CalibratedEngine(ClosedLoopEngine):
def on_start(self) -> None:
self.calibration.QUALITY_THRESHOLD = 0.85 # stricter
...For early development, a lower threshold (0.5) lets you iterate without re-fitting the suit between runs. For sessions that matter, push it higher (0.85–0.9).
What you've learned#
ClosedLoopEnginesubclassing +on_start()is the canonical hook for pre-flight gating.engine.calibration.calibrate()+.check_quality()is the standard pattern; the framework gives you both the calibration itself and a quality score.self.stop()fromon_start()is safe — no stimulation happens if you stop before the first cycle.
Next#
Step 5 — Runtime parameters. You'll let the operator tune thresholds and amplitudes from a controller without restarting the application.
