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"
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
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.70 | 2026-08-04 11:47 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
37 changes: 37 additions & 0 deletions docs/development/arena-studio-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,43 @@ The Studio's footer used to carry the full changelog inline; it now shows one li
history lives here. Newest first. (Per-session engineering detail stays in
`arena-studio-handover.md` and the design docs — this file is the user-facing what-changed list.)

## v0.70 (2026-08-04) · Closed-loop bias — add a disturbance to a fly-on-ball trial

- **A closed-loop condition can now add a smooth disturbance to the visual
display**, so the arena keeps moving even when the fly holds still. This is
the stimulus for disturbance-rejection experiments: impose a known motion and
see how the fly counter-turns to null it. Set it per condition in Edit → the
condition's **Start closed-loop** command, which grows three fields:
- **Bias waveform** — None, Constant (steady drift), Sine, or Square.
- **Bias amplitude (deg/s, peak)** — how fast the display is pushed. A
**positive** amplitude turns the display **clockwise** (equivalently, the
pattern sweeps rightward across the fly's view); negative turns it
counter-clockwise. Confirmed on the arena.
- **Bias frequency (Hz)** — for Sine and Square only; Constant ignores it.
- **Constant** drifts the display steadily in one direction. **Sine** and
**Square** push it equally left and right around the frame it started on, so
they add no net rotation over a trial. Because the amplitude is a *speed*, how
far the display travels shrinks as the frequency rises: at 90 deg/s a sine
covers ±28.6° at 0.5 Hz but only ±14.3° at 1 Hz.
- **The Run view shows the live bias** next to the closed-loop indicator — the
waveform the running protocol asked for, plus the angle it is adding right now
— so you can confirm at the bench that it took effect. It is read-only there;
the protocol is the only place it is set.
- **Stop closed-loop clears the bias**, so a disturbance never leaks into
following trials — and so does **pressing STOP mid-run**, which skips the
protocol's own Stop closed-loop entirely. Without that, the next run started
with the previous run's disturbance still going (and the bridge kept driving
the arena after STOP). Bench-reported and fixed before release.
- A ready-made sweep of all four waveforms ships as **FicTrac closed-loop
bias/disturbance test** in File ▾ → Open from library.
- Needs an updated FicTrac bridge (`pixi run bridge`, reports version 2.1 or
later) — the bias is computed there. An older bridge simply ignores it (verified:
a stale bridge keeps streaming normally, it just applies no disturbance).
- **Validated on arena hardware.** A full 8-condition run drove a real controller
and the recorded log matches the intended frame index on every one of its 328,733
frames; the rotation direction was checked by eye. Not yet used in an experiment
with a behaving fly.

## v0.69 (2026-07-21) · ISP batch retries a failed panel twice

- **A failed panel flash now gets up to two retries** (was one) before being
Expand Down
Loading