150 lines
6.1 KiB
Python
Executable File
150 lines
6.1 KiB
Python
Executable File
"""Burst-mode FastFrame acquisition policy.
|
|
|
|
Per-row acquisition pays a full arm/stop/transfer round trip for every row,
|
|
and the transfer alone is one IEEE-488.2 block read per frame (~16k frames a
|
|
row). A burst instead runs one FastFrame acquisition across as many complete
|
|
rows as the scope's frame memory holds, then pulls the whole thing in a single
|
|
transaction — amortising the round trip over `rows_per_burst` rows.
|
|
|
|
The scope reports its capacity with ``HORizontal:FASTframe:MAXFRames?`` once
|
|
the horizontal settings are fixed; ``rows_per_burst`` turns that into a row
|
|
count. Everything here that computes rather than talks to hardware is a free
|
|
function, so the row-splitting logic is testable without a rig.
|
|
|
|
The catch is that the burst contains no row markers: the scope hands back one
|
|
flat run of frames. Boundaries come from polling ``ACQuire:NUMFRAMESACQuired?``
|
|
after each row's acquiring pass, while the stage gate is already low — see
|
|
``split_row_counts``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
import numpy as np
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Peak transfer buffer, per channel. The writer holds one channel at a time
|
|
# (see ScanEngine._scan_rows_burst), so this is the real high-water mark.
|
|
BURST_MEMORY_BUDGET_BYTES = 512 * 1024 * 1024
|
|
|
|
# Extra frames budgeted per row on top of n_frames. 0 gives the plain
|
|
# floor(max_frames / n_frames) row count; raise it if the acquiring pass
|
|
# routinely over-triggers (watch the pad/truncate warnings).
|
|
BURST_FRAME_HEADROOM = 0
|
|
|
|
BURST_ARM_SETTLE_S = 0.05 # after ACQuire:STATE RUN, before the first move
|
|
BURST_ROW_SETTLE_S = 0.05 # after the gate drops, before reading the counter
|
|
|
|
|
|
# ── Pure helpers ─────────────────────────────────────────────────────────────
|
|
|
|
def rows_per_burst(max_frames: int, n_frames: int, samples_per_frame: int,
|
|
rows_remaining: int,
|
|
memory_budget: int = BURST_MEMORY_BUDGET_BYTES,
|
|
headroom: int = BURST_FRAME_HEADROOM) -> int:
|
|
"""How many complete rows fit in one acquisition.
|
|
|
|
Rounds down — a partial row is worthless, since a row must be transferred
|
|
whole to be written. Clamped by the transfer buffer budget and by the rows
|
|
actually left in the angle, and never below 1 (a single row always goes,
|
|
even if it exceeds the budget, so the scan can still make progress).
|
|
"""
|
|
if n_frames < 1 or samples_per_frame < 1:
|
|
raise ValueError(f"n_frames={n_frames} samples_per_frame={samples_per_frame}")
|
|
|
|
by_scope = max_frames // (n_frames + headroom)
|
|
by_memory = memory_budget // (n_frames * samples_per_frame)
|
|
return max(1, min(by_scope, by_memory, rows_remaining))
|
|
|
|
|
|
def split_row_counts(cumulative: list[int]) -> list[int]:
|
|
"""Per-row frame counts from the cumulative counter sampled after each row.
|
|
|
|
``cumulative`` is ``ACQuire:NUMFRAMESACQuired?`` read once per row, already
|
|
rebased on the value at burst start.
|
|
"""
|
|
counts = []
|
|
prev = 0
|
|
for i, c in enumerate(cumulative):
|
|
if c < prev:
|
|
raise RuntimeError(
|
|
f"FastFrame counter went backwards at row {i} ({prev} → {c}) — "
|
|
"the acquisition was restarted mid-burst"
|
|
)
|
|
counts.append(c - prev)
|
|
prev = c
|
|
return counts
|
|
|
|
|
|
def normalize_row(buf, offset: int, count: int, n_frames: int,
|
|
samples_per_frame: int):
|
|
"""Coerce one row's frames to exactly ``n_frames``.
|
|
|
|
The v6 format commits to n_frames per row in the header and has no per-row
|
|
length field, so a row that over- or under-triggers must be squared up or
|
|
every later row in the file shifts. Short rows are zero-padded, long rows
|
|
lose their trailing frames. Returns something writable directly.
|
|
"""
|
|
want = n_frames * samples_per_frame
|
|
end = min(offset + count * samples_per_frame, offset + want, len(buf))
|
|
chunk = memoryview(buf)[offset:end]
|
|
if len(chunk) == want:
|
|
return chunk
|
|
return bytes(chunk) + bytes(want - len(chunk))
|
|
|
|
|
|
def frame_means_block(buf, offset: int, n_frames: int,
|
|
samples_per_frame: int) -> list[float]:
|
|
"""Per-frame DC mean over one row's slice of a burst buffer."""
|
|
n = n_frames * samples_per_frame
|
|
block = np.frombuffer(buf, dtype=np.int8, count=n, offset=offset)
|
|
return block.reshape(n_frames, samples_per_frame).mean(
|
|
axis=1, dtype=np.float32).tolist()
|
|
|
|
|
|
# ── Instrument control ───────────────────────────────────────────────────────
|
|
|
|
def max_frames(scope) -> int:
|
|
"""Frames the scope can hold under the current horizontal settings."""
|
|
try:
|
|
m = scope.get_fastframe_max_frames()
|
|
except Exception as exc:
|
|
raise RuntimeError(
|
|
"Scope did not answer HORizontal:FASTframe:MAXFRames? — burst mode "
|
|
"cannot size a burst without it. Use per-row acquisition on this "
|
|
f"firmware. ({exc})"
|
|
) from exc
|
|
if m < 1:
|
|
raise RuntimeError(f"Scope reports a FastFrame capacity of {m} frames")
|
|
return m
|
|
|
|
|
|
def start_burst(scope, frame_count: int) -> int:
|
|
"""Arm one burst; returns the counter baseline to subtract from later reads.
|
|
|
|
Reading the baseline back beats assuming the counter resets to 0 on RUN —
|
|
any residual is simply subtracted out instead of being misattributed to the
|
|
first row.
|
|
"""
|
|
scope.set_fastframe_count(frame_count)
|
|
scope.write("ACQuire:STATE RUN")
|
|
time.sleep(BURST_ARM_SETTLE_S)
|
|
return frames_acquired(scope)
|
|
|
|
|
|
def stop_burst(scope) -> None:
|
|
time.sleep(BURST_ROW_SETTLE_S)
|
|
scope.write("ACQuire:STATE STOP")
|
|
|
|
|
|
def frames_acquired(scope) -> int:
|
|
return int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
|
|
|
|
|
def transfer_burst(scope, ch: int, frame_count: int, samples_per_frame: int):
|
|
"""Pull a whole burst for one channel in a single CURVe? transaction."""
|
|
scope.set_data_source(ch)
|
|
return scope.transfer_fastframe_bulk(frame_count, samples_per_frame)
|