aa06fa1460
A multi-angle scan runs for hours, but every angle was referenced against
one background captured before the first row of the first angle. That
reference has drifted by the last angle, and comparing angles — the whole
point of a multi-angle scan — was comparing each one against a noise floor
measured at whichever angle came first.
Every angle now captures its own. Before each angle's rows, the operator is
prompted to switch the Genesis laser off, the engine averages a fresh CH1
record, and the operator switches it back on. The data block therefore reads
[background][scan][background][scan] …, one pair per angle.
Format v7 (scan) and v11 (SAW check) carry the background inside the data
block, one length-prefixed block ahead of each angle's rows; the single
block that sat between the preambles and the data is gone. Per-angle offsets
now come from a walk of the data block at parse time rather than arithmetic
over the geometry table, and an angle whose background is not fully on disk
is the frontier — nothing of it was written yet.
v6/v10 files still read: SrasFile hands their one background to every angle,
so readers never branch on the version. Nothing writes them, and a resume
refuses them, since a re-acquired angle writes a block the old layout has no
room for. A resumed v7 angle rewrites its background in place, and the
engine checks the new block fits the room the file has before writing it —
anything else would shift every row behind it.
Two fixes made along the way:
* QtScanController never accepted file_version, so every scan launched
from the app raised TypeError at construction.
* angle_status() left its cursor parked at the frontier, so every angle
past it reported the frontier's own data_offset — which handed a resumed
scan the same write position for several angles. Two recorded offsets in
tests/golden/sras_expected.json are corrected accordingly.
The v6 goldens stay as parser fixtures; the writer is now locked against
bytes the test lays out from scan_format.md itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
689 lines
30 KiB
Python
689 lines
30 KiB
Python
"""Headless SRAS scan engine.
|
||
|
||
Takes plain hardware drivers, a ScanPlan, and callbacks — no Qt, no
|
||
widgets. ``run()`` blocks, so the caller owns the thread; GUIs wrap this
|
||
with gui.scan_bridge.QtScanController, which adapts the callbacks to Qt
|
||
signals. A CLI or a simpler GUI can drive the same engine with nothing but
|
||
functions.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import threading
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Callable
|
||
|
||
from core import scope_burst, scope_sras
|
||
from core.rotation import RotationAxis
|
||
from core.scan_geometry import ScanPlan, validate_plan
|
||
from core.sras_format import (
|
||
BG_LEN_SIZE, SCAN_CHANNELS, VERSION, create_scan_file,
|
||
write_background_block,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SCAN_VELOCITY_MM_S = 100.0
|
||
SCAN_ACCEL_MM_S2 = 1500.0
|
||
LASER_FREQ_HZ = 20000.0 # laser pulse frequency during data acquisition
|
||
# Theoretical ramp distance: d = v² / (2a) = 100² / (2×1500) ≈ 3.33 mm
|
||
SCAN_RAMP_MM = SCAN_VELOCITY_MM_S**2 / (2.0 * SCAN_ACCEL_MM_S2)
|
||
# Extra buffer added to both ends of the ramp. The BBD202 controller begins
|
||
# decelerating slightly before the theoretical point to avoid overshoot,
|
||
# which drops TRIGOUT_MAXV early and clips the last few data points.
|
||
SCAN_RAMP_BUFFER_MM = 1.0
|
||
|
||
AXIS_X = 0x21
|
||
AXIS_Y = 0x22
|
||
|
||
|
||
class ScanAborted(Exception):
|
||
"""Raised inside the engine thread to unwind a scan cleanly."""
|
||
|
||
|
||
@dataclass
|
||
class ResumeTarget:
|
||
"""One angle selected for (re)acquisition in an existing file.
|
||
|
||
``bg_offset`` is where the angle's background block starts and
|
||
``data_offset`` where its rows do; the gap between them is the room the
|
||
file already has for a background, which a re-acquired angle must fill
|
||
exactly or every row behind it would shift.
|
||
"""
|
||
angle_idx: int
|
||
bg_offset: int
|
||
data_offset: int
|
||
n_rows: int
|
||
angle_deg: float
|
||
|
||
|
||
@dataclass
|
||
class ResumeState:
|
||
path: Path
|
||
targets: list[ResumeTarget]
|
||
samples_per_frame: int
|
||
|
||
@property
|
||
def target_indices(self) -> set[int]:
|
||
return {t.angle_idx for t in self.targets}
|
||
|
||
|
||
@dataclass
|
||
class ScanCallbacks:
|
||
"""Progress reporting hooks. Every one is optional."""
|
||
on_status: Callable[[str], None] = lambda msg: None
|
||
on_started: Callable[[], None] = lambda: None
|
||
on_row_started: Callable[[int, int, int, int], None] = lambda r, nr, a, na: None
|
||
on_row_done: Callable[[int, int, int, int], None] = lambda r, nr, a, na: None
|
||
on_dc_bias: Callable[[int, list], None] = lambda row, means: None
|
||
on_paused_changed: Callable[[bool], None] = lambda paused: None
|
||
# Blocking operator prompt: must not return until acknowledged.
|
||
prompt: Callable[[str, str], None] = lambda title, msg: None
|
||
|
||
|
||
@dataclass
|
||
class ScanResult:
|
||
path: Path
|
||
rows_written: int = 0
|
||
aborted: bool = False
|
||
angles_acquired: list[int] = field(default_factory=list)
|
||
|
||
|
||
class ScanEngine:
|
||
"""Runs a full SRAS acquisition: stage, rotation, scope, and file output.
|
||
|
||
Constructed with the concrete drivers (not worker/queue wrappers), so
|
||
any front end can reuse it::
|
||
|
||
engine = ScanEngine(stage, scope, rotator, plan, out_path,
|
||
callbacks=ScanCallbacks(on_status=print))
|
||
result = engine.run() # blocking
|
||
"""
|
||
|
||
def __init__(self, stage, scope, rotator: RotationAxis | None,
|
||
plan: ScanPlan, out_path: Path,
|
||
resume: ResumeState | None = None,
|
||
callbacks: ScanCallbacks | None = None,
|
||
burst_mode: bool = False, strict_rows: bool = False,
|
||
file_version: int = VERSION):
|
||
self._stage = stage
|
||
self._scope = scope
|
||
self._rotator = rotator
|
||
self._plan = plan
|
||
self._out_path = Path(out_path)
|
||
self._resume = resume
|
||
self._cb = callbacks if callbacks is not None else ScanCallbacks()
|
||
# Burst mode acquires as many whole rows per FastFrame acquisition as
|
||
# the scope's frame memory holds, instead of one row per acquisition.
|
||
self._burst_mode = burst_mode
|
||
# Strict row packing stops the scan on a frame-count mismatch
|
||
# instead of squaring the row up (see _check_frame_delta).
|
||
self._strict_rows = strict_rows
|
||
# Which kind of file this run produces. The acquisition is identical
|
||
# either way; VERSION_SAW_CHECK only marks a one-row-per-angle plan
|
||
# (core.saw_check) as the quality check it is, so a reader does not
|
||
# mistake it for a scan that aborted after its first row.
|
||
self._file_version = file_version
|
||
self._max_frames = 0
|
||
self._preflight_done = False
|
||
|
||
self._abort = threading.Event()
|
||
self._resume_event = threading.Event()
|
||
self._resume_event.set() # set = running, cleared = pause requested
|
||
|
||
# ── External control (thread-safe) ────────────────────────────────────────
|
||
|
||
def abort(self):
|
||
self._abort.set()
|
||
self._resume_event.set() # unblock a paused scan so it can exit
|
||
|
||
def pause(self):
|
||
"""Request a pause; takes effect at the next row boundary."""
|
||
self._resume_event.clear()
|
||
|
||
def resume(self):
|
||
self._resume_event.set()
|
||
|
||
@property
|
||
def aborted(self) -> bool:
|
||
return self._abort.is_set()
|
||
|
||
# ── Internals ─────────────────────────────────────────────────────────────
|
||
|
||
def _check_abort(self):
|
||
if self._abort.is_set():
|
||
raise ScanAborted("Scan aborted by user.")
|
||
|
||
def _pause_point(self):
|
||
"""Block here (between rows, hardware idle) while a pause is requested."""
|
||
if self._resume_event.is_set():
|
||
self._check_abort()
|
||
return
|
||
self._cb.on_status(
|
||
"Scan paused — lasers may be switched off. "
|
||
"Turn lasers back on before resuming."
|
||
)
|
||
self._cb.on_paused_changed(True)
|
||
while not self._resume_event.wait(0.2):
|
||
if self._abort.is_set():
|
||
break
|
||
self._cb.on_paused_changed(False)
|
||
self._check_abort()
|
||
self._cb.on_status("Scan resumed.")
|
||
|
||
def _prompt(self, title: str, message: str):
|
||
self._cb.prompt(title, message)
|
||
self._check_abort()
|
||
|
||
# ── Main sequence ─────────────────────────────────────────────────────────
|
||
|
||
def run(self) -> ScanResult:
|
||
"""Execute the scan. Blocking; returns a ScanResult.
|
||
|
||
Raises ScanGeometryError for an unrunnable plan, RuntimeError for
|
||
missing/mismatched hardware, or ScanAborted if the operator aborts.
|
||
"""
|
||
plan = self._plan
|
||
per_angle = plan.per_angle
|
||
n_angles = plan.n_angles
|
||
result = ScanResult(path=self._out_path)
|
||
|
||
validate_plan(plan, SCAN_RAMP_MM, SCAN_RAMP_BUFFER_MM)
|
||
|
||
geometry_summary = ", ".join(
|
||
f"{pa.angle_deg:.1f}°: {pa.n_rows} row(s) × {pa.n_frames} pts/row"
|
||
for pa in per_angle
|
||
)
|
||
self._cb.on_status(
|
||
f"Scan geometry: {n_angles} angle(s), {plan.total_rows} row(s) total "
|
||
f"(per-angle bounding box) | save → {self._out_path.parent}\n"
|
||
f"{geometry_summary}"
|
||
)
|
||
self._cb.on_started()
|
||
|
||
if self._stage is None:
|
||
raise RuntimeError("BBD202 not connected")
|
||
if self._scope is None:
|
||
raise RuntimeError("Oscilloscope not connected")
|
||
rotator_ready = self._rotator is not None and self._rotator.is_available
|
||
if n_angles > 1 and not rotator_ready:
|
||
raise RuntimeError(
|
||
f"NumAngles={n_angles} requires the T3R rotation stage (GR-axis), "
|
||
"but it is not connected. Connect T3R from the T3R panel before "
|
||
"starting a multi-angle scan, or set NumAngles to 1."
|
||
)
|
||
|
||
if rotator_ready:
|
||
s = self._rotator.settings
|
||
self._cb.on_status(
|
||
f"Configuring GR axis: {s.microsteps} µsteps, "
|
||
f"{s.run_current_ma}/{s.hold_current_ma} mA run/hold …"
|
||
)
|
||
self._rotator.configure()
|
||
time.sleep(0.2)
|
||
|
||
self._prepare_stage()
|
||
samples_per_frame = self._prepare_scope()
|
||
scan_file = self._open_output(samples_per_frame, result)
|
||
|
||
try:
|
||
self._scan_loop(scan_file, samples_per_frame, result)
|
||
finally:
|
||
scan_file.close()
|
||
# Leave the X trigger output inactive. Burst mode toggles it every
|
||
# row and could exit from either state; the per-row path used to
|
||
# leave TRIGOUT_MAXV armed for the rest of the session, which keeps
|
||
# driving the gate line on every later jog.
|
||
try:
|
||
self._stage.set_trigger_gate_off(AXIS_X)
|
||
except Exception:
|
||
logger.exception("Could not return the X trigger output to idle")
|
||
# Return the GR axis home regardless of abort or error
|
||
if rotator_ready and abs(self._rotator.current_deg) > 0.001:
|
||
self._cb.on_status("Returning GR to home …")
|
||
try:
|
||
self._rotator.return_to_zero()
|
||
except Exception:
|
||
logger.exception("GR return-to-home failed")
|
||
|
||
if self._abort.is_set():
|
||
result.aborted = True
|
||
raise ScanAborted("Scan aborted by user.")
|
||
self._cb.on_status("Scan complete.")
|
||
return result
|
||
|
||
def _prepare_stage(self):
|
||
ctrl = self._stage
|
||
self._cb.on_status("Enabling stage axes …")
|
||
if not ctrl.am_enabled[0]:
|
||
ctrl.enable_axis(AXIS_X)
|
||
if not ctrl.am_enabled[1]:
|
||
ctrl.enable_axis(AXIS_Y)
|
||
time.sleep(0.2)
|
||
|
||
if not ctrl.am_homed[0] or not ctrl.am_homed[1]:
|
||
self._cb.on_status("Homing stage (may take up to 2 min) …")
|
||
if not ctrl.am_homed[0]:
|
||
ctrl.home_axis(AXIS_X, timeout=120.0)
|
||
if not ctrl.am_homed[1]:
|
||
ctrl.home_axis(AXIS_Y, timeout=120.0)
|
||
|
||
self._cb.on_status("Setting scan velocity …")
|
||
ctrl.set_velocity_params(AXIS_X, max_velocity=SCAN_VELOCITY_MM_S,
|
||
acceleration=SCAN_ACCEL_MM_S2)
|
||
ctrl.set_velocity_params(AXIS_Y, max_velocity=SCAN_VELOCITY_MM_S,
|
||
acceleration=SCAN_ACCEL_MM_S2)
|
||
# X trigger: logic-high output while the stage is at maximum velocity.
|
||
# Burst mode arms it per acquiring pass instead — a burst spans several
|
||
# rows with the scope running throughout, so leaving it armed would let
|
||
# the flyback trigger frames between rows.
|
||
if self._burst_mode:
|
||
ctrl.arm_scan_gate(AXIS_X, False)
|
||
else:
|
||
ctrl.set_trigger_trigout_maxv(AXIS_X)
|
||
|
||
def _prepare_scope(self) -> int:
|
||
self._cb.on_status("Configuring oscilloscope …")
|
||
samples_per_frame = scope_sras.configure_acquisition(self._scope)
|
||
|
||
if self._resume is None:
|
||
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS)
|
||
else:
|
||
# Resuming: the file's channel preambles are reused as-is (the
|
||
# format has no way to replace them without rewriting the whole
|
||
# file). Each re-acquired angle still captures its own fresh
|
||
# background, which is rewritten in place over the old one.
|
||
# Sanity-check that this scope still produces the same record
|
||
# length the file was started with — a mismatch would silently
|
||
# corrupt the ragged per-row byte layout on append.
|
||
if samples_per_frame != self._resume.samples_per_frame:
|
||
raise RuntimeError(
|
||
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
|
||
f"does not match the {self._resume.samples_per_frame} samples/frame "
|
||
f"this scan file was started with — cannot safely resume."
|
||
)
|
||
targets = ", ".join(str(t.angle_idx + 1) for t in self._resume.targets)
|
||
self._prompt(
|
||
"Resume Scan",
|
||
f"Resuming {self._resume.path.name} — will (re)acquire "
|
||
f"angle(s) {targets} of {self._plan.n_angles}.\n\n"
|
||
"Please re-home the GR axis to 0° before continuing — the scan "
|
||
"will rotate it directly from angle to angle before scanning resumes.\n\n"
|
||
"Each angle begins with its own background capture, so you will "
|
||
"be asked to switch the Genesis laser off and on again per angle."
|
||
)
|
||
|
||
scope_sras.configure_scan_trigger(self._scope)
|
||
|
||
if self._burst_mode:
|
||
# Horizontal settings are fixed by now, so the capacity is stable
|
||
# for the whole scan; only rows-per-burst varies (n_frames is
|
||
# per-angle).
|
||
self._max_frames = scope_burst.max_frames(self._scope)
|
||
self._cb.on_status(
|
||
f"Burst mode: scope holds {self._max_frames} frames "
|
||
f"({samples_per_frame} samples/frame)"
|
||
)
|
||
return samples_per_frame
|
||
|
||
def _open_output(self, samples_per_frame: int, result: ScanResult):
|
||
if self._resume is not None:
|
||
result.path = self._resume.path
|
||
self._cb.on_status(
|
||
f"Resuming {self._resume.path.name} — "
|
||
f"{len(self._resume.targets)} angle(s) to (re)acquire …"
|
||
)
|
||
return open(self._resume.path, "r+b")
|
||
return create_scan_file(
|
||
self._out_path, self._plan, samples_per_frame,
|
||
scope_sras.SAMPLE_RATE_HZ, self._preambles,
|
||
version=self._file_version,
|
||
)
|
||
|
||
def _scan_loop(self, scan_file, samples_per_frame: int, result: ScanResult):
|
||
plan = self._plan
|
||
n_angles = plan.n_angles
|
||
x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
||
scope = self._scope
|
||
|
||
targets_by_ai = None
|
||
if self._resume is not None:
|
||
targets_by_ai = {t.angle_idx: t for t in self._resume.targets}
|
||
|
||
# Both fresh and resumed scans assume the GR axis starts at home (0°)
|
||
# — the resume prompt instructs the operator to re-home it — so the
|
||
# first move always rotates directly from 0° to the starting angle.
|
||
for ai, pa in enumerate(plan.per_angle):
|
||
if targets_by_ai is not None and ai not in targets_by_ai:
|
||
continue # not selected for (re)acquisition
|
||
self._pause_point()
|
||
|
||
target = None if targets_by_ai is None else targets_by_ai[ai]
|
||
if target is not None:
|
||
# Interior angles may already have valid data on either side,
|
||
# so seek to this angle's fixed offset rather than relying on
|
||
# the file's current position.
|
||
scan_file.seek(target.bg_offset)
|
||
|
||
if self._rotator is not None and self._rotator.is_available:
|
||
delta = pa.angle_deg - self._rotator.current_deg
|
||
if abs(delta) > 0.001:
|
||
self._cb.on_status(
|
||
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
|
||
self._rotator.rotate_to(pa.angle_deg)
|
||
|
||
self._write_angle_background(scan_file, ai, n_angles,
|
||
pa.angle_deg, target)
|
||
|
||
if self._burst_mode:
|
||
# Burst mode sizes the FastFrame count from the scope's whole
|
||
# capacity instead (see scope_burst.start_burst), so there is
|
||
# nothing to re-arm per angle here.
|
||
self._scan_rows_burst(scan_file, pa, ai, n_angles,
|
||
samples_per_frame, result, x_ramp_total)
|
||
else:
|
||
# Each angle's bounding box gives it its own points/row count,
|
||
# so the scope's FastFrame count must be re-armed per angle.
|
||
scope.set_fastframe_count(pa.n_frames)
|
||
self._scan_rows_serial(scan_file, pa, ai, n_angles,
|
||
samples_per_frame, result, x_ramp_total)
|
||
|
||
result.angles_acquired.append(ai)
|
||
|
||
def _write_angle_background(self, scan_file, ai: int, n_angles: int,
|
||
angle_deg: float, target: ResumeTarget | None):
|
||
"""Capture this angle's background and write it ahead of its rows.
|
||
|
||
The Genesis laser has to be off for the capture and back on for the
|
||
scan, so every angle costs two operator prompts and one averaged
|
||
record. That buys a background taken minutes from the data it will
|
||
be subtracted from, instead of one taken hours earlier at angle 1.
|
||
|
||
On resume the block is overwritten in place, so it has to be exactly
|
||
as long as the one already there — anything else would shift every
|
||
row behind it. Checked before the write, not after.
|
||
"""
|
||
scope = self._scope
|
||
scope_sras.configure_background_trigger(scope)
|
||
self._prompt(
|
||
f"Background Capture — Angle {ai + 1}/{n_angles}",
|
||
f"Angle {ai + 1} of {n_angles} ({angle_deg:.1f}°) starts with its "
|
||
"own background capture.\n\n"
|
||
"Please switch the Genesis laser OFF — leave the Helios laser ON —\n"
|
||
"then click OK to capture the background waveform."
|
||
)
|
||
background = scope_sras.capture_background(
|
||
scope, should_abort=self._abort.is_set, on_status=self._cb.on_status)
|
||
self._prompt(
|
||
f"Begin Angle {ai + 1}/{n_angles}",
|
||
"Background captured successfully.\n\n"
|
||
"Please switch the Genesis laser back ON,\n"
|
||
f"then click OK to scan angle {ai + 1} of {n_angles}."
|
||
)
|
||
scope_sras.configure_scan_trigger(scope)
|
||
|
||
if target is not None:
|
||
room = target.data_offset - target.bg_offset
|
||
if BG_LEN_SIZE + len(background) != room:
|
||
raise RuntimeError(
|
||
f"Angle {ai + 1}: the new background block is "
|
||
f"{BG_LEN_SIZE + len(background)} bytes but the file has room "
|
||
f"for {room} — writing it would shift every row behind it, "
|
||
"so the scan stops here."
|
||
)
|
||
write_background_block(scan_file, background)
|
||
|
||
# ── Per-row acquisition (one FastFrame acquisition per row) ───────────────
|
||
|
||
def _scan_rows_serial(self, scan_file, pa, ai: int, n_angles: int,
|
||
samples_per_frame: int, result: ScanResult,
|
||
x_ramp_total: float):
|
||
ctrl = self._stage
|
||
scope = self._scope
|
||
|
||
for ri, y_pos in enumerate(pa.y_positions):
|
||
self._pause_point()
|
||
|
||
self._cb.on_row_started(ri + 1, pa.n_rows, ai + 1, n_angles)
|
||
self._cb.on_status(
|
||
f"Angle {ai+1}/{n_angles} Row {ri+1}/{pa.n_rows} "
|
||
f"(Y={y_pos:.3f} mm)"
|
||
)
|
||
|
||
# Position the stage one ramp-length + buffer before the data
|
||
# window so it is at full velocity before x_start.
|
||
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
|
||
ctrl.move_axis_absolute(AXIS_X, pa.x_start - x_ramp_total, timeout=30.0)
|
||
|
||
scope_sras.arm_row(scope)
|
||
|
||
# Data window + ramp + buffer run-off, so the stage does not
|
||
# begin decelerating before the last point.
|
||
x_end = pa.x_start + pa.x_delta + x_ramp_total
|
||
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||
|
||
scope_sras.finish_row(scope)
|
||
self._write_row(scan_file, samples_per_frame, ri, pa.n_frames)
|
||
|
||
result.rows_written += 1
|
||
self._cb.on_row_done(ri + 1, pa.n_rows, ai + 1, n_angles)
|
||
|
||
def _write_row(self, scan_file, samples_per_frame: int, row_idx: int,
|
||
n_frames: int):
|
||
"""Stream every channel from the scope into the file.
|
||
|
||
CH3 is the max-vel gate signal — no useful waveform data — so zeroed
|
||
frames are written to keep the file layout intact.
|
||
"""
|
||
scope = self._scope
|
||
ch_bytes = n_frames * samples_per_frame
|
||
for ch in SCAN_CHANNELS:
|
||
if ch == 3:
|
||
self._cb.on_status("Writing zeroed CH3 frames …")
|
||
scan_file.write(bytes(ch_bytes))
|
||
continue
|
||
|
||
self._cb.on_status(f"Fetching CH{ch} data …")
|
||
waveforms = scope_sras.transfer_channel(scope, ch)
|
||
if ch == SCAN_CHANNELS[0]:
|
||
self._check_frame_delta(row_idx, len(waveforms), n_frames)
|
||
row = scope_burst.normalize_row(
|
||
b"".join(waveforms), 0, len(waveforms), n_frames, samples_per_frame)
|
||
if ch == 4:
|
||
self._cb.on_dc_bias(row_idx + 1, scope_burst.frame_means_block(
|
||
row, 0, n_frames, samples_per_frame))
|
||
scan_file.write(row)
|
||
|
||
# ── Burst acquisition (many whole rows per FastFrame acquisition) ─────────
|
||
|
||
def _scan_rows_burst(self, scan_file, pa, ai: int, n_angles: int,
|
||
samples_per_frame: int, result: ScanResult,
|
||
x_ramp_total: float):
|
||
"""Acquire the angle in bursts of as many whole rows as the scope holds.
|
||
|
||
One ACQuire:STATE RUN spans the whole burst, so the gate is armed only
|
||
for each acquiring pass and dropped for the flyback — otherwise the
|
||
return move would reach max velocity and inject frames between rows.
|
||
"""
|
||
scope = self._scope
|
||
n_frames = pa.n_frames
|
||
x_lead_in = pa.x_start - x_ramp_total
|
||
x_end = pa.x_start + pa.x_delta + x_ramp_total
|
||
|
||
if not self._preflight_done:
|
||
# Once per scan: the gate wiring can't change between angles, and
|
||
# the check costs two row-times.
|
||
self._gate_off_preflight(x_lead_in, x_end)
|
||
self._preflight_done = True
|
||
|
||
row = 0
|
||
while row < pa.n_rows:
|
||
self._pause_point()
|
||
n_burst = scope_burst.rows_per_burst(
|
||
self._max_frames, n_frames, samples_per_frame, pa.n_rows - row)
|
||
self._cb.on_status(
|
||
f"Angle {ai+1}/{n_angles} Rows {row+1}-{row+n_burst}/{pa.n_rows} "
|
||
f"in one acquisition ({n_burst * n_frames} frames) …"
|
||
)
|
||
|
||
burst_start = scan_file.tell()
|
||
cumulative = []
|
||
baseline = scope_burst.start_burst(scope, self._max_frames)
|
||
try:
|
||
for r in range(n_burst):
|
||
self._check_abort()
|
||
self._cb.on_row_started(row + r + 1, pa.n_rows,
|
||
ai + 1, n_angles)
|
||
self._acquire_gated_row(pa.y_positions[row + r],
|
||
x_lead_in, x_end)
|
||
total = scope_burst.frames_acquired(scope)
|
||
if total >= self._max_frames:
|
||
raise RuntimeError(
|
||
f"FastFrame buffer full ({total}/{self._max_frames} "
|
||
f"frames) at row {row + r + 1} — later rows in this "
|
||
"burst would be misattributed. Raise "
|
||
"scope_burst.BURST_FRAME_HEADROOM and rerun."
|
||
)
|
||
cumulative.append(total - baseline)
|
||
finally:
|
||
scope_burst.stop_burst(scope)
|
||
|
||
counts = scope_burst.split_row_counts(cumulative)
|
||
self._write_burst(scan_file, burst_start, row, counts,
|
||
n_frames, samples_per_frame)
|
||
|
||
for r in range(n_burst):
|
||
result.rows_written += 1
|
||
self._cb.on_row_done(row + r + 1, pa.n_rows, ai + 1, n_angles)
|
||
row += n_burst
|
||
|
||
def _acquire_gated_row(self, y_pos: float, x_lead_in: float, x_end: float):
|
||
"""One row: step Y, fly back gated off, then acquire on the +X pass."""
|
||
ctrl = self._stage
|
||
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
|
||
ctrl.move_axis_absolute(AXIS_X, x_lead_in, timeout=30.0)
|
||
ctrl.arm_scan_gate(AXIS_X, True)
|
||
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||
ctrl.arm_scan_gate(AXIS_X, False)
|
||
time.sleep(scope_burst.BURST_ROW_SETTLE_S)
|
||
|
||
def _gate_off_preflight(self, x_lead_in: float, x_end: float):
|
||
"""Prove the gate really gates before trusting a multi-row burst.
|
||
|
||
The value that makes the BBD trigger output idle low is not settled by
|
||
the protocol docs (see apt_constants.TRIGOUT_GATE_OFF), and getting it
|
||
wrong fills every burst with flyback frames that silently shift the
|
||
file. The scope already measures the gate on CH3, so this needs no
|
||
bench probe: one gated-off flyback must acquire nothing, and one gated
|
||
pass must acquire something — the second half is what stops a dark
|
||
laser from making the first half pass vacuously.
|
||
|
||
Leaves the stage parked at x_end, where the burst loop expects it.
|
||
"""
|
||
ctrl, scope = self._stage, self._scope
|
||
self._cb.on_status("Burst preflight: checking the stage gate …")
|
||
|
||
ctrl.arm_scan_gate(AXIS_X, False)
|
||
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||
baseline = scope_burst.start_burst(scope, self._max_frames)
|
||
ctrl.move_axis_absolute(AXIS_X, x_lead_in, timeout=120.0)
|
||
scope_burst.stop_burst(scope)
|
||
leaked = scope_burst.frames_acquired(scope) - baseline
|
||
|
||
ctrl.arm_scan_gate(AXIS_X, True)
|
||
baseline = scope_burst.start_burst(scope, self._max_frames)
|
||
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||
ctrl.arm_scan_gate(AXIS_X, False)
|
||
scope_burst.stop_burst(scope)
|
||
gated = scope_burst.frames_acquired(scope) - baseline
|
||
|
||
if gated <= 0:
|
||
raise RuntimeError(
|
||
"Burst preflight: no frames acquired with the gate armed. "
|
||
"Check that the Genesis laser is pulsing (CH2) and that the "
|
||
"BBD X trigger output reaches CH3 before scanning."
|
||
)
|
||
if leaked:
|
||
raise RuntimeError(
|
||
f"Burst preflight: {leaked} frame(s) acquired during a flyback "
|
||
"that should have been gated off — the BBD trigger output is "
|
||
"not idling low. Set apt_constants.TRIGOUT_GATE_OFF to "
|
||
"TriggerBitsServo.TRIGOUT_HIGH and retry, or use per-row "
|
||
"acquisition."
|
||
)
|
||
self._cb.on_status(
|
||
f"Burst preflight OK ({gated} frames gated on, 0 leaked).")
|
||
|
||
def _write_burst(self, scan_file, burst_start: int, first_row: int,
|
||
counts: list[int], n_frames: int, samples_per_frame: int):
|
||
"""Deinterleave one burst into the file's per-row, per-channel blocks.
|
||
|
||
The wire is channel-major (every row of CH1, then every row of CH4);
|
||
the file is row-major with channels inner. Writing one channel at a
|
||
time to strided offsets keeps peak memory at a single channel's burst
|
||
instead of the whole thing.
|
||
"""
|
||
scope = self._scope
|
||
ch_bytes = n_frames * samples_per_frame
|
||
row_bytes = len(SCAN_CHANNELS) * ch_bytes
|
||
total_frames = sum(counts)
|
||
|
||
for r, count in enumerate(counts):
|
||
self._check_frame_delta(first_row + r, count, n_frames)
|
||
|
||
for ch_idx, ch in enumerate(SCAN_CHANNELS):
|
||
if ch == 3:
|
||
self._cb.on_status("Writing zeroed CH3 frames …")
|
||
blob = None
|
||
else:
|
||
self._cb.on_status(
|
||
f"Fetching CH{ch} burst ({total_frames} frames) …")
|
||
blob = scope_burst.transfer_burst(scope, ch, total_frames,
|
||
samples_per_frame)
|
||
src = 0
|
||
zeros = bytes(ch_bytes) if blob is None else None
|
||
for r, count in enumerate(counts):
|
||
scan_file.seek(burst_start + r * row_bytes + ch_idx * ch_bytes)
|
||
if blob is None:
|
||
scan_file.write(zeros)
|
||
else:
|
||
row = scope_burst.normalize_row(
|
||
blob, src, count, n_frames, samples_per_frame)
|
||
if ch == 4:
|
||
self._cb.on_dc_bias(
|
||
first_row + r + 1,
|
||
scope_burst.frame_means_block(
|
||
row, 0, n_frames, samples_per_frame))
|
||
scan_file.write(row)
|
||
src += count * samples_per_frame
|
||
del blob
|
||
|
||
scan_file.seek(burst_start + len(counts) * row_bytes)
|
||
|
||
def _check_frame_delta(self, row_idx: int, count: int, n_frames: int):
|
||
"""Decide what to do with a row that did not acquire n_frames frames.
|
||
|
||
v6 declares n_frames per row in the header and has no per-row length
|
||
field, so a mismatched row cannot just be written as-is — that would
|
||
shift every later row in the file. The only two safe options are to
|
||
square it up or to stop, which is what strict_rows selects between.
|
||
|
||
Called before anything for the row is written (CH1 leads
|
||
SCAN_CHANNELS), so raising here leaves no partial row behind.
|
||
"""
|
||
if count == n_frames:
|
||
return
|
||
verb = "zero-padded" if count < n_frames else "truncated"
|
||
if self._strict_rows:
|
||
raise RuntimeError(
|
||
f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
|
||
f"expected. Strict row packing is on, so the scan stops here "
|
||
f"rather than writing a row that would be {verb}."
|
||
)
|
||
msg = (f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
|
||
f"expected — {verb} to keep the file layout intact.")
|
||
logger.warning(msg)
|
||
self._cb.on_status(msg)
|