Skip to content

Maimu: four-field pulse grammar with width, count and intensity - #179

Open
gbradham wants to merge 7 commits into
mainfrom
maimu-pulse-grammar
Open

gbradham wants to merge 7 commits into
mainfrom
maimu-pulse-grammar

Conversation

@gbradham

Copy link
Copy Markdown
Member

Moves the Maimu device and node to the stimulator's new wire grammar. Companion to LaingLab/Maimu#1 — the two must land together, since this mirrors that firmware's command format byte-for-byte.

The old grammar was <period_ms>,<duration_s> — a fixed 50%-duty toggle, so the standard optogenetic protocol of 4 ms pulses at 20 Hz (8% duty) could not be expressed. The new one is <period_ms>,<pulse_width_ms>,<count>,<intensity_pct>.

What changed

MaimuDevice.pulse() takes four arguments instead of two, with bounds matching the firmware parser exactly: period_ms 1…3,600,000, pulse_width_ms 1…period_ms, count 0…65,535, intensity_pct 0…100. count = 0 means run until stopped and intensity_pct = 0 is a legitimate armed-but-dark control condition — those two are the only fields with a floor of 0.

MaimuNode gains Pulse width, Pulses and Intensity properties alongside Period.

Validation happens here, before anything is written. The firmware rejects malformed commands silently — it has no read characteristic and so no way to answer back — so this is the only place a researcher can learn that a command was wrong. A rejected pulse writes nothing at all.

This also closes a latent bug: the old _whole_number enforced only a floor of 1 and never the maxima its own schema advertised, so a caller could previously send period_ms=99999999.

Old graphs

A .glider file saved with the old grammar still loads, but it loads as a different stimulus. set_state now logs a warning naming the dropped duration_s and what replaced it, so that surfaces at load time rather than in data that doesn't look right. Tested in both directions — it fires on an old file and stays silent on a current one.

Safety

shutdown() still writes off before disconnecting, and _on_reconnected still writes off first on a recovered link. That matters more than before: a train runs autonomously in firmware, and after this change count = 0 is the only spelling of continuous light — so the guarantee that emergency stop, End Experiment, app quit and reconnect all route through off is load-bearing rather than one of two paths.

One bug worth calling out, found in final review and fixed here: width == period with a non-zero count was accepted by both sides and ran forever, because the firmware's continuous branch never read count. 500,500,1,40 — the natural way to write one 500 ms pulse — would have left an implanted LED lit for the rest of the session, and the node is fire-and-continue so nothing would have stopped it. Both sides now reject that combination. Nothing is lost: 1000,500,1,40 still delivers exactly one bounded 500 ms pulse.

Tests

102 passing, ruff clean. Coverage includes the closed-loop chain end to end (classifier frame → bus → Behavior Input node → Maimu node → device → GATT write, asserting on real bytes), the reconnect path, and a new cross-check that the node's schema bounds match the device's — previously a comment claimed a test asserted that, and none did.

Still never run against physical hardware. The plugin's suite is built on a fake BLE client, and the firmware it targets needs a board respin before it can drive an LED at all.

Mirrors the firmware's new command. Bounds are the firmware's bounds, and
width > period is rejected here so the researcher sees an error rather
than a device that silently did nothing.
Old graphs still load; a dropped duration_s is reported rather than
silently becoming a different stimulus.
pulse(500, 500, 1, 40) -- the natural way to write one 500 ms pulse at 40% --
passed validation and reached the firmware, which took its continuous-light
branch and never read count. The LED came on and stayed on. The Maimu node is
fire-and-continue and never writes "off", so one behaviour bout could leave an
implanted LED lit for the rest of the session while the command read as bounded.

The guard near line 224 only rejected width > period, passing on equality. It
now also rejects width >= period with a non-zero count, naming pulse_width_ms
and saying that continuous light is spelled with count = 0. Nothing is lost: a
bounded single 500 ms pulse is pulse(1000, 500, 1, 40), where count = 1 ends
the train at the first pulse end so the trailing gap never elapses. The
matching firmware parser change is in the Maimu repo.

Also:

- test_pulse_schema.py now compares MaimuNode.PROPERTIES_SCHEMA's four pulse
  fields against MaimuDevice.ACTION_ARGS_SCHEMA["pulse"] field-for-field -- key,
  min, max, default and type. node.py's comment claimed test_pulse_schema.py
  asserted this; it did not. It compared the device schema's defaults against
  the DEFAULT_* constants and its bounds against hardcoded literals, leaving the
  node's own min/max unguarded. They agreed, but only the comment stood between
  agreement and a spin box offering a value that raises mid-experiment.
- The package docstring still described the retired two-field grammar as the
  characteristic's contract. It was the last place in either repo doing so.
- The help text and README taught "a width equal to the period is continuous
  light" without the count = 0 half, which is the sentence that made the bug
  look like the documented way to do it.

Verified: 102 passed (was 99) with
PYTHONPATH="src:plugins/glider-maimu/src" QT_QPA_PLATFORM=offscreen
uv run pytest plugins/glider-maimu/tests/ -v; ruff check clean.
The lint job runs ruff, black --check and mypy; only ruff was run locally.
Formatting only -- the error-message f-string is merged into one rather
than left as Black's one-line implicit concatenation.
test_camera_panel_rehearsal.py lives in the main suite, not the plugin's,
so every plugin-scoped sweep missed it. It set stim.duration_s, which is
no longer a node property and so silently became a dead attribute -- the
node emitted its defaults and the stale assertion caught it.

Also records the grammar change under Unreleased.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant