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..3d1bba3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,6 +251,32 @@ 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 — and because a mid-trial STOP never REACHES it, the loop + is torn down twice over: `_clearClosedLoop()` (setApply(false) + bias none) is + called from ALL THREE teardown paths (`runSequence` finally, `stop()`, + `_clear()`/`abort()`, symmetric with `_clearLedActivator()`) and + `startClosedLoop` ALWAYS carries an explicit bias so an epoch can never inherit + a stale one. Call `_clearClosedLoop()` from any NEW teardown path; + (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, which `js/runlog-replay.js` surfaces as a status event + (`status.phase === 'bias_config'`, spec on `status.bias`) so `buildTimeline` + and every `status.phase === '…'` consumer keep working untouched. + 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