From 711ae4e998aab9cec84ab0cee34cda3a25bd9079 Mon Sep 17 00:00:00 2001 From: Isabel Lehenbauer <92334047+isabel-hess@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:06:33 -0400 Subject: [PATCH 1/5] feat(closed loop): bias/disturbance waveforms for FicTrac trials (LAB-185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/protocol-yaml/SKILL.md | 38 ++- .../protocol-yaml/bin/validate-protocol.mjs | 38 +++ .github/workflows/validate-fictrac-bridge.yml | 56 ++++ CLAUDE.md | 17 + arena_studio.html | 25 +- .../development/arena-studio-release-notes.md | 29 ++ docs/development/closed-loop-bias.md | 206 ++++++++++++ fictrac-bridge/README.md | 52 ++- fictrac-bridge/bridge.py | 204 +++++++++++- js/arena-runner-g6.js | 127 +++++++- js/fictrac-bridge-client.js | 61 +++- js/plugin-registry.js | 39 ++- js/run-log.js | 6 + protocols/fictrac_bias_test.yaml | 308 ++++++++++++++++++ protocols/index.json | 5 + tests/fixtures/v3_fictrac_closed_loop.yaml | 30 ++ tests/test-arena-runner-g6.js | 280 ++++++++++++++++ tests/test-arena-studio-alt.js | 8 +- tests/test-bridge-behavior.py | 92 +++++- tests/test-fictrac-bridge-client.js | 113 +++++++ tests/test-plugin-registry.js | 46 +++ tests/test-protocol-roundtrip-v3.js | 53 +++ 22 files changed, 1793 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/validate-fictrac-bridge.yml create mode 100644 docs/development/closed-loop-bias.md create mode 100644 protocols/fictrac_bias_test.yaml diff --git a/.claude/skills/protocol-yaml/SKILL.md b/.claude/skills/protocol-yaml/SKILL.md index bec6daf..4662634 100644 --- a/.claude/skills/protocol-yaml/SKILL.md +++ b/.claude/skills/protocol-yaml/SKILL.md @@ -115,14 +115,48 @@ streams frames for the wait's duration. Declare a `fictrac` plugin. Shape: frame_index: 0 frame_rate: 0 # mode 3: fixed 0 gain: 0 # mode 3: fixed 0 - - type: "controller" + - type: "plugin" # NOT "controller" — these are fictrac PLUGIN commands + plugin_name: "fictrac" command_name: "startClosedLoop" - type: "wait" duration: *cl_dur # FicTrac drives frames for this long (same anchor) - - type: "controller" + - type: "plugin" + plugin_name: "fictrac" command_name: "stopClosedLoop" ``` +`startClosedLoop` / `stopClosedLoop` are **plugin** commands on the declared `fictrac` +plugin. Typing them as `controller` makes the runner skip them with a warning, so the +loop never opens and the trial silently runs open-loop. + +### Bias / disturbance waveforms (LAB-185) + +`startClosedLoop` takes three optional params that add a smooth disturbance to the +loop, so the display keeps moving even when the fly holds still (disturbance +rejection). Authored as an added rotational **velocity**: + +```yaml + - type: "plugin" + plugin_name: "fictrac" + command_name: "startClosedLoop" + params: + gain: 1.8 + bias_type: "sine" # none | constant | sine | square + bias_amplitude: 90 # PEAK velocity, deg/s — negate to reverse direction + bias_frequency: 0.5 # Hz — sine/square only; ignored by constant +``` + +- `constant` drifts steadily; `sine`/`square` are **zero-mean in position**, so the + display is pushed equally both ways and starts where it already was. +- Amplitude is a *velocity*, so the position excursion is derived and **shrinks as + frequency rises**: `±A/(2πf)` for sine, `±A/(4f)` for square. At 90 deg/s a sine + covers ±28.6° at 0.5 Hz but only ±14.3° at 1 Hz. +- `bias_frequency` **must be non-zero** for `sine`/`square` — 0 Hz fails the step. + A negative frequency is a harmless no-op (warned): negate `bias_amplitude` instead. +- `stopClosedLoop` clears the bias automatically. Reference: + `docs/development/closed-loop-bias.md`; worked example: + `protocols/fictrac_bias_test.yaml`. + ### Conditional LED activation (index-gated LED, Mode 3 only) A Mode-3 `trialParams` may carry an optional **`led_activation`** attribute to drive diff --git a/.claude/skills/protocol-yaml/bin/validate-protocol.mjs b/.claude/skills/protocol-yaml/bin/validate-protocol.mjs index 8a049af..a1e44f4 100644 --- a/.claude/skills/protocol-yaml/bin/validate-protocol.mjs +++ b/.claude/skills/protocol-yaml/bin/validate-protocol.mjs @@ -68,6 +68,7 @@ for (const w of warnings) console.warn('⚠ ' + (w && w.message ? w.message : w) const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); let lintCount = 0; const lint = (msg) => { lintCount++; console.warn('⚠ waits: ' + msg); }; +const lintBias = (msg) => { lintCount++; console.warn('⚠ bias: ' + msg); }; for (const cond of conds) { const cmds = cond.commands || []; @@ -109,6 +110,43 @@ for (const cond of conds) { } } +// ── BIAS SANITY (LAB-185) ─────────────────────────────────────────────────── +// The bias params live on the fictrac plugin's startClosedLoop, not on trialParams, +// so this needs its own pass over plugin commands. Mirrors the runner's rules +// (js/arena-runner-g6.js normalizeBias) so an author hears about it here rather than +// at run time, where a bad spec skips the trial — possibly with a fly already mounted. +const BIAS_TYPES = ['none', 'constant', 'sine', 'square']; +for (const cond of conds) { + for (const c of cond.commands || []) { + if (c.type !== 'plugin' || c.command_name !== 'startClosedLoop') continue; + const p = c.params || {}; + if (p.bias_type === undefined || p.bias_type === null || p.bias_type === '') continue; + const t = String(p.bias_type).trim().toLowerCase(); + const label = '"' + cond.name + '" startClosedLoop'; + if (!BIAS_TYPES.includes(t)) { + lintBias(label + ' has bias_type ' + JSON.stringify(p.bias_type) + + ' — must be one of ' + BIAS_TYPES.join('/') + '. The runner will skip this step.'); + continue; + } + if (t === 'sine' || t === 'square') { + const f = num(p.bias_frequency); + if (p.bias_frequency === undefined || f === 0) { + lintBias(label + ' is bias_type ' + t + ' with bias_frequency ' + + (p.bias_frequency === undefined ? 'unset' : f) + + ' — 0 Hz has no period, so the runner will SKIP this step. ' + + 'Set a non-zero frequency (or use bias_type constant for a steady drift).'); + } else if (f < 0) { + lintBias(label + ' has a negative bias_frequency (' + f + ') — that is a no-op, ' + + 'the waveform is even in frequency. Negate bias_amplitude to reverse direction.'); + } + if (num(p.bias_amplitude) === 0) { + console.log('ℹ ' + label + ' is bias_type ' + t + + ' with bias_amplitude 0 — no disturbance will be applied.'); + } + } + } +} + if (!blocking.length && !warnings.length && !lintCount) { console.log('✓ clean — no blocking errors, no warnings, waits rule satisfied.'); } else if (!blocking.length) { diff --git a/.github/workflows/validate-fictrac-bridge.yml b/.github/workflows/validate-fictrac-bridge.yml new file mode 100644 index 0000000..a85e024 --- /dev/null +++ b/.github/workflows/validate-fictrac-bridge.yml @@ -0,0 +1,56 @@ +name: Validate FicTrac Bridge + +# The bridge owns two pieces of pure math the closed loop depends on: +# • behavior_v1_row() — the col-22 ns→ms normalization the live scope and the +# offline dashboard both read. +# • bias_angle_deg() — the closed-loop bias/disturbance waveforms (LAB-185): +# the closed-form integrals and the zero-mean-position property. +# tests/test-bridge-behavior.py covers both and runs in `pixi run test`, but until +# this workflow it ran in NO CI job — so a regression in either only surfaced on a +# developer's machine. + +on: + push: + branches: [main] + paths: + - 'fictrac-bridge/**' + - 'tests/test-bridge-behavior.py' + - '.github/workflows/validate-fictrac-bridge.yml' + pull_request: + branches: [main] + paths: + - 'fictrac-bridge/**' + - 'tests/test-bridge-behavior.py' + workflow_dispatch: # Allow manual triggering + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install bridge dependencies + # bridge.py imports websockets at module scope, so importing it for the + # offline tests needs the package even though no socket is opened. + run: pip install 'websockets>=12' + + - name: Run bridge behavior + bias tests + run: python tests/test-bridge-behavior.py + + - name: Bridge CLI smoke test + # --version proves the module imports and argparse is wired; the bad-bias + # case proves the 0 Hz guard rejects before any socket is opened. + run: | + python fictrac-bridge/bridge.py --version + if python fictrac-bridge/bridge.py --bias-type sine --bias-freq 0 2>/dev/null; then + echo "FAIL: --bias-type sine with --bias-freq 0 should be rejected" + exit 1 + fi + echo "OK: 0 Hz sine rejected at the CLI" diff --git a/CLAUDE.md b/CLAUDE.md index 45dfad6..cb5b365 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,6 +251,23 @@ fix flows to every page automatically; two hand-written HTML pages never will. when adding encoders/decoders, add them to the export list AND a test; audit with `Object.keys(require('./js/arena-wire-g6.js'))` vs the page's `Wire.*` uses (a missing export throws silently inside async handlers). +- **Closed-loop bias (LAB-185):** a disturbance waveform added to the FicTrac + closed loop so the display moves even when the fly is still. Authored + per-condition on the `fictrac` plugin's `startClosedLoop` + (`bias_type`/`bias_amplitude`/`bias_frequency`) — deliberately NOT in the + plugin's `configFields` and NOT a Console input, so there is one source of + truth. The MATH lives in the bridge (`bias_angle_deg` in + `fictrac-bridge/bridge.py`, pure + Python-tested); the runner only validates + (`normalizeBias` → `{bias, warning}`) and pushes it as bridge config. GOTCHAS: + (1) the `none|constant|sine|square` vocabulary is declared in THREE places — + bridge `BIAS_TYPES`, runner `BIAS_TYPES`, registry `bias_type.options` — and a + registry test pins them equal; (2) `stopClosedLoop` MUST push + `bias:{type:'none'}` or the waveform keeps accumulating into the frame index + through later trials; (3) `bias` is the one OBJECT-valued key in + `FicTracBridgeClient.setConfig`, whose scalar keys gate on `Number.isFinite`; + (4) never widen `BEHAVIOR_V1_COLS` to log it — the per-frame `bias` on the + WebSocket is display-only, and offline reconstruction uses the `bias_config` + log event. Full spec: `docs/development/closed-loop-bias.md`. - URL state ([#107](https://github.com/reiserlab/webDisplayTools/issues/107), read+write): `js/studio-url-state.js` (`mode` ∈ run|edit|console; a shared `p` forces `edit`→Run on fresh loads, never `console`). Write side: diff --git a/arena_studio.html b/arena_studio.html index 4cd933d..b2fcd45 100644 --- a/arena_studio.html +++ b/arena_studio.html @@ -306,6 +306,8 @@ /* closed-loop label: very light by default, bright-green pulsing when active */ .run-bridge .rb-cl{margin-left:auto;font-family:var(--head);font-size:11px;letter-spacing:.4px;color:var(--dim);opacity:.45;transition:color .2s,opacity .2s} .run-bridge .rb-cl.active{color:var(--accent);opacity:1;text-shadow:0 0 8px rgba(0,230,118,.7);animation:pulse 1.1s infinite} + /* closed-loop bias readout — read-only mirror of what the protocol pushed (LAB-185) */ + .run-bridge .rb-bias{font-size:11px;color:var(--dim);white-space:nowrap} /* quick safety button: drive the stimulus LED dark (AO 5000 mV) — pink */ .pill.led-off{border-color:#ff408c;color:#ff6fa8;font-weight:700;letter-spacing:.3px} .pill.led-off:hover{background:rgba(255,64,140,.16);border-color:#ff408c;color:#ff8fbd} @@ -2220,6 +2222,7 @@ disconnected closed-loop + @@ -2917,7 +2920,7 @@

Import error