Control standard¶
A control space is a mapping, in a file: your name for a model output on the left, a control the target declares on the right. The left side is yours and arbitrary. The right side belongs to the target, which also declares what the control is: a number or a held state, its range, its states. MyoGestic hard-codes none of that, so a Virtual Hand, a keyboard, a cursor and a prosthesis each keep their own vocabulary, and a build that grows a control needs no change on this side.
New to this? Concepts › Controls explains the system this page documents: what a control is, why the standard is fixed, and whether you write the target or it is already written.
Continuous controls are normalized: +1 is the direction the control denotes, rest is
0, and the range is signed when the target says the control is. Discrete controls are
separate on purpose: a held state delivered on change is a different kind of value.
There is no separate control for the other direction
A signed control already has both halves: +1 on vhi.prediction.thumb flexes the
thumb and -1 extends it. There is no …thumb.extension address; a model that
wants extension emits a negative value, or a mapping gives that target a negative
weight.
Two things that look like exceptions:
vhi.prediction.thumb.abductionis a second axis, not the other half of the first. The thumb has two; the short…thumbis declared to mean flexion, and the other one has to be named, because silently picking one of two would be a guess. Every other digit has one axis, so its short form is all there is.ThumbExtensionis one ofvhi.control.gesture's movement presets: a held state on the control hand. A preset commands a whole-hand pose in one held state, a compound shape no single continuous address expresses.
One address per control, always: the address is the control's identity and also the name of the one-channel LSL stream that carries it. A target that advertised two spellings of one control would make "these two aliases collide" impossible to decide from a manifest.
Inspect the whole control path with tools/inspect_control.py
A narrated walkthrough runs the whole path: declaration, the two kinds of control, the wire frame, and the negotiation against whatever Virtual Hand you have.
Run it with no Virtual Hand at all and it still walks the first three steps, then shows what a target does when the far side is absent. Launch a VHI and run it again for the handshake: it prints a different step 4 for a v2 build, a v1 build, and nothing at all.
Declaring a control space¶
Write a TOML file. A ready-to-copy one ships at
examples/controls/hand.toml:
[dofs]
# Left side: your model's output names. Right side: controls VHI declares.
my_thumb_spread = "vhi.prediction.thumb.abduction"
fist = [ # one output, fanned out
{ target = "vhi.prediction.thumb.flexion", weight = 0.6 }, # ...with a per-target gain
{ target = "vhi.prediction.index" },
{ target = "vhi.prediction.middle" },
{ target = "vhi.prediction.ring" },
{ target = "vhi.prediction.little" },
]
gesture = { target = "vhi.control.gesture", debounce_s = 0.1 }
The left side is yours. my_thumb_spread, fist, gesture, whatever your model
calls its outputs. Nothing prescribes these names or reads meaning out of them. A control
takes only one output, so no two entries may name the same address; a fan-out is how one
output reaches several.
The right side belongs to the target. vhi.prediction.index is a name VHI declares
in its own manifest, along with everything needed to send it: whether it takes a number
or a held state, its range, its states. Ask a running target what it exports:
Load it and resolve it:
import tomllib
from myogestic.controls import load_control_map, resolve
with open("hand.toml", "rb") as f: # "rb" — tomllib requires binary
control_map = load_control_map(tomllib.load(f))
# Resolution needs a live target: it is what declares the semantics.
controls = resolve(control_map, vhi.control_client().capabilities())
Mapping-first: the shape of each value says how a value travels. A bare string is
one target control; an array is a fan-out reaching several; a table with target/targets
is the explicit form, and the only place a per-target weight or a debounce_s stability
gate is written. Whether a control is a number or a held state stays out of this file: the
target declares it.
load_control_map takes a Mapping, not a path
The library reads no configuration files, by design, so the snippet above opens the file itself. The same call accepts JSON, a dict literal, a row from a database, or a config system you already have. TOML is what a human wants to edit, so the shipped example is TOML.
A mapping becomes a control space when a target answers
load_control_map checks structure; whether vhi.prediction.index is a number or a
held state is the target's to declare. So an application that launches its own target
resolves after startup rather than at import. The examples all build their bus
lazily for that reason.
Classification uses the same mapping¶
A classifier produces an activation (open or closed) where a regressor produces a
position, and an activation is just a control value, so it travels the same mapping. Add a
threshold_fraction, the probability cutoff, to say the input is a classifier's
confidence:
fist = { targets = [
{ target = "vhi.prediction.thumb.flexion", weight = 0.6 },
{ target = "vhi.prediction.index" },
], threshold_fraction = 0.5 }
Push the model's probability and the bus gates it to exactly 0.0 or 1.0 before
anything else sees it: before the weights, before the wire, before the recording. From
there it is an ordinary value, 0 to every listed control when inactive and 1 × weight
when active. The target receives continuous per-control values either way, with no
separate state command.
Drop the threshold_fraction and the identical mapping serves a regressor emitting 0..1
directly. Gating here rather than in a separate discrete path is what lets one mapping
serve both: a continuous address is a position, so a raw 0.73 streamed into one says
the finger is 73% curled, a different statement from 73% confident that it is closed.
Map onto a discrete address instead when the thing genuinely is a state rather than
an amount: a preset, a keypress, a mode. examples/controls/classification.toml shows
the activation form and examples/controls/classification_grpc.toml the discrete one.
load_control_map
¶
load_control_map(config: Mapping[str, Any]) -> ControlMap
Parse a [dofs] mapping of user aliases to target control addresses.
Takes a ~collections.abc.Mapping, not a path — parse your own TOML (or JSON, or a
dict literal) and hand it over, so this library reads no configuration files.
Checks structure, not meaning: that every alias is usable and every address is
address-shaped. Whether an address exists, and what it accepts, is the target's to
say — see resolve.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
Mapping[str, Any]
|
Typically |
required |
Returns:
| Type | Description |
|---|---|
ControlMap
|
The parsed mapping, still unresolved. |
Raises:
| Type | Description |
|---|---|
ValueError
|
With every fault found, not just the first. |
Examples:
resolve
¶
resolve(control_map: ControlMap, capabilities: Sequence[Capability]) -> ControlSet
Resolve a ControlMap against what a target says it can do.
Each alias becomes a myogestic.controls.Continuous or
myogestic.controls.Discrete according to what its target declared. The resulting
myogestic.controls.ControlSet is keyed by your aliases; the routing to addresses
travels alongside in myogestic.controls.ControlSet.routes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
control_map
|
ControlMap
|
From |
required |
capabilities
|
Sequence[Capability]
|
What the target exports — for VHI, its |
required |
Returns:
| Type | Description |
|---|---|
ControlSet
|
Resolved, and usable. |
Raises:
| Type | Description |
|---|---|
ValueError
|
For an address the target does not export (naming the near misses and the full list), for a broadcast whose members disagree about kind or states, or for a negative weight on a control that does not accept signed values. |
Examples:
Capability
dataclass
¶
Capability(address: str, kind: str, lo: float = -1.0, hi: float = 1.0, rest: float = 0.0, states: tuple[str, ...] = (), rest_state: str = '', activation_threshold: float = 0.0, description: str = '')
One control a target exports, with the semantics the target declares.
Built from a target's manifest — for VHI, from its GetControlManifest reply.
Nothing in MyoGestic invents these values.
There is no transport here on purpose. A streamed control's LSL stream is named for
the control's own address and is one channel wide, so the address is the transport
and a separate stream_name/channel pair could only ever repeat it.
Attributes:
| Name | Type | Description |
|---|---|---|
address |
str
|
The stable dotted address, e.g. |
kind |
str
|
|
lo, hi, rest |
Continuous only: the domain and the neutral value. |
|
states, rest_state |
Discrete only: the accepted states and the neutral one. |
|
activation_threshold |
float
|
Discrete only: the level at which a client emitting a probability should select
the non-rest state. |
description |
str
|
Human-readable. For a log or an error message; never parsed. |
Examples:
ControlMap
dataclass
¶
A parsed but unresolved declaration: aliases bound to target addresses.
Not yet a usable control space. Whether fist is a number or a held state, and
what its range is, are facts the target declares; resolve is where they arrive.
Examples:
>>> from myogestic.controls import load_control_map
>>> cmap = load_control_map({"dofs": {"my_index": "vhi.prediction.index"}})
>>> cmap.addresses()
('vhi.prediction.index',)
addresses
¶
Every distinct target address this map references, in first-seen order.
as_control_space
¶
A persistable control space — the inverse of read_control_space.
Tagged with CONTROL_SPACE_FORMAT so a reader can name the format it found rather
than guess from the shape. That tag is the whole difference between this and
dump_control_map, which writes the same bindings as TOML for a human to edit;
this writes them as a dict for a recording or a model sidecar to carry.
It was as_dict, which named the return type rather than the thing — nothing said
it paired with read_control_space, and the pair is the point.
Binding
dataclass
¶
Binding(alias: str, targets: tuple[TargetRef, ...], debounce_s: float = 0.0, label: str = '', threshold_fraction: float | None = None)
One [dofs] line: a user-owned alias bound to one or more target controls.
Attributes:
| Name | Type | Description |
|---|---|---|
alias |
str
|
The user's own name for a model output. Arbitrary. |
targets |
tuple[TargetRef, ...]
|
Where its value goes. More than one is a broadcast: the same scalar reaches every listed control, each applying its own declared range and direction. |
debounce_s |
float
|
For a binding that resolves to a discrete control: how long a state must hold before it counts as a transition. A property of this control loop, not of the target. |
threshold_fraction |
float | None
|
For a classifier input: the probability fraction at which it counts as active.
Below it the value is |
label |
str
|
Optional display label; the alias is used when empty. |
TargetRef
dataclass
¶
One target control a binding sends to, and the gain applied on the way.
Attributes:
| Name | Type | Description |
|---|---|---|
address |
str
|
The target-owned control address, e.g. |
weight |
float
|
Multiplied into the value before the target applies its own range, so one
member of a fan-out can move less than the others: |
read_control_space
¶
read_control_space(raw: Mapping[str, Any]) -> ControlMap
A recording's persisted control space, as a ControlMap.
New recordings store the alias-to-address mapping and tag it with
CONTROL_SPACE_FORMAT.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
Mapping[str, Any]
|
The |
required |
Returns:
| Type | Description |
|---|---|
ControlMap
|
Still unresolved, exactly like a freshly parsed file. |
Raises:
| Type | Description |
|---|---|
ValueError
|
For a control space written in the pre-alias format, naming the format it found. It is not normalised on the way in: that older shape declared its own kinds and ranges, which the target now owns. Re-record, or read the archive with the version that wrote it. |
Examples:
ControlSet
dataclass
¶
ControlSet(dofs: Mapping[str, Dof] = dict(), standard_version: str = STANDARD_VERSION, routes: Mapping[str, tuple[Any, ...]] = dict())
A validated control configuration.
dofs preserves declaration order, and that order is the wire
order: it is the layout of the vector encode produces and the labels a
target publishes.
Attributes:
| Name | Type | Description |
|---|---|---|
dofs |
Mapping[str, Dof]
|
Resolved DOFs by alias, in declaration order. |
standard_version |
str
|
The vocabulary-format version this configuration was written against. Recorded verbatim and not validated: what a version means is settled by the target handshake, not by the loader. |
Examples:
>>> from myogestic.controls import Continuous, ControlSet
>>> controls = ControlSet(dofs={"my_index": Continuous("my_index")})
>>> controls.channel_labels()
('my_index',)
channel_labels
¶
Continuous DOF names in wire order — the channel labels to publish.
Continuous
dataclass
¶
Continuous(name: str, lo: float = -1.0, hi: float = 1.0, rest: float = 0.0, label: str = '', threshold_fraction: float | None = None)
A signed, normalized DOF. +1 is the direction the name denotes.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The alias this control was declared under — the user's own name for a model
output, e.g. |
lo, hi |
The declared domain. Defaults to |
|
rest |
float
|
The value meaning "no command". Must lie inside the domain. |
label |
str
|
Optional display label; the name is used when empty. |
threshold_fraction |
float | None
|
Set when this control is driven by a classifier rather than a regressor: the
input is a probability in Distinct from a target's |
Examples:
Discrete
dataclass
¶
Discrete(name: str, states: tuple[str, ...], rest: str, debounce_s: float = 0.0, label: str = '', activates: str = '', threshold_fraction: float = 0.5)
A DOF holding exactly one of states.
A discrete DOF is a held state, not an event stream: it is delivered on
change, and a repeat is expressed by returning through rest.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The alias this control was declared under. Never interpreted. |
states |
tuple[str, ...]
|
At least two unique state names. These double as display labels. |
rest |
str
|
The neutral state. Must be one of |
debounce_s |
float
|
Seconds a new state must hold before it is delivered. |
activates |
str
|
The state a numeric activation selects once it reaches |
threshold_fraction |
float
|
The probability fraction at which |
label |
str
|
Optional display label; the name is used when empty. |
Examples:
>>> from myogestic.controls import Discrete
>>> Discrete("gesture", ("rest", "fist"), "rest").states
('rest', 'fist')
Dof
module-attribute
¶
Dof = Continuous | Discrete
Either kind of DOF. A tagged union, so ty can check a match exhaustively.
Delivering a frame¶
One bus sanitises each frame exactly once and fans it out to every target, owning an ordering that is easy to get subtly wrong per-application.
ControlBus
¶
ControlBus(controls: ControlSet, *, targets: Sequence[Target] = (), smoothing: VectorFilter | None = None, hz: float = 50.0, dead_zone: float | None = None, hysteresis: float | None = None, on_warn: Callable[[str], None] | None = None)
Sanitise a frame once, then deliver it to every target.
Owns the one ordering that must not be re-derived per application::
substitute rest -> clip -> dead zone -> smooth
-> substitute rest -> clip -> deliver
Rest substitution comes first because min(hi, max(lo, nan)) is lo —
a NaN would otherwise become full-scale deflection. It happens again after
smoothing because numpy.clip passes NaN straight through and a filter carries
state, so one bad sample would otherwise poison every later one. The final clip
is not cosmetic either: a smoother undershoots on a falling edge, and for a
one-way DOF whose rest sits at lo that undershoot is a sign flip into a
direction the DOF declares does not exist.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
controls
|
ControlSet
|
The validated configuration. |
required |
targets
|
Sequence[Target]
|
Targets to deliver to. Each is |
()
|
smoothing
|
VectorFilter | None
|
Optional |
None
|
hz
|
float
|
The rate |
50.0
|
dead_zone
|
float | None
|
Optional symmetric dead zone in normalized units, |
None
|
hysteresis
|
float | None
|
Optional threshold, |
None
|
on_warn
|
Callable[[str], None] | None
|
Called the first time each distinct condition occurs — a clamp, a failed
target, a failed frame. Deliberately once per condition rather than per
tick: at |
None
|
Notes
push is called from the predict thread and select / rebase from the UI
thread. That pairing is safe without a lock because the only shared mutable
state is each EdgeTrigger's single tuple, which is replaced by one atomic
assignment. Anything added here that mutates more than one field at a time
needs a lock.
push
¶
Sanitise one frame, deliver it, and return what was delivered.
Never raises. This runs on the predict thread, where an exception is logged with a full traceback on every tick — so a failure here degrades to the neutral frame instead of burying the log that would explain it.
select
¶
Command a discrete DOF from the UI, bypassing the debounce.
Use this for a manual click: it delivers immediately and rebases the
trigger, so the next push carrying the same state does not fire again.
Returns:
| Type | Description |
|---|---|
bool
|
Whether the state was delivered. |
rebase
¶
Accept a discrete DOF's state as current without delivering it.
For when something else already commanded the target and the bus should not repeat it.
stop
¶
Deliver the neutral frame, then stop every target. Idempotent.
Rest is delivered before the targets stop: a target that is torn down while holding a non-neutral value leaves the application it drives holding it too.
connect_controls
¶
connect_controls(control_map: Any, targets: Sequence[Any], *, ctx: Any = None, hz: float = 32.0, smoothing: Any = None) -> ControlBus | None
Resolve a map against what targets export, and build the bus. None if they cannot say yet.
The bind every VHI application has to do, and had to write out: a control map names addresses, and what an address means — number or held state, its range, its neutral value — belongs to the target. So a map cannot be resolved until the targets can answer, and an application that launches its own target has nobody to ask at import.
Call it from a UI handler, or anywhere that can afford to block, and call it again until it returns something. Never from a predict callback: asking a target costs an RPC, and stalling the control loop on it is worse than a frame with no bus.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
control_map
|
Any
|
The parsed |
required |
targets
|
Sequence[Any]
|
Every target the map may name, already constructed. Each is asked what it exports;
one that answers |
required |
ctx
|
Any
|
The app's |
None
|
hz
|
float
|
Passed to |
32.0
|
smoothing
|
float
|
Passed to |
32.0
|
Returns:
| Type | Description |
|---|---|
ControlBus or None
|
|
ControlLink
¶
ControlLink(control_map: Any, targets: Sequence[Any], *, ctx: Any = None, hz: float = 32.0, smoothing: Any = None)
Hold connect_controls's retry, so an application does not carry it as a global.
connect_controls answers None while a target cannot yet say what it exports,
which is the normal state for an application that launches its own target: it
necessarily binds before that target exists. That leaves every such application
holding the same three things — a nullable bus, a guard, and a re-try — and every
one of them wrote it out. This is those three things and nothing else.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
control_map
|
Any
|
Exactly |
required |
targets
|
Any
|
Exactly |
required |
ctx
|
Any
|
Exactly |
required |
hz
|
Any
|
Exactly |
required |
smoothing
|
Any
|
Exactly |
required |
Examples:
>>> from myogestic.controls import ControlLink, load_control_map
>>> class NotStartedYet:
... def capabilities(self):
... return None # the target is not up
>>> control_map = load_control_map({"dofs": {"aim": "cursor.x"}})
>>> link = ControlLink(control_map, [NotStartedYet()])
>>> link.ensure() is None # call it again on the next click
True
>>> link.bus is None
True
Notes
Call ensure from a UI handler or a training thread — anywhere that can afford to
block — and never from @pipeline.predict: asking a target what it exports
costs a blocking RPC, and that callback has a deadline. predict reads bus and
no-ops while it is None. One link is one atomic failure domain: if independently
useful targets may start or fail separately, give each its own map and link.
ensure
¶
ensure() -> ControlBus | None
Bind if it is not bound yet, and return the bus. Idempotent and cheap once bound.
Returns:
| Type | Description |
|---|---|
ControlBus or None
|
|
stop
¶
Rest and tear down the bus, and forget it. Idempotent.
The link is reusable afterwards: the next ensure binds the same targets again.
ControlLinkConnector
¶
ControlLinkConnector(link: ControlLink, *, retry_s: float = 2.0)
Resolve a deferred ControlLink without blocking UI or prediction frames.
A target launched from an application's process panel cannot answer its capability
request when the application first opens. ControlLink deliberately keeps the
blocking retry explicit; this coordinator supplies a rate-limited, single-flight
background retry for UI loops.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
link
|
ControlLink
|
The |
required |
retry_s
|
float
|
Minimum time between background attempts. |
2.0
|
Notes
Call poll from a UI loop. Prediction code only reads link.bus or connected;
it never calls ControlLink.ensure, which may block on a remote procedure call.
last_error
property
¶
last_error: Exception | None
The most recent unexpected connection error, or None.
poll
¶
Start one non-blocking connection attempt when it is due.
Returns:
| Type | Description |
|---|---|
bool
|
|
ensure_now
¶
ensure_now() -> ControlBus | None
Make one synchronous attempt unless a background worker owns it.
Targets¶
Anything that moves is a target: three methods and a list of Capability. The protocol is
structural, so there is no base class to inherit and nothing to register — an object with
these methods is a target. myogestic.remote.RemoteTarget and
myogestic.keyboard.KeyboardTarget are the two this project ships;
Drive your own device writes a third.
Target
¶
Bases: Protocol
Drive some control DOFs. One protocol for every application.
A target is user-owned, exactly like myogestic.outputs.Outlet: construct
it, hand it to a ControlBus, and register teardown with
app.cleanup_hooks. The framework does not track it.
Notes
bind runs on the main thread at construction and may raise — that is the
place to reject a configuration this target cannot drive, while there is still
a human reading the traceback. send runs on the predict thread and must not
raise; the bus already guarantees every value it delivers is finite and inside
its declared range.
claims
instance-attribute
¶
Which aliases this target drives, for the bus's coverage check.
Absent means "assume it takes everything" — right for a recorder or a test double that does not know. But if every target reports and a control appears in none of them, nothing drives it, which looks exactly like a control that works and holds still. Report it if you can.
bind
¶
bind(controls: ControlSet) -> None
Accept (or refuse) a configuration, before anything is running.
send
¶
Actuate one tick.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
values
|
Mapping[str, float | str]
|
Every declared DOF, sanitised: continuous names map to finite floats inside their declared range, discrete names to a valid state. |
required |
changed
|
Mapping[str, str]
|
Only the discrete DOFs whose state settled this tick — the edges. A
continuous DOF is always in |
required |
capabilities
¶
What this target exports, as myogestic.controls.Capability values.
What myogestic.controls.connect_controls asks so it can resolve a map before
anything is bound. Return None — not an empty sequence — while the target
cannot answer, e.g. a remote target that has not started: empty reads as "drives
nothing" and would resolve to a bus that silently drives nothing.
Absent is fine for a target whose vocabulary is fixed and known to the caller.
A target for a separate program¶
RemoteTarget
¶
Drive a target in another process from control values.
Requires a remote target that speaks the v2 control contract: it asks what that
target exports and refuses anything it cannot place. A build older than the
vocabulary this client needs is refused by version at bind, not driven on a guess — see
myogestic.remote._control.RemoteClient.capabilities.
One target drives a whole control map. It owns one LSL outlet per address it drives, each named for that address and one channel wide, all built after negotiation has resolved which addresses those are.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
Any
|
A |
None
|
interface
|
Any
|
The |
None
|
Notes
bind refuses a configuration it cannot drive rather than driving part of it:
a silently-dropped control is indistinguishable from one holding still.
Binding is deferred rather than decided when the far side is silent, because an
application that launches its own remote target from its UI necessarily binds before
that target exists. Call negotiate once it is up. A remote target that answers
and does not speak the current vocabulary raises.
On shutdown every stream's declared rest is pushed and flushed: the send loop is paced, so a pushed-only value would sit unsent while the process exits, leaving the hand at its last commanded pose.
Examples:
>>> from myogestic.controls import ControlBus, load_control_map, resolve
>>> from myogestic.remote import RemoteTarget
>>> from myogestic.vhi import virtual_hand
>>>
>>> vhi = virtual_hand()
>>> client = vhi.control_client()
>>> control_map = load_control_map({"dofs": {"my_index": "vhi.prediction.index"}})
>>> controls = resolve(control_map, client.capabilities()) # needs VHI running
>>> bus = ControlBus(controls, targets=[RemoteTarget(client=client, interface=vhi)])
>>> _ = bus.push({"my_index": 0.8}) # sanitised on the way to the wire
claims
property
¶
Which aliases this target actually drives.
ControlBus reads this to check that every control was claimed by someone — a
map may also name controls another target drives, a keyboard's for instance.
negotiated
property
¶
negotiated: bool
Whether the contract has been settled with the remote target.
bind
¶
bind(controls: ControlSet) -> None
Negotiate the control space with the remote target, or defer until it is reachable.
Raises:
| Type | Description |
|---|---|
ValueError
|
When no client was given, when the remote target is too old for the vocabulary
this client speaks, or when it answers and the configuration cannot be
driven — an address it does not export, or two aliases aimed at one
control. A remote target too old to answer |
Exception
|
Whatever an outlet raises: a settled negotiation puts each declared rest value on the wire, so a stream that cannot be published fails here rather than per tick. Deliberate — this is a main-thread setup call where a traceback is visible, and a target that cannot write at bind will not write later either. |
negotiate
¶
Retry the handshake now — for an application that launches its own remote target.
Cheap and idempotent when already settled, so it is fine to call from a button handler. Never call it from the predict thread — it blocks on an RPC.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
force
|
bool
|
Re-run the handshake even if one already succeeded. Useful after the remote target restarts or its manifest changes — this target caches what it resolved and does not notice on its own. There is no automatic detection of that today. |
False
|
Returns:
| Type | Description |
|---|---|
bool
|
Whether the contract is settled. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the remote target answers and the configuration cannot be driven. Call this from a setup path or a button handler where a traceback is visible, never from the predict thread. |
Exception
|
Whatever an outlet raises when the rest values that settle a negotiation are
pushed onto a dead one — the same reason |
send
¶
Encode one sample per driven control for the negotiated contract.
Only the names bind accepted are read, and each falls back to its own
declared rest, so neither a stray key nor a missing one can move a joint or
raise on the predict thread.
changed carries discrete edges, which go over gRPC rather than onto a stream.
capabilities
¶
capabilities() -> tuple[Capability, ...] | None
What the remote target exports, or None while it cannot be reached.
The same question KeyboardTarget.capabilities answers off a local list, so a
caller can ask a mixed set of targets what they drive without knowing which of
them needs a live connection. None is the honest answer for a remote target that has
not started: not an empty manifest, which would read as "drives nothing".
stop
¶
Return the hand to its declared rest pose, and take every stream down.
Each outlet is this target's own — it built them all — so each is stopped here.
Nothing else can: see _retire, which every one of them goes through.
Per outlet, not per target. Teardown is the path most likely to meet an outlet
that is already dead, and a single push or flush raising used to abandon every
outlet after it — still published, still discoverable, and still in _outlets, so
a retry double-stopped the ones it had already released. Each is rested and
released on its own now, the state is cleared up front so a second stop is a
genuine no-op, and the first failure is re-raised once they are all down.
Negotiating with the target¶
Hand RemoteTarget a control client and it asks what the target drives at bind time,
then encodes according to the answer.
One target drives the whole map. It owns one LSL outlet per address it drives, each named for that address and one channel wide, all built after negotiation has resolved which addresses those are:
vhi = virtual_hand()
client = vhi.control_client()
# No stream is named here and none is counted. `interface=` is why: which controls exist
# is the manifest's answer, and each one's stream is named for its own address, so both
# facts arrive together once `bind` has something to ask.
target = RemoteTarget(client=client, interface=vhi)
bus = connect_controls(control_map, [target]) # None while the far side is unreachable
An application that launches its own target binds before that target exists, so it
holds a ControlLink instead and calls ensure() from
each handler that needs the hand. A UI loop that should reconnect automatically wraps the
link in ControlLinkConnector and calls
poll(); the blocking capability request then runs in one rate-limited background worker.
The client is required: every address, range and state comes from that answer. A
target that answers but reports an older vocabulary_version is refused by name, since it
would be listening for a stream layout this client no longer publishes and would report
nothing while the rig stayed still.
Four things are refused rather than half-driven: a target too old for the vocabulary this client speaks, an address the target does not export, one it exports as something other than a number, and two aliases aimed at one control. A partly understood negotiation leaves some controls believed driven and others quietly dropped, and a dropped control is indistinguishable from one that is working and holding still.
One case is deferred instead: a target that has not answered at all. Nothing is
decided until negotiate settles it. A Virtual
Hand older than 2.0 has no manifest to answer with, and MyoGestic 2.x carries no fallback
table to drive one from, but it answers capabilities() with None exactly as a target
that is simply not up yet does, so it is retried the same way.
control_client
¶
Construct a client for this target's control service.
Hand it to myogestic.remote.RemoteTarget and it asks the remote target which
named DOFs it drives, refusing a configuration it cannot place. Required:
without one there is nothing to negotiate the control space against.
Imported lazily — a plain install has no [grpc] extra, and
stream_outlet / launcher must keep working without it.
Examples:
>>> from myogestic.controls import ControlBus, Continuous, ControlSet
>>> from myogestic.remote import RemoteTarget
>>> from myogestic.vhi import virtual_hand
>>>
>>> vhi = virtual_hand()
>>> controls = ControlSet(dofs={"my_index": Continuous("my_index")})
>>> target = RemoteTarget(client=vhi.control_client(), interface=vhi)
>>> bus = ControlBus(controls, targets=[target])
>>> target.negotiated # True once the remote end has answered, False until then
False
Three layers of smoothing¶
Three separate mechanisms sit at three different places, and collapsing any two of them is a bug:
| Layer | Where | Applies to | Authoritative? |
|---|---|---|---|
| 1. Continuous smoothing | ControlBus(smoothing=...), MyoGestic |
continuous DOFs | Yes - it decides the value that is commanded |
| 2. Debounce | Discrete.debounce_s, MyoGestic |
discrete DOFs | Yes - it decides when a state transition happens |
| 2b. Dead zone + hysteresis | ControlBus(dead_zone=...), ControlBus(hysteresis=...), MyoGestic |
continuous DOFs | Yes - they decide the value that is commanded |
| 3. Presentation blending | the target (control_client().set_presentation) |
how a commanded value looks | No - appearance only |
Layer 1 runs before any target sees a frame, and that is what makes it authoritative: smoothing after delivery would mean different targets acted on different values.
Layer 2 is the one people reach for layer 1 to solve. Low-pass filtering a discrete
control as though it were an axis averages "rest" and "fist" and interpolates through
states nobody selected. What a noisy classifier needs is a stability gate: hold the new
state for debounce_s before it counts, with optional hysteresis so a value hovering near
a boundary settles on one side. debounce_s is declared on the DOF for that reason,
rather than configured on the filter.
Layer 3 affects presentation only. With blending on and no debounce every accepted transition is still applied; blending smooths how the change is drawn, not whether it happened.
Recording is not control¶
A discrete DOF is a held state: ask for a grip, hold a grip. Collecting regression training data wants the opposite, a control hand that keeps moving, so the recorded kinematics sweep a continuous range for EMG windows to align against.
Two different jobs, so two vocabularies. The sweep lives in the recording aid, never
in the control standard, because bending a held state to accommodate data collection
would make hand.grip mean "grip, unless someone is recording".
While a recording trajectory runs it owns the control hand, and discrete DOFs are refused with a reason rather than silently interrupting the trajectory a recording is aligned against. Continuous DOFs keep flowing.
recording_client
¶
Construct a client for this target's recording session gate.
Not a control plane, and nothing it does is a control DOF. It carries the two things a recording session needs: the gate that stops the target's own local input competing as a movement source, and trajectories that cycle its control rig so the recorded kinematics sweep a continuous range.
Imported lazily, like the other gRPC client, so a plain install without the
[grpc] extra can still use stream_outlet / launcher.
Examples:
Encoding helpers¶
Wire-level helpers, for a target that needs a vector rather than a mapping.
encode
¶
encode(controls: ControlSet, values: Mapping[str, Any]) -> ndarray
Continuous DOF values as one float32 vector in declaration order.
Discrete DOFs are absent by construction — they travel as edges, not as a
per-tick frame. Call substitute_rest (and usually clip) first.
Examples:
decode
¶
Inverse of encode — a wire frame back to named continuous values.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
clip
¶
clip(controls: ControlSet, values: Mapping[str, Any]) -> tuple[dict[str, float | str], tuple[str, ...]]
Clamp every value into its DOF's declared range.
Clamping is to the declared domain, never to a global rail: a DOF declared
[0.0, 1.0] must not be able to emit -1. Unknown discrete states snap to
rest. Returns the frame and the names that were actually clamped, so a caller
can report them once instead of every tick.
Never raises. Call substitute_rest first — this assumes finite numbers.
Examples:
substitute_rest
¶
Fill a full frame, replacing anything unusable with the DOF's rest value.
A missing key, a non-finite number and an unknown discrete state all become rest. Keys that are not declared DOFs are dropped.
Never raises: this runs on the predict thread, where an exception is logged with a full traceback on every tick.
Examples: