Phase 2: extract headless core modules (sras_format, scan_geometry, config)
- core/sras_format.py: THE v6 implementation — create_scan_file (writer, byte-identical to the old one, enforced against the Phase-0 goldens), SrasFile parser with frontier/truncation walk, and zero-copy mmap load_angle/load_row views for multi-GB files - core/scan_geometry.py: ScanPlan/AngleGeometry dataclasses, build_plan (rotated-bbox trig from MainWindow._build_scan_params), travel-limit validate_plan (limits now a StageLimits dataclass, not literals buried in the worker), format_eta + EtaEstimator (bounded deque) - core/config.py: ScanDefaults dataclass replaces the module-import-time dict globals. FIXES: editing any main-window port used to rewrite aui_defaults.json without helios_port, silently reverting the Helios port every time (test_helios_port_survives_partial_update covers it). Also drops the inert laser_freq_hz plumbing — scans always used the LASER_FREQ_HZ constant. - hardware/serial_util.py: shared 8N1 open + scored port enumeration (promoted from t3r_control_panel); helios_laser and the panel use it - sc3_aui_app.py and sras_scan_manager.py migrated onto core (three format implementations down to one); ScanWorker now takes a ScanPlan - tests: byte-identical writer vs golden, frontier over every truncation variant, mmap==eager, geometry vs golden fixtures + invariants, config round-trip. 28 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
"""Headless scan-engine core: importable without PyQt6 or any vendor SDK."""
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Persisted user defaults (ports, scope IP, save directory).
|
||||||
|
|
||||||
|
One flat JSON file, one dataclass. ``save()`` always writes every field, so
|
||||||
|
a partial UI update can never silently drop another field's saved value
|
||||||
|
(which is exactly what the old dict-based writer did to helios_port).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import asdict, dataclass, fields
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULTS_PATH = Path(__file__).resolve().parent.parent / "aui_defaults.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanDefaults:
|
||||||
|
t3r_port: str = "/dev/ttyUSB0"
|
||||||
|
bbd_port: str = "/dev/ttyUSB1"
|
||||||
|
oscope_ip: str = "192.168.0.1"
|
||||||
|
save_dir: str = str(DEFAULTS_PATH.parent / "scans")
|
||||||
|
helios_port: str = "/dev/ttyUSB2"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path = DEFAULTS_PATH) -> "ScanDefaults":
|
||||||
|
"""Load defaults, tolerating a missing/corrupt file and unknown keys."""
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
known = {f.name for f in fields(cls)}
|
||||||
|
return cls(**{k: v for k, v in data.items() if k in known})
|
||||||
|
except (OSError, ValueError, TypeError) as e:
|
||||||
|
logger.warning("Could not load %s (%s); using fallback defaults", path, e)
|
||||||
|
inst = cls()
|
||||||
|
inst.save(path)
|
||||||
|
return inst
|
||||||
|
|
||||||
|
def save(self, path: Path = DEFAULTS_PATH) -> None:
|
||||||
|
try:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(asdict(self), f, indent=2)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Could not save defaults to %s: %s", path, e)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Scan geometry planning: angle sequences, rotated bounding boxes, travel
|
||||||
|
limits, and ETA math. Pure Python — no Qt, no hardware.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
class ScanGeometryError(ValueError):
|
||||||
|
"""Scan geometry that cannot be executed (bad inputs or off-stage)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StageLimits:
|
||||||
|
"""Usable travel of the scanning stage in mm (MLS203-1)."""
|
||||||
|
x_min: float = 0.0
|
||||||
|
x_max: float = 110.0
|
||||||
|
y_min: float = 0.0
|
||||||
|
y_max: float = 75.0
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_STAGE_LIMITS = StageLimits()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AngleGeometry:
|
||||||
|
"""One rotation angle's scan extent — also the on-disk v6 geometry row."""
|
||||||
|
angle_deg: float
|
||||||
|
x_start: float
|
||||||
|
x_delta: float
|
||||||
|
n_frames: int
|
||||||
|
n_rows: int
|
||||||
|
y_positions: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanPlan:
|
||||||
|
x_start_nominal: float
|
||||||
|
y_start_nominal: float
|
||||||
|
x_delta_nominal: float
|
||||||
|
y_delta_nominal: float
|
||||||
|
row_spacing: float
|
||||||
|
velocity_mm_s: float
|
||||||
|
laser_freq_hz: float
|
||||||
|
per_angle: list[AngleGeometry] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def n_angles(self) -> int:
|
||||||
|
return len(self.per_angle)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def angles(self) -> list[float]:
|
||||||
|
return [pa.angle_deg for pa in self.per_angle]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_rows(self) -> int:
|
||||||
|
return sum(pa.n_rows for pa in self.per_angle)
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(x_start: float, y_start: float, x_delta: float, y_delta: float,
|
||||||
|
num_angles: int, row_spacing: float, *,
|
||||||
|
laser_freq_hz: float, velocity_mm_s: float,
|
||||||
|
rotation_sign: int = -1) -> ScanPlan:
|
||||||
|
"""Compute the per-angle scan geometry for a nominal ROI.
|
||||||
|
|
||||||
|
Each angle only needs to physically scan the bounding box of the nominal
|
||||||
|
(x_start, y_start, x_delta, y_delta) rectangle rotated by THAT angle --
|
||||||
|
not the worst case across all angles -- so the X extent (and therefore
|
||||||
|
points/row) and the row count are computed per angle.
|
||||||
|
"""
|
||||||
|
if x_delta <= 0:
|
||||||
|
raise ScanGeometryError("XD must be > 0")
|
||||||
|
if row_spacing <= 0:
|
||||||
|
raise ScanGeometryError("RowSpacing must be > 0")
|
||||||
|
if num_angles < 1:
|
||||||
|
raise ScanGeometryError("NumAngles must be ≥ 1")
|
||||||
|
|
||||||
|
# Signed so the recorded/commanded angle sequence reflects the GR
|
||||||
|
# stage's actual physical rotation direction.
|
||||||
|
if num_angles > 1:
|
||||||
|
angles = [rotation_sign * i * 180.0 / (num_angles - 1) for i in range(num_angles)]
|
||||||
|
else:
|
||||||
|
angles = [0.0]
|
||||||
|
|
||||||
|
cx = x_start + x_delta / 2.0
|
||||||
|
cy = y_start + y_delta / 2.0
|
||||||
|
per_angle = []
|
||||||
|
for a in angles:
|
||||||
|
r = math.radians(a)
|
||||||
|
bb_w = abs(x_delta * math.cos(r)) + abs(y_delta * math.sin(r))
|
||||||
|
bb_h = abs(x_delta * math.sin(r)) + abs(y_delta * math.cos(r))
|
||||||
|
a_x_start = cx - bb_w / 2.0
|
||||||
|
a_y_start = cy - bb_h / 2.0
|
||||||
|
a_n_rows = max(1, round(bb_h / row_spacing) + 1) if bb_h > 0 else 1
|
||||||
|
a_n_frames = max(1, round(bb_w * laser_freq_hz / velocity_mm_s))
|
||||||
|
per_angle.append(AngleGeometry(
|
||||||
|
angle_deg=a,
|
||||||
|
x_start=a_x_start,
|
||||||
|
x_delta=bb_w,
|
||||||
|
n_frames=a_n_frames,
|
||||||
|
n_rows=a_n_rows,
|
||||||
|
y_positions=[a_y_start + i * row_spacing for i in range(a_n_rows)],
|
||||||
|
))
|
||||||
|
|
||||||
|
return ScanPlan(
|
||||||
|
x_start_nominal=x_start, y_start_nominal=y_start,
|
||||||
|
x_delta_nominal=x_delta, y_delta_nominal=y_delta,
|
||||||
|
row_spacing=row_spacing,
|
||||||
|
velocity_mm_s=velocity_mm_s, laser_freq_hz=laser_freq_hz,
|
||||||
|
per_angle=per_angle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_plan(plan: ScanPlan, ramp_mm: float, ramp_buffer_mm: float,
|
||||||
|
limits: StageLimits = DEFAULT_STAGE_LIMITS) -> None:
|
||||||
|
"""Raise ScanGeometryError if any angle's physical move leaves the stage.
|
||||||
|
|
||||||
|
The actual X move starts one ramp-length + buffer before x_start and ends
|
||||||
|
one ramp-length + buffer after x_start + x_delta, so the stage is at full
|
||||||
|
velocity across the whole data window.
|
||||||
|
"""
|
||||||
|
x_ramp_total = ramp_mm + ramp_buffer_mm
|
||||||
|
for pa in plan.per_angle:
|
||||||
|
x_move_start = pa.x_start - x_ramp_total
|
||||||
|
x_move_end = pa.x_start + pa.x_delta + x_ramp_total
|
||||||
|
if x_move_start < limits.x_min:
|
||||||
|
raise ScanGeometryError(
|
||||||
|
f"Angle {pa.angle_deg:.1f}°: scan pre-ramp start ({x_move_start:.3f} mm) "
|
||||||
|
f"is below the X axis minimum ({limits.x_min:g} mm). Reduce XD/YD or move XS/YS "
|
||||||
|
f"so every rotation angle's bounding box stays on-stage "
|
||||||
|
f"(SCAN_RAMP_MM={ramp_mm:.3f} + SCAN_RAMP_BUFFER_MM={ramp_buffer_mm:.3f})."
|
||||||
|
)
|
||||||
|
if x_move_end > limits.x_max:
|
||||||
|
raise ScanGeometryError(
|
||||||
|
f"Angle {pa.angle_deg:.1f}°: scan run-off end ({x_move_end:.3f} mm) "
|
||||||
|
f"exceeds the X axis maximum ({limits.x_max:g} mm). Reduce XD/YD or move XS/YS "
|
||||||
|
f"so every rotation angle's bounding box stays on-stage."
|
||||||
|
)
|
||||||
|
y_min = min(pa.y_positions)
|
||||||
|
y_max = max(pa.y_positions)
|
||||||
|
if y_min < limits.y_min:
|
||||||
|
raise ScanGeometryError(
|
||||||
|
f"Angle {pa.angle_deg:.1f}°: scan Y range starts at {y_min:.3f} mm, "
|
||||||
|
f"below the Y axis minimum ({limits.y_min:g} mm)."
|
||||||
|
)
|
||||||
|
if y_max > limits.y_max:
|
||||||
|
raise ScanGeometryError(
|
||||||
|
f"Angle {pa.angle_deg:.1f}°: scan Y range ends at {y_max:.3f} mm, "
|
||||||
|
f"exceeds the Y axis maximum ({limits.y_max:g} mm)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_eta(secs: float) -> str:
|
||||||
|
secs = max(0.0, secs)
|
||||||
|
m, s = divmod(int(secs), 60)
|
||||||
|
h, m = divmod(m, 60)
|
||||||
|
if h > 0:
|
||||||
|
return f"{h}h {m:02d}m"
|
||||||
|
if m > 0:
|
||||||
|
return f"{m}m {s:02d}s"
|
||||||
|
return f"{s}s"
|
||||||
|
|
||||||
|
|
||||||
|
class EtaEstimator:
|
||||||
|
"""Rolling average of recent row durations → remaining-time estimate.
|
||||||
|
|
||||||
|
Duration history resets when the angle index changes, since different
|
||||||
|
angles have different row lengths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, window: int = 5):
|
||||||
|
self._durations: deque[float] = deque(maxlen=window)
|
||||||
|
self._row_start: float | None = None
|
||||||
|
self._last_angle_idx: int = -1
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._durations.clear()
|
||||||
|
self._row_start = None
|
||||||
|
self._last_angle_idx = -1
|
||||||
|
|
||||||
|
def row_started(self, now: float | None = None) -> None:
|
||||||
|
self._row_start = time.monotonic() if now is None else now
|
||||||
|
|
||||||
|
def row_finished(self, angle_idx: int, now: float | None = None) -> None:
|
||||||
|
if angle_idx != self._last_angle_idx and self._last_angle_idx != -1:
|
||||||
|
self._durations.clear()
|
||||||
|
self._last_angle_idx = angle_idx
|
||||||
|
if self._row_start is not None:
|
||||||
|
end = time.monotonic() if now is None else now
|
||||||
|
self._durations.append(end - self._row_start)
|
||||||
|
self._row_start = None
|
||||||
|
|
||||||
|
def eta_secs(self, rows_left: int) -> float | None:
|
||||||
|
if not self._durations or rows_left <= 0:
|
||||||
|
return None
|
||||||
|
return sum(self._durations) / len(self._durations) * rows_left
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""SRAS v6 binary scan-file format — the single implementation.
|
||||||
|
|
||||||
|
Full byte-level spec: scan_format.md. Summary:
|
||||||
|
|
||||||
|
header >4sBHfffffffIdBB magic ver n_angles xs_nom ys_nom xd_nom yd_nom
|
||||||
|
row_spacing velocity laser_freq spf sample_rate
|
||||||
|
bytes_per_sample n_channels
|
||||||
|
angle table n_angles × >f
|
||||||
|
geometry table n_angles × >ffIH (x_start x_delta n_frames n_rows)
|
||||||
|
row tables (ragged) per angle: n_rows × >f (y positions)
|
||||||
|
preambles n_channels × (>H length + utf-8 WFMOutpre string)
|
||||||
|
background block >I length + raw int8 CH1 average
|
||||||
|
waveform data angle-major, row-minor, channel-inner:
|
||||||
|
for each angle, for each row, for each channel,
|
||||||
|
n_frames × samples_per_frame × bytes_per_sample
|
||||||
|
|
||||||
|
Incomplete files are valid: the data block is one contiguous append-only
|
||||||
|
stream, so the readable prefix defines a single frontier past which nothing
|
||||||
|
has been written yet (see ``SrasFile.angle_status``).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mmap
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from core.scan_geometry import AngleGeometry, ScanPlan
|
||||||
|
|
||||||
|
MAGIC = b"SRAS"
|
||||||
|
VERSION = 6
|
||||||
|
HDR_FMT = ">4sBHfffffffIdBB"
|
||||||
|
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
|
||||||
|
GEOM_FMT = ">ffIH"
|
||||||
|
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
|
||||||
|
|
||||||
|
# Oscilloscope channels recorded, in on-disk order.
|
||||||
|
SCAN_CHANNELS = [1, 3, 4]
|
||||||
|
|
||||||
|
STATUS_OK = "OK"
|
||||||
|
STATUS_TRUNCATED = "TRUNCATED"
|
||||||
|
STATUS_MISSING = "MISSING"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanHeader:
|
||||||
|
"""The fixed v6 global header (everything but magic/version)."""
|
||||||
|
n_angles: int
|
||||||
|
x_start_nominal: float
|
||||||
|
y_start_nominal: float
|
||||||
|
x_delta_nominal: float
|
||||||
|
y_delta_nominal: float
|
||||||
|
row_spacing: float
|
||||||
|
velocity: float
|
||||||
|
laser_freq: float
|
||||||
|
samples_per_frame: int
|
||||||
|
sample_rate: float
|
||||||
|
bytes_per_sample: int
|
||||||
|
n_channels: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AngleStatus:
|
||||||
|
"""How much of one angle's declared data is actually on disk."""
|
||||||
|
index: int
|
||||||
|
angle_deg: float
|
||||||
|
n_rows: int # declared
|
||||||
|
row_bytes: int
|
||||||
|
data_offset: int
|
||||||
|
n_rows_available: int
|
||||||
|
status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING
|
||||||
|
|
||||||
|
@property
|
||||||
|
def complete(self) -> bool:
|
||||||
|
return self.status == STATUS_OK
|
||||||
|
|
||||||
|
|
||||||
|
def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
|
||||||
|
sample_rate: float, preambles: list[str],
|
||||||
|
background_waveform: bytes) -> BinaryIO:
|
||||||
|
"""Create a new .sras file and write the v6 header + tables.
|
||||||
|
|
||||||
|
Returns an open binary file positioned at the start of the data block;
|
||||||
|
the caller appends waveform rows and must close it (try/finally).
|
||||||
|
"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
f = open(path, "wb")
|
||||||
|
f.write(struct.pack(
|
||||||
|
HDR_FMT, MAGIC, VERSION,
|
||||||
|
plan.n_angles,
|
||||||
|
plan.x_start_nominal, plan.y_start_nominal,
|
||||||
|
plan.x_delta_nominal, plan.y_delta_nominal,
|
||||||
|
plan.row_spacing,
|
||||||
|
plan.velocity_mm_s, plan.laser_freq_hz,
|
||||||
|
samples_per_frame,
|
||||||
|
sample_rate,
|
||||||
|
1, # bytes_per_sample: int8 from scope default
|
||||||
|
len(SCAN_CHANNELS),
|
||||||
|
))
|
||||||
|
f.write(struct.pack(f">{plan.n_angles}f", *plan.angles))
|
||||||
|
for pa in plan.per_angle:
|
||||||
|
f.write(struct.pack(GEOM_FMT, pa.x_start, pa.x_delta, pa.n_frames, pa.n_rows))
|
||||||
|
for pa in plan.per_angle:
|
||||||
|
f.write(struct.pack(f">{pa.n_rows}f", *pa.y_positions))
|
||||||
|
for p in preambles:
|
||||||
|
enc = p.encode("utf-8")
|
||||||
|
f.write(struct.pack(">H", len(enc)))
|
||||||
|
f.write(enc)
|
||||||
|
f.write(struct.pack(">I", len(background_waveform)))
|
||||||
|
f.write(background_waveform)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SrasFile:
|
||||||
|
"""Parsed v6 .sras file: header, tables, and lazy (memmap) data access.
|
||||||
|
|
||||||
|
Parsing reads only the header/tables — never the waveform block — so
|
||||||
|
opening a multi-GB file is cheap. ``load_angle``/``load_row`` return
|
||||||
|
read-only numpy views backed by a shared mmap; no data is copied until
|
||||||
|
the caller computes on it.
|
||||||
|
"""
|
||||||
|
path: Path
|
||||||
|
header: ScanHeader = field(init=False)
|
||||||
|
per_angle: list[AngleGeometry] = field(init=False)
|
||||||
|
preambles: list[str] = field(init=False)
|
||||||
|
preambles_raw: list[bytes] = field(init=False)
|
||||||
|
background: bytes = field(init=False)
|
||||||
|
data_start_offset: int = field(init=False)
|
||||||
|
file_size: int = field(init=False)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.path = Path(self.path)
|
||||||
|
self._mmap: mmap.mmap | None = None
|
||||||
|
self._parse()
|
||||||
|
|
||||||
|
def _parse(self):
|
||||||
|
self.file_size = self.path.stat().st_size
|
||||||
|
with open(self.path, "rb") as f:
|
||||||
|
raw = f.read(HDR_SIZE)
|
||||||
|
if len(raw) < HDR_SIZE:
|
||||||
|
raise ValueError(f"{self.path.name}: file too short to contain a valid header")
|
||||||
|
(magic, version, n_angles, x_start_nominal, y_start_nominal,
|
||||||
|
x_delta_nominal, y_delta_nominal, row_spacing, velocity, laser_freq,
|
||||||
|
samples_per_frame, sample_rate, bytes_per_sample,
|
||||||
|
n_channels) = struct.unpack(HDR_FMT, raw)
|
||||||
|
if magic != MAGIC:
|
||||||
|
raise ValueError(f"{self.path.name}: not a valid SRAS file (bad magic)")
|
||||||
|
if version != VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.path.name}: unsupported SRAS format version {version} "
|
||||||
|
f"(only version {VERSION} is supported)"
|
||||||
|
)
|
||||||
|
self.header = ScanHeader(
|
||||||
|
n_angles=n_angles,
|
||||||
|
x_start_nominal=x_start_nominal, y_start_nominal=y_start_nominal,
|
||||||
|
x_delta_nominal=x_delta_nominal, y_delta_nominal=y_delta_nominal,
|
||||||
|
row_spacing=row_spacing, velocity=velocity, laser_freq=laser_freq,
|
||||||
|
samples_per_frame=samples_per_frame, sample_rate=sample_rate,
|
||||||
|
bytes_per_sample=bytes_per_sample, n_channels=n_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
angles = struct.unpack(f">{n_angles}f", f.read(4 * n_angles))
|
||||||
|
|
||||||
|
self.per_angle = []
|
||||||
|
for a in angles:
|
||||||
|
x_start, x_delta, n_frames, n_rows = struct.unpack(GEOM_FMT, f.read(GEOM_SIZE))
|
||||||
|
self.per_angle.append(AngleGeometry(
|
||||||
|
angle_deg=a, x_start=x_start, x_delta=x_delta,
|
||||||
|
n_frames=n_frames, n_rows=n_rows,
|
||||||
|
))
|
||||||
|
|
||||||
|
for pa in self.per_angle:
|
||||||
|
pa.y_positions = list(struct.unpack(f">{pa.n_rows}f", f.read(4 * pa.n_rows)))
|
||||||
|
|
||||||
|
self.preambles_raw = []
|
||||||
|
for _ in range(n_channels):
|
||||||
|
(plen,) = struct.unpack(">H", f.read(2))
|
||||||
|
self.preambles_raw.append(f.read(plen))
|
||||||
|
self.preambles = [p.decode("utf-8", errors="replace") for p in self.preambles_raw]
|
||||||
|
|
||||||
|
(n_bg,) = struct.unpack(">I", f.read(4))
|
||||||
|
self.background = f.read(n_bg)
|
||||||
|
|
||||||
|
self.data_start_offset = f.tell()
|
||||||
|
|
||||||
|
# ── Frontier / truncation analysis ───────────────────────────────────────
|
||||||
|
|
||||||
|
def row_bytes(self, angle_idx: int) -> int:
|
||||||
|
pa = self.per_angle[angle_idx]
|
||||||
|
return (self.header.n_channels * pa.n_frames
|
||||||
|
* self.header.samples_per_frame * self.header.bytes_per_sample)
|
||||||
|
|
||||||
|
def angle_status(self) -> list[AngleStatus]:
|
||||||
|
"""Walk declared per-row byte counts against the actual file size.
|
||||||
|
|
||||||
|
Because the data is one contiguous append-only stream, once an angle
|
||||||
|
is found short every later angle is necessarily absent too — there is
|
||||||
|
a single frontier past which nothing has been written yet.
|
||||||
|
"""
|
||||||
|
statuses = []
|
||||||
|
cursor = self.data_start_offset
|
||||||
|
frontier_seen = False
|
||||||
|
for ai, pa in enumerate(self.per_angle):
|
||||||
|
row_bytes = self.row_bytes(ai)
|
||||||
|
data_offset = cursor
|
||||||
|
if frontier_seen:
|
||||||
|
n_rows_available = 0
|
||||||
|
status = STATUS_MISSING
|
||||||
|
else:
|
||||||
|
declared_bytes = row_bytes * pa.n_rows
|
||||||
|
if row_bytes > 0 and cursor + declared_bytes <= self.file_size:
|
||||||
|
n_rows_available = pa.n_rows
|
||||||
|
status = STATUS_OK
|
||||||
|
cursor += declared_bytes
|
||||||
|
else:
|
||||||
|
remaining = max(0, self.file_size - cursor)
|
||||||
|
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
|
||||||
|
status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED
|
||||||
|
frontier_seen = True
|
||||||
|
statuses.append(AngleStatus(
|
||||||
|
index=ai, angle_deg=pa.angle_deg, n_rows=pa.n_rows,
|
||||||
|
row_bytes=row_bytes, data_offset=data_offset,
|
||||||
|
n_rows_available=n_rows_available, status=status,
|
||||||
|
))
|
||||||
|
return statuses
|
||||||
|
|
||||||
|
def angle_data_offset(self, angle_idx: int) -> int:
|
||||||
|
offset = self.data_start_offset
|
||||||
|
for ai in range(angle_idx):
|
||||||
|
offset += self.row_bytes(ai) * self.per_angle[ai].n_rows
|
||||||
|
return offset
|
||||||
|
|
||||||
|
# ── Lazy data access ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ensure_mmap(self) -> mmap.mmap:
|
||||||
|
if self._mmap is None:
|
||||||
|
# The mapping stays valid after the file object is closed, so
|
||||||
|
# don't hold the descriptor open for the (long) life of a viewer
|
||||||
|
# session.
|
||||||
|
with open(self.path, "rb") as f:
|
||||||
|
self._mmap = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
|
||||||
|
return self._mmap
|
||||||
|
|
||||||
|
def _dtype(self) -> np.dtype:
|
||||||
|
return np.dtype(np.int16 if self.header.bytes_per_sample == 2 else np.int8)
|
||||||
|
|
||||||
|
def load_angle(self, angle_idx: int, n_rows: int | None = None) -> np.ndarray:
|
||||||
|
"""Read-only view of one angle's data block, shape
|
||||||
|
(n_rows, n_channels, n_frames, samples_per_frame).
|
||||||
|
|
||||||
|
``n_rows`` limits the view to the rows actually on disk (pass
|
||||||
|
``AngleStatus.n_rows_available`` for truncated files); default is the
|
||||||
|
declared row count.
|
||||||
|
"""
|
||||||
|
pa = self.per_angle[angle_idx]
|
||||||
|
h = self.header
|
||||||
|
if n_rows is None:
|
||||||
|
n_rows = pa.n_rows
|
||||||
|
start = self.angle_data_offset(angle_idx)
|
||||||
|
count = n_rows * h.n_channels * pa.n_frames * h.samples_per_frame
|
||||||
|
arr = np.frombuffer(self._ensure_mmap(), dtype=self._dtype(),
|
||||||
|
count=count, offset=start)
|
||||||
|
arr = arr.reshape(n_rows, h.n_channels, pa.n_frames, h.samples_per_frame)
|
||||||
|
arr.flags.writeable = False
|
||||||
|
return arr
|
||||||
|
|
||||||
|
def load_row(self, angle_idx: int, row: int, channel_idx: int) -> np.ndarray:
|
||||||
|
"""Read-only view of one row/channel, shape (n_frames, samples_per_frame)."""
|
||||||
|
pa = self.per_angle[angle_idx]
|
||||||
|
h = self.header
|
||||||
|
ch_bytes = pa.n_frames * h.samples_per_frame * h.bytes_per_sample
|
||||||
|
start = (self.angle_data_offset(angle_idx) + row * self.row_bytes(angle_idx)
|
||||||
|
+ channel_idx * ch_bytes)
|
||||||
|
arr = np.frombuffer(self._ensure_mmap(), dtype=self._dtype(),
|
||||||
|
count=pa.n_frames * h.samples_per_frame, offset=start)
|
||||||
|
arr = arr.reshape(pa.n_frames, h.samples_per_frame)
|
||||||
|
arr.flags.writeable = False
|
||||||
|
return arr
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Release this file's hold on the mapping.
|
||||||
|
|
||||||
|
Views handed out earlier stay valid — they keep the mapping alive
|
||||||
|
until they are garbage-collected, at which point the OS frees it.
|
||||||
|
"""
|
||||||
|
if self._mmap is not None:
|
||||||
|
try:
|
||||||
|
self._mmap.close()
|
||||||
|
except BufferError:
|
||||||
|
pass # live numpy views still reference the buffer
|
||||||
|
self._mmap = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# ── Axes helpers (viewer conveniences, derived from header fields) ───────
|
||||||
|
|
||||||
|
def pixel_pitch_x_mm(self) -> float:
|
||||||
|
"""Distance between adjacent frames along X."""
|
||||||
|
return self.header.velocity / self.header.laser_freq
|
||||||
|
|
||||||
|
def x_axis_mm(self, angle_idx: int) -> np.ndarray:
|
||||||
|
pa = self.per_angle[angle_idx]
|
||||||
|
return pa.x_start + np.arange(pa.n_frames) * self.pixel_pitch_x_mm()
|
||||||
|
|
||||||
|
def time_axis_ns(self) -> np.ndarray:
|
||||||
|
h = self.header
|
||||||
|
return np.arange(h.samples_per_frame) / h.sample_rate * 1e9
|
||||||
|
|
||||||
|
def freq_axis_mhz(self, nfft: int) -> np.ndarray:
|
||||||
|
return np.fft.rfftfreq(nfft, d=1.0 / self.header.sample_rate) / 1e6
|
||||||
|
|
||||||
|
|
||||||
|
def plan_from_header(sras: SrasFile) -> ScanPlan:
|
||||||
|
"""Reconstruct the ScanPlan a file was written with (for resume)."""
|
||||||
|
h = sras.header
|
||||||
|
return ScanPlan(
|
||||||
|
x_start_nominal=h.x_start_nominal, y_start_nominal=h.y_start_nominal,
|
||||||
|
x_delta_nominal=h.x_delta_nominal, y_delta_nominal=h.y_delta_nominal,
|
||||||
|
row_spacing=h.row_spacing,
|
||||||
|
velocity_mm_s=h.velocity, laser_freq_hz=h.laser_freq,
|
||||||
|
per_angle=list(sras.per_angle),
|
||||||
|
)
|
||||||
@@ -3,12 +3,13 @@ Helios Laser System Driver
|
|||||||
Basic implementation for controlling the Helios pulsed laser.
|
Basic implementation for controlling the Helios pulsed laser.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import serial
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from hardware.serial_util import open_8n1
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -42,10 +43,9 @@ class HeliosLaser:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_available_ports() -> List[str]:
|
def list_available_ports() -> List[str]:
|
||||||
"""List available serial ports"""
|
"""List available serial ports, likeliest devices first."""
|
||||||
import serial.tools.list_ports
|
from hardware.serial_util import list_port_devices
|
||||||
ports = serial.tools.list_ports.comports()
|
return list_port_devices()
|
||||||
return [port.device for port in ports]
|
|
||||||
|
|
||||||
def connect(self, port: str = None) -> bool:
|
def connect(self, port: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -65,14 +65,7 @@ class HeliosLaser:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.serial = serial.Serial(
|
self.serial = open_8n1(self.port, baudrate=9600, timeout=self.timeout)
|
||||||
port=self.port,
|
|
||||||
baudrate=9600,
|
|
||||||
bytesize=serial.EIGHTBITS,
|
|
||||||
parity=serial.PARITY_NONE,
|
|
||||||
stopbits=serial.STOPBITS_ONE,
|
|
||||||
timeout=self.timeout
|
|
||||||
)
|
|
||||||
time.sleep(0.1) # Allow time for connection to stabilize
|
time.sleep(0.1) # Allow time for connection to stabilize
|
||||||
self.is_connected = True
|
self.is_connected = True
|
||||||
logger.info(f"Connected to Helios laser on {self.port}")
|
logger.info(f"Connected to Helios laser on {self.port}")
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Shared serial-port helpers: 8N1 open and scored port enumeration.
|
||||||
|
|
||||||
|
Qt-free — GUI code adapts the (device, label) list into its own widgets.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import serial
|
||||||
|
from serial.tools import list_ports
|
||||||
|
|
||||||
|
# Substrings that suggest a USB-serial adapter we actually talk to
|
||||||
|
# (ESP32-based T3R, CP210x/CH340 dongles, CDC-ACM devices); matching ports
|
||||||
|
# sort first in pickers.
|
||||||
|
DEVICE_HINTS = ("esp32", "jtag", "espressif", "usb serial", "cp210", "ch340", "cdc")
|
||||||
|
|
||||||
|
|
||||||
|
def open_8n1(port: str, baudrate: int, timeout: float,
|
||||||
|
write_timeout: float | None = None) -> serial.Serial:
|
||||||
|
"""Open a serial port with the 8N1 framing every device here uses."""
|
||||||
|
return serial.Serial(
|
||||||
|
port=port,
|
||||||
|
baudrate=baudrate,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_NONE,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
timeout=timeout,
|
||||||
|
write_timeout=write_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scored_ports() -> list[tuple[str, str]]:
|
||||||
|
"""Enumerate serial ports as (device, human label), likeliest-first."""
|
||||||
|
ports = list(list_ports.comports())
|
||||||
|
|
||||||
|
def score(p):
|
||||||
|
text = f"{p.description} {p.manufacturer or ''} {p.product or ''}".lower()
|
||||||
|
return -sum(h in text for h in DEVICE_HINTS)
|
||||||
|
|
||||||
|
ports.sort(key=score)
|
||||||
|
return [(p.device, f"{p.device} — {p.description or p.device}") for p in ports]
|
||||||
|
|
||||||
|
|
||||||
|
def list_port_devices() -> list[str]:
|
||||||
|
"""Plain device-path list, likeliest-first."""
|
||||||
|
return [dev for dev, _ in scored_ports()]
|
||||||
+95
-438
@@ -5,8 +5,6 @@ Loads sc3-aui-main.ui, sc3-aui-camera.ui, sc3-aui-scanprogress.ui via uic
|
|||||||
and wires up T3R, BBD202, oscilloscope, and camera hardware workers.
|
and wires up T3R, BBD202, oscilloscope, and camera hardware workers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import queue
|
import queue
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -30,6 +28,11 @@ from matplotlib.figure import Figure
|
|||||||
ROOT = Path(__file__).parent
|
ROOT = Path(__file__).parent
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from core.config import ScanDefaults
|
||||||
|
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta, validate_plan
|
||||||
|
from core.sras_format import (
|
||||||
|
SCAN_CHANNELS, SrasFile, create_scan_file, plan_from_header,
|
||||||
|
)
|
||||||
from hardware.helios_laser import HeliosLaser
|
from hardware.helios_laser import HeliosLaser
|
||||||
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
|
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
|
||||||
from hardware.t3r_driver import T3RDriver
|
from hardware.t3r_driver import T3RDriver
|
||||||
@@ -39,44 +42,7 @@ from hardware.uc480_camera import (
|
|||||||
)
|
)
|
||||||
from t3r_control_panel import T3RControlPanel
|
from t3r_control_panel import T3RControlPanel
|
||||||
|
|
||||||
# ── Config defaults ──────────────────────────────────────────────────────────
|
DEFAULTS = ScanDefaults.load()
|
||||||
|
|
||||||
_AUI_DEFAULTS_PATH = ROOT / "aui_defaults.json"
|
|
||||||
_AUI_DEFAULTS_FALLBACK = {
|
|
||||||
"t3r_port": "/dev/ttyUSB0",
|
|
||||||
"bbd_port": "/dev/ttyUSB1",
|
|
||||||
"oscope_ip": "192.168.0.1",
|
|
||||||
"laser_freq_hz": 2000,
|
|
||||||
"save_dir": str(ROOT / "scans"),
|
|
||||||
"helios_port": "/dev/ttyUSB2",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _load_aui_defaults() -> dict:
|
|
||||||
if _AUI_DEFAULTS_PATH.exists():
|
|
||||||
try:
|
|
||||||
with open(_AUI_DEFAULTS_PATH) as f:
|
|
||||||
return {**_AUI_DEFAULTS_FALLBACK, **json.load(f)}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# File absent or unreadable — write fresh copy and return fallback
|
|
||||||
_save_aui_defaults(_AUI_DEFAULTS_FALLBACK)
|
|
||||||
return dict(_AUI_DEFAULTS_FALLBACK)
|
|
||||||
|
|
||||||
def _save_aui_defaults(d: dict) -> None:
|
|
||||||
try:
|
|
||||||
with open(_AUI_DEFAULTS_PATH, "w") as f:
|
|
||||||
json.dump(d, f, indent=2)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[AUI] Could not save defaults: {e}")
|
|
||||||
|
|
||||||
_aui = _load_aui_defaults()
|
|
||||||
|
|
||||||
DEFAULT_T3R_PORT = _aui["t3r_port"]
|
|
||||||
DEFAULT_BBD_PORT = _aui["bbd_port"]
|
|
||||||
DEFAULT_OSCOPE_IP = _aui["oscope_ip"]
|
|
||||||
DEFAULT_LASER_FREQ_HZ = float(_aui["laser_freq_hz"])
|
|
||||||
DEFAULT_SAVE_DIR = _aui["save_dir"]
|
|
||||||
DEFAULT_HELIOS_PORT = _aui.get("helios_port", "/dev/ttyUSB2")
|
|
||||||
|
|
||||||
# ── Scan constants ───────────────────────────────────────────────────────────
|
# ── Scan constants ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -155,202 +121,6 @@ class DCBiasImageWidget(FigureCanvas):
|
|||||||
BBD_JOG_SPEED_MM_S = 10.0
|
BBD_JOG_SPEED_MM_S = 10.0
|
||||||
BBD_JOG_ACCEL_MM_S2 = 50.0
|
BBD_JOG_ACCEL_MM_S2 = 50.0
|
||||||
|
|
||||||
# ── Binary blob format ───────────────────────────────────────────────────────
|
|
||||||
# Full spec: scan_format.md
|
|
||||||
BLOB_MAGIC = b"SRAS"
|
|
||||||
BLOB_VERSION = 6
|
|
||||||
SCAN_CHANNELS = [1, 3, 4] # oscilloscope channels recorded, in order
|
|
||||||
# v6: each angle scans only the bounding box of the nominal ROI rotated by
|
|
||||||
# that angle, so x_start/x_delta/n_frames/n_rows all vary per angle and are
|
|
||||||
# no longer in the fixed header — see the per-angle geometry table.
|
|
||||||
# ">4s B H f f f f f f f I d B B"
|
|
||||||
# magic ver n_angles xs_nom ys_nom xd_nom yd_nom row_spacing vel freq spf sr bps n_channels
|
|
||||||
BLOB_HDR_FMT = ">4sBHfffffffIdBB"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_eta(secs: float) -> str:
|
|
||||||
secs = max(0.0, secs)
|
|
||||||
m, s = divmod(int(secs), 60)
|
|
||||||
h, m = divmod(m, 60)
|
|
||||||
if h > 0:
|
|
||||||
return f"{h}h {m:02d}m"
|
|
||||||
if m > 0:
|
|
||||||
return f"{m}m {s:02d}s"
|
|
||||||
return f"{s}s"
|
|
||||||
|
|
||||||
|
|
||||||
def _open_scan_file(path: Path, angles: list[float], per_angle: list[dict],
|
|
||||||
x_start_nominal: float, y_start_nominal: float,
|
|
||||||
x_delta_nominal: float, y_delta_nominal: float,
|
|
||||||
row_spacing: float,
|
|
||||||
velocity: float, laser_freq: float,
|
|
||||||
samples_per_frame: int, sample_rate: float,
|
|
||||||
preambles: list[str],
|
|
||||||
background_waveform: bytes):
|
|
||||||
"""Create a new SRAS file and write the v6 global header + per-angle tables.
|
|
||||||
|
|
||||||
Returns an open binary file object positioned at the start of the data
|
|
||||||
block. The caller must close it (use in a try/finally block).
|
|
||||||
|
|
||||||
Each angle only scans the bounding box of the nominal
|
|
||||||
(x_start_nominal, y_start_nominal, x_delta_nominal, y_delta_nominal) ROI
|
|
||||||
rotated by that angle, so x_start, x_delta, n_frames (points/row) and
|
|
||||||
n_rows all vary per angle. `per_angle` holds one dict per angle (same
|
|
||||||
order as `angles`) with keys "x_start", "x_delta", "n_frames", "n_rows",
|
|
||||||
"y_positions".
|
|
||||||
|
|
||||||
Data is written by appending waveforms in angle-major, row-minor,
|
|
||||||
channel-inner order: for each angle, for each of its rows, for each
|
|
||||||
channel in SCAN_CHANNELS order, that angle's n_frames waveforms are
|
|
||||||
written sequentially.
|
|
||||||
|
|
||||||
preambles: one WFMOutpre string per channel (same order as SCAN_CHANNELS),
|
|
||||||
written as length-prefixed UTF-8 blocks after the row table.
|
|
||||||
|
|
||||||
background_waveform: raw int8 bytes of a 64-sample averaged CH1 waveform
|
|
||||||
captured with the Helios laser enabled and Genesis laser
|
|
||||||
disabled, written as uint32 length prefix followed by
|
|
||||||
the data.
|
|
||||||
"""
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
n_angles = len(angles)
|
|
||||||
f = open(path, "wb")
|
|
||||||
header = struct.pack(
|
|
||||||
BLOB_HDR_FMT,
|
|
||||||
BLOB_MAGIC, BLOB_VERSION,
|
|
||||||
n_angles,
|
|
||||||
x_start_nominal, y_start_nominal,
|
|
||||||
x_delta_nominal, y_delta_nominal,
|
|
||||||
row_spacing,
|
|
||||||
velocity, laser_freq,
|
|
||||||
samples_per_frame,
|
|
||||||
sample_rate,
|
|
||||||
1, # bytes_per_sample: int8 from scope default
|
|
||||||
len(SCAN_CHANNELS), # n_channels
|
|
||||||
)
|
|
||||||
f.write(header)
|
|
||||||
f.write(struct.pack(f">{n_angles}f", *angles))
|
|
||||||
# Per-angle geometry table: x_start, x_delta, n_frames, n_rows.
|
|
||||||
for pa in per_angle:
|
|
||||||
f.write(struct.pack(">ffIH", pa["x_start"], pa["x_delta"], pa["n_frames"], pa["n_rows"]))
|
|
||||||
# Ragged row table: each angle's y_positions, concatenated in order.
|
|
||||||
for pa in per_angle:
|
|
||||||
f.write(struct.pack(f">{pa['n_rows']}f", *pa["y_positions"]))
|
|
||||||
for p in preambles:
|
|
||||||
enc = p.encode("utf-8")
|
|
||||||
f.write(struct.pack(">H", len(enc)))
|
|
||||||
f.write(enc)
|
|
||||||
# v4+: background waveform block — CH1 64-sample average (Helios ON, Genesis OFF)
|
|
||||||
f.write(struct.pack(">I", len(background_waveform)))
|
|
||||||
f.write(background_waveform)
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def _read_sras_header(path: Path) -> dict:
|
|
||||||
"""Parse a v6 .sras file's header, per-angle geometry, and row tables.
|
|
||||||
|
|
||||||
Does not read the (potentially huge) waveform data block itself — only
|
|
||||||
enough to know each angle's geometry and the byte offset at which the
|
|
||||||
waveform data begins.
|
|
||||||
"""
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
hdr_size = struct.calcsize(BLOB_HDR_FMT)
|
|
||||||
raw = f.read(hdr_size)
|
|
||||||
if len(raw) < hdr_size:
|
|
||||||
raise ValueError(f"{path.name}: file too short to contain a valid header")
|
|
||||||
(magic, version, n_angles, x_start_nominal, y_start_nominal,
|
|
||||||
x_delta_nominal, y_delta_nominal, row_spacing, velocity, laser_freq,
|
|
||||||
samples_per_frame, sample_rate, bytes_per_sample,
|
|
||||||
n_channels) = struct.unpack(BLOB_HDR_FMT, raw)
|
|
||||||
if magic != BLOB_MAGIC:
|
|
||||||
raise ValueError(f"{path.name}: not a valid SRAS file (bad magic)")
|
|
||||||
if version != BLOB_VERSION:
|
|
||||||
raise ValueError(
|
|
||||||
f"{path.name}: unsupported SRAS format version {version} "
|
|
||||||
f"(this app can only resume version {BLOB_VERSION} files)"
|
|
||||||
)
|
|
||||||
|
|
||||||
angles = list(struct.unpack(f">{n_angles}f", f.read(4 * n_angles)))
|
|
||||||
|
|
||||||
per_angle = []
|
|
||||||
for a in angles:
|
|
||||||
x_start, x_delta, n_frames, n_rows = struct.unpack(">ffIH", f.read(14))
|
|
||||||
per_angle.append({
|
|
||||||
"angle": a, "x_start": x_start, "x_delta": x_delta,
|
|
||||||
"n_frames": n_frames, "n_rows": n_rows,
|
|
||||||
})
|
|
||||||
|
|
||||||
for pa in per_angle:
|
|
||||||
n = pa["n_rows"]
|
|
||||||
pa["y_positions"] = list(struct.unpack(f">{n}f", f.read(4 * n)))
|
|
||||||
|
|
||||||
for _ in range(n_channels):
|
|
||||||
(plen,) = struct.unpack(">H", f.read(2))
|
|
||||||
f.read(plen)
|
|
||||||
|
|
||||||
(n_bg,) = struct.unpack(">I", f.read(4))
|
|
||||||
f.read(n_bg)
|
|
||||||
|
|
||||||
data_start_offset = f.tell()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"version": version, "n_angles": n_angles,
|
|
||||||
"x_start_nominal": x_start_nominal, "y_start_nominal": y_start_nominal,
|
|
||||||
"x_delta_nominal": x_delta_nominal, "y_delta_nominal": y_delta_nominal,
|
|
||||||
"row_spacing": row_spacing, "velocity": velocity, "laser_freq": laser_freq,
|
|
||||||
"samples_per_frame": samples_per_frame, "sample_rate": sample_rate,
|
|
||||||
"bytes_per_sample": bytes_per_sample, "n_channels": n_channels,
|
|
||||||
"angles": angles, "per_angle": per_angle,
|
|
||||||
"data_start_offset": data_start_offset,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_angle_status(path: Path, info: dict) -> list[dict]:
|
|
||||||
"""Figure out, per angle, how much waveform data is actually present on
|
|
||||||
disk versus declared in the per-angle geometry table.
|
|
||||||
|
|
||||||
Waveform data is written angle-major, row-minor with a fixed number of
|
|
||||||
bytes per row (derivable from the per-angle geometry table), so this
|
|
||||||
walks the expected per-row byte counts against the actual file size.
|
|
||||||
Because the data is one contiguous stream, once an angle is found to be
|
|
||||||
short every angle after it is necessarily entirely absent too — there is
|
|
||||||
always a single "frontier" past which nothing has been written yet.
|
|
||||||
|
|
||||||
Returns a list of dicts, one per angle, each with: index, angle_deg,
|
|
||||||
n_rows (declared), row_bytes, data_offset (byte offset where this
|
|
||||||
angle's data starts), n_rows_available, and status
|
|
||||||
("OK" / "TRUNCATED" / "MISSING").
|
|
||||||
"""
|
|
||||||
actual_size = path.stat().st_size
|
|
||||||
cursor = info["data_start_offset"]
|
|
||||||
statuses = []
|
|
||||||
frontier_seen = False
|
|
||||||
for ai, pa in enumerate(info["per_angle"]):
|
|
||||||
row_bytes = info["n_channels"] * pa["n_frames"] * info["samples_per_frame"] * info["bytes_per_sample"]
|
|
||||||
n_rows = pa["n_rows"]
|
|
||||||
data_offset = cursor
|
|
||||||
if frontier_seen:
|
|
||||||
n_rows_available = 0
|
|
||||||
status = "MISSING"
|
|
||||||
else:
|
|
||||||
declared_bytes = row_bytes * n_rows
|
|
||||||
if row_bytes > 0 and cursor + declared_bytes <= actual_size:
|
|
||||||
n_rows_available = n_rows
|
|
||||||
status = "OK"
|
|
||||||
cursor += declared_bytes
|
|
||||||
else:
|
|
||||||
remaining = max(0, actual_size - cursor)
|
|
||||||
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
|
|
||||||
status = "MISSING" if n_rows_available == 0 else "TRUNCATED"
|
|
||||||
frontier_seen = True
|
|
||||||
statuses.append({
|
|
||||||
"index": ai, "angle_deg": pa["angle"], "n_rows": n_rows,
|
|
||||||
"row_bytes": row_bytes, "data_offset": data_offset,
|
|
||||||
"n_rows_available": n_rows_available, "status": status,
|
|
||||||
})
|
|
||||||
return statuses
|
|
||||||
|
|
||||||
|
|
||||||
# ── BBD202 worker ─────────────────────────────────────────────────────────────
|
# ── BBD202 worker ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class BBD202Worker(QObject):
|
class BBD202Worker(QObject):
|
||||||
@@ -969,9 +739,8 @@ class HeliosWindow(QWidget):
|
|||||||
self.helios_connect_toggle.setText("Connecting…")
|
self.helios_connect_toggle.setText("Connecting…")
|
||||||
self.helios_connect_toggle.setEnabled(False)
|
self.helios_connect_toggle.setEnabled(False)
|
||||||
port = self.helios_port_edit.text().strip()
|
port = self.helios_port_edit.text().strip()
|
||||||
d = _load_aui_defaults()
|
DEFAULTS.helios_port = port
|
||||||
d["helios_port"] = port
|
DEFAULTS.save()
|
||||||
_save_aui_defaults(d)
|
|
||||||
self._worker.queue_connect(port)
|
self._worker.queue_connect(port)
|
||||||
else:
|
else:
|
||||||
self._poll_timer.stop()
|
self._poll_timer.stop()
|
||||||
@@ -1068,7 +837,7 @@ class ResumeAngleDialog(QDialog):
|
|||||||
completed angle that's known to be bad).
|
completed angle that's known to be bad).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, statuses: list[dict], parent: QWidget | None = None):
|
def __init__(self, statuses, parent: QWidget | None = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Select Angles to Rescan")
|
self.setWindowTitle("Select Angles to Rescan")
|
||||||
self.resize(480, 420)
|
self.resize(480, 420)
|
||||||
@@ -1082,13 +851,13 @@ class ResumeAngleDialog(QDialog):
|
|||||||
self.list_widget = QListWidget(self)
|
self.list_widget = QListWidget(self)
|
||||||
for s in statuses:
|
for s in statuses:
|
||||||
label = (
|
label = (
|
||||||
f"Angle {s['index'] + 1}/{len(statuses)} — {s['angle_deg']:.2f}° — "
|
f"Angle {s.index + 1}/{len(statuses)} — {s.angle_deg:.2f}° — "
|
||||||
f"{s['n_rows_available']}/{s['n_rows']} rows — {s['status']}"
|
f"{s.n_rows_available}/{s.n_rows} rows — {s.status}"
|
||||||
)
|
)
|
||||||
item = QListWidgetItem(label)
|
item = QListWidgetItem(label)
|
||||||
item.setData(Qt.ItemDataRole.UserRole, s["index"])
|
item.setData(Qt.ItemDataRole.UserRole, s.index)
|
||||||
item.setCheckState(
|
item.setCheckState(
|
||||||
Qt.CheckState.Checked if s["status"] != "OK" else Qt.CheckState.Unchecked
|
Qt.CheckState.Unchecked if s.complete else Qt.CheckState.Checked
|
||||||
)
|
)
|
||||||
self.list_widget.addItem(item)
|
self.list_widget.addItem(item)
|
||||||
layout.addWidget(self.list_widget)
|
layout.addWidget(self.list_widget)
|
||||||
@@ -1142,9 +911,7 @@ class ScanProgressWindow(QWidget):
|
|||||||
self.verticalLayout.insertWidget(insert_pos + 1, self.bias_image_widget)
|
self.verticalLayout.insertWidget(insert_pos + 1, self.bias_image_widget)
|
||||||
self.verticalLayout.setStretch(insert_pos + 1, 1)
|
self.verticalLayout.setStretch(insert_pos + 1, 1)
|
||||||
|
|
||||||
self._row_start_time: float | None = None
|
self._eta = EtaEstimator()
|
||||||
self._row_durations: list[float] = []
|
|
||||||
self._last_angle_idx: int = -1
|
|
||||||
|
|
||||||
def _on_pause_btn(self, checked: bool):
|
def _on_pause_btn(self, checked: bool):
|
||||||
# Pause takes effect at the next row boundary; show the intermediate
|
# Pause takes effect at the next row boundary; show the intermediate
|
||||||
@@ -1163,7 +930,7 @@ class ScanProgressWindow(QWidget):
|
|||||||
self.pause_btn.blockSignals(False)
|
self.pause_btn.blockSignals(False)
|
||||||
|
|
||||||
def on_row_started(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
|
def on_row_started(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
|
||||||
self._row_start_time = time.monotonic()
|
self._eta.row_started()
|
||||||
|
|
||||||
def update_progress(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
|
def update_progress(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
|
||||||
n_rows = max(1, n_rows)
|
n_rows = max(1, n_rows)
|
||||||
@@ -1173,31 +940,20 @@ class ScanProgressWindow(QWidget):
|
|||||||
self.overall_scan_current_angle_indicator.setText(f"Angle {angle_idx} of {n_angles}")
|
self.overall_scan_current_angle_indicator.setText(f"Angle {angle_idx} of {n_angles}")
|
||||||
|
|
||||||
if row == 0:
|
if row == 0:
|
||||||
self._row_durations = []
|
self._eta.reset()
|
||||||
self._row_start_time = None
|
|
||||||
self._last_angle_idx = -1
|
|
||||||
self.current_scan_progbar.setMaximum(n_rows)
|
self.current_scan_progbar.setMaximum(n_rows)
|
||||||
self.current_scan_progbar.setValue(0)
|
self.current_scan_progbar.setValue(0)
|
||||||
self.current_scan_progbar.setFormat("Row %v/%m")
|
self.current_scan_progbar.setFormat("Row %v/%m")
|
||||||
else:
|
else:
|
||||||
# Reset duration history when the angle changes
|
self._eta.row_finished(angle_idx)
|
||||||
if angle_idx != self._last_angle_idx and self._last_angle_idx != -1:
|
|
||||||
self._row_durations = []
|
|
||||||
self._last_angle_idx = angle_idx
|
|
||||||
|
|
||||||
if self._row_start_time is not None:
|
|
||||||
self._row_durations.append(time.monotonic() - self._row_start_time)
|
|
||||||
self._row_start_time = None
|
|
||||||
|
|
||||||
self.current_scan_progbar.setMaximum(n_rows)
|
self.current_scan_progbar.setMaximum(n_rows)
|
||||||
self.current_scan_progbar.setValue(row)
|
self.current_scan_progbar.setValue(row)
|
||||||
|
|
||||||
rows_left = n_rows - row
|
rows_left = n_rows - row
|
||||||
if self._row_durations and rows_left > 0:
|
eta_secs = self._eta.eta_secs(rows_left)
|
||||||
recent = self._row_durations[-5:]
|
if eta_secs is not None:
|
||||||
avg = sum(recent) / len(recent)
|
self.current_scan_progbar.setFormat(
|
||||||
eta_str = _format_eta(avg * rows_left)
|
f"Row %v/%m — ETA: {format_eta(eta_secs)}")
|
||||||
self.current_scan_progbar.setFormat(f"Row %v/%m — ETA: {eta_str}")
|
|
||||||
elif rows_left == 0:
|
elif rows_left == 0:
|
||||||
self.current_scan_progbar.setFormat("Row %v/%m — Done")
|
self.current_scan_progbar.setFormat("Row %v/%m — Done")
|
||||||
else:
|
else:
|
||||||
@@ -1230,13 +986,15 @@ class ScanWorker(QObject):
|
|||||||
paused_changed = pyqtSignal(bool) # True while paused at a row boundary
|
paused_changed = pyqtSignal(bool) # True while paused at a row boundary
|
||||||
|
|
||||||
def __init__(self, bbd: BBD202Worker, t3r: T3RDriver | None,
|
def __init__(self, bbd: BBD202Worker, t3r: T3RDriver | None,
|
||||||
oscope: OscopeWorker, params: dict,
|
oscope: OscopeWorker, plan: ScanPlan, prefix: str,
|
||||||
resume_info: dict | None = None):
|
save_dir: str, resume_info: dict | None = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._bbd = bbd
|
self._bbd = bbd
|
||||||
self._t3r = t3r
|
self._t3r = t3r
|
||||||
self._oscope = oscope
|
self._oscope = oscope
|
||||||
self._params = params
|
self._plan = plan
|
||||||
|
self._prefix = prefix
|
||||||
|
self._save_dir = save_dir
|
||||||
self._resume_info = resume_info
|
self._resume_info = resume_info
|
||||||
self._abort = False
|
self._abort = False
|
||||||
self._prompt_event = threading.Event()
|
self._prompt_event = threading.Event()
|
||||||
@@ -1290,66 +1048,23 @@ class ScanWorker(QObject):
|
|||||||
self.failed.emit(str(e))
|
self.failed.emit(str(e))
|
||||||
|
|
||||||
def _run_scan(self):
|
def _run_scan(self):
|
||||||
p = self._params
|
plan = self._plan
|
||||||
x_start_nominal = p["x_start_nominal"]
|
per_angle = plan.per_angle
|
||||||
y_start_nominal = p["y_start_nominal"]
|
n_angles = plan.n_angles
|
||||||
x_delta_nominal = p["x_delta_nominal"]
|
save_dir = Path(self._save_dir)
|
||||||
y_delta_nominal = p["y_delta_nominal"]
|
|
||||||
n_angles = max(1, p["num_angles"])
|
|
||||||
row_spacing = p["row_spacing"]
|
|
||||||
prefix = p["prefix"]
|
|
||||||
save_dir = Path(p["save_dir"])
|
|
||||||
|
|
||||||
# Each angle's bounding box (x_start, x_delta, n_frames, n_rows,
|
# The actual X move starts one ramp-length + buffer before x_start and
|
||||||
# y_positions) was pre-computed in _build_scan_params() from the
|
# ends one ramp-length + buffer after x_start + x_delta, so the stage
|
||||||
# nominal ROI rotated by *that* angle only, so every angle scans the
|
# is at full velocity across the whole data window.
|
||||||
# minimum area needed to cover the ROI at its own rotation instead of
|
|
||||||
# the worst case across all angles.
|
|
||||||
per_angle = p["per_angle"]
|
|
||||||
angles = [pa["angle"] for pa in per_angle]
|
|
||||||
|
|
||||||
# ── Validate scan geometry against stage travel limits ─────────────────
|
|
||||||
# X axis: 0–110 mm (bbd20x.py). The actual move starts one ramp-length
|
|
||||||
# + buffer before x_start and ends one ramp-length + buffer after
|
|
||||||
# x_start + x_delta.
|
|
||||||
_x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
_x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
||||||
for pa in per_angle:
|
validate_plan(plan, SCAN_RAMP_MM, SCAN_RAMP_BUFFER_MM)
|
||||||
a_x_start, a_x_delta = pa["x_start"], pa["x_delta"]
|
|
||||||
x_move_start = a_x_start - _x_ramp_total
|
|
||||||
x_move_end = a_x_start + a_x_delta + _x_ramp_total
|
|
||||||
if x_move_start < 0.0:
|
|
||||||
raise ValueError(
|
|
||||||
f"Angle {pa['angle']:.1f}°: scan pre-ramp start ({x_move_start:.3f} mm) "
|
|
||||||
f"is below the X axis minimum (0 mm). Reduce XD/YD or move XS/YS "
|
|
||||||
f"so every rotation angle's bounding box stays on-stage "
|
|
||||||
f"(SCAN_RAMP_MM={SCAN_RAMP_MM:.3f} + SCAN_RAMP_BUFFER_MM={SCAN_RAMP_BUFFER_MM:.3f})."
|
|
||||||
)
|
|
||||||
if x_move_end > 110.0:
|
|
||||||
raise ValueError(
|
|
||||||
f"Angle {pa['angle']:.1f}°: scan run-off end ({x_move_end:.3f} mm) "
|
|
||||||
f"exceeds the X axis maximum (110 mm). Reduce XD/YD or move XS/YS "
|
|
||||||
f"so every rotation angle's bounding box stays on-stage."
|
|
||||||
)
|
|
||||||
y_min = min(pa["y_positions"])
|
|
||||||
y_max = max(pa["y_positions"])
|
|
||||||
if y_min < 0.0:
|
|
||||||
raise ValueError(
|
|
||||||
f"Angle {pa['angle']:.1f}°: scan Y range starts at {y_min:.3f} mm, "
|
|
||||||
f"below the Y axis minimum (0 mm)."
|
|
||||||
)
|
|
||||||
if y_max > 75.0:
|
|
||||||
raise ValueError(
|
|
||||||
f"Angle {pa['angle']:.1f}°: scan Y range ends at {y_max:.3f} mm, "
|
|
||||||
f"exceeds the Y axis maximum (75 mm)."
|
|
||||||
)
|
|
||||||
|
|
||||||
total_rows = sum(pa["n_rows"] for pa in per_angle)
|
|
||||||
geometry_summary = ", ".join(
|
geometry_summary = ", ".join(
|
||||||
f"{pa['angle']:.1f}°: {pa['n_rows']} row(s) × {pa['n_frames']} pts/row"
|
f"{pa.angle_deg:.1f}°: {pa.n_rows} row(s) × {pa.n_frames} pts/row"
|
||||||
for pa in per_angle
|
for pa in per_angle
|
||||||
)
|
)
|
||||||
self.status_msg.emit(
|
self.status_msg.emit(
|
||||||
f"Scan geometry: {n_angles} angle(s), {total_rows} row(s) total "
|
f"Scan geometry: {n_angles} angle(s), {plan.total_rows} row(s) total "
|
||||||
f"(per-angle bounding box) | save → {save_dir}\n{geometry_summary}"
|
f"(per-angle bounding box) | save → {save_dir}\n{geometry_summary}"
|
||||||
)
|
)
|
||||||
self.started.emit()
|
self.started.emit()
|
||||||
@@ -1530,12 +1245,9 @@ class ScanWorker(QObject):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
targets_by_ai = None
|
targets_by_ai = None
|
||||||
fname = save_dir / f"{prefix}.sras"
|
fname = save_dir / f"{self._prefix}.sras"
|
||||||
scan_file = _open_scan_file(
|
scan_file = create_scan_file(
|
||||||
fname, angles, per_angle,
|
fname, plan, samples_per_frame, SCOPE_SAMPLE_RATE,
|
||||||
x_start_nominal, y_start_nominal, x_delta_nominal, y_delta_nominal,
|
|
||||||
row_spacing, SCAN_VELOCITY_MM_S, LASER_FREQ_HZ,
|
|
||||||
samples_per_frame, SCOPE_SAMPLE_RATE,
|
|
||||||
preambles, background_waveform,
|
preambles, background_waveform,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1554,12 +1266,12 @@ class ScanWorker(QObject):
|
|||||||
if self._abort:
|
if self._abort:
|
||||||
break
|
break
|
||||||
|
|
||||||
angle = pa["angle"]
|
angle = pa.angle_deg
|
||||||
a_x_start = pa["x_start"]
|
a_x_start = pa.x_start
|
||||||
a_x_delta = pa["x_delta"]
|
a_x_delta = pa.x_delta
|
||||||
a_n_rows = pa["n_rows"]
|
a_n_rows = pa.n_rows
|
||||||
a_n_frames = pa["n_frames"]
|
a_n_frames = pa.n_frames
|
||||||
y_positions = pa["y_positions"]
|
y_positions = pa.y_positions
|
||||||
|
|
||||||
if targets_by_ai is not None:
|
if targets_by_ai is not None:
|
||||||
# Interior angles may already have valid data on either
|
# Interior angles may already have valid data on either
|
||||||
@@ -1733,10 +1445,10 @@ class MainWindow(QMainWindow):
|
|||||||
# ── UI initialisation ─────────────────────────────────────────────────────
|
# ── UI initialisation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _init_ui_fields(self):
|
def _init_ui_fields(self):
|
||||||
self.t3r_comport_edit.setText(DEFAULT_T3R_PORT)
|
self.t3r_comport_edit.setText(DEFAULTS.t3r_port)
|
||||||
self.bbd202_comport_edit.setText(DEFAULT_BBD_PORT)
|
self.bbd202_comport_edit.setText(DEFAULTS.bbd_port)
|
||||||
self.oscope_ip_edit.setText(DEFAULT_OSCOPE_IP)
|
self.oscope_ip_edit.setText(DEFAULTS.oscope_ip)
|
||||||
self._helios_win.helios_port_edit.setText(DEFAULT_HELIOS_PORT)
|
self._helios_win.helios_port_edit.setText(DEFAULTS.helios_port)
|
||||||
self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM))
|
self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM))
|
||||||
|
|
||||||
self.x_start_edit.setText("10.000")
|
self.x_start_edit.setText("10.000")
|
||||||
@@ -1746,7 +1458,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.num_angles_edit.setText("1")
|
self.num_angles_edit.setText("1")
|
||||||
self.row_spacing_edit.setText("0.250")
|
self.row_spacing_edit.setText("0.250")
|
||||||
self.scan_prefix_edit.setText("scan")
|
self.scan_prefix_edit.setText("scan")
|
||||||
self.scan_save_dir_edit.setText(DEFAULT_SAVE_DIR)
|
self.scan_save_dir_edit.setText(DEFAULTS.save_dir)
|
||||||
|
|
||||||
# Hide the old T3R manual controls; the connect toggle becomes the panel button.
|
# Hide the old T3R manual controls; the connect toggle becomes the panel button.
|
||||||
for w in (
|
for w in (
|
||||||
@@ -1960,13 +1672,11 @@ class MainWindow(QMainWindow):
|
|||||||
# ── Persist defaults ──────────────────────────────────────────────────────
|
# ── Persist defaults ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _persist_defaults(self):
|
def _persist_defaults(self):
|
||||||
_save_aui_defaults({
|
DEFAULTS.t3r_port = self.t3r_comport_edit.text().strip()
|
||||||
"t3r_port": self.t3r_comport_edit.text().strip(),
|
DEFAULTS.bbd_port = self.bbd202_comport_edit.text().strip()
|
||||||
"bbd_port": self.bbd202_comport_edit.text().strip(),
|
DEFAULTS.oscope_ip = self.oscope_ip_edit.text().strip()
|
||||||
"oscope_ip": self.oscope_ip_edit.text().strip(),
|
DEFAULTS.save_dir = self.scan_save_dir_edit.text().strip()
|
||||||
"laser_freq_hz": DEFAULT_LASER_FREQ_HZ,
|
DEFAULTS.save()
|
||||||
"save_dir": self.scan_save_dir_edit.text().strip(),
|
|
||||||
})
|
|
||||||
|
|
||||||
# ── Camera toggle ─────────────────────────────────────────────────────────
|
# ── Camera toggle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2000,11 +1710,11 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _on_start_scan(self):
|
def _on_start_scan(self):
|
||||||
try:
|
try:
|
||||||
params = self._build_scan_params()
|
plan, prefix, save_dir = self._build_scan_plan()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
|
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
|
||||||
return
|
return
|
||||||
self._launch_scan_worker(params)
|
self._launch_scan_worker(plan, prefix, save_dir)
|
||||||
|
|
||||||
def _on_resume_scan_action(self):
|
def _on_resume_scan_action(self):
|
||||||
if self._scan_thread is not None and self._scan_thread.isRunning():
|
if self._scan_thread is not None and self._scan_thread.isRunning():
|
||||||
@@ -2014,7 +1724,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
start_dir = self.scan_save_dir_edit.text().strip() or DEFAULT_SAVE_DIR
|
start_dir = self.scan_save_dir_edit.text().strip() or DEFAULTS.save_dir
|
||||||
path_str, _ = QFileDialog.getOpenFileName(
|
path_str, _ = QFileDialog.getOpenFileName(
|
||||||
self, "Select Scan File to Resume", start_dir, "SRAS Scan Files (*.sras)"
|
self, "Select Scan File to Resume", start_dir, "SRAS Scan Files (*.sras)"
|
||||||
)
|
)
|
||||||
@@ -2023,16 +1733,17 @@ class MainWindow(QMainWindow):
|
|||||||
path = Path(path_str)
|
path = Path(path_str)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
info = _read_sras_header(path)
|
sras = SrasFile(path)
|
||||||
statuses = _compute_angle_status(path, info)
|
statuses = sras.angle_status()
|
||||||
except (ValueError, struct.error, OSError) as e:
|
except (ValueError, struct.error, OSError) as e:
|
||||||
QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}")
|
QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if (info["bytes_per_sample"] != 1 or info["n_channels"] != len(SCAN_CHANNELS)
|
hdr = sras.header
|
||||||
or abs(info["velocity"] - SCAN_VELOCITY_MM_S) > 1e-3
|
if (hdr.bytes_per_sample != 1 or hdr.n_channels != len(SCAN_CHANNELS)
|
||||||
or abs(info["laser_freq"] - LASER_FREQ_HZ) > 1e-3
|
or abs(hdr.velocity - SCAN_VELOCITY_MM_S) > 1e-3
|
||||||
or abs(info["sample_rate"] - SCOPE_SAMPLE_RATE) > 1.0):
|
or abs(hdr.laser_freq - LASER_FREQ_HZ) > 1e-3
|
||||||
|
or abs(hdr.sample_rate - SCOPE_SAMPLE_RATE) > 1.0):
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self, "Cannot Resume Scan",
|
self, "Cannot Resume Scan",
|
||||||
f"{path.name} was recorded with acquisition settings that don't "
|
f"{path.name} was recorded with acquisition settings that don't "
|
||||||
@@ -2049,11 +1760,12 @@ class MainWindow(QMainWindow):
|
|||||||
if not selected:
|
if not selected:
|
||||||
return
|
return
|
||||||
|
|
||||||
frontier_idx = next((s["index"] for s in statuses if s["status"] != "OK"), len(statuses))
|
# Data is one contiguous stream, so angles past the frontier (the
|
||||||
|
# first incomplete one) can't be skipped over — back-fill the range.
|
||||||
|
frontier_idx = next((s.index for s in statuses if not s.complete), len(statuses))
|
||||||
at_or_past_frontier = {i for i in selected if i >= frontier_idx}
|
at_or_past_frontier = {i for i in selected if i >= frontier_idx}
|
||||||
if at_or_past_frontier:
|
if at_or_past_frontier:
|
||||||
required = set(range(frontier_idx, max(at_or_past_frontier) + 1))
|
final = selected | set(range(frontier_idx, max(at_or_past_frontier) + 1))
|
||||||
final = selected | required
|
|
||||||
else:
|
else:
|
||||||
final = selected
|
final = selected
|
||||||
|
|
||||||
@@ -2069,10 +1781,10 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
targets = [
|
targets = [
|
||||||
{
|
{
|
||||||
"ai": s["index"], "data_offset": s["data_offset"],
|
"ai": s.index, "data_offset": s.data_offset,
|
||||||
"n_rows": s["n_rows"], "angle_deg": s["angle_deg"],
|
"n_rows": s.n_rows, "angle_deg": s.angle_deg,
|
||||||
}
|
}
|
||||||
for s in statuses if s["index"] in final
|
for s in statuses if s.index in final
|
||||||
]
|
]
|
||||||
total_rows = sum(t["n_rows"] for t in targets)
|
total_rows = sum(t["n_rows"] for t in targets)
|
||||||
angle_list = ", ".join(f"{t['ai'] + 1}" for t in targets)
|
angle_list = ", ".join(f"{t['ai'] + 1}" for t in targets)
|
||||||
@@ -2087,28 +1799,20 @@ class MainWindow(QMainWindow):
|
|||||||
if reply != QMessageBox.StandardButton.Yes:
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
return
|
return
|
||||||
|
|
||||||
params = {
|
plan = plan_from_header(sras)
|
||||||
"x_start_nominal": info["x_start_nominal"], "y_start_nominal": info["y_start_nominal"],
|
|
||||||
"x_delta_nominal": info["x_delta_nominal"], "y_delta_nominal": info["y_delta_nominal"],
|
|
||||||
"num_angles": info["n_angles"],
|
|
||||||
"row_spacing": info["row_spacing"],
|
|
||||||
"laser_freq": info["laser_freq"],
|
|
||||||
"prefix": path.stem,
|
|
||||||
"save_dir": str(path.parent),
|
|
||||||
"per_angle": info["per_angle"],
|
|
||||||
}
|
|
||||||
resume_info = {
|
resume_info = {
|
||||||
"path": path,
|
"path": path,
|
||||||
"targets": targets,
|
"targets": targets,
|
||||||
"samples_per_frame": info["samples_per_frame"],
|
"samples_per_frame": hdr.samples_per_frame,
|
||||||
}
|
}
|
||||||
self._launch_scan_worker(params, resume_info)
|
self._launch_scan_worker(plan, path.stem, str(path.parent), resume_info)
|
||||||
|
|
||||||
def _launch_scan_worker(self, params: dict, resume_info: dict | None = None):
|
def _launch_scan_worker(self, plan: ScanPlan, prefix: str, save_dir: str,
|
||||||
# ── Launch scan worker ────────────────────────────────────────────────
|
resume_info: dict | None = None):
|
||||||
self._scan_thread = QThread(self)
|
self._scan_thread = QThread(self)
|
||||||
self._scan_worker = ScanWorker(
|
self._scan_worker = ScanWorker(
|
||||||
self._bbd_worker, self._t3r_driver, self._oscope_worker, params, resume_info
|
self._bbd_worker, self._t3r_driver, self._oscope_worker,
|
||||||
|
plan, prefix, save_dir, resume_info
|
||||||
)
|
)
|
||||||
self._scan_worker.moveToThread(self._scan_thread)
|
self._scan_worker.moveToThread(self._scan_thread)
|
||||||
self._scan_thread.started.connect(self._scan_worker.run)
|
self._scan_thread.started.connect(self._scan_worker.run)
|
||||||
@@ -2124,85 +1828,38 @@ class MainWindow(QMainWindow):
|
|||||||
self.start_scan_btn.setEnabled(False)
|
self.start_scan_btn.setEnabled(False)
|
||||||
self._scan_progress.reset_pause_btn()
|
self._scan_progress.reset_pause_btn()
|
||||||
if resume_info is None:
|
if resume_info is None:
|
||||||
self._scan_progress.update_progress(0, params["per_angle"][0]["n_rows"], 0, params["num_angles"])
|
self._scan_progress.update_progress(0, plan.per_angle[0].n_rows, 0, plan.n_angles)
|
||||||
else:
|
else:
|
||||||
ai0 = resume_info["targets"][0]["ai"]
|
ai0 = resume_info["targets"][0]["ai"]
|
||||||
self._scan_progress.update_progress(
|
self._scan_progress.update_progress(
|
||||||
0, params["per_angle"][ai0]["n_rows"], ai0 + 1, params["num_angles"]
|
0, plan.per_angle[ai0].n_rows, ai0 + 1, plan.n_angles
|
||||||
)
|
)
|
||||||
self._scan_progress.show()
|
self._scan_progress.show()
|
||||||
self._scan_thread.start()
|
self._scan_thread.start()
|
||||||
|
|
||||||
def _build_scan_params(self) -> dict:
|
def _build_scan_plan(self) -> tuple[ScanPlan, str, str]:
|
||||||
def _f(w, label):
|
def _f(w, label):
|
||||||
try:
|
try:
|
||||||
return float(w.text())
|
return float(w.text())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"'{label}' is not a valid number: {w.text()!r}")
|
raise ValueError(f"'{label}' is not a valid number: {w.text()!r}") from None
|
||||||
def _i(w, label):
|
def _i(w, label):
|
||||||
try:
|
try:
|
||||||
return int(w.text())
|
return int(w.text())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"'{label}' is not a valid integer: {w.text()!r}")
|
raise ValueError(f"'{label}' is not a valid integer: {w.text()!r}") from None
|
||||||
|
|
||||||
x_start = _f(self.x_start_edit, "XS")
|
plan = build_plan(
|
||||||
y_start = _f(self.y_start_edit, "YS")
|
_f(self.x_start_edit, "XS"), _f(self.y_start_edit, "YS"),
|
||||||
x_delta = _f(self.x_delta_edit, "XD")
|
_f(self.x_delta_edit, "XD"), _f(self.y_delta_edit, "YD"),
|
||||||
y_delta = _f(self.y_delta_edit, "YD")
|
_i(self.num_angles_edit, "NumAngles"),
|
||||||
num_angles = _i(self.num_angles_edit, "NumAngles")
|
_f(self.row_spacing_edit, "RowSpacing"),
|
||||||
row_spacing = _f(self.row_spacing_edit, "RowSpacing")
|
laser_freq_hz=LASER_FREQ_HZ, velocity_mm_s=SCAN_VELOCITY_MM_S,
|
||||||
prefix = self.scan_prefix_edit.text().strip() or "scan"
|
rotation_sign=GR_ROTATION_SIGN,
|
||||||
save_dir = self.scan_save_dir_edit.text().strip() or DEFAULT_SAVE_DIR
|
)
|
||||||
|
prefix = self.scan_prefix_edit.text().strip() or "scan"
|
||||||
if x_delta <= 0:
|
save_dir = self.scan_save_dir_edit.text().strip() or DEFAULTS.save_dir
|
||||||
raise ValueError("XD must be > 0")
|
return plan, prefix, save_dir
|
||||||
if row_spacing <= 0:
|
|
||||||
raise ValueError("RowSpacing must be > 0")
|
|
||||||
if num_angles < 1:
|
|
||||||
raise ValueError("NumAngles must be ≥ 1")
|
|
||||||
|
|
||||||
# Signed so the recorded/commanded angle sequence reflects the GR
|
|
||||||
# stage's actual physical rotation direction (see GR_ROTATION_SIGN).
|
|
||||||
if num_angles > 1:
|
|
||||||
angles = [GR_ROTATION_SIGN * i * 180.0 / (num_angles - 1) for i in range(num_angles)]
|
|
||||||
else:
|
|
||||||
angles = [0.0]
|
|
||||||
|
|
||||||
# Each angle only needs to physically scan the bounding box of the
|
|
||||||
# nominal (x_start, y_start, x_delta, y_delta) rectangle rotated by
|
|
||||||
# THAT angle -- not the worst case across all angles -- so the X
|
|
||||||
# extent (and therefore points/row) and the row count are computed
|
|
||||||
# per angle instead of once globally.
|
|
||||||
cx = x_start + x_delta / 2.0
|
|
||||||
cy = y_start + y_delta / 2.0
|
|
||||||
per_angle = []
|
|
||||||
for a in angles:
|
|
||||||
r = math.radians(a)
|
|
||||||
bb_w = abs(x_delta * math.cos(r)) + abs(y_delta * math.sin(r))
|
|
||||||
bb_h = abs(x_delta * math.sin(r)) + abs(y_delta * math.cos(r))
|
|
||||||
a_x_start = cx - bb_w / 2.0
|
|
||||||
a_y_start = cy - bb_h / 2.0
|
|
||||||
a_n_rows = max(1, round(bb_h / row_spacing) + 1) if bb_h > 0 else 1
|
|
||||||
a_n_frames = max(1, round(bb_w * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S))
|
|
||||||
per_angle.append({
|
|
||||||
"angle": a,
|
|
||||||
"x_start": a_x_start,
|
|
||||||
"x_delta": bb_w,
|
|
||||||
"n_frames": a_n_frames,
|
|
||||||
"n_rows": a_n_rows,
|
|
||||||
"y_positions": [a_y_start + i * row_spacing for i in range(a_n_rows)],
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"x_start_nominal": x_start, "y_start_nominal": y_start,
|
|
||||||
"x_delta_nominal": x_delta, "y_delta_nominal": y_delta,
|
|
||||||
"num_angles": num_angles,
|
|
||||||
"row_spacing": row_spacing,
|
|
||||||
"laser_freq": DEFAULT_LASER_FREQ_HZ,
|
|
||||||
"prefix": prefix,
|
|
||||||
"save_dir": save_dir,
|
|
||||||
"per_angle": per_angle,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _on_row_done(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
|
def _on_row_done(self, row: int, n_rows: int, angle_idx: int, n_angles: int):
|
||||||
self._scan_progress.update_progress(row, n_rows, angle_idx, n_angles)
|
self._scan_progress.update_progress(row, n_rows, angle_idx, n_angles)
|
||||||
|
|||||||
+33
-93
@@ -22,12 +22,9 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BLOB_MAGIC = b"SRAS"
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
BLOB_VERSION = 6
|
|
||||||
HDR_FMT = ">4sBHfffffffIdBB"
|
from core.sras_format import GEOM_FMT, HDR_FMT, MAGIC, VERSION as BLOB_VERSION, SrasFile
|
||||||
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
|
|
||||||
GEOM_FMT = ">ffIH"
|
|
||||||
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -58,94 +55,37 @@ class SrasScanFile:
|
|||||||
self._parse()
|
self._parse()
|
||||||
|
|
||||||
def _parse(self):
|
def _parse(self):
|
||||||
file_size = self.path.stat().st_size
|
sras = SrasFile(self.path)
|
||||||
with open(self.path, "rb") as f:
|
h = sras.header
|
||||||
raw = f.read(HDR_SIZE)
|
self.x_start_nominal = h.x_start_nominal
|
||||||
if len(raw) < HDR_SIZE:
|
self.y_start_nominal = h.y_start_nominal
|
||||||
raise ValueError(f"{self.path.name}: file too short for a valid header")
|
self.x_delta_nominal = h.x_delta_nominal
|
||||||
(magic, version, n_angles, x_start_nom, y_start_nom, x_delta_nom,
|
self.y_delta_nominal = h.y_delta_nominal
|
||||||
y_delta_nom, row_spacing, velocity, laser_freq, samples_per_frame,
|
self.row_spacing_mm = h.row_spacing
|
||||||
sample_rate, bytes_per_sample, n_channels) = struct.unpack(HDR_FMT, raw)
|
self.velocity_mm_s = h.velocity
|
||||||
|
self.laser_freq_hz = h.laser_freq
|
||||||
|
self.samples_per_frame = h.samples_per_frame
|
||||||
|
self.sample_rate_hz = h.sample_rate
|
||||||
|
self.bytes_per_sample = h.bytes_per_sample
|
||||||
|
self.n_channels = h.n_channels
|
||||||
|
self.preambles_raw = sras.preambles_raw
|
||||||
|
self.background_raw = sras.background
|
||||||
|
self.data_start_offset = sras.data_start_offset
|
||||||
|
self.file_size = sras.file_size
|
||||||
|
|
||||||
if magic != BLOB_MAGIC:
|
self.angles = [
|
||||||
raise ValueError(f"{self.path.name}: bad magic {magic!r}, not a .sras file")
|
AngleEntry(
|
||||||
if version != BLOB_VERSION:
|
index=st.index, angle_deg=st.angle_deg,
|
||||||
raise ValueError(
|
x_start=pa.x_start, x_delta=pa.x_delta,
|
||||||
f"{self.path.name}: unsupported format version {version} "
|
n_frames=pa.n_frames, n_rows_declared=pa.n_rows,
|
||||||
f"(this tool only supports v{BLOB_VERSION})")
|
y_positions=pa.y_positions, row_bytes=st.row_bytes,
|
||||||
|
data_offset=st.data_offset,
|
||||||
self.x_start_nominal = x_start_nom
|
n_rows_available=st.n_rows_available,
|
||||||
self.y_start_nominal = y_start_nom
|
data_size_available=st.n_rows_available * st.row_bytes,
|
||||||
self.x_delta_nominal = x_delta_nom
|
complete=st.complete,
|
||||||
self.y_delta_nominal = y_delta_nom
|
|
||||||
self.row_spacing_mm = row_spacing
|
|
||||||
self.velocity_mm_s = velocity
|
|
||||||
self.laser_freq_hz = laser_freq
|
|
||||||
self.samples_per_frame = samples_per_frame
|
|
||||||
self.sample_rate_hz = sample_rate
|
|
||||||
self.bytes_per_sample = bytes_per_sample
|
|
||||||
self.n_channels = n_channels
|
|
||||||
|
|
||||||
angles = list(struct.unpack(f">{n_angles}f", f.read(4 * n_angles)))
|
|
||||||
|
|
||||||
geoms = []
|
|
||||||
for _ in range(n_angles):
|
|
||||||
x_start, x_delta, n_frames, n_rows = struct.unpack(GEOM_FMT, f.read(GEOM_SIZE))
|
|
||||||
geoms.append((x_start, x_delta, n_frames, n_rows))
|
|
||||||
|
|
||||||
row_tables = []
|
|
||||||
for (_, _, _, n_rows) in geoms:
|
|
||||||
row_tables.append(list(struct.unpack(f">{n_rows}f", f.read(4 * n_rows))))
|
|
||||||
|
|
||||||
preambles_raw = []
|
|
||||||
for _ in range(n_channels):
|
|
||||||
(plen,) = struct.unpack(">H", f.read(2))
|
|
||||||
preambles_raw.append(f.read(plen))
|
|
||||||
self.preambles_raw = preambles_raw
|
|
||||||
|
|
||||||
(n_bg,) = struct.unpack(">I", f.read(4))
|
|
||||||
self.background_raw = f.read(n_bg)
|
|
||||||
|
|
||||||
data_start_offset = f.tell()
|
|
||||||
|
|
||||||
# Build angle entries and compute what's actually present on disk,
|
|
||||||
# in case the file was closed early (aborted scan) — see scan_format.md's
|
|
||||||
# "Incomplete files" note. Waveform data is angle-major/row-minor with a
|
|
||||||
# fixed per-row byte count within an angle, so we walk cumulative offsets.
|
|
||||||
self.angles = []
|
|
||||||
cursor = data_start_offset
|
|
||||||
truncated_seen = False
|
|
||||||
for i, (angle, (x_start, x_delta, n_frames, n_rows)) in enumerate(zip(angles, geoms)):
|
|
||||||
row_bytes = n_channels * n_frames * samples_per_frame * bytes_per_sample
|
|
||||||
entry = AngleEntry(
|
|
||||||
index=i, angle_deg=angle, x_start=x_start, x_delta=x_delta,
|
|
||||||
n_frames=n_frames, n_rows_declared=n_rows,
|
|
||||||
y_positions=row_tables[i], row_bytes=row_bytes,
|
|
||||||
data_offset=cursor,
|
|
||||||
)
|
)
|
||||||
if truncated_seen:
|
for pa, st in zip(sras.per_angle, sras.angle_status(), strict=True)
|
||||||
entry.n_rows_available = 0
|
]
|
||||||
entry.data_size_available = 0
|
|
||||||
entry.complete = False
|
|
||||||
else:
|
|
||||||
declared_bytes = row_bytes * n_rows
|
|
||||||
if row_bytes > 0 and cursor + declared_bytes <= file_size:
|
|
||||||
entry.n_rows_available = n_rows
|
|
||||||
entry.data_size_available = declared_bytes
|
|
||||||
entry.complete = True
|
|
||||||
cursor += declared_bytes
|
|
||||||
else:
|
|
||||||
remaining = max(0, file_size - cursor)
|
|
||||||
n_complete = remaining // row_bytes if row_bytes > 0 else 0
|
|
||||||
entry.n_rows_available = n_complete
|
|
||||||
entry.data_size_available = n_complete * row_bytes
|
|
||||||
entry.complete = (n_complete == n_rows)
|
|
||||||
cursor += entry.data_size_available
|
|
||||||
truncated_seen = True
|
|
||||||
self.angles.append(entry)
|
|
||||||
|
|
||||||
self.data_start_offset = data_start_offset
|
|
||||||
self.file_size = file_size
|
|
||||||
|
|
||||||
def get(self, index: int) -> AngleEntry:
|
def get(self, index: int) -> AngleEntry:
|
||||||
return self.angles[index]
|
return self.angles[index]
|
||||||
@@ -165,7 +105,7 @@ def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
|||||||
selected = [sf.get(i) for i in indices]
|
selected = [sf.get(i) for i in indices]
|
||||||
|
|
||||||
header = struct.pack(
|
header = struct.pack(
|
||||||
HDR_FMT, BLOB_MAGIC, BLOB_VERSION, len(selected),
|
HDR_FMT, MAGIC, BLOB_VERSION, len(selected),
|
||||||
sf.x_start_nominal, sf.y_start_nominal,
|
sf.x_start_nominal, sf.y_start_nominal,
|
||||||
sf.x_delta_nominal, sf.y_delta_nominal,
|
sf.x_delta_nominal, sf.y_delta_nominal,
|
||||||
sf.row_spacing_mm, sf.velocity_mm_s, sf.laser_freq_hz,
|
sf.row_spacing_mm, sf.velocity_mm_s, sf.laser_freq_hz,
|
||||||
|
|||||||
+3
-11
@@ -28,8 +28,8 @@ from PyQt6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from hardware.t3r_driver import T3RDriver
|
from hardware.t3r_driver import T3RDriver
|
||||||
|
from hardware.serial_util import scored_ports
|
||||||
import hardware.t3r_protocol as proto
|
import hardware.t3r_protocol as proto
|
||||||
import serial.tools.list_ports
|
|
||||||
|
|
||||||
|
|
||||||
# ── Utilities ─────────────────────────────────────────────────────────────────
|
# ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
@@ -613,16 +613,8 @@ class T3RControlPanel(QDialog):
|
|||||||
def _refresh_ports(self):
|
def _refresh_ports(self):
|
||||||
current = self.port_combo.currentText()
|
current = self.port_combo.currentText()
|
||||||
self.port_combo.clear()
|
self.port_combo.clear()
|
||||||
ports = list(serial.tools.list_ports.comports())
|
for device, label in scored_ports():
|
||||||
|
self.port_combo.addItem(label, device)
|
||||||
def score(p):
|
|
||||||
text = f"{p.description} {p.manufacturer or ''} {p.product or ''}".lower()
|
|
||||||
hints = ("esp32", "jtag", "espressif", "usb serial", "cp210", "ch340", "cdc")
|
|
||||||
return -sum(h in text for h in hints)
|
|
||||||
|
|
||||||
ports.sort(key=score)
|
|
||||||
for p in ports:
|
|
||||||
self.port_combo.addItem(f"{p.device} — {p.description or p.device}", p.device)
|
|
||||||
if self.port_combo.count() == 0:
|
if self.port_combo.count() == 0:
|
||||||
self.port_combo.addItem("(no serial ports found)", None)
|
self.port_combo.addItem("(no serial ports found)", None)
|
||||||
elif current:
|
elif current:
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
"""Generate golden fixtures capturing PRE-REFACTOR behavior (Phase 0).
|
|
||||||
|
|
||||||
Run once against the un-refactored sc3_aui_app.py; the outputs are committed
|
|
||||||
under tests/golden/. After the refactor, tests compare the new
|
|
||||||
implementations against these committed files — do NOT regenerate them
|
|
||||||
against refactored code, that would defeat the purpose.
|
|
||||||
|
|
||||||
Usage: .venv/bin/python tests/gen_goldens.py
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
import conftest # noqa: F401 (pyueye stub + offscreen)
|
|
||||||
|
|
||||||
import sc3_aui_app as app
|
|
||||||
|
|
||||||
GOLDEN = Path(__file__).parent / "golden"
|
|
||||||
|
|
||||||
# ── Geometry fixtures ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
GEOMETRY_CASES = [
|
|
||||||
# label, XS, YS, XD, YD, num_angles, row_spacing
|
|
||||||
("single_angle_square", "10.0", "10.0", "10.0", "10.0", "1", "0.25"),
|
|
||||||
("single_angle_rect", "10.0", "10.0", "80.0", "50.0", "1", "0.25"),
|
|
||||||
("three_angles", "5.0", "5.0", "20.0", "12.0", "3", "0.1"),
|
|
||||||
("five_angles", "0.0", "0.0", "30.0", "30.0", "5", "0.5"),
|
|
||||||
("seven_angles_asym", "12.5", "7.5", "42.0", "17.0", "7", "0.05"),
|
|
||||||
("two_angles_tiny", "1.0", "1.0", "0.02", "0.02", "2", "0.01"),
|
|
||||||
("flat_line_ydelta_zero", "10.0", "10.0", "5.0", "0.0", "1", "0.25"),
|
|
||||||
("fine_spacing", "3.0", "4.0", "2.5", "1.5", "4", "0.025"),
|
|
||||||
]
|
|
||||||
|
|
||||||
GEOMETRY_ERROR_CASES = [
|
|
||||||
("xd_zero", "10.0", "10.0", "0.0", "10.0", "1", "0.25"),
|
|
||||||
("xd_negative", "10.0", "10.0", "-5.0", "10.0", "1", "0.25"),
|
|
||||||
("spacing_zero", "10.0", "10.0", "10.0", "10.0", "1", "0.0"),
|
|
||||||
("angles_zero", "10.0", "10.0", "10.0", "10.0", "0", "0.25"),
|
|
||||||
("xs_not_number", "abc", "10.0", "10.0", "10.0", "1", "0.25"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class _W:
|
|
||||||
"""Stands in for a QLineEdit: _build_scan_params only calls .text()."""
|
|
||||||
def __init__(self, text):
|
|
||||||
self._text = text
|
|
||||||
|
|
||||||
def text(self):
|
|
||||||
return self._text
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_window(xs, ys, xd, yd, na, rs):
|
|
||||||
fs = type("FakeMainWindow", (), {})()
|
|
||||||
fs.x_start_edit = _W(xs)
|
|
||||||
fs.y_start_edit = _W(ys)
|
|
||||||
fs.x_delta_edit = _W(xd)
|
|
||||||
fs.y_delta_edit = _W(yd)
|
|
||||||
fs.num_angles_edit = _W(na)
|
|
||||||
fs.row_spacing_edit = _W(rs)
|
|
||||||
fs.scan_prefix_edit = _W("golden")
|
|
||||||
fs.scan_save_dir_edit = _W("/tmp/golden-scans")
|
|
||||||
return fs
|
|
||||||
|
|
||||||
|
|
||||||
def build_geometry_fixtures():
|
|
||||||
out = {"constants": {
|
|
||||||
"LASER_FREQ_HZ": app.LASER_FREQ_HZ,
|
|
||||||
"SCAN_VELOCITY_MM_S": app.SCAN_VELOCITY_MM_S,
|
|
||||||
"GR_ROTATION_SIGN": app.GR_ROTATION_SIGN,
|
|
||||||
}, "cases": {}, "error_cases": {}}
|
|
||||||
|
|
||||||
for label, *inputs in GEOMETRY_CASES:
|
|
||||||
params = app.MainWindow._build_scan_params(_fake_window(*inputs))
|
|
||||||
out["cases"][label] = {
|
|
||||||
"inputs": dict(zip(("XS", "YS", "XD", "YD", "num_angles", "row_spacing"), inputs)),
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
for label, *inputs in GEOMETRY_ERROR_CASES:
|
|
||||||
try:
|
|
||||||
app.MainWindow._build_scan_params(_fake_window(*inputs))
|
|
||||||
raise AssertionError(f"error case {label} did not raise")
|
|
||||||
except ValueError as e:
|
|
||||||
out["error_cases"][label] = {
|
|
||||||
"inputs": dict(zip(("XS", "YS", "XD", "YD", "num_angles", "row_spacing"), inputs)),
|
|
||||||
"message": str(e),
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(GOLDEN / "geometry.json", "w") as f:
|
|
||||||
json.dump(out, f, indent=2)
|
|
||||||
print(f"geometry.json: {len(out['cases'])} cases, {len(out['error_cases'])} error cases")
|
|
||||||
|
|
||||||
|
|
||||||
# ── SRAS v6 file fixtures ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
SPF = 8 # samples per frame (synthetic, tiny)
|
|
||||||
SAMPLE_RATE = 6.25e9
|
|
||||||
PREAMBLES = [f"WFMOUTPRE:CH{ch};SYNTHETIC;PT_FMT Y;XINCR 1.6E-10" for ch in app.SCAN_CHANNELS]
|
|
||||||
BACKGROUND = bytes(range(SPF))
|
|
||||||
|
|
||||||
|
|
||||||
def _synthetic_frame(ai, ri, ci, fi):
|
|
||||||
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
|
|
||||||
|
|
||||||
|
|
||||||
def build_sras_fixtures():
|
|
||||||
# Geometry via the real pre-refactor code path: 2 angles, 3 rows, 4 frames.
|
|
||||||
params = app.MainWindow._build_scan_params(
|
|
||||||
_fake_window("1.0", "1.0", "0.02", "0.02", "2", "0.01"))
|
|
||||||
per_angle = params["per_angle"]
|
|
||||||
angles = [pa["angle"] for pa in per_angle]
|
|
||||||
|
|
||||||
complete = GOLDEN / "complete.sras"
|
|
||||||
f = app._open_scan_file(
|
|
||||||
complete, angles, per_angle,
|
|
||||||
params["x_start_nominal"], params["y_start_nominal"],
|
|
||||||
params["x_delta_nominal"], params["y_delta_nominal"],
|
|
||||||
params["row_spacing"],
|
|
||||||
app.SCAN_VELOCITY_MM_S, app.LASER_FREQ_HZ,
|
|
||||||
SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
data_start = f.tell()
|
|
||||||
for ai, pa in enumerate(per_angle):
|
|
||||||
for ri in range(pa["n_rows"]):
|
|
||||||
for ci in range(len(app.SCAN_CHANNELS)):
|
|
||||||
for fi in range(pa["n_frames"]):
|
|
||||||
f.write(_synthetic_frame(ai, ri, ci, fi))
|
|
||||||
finally:
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
info = app._read_sras_header(complete)
|
|
||||||
assert info["data_start_offset"] == data_start
|
|
||||||
row_bytes = info["n_channels"] * per_angle[0]["n_frames"] * SPF * info["bytes_per_sample"]
|
|
||||||
angle_bytes = [row_bytes * pa["n_rows"] for pa in per_angle]
|
|
||||||
|
|
||||||
def _truncate(name, size):
|
|
||||||
dst = GOLDEN / name
|
|
||||||
shutil.copyfile(complete, dst)
|
|
||||||
with open(dst, "r+b") as g:
|
|
||||||
g.truncate(data_start + size)
|
|
||||||
return dst
|
|
||||||
|
|
||||||
variants = {
|
|
||||||
"complete.sras": complete,
|
|
||||||
"trunc_midrow_a1.sras": _truncate(
|
|
||||||
"trunc_midrow_a1.sras", angle_bytes[0] + row_bytes + row_bytes // 2),
|
|
||||||
"trunc_rowboundary_a1.sras": _truncate(
|
|
||||||
"trunc_rowboundary_a1.sras", angle_bytes[0] + 2 * row_bytes),
|
|
||||||
"trunc_angleboundary.sras": _truncate(
|
|
||||||
"trunc_angleboundary.sras", angle_bytes[0]),
|
|
||||||
"trunc_midrow_a0.sras": _truncate(
|
|
||||||
"trunc_midrow_a0.sras", row_bytes + row_bytes // 2),
|
|
||||||
"header_only.sras": _truncate("header_only.sras", 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
expected = {"header": info, "statuses": {}}
|
|
||||||
for name, path in variants.items():
|
|
||||||
expected["statuses"][name] = app._compute_angle_status(path, info)
|
|
||||||
|
|
||||||
with open(GOLDEN / "sras_expected.json", "w") as f_json:
|
|
||||||
json.dump(expected, f_json, indent=2)
|
|
||||||
print(f"sras fixtures: {len(variants)} files, "
|
|
||||||
f"data block {sum(angle_bytes)} bytes, row_bytes={row_bytes}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
GOLDEN.mkdir(exist_ok=True)
|
|
||||||
build_geometry_fixtures()
|
|
||||||
build_sras_fixtures()
|
|
||||||
print("done")
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Shared constants for the golden .sras fixtures.
|
||||||
|
|
||||||
|
These mirror the values tests/gen_goldens.py used when the fixtures were
|
||||||
|
generated against the pre-refactor code (commit d185676); they must never
|
||||||
|
change, or the byte-identical comparisons stop meaning anything.
|
||||||
|
"""
|
||||||
|
SPF = 8
|
||||||
|
SAMPLE_RATE = 6.25e9
|
||||||
|
CHANNELS = [1, 3, 4]
|
||||||
|
PREAMBLES = [f"WFMOUTPRE:CH{ch};SYNTHETIC;PT_FMT Y;XINCR 1.6E-10" for ch in CHANNELS]
|
||||||
|
BACKGROUND = bytes(range(SPF))
|
||||||
|
|
||||||
|
# build_plan inputs for the fixture geometry: 2 angles × 3 rows × 4 frames
|
||||||
|
TINY_PLAN_ARGS = dict(x_start=1.0, y_start=1.0, x_delta=0.02, y_delta=0.02,
|
||||||
|
num_angles=2, row_spacing=0.01)
|
||||||
|
LASER_FREQ_HZ = 20000.0
|
||||||
|
VELOCITY_MM_S = 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def synthetic_frame(ai, ri, ci, fi):
|
||||||
|
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""core.config: round-trip, tolerance, and the helios_port regression.
|
||||||
|
|
||||||
|
The old dict-based writer rebuilt the JSON from only the main window's
|
||||||
|
fields, silently discarding helios_port every time a port was edited.
|
||||||
|
ScanDefaults.save() always writes every field.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from core.config import ScanDefaults
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip(tmp_path):
|
||||||
|
p = tmp_path / "defaults.json"
|
||||||
|
d = ScanDefaults(t3r_port="/dev/ttyACM3", helios_port="/dev/ttyUSB9")
|
||||||
|
d.save(p)
|
||||||
|
loaded = ScanDefaults.load(p)
|
||||||
|
assert loaded == d
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_creates_defaults(tmp_path):
|
||||||
|
p = tmp_path / "defaults.json"
|
||||||
|
d = ScanDefaults.load(p)
|
||||||
|
assert d == ScanDefaults()
|
||||||
|
assert p.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_file_falls_back(tmp_path):
|
||||||
|
p = tmp_path / "defaults.json"
|
||||||
|
p.write_text("{not json")
|
||||||
|
assert ScanDefaults.load(p) == ScanDefaults()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_keys_ignored(tmp_path):
|
||||||
|
p = tmp_path / "defaults.json"
|
||||||
|
p.write_text(json.dumps({"t3r_port": "/dev/ttyACM7", "laser_freq_hz": 20000.0}))
|
||||||
|
d = ScanDefaults.load(p)
|
||||||
|
assert d.t3r_port == "/dev/ttyACM7"
|
||||||
|
assert d.helios_port == ScanDefaults().helios_port
|
||||||
|
|
||||||
|
|
||||||
|
def test_helios_port_survives_partial_update(tmp_path):
|
||||||
|
"""Regression: editing main-window ports must not clobber helios_port."""
|
||||||
|
p = tmp_path / "defaults.json"
|
||||||
|
ScanDefaults(helios_port="/dev/ttyUSB7").save(p)
|
||||||
|
|
||||||
|
d = ScanDefaults.load(p)
|
||||||
|
d.t3r_port = "/dev/ttyACM1" # what _persist_defaults does
|
||||||
|
d.save(p)
|
||||||
|
|
||||||
|
assert ScanDefaults.load(p).helios_port == "/dev/ttyUSB7"
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""Golden-fixture consistency tests.
|
|
||||||
|
|
||||||
Phase 0: assert the pre-refactor implementations reproduce the committed
|
|
||||||
fixtures. Phase 2 migrates these assertions onto core.sras_format /
|
|
||||||
core.scan_geometry; the fixtures themselves never change.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import sc3_aui_app as app
|
|
||||||
from gen_goldens import (
|
|
||||||
BACKGROUND, PREAMBLES, SAMPLE_RATE, SPF, _fake_window, _synthetic_frame,
|
|
||||||
)
|
|
||||||
|
|
||||||
GOLDEN = Path(__file__).parent / "golden"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def geometry():
|
|
||||||
with open(GOLDEN / "geometry.json") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def sras_expected():
|
|
||||||
with open(GOLDEN / "sras_expected.json") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def test_geometry_cases_match(geometry):
|
|
||||||
for label, case in geometry["cases"].items():
|
|
||||||
inputs = case["inputs"]
|
|
||||||
params = app.MainWindow._build_scan_params(_fake_window(*inputs.values()))
|
|
||||||
assert params == case["params"], f"geometry mismatch for case {label}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_geometry_error_cases_raise(geometry):
|
|
||||||
for label, case in geometry["error_cases"].items():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
app.MainWindow._build_scan_params(_fake_window(*case["inputs"].values()))
|
|
||||||
|
|
||||||
|
|
||||||
def test_writer_output_byte_identical(tmp_path, sras_expected):
|
|
||||||
params = app.MainWindow._build_scan_params(
|
|
||||||
_fake_window("1.0", "1.0", "0.02", "0.02", "2", "0.01"))
|
|
||||||
per_angle = params["per_angle"]
|
|
||||||
angles = [pa["angle"] for pa in per_angle]
|
|
||||||
|
|
||||||
out = tmp_path / "rewrite.sras"
|
|
||||||
f = app._open_scan_file(
|
|
||||||
out, angles, per_angle,
|
|
||||||
params["x_start_nominal"], params["y_start_nominal"],
|
|
||||||
params["x_delta_nominal"], params["y_delta_nominal"],
|
|
||||||
params["row_spacing"],
|
|
||||||
app.SCAN_VELOCITY_MM_S, app.LASER_FREQ_HZ,
|
|
||||||
SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
for ai, pa in enumerate(per_angle):
|
|
||||||
for ri in range(pa["n_rows"]):
|
|
||||||
for ci in range(len(app.SCAN_CHANNELS)):
|
|
||||||
for fi in range(pa["n_frames"]):
|
|
||||||
f.write(_synthetic_frame(ai, ri, ci, fi))
|
|
||||||
finally:
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
assert out.read_bytes() == (GOLDEN / "complete.sras").read_bytes()
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_parse_matches(sras_expected):
|
|
||||||
info = app._read_sras_header(GOLDEN / "complete.sras")
|
|
||||||
assert info == sras_expected["header"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_angle_status_all_variants(sras_expected):
|
|
||||||
info = app._read_sras_header(GOLDEN / "complete.sras")
|
|
||||||
for name, expected in sras_expected["statuses"].items():
|
|
||||||
statuses = app._compute_angle_status(GOLDEN / name, info)
|
|
||||||
assert statuses == expected, f"status mismatch for {name}"
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""core.scan_geometry vs the pre-refactor golden geometry fixtures,
|
||||||
|
plus structural invariants and travel-limit validation."""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.scan_geometry import (
|
||||||
|
EtaEstimator, ScanGeometryError, StageLimits, build_plan, format_eta,
|
||||||
|
validate_plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
GOLDEN = Path(__file__).parent / "golden"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def geometry():
|
||||||
|
with open(GOLDEN / "geometry.json") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_from_case(case, consts):
|
||||||
|
i = case["inputs"]
|
||||||
|
return build_plan(
|
||||||
|
float(i["XS"]), float(i["YS"]), float(i["XD"]), float(i["YD"]),
|
||||||
|
int(i["num_angles"]), float(i["row_spacing"]),
|
||||||
|
laser_freq_hz=consts["LASER_FREQ_HZ"],
|
||||||
|
velocity_mm_s=consts["SCAN_VELOCITY_MM_S"],
|
||||||
|
rotation_sign=consts["GR_ROTATION_SIGN"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_golden_cases_match(geometry):
|
||||||
|
consts = geometry["constants"]
|
||||||
|
for label, case in geometry["cases"].items():
|
||||||
|
plan = _plan_from_case(case, consts)
|
||||||
|
exp = case["params"]
|
||||||
|
assert plan.x_start_nominal == exp["x_start_nominal"], label
|
||||||
|
assert plan.y_start_nominal == exp["y_start_nominal"], label
|
||||||
|
assert plan.x_delta_nominal == exp["x_delta_nominal"], label
|
||||||
|
assert plan.y_delta_nominal == exp["y_delta_nominal"], label
|
||||||
|
assert plan.row_spacing == exp["row_spacing"], label
|
||||||
|
assert plan.n_angles == exp["num_angles"], label
|
||||||
|
assert len(plan.per_angle) == len(exp["per_angle"]), label
|
||||||
|
for pa, e in zip(plan.per_angle, exp["per_angle"], strict=True):
|
||||||
|
assert pa.angle_deg == e["angle"], label
|
||||||
|
assert pa.x_start == e["x_start"], label
|
||||||
|
assert pa.x_delta == e["x_delta"], label
|
||||||
|
assert pa.n_frames == e["n_frames"], label
|
||||||
|
assert pa.n_rows == e["n_rows"], label
|
||||||
|
assert pa.y_positions == e["y_positions"], label
|
||||||
|
|
||||||
|
|
||||||
|
def test_golden_error_cases_raise(geometry):
|
||||||
|
consts = geometry["constants"]
|
||||||
|
for case in geometry["error_cases"].values():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_plan_from_case(case, consts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_degree_bbox_equals_nominal_roi():
|
||||||
|
plan = build_plan(10.0, 5.0, 20.0, 8.0, 1, 0.5,
|
||||||
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
pa = plan.per_angle[0]
|
||||||
|
assert pa.angle_deg == 0.0
|
||||||
|
assert math.isclose(pa.x_start, 10.0)
|
||||||
|
assert math.isclose(pa.x_delta, 20.0)
|
||||||
|
assert math.isclose(pa.y_positions[0], 5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotated_bbox_contains_all_roi_corners():
|
||||||
|
plan = build_plan(30.0, 20.0, 24.0, 10.0, 7, 0.1,
|
||||||
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
cy = 20.0 + 5.0
|
||||||
|
corners = [(-12.0, -5.0), (12.0, -5.0), (-12.0, 5.0), (12.0, 5.0)]
|
||||||
|
for pa in plan.per_angle:
|
||||||
|
r = math.radians(pa.angle_deg)
|
||||||
|
half_w = pa.x_delta / 2.0
|
||||||
|
y_lo, y_hi = min(pa.y_positions), max(pa.y_positions)
|
||||||
|
for dx, dy in corners:
|
||||||
|
# ROI corner in the rotated frame
|
||||||
|
rx = dx * math.cos(r) - dy * math.sin(r)
|
||||||
|
ry = dx * math.sin(r) + dy * math.cos(r)
|
||||||
|
assert abs(rx) <= half_w + 1e-9, pa.angle_deg
|
||||||
|
# Row grid covers within one row-spacing at the edges
|
||||||
|
assert y_lo - 0.1 - 1e-9 <= cy + ry <= y_hi + 0.1 + 1e-9, pa.angle_deg
|
||||||
|
|
||||||
|
|
||||||
|
def test_plus_minus_theta_symmetry():
|
||||||
|
a = build_plan(10, 10, 20, 10, 5, 0.25, laser_freq_hz=20000.0,
|
||||||
|
velocity_mm_s=100.0, rotation_sign=1)
|
||||||
|
b = build_plan(10, 10, 20, 10, 5, 0.25, laser_freq_hz=20000.0,
|
||||||
|
velocity_mm_s=100.0, rotation_sign=-1)
|
||||||
|
for pa, pb in zip(a.per_angle, b.per_angle, strict=True):
|
||||||
|
assert pa.angle_deg == -pb.angle_deg
|
||||||
|
assert math.isclose(pa.x_delta, pb.x_delta)
|
||||||
|
assert pa.n_rows == pb.n_rows
|
||||||
|
assert pa.n_frames == pb.n_frames
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_plan_limit_violations():
|
||||||
|
limits = StageLimits()
|
||||||
|
ramp, buf = 100.0**2 / (2 * 1500.0), 1.0
|
||||||
|
|
||||||
|
ok = build_plan(20.0, 10.0, 40.0, 30.0, 3, 0.25,
|
||||||
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
validate_plan(ok, ramp, buf, limits) # must not raise
|
||||||
|
|
||||||
|
too_left = build_plan(2.0, 10.0, 40.0, 30.0, 1, 0.25,
|
||||||
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
with pytest.raises(ScanGeometryError, match="pre-ramp start"):
|
||||||
|
validate_plan(too_left, ramp, buf, limits)
|
||||||
|
|
||||||
|
too_right = build_plan(80.0, 10.0, 40.0, 30.0, 1, 0.25,
|
||||||
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
with pytest.raises(ScanGeometryError, match="run-off end"):
|
||||||
|
validate_plan(too_right, ramp, buf, limits)
|
||||||
|
|
||||||
|
too_low = build_plan(30.0, -5.0, 40.0, 30.0, 1, 0.25,
|
||||||
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
with pytest.raises(ScanGeometryError, match="Y axis minimum"):
|
||||||
|
validate_plan(too_low, ramp, buf, limits)
|
||||||
|
|
||||||
|
too_high = build_plan(30.0, 60.0, 40.0, 30.0, 1, 0.25,
|
||||||
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
with pytest.raises(ScanGeometryError, match="Y axis maximum"):
|
||||||
|
validate_plan(too_high, ramp, buf, limits)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_eta():
|
||||||
|
assert format_eta(-3) == "0s"
|
||||||
|
assert format_eta(42) == "42s"
|
||||||
|
assert format_eta(90) == "1m 30s"
|
||||||
|
assert format_eta(3720) == "1h 02m"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eta_estimator_rolls_and_resets_on_angle_change():
|
||||||
|
eta = EtaEstimator(window=3)
|
||||||
|
assert eta.eta_secs(5) is None
|
||||||
|
t = 100.0
|
||||||
|
for dur in (2.0, 4.0, 6.0, 8.0):
|
||||||
|
eta.row_started(now=t)
|
||||||
|
eta.row_finished(angle_idx=1, now=t + dur)
|
||||||
|
t += dur
|
||||||
|
# window=3 keeps [4, 6, 8] → avg 6
|
||||||
|
assert eta.eta_secs(2) == pytest.approx(12.0)
|
||||||
|
# angle change wipes history
|
||||||
|
eta.row_started(now=t)
|
||||||
|
eta.row_finished(angle_idx=2, now=t + 10.0)
|
||||||
|
assert eta.eta_secs(3) == pytest.approx(30.0)
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""core.sras_format vs the pre-refactor golden fixtures.
|
||||||
|
|
||||||
|
The goldens were produced by the original sc3_aui_app implementation; the
|
||||||
|
extracted module must reproduce them byte-for-byte (writer) and
|
||||||
|
field-for-field (parser + frontier walk).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.scan_geometry import build_plan
|
||||||
|
from core.sras_format import SrasFile, create_scan_file
|
||||||
|
from golden_util import (
|
||||||
|
BACKGROUND, CHANNELS, LASER_FREQ_HZ, PREAMBLES, SAMPLE_RATE, SPF,
|
||||||
|
TINY_PLAN_ARGS, VELOCITY_MM_S, synthetic_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
GOLDEN = Path(__file__).parent / "golden"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def expected():
|
||||||
|
with open(GOLDEN / "sras_expected.json") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _tiny_plan():
|
||||||
|
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ,
|
||||||
|
velocity_mm_s=VELOCITY_MM_S)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_complete(path):
|
||||||
|
plan = _tiny_plan()
|
||||||
|
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND)
|
||||||
|
try:
|
||||||
|
for ai, pa in enumerate(plan.per_angle):
|
||||||
|
for ri in range(pa.n_rows):
|
||||||
|
for ci in range(len(CHANNELS)):
|
||||||
|
for fi in range(pa.n_frames):
|
||||||
|
f.write(synthetic_frame(ai, ri, ci, fi))
|
||||||
|
finally:
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_writer_byte_identical_to_golden(tmp_path):
|
||||||
|
out = tmp_path / "rewrite.sras"
|
||||||
|
_write_complete(out)
|
||||||
|
assert out.read_bytes() == (GOLDEN / "complete.sras").read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_matches_golden(expected):
|
||||||
|
sras = SrasFile(GOLDEN / "complete.sras")
|
||||||
|
h = expected["header"]
|
||||||
|
assert asdict(sras.header) == {
|
||||||
|
"n_angles": h["n_angles"],
|
||||||
|
"x_start_nominal": h["x_start_nominal"], "y_start_nominal": h["y_start_nominal"],
|
||||||
|
"x_delta_nominal": h["x_delta_nominal"], "y_delta_nominal": h["y_delta_nominal"],
|
||||||
|
"row_spacing": h["row_spacing"], "velocity": h["velocity"],
|
||||||
|
"laser_freq": h["laser_freq"],
|
||||||
|
"samples_per_frame": h["samples_per_frame"], "sample_rate": h["sample_rate"],
|
||||||
|
"bytes_per_sample": h["bytes_per_sample"], "n_channels": h["n_channels"],
|
||||||
|
}
|
||||||
|
assert sras.data_start_offset == h["data_start_offset"]
|
||||||
|
assert [pa.angle_deg for pa in sras.per_angle] == h["angles"]
|
||||||
|
for pa, exp in zip(sras.per_angle, h["per_angle"], strict=True):
|
||||||
|
assert pa.angle_deg == exp["angle"]
|
||||||
|
assert pa.x_start == exp["x_start"]
|
||||||
|
assert pa.x_delta == exp["x_delta"]
|
||||||
|
assert pa.n_frames == exp["n_frames"]
|
||||||
|
assert pa.n_rows == exp["n_rows"]
|
||||||
|
assert pa.y_positions == exp["y_positions"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontier_all_truncation_variants(expected):
|
||||||
|
for name, exp_statuses in expected["statuses"].items():
|
||||||
|
statuses = SrasFile(GOLDEN / name).angle_status()
|
||||||
|
assert [asdict(s) for s in statuses] == exp_statuses, f"mismatch for {name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preambles_and_background_roundtrip():
|
||||||
|
sras = SrasFile(GOLDEN / "complete.sras")
|
||||||
|
assert sras.preambles == PREAMBLES
|
||||||
|
assert sras.background == BACKGROUND
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_angle_memmap_equals_eager():
|
||||||
|
with SrasFile(GOLDEN / "complete.sras") as sras:
|
||||||
|
raw = (GOLDEN / "complete.sras").read_bytes()
|
||||||
|
for ai, pa in enumerate(sras.per_angle):
|
||||||
|
view = sras.load_angle(ai)
|
||||||
|
h = sras.header
|
||||||
|
assert view.shape == (pa.n_rows, h.n_channels, pa.n_frames, h.samples_per_frame)
|
||||||
|
start = sras.angle_data_offset(ai)
|
||||||
|
eager = np.frombuffer(
|
||||||
|
raw, dtype=np.int8, offset=start, count=view.size
|
||||||
|
).reshape(view.shape)
|
||||||
|
assert np.array_equal(view, eager)
|
||||||
|
assert not view.flags.writeable
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_row_matches_synthetic_pattern():
|
||||||
|
with SrasFile(GOLDEN / "complete.sras") as sras:
|
||||||
|
for ai in range(2):
|
||||||
|
for ri in range(3):
|
||||||
|
for ci in range(3):
|
||||||
|
row = sras.load_row(ai, ri, ci)
|
||||||
|
expected_bytes = b"".join(
|
||||||
|
synthetic_frame(ai, ri, ci, fi)
|
||||||
|
for fi in range(sras.per_angle[ai].n_frames)
|
||||||
|
)
|
||||||
|
assert row.tobytes() == expected_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_load_angle_partial_rows():
|
||||||
|
sras = SrasFile(GOLDEN / "trunc_rowboundary_a1.sras")
|
||||||
|
st = sras.angle_status()[1]
|
||||||
|
assert st.status == "TRUNCATED" and st.n_rows_available == 2
|
||||||
|
view = sras.load_angle(1, n_rows=st.n_rows_available)
|
||||||
|
assert view.shape[0] == 2
|
||||||
|
sras.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_magic_and_version_rejected(tmp_path):
|
||||||
|
bad = tmp_path / "bad.sras"
|
||||||
|
bad.write_bytes(b"XXXX" + bytes(60))
|
||||||
|
with pytest.raises(ValueError, match="bad magic"):
|
||||||
|
SrasFile(bad)
|
||||||
|
|
||||||
|
data = bytearray((GOLDEN / "complete.sras").read_bytes())
|
||||||
|
data[4] = 5 # version byte
|
||||||
|
v5 = tmp_path / "v5.sras"
|
||||||
|
v5.write_bytes(bytes(data))
|
||||||
|
with pytest.raises(ValueError, match="version 5"):
|
||||||
|
SrasFile(v5)
|
||||||
Reference in New Issue
Block a user