fix(flomni): mask gate_detector_active to its own bit, record confirmed PandA wiring

TTLIN1 (detector trigger) drives both COUNTER1 (frame_counter) and the
PCAP.BITS0 gate-bit capture, confirmed 2026-09-15 against the omny-panda
hardware via TTLIN1.VAL.CAPTURE_WORD?/OFFSET? (PCAP.BITS0, bit 0).

PCAP.BITS0 is a shared 32-bit word -- other captured bit_out signals may
occupy other bit positions of it, so the raw word is not itself a clean
0/1. PandaBoxOMNY now masks gate_detector_active down to bit 0 before
publishing it, instead of trusting the whole word.

Also records the confirmed free-running setup in the developer docs: all
four encoders plus both FMC_IN channels switched to raw Value capture,
CLOCK1 driving continuous PCAP gate/trig, and the initial 100 Hz test
rate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsfqLcPWspjJ6vedMyCxco
This commit is contained in:
x12saandClaude Sonnet 5 committed 2026-09-15 14:13:22 +02:00
1 parent 82ecab4c21
commit 3012cd3154
5 files changed
+129 -46

No files matched your search

+4 -3
View File
@@ -676,9 +676,10 @@ omny_panda:
INENC4.VAL.Mean: interf_st_rotx_mean
INENC4.VAL.Min: interf_st_rotx_min
PCAP.GATE_DURATION.Value: pcap_gate_duration_value
# Free-running mode (raw_stream_mode: true) signals. Block numbers below are
# placeholders (TODO) pending the actual PandaBox wiring/layout - see
# docs/developer/panda_box_free_running_setup.md.
# Free-running mode (raw_stream_mode: true) signals. TTLIN1 (detector trigger) feeds
# both blocks below - confirmed 2026-09-15 against the omny-panda hardware, see
# docs/developer/panda_box_free_running_setup.md. PCAP.BITS0 is a shared 32-bit word;
# PandaBoxOMNY masks it to bit 0 (TTLIN1.VAL) before publishing gate_detector_active.
PCAP.BITS0.Value: gate_detector_active
COUNTER1.OUT.Value: frame_counter
enabled: true
@@ -36,6 +36,17 @@ class PandaBoxOMNY(PandaBox):
capture.
"""
# PCAP.BITS0 is a shared 32-bit capture word; other captured bit_out signals (e.g. other
# TTLIN/LVDSIN/encoder bits also flagged for capture) may share it at different bit
# offsets, so the raw word is not itself a clean 0/1. TTLIN1.VAL (the detector-trigger
# gate signal, aliased to _GATE_SIGNAL_NAME below) was confirmed at bit offset 0 of
# PCAP.BITS0 on the omny-panda hardware on 2026-09-15 via
# `TTLIN1.VAL.CAPTURE_WORD?`/`TTLIN1.VAL.OFFSET?` (see
# docs/developer/panda_box_free_running_setup.md). Re-verify with the same query if the
# PandA layout is ever rebuilt.
_GATE_SIGNAL_NAME = "gate_detector_active"
_GATE_BIT_OFFSET = 0
def __init__(
self,
*,
@@ -176,6 +187,18 @@ class PandaBoxOMNY(PandaBox):
self.cancel_on_stop(status_captured)
return status_captured
def convert_frame_data(self, frame_data: FrameData) -> dict[str, Any]:
"""Same as the base class, but in raw_stream_mode additionally masks
``gate_detector_active`` down to its own bit within the shared PCAP.BITS0 capture
word -- see the class-level note on ``_GATE_BIT_OFFSET`` for why the raw word can't
be trusted as a clean 0/1 on its own.
"""
out = super().convert_frame_data(frame_data)
if self.raw_stream_mode and self._GATE_SIGNAL_NAME in out:
entry = out[self._GATE_SIGNAL_NAME]
entry["value"] = [(int(v) >> self._GATE_BIT_OFFSET) & 1 for v in entry["value"]]
return out
def _receive_frame_data(self, data: FrameData) -> None:
"""Callback to receive frame data from the PandaBox.
@@ -3,10 +3,11 @@
> **Status: schema for the acquisition side only.** This document defines
> what raw data `omny_panda` writes to its HDF5 async dataset when
> `raw_stream_mode: true` (see `docs/developer/panda_box_free_running_setup.md`
> for the hardware/mode setup). Reconstructing the previous
> averaged/stdev-per-position view from this raw data (binning by the
> `gate_detector_active` transitions or timestamps) is a separate,
> not-yet-planned analysis task and is intentionally out of scope here.
> for the hardware/mode setup, confirmed against real PandA hardware
> 2026-09-15). Reconstructing the previous averaged/stdev-per-position view
> from this raw data (binning by the `gate_detector_active` transitions or
> timestamps) is a separate, not-yet-planned analysis task and is
> intentionally out of scope here.
## Where this data lands
@@ -22,16 +23,16 @@ just batched differently for publishing efficiency.
## Per-row fields
Each row corresponds to one PCAP capture tick (one raw sample at the
free-running capture rate, e.g. ~20 kHz — see the setup doc for the actual
configured rate):
free-running capture rate — `CLOCK1`'s configured rate, started at 100 Hz for
initial testing on 2026-09-15, see the setup doc for the current rate):
| Field (BEC signal name) | Source PandA block | Meaning |
|---|---|---|
| *(raw analog/position value(s), name TBD per setup doc)* | e.g. `FMC_IN.VAL1.Value` / `INENC1.VAL.Value` | The instantaneous raw sample at this capture tick, replacing the Mode A aggregate (Mean/Min/Max) for whichever channel(s) are switched to raw capture. |
| `gate_detector_active` | `PCAP.BITS0.Value` (block TBD) | The other detectors' gate signal, sampled at this same tick: `1` while the other detectors are actively gated/acquiring, `0` otherwise. This is what a future reconstruction step would use to delimit which raw samples belong to which detector-gate window. |
| `frame_counter` | `COUNTER1.OUT.Value` (block TBD) | Monotonically incrementing hardware counter, one increment per capture tick. Used to verify data completeness after acquisition: a gap-free, strictly increasing sequence means no samples were dropped between the PandA and the HDF5 file; a gap indicates lost data (e.g. from the Redis stream's `MAXLEN` trimming if the file writer fell behind — see the acquisition plan's data-path notes). |
| `cap_voltage_fzp_y`/`cap_voltage_fzp_x` etc. (raw analog/encoder value) | `FMC_IN.VAL1.Value` / `FMC_IN.VAL2.Value` / `INENC1`-`4.VAL.Value` | The instantaneous raw sample at this capture tick, replacing the Mode A aggregate (Mean/Min/Max). All six channels (both FMC analog inputs and all four encoders) are switched to raw capture together on this layout — not a subset. |
| `gate_detector_active` | `PCAP.BITS0.Value`, bit 0 (`TTLIN1.VAL`), masked in software — see setup doc | The detector-trigger signal, sampled at this same tick: `1` while a detector trigger is active, `0` otherwise. `PCAP.BITS0` is a shared 32-bit word; `PandaBoxOMNY` masks it down to bit 0 before this reaches the HDF5 file (see `_GATE_BIT_OFFSET` in `panda_box_omny.py`) — the raw word itself is not a clean 0/1. This is what a future reconstruction step would use to delimit which raw samples belong to which detector-gate window. |
| `frame_counter` | `COUNTER1.OUT.Value`, free-running off `TTLIN1.VAL` (i.e. increments once per detector trigger pulse, not once per PCAP capture tick as originally guessed here) | Monotonically incrementing hardware counter. Used to verify data completeness after acquisition: a gap-free, strictly increasing sequence means no detector triggers were missed between the PandA and the HDF5 file; a gap indicates lost data (e.g. from the Redis stream's `MAXLEN` trimming if the file writer fell behind — see the acquisition plan's data-path notes). Because it counts detector triggers rather than capture ticks, it verifies detector-frame completeness directly rather than raw-sample completeness. |
| `PCAP.TS_TRIG.Value` (existing) | `PCAP.TS_TRIG` | Absolute/relative timestamp already captured today; usable for time-alignment against other detectors' data during future reconstruction. |
| `PCAP.GATE_DURATION.Value` (existing, aliased as `pcap_gate_duration_value`) | `PCAP.GATE_DURATION` | Existing field, retained; less meaningful once the gate is held open continuously for the whole burst window rather than pulsed per point (see setup doc, Mode B gate/arm row) — re-evaluate whether this still needs to be captured once real hardware is available. |
| `PCAP.GATE_DURATION.Value` (existing, aliased as `pcap_gate_duration_value`) | `PCAP.GATE_DURATION` | Existing field, retained; less meaningful once the gate is held open continuously for the whole burst window rather than pulsed per point (see setup doc, Mode B gate/arm row) — re-evaluate whether this still needs to be captured now that real hardware testing has started. |
All rows across a burst window share the same set of fields; there is no
per-point boundary marker in this raw stream other than what can be derived
+72 -33
View File
@@ -1,10 +1,11 @@
# PandABox (`omny_panda`) burst acquisition: two operation modes
> **Status: design + software implemented, hardware not yet configured or
> tested.** The PandA layout changes and target sample rate below are
> placeholders (marked TODO) until real PandA hardware is available to
> configure and measure against. This document should be updated with actual
> values once that happens, per the flomni burst-acquisition redesign plan.
> **Status: software implemented, Mode B layout wired and under initial
> testing on real PandA hardware (2026-09-15, omny-panda.psi.ch).** The block
> assignments below reflect what is actually loaded on the OMNY PandA design
> as of that date. The target free-running rate is still being ramped up
> (started at 100 Hz for initial testing); update the rate row once a final
> operating rate is settled.
## Context
@@ -28,7 +29,7 @@ the end) before that mode can be used.
|---|---|---|
| Selected by | `raw_stream_mode` unset / `false` | `raw_stream_mode: true` |
| PCAP capture fields | `Mean`/`Min`/`Max`/`Sum`/`Diff` per gate | `Value` (raw instantaneous sample) + gate bit + frame counter |
| Gate/arm timing | Pulsed per point / sub-frame (rate = scan rate × `frames_per_trigger`) | PCAP held open continuously for the burst window, free-running at a high internal sample rate (target: **TODO**, e.g. ~20 kHz) |
| Gate/arm timing | Pulsed per point / sub-frame (rate = scan rate × `frames_per_trigger`) | PCAP held open continuously for the burst window, free-running at `CLOCK1`'s configured rate (started at **100 Hz** for initial testing on 2026-09-15; will be ramped up as testing progresses) |
| `_acquisition_group` (BEC async metadata) | `fly` / `monitored` / `burst`, chosen from `scan_type`/`frames_per_trigger` | `free_running` |
| Message publishing | One `.data.put()` per PandA network frame, unchanged | Coalesced client-side: flushed every `raw_stream_flush_row_count` rows or `raw_stream_flush_interval` seconds, whichever comes first (mitigates Redis/HDF5 message-rate risk — see the acquisition plan's data-path notes) |
| Completion check (`on_complete`) | Exact count: waits for `*PCAP.CAPTURED?` to reach `num_points * frames_per_trigger` | Settle-based: waits for `*PCAP.CAPTURED?` to stop changing for `_settle_timeout_in_s`, since an exact expected sample count can't be predicted client-side for continuous capture |
@@ -40,8 +41,10 @@ exact behavior unchanged.
## Physical wiring (Mode B only)
**TODO**: which PandA digital input connector/pin the other detectors' gate
TTL signal is patched into. Fill in once the cabling is done.
The detector-trigger signal is patched into **TTL input 1** (`TTLIN1`) on the
PandA front panel. That same `TTLIN1.VAL` signal feeds both downstream
consumers (see "Block configuration" below): it is the source counted by
`COUNTER1` (the frame counter) and the source captured into the gate bit.
## Block configuration
@@ -60,24 +63,50 @@ layout can be restored unambiguously (see `ptycho_flomni.yaml`):
### Mode B (new) — free-running raw capture
- **Gate-bit input**: the other detectors' gate signal (wired above) →
**TODO: which `PCAP.BITSx` block and bit position** — set
`CAPTURE=Value` so every captured row records the instantaneous gate
state (0/1).
- **Frame counter**: dedicate **TODO: which `COUNTERx.OUT` block** (any of
`COUNTER1`-`COUNTER8` not already in use) to free-run, incrementing once
per PCAP capture tick, with `CAPTURE=Value`. This is the
data-completeness signal (`frame_counter` in `signal_alias`, see below).
- **Raw analog/position capture**: switch **TODO: which of
`FMC_IN.VAL1/VAL2`/`INENC1`-`4.VAL`** from aggregate capture to
`CAPTURE=Value`, so the raw time series is recorded instead of (or in
addition to) the per-gate statistics.
- **Continuous gate/arm**: instead of pulsing PCAP per point, keep the gate
open for the whole burst window (armed once per point-group / scan) so
PCAP free-runs at the target sample rate — **TODO: confirm target rate**
(design target: ~20 kHz) and how the continuous gate is generated (e.g. a
PandA-internal clock/pulse-generator block feeding PCAP's gate input,
rather than the external per-point trigger used in Mode A).
- **Gate-bit input**: the detector-trigger signal, wired into `TTLIN1` (see
"Physical wiring" above). `TTLIN1.VAL` is captured into **`PCAP.BITS0`,
bit offset 0** — confirmed 2026-09-15 against the omny-panda hardware via:
```
TTLIN1.VAL.CAPTURE_WORD? -> OK =PCAP.BITS0
TTLIN1.VAL.OFFSET? -> OK =0
```
`PCAP.BITS0` is a shared 32-bit capture word — other captured `bit_out`
signals (several TTLIN/LVDSIN/encoder bits are also flagged for capture on
this layout) may occupy other bit positions of the same word, so the raw
`PCAP.BITS0.Value` is **not** itself a clean 0/1. `PandaBoxOMNY` masks it
down to bit 0 in `raw_stream_mode` (see `_GATE_BIT_OFFSET` in
`panda_box_omny.py`) before publishing it as `gate_detector_active` — treat
that masking as required, not optional, unless it's confirmed nothing else
shares `BITS0`. Re-run the query above (read-only, safe against live
hardware) if the PandA layout is ever rebuilt, since bit assignment is
fixed by the FPGA build and not guaranteed stable across rebuilds.
- **Frame counter**: `COUNTER1`, free-running off `TTLIN1.VAL` as its
`trig` input (see layout below) — i.e. it increments once per detector
trigger pulse, not once per PCAP capture tick as originally guessed here.
Captured with `CAPTURE=Value`; this is the data-completeness signal
(`frame_counter` in `signal_alias`, see below).
- **Raw analog/position capture**: `FMC_IN.VAL1`, `FMC_IN.VAL2` and all four
`INENC1`-`4.VAL` are switched from aggregate capture to `CAPTURE=Value`
on this layout (confirmed 2026-09-15) — i.e. every analog/encoder channel
goes raw together, not a subset. This means the current PandA layout
cannot simultaneously produce Mode A's aggregate (Min/Max/Mean) data — see
"Open question" below.
- **Continuous gate/arm**: `CLOCK1`, enabled by the constant `ONE`, drives
`PCAP`'s `gate` and `trig` inputs continuously (instead of the external
per-point trigger used in Mode A), so PCAP free-runs at `CLOCK1`'s
configured rate. Testing started at **100 Hz** on 2026-09-15 and will be
ramped up; there is no PandA-side layout change needed to change the
rate, only `CLOCK1`'s period.
```
TTLIN1 (TTL input 1, detector trigger)
├──▶ COUNTER1.trig (Up/Down pulse counter) ──▶ COUNTER1.OUT = frame_counter (CAPTURE=Value)
└──▶ captured into PCAP.BITS0 bit 0 ──▶ masked in software = gate_detector_active
CLOCK1 (enabled by constant ONE) ──▶ PCAP.gate, PCAP.trig (continuous arm/capture)
```
## Saving / restoring layouts
@@ -103,20 +132,30 @@ in sync as a single source of truth:
| `INENC3.VAL.Min`/`.Max`/`.Mean` | A | `interf_st_rotz_min`/`_max`/`_mean` |
| `INENC4.VAL.Min`/`.Max`/`.Mean` | A | `interf_st_rotx_min`/`_max`/`_mean` |
| `PCAP.GATE_DURATION.Value` | A | `pcap_gate_duration_value` |
| `PCAP.BITS0.Value` (**TODO: confirm bit block**) | B | `gate_detector_active` |
| `COUNTER1.OUT.Value` (**TODO: confirm counter block**) | B | `frame_counter` |
| `PCAP.BITS0.Value` (bit 0 = `TTLIN1.VAL`, confirmed 2026-09-15; masked in software, see above) | B | `gate_detector_active` |
| `COUNTER1.OUT.Value` (confirmed 2026-09-15, free-running off `TTLIN1.VAL`) | B | `frame_counter` |
The BEC-side data schema produced by Mode B (what ends up in the HDF5 async
dataset) is documented separately in
`docs/developer/panda_box_free_running_data_schema.md`.
## Open question to resolve once hardware is available
## Open question, now partially resolved
Can both modes coexist under a single static PandA layout — i.e. is mode
switching purely the `raw_stream_mode` software flag, with the PandA always
capturing both the aggregate and raw fields simultaneously — or do Mode A
and Mode B require two distinct saved layouts that must be loaded before a
scan depending on the desired mode? If two layouts are required,
`raw_stream_mode` alone is necessary but not sufficient to switch modes, and
the operator must also load the matching PandA layout beforehand — update
this document with the answer and, if applicable, the load procedure.
scan depending on the desired mode?
As currently wired for testing (2026-09-15), the answer looks like **two
distinct layouts are required**: `FMC_IN.VAL1/VAL2` and `INENC1`-`4.VAL` are
each set to `CAPTURE=Value` (single selection), not simultaneously
`CAPTURE=Value,Min,Max,Mean` — so this layout cannot also produce Mode A's
aggregate statistics at the same time. This has not yet been deliberately
tested against the alternative (whether the PandA capture UI actually
supports selecting `Value` together with `Min`/`Max`/`Mean` on the same
field) — if that turns out to work, a single layout could serve both modes.
Once Mode B testing is far enough along to save a named layout (see
"Saving / restoring layouts" below), settle this explicitly and record the
answer plus, if two layouts are needed, the load procedure to switch between
them.
+19
View File
@@ -197,6 +197,25 @@ def test_panda_omny_raw_stream_coalesces_by_row_count(panda_omny_raw_stream):
assert [v for v in out["gate_detector_active"]["value"]] == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
def test_panda_omny_raw_stream_gate_bit_is_masked_from_shared_word(panda_omny_raw_stream):
"""PCAP.BITS0 is a 32-bit word shared with other captured bit_out signals; only bit 0
(TTLIN1.VAL, confirmed via CAPTURE_WORD?/OFFSET? against the omny-panda hardware) is the
detector-trigger gate. gate_detector_active must be masked down to that bit rather than
passed through as the raw word."""
dev = panda_omny_raw_stream
data = np.array(
# bit 0 set (gate active) plus other bits from something else sharing BITS0
[(0.0, 5.0), (1.0, 4.0), (2.0, 3.0)],
dtype=[("COUNTER1.OUT.Value", "<f8"), ("PCAP.BITS0.Value", "<f8")],
)
with mock.patch.object(dev.data, "put") as mock_put:
dev._receive_frame_data(FrameData(data))
dev._flush_raw_stream_buffer()
out = mock_put.call_args.args[0]
assert [v for v in out["gate_detector_active"]["value"]] == [1, 0, 1]
def test_panda_omny_raw_stream_flush_on_demand(panda_omny_raw_stream):
"""Buffered data below the threshold must still be flushed on demand (as on_complete/
on_unstage do), and flushing an empty buffer must not publish again."""