Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions .claude/skills/protocol-yaml/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions .claude/skills/protocol-yaml/bin/validate-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down Expand Up @@ -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) {
Expand Down
56 changes: 56 additions & 0 deletions .github/workflows/validate-fictrac-bridge.yml
Original file line number Diff line number Diff line change
@@ -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"
41 changes: 41 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,47 @@ 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;
(5) **the FRAME MODULUS must reach the bridge.** It wraps every index with
`% n_frames`, and firmware REJECTS `index >= frame_count_` with
`showError(CE_BAD_PARAM)`. Bench 2026-08-12 (Isabel): a 20-frame grating ran
against the bridge's `--frames` default of 200 (the runner pushed none) → 91% of
0x70s rejected, a flickering error glyph with the pattern flashing through, and a
Console left unresponsive until a power cycle (single-slot `resp_buf_` overrun →
request↔response desync). The 200-frame validation pattern had hidden this by
matching the default. `ArenaRunner._resolveFrameModulus()` now resolves it: the
`resolvePatternFrames` hook (Studio-side, only knows patterns whose Console
thumbnail rendered — `onSdListing` sets `preview: null`), else **GET_PATTERN_INFO
(0x88)** on the controller (cached per pattern per run), else the step FAILS
rather than starting the loop on a guess. `FicTracBridgeClient` also defaults
`clampFrame` to wrap on the last pushed count. Resolve the modulus this way from
any NEW closed-loop entry point. A short pattern is legitimate — a 20-px grating
needs 20 frames and the modulus tiles it; keep `gain = 360/azimuth_pixels`.
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:
Expand Down
25 changes: 22 additions & 3 deletions arena_studio.html
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -2220,6 +2222,7 @@
<span class="dim" id="rbStatus">disconnected</span>
<span class="dim rb-count" id="rbCount"></span>
<span class="rb-cl" id="rbClosedLoop" title="Closed-loop (FicTrac Mode 3/4) — lights green while a trial or experiment is driving the arena from FicTrac">closed-loop</span>
<span class="dim rb-bias" id="rbBias" title="Closed-loop bias — the disturbance waveform the running protocol pushed to the bridge, and the angle it is currently adding. Set per condition in the protocol (fictrac → Start closed-loop)."></span>
<button class="pill led-off" id="btnLedOff" title="Turn the stimulus LED off now — sets Analog Out to 5000 mV (BuckPuck dark). Works during a run.">TURN OFF LED</button>
</div>
</div>
Expand Down Expand Up @@ -2917,7 +2920,7 @@ <h2 id="modalTitle">Import error</h2>
</div>

<footer id="footer">
<span class="foot-left">Arena Studio v0.69 | 2026-07-21 16:46 ET · <a href="https://github.com/reiserlab/webDisplayTools" target="_blank" rel="noopener" title="webDisplayTools on GitHub: source, issues, and release notes (docs/development/arena-studio-release-notes.md)">GitHub</a></span>
<span class="foot-left">Arena Studio v0.71 | 2026-08-12 16:56 ET · <a href="https://github.com/reiserlab/webDisplayTools" target="_blank" rel="noopener" title="webDisplayTools on GitHub: source, issues, and release notes (docs/development/arena-studio-release-notes.md)">GitHub</a></span>
<!-- Course-repo quick-links: open protocols / logs / patterns in a new tab.
Hrefs built from the configured repo + bench id (updateGhQuickLinks). -->
<span id="ghQuickLinks" title="Open the course repo on GitHub (new tab)">
Expand Down Expand Up @@ -3096,7 +3099,7 @@ <h2 id="modalTitle">Import error</h2>
}
const div = document.createElement('div');
if (ev.phase === 'error') div.className = 'l-err';
else if (ev.phase === 'skip') div.className = 'l-warn';
else if (ev.phase === 'skip' || ev.phase === 'warn') div.className = 'l-warn';
// Reuse run-log.js's own transcript line if available, else a compact fallback.
div.textContent =
(ev.t_iso ? ev.t_iso.slice(11, 19) + ' ' : '') +
Expand Down Expand Up @@ -5745,19 +5748,34 @@ <h2 id="modalTitle">Import error</h2>
// sync (both subscribe to the one client's events), even when switching
// views. Only connect + status + live counts + a closed-loop indicator
// are exposed here; gain / closed-loop config stay in the Console.
// Bias is READ-ONLY here: it is authored per condition in the protocol
// (fictrac → Start closed-loop), so this only mirrors what the runner pushed
// plus the live angle the bridge reports on each frame. Blank when inactive.
function biasText() {
const b = bridge.bias;
if (!b || b.type === 'none') return '';
const head =
b.type === 'constant'
? 'constant ' + b.amplitude + '°/s'
: b.type + ' ' + b.amplitude + '°/s @' + Math.abs(b.frequency) + ' Hz';
const live = bridge.biasAngleDeg;
return 'bias: ' + head + (live == null ? '' : ' · ' + (live > 0 ? '+' : '') + live.toFixed(1) + '°');
}
function refreshRunBridge() {
const on = bridge.connected;
const dot = $('rbDot'), conn = $('rbConnBtn'), st = $('rbStatus'), cnt = $('rbCount'), cl = $('rbClosedLoop');
const dot = $('rbDot'), conn = $('rbConnBtn'), st = $('rbStatus'), cnt = $('rbCount'), cl = $('rbClosedLoop'), bias = $('rbBias');
if (dot) dot.classList.toggle('on', on);
if (conn) conn.textContent = on ? 'Disconnect' : 'Connect';
if (st) st.textContent = on ? 'connected' : 'disconnected';
const s = bridge.stats;
if (cnt) cnt.textContent = (on && s && s.recv) ? 'rx ' + s.recv + ' · applied ' + s.applied : '';
if (cl) cl.classList.toggle('active', on && bridge.apply); // 'apply' = closed-loop live
if (bias) bias.textContent = on ? biasText() : '';
}
bridge.on('status', refreshRunBridge);
bridge.on('stats', refreshRunBridge);
bridge.on('apply', refreshRunBridge);
bridge.on('bias', refreshRunBridge);
if ($('rbConnBtn')) $('rbConnBtn').addEventListener('click', () => {
if (bridge.connected) { bridge.disconnect(); }
else { ftSendConfig(); bridge.connect(($('cFtUrl').value || 'ws://localhost:8765').trim()); }
Expand Down Expand Up @@ -14903,6 +14921,7 @@ <h2 id="modalTitle">Import error</h2>
'#mNotes': 'Anything worth remembering about this run — temperature, fly age, oddities.',
'#provBox': 'Filled in automatically — which protocol, arena, and controller produced this run.',
'#runBridge': 'The FicTrac bridge program records runs and drives closed-loop trials. Connect it before a recorded run.',
'#rbBias': 'The bias (disturbance) the running protocol added to the closed loop, and the angle it is adding right now. A bias keeps the display moving even when the fly holds still. Set it per condition in Edit → the condition’s Start closed-loop command.',
/* File ▾ — relocated Edit actions */
'#fmOpenDemo': 'Open one of the bundled demo protocols to explore how experiments are built.',
'#fmSaveCopy': 'Download a regenerated copy of the protocol for diffing or MATLAB checks — this does NOT count as saving.',
Expand Down
Loading