feat(closed loop): bias/disturbance waveforms for FicTrac trials (LAB-185) - #175
Open
isabel-hess wants to merge 5 commits into
Open
feat(closed loop): bias/disturbance waveforms for FicTrac trials (LAB-185)#175isabel-hess wants to merge 5 commits into
isabel-hess wants to merge 5 commits into
Conversation
…-185)
Add a smooth disturbance to the FicTrac closed loop so the display keeps
moving even when the fly holds still — the stimulus for disturbance-rejection
experiments in the fly-on-ball assay.
The bias is authored as an added rotational VELOCITY (deg/s peak); the bridge
integrates it analytically into an angle and folds that into its
heading→frame-index mapping alongside `offset`:
idx = round((heading + offset + b(t)) / gain) mod n_frames
type v(t) b(t) = ∫v position range
constant A A·t steady drift
sine A·cos(ωt) (A/ω)·sin(ωt) ±A/(2πf)
square A·sign(cos ωt) sym. triangle ±A/(4f)
Every waveform has b(0)=0 (no jump at onset) and the two periodic ones are
ZERO-MEAN in position — the display is pushed equally both ways rather than
drifting to one side. `sign(cos ωt)` (not sin) is what makes the triangle
symmetric. Since amplitude is a velocity, position excursion shrinks as
frequency rises. b(t) is evaluated analytically, never integrated
incrementally, so a dropped FicTrac frame costs nothing.
Authored per condition on the fictrac plugin's startClosedLoop
(bias_type/bias_amplitude/bias_frequency) — deliberately NOT in configFields
and NOT a Console input, so there is one source of truth. The math lives in
the bridge; the runner only validates and pushes it as bridge config.
- bridge: bias_angle_deg() (pure), BIAS_TYPES, bias_deg arg to
frame_index_from_fictrac, Pipeline.set_bias + its own phase clock,
LogWriter.write_event, --bias-type/--bias-amplitude/--bias-freq, v2.1
- client: `bias` as the one OBJECT-valued config key, setBias(), 'bias' event,
biasAngleDeg from the per-frame WebSocket field
- runner: normalizeBias() → {bias, warning}; fails the STEP (not the run) on a
bad type or 0 Hz sine/square, warns on a negative frequency (a no-op — both
velocities are even in ω; negate the amplitude instead). stopClosedLoop
pushes bias:{type:'none'} or the waveform keeps accumulating into later trials
- Studio v0.70: read-only #rbBias readout in the Run bridge strip
- protocols/fictrac_bias_test.yaml — all four waveforms, in the library index
The phase clock re-zeros on any config message CARRYING a `bias` key, so every
closed-loop epoch starts at phase 0. Offline reconstruction uses the new
`bias_config` log event (ms in the behavior_v1 timebase); BEHAVIOR_V1_COLS is
deliberately NOT widened — the per-frame `bias` on the WebSocket is
display-only.
Verified: full suite green; end-to-end against the real bridge over a real
WebSocket with a still fly — constant drifted at exactly 90.00 deg/s, sine
swung ±28.65°, clearing returned to frame 0, log rows stayed 7 wide. In the
Studio the three fields render from the registry schema with anchor binding,
and a UI edit round-trips as a quoted string with comments/aliases preserved.
NOT yet bench-validated on an arena with a fly.
Also fixed in passing:
- the protocol-yaml skill typed startClosedLoop/stopClosedLoop as `controller`
commands; the runner skips those, so the loop would silently never open
- tests/test-bridge-behavior.py ran in no CI workflow, leaving all the bias
math uncovered → new validate-fictrac-bridge.yml
- an Alt test pinned Classic's exact footer version, so it broke on every
Studio version bump (which CLAUDE.md mandates) → matched by shape instead
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…xt run
Bench-reported: stopping an experiment while a closed-loop trial is running
aborts the sequence, so the protocol's own `stopClosedLoop` never executes. The
bias stayed installed on the bridge with its phase clock still running, so the
NEXT run inherited a stale, already-drifted disturbance until some condition
happened to push a new one.
Investigating turned up a second, worse consequence of the same root cause: the
three run-teardown paths cleared the LED activator but never touched the bridge
at all, so `setApply` was also left ON — after STOP the bridge kept streaming
SET_FRAME_POSITION at the arena. Only the happy path (stopClosedLoop) and a
bridge disconnect ever disabled it.
Two independent guards, either of which alone fixes the reported symptom:
1. ArenaRunner._clearClosedLoop() — setApply(false) + a bias:{type:'none'}
push — called from ALL THREE teardown paths, symmetric with the existing
_clearLedActivator(): runSequence's finally (normal end AND abort unwind),
stop() (the STOP button), and _clear()/abort() (involuntary disconnect).
Bridge-only, so it still works when the serial link is already gone. Clears
the bias only when the CLIENT has one installed, so a bias set on the
bridge's own CLI (--bias-type) survives a run rather than being stomped.
2. startClosedLoop now ALWAYS carries a bias in its IR — {type:'none'} when the
condition authors none — so a closed-loop epoch can never inherit whatever
the bridge still had installed. Same reasoning as `duty` in
buildTrialParams: relying on someone else having cleared state leaks one
trial's settings into the next.
Regression test verified to FAIL against the old code (7 checks, including the
exact symptom and the frames-still-streaming one) and pass after: STOP
mid-wait, the following bias-free run, abort() with no link, the CLI-bias
carve-out, and teardown with no bridge at all.
Also verified end to end against the real bridge: a constant bias drifted to
269.6 deg, STOP cleared it, the next bias-free epoch held frame 0 with no bias
field, and a fresh biased epoch restarted at 0.00 deg instead of resuming
mid-drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…convention
Ran the full 8-condition sweep on real hardware (32,499 SET_FRAME_POSITION
commands to a G6 controller) and checked the exported bridge log:
- round((heading + bias)/gain) mod 200 reproduces the logged idx for all
328,733 frame rows — 4 mismatches (0.0012%), all +/-1 frame from millisecond
rounding between the bias evaluation and the row's own stamp
- constant 90 deg/s measured 50.0 frames/s = 90.0 deg/s = one revolution per
4.00 s
- every amplitude exact: sine +/-28.6 / +/-14.3 / +/-9.5, square +/-45.0 / +/-22.5
- b(0) = 0 at all 16 epoch onsets; 8 waveform pushes each cleared by a `none`
- the log check subsumes the "ball held still" version of the test, since it
validates against the LOGGED per-frame heading rather than a constant one
THE SIGN CONVENTION, confirmed by eye and now recorded in the doc, the protocol
header, and the release notes: at the normal positive gain, a POSITIVE
bias_amplitude turns the display CLOCKWISE — equivalently the pattern sweeps
rightward across the fly's visual field. Both phrasings agree on this rig.
This is the one thing no log can establish, since it depends on the pattern and
panel wiring rather than on this code, so it is flagged as re-checkable after any
change to wiring, panel numbering, or the pattern's direction. Getting it
backwards inverts the science: a fly correctly counter-turning against the
disturbance reads as following it.
Also documented: keep cl_dur an integer number of bias cycles. The committed
protocol's 30 s is exact for both 0.5 Hz (15 cycles) and 1 Hz (30); a 15 s trial
at 0.5 Hz is 7.5 cycles and carries ~4% of peak as a net DC offset (it still ends
at b = 0, so no jump at stopClosedLoop). No protocol change needed.
Docs only — no behaviour change, suite unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
js/runlog-replay.js dispatches on specific record types and dropped `bias_config`
on the floor — the log parsed fine (no crash), but its `events` list only
collected runner status events, so the closed-loop bias was invisible to the
replay path and the analysis dashboard. Since the bias is deliberately NOT a
per-frame column, those epoch markers plus the behavior_v1 `ms` column are the
whole offline record of the disturbance; without them nothing downstream can
annotate or reconstruct it.
Surfaced as a STATUS event — `status.phase === 'bias_config'` with the spec on
`status.bias` — rather than a new top-level array, so it flows through
buildTimeline like any other status item and every existing
`status.phase === '...'` consumer skips it instead of breaking (all of them
already guard that way and read `event.status || {}`).
Reconstruction then reads:
parsed.events
.filter((e) => e.status && e.status.phase === 'bias_config')
.map((e) => ({ ms: e.ms, bias: e.status.bias }))
Verified against the 24 MB bench log from the hardware validation run: all 16
epochs surface at their original timestamps, every one inside the sample ms
range, 16 bias items in a 361,302-item timeline, and the 54 pre-existing runner
events come through unchanged (`command` reads 34 because the 8 `trial-running`
events normalize into it).
Test note: the epoch `ms` is used verbatim only when the log carries its usual
epoch-stamped `logging_started` anchor, which is what tells the parser the origin
is wall-clock. A first fixture omitted it and the parser rebased every epoch
against the first record — realistic fixture added, and the requirement
documented, rather than changing shared rebasing logic to suit a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isabel-hess
marked this pull request as ready for review
August 5, 2026 14:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes LAB-185.
Adds a bias waveform to the FicTrac closed loop, so the display keeps moving even
when the fly holds still — the stimulus for measuring disturbance rejection in the
fly-on-ball assay.
Semantics
Authored as an added rotational velocity in deg/s (
bias_amplitude= peak). Thebridge integrates it analytically into an angle and folds that into its
heading→frame-index mapping alongside the existing
offset:bias_typev(t)b(t) = ∫vnoneconstantAA·tsineA·cos(ωt)(A/ω)·sin(ωt)±A/(2πf)squareA·sign(cos ωt)±A/(4f)Three properties are load-bearing, each pinned by a test:
b(0) = 0for every waveform — closed-loop onset never jumps the display.both ways rather than drifting to one side. (
squareusessign(cos ωt), notsign(sin ωt), which would integrate to a one-sided ramp.)bis evaluated analytically, never integrated incrementally — a droppedFicTrac frame costs nothing and the value is exactly reproducible offline.
Because amplitude is a velocity, position excursion shrinks as frequency rises:
at 90 deg/s a sine covers ±28.6° at 0.5 Hz but only ±14.3° at 1 Hz.
On-arena direction (confirmed by eye): at the normal positive gain, a positive
bias_amplitudeturns the display clockwise — equivalently the pattern sweepsrightward across the fly's visual field. This is the sign an analysis must assume; get
it backwards and a fly correctly counter-turning reads as following the disturbance. A
negative
gainflips it, since the bias is divided by gain along with the heading.Where it lives
fictrac-bridge/bridge.pybias_angle_deg()(pure),BIAS_TYPES, thebias_degarg toframe_index_from_fictrac()fictrac-bridge/bridge.pyPipeline.set_bias(),LogWriter.write_event(), theconfigdispatch,--bias-*flags, v2.1js/fictrac-bridge-client.jsbiasas the one object-valued config key;setBias(),'bias'event,biasAngleDegjs/plugin-registry.jsstartClosedLoopparamsjs/arena-runner-g6.jsnormalizeBias()→{bias, warning}; thefictracApplyIRarena_studio.html#rbBiasin the Run-view bridge stripAuthored per condition on the
fictracplugin'sstartClosedLoop— deliberatelynot in the plugin's
configFieldsand not a Console input, so there is one source oftruth. The three fields render automatically in both designers from the registry
schema; no designer HTML knows them by name.
The
none|constant|sine|squarevocabulary is declared in three places (bridge, runner,registry) and a registry test pins all three equal.
Validation policy
bias_typeomitted /none/""bias_type, non-finite amplitude/frequencysine/squarewithbias_frequency == 0ω=0)constantwithbias_frequency == 0bias_frequencyFailing the step rather than aborting the run follows the
dutyprecedent inbuildTrialParams. 0 Hz is a hard failure because the bridge's defensive fallbackreturns a 0 bias, which at the bench reads as "the disturbance didn't work" — a silent
no-op is the worst outcome.
A bug this found on the bench
Stopping a run mid-trial skips the protocol's own
stopClosedLoop, so the bias stayedinstalled with its phase clock running and the next run inherited a stale,
already-drifted disturbance. Investigating turned up a second consequence of the same
root cause: the three teardown paths cleared the LED activator but never touched the
bridge, so
setApplywas also left ON — after STOP the bridge kept streamingSET_FRAME_POSITIONat the arena.Two independent guards, either of which alone fixes the reported symptom:
ArenaRunner._clearClosedLoop()—setApply(false)+ abias:{type:'none'}push —called from all three teardown paths, symmetric with
_clearLedActivator().Bridge-only, so it works when the serial link is already gone. Clears the bias only
when the client has one installed, so a
--bias-typeCLI default survives a run.startClosedLoopalways carries an explicit bias ({type:'none'}when none isauthored), so an epoch can never inherit what the bridge still had.
The regression test was verified to fail against the pre-fix code (7 checks).
Logging
Not a per-frame column —
BEHAVIOR_V1_COLSis deliberately not widened, since thepositional row format is parsed by
js/runlog-replay.jsand the dashboard's vendoredparser. Instead the bridge writes one
bias_configevent per push whosemsis in thesame relative timebase as the frame rows, marking the waveform's phase-clock zero:
{"type":"bias_config","dir":"bridge","ms":45024,"bias":{"type":"sine","amplitude":90.0,"frequency":0.5}}Because the waveform is analytic, that plus the
mscolumn recoversb(t)exactly.js/runlog-replay.jsnow surfaces each epoch as a status event(
status.phase === 'bias_config') so it flows throughbuildTimelineand existingconsumers skip it rather than break.
idxin every frame row already has the biasfolded in, so what the fly saw needs no reconstruction at all.
Verification
Automated —
pixi run testgreen (28 suites). New coverage: the closed forms andthe zero-mean regression guard (
tests/test-bridge-behavior.py), runner validation andteardown, client transport, registry schema, YAML round-trip, and replay parsing. Also
closes a pre-existing gap:
tests/fixtures/v3_fictrac_closed_loop.yamlmatched the CIpath filter but was loaded by no test.
On arena hardware — a full 8-condition run of
protocols/fictrac_bias_test.yamldrove a real G6 controller (32,499
SET_FRAME_POSITIONcommands). From the 24 MB log:round((heading + bias)/gain) mod 200reproduces the loggedidxfor all 328,733frame rows — 4 mismatches (0.0012%), all ±1 frame from millisecond rounding
constant 90 deg/smeasured 50.0 frames/s = 90.0 deg/s = one revolution per 4.00 sb(0) = 0at all 16 epoch onsets; 8 waveform pushes each cleared by anoneCompatibility — a stale pre-2.1 bridge receiving the new
biaskey keeps streamingnormally and simply applies no disturbance (tested against the pre-change
bridge.py).Needs
pixi run bridgereporting 2.1; the bias is computed there.Also in here
validate-fictrac-bridge.yml.tests/test-bridge-behavior.pyran in
pixi run testbut in no GitHub workflow, leaving all the bias mathwithout CI coverage.
protocol-yamlskill's closed-loop example, which typedstartClosedLoop/stopClosedLoopascontrollercommands. The runner skips those,so the loop would silently never open and the trial would run open-loop.
validate-protocol.mjsmirroring the runner's rules, so a0 Hz sine is caught when the YAML is validated rather than at run time.
every Studio version bump (which CLAUDE.md mandates).
Not covered
nothing depended on the fly's behaviour.
v1 non-goals.
draws them; a channel would mean touching
js/kinematics.jsand the dashboard'svendored parser.
so the host has no injection point.
Full spec:
docs/development/closed-loop-bias.md.