Skip to content

Widgets

Widget classes you construct once and render with .ui(...) from inside @app.ui. See the Widgets concept page for the contract and the widget gallery for a visual index of all of them on one page.

Signal viewers

SignalViewer

SignalViewer(stream_name: str, *, size: tuple[float, float] = (-1, -1), n_pixels: int | None = None, channel_height: float = 0.0, show_diagnostics: bool = False, show_connect: bool = True, selectable: bool = False, scale_mode: str = 'auto', y_range: tuple[float, float] = (-1.0, 1.0), show_markers: bool = False, window_s: float = 5.0, initial_channels: Iterable[int] | None = None, widget_id: str | None = None, title: str | None = None, show_controls: bool = True, show_title: bool = True, channel_scope: Iterable[int] | None = None)

Real-time multi-channel signal viewer.

Construct once with the stable config, then call ui with the live ctx each frame. Includes decimation, pause, auto/manual Y scale, visual-only display filters, channel toggles, stats, stream retargeting, and label markers.

Parameters:

Name Type Description Default
stream_name str

The stable widget ID / stream to view. When selectable=True, the user may switch the active stream from the UI — each stream's channel selection is tracked separately and restored when the user switches back.

required
widget_id str | None

Explicit state / ImGui id scope. Defaults to stream_name. Give each viewer its OWN id to show one stream through several panels (e.g. one per electrode grid) — otherwise they share a single state and render identically. Pair with channel_scope to give each its own channels, and prefer a stable, unique string (grid labels can repeat).

None
channel_scope Iterable[int] | None

The columns this viewer may ever show — a hard restriction, unlike initial_channels (which only seeds the opening selection). All / None / Invert, the N/total count, the [Edit…] grid and shift-click ranges are all bounded by it, so a per-electrode-grid panel stays its own array however the user clicks. None (default) is unrestricted; an explicit scope that matches no valid column renders "no channels in scope" rather than quietly widening back to the whole stream. Positional, so with selectable=True it is re-applied (clamped) to whichever stream is shown. Note it also drives the default selection: a 64-channel scope opens on its first 16 unless you pass initial_channels too.

None
title str | None

Panel header text. Defaults to "SIGNAL · <stream>" — set it per panel when several viewers share a stream, or every tile reads alike.

None
show_connect bool

Offer a Connect button in the empty state while the stream is detached. Turn it off in an app where another widget owns connecting — a StreamPanel or a DevicePicker — or the app ends up with two controls named Connect that do different things: this one attaches whatever source the stream already holds, a picker's builds a new one from its dropdown. They agree only until somebody changes the dropdown.

Left on by default: an app that is just App + Stream + a viewer has no other way to attach, and nothing attaches on its own.

True
show_controls bool

Whether the panel's chrome — control menu, channel bar and footer — starts expanded (default True). The header's ⌃⌃ button toggles it at runtime and becomes ⌄⌄ to unfold it; the title and the toggle stay put either way, so a collapsed panel is still identifiable and there is always a way back. Pass False for small tiled panels that should be nearly all plot. Dropping the title itself is show_title's job.

True
show_title bool

Whether to draw the header row at all (default True). Pass False inside a tab or a titled container that already names the panel — a panel_header under a tab label is the title twice, and the row it costs is pure padding. Orthogonal to show_controls, which folds the chrome below the header: show_title=False, show_controls=True is an untitled viewer with its full control menu, channel bar and footer. Note the ⌃⌃ collapse toggle lives in that header and goes with it, so with no title the chrome is fixed at whatever show_controls was constructed with — intended for a tab, where the chrome is a layout decision rather than something the user folds away.

True
n_pixels int | None

Optional hard cap on the points drawn per channel. None (default) means no cap — draw density tracks the plot width via the runtime "Detail" slider, which is the normal control. Set it only to force a ceiling (e.g. a slow machine with very high channel counts).

None
scale_mode str

"auto" for ImPlot fitting, "manual" for the user-set y_range.

'auto'
window_s float

The initial display window in seconds — the user can still drag the slider afterwards. Defaults to 5 s. The stream's buffer_ms must be at least this large.

5.0
initial_channels Iterable[int] | None

Which channels open enabled, e.g. range(16) for "the first 16" — do not pass a bare int (ambiguous with a single channel index). It seeds only the very first selection this viewer resolves; a different stream later shown through a selectable viewer falls back to the None policy. Once a selection exists (here or restored from a prior visit), the user's own toggle edits are never overwritten. None (default) falls back to resolve_initial's policy: every channel when n_channels <= 32, else the first 16.

None

Examples:

>>> from myogestic.widgets import SignalViewer
>>> viewer = SignalViewer("emg", selectable=True)
>>> viewer.ui(ctx)

ui

ui(ctx: Context) -> None

Render the viewer. Call once per frame inside @app.ui.

signal_viewer

RawSignalViewer

RawSignalViewer(stream_name: str, *, size: tuple[float, float] = (-1, 300), channel_height: float = 0.0, show_connect: bool = True)

Raw signal viewer — every visible sample, no decimation, bounded-copy render path.

Construct once with the stream name (+ optional size / channel height), then call ui with the live ctx each frame.

Parameters:

Name Type Description Default
stream_name str

Stream to draw, as registered with app.streams.

required
size tuple[float, float]

Plot size in pixels, (width, height). A negative width fills the available space, which is what the default (-1, 300) does.

(-1, 300)
show_connect bool

Offer a Connect button while the stream is detached. Turn it off in an app where another widget owns connecting — a StreamPanel or a DevicePicker — or the app shows two controls named Connect that do different things: this one attaches whatever source the stream already holds, a picker's builds a new one from its dropdown.

True
channel_height float

Vertical spacing between channel traces, in signal units. 0 (the default) derives it from the data's own range each frame, so the channels stay separated whatever the amplitude. Set it explicitly when several viewers must be read against each other — with per-viewer autoscaling a quiet channel and a loud one render identically.

0.0

Examples:

>>> from myogestic.widgets import RawSignalViewer
>>> viewer = RawSignalViewer("emg")
>>> viewer.ui(ctx)

ui

ui(ctx: Context) -> None

Render the raw viewer. Call once per frame inside @app.ui.

Device selection

DevicePicker

DevicePicker(stream: str, *, devices: Sequence[DeviceSpec] = DEFAULT_DEVICES, show_header: bool = True, selectable: bool = False, exclude: Iterable[str] = (), widget_id: str | None = None)

Pick a device, configure it, and connect the stream to it.

Replaces StreamPanel for the one stream it names: the panel header's dot carries the connection state, and the Connect button is what binds the stream to hardware. Nothing connects on its own, and changing the controls does nothing until Connect is pressed — so the plot never swaps out from under a recording.

Parameters:

Name Type Description Default
stream str

Name of the stream to attach to, as registered with app.streams.

required
devices Sequence[DeviceSpec]

What the dropdown offers. Defaults to DEFAULT_DEVICES (the OTB family plus LSL); pass a narrower list for an app that supports one amplifier, or add your own DeviceSpec entries.

DEFAULT_DEVICES
widget_id str | None

ImGui id scope and state key. Defaults to the stream name. Give each instance its own when an app renders more than one: ImGui derives a control's identity from its label plus the enclosing scope, and a Grid cell is a single child window — so two of these in one cell share every slider, popup and plot until they are told apart.

None
selectable bool

Add a Stream row naming which stream this panel configures, chosen from ctx.streams. For an app where streams come and go at runtime — one picker follows whichever you are setting up, instead of one panel per stream. Off by default: with a single declared stream the row is a dropdown of one.

False
exclude Iterable[str]

Stream names this panel must not offer. For a stream whose source belongs to another widget — a TrackingTask's target, a replay — where a Connect would replace that source and leave its owner writing to nothing, with the recording holding a device's data under the wrong name. A frozen set, so streams added at runtime are offered unless they are named here.

()
show_header bool

Render the standard panel_header. Turning it off also removes the status dot, so the panel loses its state cue — leave it on unless the surrounding layout already says which device this is.

True

Examples:

>>> from myogestic.widgets import DevicePicker, OTB_DEVICES
>>> picker = DevicePicker("emg", devices=OTB_DEVICES)
>>> picker.ui(ctx)

ui

ui(ctx: Context) -> None

Render the picker. Call once per frame.

Everything below is drawn inside an ImGui id scope named by widget_id. ImGui derives a control's identity from its label plus the enclosing scope, and a Grid cell is one child window — so without this, two of these panels in a single cell would share every control. Pushed around the whole body rather than prefixed onto each label, because a prefix has to be remembered at every site and this cannot be forgotten.

Describing a device

DEFAULT_DEVICES covers the shipped amplifiers. Build a DeviceSpec only for hardware the picker does not already list.

DeviceSpec dataclass

DeviceSpec(label: str, factory: Callable[..., Any], options: Sequence[DeviceOption] = tuple(), scan: bool = False, live: Sequence[DeviceParam] = tuple(), hint: str = '', steps: Sequence[str] = tuple())

One selectable entry in a DevicePicker dropdown.

Parameters:

Name Type Description Default
label str

Shown in the dropdown, e.g. "Quattrocento".

required
factory Callable[..., Any]

Builds the source when Connect is pressed — a source class, or a functools.partial of one. Called with the chosen options as keyword arguments, and only on Connect: selecting a device costs nothing.

required
options Sequence[DeviceOption]

The DeviceOption rows to draw, in order. Their chosen values are passed to factory as keyword arguments. Each row starts on whichever choice matches the factory's own default, so leaving them all alone reproduces factory() exactly.

tuple()
scan bool

True for a source whose target is discovered at runtime rather than configured — LSL, serial. Draws a target list fed by discover() instead of the option rows, and hands the choice to Stream.reconnect. This is declared, not detected: a source can implement discover() and still be configured statically, and some do.

False
live Sequence[DeviceParam]

DeviceParam sliders, shown only once this device is the connected one. They tune the running source in place — use them for anything worth changing without dropping the stream.

tuple()
hint str

One line on what this device is, or how it connects. Shown behind an button beside the dropdown rather than on the panel: it is read once, when the hardware is first wired up. No hint and no steps, no button.

''
steps Sequence[str]

The setup procedure, one instruction per entry, rendered as a numbered list under the hint. Setup is a sequence of physical acts — hold this, join that, then press Connect — and a paragraph makes the reader re-derive the order every time they come back to it.

tuple()

Examples:

>>> from functools import partial
>>> from myogestic.sources.otb import MuoviSource
>>> DeviceSpec(
...     "Muovi+ — 64 ch",
...     partial(MuoviSource, plus=True),
...     (DeviceOption("emg", "Signal", {"EMG": True, "EEG": False}),),
...     hint="Join the probe's Wi-Fi network, then Connect.",
... ).label
'Muovi+ — 64 ch'

DeviceOption dataclass

DeviceOption(kwarg: str, label: str, choices: Mapping[str, Any])

One labelled row of choices in a DeviceSpec's configuration.

Parameters:

Name Type Description Default
kwarg str

Constructor keyword the chosen value is passed as.

required
label str

Row label, spelled out in the unit the operator thinks in — "Channels", "Sample rate", not "nch_mode". Only the device entry knows what its own constructor keywords mean.

required
choices Mapping[str, Any]

{shown: value}, in the order they should appear. Shown text is drawn as a segmented control when the row is wide enough for every option at once, so keep it short — the label carries the unit.

required

DeviceParam dataclass

DeviceParam(attr: str, label: str, lo: float, hi: float, fmt: str = '%.2f')

One slider that tunes a connected source while it streams.

Where an DeviceOption is a constructor argument — fixed when Connect builds the source — a DeviceParam writes straight to an attribute of the source that is already running. The next chunk follows; nothing reconnects, the plot does not reset, and a recording in progress keeps its geometry.

The source must therefore expose that attribute publicly and read it fresh each chunk. Assigning a float is atomic under the GIL, so no lock is needed between the UI thread and the acquire thread.

Parameters:

Name Type Description Default
attr str

Public attribute on the source to read and write.

required
label str

Row label, spelled out in the unit the operator thinks in.

required
lo float

Slider range.

required
hi float

Slider range.

required
fmt str

printf format for the value shown on the slider.

'%.2f'

Examples:

>>> DeviceParam("noise", "Noise", 0.0, 1.0).attr
'noise'

DEFAULT_DEVICES module-attribute

DEFAULT_DEVICES: tuple[DeviceSpec, ...] = (*OTB_DEVICES, LSL_DEVICE, SYNTHETIC_DEVICE)

OTB_DEVICES module-attribute

OTB_DEVICES: tuple[DeviceSpec, ...] = (DeviceSpec('Muovi — 32 ch', MuoviSource, (_MUOVI_SIGNAL, _MUOVI_AUX), hint=_MUOVI_HINT, steps=_MUOVI_STEPS), DeviceSpec('Muovi+ — 64 ch', partial(MuoviSource, plus=True), (_MUOVI_SIGNAL, _MUOVI_AUX), hint=_MUOVI_HINT, steps=_MUOVI_STEPS), DeviceSpec('Sessantaquattro / +', SessantaquattroSource, (DeviceOption('nch_mode', 'Channels', {'8': 0, '16': 1, '32': 2, '64': 3}), DeviceOption('fs_mode', 'Sample rate', {'500': 0, '1000': 1, '2000': 2, '4000': 3}), DeviceOption('mode', 'Detection', _DETECTION), _SESSANTAQUATTRO_AUX), hint=_SESSANTAQUATTRO_HINT, steps=_SESSANTAQUATTRO_STEPS), DeviceSpec('Quattrocento', QuattrocentoSource, (DeviceOption('nch_mode', 'Channels', {'96': 0, '192': 1, '288': 2, '384': 3}), DeviceOption('fs_mode', 'Sample rate', {'512': 0, '2048': 1, '5120': 2, '10240': 3}), DeviceOption('detection', 'Detection', _DETECTION), _QUATTROCENTO_AUX), hint=_QUATTROCENTO_HINT, steps=_QUATTROCENTO_STEPS))

LSL_DEVICE module-attribute

LSL_DEVICE = DeviceSpec('LSL stream', lambda: LSLSource(''), scan=True, hint='Any Lab Streaming Layer outlet advertised on this network.', steps=('Press Scan.', 'Pick the outlet from the list.', 'Press Connect.'))

SYNTHETIC_DEVICE module-attribute

SYNTHETIC_DEVICE = DeviceSpec('Synthetic (no hardware)', SyntheticSource, (DeviceOption('n_channels', 'Channels', {'8': 8, '16': 16, '32': 32, '64': 64}), DeviceOption('fs', 'Sample rate', {'512': 512.0, '1000': 1000.0, '2048': 2048.0})), live=(DeviceParam('activation', 'Activation', 0.0, 1.0), DeviceParam('direction', 'Direction', -1.0, 1.0), DeviceParam('noise', 'Noise', 0.0, 1.0), DeviceParam('hum', 'Hum', 0.0, 1.0), DeviceParam('hum_hz', 'Hum (Hz)', 50.0, 60.0, '%.0f Hz')), hint='In-process sine waves and mains hum. A test signal, not data.', steps=('Pick a channel count and sample rate.', 'Press Connect — nothing has to be plugged in.', 'Drag Activation to contract the imaginary muscle; you are the subject.', 'Direction steers that effort between the first and second half of the channels, so a two-way gesture reads differently each way.', 'Noise, Hum and Hum (Hz) retune the signal live, without a reconnect.'))

Recording and sessions

RecordingControls

RecordingControls(class_names: list[str] | None = None, *, on_record: Callable[[], None], on_stop: Callable[[], None], on_gesture: Callable[[int], None] | None = None)

Record/Stop toggle + per-class label buttons + state pill.

Construct once with the class names and callbacks, then call ui with the live ctx each frame. Pass app.start_recording / app.stop_recording for on_record / on_stop if you're using the standard App. For plain capture with no gesture protocol, use RecordButton instead.

Parameters:

Name Type Description Default
class_names list[str] | None

One label button per name. Clicking one while recording snaps a label event at that moment; outside a recording it sets the class the next Record will start in. None renders the transport alone. The names are mirrored into ctx.class_names so App.stop_recording persists them in the session's meta.json — old recordings stay self-describing.

None
on_record Callable[[], None]

Called when Record is clicked (idle → recording).

required
on_stop Callable[[], None]

Called when Stop is clicked (recording → idle).

required
on_gesture Callable[[int], None] | None

Optional (class_index) -> None for side effects on a label-button click — cueing a subject, switching a fake-signal generator.

None

Examples:

>>> from myogestic.widgets import RecordingControls
>>> controls = RecordingControls(
...     ["Rest", "Fist"],
...     on_record=app.start_recording,
...     on_stop=app.stop_recording,
... )
>>> controls.ui(ctx)

ui

ui(ctx: Context) -> None

Render the recording controls. Call once per frame inside @app.ui.

recording_controls

RecordButton

RecordButton(*, on_record: Callable[[], None], on_stop: Callable[[], None], on_discard: Callable[[], None] | None = None, show_header: bool = True, widget_id: str | None = None)

One button that records, and asks what to call the take when it stops.

The plain-recording counterpart to RecordingControls: no per-class label buttons, no gesture protocol — press Record, press Stop, name what you just captured. For an app whose job is collect some data, rather than one building a labelled training set.

Capture ends the instant Stop is pressed — the streams are detached before the dialog opens — so the seconds spent typing a name are never recorded. The name is written to the session's meta.json and into the archive filename, so it shows up in SessionManager and is findable on disk.

Parameters:

Name Type Description Default
on_record Callable[[], None]

Called when Record is clicked (idle → recording). Pass app.start_recording.

required
on_stop Callable[[], None]

Called when the dialog is saved. Pass app.stop_recording.

required
on_discard Callable[[], None] | None

Called when the dialog is discarded. Pass app.discard_recording. Omit it and the dialog offers no Discard — appropriate for an app where deleting a take should not be one click away.

None
widget_id str | None

ImGui id scope and state key. Defaults to "recorder". Give each instance its own when an app renders more than one: ImGui derives a control's identity from its label plus the enclosing scope, and a Grid cell is a single child window — so two of these in one cell share every slider, popup and plot until they are told apart.

None
show_header bool

Render the standard panel_header.

True

Examples:

>>> from myogestic.widgets import RecordButton
>>> recorder = RecordButton(
...     on_record=app.start_recording,
...     on_stop=app.stop_recording,
...     on_discard=app.discard_recording,
... )
>>> recorder.ui(ctx)

ui

ui(ctx: Context) -> None

Render the recorder. Call once per frame inside @app.ui.

Deliberately the same shape as DevicePicker: status dot in the header, one full-width action, one muted detail line. Stacked in the same column the two panels should read as one instrument, not two widgets that grew up apart. The header icon is an archive rather than the usual record circle — beside a status dot, a second circle reads as a second state.

Drawn inside an ImGui id scope named by widget_id, so two recorders in one Grid cell do not share a button and a naming dialog.

StreamManager

StreamManager(*, on_add: Callable[[str], object], on_remove: Callable[[str], object], show_header: bool = True, widget_id: str | None = None)

The streams this app is running: add one, remove one, see their state.

For an app whose sources are not known when it is written — a second amplifier, a force transducer on its own device — rather than one that declares every stream up front with app.streams(...). Pair it with a DevicePicker per stream to choose what each one is attached to.

Adding and removing are refused while a recording is running, and the panel says so: a session sizes one Zarr array per stream when recording starts, so a stream appearing afterwards has nowhere to write, and one vanishing mid-take is never finalised.

Parameters:

Name Type Description Default
on_add Callable[[str], object]

Called with a cleaned stream name when Add is pressed. Build the Stream and hand it to App.add_stream — the app owns the geometry (window and buffer length), not this panel.

required
on_remove Callable[[str], object]

Called with the stream's name when its Remove is pressed. Pass App.remove_stream.

required
show_header bool

Render the standard panel_header.

True
widget_id str | None

ImGui id scope. Defaults to "streams"; give each instance its own if an app renders more than one.

None

Examples:

>>> from myogestic.widgets import StreamManager
>>> manager = StreamManager(
...     on_add=lambda name: app.add_stream(Stream(name, source=..., window_ms=200)),
...     on_remove=app.remove_stream,
... )
>>> manager.ui(ctx)

ui

ui(ctx: Context) -> None

Render the manager. Call once per frame.

SessionManager

SessionManager(base_path: str = 'sessions', *, title: str = 'Sessions', class_names: list[str] | None = None)

Session picker widget. ui() returns TrainingData(paths, class_names, classes).

Construct once with the base path / title / class names, then call ui each frame. The widget has two training filters: selected session files and selected class indices. Assign the returned value to pipeline.training_data to make it visible to @pipeline.train::

sessions = SessionManager("sessions", class_names=CLASSES)

@app.ui
def ui(ctx):
    pipeline.training_data = sessions.ui()

Parameters:

Name Type Description Default
base_path str

Folder scanned for .session.zip archives and session folders. Not read until you press Scan folder — opening an app does not present every session anyone ever recorded.

'sessions'
title str

Panel header text. Also part of the widget's ImGui id scope, so two managers over the same folder need different titles to keep separate selections.

'Sessions'
class_names list[str] | None

Human-readable names for the label class indices, used to render the per-class filter buttons. None falls back to whatever each session recorded in its own meta.json, then to c0, c1, …

None

Examples:

>>> from myogestic.widgets import SessionManager
>>> manager = SessionManager("sessions", class_names=["Rest", "Fist"])
>>> training_data = manager.ui()

ui

ui() -> TrainingData

Render the picker and return the current training selection.

session_manager

Force tracking

See Track a force target for the whole loop, including how the transducer reaches the stream and why calibration takes two captures.

TrackingTask

TrackingTask(stream: str = '', *, channel: int = 0, trapezoid: Trapezoid | None = None, target: TargetSource | None = None, tail_ms: float = 100.0, mvc_capture_s: float = 3.0, plot_height: float = 220.0, look_ahead_s: float = 4.0, widget_id: str | None = None)

Force-tracking task: follow a trapezoidal target on an auxiliary channel.

Pick the stream and the channel the force transducer is on, capture a resting Calibration.zero and a maximum Calibration.mvc, shape the Trapezoid, press Start. The target and the subject's normalised force are then drawn on one pair of axes — x is task time in seconds, y is percent of MVC — because a following error is only readable when both traces share a range.

The live force is the mean of the last tail_ms of the chosen channel, not one sample: a single raw sample from a load cell is noise with a force in it.

Nothing is stored per stream. The stream name and the channel index are what the widget holds, and the index is clamped only where it is used — so an amplifier that reconnects at a different channel count does not silently rewrite the operator's choice.

Parameters:

Name Type Description Default
stream str

Stream name to start on, as registered with app.streams. May be "": the panel offers every stream in ctx.streams and the choice is made at runtime.

''
channel int

Channel index within that stream carrying the force signal.

0
trapezoid Trapezoid | None

Initial target shape. Every segment stays editable in the panel; this is only where it starts. Defaults to Trapezoid's own defaults.

None
target TargetSource | None

Optional myogestic.sources.target.TargetSource to drive, so the trajectory is recorded beside the EMG instead of being reconstructed afterwards from a start time and a copy of these settings. The app owns it — register it as a Stream and pass it here; the task never builds one. Start, Stop and every shape edit are forwarded, so what is drawn and what is recorded cannot diverge.

None
tail_ms float

How much of the window the live reading averages over. Long enough to be steady, short enough that the trace still follows the subject.

100.0
mvc_capture_s float

How long the MVC action watches for a peak after it is pressed.

3.0
widget_id str | None

ImGui id scope and state key. Defaults to the stream name. Give each instance its own when an app renders more than one — two transducers in one study is the obvious case: ImGui derives a control's identity from its label plus the enclosing scope, and a Grid cell is a single child window, so two of these in one cell share every slider and both plots until they are told apart.

None
look_ahead_s float

How far past "now" the target is drawn, in seconds. The subject sees the trajectory only as far as it is about to be: a block whose ending is visible from the start is one they can plan around instead of track.

4.0
plot_height float

Plot height in pixels.

220.0

Examples:

>>> from myogestic.tracking import Trapezoid
>>> from myogestic.widgets import TrackingTask
>>> task = TrackingTask("emg", channel=64, trapezoid=Trapezoid(level_pct=20.0))
>>> task.ui(ctx)

ui

ui(ctx: Context) -> None

Render the task. Call once per frame.

Everything below is drawn inside an ImGui id scope named by widget_id. ImGui derives a control's identity from its label plus the enclosing scope, and a Grid cell is one child window — so without this, two of these panels in a single cell would share every control. Pushed around the whole body rather than prefixed onto each label, because a prefix has to be remembered at every site and this cannot be forgotten.

Task trajectories

What a subject is asked to follow. myogestic.tracking is plain data — no ImGui, nothing that talks to a device — so the same trajectory can be evaluated in a test, in an offline script and by whatever draws it, with no second implementation to drift. TrackingTask forwards every edit to the TargetSource it was handed, so what is drawn and what is recorded cannot diverge.

Trajectory is the structural protocol the two shapes satisfy by having its three members, not by inheriting anything. They differ in unit and in what they are for: Trapezoid is percent of MVC for isometric force tracking; Pursuit is signed [-1, +1] control units for proportional-control training, and it exists because a block cueing only Down / Rest / Up asks for three distinct target values, so what a fit produces between them comes from the estimator rather than from the recording — for the tree ensembles shipped here, nothing at all.

Trajectory

Bases: Protocol

What a trajectory has to offer to be streamed and recorded.

Structural, so Trapezoid, Pursuit and any shape added later satisfy it by having these three members rather than by inheriting anything. It is deliberately narrower than either concrete class: TargetSource reads exactly this much, so a shape is free to differ in everything else — segments, levels, units.

The unit of value_at is the trajectory's own, and is not part of this contract — percent of MVC for Trapezoid, signed control units for Pursuit. What is part of it is that phase_at returns a name from the recorded phase table, so a recording can be sliced by phase whatever shape produced it.

total_duration property

total_duration: float

Seconds for the whole block, after which the target is over.

value_at

value_at(t: float) -> float

Target level at task time t seconds, in the trajectory's own unit.

phase_at

phase_at(t: float) -> str

Which segment task time t falls in — a key of PHASE_CODES.

Trapezoid dataclass

Trapezoid(rest_s: float = 3.0, ramp_up_s: float = 5.0, hold_s: float = 10.0, ramp_down_s: float = 5.0, recover_s: float = 5.0, level_pct: float = 30.0, reps: int = 1)

A trapezoidal force-tracking target: rest, ramp up, hold, ramp down, recover.

Named for the shape, not the study — a triangular or sinusoidal target is a different shape and gets its own name in this module rather than a flag on this one.

Every segment is independently configurable and may be zero: hold_s=0 is a legal triangle, rest_s=0 starts the ramp immediately. A zero-length segment is simply skipped; the trajectory steps straight to the next level.

Parameters:

Name Type Description Default
rest_s float

Baseline seconds before the ramp begins.

3.0
ramp_up_s float

Seconds spent rising linearly from baseline to level_pct.

5.0
hold_s float

Seconds at level_pct.

10.0
ramp_down_s float

Seconds spent falling linearly back to baseline.

5.0
recover_s float

Baseline seconds after the ramp, before the next repetition.

5.0
level_pct float

Plateau height as a percentage of MVC.

30.0
reps int

How many times the shape repeats back to back.

1

Examples:

>>> from myogestic.tracking import Trapezoid
>>> task = Trapezoid(rest_s=1.0, ramp_up_s=2.0, hold_s=4.0, ramp_down_s=2.0,
...                  recover_s=1.0, level_pct=40.0)
>>> task.duration
10.0
>>> task.value_at(2.0), task.phase_at(2.0)
(20.0, 'ramp_up')
>>> task.value_at(5.0), task.phase_at(5.0)
(40.0, 'hold')
>>> task.value_at(10.0), task.phase_at(10.0)
(0.0, 'done')

duration property

duration: float

Seconds for one repetition.

total_duration property

total_duration: float

Seconds for the whole block — one repetition times reps.

value_at

value_at(t: float) -> float

Target level in percent of MVC at task time t seconds.

Baseline segments are 0.0, ramps are linear, the hold is level_pct. Before the block starts and once it has finished, the target is 0.0.

Parameters:

Name Type Description Default
t float

Task time in seconds since the block started.

required

phase_at

phase_at(t: float) -> str

Which segment task time t falls in.

One of "rest", "ramp_up", "hold", "ramp_down", "recover", or "done" once the whole block has elapsed.

Parameters:

Name Type Description Default
t float

Task time in seconds since the block started.

required

Pursuit dataclass

Pursuit(rest_s: float = 5.0, hop_s: float = 2.0, hops: int = 24, recover_s: float = 5.0, reps: int = 1)

A signed pursuit target: rest, then a smooth aperiodic wander over [-1, +1].

Why this shape and not a trapezoid. A cued block that only ever asks for "down", "rest" and "up" holds three distinct target values, so a regressor trained on it has never seen an intermediate one: nothing in the data says what half a contraction should produce, and what the fit does between the cued levels is whatever its own inductive bias supplies. For the tree ensembles shipped here that is an average of the targets it saw, which cannot be an intermediate level at all; least squares, at the other end, draws the straight line through them and needs nothing more. This trajectory instead spends its whole length at intermediate levels, so the mapping from effort to output is measured rather than assumed, and monotonicity in effort is something the fit is actually held to.

The shape is a chain of hops equal-length segments between waypoints, each interpolated with a smootherstep (:math:6x^5 - 15x^4 + 10x^3). That gives three things a proportional decoder needs:

  • Dense, even coverage of the level axis. Waypoints come from the golden-ratio orbit, whose defining property is that it spreads a sequence as evenly as any sequence can be spread. The interpolation eases to a standstill at each waypoint, so dwell time follows waypoint density and every level is trained on, not just the ones between the extremes. With the defaults no twentieth of the range takes less than 2% of the wander — 1.7% of the whole block, the difference being the rest and recover segments, which sit at one level and are not part of the sweep.
  • Rate largely decoupled from level. Segments are equal in time but not in height, so the same level is crossed slowly on one pass and quickly on another. Under a single sinusoid the target is always slowest at the extremes and fastest at zero, and a decoder can learn that confound instead of the level; here the correlation between level and speed falls from about -0.92 to -0.21. Not to zero, and the residue is at the extremes: ±1 are waypoints and the interpolation eases to a standstill at every waypoint, so full deflection is still only ever reached slowly (mean rate 0.18 units/s beyond |v| > 0.9, against 0.52 below |v| < 0.5).
  • Nothing to anticipate. The orbit is irrational, so the path never repeats within a repetition and the subject has to track rather than recall — yet it is pure arithmetic on the hop index, so two sessions record the identical trajectory and a test can assert an exact value. reps above 1 repeats the same path deliberately, to make repetitions comparable, and every rep after the first is therefore learnable: raise hops rather than reps to lengthen a block the subject should not be able to anticipate.

The rest segments matter as much as the wander: they are exactly 0.0 and stay there, which is where a decoder learns its baseline. A target that is always moving never says where zero is.

Values are continuous everywhere, including across repetitions, and the slope is bounded — a target that steps cannot be followed, and every jump would land in the training set as effort the subject never produced.

Parameters:

Name Type Description Default
rest_s float

Baseline seconds before the wander begins.

5.0
hop_s float

Seconds per waypoint-to-waypoint segment. The difficulty knob: halving it doubles every rate without changing the levels visited.

2.0
hops int

How many segments one repetition is made of. Fewer than about 16 and the coverage starts to clump.

24
recover_s float

Baseline seconds after the wander, before the next repetition.

5.0
reps int

How many times the trajectory repeats back to back. Identical each time, so repetitions are directly comparable.

1

Examples:

>>> from myogestic.tracking import Pursuit
>>> task = Pursuit(rest_s=2.0, hop_s=1.0, hops=4, recover_s=2.0)
>>> task.duration
8.0
>>> task.value_at(1.0), task.phase_at(1.0)
(0.0, 'rest')
>>> task.value_at(4.0), task.phase_at(4.0)
(-1.0, 'ramp_up')
>>> round(task.value_at(4.5), 6), task.phase_at(4.5)
(0.0, 'ramp_up')
>>> task.value_at(8.0), task.phase_at(8.0)
(0.0, 'done')

duration property

duration: float

Seconds for one repetition.

total_duration property

total_duration: float

Seconds for the whole block — one repetition times reps.

value_at

value_at(t: float) -> float

Target level in signed control units at task time t seconds.

In [-1, +1]. Rest and recover are exactly 0.0, as are the times before the block starts and after it has finished.

Parameters:

Name Type Description Default
t float

Task time in seconds since the block started.

required

phase_at

phase_at(t: float) -> str

Which segment task time t falls in.

One of "rest", "ramp_up" or "ramp_down" for a segment heading up or down, "hold" for one whose endpoints are level, "recover", or "done" once the whole block has elapsed. The same vocabulary Trapezoid uses, so an analysis script can select the rising windows out of either without caring which trajectory produced the recording.

Parameters:

Name Type Description Default
t float

Task time in seconds since the block started.

required

Calibration dataclass

Calibration(zero: float, mvc: float)

Maps a raw force reading onto the percent-of-MVC scale the targets live on.

Two numbers, not one: a load cell reads some non-zero value with nobody touching it, so dividing by mvc alone leaves that resting offset in the result and puts every target at the wrong force. zero is subtracted first, from both the sample and the maximum, so 30% really is 30% of the subject's voluntary range.

Parameters:

Name Type Description Default
zero float

The resting reading, in the force channel's own signal units.

required
mvc float

The maximum voluntary contraction reading, in the same units.

required

Examples:

>>> from myogestic.tracking import Calibration
>>> cal = Calibration(zero=1.0, mvc=3.0)
>>> cal.normalise(1.0), cal.normalise(2.0), cal.normalise(3.0)
(0.0, 50.0, 100.0)

normalise

normalise(x: float) -> float

Convert a raw reading to percent of MVC.

Returns 0.0 when mvc equals zero — an uncalibrated subject reads as no effort rather than blowing up mid-trial.

Parameters:

Name Type Description Default
x float

A sample from the force channel, in the channel's own signal units.

required

Proportional-control game

PongTask

PongTask(*, ball_speed: float = 0.55, paddle_size: float = 0.36, control: str = 'velocity', opponent: float | None = None, court_height: float = 260.0, widget_id: str = 'pong')

Pong driven by one signed command — a proportional-control training game.

Call ui every frame with the command, in [-1, +1]. What the command means is control: by default it sets the paddle's velocity, so +1 drives the paddle up at full speed and 0 holds it where it is; with control="position" the command is the paddle's height instead, +1 at the top. Nothing moves until Serve is pressed, so a layout pass, a freshly opened tab and a subject still finding their range all draw a still field.

The paddle follows the command even between rallies, which is how a subject finds the range of their own contraction before the ball is in play. A command that is not finite — a diverged model reads as NaN — leaves the paddle where it was rather than throwing it to an end of the court.

Pass ui a target and the reference a subject tracks while a training block records is drawn for that command: a line across the court at the level, and a hollow bracket where a paddle obeying it would sit. The line is what you follow — the error is the gap between it and your bar. Generating that trajectory and recording against it belong to the app — see myogestic.tracking.Pursuit — and the widget only draws the number it is handed.

Parameters:

Name Type Description Default
ball_speed float

Ball speed in court widths per second. The court is 1.0 wide, so 0.55 crosses it in a little under two seconds.

0.55
paddle_size float

Paddle height as a fraction of the court, which is 2.0 tall. Bigger is easier; this is the difficulty knob.

0.36
control str

What the command does to the paddle. Neither mode is the right one in general.

"velocity", the default, integrates it: +1 drives the paddle up at full speed, -1 down, 0 holds. That makes a coarse decoder into a complete controller — three distinct outputs become up / hold / down, which reach every height in the court, where by position three outputs are three places and nothing in between. What it buys is paid for in drift: an integrator accumulates a decoder's resting bias as faithfully as its intent. _DEAD_ZONE cuts that down and is not optional, but read what it says — it slows the drift and cannot stop it, because a noisy biased command still rectifies to something positive. Expect a paddle that wanders onto a wall while the subject holds still, and expect the subject to be the one correcting it.

"position" maps the command onto the paddle's travel, +1 at the top — the full command range across the full travel, so no band at either end is unreachable and none of it is two commands deep. It cannot drift and needs no dead zone, which makes it the honest mode to debug a model against and the better one whenever the command is already continuous — a force channel, a slider, a regressor fit on densely covered levels. Its ceiling is that the paddle is exactly as smooth, and reaches exactly as many places, as the command does.

'velocity'
opponent float | None

Speed factor of a paddle playing back from the far wall, or None for the plain wall to rally against. It is the same height as the subject's and its top tracking speed is opponent * ball_speed in court y per second — that one cap is the whole difficulty: ~0.6 is a fair rally, 1.0 and above is hard. Must be positive.

None
court_height float

Court height in pixels, or 0 or less to take the height the cell has left over once the Serve row is reserved — the contract SignalViewer's plot height already uses. Either way it is floored at 80 px, below which the ball is smaller than the paddle is thick. The width is always whatever the cell gives.

260.0
widget_id str

ImGui id scope. Give each instance its own when an app renders more than one: ImGui derives a control's identity from its label plus the enclosing scope, and a Grid cell is a single child window, so two of these in one cell would otherwise share every control.

'pong'

Examples:

>>> from myogestic.widgets import PongTask
>>> pong = PongTask(paddle_size=0.5)
>>> pong.ui(0.0)

ui

ui(command: float, target: float | None = None, effort: float | None = None) -> None

Render the game. Call once per frame.

Parameters:

Name Type Description Default
command float

The control signal, in [-1, +1]. Under control="velocity" it is the paddle's speed as a fraction of full; under control="position" it is the paddle's height, +1 at the top of the court. Either way values outside the range saturate and a non-finite one is ignored.

required
target float | None

The command the subject is being asked to produce, in the same [-1, +1] as command and typically myogestic.tracking.Pursuit.value_at — drawn as a ghost paddle wherever a paddle obeying it would sit, which is the same mapping control="position" uses. So it is the number the block recorded, not a court coordinate, and a subject sitting on the ghost has produced exactly what the recording says they were asked for. None, the default, draws nothing and leaves the court exactly as it was. The ghost does not play the ball and is not a second player.

None
effort float | None

How hard the subject is contracting now, on 0..1, drawn as a magnitude gauge at the left of the court with a tick at abs(target). For the training block, where there is no model yet and so no honest way to move the paddle. Deliberately unsigned: amplitude is knowable without a decoder and direction is not, so a gauge taking its sign from target would show the subject their own instruction. None draws nothing.

None

The command is signed: +1 is the top of the court, -1 the bottom, and -1 is a real value rather than the absence of +1. The widget is only the game — it reads no stream, no session and no model, so whatever produces the float (a decoder, a force channel, a slider) stays the app's business. examples/start_here/pong.py drives it from a directional_decoder; examples/panels/pong.py drives it from a slider.

control decides what the command does, and neither mode is right in general. "velocity", the default, integrates it, which turns even a three-output decoder into a complete controller — up / hold / down reach every height in the court — at the cost of accumulating the decoder's resting bias. The dead zone it carries slows that and does not stop it — a constant command inside the band integrates to nothing, but bias plus ordinary noise rectifies to something positive and walks the paddle onto a wall in tens of seconds while the subject holds still, so expect them to be correcting and press Serve to recentre. "position" maps the command onto the paddle's travel, the full range across the full travel: it cannot drift, and where the paddle sits is what the model just emitted scaled to the court, which makes it the honest mode to debug against and the better one once the command is genuinely continuous.

ui(command, target=…) draws a ghost paddle for that command — the reference a subject tracks while a training block records, typically Pursuit.value_at. target is in the same signed [-1, +1] as command, not a court coordinate: it is mapped onto the paddle's travel by the same line control="position" uses, so a subject sitting on the ghost has produced exactly the number the session recorded. The ghost does not play the ball and is not a second player. Generating the trajectory and recording against it belong to the app, not the widget.

Without opponent the far wall simply returns the ball, and the score is hits against misses. Pass opponent and a second paddle plays it back, with each side's points drawn on its own half — the same rally, now with something to win. One number sets the whole difficulty: it is that paddle's top tracking speed as a multiple of ball_speed, so opponent=0.6 covers a little over half the court while the ball crosses and opponent=1.0 covers more than all of it.

Process management

ProcessLauncher

ProcessLauncher(processes: list[Process], *, widget_id: str = '', log_height: float = 0.0)

Dropdown + Launch/Stop + scrollable log panel.

Construct once with the process list, then call ui each frame. Multiple launchers can coexist — each gets unique ImGui IDs via widget_id (auto-generated from the process names when empty). The live subprocess registry is app-global, so processes are still killed on exit (atexit + App.run cleanup) regardless of instance lifetime.

Parameters:

Name Type Description Default
processes list[Process]

The launchable processes, as (name, argv) tuples.

required
widget_id str

ImGui id scope for this launcher. Auto-generated from the process names when empty; set it explicitly if two launchers offer the same names.

''
log_height float

Height in pixels of an optional inline log. <= 0 (the default) draws no inline log at all: the state is the header's dot and the output is one click away in the popout window, which can be moved, resized and left open while the dropdown moves to another process. Pass a positive height to get a strip under the controls instead.

0.0

Examples:

>>> import sys
>>> from myogestic.widgets import ProcessLauncher
>>> launcher = ProcessLauncher([("Worker", [sys.executable, "worker.py"])])
>>> launcher.ui()

ui

ui() -> None

Render the launcher. Call once per frame inside @app.ui.

running

running(name: str) -> bool

Whether the process called name is alive because this panel started it.

For an app that has something to do once a target is up — bind a control map to a target it just launched, enable a control that needs it — rather than asking the operator to press a second button confirming the press they already made.

Says nothing about a process started outside the app: the registry only holds what this panel launched, so a target that was already running reads False. Treat it as "I started this", not as "this is reachable"; the target's own handshake is what answers reachability.

Parameters:

Name Type Description Default
name str

The process name, as given in the (name, argv) tuple.

required

process_launcher

Plotting

Scatter2D

Scatter2D(label: str, *, size: tuple[float, float] = (-1, 300), marker_size: float = 3.0, widget_id: str | None = None)

2D scatter plot with per-class coloring.

Parameters:

Name Type Description Default
label str

Plot label shown above the scatter.

required
size tuple[float, float]

Plot size in pixels, by default (-1, 300).

(-1, 300)
marker_size float

Marker radius in pixels, by default 3.0.

3.0
widget_id str | None

Explicit ImGui id scope. Defaults to label when omitted, so two instances with the same plot label don't collide on ImGui ids.

None

Examples:

>>> import numpy as np
>>> from myogestic.widgets import Scatter2D
>>> scatter = Scatter2D("Embedding")
>>> scatter.ui(np.array([[0.0, 0.0], [1.0, 1.0]]))

ui

ui(points: ndarray, labels: ndarray | None = None, class_names: list[str] | None = None) -> None

Render the 2D scatter for the given frame.

Parameters:

Name Type Description Default
points ndarray

Point coordinates of shape (n_points, 2).

required
labels ndarray | None

Per-point integer class labels for coloring. If omitted, all points share a single series.

None
class_names list[str] | None

Legend names indexed by class label. Falls back to the label value.

None

Scatter3D

Scatter3D(label: str, *, size: tuple[float, float] = (-1, 400), axis_names: tuple[str, str, str] = ('X', 'Y', 'Z'), widget_id: str | None = None)

3D scatter plot with orbit camera.

Parameters:

Name Type Description Default
label str

Plot label shown above the scatter.

required
size tuple[float, float]

Plot size in pixels, by default (-1, 400).

(-1, 400)
axis_names tuple[str, str, str]

Names for the X, Y and Z axes, by default ("X", "Y", "Z").

('X', 'Y', 'Z')
widget_id str | None

Explicit ImGui id scope. Defaults to label when omitted, so two instances with the same plot label don't collide on ImGui ids.

None

Examples:

>>> import numpy as np
>>> from myogestic.widgets import Scatter3D
>>> scatter = Scatter3D("Embedding")
>>> scatter.ui(np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]))

ui

ui(points: ndarray, labels: ndarray | None = None, class_names: list[str] | None = None) -> None

Render the 3D scatter for the given frame.

Parameters:

Name Type Description Default
points ndarray

Point coordinates of shape (n_points, 3).

required
labels ndarray | None

Per-point integer class labels for coloring. If omitted, all points share a single series.

None
class_names list[str] | None

Legend names indexed by class label. Falls back to the label value.

None

Heatmap

Heatmap(label: str, *, size: tuple[float, float] = (-1, 300), label_fmt: str = '%.1f', colormap: int | None = None, widget_id: str | None = None)

2D heatmap widget.

Parameters:

Name Type Description Default
label str

Plot label shown above the heatmap.

required
size tuple[float, float]

Plot size in pixels, by default (-1, 300).

(-1, 300)
label_fmt str

Printf-style format for the per-cell value labels, by default "%.1f".

'%.1f'
colormap int | None

An implot.Colormap_ value. None (default) uses viridis — a perceptually-uniform map suited to continuous values (ImPlot's own default, "Deep", is categorical and misleads on a heatmap). Pass e.g. implot.Colormap_.rd_bu for signed / diverging data.

None
widget_id str | None

Explicit ImGui id scope. Defaults to label when omitted, so two instances with the same plot label don't collide on ImGui ids.

None

Examples:

>>> import numpy as np
>>> from myogestic.widgets import Heatmap
>>> heatmap = Heatmap("Confusion", label_fmt="%.2f")
>>> heatmap.ui(np.array([[0.9, 0.1], [0.2, 0.8]]))

ui

ui(data: ndarray, x_tick_labels: list[str] | None = None, y_tick_labels: list[str] | None = None, vrange: tuple[float, float] | None = None) -> None

Render the heatmap for the given frame.

Parameters:

Name Type Description Default
data ndarray

2D array of values to render, row-major (row 0 is drawn at the top).

required
x_tick_labels list[str]

Per-column / per-row axis labels (e.g. class names for a confusion matrix). When omitted, columns/rows are labelled by index. Extra labels beyond the grid size are ignored.

None
y_tick_labels list[str]

Per-column / per-row axis labels (e.g. class names for a confusion matrix). When omitted, columns/rows are labelled by index. Extra labels beyond the grid size are ignored.

None
vrange tuple[float, float]

Explicit (min, max) for the colour mapping. None (default) maps this frame's own data.min()/.max(). Pass a shared range whenever several heatmaps are meant to be compared — with per-instance autoscaling a quiet grid and a loud one render identically — or a fixed one to stop colours drifting frame to frame.

None

LinePlot

LinePlot(label: str, *, size: tuple[float, float] = (-1, 200), widget_id: str | None = None)

Multi-channel line plot widget.

Parameters:

Name Type Description Default
label str

Plot label shown above the lines.

required
size tuple[float, float]

Plot size in pixels, by default (-1, 200).

(-1, 200)
widget_id str | None

Explicit ImGui id scope. Defaults to label when omitted, so two instances with the same plot label don't collide on ImGui ids.

None

Examples:

>>> import numpy as np
>>> from myogestic.widgets import LinePlot
>>> plot = LinePlot("Channels")
>>> plot.ui(np.array([[0.0, 1.0], [1.0, 0.0]]))

ui

ui(data: ndarray, channel_names: list[str] | None = None) -> None

Render the line plot for the given frame.

Parameters:

Name Type Description Default
data ndarray

Samples of shape (n_samples,) or (n_samples, n_channels).

required
channel_names list[str] | None

Per-channel legend names. Falls back to ch{i} when omitted.

None

Output processing

PostProcessor

PostProcessor(hz: float = 50.0, *, widget_id: str = 'output_filter')

Bases: FilterProcessor

Preset FilterProcessor for post-prediction output smoothing.

The three built-in filters, a "POST-PROCESSING" header, and one_euro selected by default. For a custom palette, use FilterProcessor directly.

Parameters:

Name Type Description Default
hz float

Sample rate forwarded to filters that need it (e.g. one_euro). Set it to the rate your predictions actually arrive at, or the smoothing is tuned for the wrong timebase.

50.0
widget_id str

ImGui id scope — give each instance a unique value if you render more than one.

'output_filter'

Examples:

>>> from myogestic.widgets import PostProcessor
>>> processor = PostProcessor(hz=20.0)
>>> processor.ui()

FilterControl

FilterProcessor

FilterProcessor(filters: Sequence[FilterSpec] = BUILTIN_FILTERS, *, hz: float = 50.0, default: str | None = None, title: str = 'FILTER', widget_id: str = 'filter')

Pick-one-and-tune filter widget over an extensible palette.

Construct once with the filters it offers, call it on a vector inside @pipeline.predict, and render it each frame with ui. The active filter is applied by __call__; parameter values are cached per filter across selection changes (switching away and back builds a fresh filter — no stale history).

Parameters:

Name Type Description Default
filters Sequence[FilterSpec]

Ordered palette of FilterSpec. Defaults to the three built-ins (BUILTIN_FILTERS).

BUILTIN_FILTERS
hz float

Sample rate forwarded to filters that need it (e.g. one_euro).

50.0
default str | None

key of the filter selected on start. None selects the first.

None
title str

Panel header text.

'FILTER'
widget_id str

ImGui id scope — give each instance a unique value if you render more than one.

'filter'

Examples:

>>> from myogestic.widgets import FilterProcessor
>>> processor = FilterProcessor(default="identity")
>>> processor.ui()

Methods:

Name Description
reset

Clear the active filter's smoothing history.

ui

Render the full panel. Call once per frame inside @app.ui.

Attributes:

Name Type Description
name str

key of the currently selected filter.

filter VectorFilter

The live filter instance.

name property

name: str

key of the currently selected filter.

filter property

filter: VectorFilter

The live filter instance.

Read-only handle for inspection; mutating or calling it directly bypasses the processor's lock and parameter tracking.

reset

reset() -> None

Clear the active filter's smoothing history.

ui

ui() -> None

Render the full panel. Call once per frame inside @app.ui.

FilterSpec dataclass

FilterSpec(key: str, name: str, build: Callable[..., VectorFilter], params: tuple[FilterParam, ...] = (), description: str = '', reconfigure: Callable[..., VectorFilter] | None = None, delay: Callable[[float, Mapping[str, Any]], float] | None = None)

One selectable filter in a FilterProcessor palette.

Parameters:

Name Type Description Default
key str

Stable identifier (e.g. "one_euro") — used for default= selection and ImGui id scoping. Distinct from the display name.

required
name str

Button label shown in the panel (e.g. "One Euro").

required
build Callable[..., VectorFilter]

Factory build(*, hz, **params) -> VectorFilter — constructs the filter from the current parameter values.

required
params tuple[FilterParam, ...]

Tunable sliders. Empty for a fixed filter (e.g. identity).

()
description str

One-line blurb shown under the buttons.

''
reconfigure Callable[..., VectorFilter] | None

Optional in-place update reconfigure(current, *, hz, params) -> VectorFilter. When set, live tuning mutates the existing filter (preserving its smoothing history) instead of rebuilding it. Return the same object (or a replacement). None -> rebuild via build.

None
delay Callable[[float, Mapping[str, Any]], float] | None

Optional delay(hz, params) -> float returning the filter's latency estimate in milliseconds — shown live in the panel title. None (e.g. a passthrough) shows no delay.

None

Examples:

>>> from myogestic.outputs.filters import IdentityFilter
>>> from myogestic.widgets import FilterSpec
>>> spec = FilterSpec(key="identity", name="Identity", build=lambda **_: IdentityFilter())

FilterParam dataclass

FilterParam(key: str, label: str, min: float, max: float, default: float, kind: Literal['int', 'float'] = 'float', fmt: str = '', log: bool = False)

One tunable slider for a FilterSpec.

Parameters:

Name Type Description Default
key str

Keyword name passed to the spec's build / reconfigure.

required
label str

Slider label shown in the panel.

required
min float

Slider bounds and initial value. min <= default <= max.

required
max float

Slider bounds and initial value. min <= default <= max.

required
default float

Slider bounds and initial value. min <= default <= max.

required
kind Literal['int', 'float']

"int" (uses an integer slider) or "float".

'float'
fmt str

ImGui display format; a sensible default is used per kind.

''
log bool

Logarithmic slider (float only) — handy for wide ranges like beta.

False

Examples:

>>> from myogestic.widgets import FilterParam
>>> parameter = FilterParam("sigma", "sigma", 0.1, 10.0, 1.0)

Feature selection

FeatureSelector

FeatureSelector(features: dict[str, FeatureFn], default: Iterable[str] | None = None, *, widget_id: str = 'features')

Tickable list of named feature functions.

Parameters:

Name Type Description Default
features dict[str, FeatureFn]

Ordered map of feature name → callable. Each callable takes an EMG window (n_channels, n_samples) and returns an array - typically (n_channels, n_features_out) for time-preserving features like sliding RMS, but any consistent shape works as long as every active feature returns the same shape (they're concatenated along axis 0 by __call__).

required
default Iterable[str] | None

Optional iterable of feature names to start ticked. None (default) ticks every feature; an empty list ticks none.

None

Raises:

Type Description
ValueError

if a name in default isn't in features.

Examples:

>>> from myogestic.recipes.features import rms
>>> from myogestic.widgets import FeatureSelector
>>> selector = FeatureSelector({"RMS": rms}, default=["RMS"])
>>> selector.ui()

active_names property

active_names: list[str]

Feature names currently ticked, in registration order.

n_active property

n_active: int

Number of ticked features.

is_active

is_active(name: str) -> bool

Check whether a specific feature is ticked.

set_active

set_active(name: str, active: bool) -> None

Programmatically tick / untick a feature.

Useful for restoring saved selections from a checkpoint, or for scripted training runs that bypass the UI.

ui

ui() -> None

Render the panel inside an ImGui frame.

Call from inside @app.ui. The header carries the active count (FEATURES (2)) and the feature checkboxes reflow into a content-sized table so columns stay aligned: each column is as wide as its own widest box + label, so a label never clips under the next column's checkbox. State updates take effect on the next predict-thread tick.

FeatureSelector

Training and inspection

TemplateInspector

TemplateInspector(widget_id: str, *, title: str = 'Templates', height: float = 240.0, label_colors: dict[str, tuple[float, float, float, float]] | None = None)

Accept/reject + click-to-select table of template rows.

Construct once with a stable widget_id (+ optional title / height / label colors), then call ui each frame with the current rows. ui() returns the selected row's key (or None).

Parameters:

Name Type Description Default
widget_id str

Stable identity string. Two inspectors with the same widget_id share selection state; different widget_ids are independent.

required
title str

Header text shown above the table.

'Templates'
height float

Table height in pixels.

240.0
label_colors dict[str, tuple[float, float, float, float]] | None

Optional {label_text: (r, g, b, a)} for the colored badge in the label column. Unmapped labels render in the default text color.

None

Examples:

>>> from myogestic.widgets import TemplateInspector, TemplateInspectorRow
>>> rows = [TemplateInspectorRow("session#0", "Fist")]
>>> inspector = TemplateInspector("templates")
>>> selected = inspector.ui(rows)

ui

ui(rows: list[TemplateInspectorRow]) -> str | None

Render the table for rows; return the selected row's key.

rows is mutated in place (only accepted is touched). Returns None when no row is selected or the selection was removed.

TemplateInspectorRow dataclass

TemplateInspectorRow(key: str, label: str, accepted: bool = True, info_text: str | None = None, energy: float | None = None)

One row in the inspector table.

Parameters:

Name Type Description Default
key str

Stable identity (e.g. "<session>#<trial_idx>"). The widget uses this for selection and for ImGui id disambiguation.

required
label str

Short class/category badge (e.g. "OPEN" / "CLOSED").

required
accepted bool

Mutable. True = include in training. Toggled in place by the checkbox.

True
info_text str | None

Optional secondary text shown in the table (e.g. session name, source path). May be None.

None
energy float | None

Optional scalar shown as a normalised progress bar in the energy column. Caller's choice of metric — RMS energy, peak amplitude, anything monotonic. None hides the bar.

None

Examples:

>>> from myogestic.widgets import TemplateInspectorRow
>>> row = TemplateInspectorRow("session#0", "Fist", accepted=True, energy=0.8)

TrialPreview

TrialPreview(*, widget_id: str, data_layout: Literal['channels_first', 'samples_first'] = 'channels_first', title: str | None = None, size: tuple[float, float] = (-1.0, 240.0), channel_names: list[str] | None = None, band: tuple[float, float] | None = None, band_color: tuple[float, float, float, float] | None = None, gain: float = 1.0, display_filter: Literal['none', 'rectify', 'dc_removal', 'rms_env'] = 'none', scale_mode: Literal['auto', 'manual'] = 'auto', y_range: tuple[float, float] = (-1.0, 1.0), as_window: bool = False)

Render stacked multi-channel waveform with optional band overlay.

Examples:

>>> import numpy as np
>>> from myogestic.widgets import TrialPreview
>>> preview = TrialPreview(widget_id="trial")
>>> preview.ui(np.zeros((8, 400), dtype=np.float32), fs=2000.0)

Configure the trial-preview widget.

Parameters:

Name Type Description Default
widget_id str

Stable identity string for ImPlot (combined into plot ids so two TrialPreview instances in the same frame don't collide).

required
data_layout Literal['channels_first', 'samples_first']

"channels_first" (default) treats data as (n_channels, n_samples); "samples_first" as (n_samples, n_channels).

'channels_first'
title str | None

Optional header line shown above the plot.

None
size tuple[float, float]

ImPlot size as (width, height). -1 width fills the available content region.

(-1.0, 240.0)
channel_names list[str] | None

Optional per-channel labels. When omitted, channels are shown as ch0..chN-1.

None
band tuple[float, float] | None

Optional (t_start_s, t_end_s) shaded band drawn behind the traces — useful for marking an extracted template, highlighting a labeled segment, etc.

None
band_color tuple[float, float, float, float] | None

RGBA in [0,1]. Defaults to a soft cyan.

None
gain float

Multiplier applied to each channel before plotting. Match this to your live viewer's gain knob if you want the preview to look like what was on screen.

1.0
display_filter Literal['none', 'rectify', 'dc_removal', 'rms_env']

Visual-only transform applied to a copy of data before plotting. Same vocabulary as signal_viewer's display dropdown.

'none'
scale_mode Literal['auto', 'manual']

"auto" (default) computes the per-lane height from the signal's global min/max with 20% padding; "manual" uses y_range directly.

'auto'
y_range tuple[float, float]

(y_min, y_max) used in manual scale mode.

(-1.0, 1.0)
as_window bool

When True, the widget wraps itself in a free-floating ImGui window with title title. When False (default), it draws inline at the current cursor position.

False

ui

ui(data: ndarray, fs: float) -> None

Render stacked multi-channel waveform with optional band overlay.

Parameters:

Name Type Description Default
data ndarray

Multi-channel signal. Shape (n_channels, n_samples) if data_layout == "channels_first" (default) or (n_samples, n_channels) if "samples_first".

required
fs float

Sampling rate in Hz, used for the x-axis labels in seconds.

required

Layout helpers

panel_header

panel_header(title: str, icon: str | None = None, *, reserve: float = 0.0, status: ImVec4 | None = None) -> None

Render a uniform panel-header line: muted, all-caps, optional FA icon.

Pairs with the button + slider styling used by the other widgets in this package. Use it at the top of any custom panel to match the look::

panel_header("MODEL", icons_fontawesome_6.ICON_FA_BRAIN)
train_button(pipeline)
...

When the panel is too narrow for the full title, the title is truncated with a ellipsis; when there is no room for any label, only the icon is shown. Pass reserve to leave that many pixels for controls placed after the header on the same row (e.g. a right-aligned button), so the title collapses instead of pushing those controls off the panel.

Pass status — one of SUCCESS, IDLE, DANGER, WARNING — to put a filled circle in that colour at the right end of the header row. Right-aligned so the titles of stacked panels line up on their first glyph: a dot before the title would indent the ones that have state and leave the ones that don't hanging. Colour is the only thing the dot carries, so it must not be the only place the state is available: give the header a tooltip with the detail (a PID, an exit code) for anyone who cannot read the hue.

It sits inside reserve, so a right-aligned control placed after the header — panel_header_button does this — still gets its space, with the dot to its left.

Examples:

>>> from myogestic.widgets import panel_header
>>> panel_header("MODEL")

popout_panel

popout_panel(title: str, gui_fn: Callable[[], None], *, default_open: bool = True, can_be_closed: bool = True, remember_is_visible: bool | None = None) -> None

Render gui_fn inside a dockable, tearable ImGui window.

Parameters:

Name Type Description Default
title str

Window title — also used as the ImGui id and as the dedup key for repeated calls.

required
gui_fn Callable[[], None]

Zero-arg callable invoked by ImGui every frame. Treat it like the body of a with imgui.begin(...): — call imgui/ implot from inside.

required
default_open bool

Initial visibility of the window on first launch. Subsequent launches restore from .imgui_state.

True
can_be_closed bool

Whether the user can close the window with the X button. Closed windows reappear via the "View" menu.

True
remember_is_visible bool | None

Whether visibility is persisted in the imgui ini file. Defaults to True for existing behavior.

None
Notes

When App(docking=True) is not active, this just runs gui_fn() inline so the call site stays the same.

Examples:

>>> from myogestic.widgets import panel_header, popout_panel
>>> popout_panel("Details", lambda: panel_header("Details"))

Status and logs

StreamPanel

StreamPanel(*, selectable: bool = True, show_header: bool = True)

Per-stream status panel — one row per stream with status + reconnect.

Construct once (optionally toggling selectable / show_header), then call ui with the live ctx each frame.

Parameters:

Name Type Description Default
selectable bool

When True and the stream's source supports discover(), auto-populate available targets as inline connect buttons.

True
show_header bool

Render a uniform panel_header above the rows.

True

Examples:

>>> from myogestic.widgets import StreamPanel
>>> panel = StreamPanel()
>>> panel.ui(ctx)

ui

ui(ctx: Context) -> None

Render one row per registered stream. Call once per frame.

LogPanel

LogPanel(*, height: float = -1.0, title: str = 'Log', show_header: bool = True, widget_id: str | None = None)

Render the app log as a scrollable, read-only panel.

Examples:

>>> from myogestic.widgets import LogPanel
>>> panel = LogPanel()
>>> panel.ui(ctx)

Configure the log panel.

Parameters:

Name Type Description Default
height float

Panel height in pixels. Pass a value <= 0 (default) to fill the remaining vertical space of the parent cell — matches the ImGui convention where -1 means "fill available".

-1.0
title str

Header label (only shown when show_header=True).

'Log'
show_header bool

Render the button-style panel_header above the log.

True
widget_id str | None

Optional per-instance ImGui id scope. Defaults to title.

None

ui

ui(ctx: Context) -> None

Render the app log.

Parameters:

Name Type Description Default
ctx Context

App context; reads from ctx.logs.

required

Branding

Image

Image(asset: str, *, max_size: float | None = None, padding: float = 12.0, missing_label: str | None = None, widget_id: str | None = None)

Generic fit-to-cell image widget.

Reads the available content area inside the current panel and renders the image as the largest aspect-preserving rectangle that fits both dimensions (minus padding), then centres it. In a cell whose aspect matches the image, it fills edge-to-edge minus the padding margin; in a different aspect, it fills the tighter dimension and leaves balanced padding along the other.

Notes

Uses image_and_size_from_asset + raw imgui.image rather than the higher-level image_from_asset(..., size=...) helper, which in this version of hello_imgui ignored the explicit size and rendered at the natural pixel dimensions of the image.

Examples:

>>> from myogestic.widgets import Image
>>> image = Image("app_settings/icon.png", max_size=256)
>>> image.ui()

Configure the image widget.

Parameters:

Name Type Description Default
asset str

Path to the image, relative to MyoGestic's registered assets folder (resolved via HelloImGui's asset system).

required
max_size float | None

Optional cap on the rendered width in pixels. None (default) lets the image grow to fill the cell.

None
padding float

Margin in pixels reserved on every side. Default 12 px.

12.0
missing_label str | None

Text shown (muted) when the asset can't be found. Defaults to "(image asset missing: <asset>)".

None
widget_id str | None

Optional per-instance ImGui id scope. Defaults to asset.

None

ui

ui() -> None

Render the image asset fit-to-cell, aspect-preserving, centred.

AppLogo(*, max_size: float | None = None, padding: float = 12.0, widget_id: str | None = None)

Render the MyoGestic wordmark, fit-to-cell, aspect-preserving.

Thin wrapper over Image pinned to the shipped wordmark. See that widget for the fit/centre behaviour.

Examples:

>>> from myogestic.widgets import AppLogo
>>> logo = AppLogo(max_size=240)
>>> logo.ui()

Configure the wordmark widget.

Parameters:

Name Type Description Default
max_size float | None

Optional cap on the wordmark's width in pixels. None (default) lets it grow to fill the cell — appropriate for a dedicated branding cell. Pass a value when the cell can be much larger than the wordmark should ever appear (e.g. a full-screen splash).

None
padding float

Margin in pixels reserved on every side. Default 12 px gives the wordmark breathing room against the panel border.

12.0
widget_id str | None

Optional per-instance ImGui id scope.

None

ui

ui() -> None

Render the MyoGestic wordmark, fit-to-cell, aspect-preserving.

app_logo

ML readout

PredictionLabel

PredictionLabel(pipeline: Pipeline, class_names: Sequence[str], *, class_key: str = 'class', probability_key: str = 'proba', title: str = 'Prediction', show_probability: bool = False, font_scale: float = 2.0, widget_id: str | None = None)

Render the current predicted class name as a big centred label.

The class index is looked up in pipeline.predictions[class_key] and the name is taken from class_names. Colour-coded from the shared PALETTE, so a class keeps the same colour as its recording / session-manager chips.

Examples:

>>> from myogestic.widgets import PredictionLabel
>>> label = PredictionLabel(pipeline, ["Rest", "Fist"])
>>> label.ui()

Configure the prediction label.

Parameters:

Name Type Description Default
pipeline Pipeline

The Pipeline whose predictions to read. Untrained or first-frame state (predictions == {}) renders as a muted "—".

required
class_names Sequence[str]

Class names indexed the same way as the model — class_names[i] is the name for class index i.

required
class_key str

Dict key in predictions holding the class index. Default "class" matches the convention in the bundled examples.

'class'
probability_key str

Dict key holding the per-class probability vector, consumed only when show_probability is on.

'proba'
title str

Panel header text.

'Prediction'
show_probability bool

When True, render a coloured progress bar of the predicted class's probability below the name.

False
font_scale float

Multiplier applied to the class name's text size. Defaults to 2× the panel font.

2.0
widget_id str | None

Optional per-instance ImGui id scope. Defaults to title.

None

ui

ui() -> None

Render the current predicted class name as a big centred label.

prediction_label

Virtual Hand integration

VhiMovementPanel

VhiMovementPanel(client: RecordingClient, on_movement: Callable[[str], None], *, min_interval_s: float = 1.0, title: str = 'VHI Control Hand')

Stateful widget — instantiate once at module level, call .ui() per frame.

Parameters:

Name Type Description Default
client RecordingClient

The myogestic.remote.RecordingClient used to fetch control-hand state (available movements, the current one, whether a recording trajectory is running).

required
on_movement Callable[[str], None]

Click handler for a movement button — required. Wire it to a discrete DOF, e.g. lambda s: bus.select("gesture", s) — the states come from the target's manifest, so pass one of those names through. There is deliberately no default: dispatching straight at the target would bypass the DOF's debounce, which is the only thing protecting a classifier-driven session from state chatter.

required
min_interval_s float

Minimum seconds between background state refreshes. Default 1 s.

1.0
title str

Panel header text rendered above the button grid.

'VHI Control Hand'

Examples:

>>> from myogestic.widgets import VhiMovementPanel
>>> panel = VhiMovementPanel(
...     vhi.recording_client(),
...     lambda state: bus.select("gesture", state),
... )
>>> panel.ui()

ui

ui(*, auto_refresh: bool = True) -> None

Render the panel — call once per frame inside @app.ui.

Set auto_refresh=False in latency-sensitive phases such as live prediction. The last cached state remains visible and the panel's explicit Refresh button still starts one background request, but no periodic gRPC work is scheduled from the frame loop.

VhiMovementPanel

Lower-level pieces

VhiMovementPanel wraps these for the common case. Reach for them directly when you want to share one state cache across multiple panels, or render the palette without owning a client.

vhi_movement_palette

vhi_movement_palette(movements: Sequence[str], *, on_movement: Callable[[str], None], on_refresh: Callable[[], None] | None = None, current_movement: str = '', connected: bool = False, status: str = '', title: str = 'VHI Movements') -> None

Render VHI's movement names as a grid of command buttons.

Pure ImGui: performs no RPC and owns no client. movements is the cached list from the last successful GetState; on_movement(name) fires on click (wire it to a discrete DOF, e.g. bus.select("gesture", name)). If on_refresh is given, a refresh button is drawn. Movement buttons are disabled while connected is False, but a stale list stays visible.

The grid uses as many columns as fit the panel width, so it reflows when the panel is resized; the button matching current_movement is highlighted.

Examples:

>>> from myogestic.widgets import vhi_movement_palette
>>> vhi_movement_palette(
...     ["Rest", "Fist"], connected=True, on_movement=vhi_client.set_movement
... )

VhiStateCache dataclass

VhiStateCache(movements: list[str] = list(), current_movement: str = '', current_state: str = '', mode: str = '', trajectory_running: bool = False, trajectory_movement: str = '', connected: bool = False, refreshing: bool = False, message: str = 'Launch VHI, then refresh.', last_attempt_s: float = 0.0, lock: Lock = Lock())

Last-known VHI state, refreshed off-thread. Use snapshot() to read.

Examples:

>>> from myogestic.widgets import VhiStateCache
>>> state = VhiStateCache()
>>> snapshot = state.snapshot()

snapshot

snapshot() -> VhiStateSnapshot

Return a consistent, immutable view — safe to read all frame.

VhiStateSnapshot dataclass

VhiStateSnapshot(movements: tuple[str, ...], current_movement: str, current_state: str, mode: str, connected: bool, message: str, trajectory_running: bool = False, trajectory_movement: str = '')

An immutable, lock-free view of VhiStateCache for one UI frame.

Examples:

>>> from myogestic.widgets import VhiStateCache, VhiStateSnapshot
>>> snapshot: VhiStateSnapshot = VhiStateCache(movements=["Rest"]).snapshot()
>>> snapshot.movements
('Rest',)

request_vhi_state_refresh

request_vhi_state_refresh(client: RecordingClient, cache: VhiStateCache, *, force: bool = False, min_interval_s: float = 1.0, disconnected_interval_s: float = 5.0, probe_timeout_s: float = 0.5) -> None

Start at most one throttled background GetRecordingSessionState refresh.

Safe to call every frame from @app.ui: it returns immediately unless a refresh is due and none is already in flight. The blocking state() runs on a daemon thread; the result lands in cache under its lock.

While VHI is unreachable the poll backs off to disconnected_interval_s and (historically) used a short probe_timeout_s deadline — so a down server is probed only occasionally with a fast-failing call, never a 2 s blocking RPC that is ~always in flight. (A continuously in-flight failing call keeps the gRPC channel in connect/reconnect churn, which stutters the 60 fps render loop.) Once connected it polls every min_interval_s again. An explicit force refresh ignores the interval and uses the client's full deadline (a cold connect can be slower than probe_timeout_s).

Examples:

>>> from myogestic.widgets import VhiStateCache, request_vhi_state_refresh
>>> state = VhiStateCache()
>>> request_vhi_state_refresh(vhi_client, state)