Files
scanengine-3/core/scan_engine.py
Thomas Ales 52fdcdd9f3 Make row packing toggleable: pad (default) or strict abort
A mis-triggered row cannot be written as it arrived — v6 declares n_frames per
row in the header and has no per-row length field, so a short or long row
would shift every later row in the file. Until now the only policy was to
square it up, which keeps the scan running but leaves the affected row
indistinguishable from a good one afterwards: nothing in the file records that
it was padded.

strict_rows selects the other trade. On any frame-count mismatch the scan
stops instead of writing the row, so a data run either produces rows that mean
what the header says they mean or fails loudly. Default stays pad, so existing
behaviour is unchanged.

_warn_frame_delta becomes _check_frame_delta, since it now decides rather than
just reports. Both acquisition paths already call it before writing anything
for the row (CH1 leads SCAN_CHANNELS, and the burst path checks every row up
front), so an abort leaves the file on a whole-row boundary rather than a
half-written row — test_strict_row_packing_writes_nothing_for_the_failed_row
pins that.

Plumbed through QtScanController to a checkbox in the scan panel, persisted in
ScanDefaults alongside burst_mode. scan_format.md documents both policies and
notes that the choice is not recorded in the file.

The row-clipping setup in the padding test is now a _clip_one_row helper,
reused by the strict tests. 92 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:32:09 -05:00

638 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 SCAN_CHANNELS, create_scan_file
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."""
angle_idx: 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):
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
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)
self._prompt(
"Background Capture",
"Please ensure the Helios laser is ON and the Genesis laser is OFF,\n"
"then click OK to capture the background waveform."
)
self._background = scope_sras.capture_background(
self._scope, should_abort=self._abort.is_set,
on_status=self._cb.on_status)
self._prompt(
"Begin Scanning",
"Background captured successfully.\n\n"
"Please ensure the Genesis laser is back ON,\n"
"then click OK to begin scanning."
)
else:
# Resuming: the file's existing background waveform and channel
# preambles are reused as-is (the format has no way to replace
# them without rewriting the whole file), so background capture is
# skipped. 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"
"Please ensure the Genesis laser is ON,\n"
"then click OK to continue scanning."
)
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, self._background,
)
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()
if targets_by_ai 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(targets_by_ai[ai].data_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)
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)
# ── 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)