Compare commits
24 Commits
refactor-01
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dff7048429 | |||
| 43337bfa67 | |||
| 92bbf02c8e | |||
| b473aac6aa | |||
| 76ed7828cc | |||
| eff589d901 | |||
| c7eb33891c | |||
| aeac5fe4d6 | |||
| ff68901c57 | |||
| 6e8c1cb7a2 | |||
| 083cbdaa34 | |||
| aa06fa1460 | |||
| 83744c3337 | |||
| f823e2eb42 | |||
| c588be523e | |||
| 844fcd0297 | |||
| dfd6c9e2b8 | |||
| 23546a03f7 | |||
| 817da0160c | |||
| 52fdcdd9f3 | |||
| ab5f3166f4 | |||
| ef8c0feb91 | |||
| 116c9c07c7 | |||
| d6a56266b7 |
+72
-5
@@ -25,11 +25,12 @@ anywhere in this repo's protocol notes. `helios_test_app.py` called it
|
|||||||
anyway and raised `AttributeError` into a popup; the button is now disabled
|
anyway and raised `AttributeError` into a popup; the button is now disabled
|
||||||
and the handler reports the gap instead.
|
and the handler reports the gap instead.
|
||||||
|
|
||||||
**Bench check:** find the power-read command in the Helios manual (the
|
**Answered (2026-09-08):** the mnemonic is `HMP` (Table 6-3, "Laser Power
|
||||||
other reads are three-letter mnemonics like `LDO`, `LDS`, `LTA`). If one
|
Monitor", read-only, 0-5000 mW) — but the table adds "not available on all
|
||||||
exists, add `get_power_mw()` to `hardware/helios_laser.py` using
|
models", and the operator confirms this rig's laser has no power meter. So
|
||||||
`_query_int`, then re-enable the button. If it doesn't, delete the Power
|
the button stays disabled; what remains is to delete the Power Monitoring
|
||||||
Monitoring group from the test app and fix the README.
|
group from `helios_test_app.py` and drop the power-monitoring claims from
|
||||||
|
`docs/hardware/HELIOS_DRIVER_README.md`.
|
||||||
|
|
||||||
## Genesis laser: forked protocol implementations disagree
|
## Genesis laser: forked protocol implementations disagree
|
||||||
|
|
||||||
@@ -48,3 +49,69 @@ rig, check whether the camera apps run without the shim; if they do, delete
|
|||||||
`lib/ueye_loader.{c,so}`. Either way, record in SETUP.md where
|
`lib/ueye_loader.{c,so}`. Either way, record in SETUP.md where
|
||||||
`libueye_api64.so.3.82` came from (IDS SDK version) and how the loader is
|
`libueye_api64.so.3.82` came from (IDS SDK version) and how the loader is
|
||||||
meant to be used.
|
meant to be used.
|
||||||
|
|
||||||
|
## Per-angle background: trigger round trip mid-scan
|
||||||
|
|
||||||
|
Every angle now captures its own background, so the scope switches from the
|
||||||
|
scan-time logic-AND trigger back to the single-record edge trigger and
|
||||||
|
returns to it once per angle (`core/scope_sras.py`:
|
||||||
|
`configure_background_trigger` → `capture_background` →
|
||||||
|
`configure_scan_trigger`). Before this, that transition happened once per
|
||||||
|
scan, with the stage idle and nothing depending on how long it took.
|
||||||
|
|
||||||
|
**Bench check:** run a multi-angle scan and watch the first row after each
|
||||||
|
background. If frames go missing at the start of an angle, the 0.2 s settle
|
||||||
|
in `configure_scan_trigger` is not enough for FastFrame to re-arm after an
|
||||||
|
AVERAGE-mode sequence, and the row-packing warning ("N frames acquired, M
|
||||||
|
expected") will say so in the log. Raise the settle rather than the ramp
|
||||||
|
buffer — the stage geometry is not what changed.
|
||||||
|
|
||||||
|
## Auto-align: constants that are guesses until the rig confirms them
|
||||||
|
|
||||||
|
`core/auto_align.py` closes a loop over hardware whose gain nobody has
|
||||||
|
measured. Three numbers in `AlignSettings`/`TAxisSettings` are reasoned
|
||||||
|
defaults, not readings:
|
||||||
|
|
||||||
|
- `probe_steps = 200` — the first move of every search, made only to learn how
|
||||||
|
many millivolts a microstep is worth. Too small and each search wastes
|
||||||
|
iterations doubling it (the status line says so: "moved N microsteps and the
|
||||||
|
DC difference did not change"); too large and the first move overshoots by
|
||||||
|
more than the platform should be asked to travel in one go.
|
||||||
|
- `max_excursion_steps = 20000` — the per-axis safety limit, measured from
|
||||||
|
wherever the axis started. It exists to stop a runaway before the actuator
|
||||||
|
reaches its end stop, so it has to be smaller than the real travel.
|
||||||
|
- `hold_current_ma = 300` — the run current (600 mA) and microstepping (32)
|
||||||
|
are specified; the standstill current is half the run current by analogy
|
||||||
|
with the GR axis, and has not been checked against the platform's weight.
|
||||||
|
|
||||||
|
**Bench check:** run one auto-align and read the log. The first search's
|
||||||
|
iteration count is the probe verdict — 3 or 4 steps means the probe is about
|
||||||
|
right, and a "did not change by 2 mV" line means it is too small. Convert the
|
||||||
|
applied corrections into actuator travel and compare against the T-axis
|
||||||
|
travel to set the excursion limit. Watch whether the platform holds its tilt
|
||||||
|
between the two phases; if it sags, raise the hold current.
|
||||||
|
|
||||||
|
## Auto-align: does the X phase's piston matter, and where is the pivot?
|
||||||
|
|
||||||
|
The X phase moves T1 alone, as specified. T1 is the only axis lying along X,
|
||||||
|
so it does tilt the platform along X — but moving one leg of three also lifts
|
||||||
|
the platform by a third of the move (`tilt_response(X_TILT)` returns a piston
|
||||||
|
of 1/3 alongside the 2/3 tilt). The search nulls the split-detector
|
||||||
|
difference, which a piston should not move, so the assumption is that the
|
||||||
|
piston is harmless. The piston-free alternative is T1 +1 with T0 and T2 at
|
||||||
|
−0.5 each.
|
||||||
|
|
||||||
|
Separately, the procedure assumes the tilt pivot is under the beam: if it is
|
||||||
|
not, applying the correction shifts the DC levels at the reference point
|
||||||
|
itself, and the Y phase then chases levels that no longer describe the rig.
|
||||||
|
The code reports this rather than compensating for it — `AxisResult`'s
|
||||||
|
"back at the reference" reading after the X phase is exactly that
|
||||||
|
measurement.
|
||||||
|
|
||||||
|
**Bench check:** during an X search, watch DC1 + DC2 (the sum, not the
|
||||||
|
difference) on the scope. If the sum moves as T1 moves, the piston is
|
||||||
|
changing the amount of collected light and `X_TILT` should become the
|
||||||
|
piston-free triple. Then read the X phase's reference residual out of the
|
||||||
|
log: more than a few millivolts means the pivot is not under the beam, and
|
||||||
|
the Y phase's reference should be re-measured after the X correction instead
|
||||||
|
of reusing the operator's original numbers.
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ scanengine-3 is a unified platform for scanning acoustic microscopy and precisio
|
|||||||
- **Laser Systems**: Helios pulsed laser and Genesis CW laser control
|
- **Laser Systems**: Helios pulsed laser and Genesis CW laser control
|
||||||
- **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support
|
- **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support
|
||||||
- **Scan Planning**: Automated raster scan generation and execution
|
- **Scan Planning**: Automated raster scan generation and execution
|
||||||
|
- **Per-Angle Background**: every angle opens with its own background
|
||||||
|
capture (Genesis off, Helios on), stored ahead of that angle's data
|
||||||
|
- **Angle Inspection**: Park the rig at random points across a plan's angles
|
||||||
|
to check the SAW response on the scope before committing to a long scan
|
||||||
|
- **SAW Quality Check**: Acquire one row per angle — the row-wise middle of
|
||||||
|
the ROI — as a v11 `.sras`, then compare every angle's SAW frequency on one
|
||||||
|
graph to judge the alignment before a full run
|
||||||
|
- **Auto-Align**: Level the sample from the camera window — step the stage
|
||||||
|
1.5 mm either side on X and then Y, tilt the T-axes until the DC bias levels
|
||||||
|
read what they read at the reference point, and leave the correction applied
|
||||||
- **Real-time Monitoring**: Live status updates and progress tracking
|
- **Real-time Monitoring**: Live status updates and progress tracking
|
||||||
|
|
||||||
## Hardware Components
|
## Hardware Components
|
||||||
@@ -55,8 +65,13 @@ scanengine-3/
|
|||||||
│ ├── scan_geometry.py # ScanPlan, rotated-bbox planning, limits
|
│ ├── scan_geometry.py # ScanPlan, rotated-bbox planning, limits
|
||||||
│ ├── scan_resume.py # Resume planning (frontier rule)
|
│ ├── scan_resume.py # Resume planning (frontier rule)
|
||||||
│ ├── scope_sras.py # Oscilloscope SCPI policy for SRAS
|
│ ├── scope_sras.py # Oscilloscope SCPI policy for SRAS
|
||||||
|
│ ├── scope_burst.py # Burst-mode FastFrame sizing + row splitting
|
||||||
|
│ ├── scope_inspect.py # Scope setup for inspection + bias read-back
|
||||||
|
│ ├── angle_inspect.py # AngleInspector — park on a point per angle
|
||||||
|
│ ├── auto_align.py # AutoAligner — tilt the sample level on the DC levels
|
||||||
|
│ ├── saw_check.py # Middle-row SAW check: plan + alignment read-out
|
||||||
│ ├── rotation.py # GR rotation axis settings + moves
|
│ ├── rotation.py # GR rotation axis settings + moves
|
||||||
│ ├── sras_format.py # v6 .sras writer/reader (memory-mapped)
|
│ ├── sras_format.py # v7/v11 .sras writer, v6/v10 reader (mmap)
|
||||||
│ ├── sras_analysis.py # Image reducers + SAW matched filter
|
│ ├── sras_analysis.py # Image reducers + SAW matched filter
|
||||||
│ └── config.py # ScanDefaults ⇄ aui_defaults.json
|
│ └── config.py # ScanDefaults ⇄ aui_defaults.json
|
||||||
│
|
│
|
||||||
@@ -72,12 +87,16 @@ scanengine-3/
|
|||||||
│
|
│
|
||||||
├── gui/ # Shared PyQt6 layer
|
├── gui/ # Shared PyQt6 layer
|
||||||
│ ├── scan_bridge.py # QtScanController over core.scan_engine
|
│ ├── scan_bridge.py # QtScanController over core.scan_engine
|
||||||
|
│ ├── inspect_bridge.py # QtAngleInspector over core.angle_inspect
|
||||||
|
│ ├── align_bridge.py # QtAutoAligner over core.auto_align
|
||||||
│ ├── qt_t3r.py # Qt adapter over the T3R driver
|
│ ├── qt_t3r.py # Qt adapter over the T3R driver
|
||||||
│ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases
|
│ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases
|
||||||
|
│ ├── jog_panel.py # T3R + BBD202 jog controls (camera window)
|
||||||
│ └── widgets.py # ConnectionBar, LogConsole, PortSelector…
|
│ └── widgets.py # ConnectionBar, LogConsole, PortSelector…
|
||||||
│
|
│
|
||||||
├── sc3_aui_app.py # Main acquisition application
|
├── sc3_aui_app.py # Main acquisition application
|
||||||
├── sras_viewer.py # Scan data viewer
|
├── sras_viewer.py # Scan data viewer
|
||||||
|
├── saw_check_viewer.py # SAW check viewer: every angle's frequency, one graph
|
||||||
├── sras_scan_manager.py # CLI: inspect/export/delete angles
|
├── sras_scan_manager.py # CLI: inspect/export/delete angles
|
||||||
├── t3r_control_panel.py # T3R panel (used by the main app)
|
├── t3r_control_panel.py # T3R panel (used by the main app)
|
||||||
├── helios_test_app.py # Per-device test benches
|
├── helios_test_app.py # Per-device test benches
|
||||||
@@ -86,7 +105,7 @@ scanengine-3/
|
|||||||
├── sc3-aui-*.ui # Qt Designer files loaded at runtime
|
├── sc3-aui-*.ui # Qt Designer files loaded at runtime
|
||||||
│
|
│
|
||||||
├── tests/ # pytest suite
|
├── tests/ # pytest suite
|
||||||
│ ├── golden/ # v6 .sras + geometry fixtures
|
│ ├── golden/ # legacy v6 .sras + geometry fixtures
|
||||||
│ ├── fakes.py # Recording fake stage/scope/rotator
|
│ ├── fakes.py # Recording fake stage/scope/rotator
|
||||||
│ └── test_*.py
|
│ └── test_*.py
|
||||||
│
|
│
|
||||||
@@ -127,6 +146,9 @@ python sc3_aui_app.py
|
|||||||
# Scan data viewer
|
# Scan data viewer
|
||||||
python sras_viewer.py
|
python sras_viewer.py
|
||||||
|
|
||||||
|
# SAW quality check viewer (every angle's frequency on one graph)
|
||||||
|
python saw_check_viewer.py path/to/scan-sawcheck.sras
|
||||||
|
|
||||||
# Inspect / export / delete angles in a .sras file
|
# Inspect / export / delete angles in a .sras file
|
||||||
python sras_scan_manager.py path/to/scan.sras
|
python sras_scan_manager.py path/to/scan.sras
|
||||||
|
|
||||||
@@ -202,6 +224,59 @@ result = engine.run() # blocking; engine.abort() is thread-safe
|
|||||||
print(f"wrote {result.rows_written} rows to {result.path}")
|
print(f"wrote {result.rows_written} rows to {result.path}")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Running a SAW quality check
|
||||||
|
|
||||||
|
Same engine, same hardware sequence — the plan is reduced to one row per
|
||||||
|
angle and the result is tagged v11 so the viewer knows it is a check rather
|
||||||
|
than a scan cut short:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.saw_check import alignment_summary, frequency_traces, middle_row_plan
|
||||||
|
from core.sras_format import VERSION_SAW_CHECK, SrasFile
|
||||||
|
|
||||||
|
check = middle_row_plan(plan) # the plan above: 163 rows → 3
|
||||||
|
engine = ScanEngine(stage, scope, RotationAxis(t3r), check,
|
||||||
|
Path("/data/SRAS/demo-sawcheck.sras"),
|
||||||
|
callbacks=ScanCallbacks(on_status=print),
|
||||||
|
file_version=VERSION_SAW_CHECK)
|
||||||
|
engine.run()
|
||||||
|
|
||||||
|
with SrasFile("/data/SRAS/demo-sawcheck.sras") as sras:
|
||||||
|
traces = frequency_traces(sras, dc_threshold_mv=50.0)
|
||||||
|
for t in traces:
|
||||||
|
print(f"{t.angle_deg:+7.1f}° {t.median_mhz:.2f} MHz "
|
||||||
|
f"drift {t.drift_mhz_per_mm:+.3f} MHz/mm")
|
||||||
|
print(alignment_summary(traces).describe())
|
||||||
|
```
|
||||||
|
|
||||||
|
`saw_check_viewer.py` is the same read-out with the curves drawn.
|
||||||
|
|
||||||
|
### Levelling the sample (auto-align)
|
||||||
|
|
||||||
|
Two phases, because the operator sits between them: `prepare()` configures
|
||||||
|
the rig and reads the DC levels where the stage stands, and `run()` only
|
||||||
|
starts once those levels have been confirmed as the ones to hold.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.auto_align import AlignCallbacks, AutoAligner
|
||||||
|
|
||||||
|
aligner = AutoAligner(stage, scope, t3r,
|
||||||
|
callbacks=AlignCallbacks(on_status=print))
|
||||||
|
reference = aligner.prepare() # scope + T-axes configured, one reading
|
||||||
|
print(reference.describe()) # "is the image correct?" happens here
|
||||||
|
result = aligner.run() # X on T1, then Y on T0/T2
|
||||||
|
print(result.describe())
|
||||||
|
aligner.stop() # stage parked; the tilt stays applied
|
||||||
|
```
|
||||||
|
|
||||||
|
The scope has to be cabled CH1 SAW / CH2 trigger / CH3 DC 1 / CH4 DC 2 — the
|
||||||
|
same channels a scan uses, except that CH3 carries the DC monitor here rather
|
||||||
|
than the max-velocity gate. Nothing rewires it; the app asks the operator to
|
||||||
|
confirm the cabling, and refuses to servo on a scope that is not triggering.
|
||||||
|
|
||||||
|
In the main app the button is in the camera window, because judging the image
|
||||||
|
is the first step of the procedure.
|
||||||
|
|
||||||
### Reading a scan file
|
### Reading a scan file
|
||||||
|
|
||||||
`SrasFile` memory-maps the data block, so opening a multi-gigabyte scan
|
`SrasFile` memory-maps the data block, so opening a multi-gigabyte scan
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Pre-scan angle inspection: park the rig on a point and let the operator look.
|
||||||
|
|
||||||
|
A multi-angle scan can take hours, and an angle that responds poorly produces
|
||||||
|
rows that look fine in the file but carry no usable SAW packet. This drives
|
||||||
|
the rig through the same angles the scan will use, parking at a random point
|
||||||
|
inside each angle's own bounding box so the response can be judged on the
|
||||||
|
oscilloscope before committing to the run.
|
||||||
|
|
||||||
|
Headless and Qt-free, like ScanEngine: gui/inspect_bridge.py wraps it.
|
||||||
|
|
||||||
|
No waveform ever crosses this boundary. The operator reads the scope screen
|
||||||
|
directly; this module's job is only to put the hardware in the right place and
|
||||||
|
the scope in a state worth looking at (see core.scope_inspect).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from core import scope_inspect
|
||||||
|
from core.rotation import RotationAxis
|
||||||
|
from core.scan_engine import (
|
||||||
|
AXIS_X, AXIS_Y, SCAN_ACCEL_MM_S2, SCAN_VELOCITY_MM_S,
|
||||||
|
)
|
||||||
|
from core.scan_geometry import DEFAULT_STAGE_LIMITS, ScanPlan, StageLimits
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Positioning moves only — no data is taken while moving, so there is no
|
||||||
|
# reason to cross the tray at full scan velocity.
|
||||||
|
INSPECT_VELOCITY_MM_S = SCAN_VELOCITY_MM_S / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InspectionPoint:
|
||||||
|
"""Where the rig is parked, and which angle it is parked for."""
|
||||||
|
angle_idx: int
|
||||||
|
angle_deg: float
|
||||||
|
x_mm: float
|
||||||
|
y_mm: float
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return (f"Angle {self.angle_idx + 1} ({self.angle_deg:.1f}°) "
|
||||||
|
f"X={self.x_mm:.3f} mm Y={self.y_mm:.3f} mm")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InspectCallbacks:
|
||||||
|
"""Progress reporting. Defaults are no-ops so the core needs no front end."""
|
||||||
|
on_status: Callable[[str], None] = lambda msg: None
|
||||||
|
on_point: Callable[[InspectionPoint], None] = lambda pt: None
|
||||||
|
on_busy: Callable[[bool], None] = lambda busy: None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _State:
|
||||||
|
angle_idx: int = 0
|
||||||
|
point: InspectionPoint | None = None
|
||||||
|
started: bool = False
|
||||||
|
rotator_ready: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AngleInspector:
|
||||||
|
"""Drives stage + rotator to inspection points across a plan's angles."""
|
||||||
|
|
||||||
|
def __init__(self, stage, scope, rotator: RotationAxis | None,
|
||||||
|
plan: ScanPlan,
|
||||||
|
callbacks: InspectCallbacks | None = None,
|
||||||
|
limits: StageLimits = DEFAULT_STAGE_LIMITS,
|
||||||
|
rng: random.Random | None = None):
|
||||||
|
self._stage = stage
|
||||||
|
self._scope = scope
|
||||||
|
self._rotator = rotator
|
||||||
|
self._plan = plan
|
||||||
|
self._cb = callbacks if callbacks is not None else InspectCallbacks()
|
||||||
|
self._limits = limits
|
||||||
|
# Injectable so tests can pin the point selection.
|
||||||
|
self._rng = rng if rng is not None else random.Random()
|
||||||
|
self._st = _State()
|
||||||
|
|
||||||
|
# ── Introspection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def n_angles(self) -> int:
|
||||||
|
return self._plan.n_angles
|
||||||
|
|
||||||
|
@property
|
||||||
|
def angle_idx(self) -> int:
|
||||||
|
return self._st.angle_idx
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_point(self) -> InspectionPoint | None:
|
||||||
|
return self._st.point
|
||||||
|
|
||||||
|
def angle_labels(self) -> list[str]:
|
||||||
|
return [f"Angle {i + 1}/{self.n_angles} — {pa.angle_deg:.2f}°"
|
||||||
|
for i, pa in enumerate(self._plan.per_angle)]
|
||||||
|
|
||||||
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def start(self) -> InspectionPoint:
|
||||||
|
"""Configure the hardware and park on the first angle."""
|
||||||
|
if self._stage is None:
|
||||||
|
raise RuntimeError("BBD202 not connected")
|
||||||
|
if self._scope is None:
|
||||||
|
raise RuntimeError("Oscilloscope not connected")
|
||||||
|
|
||||||
|
self._st.rotator_ready = (self._rotator is not None
|
||||||
|
and self._rotator.is_available)
|
||||||
|
if self.n_angles > 1 and not self._st.rotator_ready:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Inspecting {self.n_angles} angles requires the T3R rotation "
|
||||||
|
"stage (GR-axis), but it is not connected. Connect T3R from "
|
||||||
|
"the T3R panel, or inspect a single-angle plan."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._cb.on_busy(True)
|
||||||
|
try:
|
||||||
|
self._cb.on_status("Configuring stage for inspection …")
|
||||||
|
ctrl = self._stage
|
||||||
|
for axis in (AXIS_X, AXIS_Y):
|
||||||
|
ctrl.set_velocity_params(axis,
|
||||||
|
max_velocity=INSPECT_VELOCITY_MM_S,
|
||||||
|
acceleration=SCAN_ACCEL_MM_S2)
|
||||||
|
# Nothing here is gated, and an armed trigger output would keep
|
||||||
|
# driving the gate line on every positioning move.
|
||||||
|
ctrl.set_trigger_gate_off(AXIS_X)
|
||||||
|
|
||||||
|
if self._st.rotator_ready:
|
||||||
|
self._cb.on_status("Configuring GR axis …")
|
||||||
|
self._rotator.configure()
|
||||||
|
|
||||||
|
self._cb.on_status("Configuring oscilloscope for inspection …")
|
||||||
|
scope_inspect.configure_inspection(self._scope)
|
||||||
|
|
||||||
|
self._st.started = True
|
||||||
|
return self._goto(0, new_point=True)
|
||||||
|
finally:
|
||||||
|
self._cb.on_busy(False)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the sweep and send the rotator home. Safe to call twice."""
|
||||||
|
if not self._st.started:
|
||||||
|
return
|
||||||
|
self._st.started = False
|
||||||
|
self._cb.on_busy(True)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
scope_inspect.stop_inspection(self._scope)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Could not stop the inspection acquisition")
|
||||||
|
if self._st.rotator_ready and abs(self._rotator.current_deg) > 0.001:
|
||||||
|
self._cb.on_status("Returning GR to home …")
|
||||||
|
try:
|
||||||
|
self._rotator.return_to_zero()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("GR return-to-home failed")
|
||||||
|
self._cb.on_status("Inspection finished.")
|
||||||
|
finally:
|
||||||
|
self._cb.on_busy(False)
|
||||||
|
|
||||||
|
# ── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def goto_angle(self, angle_idx: int) -> InspectionPoint:
|
||||||
|
"""Rotate to `angle_idx` and park on a fresh random point there."""
|
||||||
|
self._require_started()
|
||||||
|
self._cb.on_busy(True)
|
||||||
|
try:
|
||||||
|
return self._goto(angle_idx, new_point=True)
|
||||||
|
finally:
|
||||||
|
self._cb.on_busy(False)
|
||||||
|
|
||||||
|
def next_angle(self) -> InspectionPoint:
|
||||||
|
"""Advance one angle, wrapping at the end."""
|
||||||
|
return self.goto_angle((self._st.angle_idx + 1) % self.n_angles)
|
||||||
|
|
||||||
|
def prev_angle(self) -> InspectionPoint:
|
||||||
|
return self.goto_angle((self._st.angle_idx - 1) % self.n_angles)
|
||||||
|
|
||||||
|
def new_point(self) -> InspectionPoint:
|
||||||
|
"""Re-roll the point within the current angle, without rotating.
|
||||||
|
|
||||||
|
One point can be unrepresentative — a bad spot on the sample looks the
|
||||||
|
same as a bad angle. Re-rolling a few times is how you tell them
|
||||||
|
apart, so this deliberately skips the rotation.
|
||||||
|
"""
|
||||||
|
self._require_started()
|
||||||
|
self._cb.on_busy(True)
|
||||||
|
try:
|
||||||
|
return self._goto(self._st.angle_idx, new_point=True, rotate=False)
|
||||||
|
finally:
|
||||||
|
self._cb.on_busy(False)
|
||||||
|
|
||||||
|
# ── Internals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _require_started(self):
|
||||||
|
if not self._st.started:
|
||||||
|
raise RuntimeError("Inspection has not been started")
|
||||||
|
|
||||||
|
def _goto(self, angle_idx: int, new_point: bool,
|
||||||
|
rotate: bool = True) -> InspectionPoint:
|
||||||
|
if not 0 <= angle_idx < self.n_angles:
|
||||||
|
raise IndexError(
|
||||||
|
f"Angle {angle_idx} out of range (plan has {self.n_angles})")
|
||||||
|
|
||||||
|
pa = self._plan.per_angle[angle_idx]
|
||||||
|
self._st.angle_idx = angle_idx
|
||||||
|
|
||||||
|
if rotate and self._st.rotator_ready:
|
||||||
|
delta = pa.angle_deg - self._rotator.current_deg
|
||||||
|
if abs(delta) > 0.001:
|
||||||
|
self._cb.on_status(
|
||||||
|
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
|
||||||
|
self._rotator.rotate_to(pa.angle_deg)
|
||||||
|
|
||||||
|
point = self._pick_point(angle_idx) if new_point else self._st.point
|
||||||
|
|
||||||
|
self._cb.on_status(f"Moving to {point.describe()} …")
|
||||||
|
# Y first, then X — the same order the scan uses to reach a row.
|
||||||
|
self._stage.move_axis_absolute(AXIS_Y, point.y_mm, timeout=60.0)
|
||||||
|
self._stage.move_axis_absolute(AXIS_X, point.x_mm, timeout=60.0)
|
||||||
|
|
||||||
|
self._st.point = point
|
||||||
|
self._cb.on_point(point)
|
||||||
|
self._cb.on_status(f"Parked at {point.describe()}")
|
||||||
|
return point
|
||||||
|
|
||||||
|
def _pick_point(self, angle_idx: int) -> InspectionPoint:
|
||||||
|
"""A random point on this angle's scan grid.
|
||||||
|
|
||||||
|
Y is drawn from the angle's actual row positions and X uniformly from
|
||||||
|
its data window, so the point is somewhere the scan would really
|
||||||
|
sample — not merely inside the bounding box.
|
||||||
|
"""
|
||||||
|
pa = self._plan.per_angle[angle_idx]
|
||||||
|
if not pa.y_positions:
|
||||||
|
raise ValueError(f"Angle {angle_idx + 1} has no rows to inspect")
|
||||||
|
|
||||||
|
y = self._rng.choice(pa.y_positions)
|
||||||
|
x = self._rng.uniform(pa.x_start, pa.x_start + pa.x_delta)
|
||||||
|
|
||||||
|
lim = self._limits
|
||||||
|
if not (lim.x_min <= x <= lim.x_max and lim.y_min <= y <= lim.y_max):
|
||||||
|
raise ValueError(
|
||||||
|
f"Inspection point X={x:.3f} Y={y:.3f} is outside the stage "
|
||||||
|
f"travel ({lim.x_min}–{lim.x_max} × {lim.y_min}–{lim.y_max} mm)"
|
||||||
|
)
|
||||||
|
return InspectionPoint(angle_idx=angle_idx, angle_deg=pa.angle_deg,
|
||||||
|
x_mm=x, y_mm=y)
|
||||||
@@ -0,0 +1,708 @@
|
|||||||
|
"""Auto-align: level the sample against the stage's travel plane.
|
||||||
|
|
||||||
|
The operator frames a good spot by eye and confirms the DC bias levels the
|
||||||
|
detector reads there. Those two numbers — DC 1 on CH3, DC 2 on CH4 — are the
|
||||||
|
definition of "aligned" for this rig, and they are the only thing this module
|
||||||
|
optimises.
|
||||||
|
|
||||||
|
Why moving the stage tells you about tilt: the detection beam is fixed in
|
||||||
|
space and the XY stage carries the sample under it, so the height of the
|
||||||
|
surface under the beam is ``h(x) = h0 + theta * x`` when the sample sits at an
|
||||||
|
angle ``theta`` to the travel plane. Step 1.5 mm along X and the bias levels
|
||||||
|
move by ``theta * 1.5``; tilt the platform until they read what they read at
|
||||||
|
the reference point and you have measured ``theta`` directly, because a
|
||||||
|
platform tilt changes the height under the beam in proportion to x as well.
|
||||||
|
The correction that fixes the offset point is therefore the same correction
|
||||||
|
that levels the whole travel — which is why this procedure ends by applying
|
||||||
|
it and leaving it applied.
|
||||||
|
|
||||||
|
Both directions are measured, from the same starting tilt, and the two answers
|
||||||
|
are averaged. On a flat sample they agree; a disagreement is the read-out
|
||||||
|
saying the surface is not a plane (or that the platform has backlash), and it
|
||||||
|
is reported rather than averaged away silently.
|
||||||
|
|
||||||
|
The three T-axes form a tip/tilt platform. Their azimuths on the platform
|
||||||
|
(see T_AXIS_AZIMUTH_DEG) decide which axis corrects which stage direction:
|
||||||
|
T1 lies along +X, so it alone tilts the platform along X; T0 and T2 sit at
|
||||||
|
+/-120 degrees from it and have to move as an equal-and-opposite pair to tilt
|
||||||
|
along Y without also tilting along X. ``tilt_response`` derives that from the
|
||||||
|
azimuths, so a re-plumbed platform is a one-line change to the azimuth map and
|
||||||
|
not a re-derivation of the whole procedure.
|
||||||
|
|
||||||
|
Qt-free, like ScanEngine and AngleInspector: gui/align_bridge.py wraps it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from core import scope_inspect
|
||||||
|
from core.scan_engine import (
|
||||||
|
AXIS_X, AXIS_Y, SCAN_ACCEL_MM_S2, SCAN_VELOCITY_MM_S,
|
||||||
|
)
|
||||||
|
from core.scan_geometry import DEFAULT_STAGE_LIMITS, StageLimits
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Where each T-axis sits on the tilt platform, in degrees from the stage's +X
|
||||||
|
# axis. T1 is co-linear with +X; T0 and T2 are the other two legs of the
|
||||||
|
# kinematic triangle. This map is the whole geometry — everything else about
|
||||||
|
# which axis moves when is derived from it.
|
||||||
|
T_AXIS_AZIMUTH_DEG = {0: 120.0, 1: 0.0, 2: 240.0}
|
||||||
|
T_AXES = tuple(sorted(T_AXIS_AZIMUTH_DEG))
|
||||||
|
T_AXIS_LABELS = {ch: f"T{ch}" for ch in T_AXES}
|
||||||
|
|
||||||
|
# Positioning moves only, so there is no reason to cross the tray at full scan
|
||||||
|
# velocity — the same halving the angle inspector uses.
|
||||||
|
ALIGN_VELOCITY_MM_S = SCAN_VELOCITY_MM_S / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
class AutoAlignError(RuntimeError):
|
||||||
|
"""The procedure cannot continue: bad rig state, or nothing responding."""
|
||||||
|
|
||||||
|
|
||||||
|
class AutoAlignAborted(RuntimeError):
|
||||||
|
"""The operator stopped the procedure part-way through."""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Platform geometry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TiltGroup:
|
||||||
|
"""The T-axis move that tilts the platform along one stage axis.
|
||||||
|
|
||||||
|
``weights`` maps a T-axis channel to the microsteps it contributes per
|
||||||
|
unit of correction, so a correction of ``c`` moves channel ``ch`` by
|
||||||
|
``c * weights[ch]``.
|
||||||
|
"""
|
||||||
|
stage_axis: int # APT axis address of the stage axis this corrects
|
||||||
|
label: str # "X" or "Y", for the operator
|
||||||
|
weights: dict[int, float]
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
if len(self.weights) == 1:
|
||||||
|
return T_AXIS_LABELS[next(iter(self.weights))]
|
||||||
|
return " / ".join(f"{T_AXIS_LABELS[ch]} {w:+.0f}"
|
||||||
|
for ch, w in sorted(self.weights.items()))
|
||||||
|
|
||||||
|
|
||||||
|
# X is corrected by the one axis that lies along it, and Y by the other two
|
||||||
|
# driven equal and opposite — that pairing is what makes the Y move a pure
|
||||||
|
# tilt along Y (tilt_response(Y_TILT) has no X term), so the two phases of the
|
||||||
|
# procedure do not fight each other. Moving T1 alone does raise the platform
|
||||||
|
# as well as tilt it, which the search absorbs: it nulls a measured level, not
|
||||||
|
# a model of the platform.
|
||||||
|
X_TILT = TiltGroup(AXIS_X, "X", {1: +1.0})
|
||||||
|
Y_TILT = TiltGroup(AXIS_Y, "Y", {0: +1.0, 2: -1.0})
|
||||||
|
TILT_GROUPS = (X_TILT, Y_TILT)
|
||||||
|
|
||||||
|
|
||||||
|
def tilt_response(group: TiltGroup) -> tuple[float, float, float]:
|
||||||
|
"""What one unit of ``group`` does to the platform: (piston, x_tilt, y_tilt).
|
||||||
|
|
||||||
|
The three actuators define a plane, so their heights fix it exactly:
|
||||||
|
fitting ``z = piston + x_tilt * x + y_tilt * y`` through the three
|
||||||
|
(azimuth, weight) points is a closed-form solution on a symmetric triangle
|
||||||
|
— the mean is the piston and the projections onto x and y are the tilts,
|
||||||
|
scaled by 2/3 because each actuator sits one unit radius out.
|
||||||
|
|
||||||
|
Used to check the groups above are the moves they claim to be, and to say
|
||||||
|
in one place what "moving T0 and T2 as a pair" actually produces.
|
||||||
|
"""
|
||||||
|
heights = {ch: group.weights.get(ch, 0.0) for ch in T_AXES}
|
||||||
|
piston = sum(heights.values()) / len(T_AXES)
|
||||||
|
x_tilt = y_tilt = 0.0
|
||||||
|
for ch, h in heights.items():
|
||||||
|
theta = math.radians(T_AXIS_AZIMUTH_DEG[ch])
|
||||||
|
x_tilt += h * math.cos(theta)
|
||||||
|
y_tilt += h * math.sin(theta)
|
||||||
|
scale = 2.0 / len(T_AXES)
|
||||||
|
return piston, x_tilt * scale, y_tilt * scale
|
||||||
|
|
||||||
|
|
||||||
|
# ── Settings ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TAxisSettings:
|
||||||
|
"""Drive settings for the three T-axes during the procedure.
|
||||||
|
|
||||||
|
32 microsteps and 600 mA are the operating point this procedure is
|
||||||
|
specified at; they are applied to all three axes at the start rather than
|
||||||
|
trusted from whatever the T3R panel last left behind, because the search
|
||||||
|
reports its corrections in microsteps and a different microstep setting
|
||||||
|
would silently change what a step means.
|
||||||
|
"""
|
||||||
|
microsteps: int = 32
|
||||||
|
run_current_ma: int = 600
|
||||||
|
hold_current_ma: int = 300 # half of run: holds the platform, runs cool
|
||||||
|
ihold_delay: int = 6
|
||||||
|
velocity: int = 4000 # steps/s — small moves, so ramps dominate
|
||||||
|
accel: int = 2000 # steps/s^2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AlignSettings:
|
||||||
|
"""How far to step, how close to get, and how hard to try."""
|
||||||
|
offset_mm: float = 1.5 # stage step either side of the reference
|
||||||
|
tolerance_mv: float = 5.0 # "same DC values" means within this
|
||||||
|
probe_steps: int = 200 # first move of a search: gain is unknown
|
||||||
|
max_step_steps: int = 2000 # per-iteration clamp on a correction
|
||||||
|
max_excursion_steps: int = 20000 # per-axis limit from the starting tilt
|
||||||
|
max_iterations: int = 25
|
||||||
|
max_probe_doublings: int = 4 # escalation when a probe reads as no response
|
||||||
|
min_response_mv: float = 2.0 # below this a probe has told us nothing
|
||||||
|
settle_s: float = 0.3 # after a move, before believing a reading
|
||||||
|
acquisition_retries: int = 2 # re-reads before calling the scope stalled
|
||||||
|
reads_per_measurement: int = scope_inspect.BIAS_READS
|
||||||
|
move_timeout_margin_s: float = 5.0
|
||||||
|
stage_timeout_s: float = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_T_AXIS = TAxisSettings()
|
||||||
|
DEFAULT_ALIGN = AlignSettings()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Read-out ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Reading:
|
||||||
|
"""One measurement of the two DC bias levels, in millivolts."""
|
||||||
|
dc1_mv: float
|
||||||
|
dc2_mv: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def difference_mv(self) -> float:
|
||||||
|
"""DC 1 - DC 2.
|
||||||
|
|
||||||
|
The split-detector difference is what a tilt actually steers, so it is
|
||||||
|
the signal the search drives to zero; the sum is set by the laser and
|
||||||
|
the surface reflectivity, which no amount of tilting will change.
|
||||||
|
"""
|
||||||
|
return self.dc1_mv - self.dc2_mv
|
||||||
|
|
||||||
|
def error_vs(self, ref: "Reading") -> tuple[float, float]:
|
||||||
|
return self.dc1_mv - ref.dc1_mv, self.dc2_mv - ref.dc2_mv
|
||||||
|
|
||||||
|
def difference_error_vs(self, ref: "Reading") -> float:
|
||||||
|
return self.difference_mv - ref.difference_mv
|
||||||
|
|
||||||
|
def matches(self, ref: "Reading", tolerance_mv: float) -> bool:
|
||||||
|
return all(abs(e) <= tolerance_mv for e in self.error_vs(ref))
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return f"DC1 {self.dc1_mv:+.1f} mV, DC2 {self.dc2_mv:+.1f} mV"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OffsetResult:
|
||||||
|
"""What one search — one stage offset, one tilt group — ended up doing."""
|
||||||
|
axis_label: str
|
||||||
|
offset_mm: float
|
||||||
|
correction_steps: float
|
||||||
|
iterations: int
|
||||||
|
nulled: bool # difference back within tolerance: the tilt loop worked
|
||||||
|
converged: bool # both levels back within tolerance: the operator's test
|
||||||
|
reason: str
|
||||||
|
final: Reading
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return (f"{self.axis_label}{self.offset_mm:+.2f} mm: "
|
||||||
|
f"{self.correction_steps:+.0f} usteps in {self.iterations} steps "
|
||||||
|
f"→ {self.final.describe()} ({self.reason})")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AxisResult:
|
||||||
|
"""Both offsets for one stage axis, and the tilt they agreed on."""
|
||||||
|
axis_label: str
|
||||||
|
group: TiltGroup
|
||||||
|
offsets: list[OffsetResult]
|
||||||
|
applied_steps: float
|
||||||
|
disagreement_steps: float
|
||||||
|
applied: bool
|
||||||
|
reference_residual: Reading | None # measured back at the reference point
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.applied and all(o.nulled for o in self.offsets)
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
if not self.applied:
|
||||||
|
return (f"{self.axis_label}: no correction applied — "
|
||||||
|
+ "; ".join(o.reason for o in self.offsets))
|
||||||
|
residual = (f", back at the reference {self.reference_residual.describe()}"
|
||||||
|
if self.reference_residual else "")
|
||||||
|
return (f"{self.axis_label}: applied {self.applied_steps:+.0f} usteps on "
|
||||||
|
f"{self.group.describe()} (the two directions disagreed by "
|
||||||
|
f"{self.disagreement_steps:.0f} usteps){residual}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlignResult:
|
||||||
|
"""The whole procedure, as the summary the operator is shown."""
|
||||||
|
reference: Reading
|
||||||
|
axes: list[AxisResult] = field(default_factory=list)
|
||||||
|
final: Reading | None = None
|
||||||
|
tolerance_mv: float = DEFAULT_ALIGN.tolerance_mv
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return bool(self.axes) and all(a.ok for a in self.axes) and (
|
||||||
|
self.final is not None
|
||||||
|
and self.final.matches(self.reference, self.tolerance_mv))
|
||||||
|
|
||||||
|
def verdict(self) -> str:
|
||||||
|
"""The closing lines: where it ended up, and whether that is aligned."""
|
||||||
|
lines = []
|
||||||
|
if self.final is not None:
|
||||||
|
d1, d2 = self.final.error_vs(self.reference)
|
||||||
|
lines.append(f"Final at the reference point: {self.final.describe()} "
|
||||||
|
f"({d1:+.1f} / {d2:+.1f} mV from the good values)")
|
||||||
|
lines.append("Aligned." if self.ok else
|
||||||
|
"Finished without meeting the tolerance — see the log.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
lines = [f"Reference: {self.reference.describe()}"]
|
||||||
|
for axis in self.axes:
|
||||||
|
lines.append(axis.describe())
|
||||||
|
lines.extend(f" {o.describe()}" for o in axis.offsets)
|
||||||
|
lines.append(self.verdict())
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlignCallbacks:
|
||||||
|
"""Progress reporting. Defaults are no-ops so the core needs no front end."""
|
||||||
|
on_status: Callable[[str], None] = lambda msg: None
|
||||||
|
on_reading: Callable[[Reading], None] = lambda r: None
|
||||||
|
on_busy: Callable[[bool], None] = lambda busy: None
|
||||||
|
on_offset_done: Callable[[OffsetResult], None] = lambda r: None
|
||||||
|
on_axis_done: Callable[[AxisResult], None] = lambda r: None
|
||||||
|
|
||||||
|
|
||||||
|
# ── The tilt platform ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _TiltPlatform:
|
||||||
|
"""The three T-axes, driven in microsteps relative to where they started.
|
||||||
|
|
||||||
|
Positions are tracked as floats and commanded as integers, with the
|
||||||
|
rounding residue carried forward, so a long run of fractional corrections
|
||||||
|
cannot drift the platform away from what the procedure thinks it applied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, driver, settings: TAxisSettings, max_excursion_steps: int,
|
||||||
|
timeout_margin_s: float = 5.0):
|
||||||
|
self._driver = driver
|
||||||
|
self._s = settings
|
||||||
|
self._max = max_excursion_steps
|
||||||
|
self._timeout_margin_s = timeout_margin_s
|
||||||
|
self._target = {ch: 0.0 for ch in T_AXES} # wanted, fractional
|
||||||
|
self._actual = {ch: 0 for ch in T_AXES} # commanded, integral
|
||||||
|
|
||||||
|
@property
|
||||||
|
def positions(self) -> dict[int, int]:
|
||||||
|
return dict(self._actual)
|
||||||
|
|
||||||
|
def configure(self) -> None:
|
||||||
|
s = self._s
|
||||||
|
for ch in T_AXES:
|
||||||
|
self._driver.set_microstep(ch, s.microsteps)
|
||||||
|
self._driver.set_current(ch, s.run_current_ma, s.hold_current_ma,
|
||||||
|
s.ihold_delay)
|
||||||
|
self._driver.enable(ch)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[int, float]:
|
||||||
|
return dict(self._target)
|
||||||
|
|
||||||
|
def apply(self, group: TiltGroup, amount: float) -> None:
|
||||||
|
"""Move the group by ``amount`` units of its weights."""
|
||||||
|
self._goto({ch: self._target[ch] + amount * w
|
||||||
|
for ch, w in group.weights.items()})
|
||||||
|
|
||||||
|
def restore(self, snapshot: dict[int, float]) -> None:
|
||||||
|
self._goto(snapshot)
|
||||||
|
|
||||||
|
def _goto(self, targets: dict[int, float]) -> None:
|
||||||
|
for ch, target in targets.items():
|
||||||
|
if abs(target) > self._max:
|
||||||
|
raise AutoAlignError(
|
||||||
|
f"{T_AXIS_LABELS[ch]} would travel {target:+.0f} microsteps "
|
||||||
|
f"from where it started, past the {self._max} microstep "
|
||||||
|
f"safety limit. Stopping before the actuator runs out of "
|
||||||
|
f"travel — align the rig by hand and start again.")
|
||||||
|
self._target[ch] = target
|
||||||
|
delta = round(target) - self._actual[ch]
|
||||||
|
if delta:
|
||||||
|
self._move(ch, delta)
|
||||||
|
self._actual[ch] += delta
|
||||||
|
|
||||||
|
def _move(self, ch: int, steps: int) -> None:
|
||||||
|
s = self._s
|
||||||
|
self._driver.move(ch, steps, s.velocity, s.accel)
|
||||||
|
timeout = (abs(steps) / s.velocity + s.velocity / s.accel
|
||||||
|
+ self._timeout_margin_s)
|
||||||
|
if not self._driver.wait_motion_done(ch, timeout):
|
||||||
|
logger.warning("%s did not report MOTION_DONE within %.1f s for a "
|
||||||
|
"%+d microstep move; continuing",
|
||||||
|
T_AXIS_LABELS[ch], timeout, steps)
|
||||||
|
|
||||||
|
|
||||||
|
# ── The procedure ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AutoAligner:
|
||||||
|
"""Drives the stage and the tilt platform to level the sample.
|
||||||
|
|
||||||
|
Usage is two-phase because the operator sits in the middle of it:
|
||||||
|
``prepare()`` puts the rig in a known state and reads the bias levels at
|
||||||
|
the reference point, the operator confirms the image and those levels are
|
||||||
|
the ones to hold, then ``run()`` measures and applies the tilt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, stage, scope, t3r,
|
||||||
|
settings: AlignSettings = DEFAULT_ALIGN,
|
||||||
|
t_axis: TAxisSettings = DEFAULT_T_AXIS,
|
||||||
|
limits: StageLimits = DEFAULT_STAGE_LIMITS,
|
||||||
|
callbacks: AlignCallbacks | None = None,
|
||||||
|
should_abort: Callable[[], bool] = lambda: False):
|
||||||
|
self._stage = stage
|
||||||
|
self._scope = scope
|
||||||
|
self._t3r = t3r
|
||||||
|
self._s = settings
|
||||||
|
self._cb = callbacks if callbacks is not None else AlignCallbacks()
|
||||||
|
self._limits = limits
|
||||||
|
self._should_abort = should_abort
|
||||||
|
|
||||||
|
self._platform = _TiltPlatform(t3r, t_axis, settings.max_excursion_steps,
|
||||||
|
settings.move_timeout_margin_s)
|
||||||
|
self._reference: Reading | None = None
|
||||||
|
self._ref_mm: tuple[float, float] | None = None
|
||||||
|
self._last_acq: int | None = None
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reference(self) -> Reading | None:
|
||||||
|
return self._reference
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reference_position_mm(self) -> tuple[float, float] | None:
|
||||||
|
return self._ref_mm
|
||||||
|
|
||||||
|
def prepare(self) -> Reading:
|
||||||
|
"""Configure the rig and read the bias levels where it stands.
|
||||||
|
|
||||||
|
The returned reading is a candidate, not yet the reference: the
|
||||||
|
operator has to confirm the camera image is the one to align on before
|
||||||
|
anything moves.
|
||||||
|
"""
|
||||||
|
self._require_hardware()
|
||||||
|
self._cb.on_busy(True)
|
||||||
|
try:
|
||||||
|
x_mm, y_mm = self._stage_position()
|
||||||
|
self._check_travel(x_mm, y_mm)
|
||||||
|
self._ref_mm = (x_mm, y_mm)
|
||||||
|
|
||||||
|
self._cb.on_status("Configuring stage …")
|
||||||
|
for axis in (AXIS_X, AXIS_Y):
|
||||||
|
self._stage.set_velocity_params(axis,
|
||||||
|
max_velocity=ALIGN_VELOCITY_MM_S,
|
||||||
|
acceleration=SCAN_ACCEL_MM_S2)
|
||||||
|
# Nothing here is gated, and an armed trigger output would keep
|
||||||
|
# driving the gate line on every positioning move.
|
||||||
|
self._stage.set_trigger_gate_off(AXIS_X)
|
||||||
|
|
||||||
|
self._cb.on_status("Configuring oscilloscope …")
|
||||||
|
scope_inspect.configure_inspection(self._scope)
|
||||||
|
|
||||||
|
self._cb.on_status(
|
||||||
|
f"Configuring T-axes ({DEFAULT_T_AXIS.microsteps} usteps, "
|
||||||
|
f"{DEFAULT_T_AXIS.run_current_ma} mA) …")
|
||||||
|
self._platform.configure()
|
||||||
|
|
||||||
|
self._started = True
|
||||||
|
self._cb.on_status("Reading the DC levels at the reference point …")
|
||||||
|
reading = self._measure()
|
||||||
|
self._reference = reading
|
||||||
|
return reading
|
||||||
|
finally:
|
||||||
|
self._cb.on_busy(False)
|
||||||
|
|
||||||
|
def run(self) -> AlignResult:
|
||||||
|
"""Measure and apply the tilt, X first and then Y.
|
||||||
|
|
||||||
|
Y follows X because the two corrections are independent moves (see
|
||||||
|
``tilt_response``) but not independent measurements: the X phase is
|
||||||
|
the one that can reveal a platform whose pivot is not under the beam,
|
||||||
|
and its reference residual is reported before Y adds to it.
|
||||||
|
"""
|
||||||
|
if not self._started or self._reference is None:
|
||||||
|
raise AutoAlignError("prepare() must run, and the operator must "
|
||||||
|
"confirm the image, before run()")
|
||||||
|
result = AlignResult(reference=self._reference,
|
||||||
|
tolerance_mv=self._s.tolerance_mv)
|
||||||
|
self._cb.on_busy(True)
|
||||||
|
try:
|
||||||
|
for group in TILT_GROUPS:
|
||||||
|
axis_result = self._align_axis(group)
|
||||||
|
result.axes.append(axis_result)
|
||||||
|
self._cb.on_axis_done(axis_result)
|
||||||
|
if not axis_result.applied:
|
||||||
|
break
|
||||||
|
result.final = self._measure()
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
self._cb.on_busy(False)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Park the rig: stage back at the reference point, scope idle.
|
||||||
|
|
||||||
|
The tilt correction stays applied — it is the result. Safe to call
|
||||||
|
twice, and safe to call after a failure part-way through.
|
||||||
|
"""
|
||||||
|
if not self._started:
|
||||||
|
return
|
||||||
|
self._started = False
|
||||||
|
self._cb.on_busy(True)
|
||||||
|
try:
|
||||||
|
if self._ref_mm is not None:
|
||||||
|
try:
|
||||||
|
self._cb.on_status("Returning to the reference point …")
|
||||||
|
# Deliberately not abort-checked: this *is* the response to
|
||||||
|
# an abort, and a stop that left the stage 1.5 mm off the
|
||||||
|
# operator's point would be worse than no stop at all.
|
||||||
|
self._goto_reference(check_abort=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Could not return the stage to the "
|
||||||
|
"reference point")
|
||||||
|
try:
|
||||||
|
scope_inspect.stop_inspection(self._scope)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Could not stop the inspection acquisition")
|
||||||
|
self._cb.on_status("Auto-align finished.")
|
||||||
|
finally:
|
||||||
|
self._cb.on_busy(False)
|
||||||
|
|
||||||
|
# ── One stage axis ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _align_axis(self, group: TiltGroup) -> AxisResult:
|
||||||
|
name = group.label
|
||||||
|
start_tilt = self._platform.snapshot()
|
||||||
|
offsets: list[OffsetResult] = []
|
||||||
|
|
||||||
|
for sign in (+1.0, -1.0):
|
||||||
|
offset_mm = sign * self._s.offset_mm
|
||||||
|
self._cb.on_status(
|
||||||
|
f"{name}: stepping {offset_mm:+.2f} mm and re-tilting on "
|
||||||
|
f"{group.describe()} …")
|
||||||
|
self._goto_offset(group, offset_mm)
|
||||||
|
offsets.append(self._null(group, name, offset_mm))
|
||||||
|
self._cb.on_offset_done(offsets[-1])
|
||||||
|
# Both directions are measured from the same tilt, so they are two
|
||||||
|
# independent estimates of the same angle rather than one estimate
|
||||||
|
# and one correction to it.
|
||||||
|
self._platform.restore(start_tilt)
|
||||||
|
|
||||||
|
applied = 0.5 * sum(o.correction_steps for o in offsets)
|
||||||
|
disagreement = abs(offsets[0].correction_steps - offsets[1].correction_steps)
|
||||||
|
can_apply = all(o.nulled for o in offsets)
|
||||||
|
|
||||||
|
if can_apply:
|
||||||
|
self._cb.on_status(f"{name}: applying {applied:+.0f} microsteps …")
|
||||||
|
self._platform.apply(group, applied)
|
||||||
|
else:
|
||||||
|
self._cb.on_status(
|
||||||
|
f"{name}: no correction applied — a search did not null the "
|
||||||
|
f"DC difference, so the tilt it found means nothing.")
|
||||||
|
|
||||||
|
residual = None
|
||||||
|
self._cb.on_status(f"{name}: back to the reference point …")
|
||||||
|
self._goto_reference()
|
||||||
|
if can_apply:
|
||||||
|
residual = self._measure()
|
||||||
|
|
||||||
|
return AxisResult(axis_label=name, group=group, offsets=offsets,
|
||||||
|
applied_steps=applied if can_apply else 0.0,
|
||||||
|
disagreement_steps=disagreement,
|
||||||
|
applied=can_apply, reference_residual=residual)
|
||||||
|
|
||||||
|
# ── One search ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _null(self, group: TiltGroup, name: str, offset_mm: float) -> OffsetResult:
|
||||||
|
"""Tilt until the bias levels read what they read at the reference.
|
||||||
|
|
||||||
|
A secant search on the split-detector difference: probe once to learn
|
||||||
|
how many millivolts a microstep is worth (the sign included — which
|
||||||
|
way is "up" is a wiring question this refuses to assume), then step
|
||||||
|
straight at the null and re-estimate the slope from each pair of
|
||||||
|
readings.
|
||||||
|
|
||||||
|
Ends on one of three outcomes, which mean different things:
|
||||||
|
*converged* — both levels back inside the tolerance, the result the
|
||||||
|
operator asked for; *difference nulled* — the beam is back on the
|
||||||
|
centre of the detector but both levels sit at the wrong height, which
|
||||||
|
no tilt can fix and which therefore still leaves a usable tilt answer;
|
||||||
|
anything else means the search failed and its answer must not be used.
|
||||||
|
"""
|
||||||
|
s = self._s
|
||||||
|
reading = self._measure()
|
||||||
|
applied = 0.0
|
||||||
|
gain: float | None = None # mV of difference error per microstep
|
||||||
|
probe = float(s.probe_steps)
|
||||||
|
doublings = 0
|
||||||
|
|
||||||
|
for iteration in range(1, s.max_iterations + 1):
|
||||||
|
err = reading.difference_error_vs(self._reference)
|
||||||
|
d1, d2 = reading.error_vs(self._reference)
|
||||||
|
self._cb.on_status(
|
||||||
|
f"{name}{offset_mm:+.2f} mm, step {iteration}: "
|
||||||
|
f"DC1 {d1:+.1f} / DC2 {d2:+.1f} mV from the good values")
|
||||||
|
|
||||||
|
if reading.matches(self._reference, s.tolerance_mv):
|
||||||
|
return self._offset_result(name, offset_mm, applied, iteration,
|
||||||
|
True, True, "within tolerance", reading)
|
||||||
|
if abs(err) <= s.tolerance_mv:
|
||||||
|
return self._offset_result(
|
||||||
|
name, offset_mm, applied, iteration, True, False,
|
||||||
|
f"difference nulled, but both levels are off by "
|
||||||
|
f"{0.5 * (d1 + d2):+.1f} mV — not something tilt can fix",
|
||||||
|
reading)
|
||||||
|
|
||||||
|
if gain is None:
|
||||||
|
step = probe
|
||||||
|
else:
|
||||||
|
step = max(-s.max_step_steps, min(s.max_step_steps, -err / gain))
|
||||||
|
if abs(step) < 1.0:
|
||||||
|
step = math.copysign(1.0, step)
|
||||||
|
|
||||||
|
self._platform.apply(group, step)
|
||||||
|
applied += step
|
||||||
|
previous, reading = reading, self._measure()
|
||||||
|
response = reading.difference_error_vs(self._reference) - err
|
||||||
|
|
||||||
|
if abs(response) >= s.min_response_mv:
|
||||||
|
gain = response / step
|
||||||
|
elif gain is None:
|
||||||
|
# The probe moved nothing measurable. Usually the probe is
|
||||||
|
# simply too small for this actuator's pitch, so escalate
|
||||||
|
# before concluding the axis is dead.
|
||||||
|
self._platform.apply(group, -step)
|
||||||
|
applied -= step
|
||||||
|
reading = previous
|
||||||
|
doublings += 1
|
||||||
|
if doublings > s.max_probe_doublings:
|
||||||
|
raise AutoAlignError(
|
||||||
|
f"{group.describe()} moved {probe:.0f} microsteps and "
|
||||||
|
f"the DC difference did not change by "
|
||||||
|
f"{s.min_response_mv:.0f} mV. Check that the laser is "
|
||||||
|
f"pulsing, that CH3/CH4 are the DC monitors, and that "
|
||||||
|
f"the T-axes are energised.")
|
||||||
|
probe *= 2.0
|
||||||
|
|
||||||
|
return self._offset_result(
|
||||||
|
name, offset_mm, applied, s.max_iterations, False, False,
|
||||||
|
f"gave up after {s.max_iterations} steps", reading)
|
||||||
|
|
||||||
|
def _offset_result(self, name, offset_mm, applied, iterations, nulled,
|
||||||
|
converged, reason, reading) -> OffsetResult:
|
||||||
|
return OffsetResult(axis_label=name, offset_mm=offset_mm,
|
||||||
|
correction_steps=applied, iterations=iterations,
|
||||||
|
nulled=nulled, converged=converged, reason=reason,
|
||||||
|
final=reading)
|
||||||
|
|
||||||
|
# ── Hardware ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _require_hardware(self):
|
||||||
|
if self._stage is None:
|
||||||
|
raise AutoAlignError("BBD202 not connected")
|
||||||
|
if self._scope is None:
|
||||||
|
raise AutoAlignError("Oscilloscope not connected")
|
||||||
|
if self._t3r is None or not self._t3r.is_open:
|
||||||
|
raise AutoAlignError(
|
||||||
|
"The T3R is not connected, so the T-axes cannot be moved. "
|
||||||
|
"Connect it from the T3R panel and try again.")
|
||||||
|
|
||||||
|
def _stage_position(self) -> tuple[float, float]:
|
||||||
|
try:
|
||||||
|
return float(self._stage.positions[0]), float(self._stage.positions[1])
|
||||||
|
except Exception as exc:
|
||||||
|
raise AutoAlignError(f"Cannot read the stage position: {exc}") from exc
|
||||||
|
|
||||||
|
def _check_travel(self, x_mm: float, y_mm: float) -> None:
|
||||||
|
"""Both offsets on both axes have to be reachable before anything moves."""
|
||||||
|
off = self._s.offset_mm
|
||||||
|
lim = self._limits
|
||||||
|
for name, value, lo, hi in (("X", x_mm, lim.x_min, lim.x_max),
|
||||||
|
("Y", y_mm, lim.y_min, lim.y_max)):
|
||||||
|
if value - off < lo or value + off > hi:
|
||||||
|
raise AutoAlignError(
|
||||||
|
f"{name} is at {value:.3f} mm, and this procedure needs "
|
||||||
|
f"{off:.2f} mm either side of it — that leaves the "
|
||||||
|
f"{lo:g}–{hi:g} mm travel. Move to a point further from "
|
||||||
|
f"the end of travel and try again.")
|
||||||
|
|
||||||
|
def _goto_offset(self, group: TiltGroup, offset_mm: float) -> None:
|
||||||
|
x_mm, y_mm = self._ref_mm
|
||||||
|
target = (x_mm if group.stage_axis == AXIS_X else y_mm) + offset_mm
|
||||||
|
self._move_stage(group.stage_axis, target)
|
||||||
|
|
||||||
|
def _goto_reference(self, check_abort: bool = True) -> None:
|
||||||
|
x_mm, y_mm = self._ref_mm
|
||||||
|
self._move_stage(AXIS_X, x_mm, check_abort=check_abort)
|
||||||
|
self._move_stage(AXIS_Y, y_mm, check_abort=check_abort)
|
||||||
|
|
||||||
|
def _move_stage(self, stage_axis: int, position_mm: float,
|
||||||
|
check_abort: bool = True) -> None:
|
||||||
|
if check_abort:
|
||||||
|
self._check_abort()
|
||||||
|
self._stage.move_axis_absolute(stage_axis, position_mm,
|
||||||
|
timeout=self._s.stage_timeout_s)
|
||||||
|
|
||||||
|
def _measure(self) -> Reading:
|
||||||
|
"""Settle, check the scope is still acquiring, then read both levels."""
|
||||||
|
self._check_abort()
|
||||||
|
time.sleep(self._s.settle_s)
|
||||||
|
self._require_fresh_acquisition()
|
||||||
|
dc1, dc2 = scope_inspect.read_bias_mv(self._scope,
|
||||||
|
self._s.reads_per_measurement)
|
||||||
|
reading = Reading(dc1_mv=dc1, dc2_mv=dc2)
|
||||||
|
self._cb.on_reading(reading)
|
||||||
|
return reading
|
||||||
|
|
||||||
|
def _require_fresh_acquisition(self) -> None:
|
||||||
|
"""Refuse to servo on a record the scope has not re-taken.
|
||||||
|
|
||||||
|
A stale record reads as a perfectly stable measurement, which is the
|
||||||
|
one failure this loop cannot see for itself: it would keep stepping
|
||||||
|
the actuators against a number that never moves.
|
||||||
|
|
||||||
|
The very first reading has no previous count to compare against, so it
|
||||||
|
makes one — waiting for the count to move rather than assuming it
|
||||||
|
does. That reading becomes the reference the operator confirms and
|
||||||
|
the whole procedure then chases, which makes it the worst one to take
|
||||||
|
off a scope that is not triggering.
|
||||||
|
"""
|
||||||
|
baseline = self._last_acq
|
||||||
|
count = self._scope.get_acquisition_count()
|
||||||
|
if baseline is None:
|
||||||
|
baseline = count
|
||||||
|
for _ in range(self._s.acquisition_retries):
|
||||||
|
if count != baseline:
|
||||||
|
self._last_acq = count
|
||||||
|
return
|
||||||
|
time.sleep(self._s.settle_s)
|
||||||
|
count = self._scope.get_acquisition_count()
|
||||||
|
if count == baseline:
|
||||||
|
raise AutoAlignError(
|
||||||
|
"The oscilloscope has not triggered since the last reading, "
|
||||||
|
"so its DC levels are stale. Check that the laser is pulsing "
|
||||||
|
"and that CH2 carries the trigger.")
|
||||||
|
self._last_acq = count
|
||||||
|
|
||||||
|
def _check_abort(self) -> None:
|
||||||
|
if self._should_abort():
|
||||||
|
raise AutoAlignAborted("Auto-align stopped by the operator")
|
||||||
@@ -23,6 +23,8 @@ class ScanDefaults:
|
|||||||
oscope_ip: str = "192.168.0.1"
|
oscope_ip: str = "192.168.0.1"
|
||||||
save_dir: str = str(DEFAULTS_PATH.parent / "scans")
|
save_dir: str = str(DEFAULTS_PATH.parent / "scans")
|
||||||
helios_port: str = "/dev/ttyUSB2"
|
helios_port: str = "/dev/ttyUSB2"
|
||||||
|
burst_mode: bool = False
|
||||||
|
strict_rows: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: Path = DEFAULTS_PATH) -> "ScanDefaults":
|
def load(cls, path: Path = DEFAULTS_PATH) -> "ScanDefaults":
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Middle-row SAW quality check: acquire one row per angle, then read the
|
||||||
|
alignment off the frequencies it produces.
|
||||||
|
|
||||||
|
Two halves of one test mode, kept together because neither is much use
|
||||||
|
without the other:
|
||||||
|
|
||||||
|
*Acquisition* — ``middle_row_plan`` reduces a full ScanPlan to a single row
|
||||||
|
per angle, the row-wise middle of the ROI. ScanEngine runs the result
|
||||||
|
exactly like any other scan and writes it as a v11 .sras file
|
||||||
|
(``sras_format.VERSION_SAW_CHECK``), so a check costs one row-time per angle
|
||||||
|
instead of the hours a full multi-angle scan takes.
|
||||||
|
|
||||||
|
*Analysis* — ``frequency_traces`` turns such a file back into one peak-SAW-
|
||||||
|
frequency trace per angle, and ``alignment_summary`` reduces those to the
|
||||||
|
numbers the operator is actually asking about. Both are Qt-free; the plotting
|
||||||
|
lives in saw_check_viewer.py.
|
||||||
|
|
||||||
|
Why the middle row answers an alignment question: ``scan_geometry.build_plan``
|
||||||
|
centres every angle's rotated bounding box on the same nominal ROI centre, so
|
||||||
|
each angle's middle row crosses that one point on the sample. Every angle
|
||||||
|
therefore measures the same material, and a spread in the per-angle
|
||||||
|
frequencies is a property of the rig (or of a genuinely anisotropic sample),
|
||||||
|
not of where each row happened to land.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from core.scan_geometry import ScanGeometryError, ScanPlan
|
||||||
|
from core.sras_analysis import ChannelCalibration, compute_rf_image
|
||||||
|
from core.sras_format import SrasFile
|
||||||
|
|
||||||
|
# Rules of thumb for the read-out, not physics. A well-aligned rig on an
|
||||||
|
# isotropic sample reads the same frequency at every angle, so the spread of
|
||||||
|
# the per-angle medians is the alignment signal — but an anisotropic sample
|
||||||
|
# genuinely varies with angle, so a wide spread is a prompt to look at the
|
||||||
|
# curves, never a verdict on its own.
|
||||||
|
SPREAD_GOOD_PCT = 1.0
|
||||||
|
SPREAD_MARGINAL_PCT = 3.0
|
||||||
|
# Below this fraction of unmasked pixels a trace is too sparse to read.
|
||||||
|
VALID_FRACTION_FLOOR = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
# ── Acquisition side ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def middle_row_plan(plan: ScanPlan) -> ScanPlan:
|
||||||
|
"""Reduce a scan plan to its row-wise middle row at every angle.
|
||||||
|
|
||||||
|
Each angle keeps the geometry the full scan would have used — same
|
||||||
|
x_start, x_delta and n_frames from its own rotated bounding box — and
|
||||||
|
scans only the middle entry of its row list, so the check samples exactly
|
||||||
|
what the scan would along that row.
|
||||||
|
|
||||||
|
An even row count has no exact middle; the upper of the two central rows
|
||||||
|
is taken (``n_rows // 2``), which is also the row the viewer picks when it
|
||||||
|
reads the middle row out of a full scan.
|
||||||
|
"""
|
||||||
|
if plan.n_angles == 0:
|
||||||
|
raise ScanGeometryError("Cannot build a SAW check from a plan with no angles")
|
||||||
|
|
||||||
|
per_angle = []
|
||||||
|
for pa in plan.per_angle:
|
||||||
|
if not pa.y_positions:
|
||||||
|
raise ScanGeometryError(
|
||||||
|
f"Angle {pa.angle_deg:.1f}° has no rows, so it has no middle row to check"
|
||||||
|
)
|
||||||
|
per_angle.append(replace(pa, n_rows=1,
|
||||||
|
y_positions=[pa.y_positions[middle_row_index(pa.n_rows)]]))
|
||||||
|
return replace(plan, per_angle=per_angle)
|
||||||
|
|
||||||
|
|
||||||
|
def middle_row_index(n_rows: int) -> int:
|
||||||
|
"""The row this check calls the middle one. One rule, two callers."""
|
||||||
|
return max(0, n_rows // 2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analysis side ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AngleTrace:
|
||||||
|
"""One angle's peak SAW frequency along its middle row.
|
||||||
|
|
||||||
|
``freq_mhz`` is NaN wherever the pixel was masked out (CH4 DC below the
|
||||||
|
threshold), so the gaps stay gaps instead of reading as 0 MHz.
|
||||||
|
"""
|
||||||
|
angle_idx: int
|
||||||
|
angle_deg: float
|
||||||
|
row_idx: int
|
||||||
|
y_mm: float
|
||||||
|
x_mm: np.ndarray # absolute stage X of each frame
|
||||||
|
freq_mhz: np.ndarray # NaN where masked
|
||||||
|
_valid: np.ndarray = field(init=False, repr=False)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self._valid = np.isfinite(self.freq_mhz)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offset_mm(self) -> np.ndarray:
|
||||||
|
"""X relative to the centre of this row.
|
||||||
|
|
||||||
|
Every angle's row is centred on the same ROI centre, so plotting
|
||||||
|
against this puts all the angles' curves over the same piece of
|
||||||
|
sample — which is the whole point of the comparison.
|
||||||
|
"""
|
||||||
|
if len(self.x_mm) == 0:
|
||||||
|
return self.x_mm
|
||||||
|
return self.x_mm - 0.5 * (self.x_mm[0] + self.x_mm[-1])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def n_valid(self) -> int:
|
||||||
|
return int(self._valid.sum())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valid_fraction(self) -> float:
|
||||||
|
return self.n_valid / len(self.freq_mhz) if len(self.freq_mhz) else 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def median_mhz(self) -> float:
|
||||||
|
return float(np.median(self.freq_mhz[self._valid])) if self.n_valid else float("nan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def std_mhz(self) -> float:
|
||||||
|
return float(np.std(self.freq_mhz[self._valid])) if self.n_valid > 1 else float("nan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def drift_mhz_per_mm(self) -> float:
|
||||||
|
"""Least-squares slope of frequency along the row.
|
||||||
|
|
||||||
|
A flat trace means the response did not change across the ROI; a
|
||||||
|
sloped one is the signature of a tilt or a defocus the angle spread
|
||||||
|
alone would not show.
|
||||||
|
"""
|
||||||
|
if self.n_valid < 2:
|
||||||
|
return float("nan")
|
||||||
|
x = self.offset_mm[self._valid]
|
||||||
|
if np.ptp(x) == 0:
|
||||||
|
return float("nan")
|
||||||
|
return float(np.polyfit(x, self.freq_mhz[self._valid], 1)[0])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlignmentSummary:
|
||||||
|
"""What the per-angle traces say about the alignment, in scalars."""
|
||||||
|
n_angles: int
|
||||||
|
median_mhz: float
|
||||||
|
spread_mhz: float # max − min of the per-angle medians
|
||||||
|
spread_pct: float # that spread as a % of the overall median
|
||||||
|
best_angle_deg: float # angle reading the highest median
|
||||||
|
worst_angle_deg: float # angle reading the lowest median
|
||||||
|
worst_drift_mhz_per_mm: float
|
||||||
|
worst_drift_angle_deg: float
|
||||||
|
min_valid_fraction: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def level(self) -> str:
|
||||||
|
""""good" / "marginal" / "poor" — see the module's threshold note."""
|
||||||
|
if self.n_angles == 0 or not np.isfinite(self.spread_pct):
|
||||||
|
return "poor"
|
||||||
|
if self.min_valid_fraction < VALID_FRACTION_FLOOR:
|
||||||
|
return "poor"
|
||||||
|
if self.spread_pct <= SPREAD_GOOD_PCT:
|
||||||
|
return "good"
|
||||||
|
if self.spread_pct <= SPREAD_MARGINAL_PCT:
|
||||||
|
return "marginal"
|
||||||
|
return "poor"
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
if self.n_angles == 0:
|
||||||
|
return "No angle produced a usable frequency trace."
|
||||||
|
if self.min_valid_fraction < VALID_FRACTION_FLOOR:
|
||||||
|
return (f"Only {self.min_valid_fraction * 100:.0f} % of the worst angle's row "
|
||||||
|
f"is above the DC threshold — check the detection beam and the "
|
||||||
|
f"threshold before reading the spread.")
|
||||||
|
return (f"Per-angle medians span {self.spread_mhz:.3f} MHz "
|
||||||
|
f"({self.spread_pct:.2f} % of {self.median_mhz:.3f} MHz), "
|
||||||
|
f"lowest at {self.worst_angle_deg:.1f}°, highest at {self.best_angle_deg:.1f}°. "
|
||||||
|
f"Largest drift along a row: {self.worst_drift_mhz_per_mm:+.3f} MHz/mm "
|
||||||
|
f"at {self.worst_drift_angle_deg:.1f}°.")
|
||||||
|
|
||||||
|
|
||||||
|
def frequency_traces(sras: SrasFile, *, dc_threshold_mv: float = 0.0,
|
||||||
|
subtract_background: bool = False,
|
||||||
|
gate_start_ns: float | None = None,
|
||||||
|
gate_end_ns: float | None = None,
|
||||||
|
calib: ChannelCalibration | None = None,
|
||||||
|
on_progress=lambda done, total: None) -> list[AngleTrace]:
|
||||||
|
"""Peak SAW frequency along the middle row of every angle in ``sras``.
|
||||||
|
|
||||||
|
Works on a v11 check (one row per angle, so the middle row is the only
|
||||||
|
row) and on a full scan alike — the same middle row the check would
|
||||||
|
have acquired is pulled out of the scan, which is what lets a finished
|
||||||
|
scan be re-examined with the check's own read-out.
|
||||||
|
|
||||||
|
``subtract_background`` takes each angle's own background out of its
|
||||||
|
frames (v6/v10 files have only the one, which every angle then shares).
|
||||||
|
Doing it per angle is the point of the per-angle capture: comparing
|
||||||
|
angles is exactly what this read-out is for, so they must not be
|
||||||
|
referenced against one background taken at whichever angle came first.
|
||||||
|
|
||||||
|
Angles with nothing on disk (an aborted file) are skipped rather than
|
||||||
|
reported as flat zero.
|
||||||
|
"""
|
||||||
|
calib = calib if calib is not None else ChannelCalibration.from_preambles(sras.preambles)
|
||||||
|
freq_axis = sras.freq_axis_mhz(sras.header.samples_per_frame)
|
||||||
|
time_axis = sras.time_axis_ns()
|
||||||
|
statuses = sras.angle_status()
|
||||||
|
|
||||||
|
traces: list[AngleTrace] = []
|
||||||
|
for st in statuses:
|
||||||
|
on_progress(st.index, len(statuses))
|
||||||
|
if st.n_rows_available < 1:
|
||||||
|
continue
|
||||||
|
pa = sras.per_angle[st.index]
|
||||||
|
row = middle_row_index(st.n_rows_available)
|
||||||
|
view = sras.load_angle(st.index, n_rows=st.n_rows_available)[row:row + 1]
|
||||||
|
|
||||||
|
background = sras.background_array(st.index) if subtract_background else None
|
||||||
|
img = compute_rf_image(view, calib, freq_axis, dc_threshold_mv,
|
||||||
|
background=background,
|
||||||
|
gate_start_ns=gate_start_ns, gate_end_ns=gate_end_ns,
|
||||||
|
time_axis_ns=time_axis)
|
||||||
|
# compute_rf_image zeroes masked pixels and its FFT never peaks in the
|
||||||
|
# suppressed DC bin, so 0 MHz means "no reading" and nothing else.
|
||||||
|
freq = img[0].astype(np.float64)
|
||||||
|
freq[freq <= 0.0] = np.nan
|
||||||
|
|
||||||
|
traces.append(AngleTrace(
|
||||||
|
angle_idx=st.index, angle_deg=pa.angle_deg, row_idx=row,
|
||||||
|
y_mm=pa.y_positions[row] if row < len(pa.y_positions) else float("nan"),
|
||||||
|
x_mm=sras.x_axis_mm(st.index), freq_mhz=freq,
|
||||||
|
))
|
||||||
|
on_progress(len(statuses), len(statuses))
|
||||||
|
return traces
|
||||||
|
|
||||||
|
|
||||||
|
def alignment_summary(traces: list[AngleTrace]) -> AlignmentSummary:
|
||||||
|
"""Reduce per-angle traces to the alignment read-out."""
|
||||||
|
usable = [t for t in traces if t.n_valid > 0]
|
||||||
|
if not usable:
|
||||||
|
nan = float("nan")
|
||||||
|
return AlignmentSummary(0, nan, nan, nan, nan, nan, nan, nan, 0.0)
|
||||||
|
|
||||||
|
medians = np.array([t.median_mhz for t in usable])
|
||||||
|
overall = float(np.median(medians))
|
||||||
|
spread = float(medians.max() - medians.min())
|
||||||
|
drifts = [(abs(t.drift_mhz_per_mm), t) for t in usable
|
||||||
|
if np.isfinite(t.drift_mhz_per_mm)]
|
||||||
|
worst_drift = max(drifts, key=lambda d: d[0])[1] if drifts else None
|
||||||
|
|
||||||
|
return AlignmentSummary(
|
||||||
|
n_angles=len(usable),
|
||||||
|
median_mhz=overall,
|
||||||
|
spread_mhz=spread,
|
||||||
|
spread_pct=spread / overall * 100.0 if overall else float("nan"),
|
||||||
|
best_angle_deg=usable[int(np.argmax(medians))].angle_deg,
|
||||||
|
worst_angle_deg=usable[int(np.argmin(medians))].angle_deg,
|
||||||
|
worst_drift_mhz_per_mm=worst_drift.drift_mhz_per_mm if worst_drift else float("nan"),
|
||||||
|
worst_drift_angle_deg=worst_drift.angle_deg if worst_drift else float("nan"),
|
||||||
|
min_valid_fraction=min(t.valid_fraction for t in usable),
|
||||||
|
)
|
||||||
+339
-44
@@ -15,10 +15,13 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from core import scope_sras
|
from core import scope_burst, scope_sras
|
||||||
from core.rotation import RotationAxis
|
from core.rotation import RotationAxis
|
||||||
from core.scan_geometry import ScanPlan, validate_plan
|
from core.scan_geometry import ScanPlan, validate_plan
|
||||||
from core.sras_format import SCAN_CHANNELS, create_scan_file
|
from core.sras_format import (
|
||||||
|
BG_LEN_SIZE, SCAN_CHANNELS, VERSION, create_scan_file,
|
||||||
|
write_background_block,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,8 +45,15 @@ class ScanAborted(Exception):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResumeTarget:
|
class ResumeTarget:
|
||||||
"""One angle selected for (re)acquisition in an existing file."""
|
"""One angle selected for (re)acquisition in an existing file.
|
||||||
|
|
||||||
|
``bg_offset`` is where the angle's background block starts and
|
||||||
|
``data_offset`` where its rows do; the gap between them is the room the
|
||||||
|
file already has for a background, which a re-acquired angle must fill
|
||||||
|
exactly or every row behind it would shift.
|
||||||
|
"""
|
||||||
angle_idx: int
|
angle_idx: int
|
||||||
|
bg_offset: int
|
||||||
data_offset: int
|
data_offset: int
|
||||||
n_rows: int
|
n_rows: int
|
||||||
angle_deg: float
|
angle_deg: float
|
||||||
@@ -95,7 +105,9 @@ class ScanEngine:
|
|||||||
def __init__(self, stage, scope, rotator: RotationAxis | None,
|
def __init__(self, stage, scope, rotator: RotationAxis | None,
|
||||||
plan: ScanPlan, out_path: Path,
|
plan: ScanPlan, out_path: Path,
|
||||||
resume: ResumeState | None = None,
|
resume: ResumeState | None = None,
|
||||||
callbacks: ScanCallbacks | None = None):
|
callbacks: ScanCallbacks | None = None,
|
||||||
|
burst_mode: bool = False, strict_rows: bool = False,
|
||||||
|
file_version: int = VERSION):
|
||||||
self._stage = stage
|
self._stage = stage
|
||||||
self._scope = scope
|
self._scope = scope
|
||||||
self._rotator = rotator
|
self._rotator = rotator
|
||||||
@@ -103,6 +115,19 @@ class ScanEngine:
|
|||||||
self._out_path = Path(out_path)
|
self._out_path = Path(out_path)
|
||||||
self._resume = resume
|
self._resume = resume
|
||||||
self._cb = callbacks if callbacks is not None else ScanCallbacks()
|
self._cb = callbacks if callbacks is not None else ScanCallbacks()
|
||||||
|
# Burst mode acquires as many whole rows per FastFrame acquisition as
|
||||||
|
# the scope's frame memory holds, instead of one row per acquisition.
|
||||||
|
self._burst_mode = burst_mode
|
||||||
|
# Strict row packing stops the scan on a frame-count mismatch
|
||||||
|
# instead of squaring the row up (see _check_frame_delta).
|
||||||
|
self._strict_rows = strict_rows
|
||||||
|
# Which kind of file this run produces. The acquisition is identical
|
||||||
|
# either way; VERSION_SAW_CHECK only marks a one-row-per-angle plan
|
||||||
|
# (core.saw_check) as the quality check it is, so a reader does not
|
||||||
|
# mistake it for a scan that aborted after its first row.
|
||||||
|
self._file_version = file_version
|
||||||
|
self._max_frames = 0
|
||||||
|
self._preflight_done = False
|
||||||
|
|
||||||
self._abort = threading.Event()
|
self._abort = threading.Event()
|
||||||
self._resume_event = threading.Event()
|
self._resume_event = threading.Event()
|
||||||
@@ -207,6 +232,14 @@ class ScanEngine:
|
|||||||
self._scan_loop(scan_file, samples_per_frame, result)
|
self._scan_loop(scan_file, samples_per_frame, result)
|
||||||
finally:
|
finally:
|
||||||
scan_file.close()
|
scan_file.close()
|
||||||
|
# Leave the X trigger output inactive. Burst mode toggles it every
|
||||||
|
# row and could exit from either state; the per-row path used to
|
||||||
|
# leave TRIGOUT_MAXV armed for the rest of the session, which keeps
|
||||||
|
# driving the gate line on every later jog.
|
||||||
|
try:
|
||||||
|
self._stage.set_trigger_gate_off(AXIS_X)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Could not return the X trigger output to idle")
|
||||||
# Return the GR axis home regardless of abort or error
|
# Return the GR axis home regardless of abort or error
|
||||||
if rotator_ready and abs(self._rotator.current_deg) > 0.001:
|
if rotator_ready and abs(self._rotator.current_deg) > 0.001:
|
||||||
self._cb.on_status("Returning GR to home …")
|
self._cb.on_status("Returning GR to home …")
|
||||||
@@ -242,7 +275,13 @@ class ScanEngine:
|
|||||||
acceleration=SCAN_ACCEL_MM_S2)
|
acceleration=SCAN_ACCEL_MM_S2)
|
||||||
ctrl.set_velocity_params(AXIS_Y, max_velocity=SCAN_VELOCITY_MM_S,
|
ctrl.set_velocity_params(AXIS_Y, max_velocity=SCAN_VELOCITY_MM_S,
|
||||||
acceleration=SCAN_ACCEL_MM_S2)
|
acceleration=SCAN_ACCEL_MM_S2)
|
||||||
# X trigger: logic-high output while the stage is at maximum velocity
|
# X trigger: logic-high output while the stage is at maximum velocity.
|
||||||
|
# Burst mode arms it per acquiring pass instead — a burst spans several
|
||||||
|
# rows with the scope running throughout, so leaving it armed would let
|
||||||
|
# the flyback trigger frames between rows.
|
||||||
|
if self._burst_mode:
|
||||||
|
ctrl.arm_scan_gate(AXIS_X, False)
|
||||||
|
else:
|
||||||
ctrl.set_trigger_trigout_maxv(AXIS_X)
|
ctrl.set_trigger_trigout_maxv(AXIS_X)
|
||||||
|
|
||||||
def _prepare_scope(self) -> int:
|
def _prepare_scope(self) -> int:
|
||||||
@@ -251,27 +290,14 @@ class ScanEngine:
|
|||||||
|
|
||||||
if self._resume is None:
|
if self._resume is None:
|
||||||
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS)
|
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS)
|
||||||
self._prompt(
|
|
||||||
"Background Capture",
|
|
||||||
"Please ensure the Helios laser is ON and the Genesis laser is OFF,\n"
|
|
||||||
"then click OK to capture the background waveform."
|
|
||||||
)
|
|
||||||
self._background = scope_sras.capture_background(
|
|
||||||
self._scope, should_abort=self._abort.is_set,
|
|
||||||
on_status=self._cb.on_status)
|
|
||||||
self._prompt(
|
|
||||||
"Begin Scanning",
|
|
||||||
"Background captured successfully.\n\n"
|
|
||||||
"Please ensure the Genesis laser is back ON,\n"
|
|
||||||
"then click OK to begin scanning."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Resuming: the file's existing background waveform and channel
|
# Resuming: the file's channel preambles are reused as-is (the
|
||||||
# preambles are reused as-is (the format has no way to replace
|
# format has no way to replace them without rewriting the whole
|
||||||
# them without rewriting the whole file), so background capture is
|
# file). Each re-acquired angle still captures its own fresh
|
||||||
# skipped. Sanity-check that this scope still produces the same
|
# background, which is rewritten in place over the old one.
|
||||||
# record length the file was started with — a mismatch would
|
# Sanity-check that this scope still produces the same record
|
||||||
# silently corrupt the ragged per-row byte layout on append.
|
# length the file was started with — a mismatch would silently
|
||||||
|
# corrupt the ragged per-row byte layout on append.
|
||||||
if samples_per_frame != self._resume.samples_per_frame:
|
if samples_per_frame != self._resume.samples_per_frame:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
|
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
|
||||||
@@ -285,11 +311,21 @@ class ScanEngine:
|
|||||||
f"angle(s) {targets} of {self._plan.n_angles}.\n\n"
|
f"angle(s) {targets} of {self._plan.n_angles}.\n\n"
|
||||||
"Please re-home the GR axis to 0° before continuing — the scan "
|
"Please re-home the GR axis to 0° before continuing — the scan "
|
||||||
"will rotate it directly from angle to angle before scanning resumes.\n\n"
|
"will rotate it directly from angle to angle before scanning resumes.\n\n"
|
||||||
"Please ensure the Genesis laser is ON,\n"
|
"Each angle begins with its own background capture, so you will "
|
||||||
"then click OK to continue scanning."
|
"be asked to switch the Genesis laser off and on again per angle."
|
||||||
)
|
)
|
||||||
|
|
||||||
scope_sras.configure_scan_trigger(self._scope)
|
scope_sras.configure_scan_trigger(self._scope)
|
||||||
|
|
||||||
|
if self._burst_mode:
|
||||||
|
# Horizontal settings are fixed by now, so the capacity is stable
|
||||||
|
# for the whole scan; only rows-per-burst varies (n_frames is
|
||||||
|
# per-angle).
|
||||||
|
self._max_frames = scope_burst.max_frames(self._scope)
|
||||||
|
self._cb.on_status(
|
||||||
|
f"Burst mode: scope holds {self._max_frames} frames "
|
||||||
|
f"({samples_per_frame} samples/frame)"
|
||||||
|
)
|
||||||
return samples_per_frame
|
return samples_per_frame
|
||||||
|
|
||||||
def _open_output(self, samples_per_frame: int, result: ScanResult):
|
def _open_output(self, samples_per_frame: int, result: ScanResult):
|
||||||
@@ -302,14 +338,14 @@ class ScanEngine:
|
|||||||
return open(self._resume.path, "r+b")
|
return open(self._resume.path, "r+b")
|
||||||
return create_scan_file(
|
return create_scan_file(
|
||||||
self._out_path, self._plan, samples_per_frame,
|
self._out_path, self._plan, samples_per_frame,
|
||||||
scope_sras.SAMPLE_RATE_HZ, self._preambles, self._background,
|
scope_sras.SAMPLE_RATE_HZ, self._preambles,
|
||||||
|
version=self._file_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _scan_loop(self, scan_file, samples_per_frame: int, result: ScanResult):
|
def _scan_loop(self, scan_file, samples_per_frame: int, result: ScanResult):
|
||||||
plan = self._plan
|
plan = self._plan
|
||||||
n_angles = plan.n_angles
|
n_angles = plan.n_angles
|
||||||
x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
||||||
ctrl = self._stage
|
|
||||||
scope = self._scope
|
scope = self._scope
|
||||||
|
|
||||||
targets_by_ai = None
|
targets_by_ai = None
|
||||||
@@ -324,11 +360,12 @@ class ScanEngine:
|
|||||||
continue # not selected for (re)acquisition
|
continue # not selected for (re)acquisition
|
||||||
self._pause_point()
|
self._pause_point()
|
||||||
|
|
||||||
if targets_by_ai is not None:
|
target = None if targets_by_ai is None else targets_by_ai[ai]
|
||||||
|
if target is not None:
|
||||||
# Interior angles may already have valid data on either side,
|
# Interior angles may already have valid data on either side,
|
||||||
# so seek to this angle's fixed offset rather than relying on
|
# so seek to this angle's fixed offset rather than relying on
|
||||||
# the file's current position.
|
# the file's current position.
|
||||||
scan_file.seek(targets_by_ai[ai].data_offset)
|
scan_file.seek(target.bg_offset)
|
||||||
|
|
||||||
if self._rotator is not None and self._rotator.is_available:
|
if self._rotator is not None and self._rotator.is_available:
|
||||||
delta = pa.angle_deg - self._rotator.current_deg
|
delta = pa.angle_deg - self._rotator.current_deg
|
||||||
@@ -337,9 +374,74 @@ class ScanEngine:
|
|||||||
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
|
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
|
||||||
self._rotator.rotate_to(pa.angle_deg)
|
self._rotator.rotate_to(pa.angle_deg)
|
||||||
|
|
||||||
# Each angle's bounding box gives it its own points/row count, so
|
self._write_angle_background(scan_file, ai, n_angles,
|
||||||
# the scope's FastFrame count must be re-armed per angle.
|
pa.angle_deg, target)
|
||||||
|
|
||||||
|
if self._burst_mode:
|
||||||
|
# Burst mode sizes the FastFrame count from the scope's whole
|
||||||
|
# capacity instead (see scope_burst.start_burst), so there is
|
||||||
|
# nothing to re-arm per angle here.
|
||||||
|
self._scan_rows_burst(scan_file, pa, ai, n_angles,
|
||||||
|
samples_per_frame, result, x_ramp_total)
|
||||||
|
else:
|
||||||
|
# Each angle's bounding box gives it its own points/row count,
|
||||||
|
# so the scope's FastFrame count must be re-armed per angle.
|
||||||
scope.set_fastframe_count(pa.n_frames)
|
scope.set_fastframe_count(pa.n_frames)
|
||||||
|
self._scan_rows_serial(scan_file, pa, ai, n_angles,
|
||||||
|
samples_per_frame, result, x_ramp_total)
|
||||||
|
|
||||||
|
result.angles_acquired.append(ai)
|
||||||
|
|
||||||
|
def _write_angle_background(self, scan_file, ai: int, n_angles: int,
|
||||||
|
angle_deg: float, target: ResumeTarget | None):
|
||||||
|
"""Capture this angle's background and write it ahead of its rows.
|
||||||
|
|
||||||
|
The Genesis laser has to be off for the capture and back on for the
|
||||||
|
scan, so every angle costs two operator prompts and one averaged
|
||||||
|
record. That buys a background taken minutes from the data it will
|
||||||
|
be subtracted from, instead of one taken hours earlier at angle 1.
|
||||||
|
|
||||||
|
On resume the block is overwritten in place, so it has to be exactly
|
||||||
|
as long as the one already there — anything else would shift every
|
||||||
|
row behind it. Checked before the write, not after.
|
||||||
|
"""
|
||||||
|
scope = self._scope
|
||||||
|
scope_sras.configure_background_trigger(scope)
|
||||||
|
self._prompt(
|
||||||
|
f"Background Capture — Angle {ai + 1}/{n_angles}",
|
||||||
|
f"Angle {ai + 1} of {n_angles} ({angle_deg:.1f}°) starts with its "
|
||||||
|
"own background capture.\n\n"
|
||||||
|
"Please switch the Genesis laser OFF — leave the Helios laser ON —\n"
|
||||||
|
"then click OK to capture the background waveform."
|
||||||
|
)
|
||||||
|
background = scope_sras.capture_background(
|
||||||
|
scope, should_abort=self._abort.is_set, on_status=self._cb.on_status)
|
||||||
|
self._prompt(
|
||||||
|
f"Begin Angle {ai + 1}/{n_angles}",
|
||||||
|
"Background captured successfully.\n\n"
|
||||||
|
"Please switch the Genesis laser back ON,\n"
|
||||||
|
f"then click OK to scan angle {ai + 1} of {n_angles}."
|
||||||
|
)
|
||||||
|
scope_sras.configure_scan_trigger(scope)
|
||||||
|
|
||||||
|
if target is not None:
|
||||||
|
room = target.data_offset - target.bg_offset
|
||||||
|
if BG_LEN_SIZE + len(background) != room:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Angle {ai + 1}: the new background block is "
|
||||||
|
f"{BG_LEN_SIZE + len(background)} bytes but the file has room "
|
||||||
|
f"for {room} — writing it would shift every row behind it, "
|
||||||
|
"so the scan stops here."
|
||||||
|
)
|
||||||
|
write_background_block(scan_file, background)
|
||||||
|
|
||||||
|
# ── Per-row acquisition (one FastFrame acquisition per row) ───────────────
|
||||||
|
|
||||||
|
def _scan_rows_serial(self, scan_file, pa, ai: int, n_angles: int,
|
||||||
|
samples_per_frame: int, result: ScanResult,
|
||||||
|
x_ramp_total: float):
|
||||||
|
ctrl = self._stage
|
||||||
|
scope = self._scope
|
||||||
|
|
||||||
for ri, y_pos in enumerate(pa.y_positions):
|
for ri, y_pos in enumerate(pa.y_positions):
|
||||||
self._pause_point()
|
self._pause_point()
|
||||||
@@ -363,31 +465,224 @@ class ScanEngine:
|
|||||||
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||||||
|
|
||||||
scope_sras.finish_row(scope)
|
scope_sras.finish_row(scope)
|
||||||
self._write_row(scan_file, samples_per_frame, ri)
|
self._write_row(scan_file, samples_per_frame, ri, pa.n_frames)
|
||||||
|
|
||||||
result.rows_written += 1
|
result.rows_written += 1
|
||||||
self._cb.on_row_done(ri + 1, pa.n_rows, ai + 1, n_angles)
|
self._cb.on_row_done(ri + 1, pa.n_rows, ai + 1, n_angles)
|
||||||
|
|
||||||
result.angles_acquired.append(ai)
|
def _write_row(self, scan_file, samples_per_frame: int, row_idx: int,
|
||||||
|
n_frames: int):
|
||||||
def _write_row(self, scan_file, samples_per_frame: int, row_idx: int):
|
|
||||||
"""Stream every channel from the scope into the file.
|
"""Stream every channel from the scope into the file.
|
||||||
|
|
||||||
CH3 is the max-vel gate signal — no useful waveform data — so zeroed
|
CH3 is the max-vel gate signal — no useful waveform data — so zeroed
|
||||||
frames are written to keep the file layout intact.
|
frames are written to keep the file layout intact.
|
||||||
"""
|
"""
|
||||||
scope = self._scope
|
scope = self._scope
|
||||||
|
ch_bytes = n_frames * samples_per_frame
|
||||||
for ch in SCAN_CHANNELS:
|
for ch in SCAN_CHANNELS:
|
||||||
if ch == 3:
|
if ch == 3:
|
||||||
self._cb.on_status("Writing zeroed CH3 frames …")
|
self._cb.on_status("Writing zeroed CH3 frames …")
|
||||||
zero_frame = bytes(samples_per_frame)
|
scan_file.write(bytes(ch_bytes))
|
||||||
for _ in range(scope_sras.frames_acquired(scope)):
|
|
||||||
scan_file.write(zero_frame)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._cb.on_status(f"Fetching CH{ch} data …")
|
self._cb.on_status(f"Fetching CH{ch} data …")
|
||||||
waveforms = scope_sras.transfer_channel(scope, ch)
|
waveforms = scope_sras.transfer_channel(scope, ch)
|
||||||
if ch == 4 and waveforms:
|
if ch == SCAN_CHANNELS[0]:
|
||||||
self._cb.on_dc_bias(row_idx + 1, scope_sras.frame_means(waveforms))
|
self._check_frame_delta(row_idx, len(waveforms), n_frames)
|
||||||
for w in waveforms:
|
row = scope_burst.normalize_row(
|
||||||
scan_file.write(w)
|
b"".join(waveforms), 0, len(waveforms), n_frames, samples_per_frame)
|
||||||
|
if ch == 4:
|
||||||
|
self._cb.on_dc_bias(row_idx + 1, scope_burst.frame_means_block(
|
||||||
|
row, 0, n_frames, samples_per_frame))
|
||||||
|
scan_file.write(row)
|
||||||
|
|
||||||
|
# ── Burst acquisition (many whole rows per FastFrame acquisition) ─────────
|
||||||
|
|
||||||
|
def _scan_rows_burst(self, scan_file, pa, ai: int, n_angles: int,
|
||||||
|
samples_per_frame: int, result: ScanResult,
|
||||||
|
x_ramp_total: float):
|
||||||
|
"""Acquire the angle in bursts of as many whole rows as the scope holds.
|
||||||
|
|
||||||
|
One ACQuire:STATE RUN spans the whole burst, so the gate is armed only
|
||||||
|
for each acquiring pass and dropped for the flyback — otherwise the
|
||||||
|
return move would reach max velocity and inject frames between rows.
|
||||||
|
"""
|
||||||
|
scope = self._scope
|
||||||
|
n_frames = pa.n_frames
|
||||||
|
x_lead_in = pa.x_start - x_ramp_total
|
||||||
|
x_end = pa.x_start + pa.x_delta + x_ramp_total
|
||||||
|
|
||||||
|
if not self._preflight_done:
|
||||||
|
# Once per scan: the gate wiring can't change between angles, and
|
||||||
|
# the check costs two row-times.
|
||||||
|
self._gate_off_preflight(x_lead_in, x_end)
|
||||||
|
self._preflight_done = True
|
||||||
|
|
||||||
|
row = 0
|
||||||
|
while row < pa.n_rows:
|
||||||
|
self._pause_point()
|
||||||
|
n_burst = scope_burst.rows_per_burst(
|
||||||
|
self._max_frames, n_frames, samples_per_frame, pa.n_rows - row)
|
||||||
|
self._cb.on_status(
|
||||||
|
f"Angle {ai+1}/{n_angles} Rows {row+1}-{row+n_burst}/{pa.n_rows} "
|
||||||
|
f"in one acquisition ({n_burst * n_frames} frames) …"
|
||||||
|
)
|
||||||
|
|
||||||
|
burst_start = scan_file.tell()
|
||||||
|
cumulative = []
|
||||||
|
baseline = scope_burst.start_burst(scope, self._max_frames)
|
||||||
|
try:
|
||||||
|
for r in range(n_burst):
|
||||||
|
self._check_abort()
|
||||||
|
self._cb.on_row_started(row + r + 1, pa.n_rows,
|
||||||
|
ai + 1, n_angles)
|
||||||
|
self._acquire_gated_row(pa.y_positions[row + r],
|
||||||
|
x_lead_in, x_end)
|
||||||
|
total = scope_burst.frames_acquired(scope)
|
||||||
|
if total >= self._max_frames:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"FastFrame buffer full ({total}/{self._max_frames} "
|
||||||
|
f"frames) at row {row + r + 1} — later rows in this "
|
||||||
|
"burst would be misattributed. Raise "
|
||||||
|
"scope_burst.BURST_FRAME_HEADROOM and rerun."
|
||||||
|
)
|
||||||
|
cumulative.append(total - baseline)
|
||||||
|
finally:
|
||||||
|
scope_burst.stop_burst(scope)
|
||||||
|
|
||||||
|
counts = scope_burst.split_row_counts(cumulative)
|
||||||
|
self._write_burst(scan_file, burst_start, row, counts,
|
||||||
|
n_frames, samples_per_frame)
|
||||||
|
|
||||||
|
for r in range(n_burst):
|
||||||
|
result.rows_written += 1
|
||||||
|
self._cb.on_row_done(row + r + 1, pa.n_rows, ai + 1, n_angles)
|
||||||
|
row += n_burst
|
||||||
|
|
||||||
|
def _acquire_gated_row(self, y_pos: float, x_lead_in: float, x_end: float):
|
||||||
|
"""One row: step Y, fly back gated off, then acquire on the +X pass."""
|
||||||
|
ctrl = self._stage
|
||||||
|
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
|
||||||
|
ctrl.move_axis_absolute(AXIS_X, x_lead_in, timeout=30.0)
|
||||||
|
ctrl.arm_scan_gate(AXIS_X, True)
|
||||||
|
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||||||
|
ctrl.arm_scan_gate(AXIS_X, False)
|
||||||
|
time.sleep(scope_burst.BURST_ROW_SETTLE_S)
|
||||||
|
|
||||||
|
def _gate_off_preflight(self, x_lead_in: float, x_end: float):
|
||||||
|
"""Prove the gate really gates before trusting a multi-row burst.
|
||||||
|
|
||||||
|
The value that makes the BBD trigger output idle low is not settled by
|
||||||
|
the protocol docs (see apt_constants.TRIGOUT_GATE_OFF), and getting it
|
||||||
|
wrong fills every burst with flyback frames that silently shift the
|
||||||
|
file. The scope already measures the gate on CH3, so this needs no
|
||||||
|
bench probe: one gated-off flyback must acquire nothing, and one gated
|
||||||
|
pass must acquire something — the second half is what stops a dark
|
||||||
|
laser from making the first half pass vacuously.
|
||||||
|
|
||||||
|
Leaves the stage parked at x_end, where the burst loop expects it.
|
||||||
|
"""
|
||||||
|
ctrl, scope = self._stage, self._scope
|
||||||
|
self._cb.on_status("Burst preflight: checking the stage gate …")
|
||||||
|
|
||||||
|
ctrl.arm_scan_gate(AXIS_X, False)
|
||||||
|
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||||||
|
baseline = scope_burst.start_burst(scope, self._max_frames)
|
||||||
|
ctrl.move_axis_absolute(AXIS_X, x_lead_in, timeout=120.0)
|
||||||
|
scope_burst.stop_burst(scope)
|
||||||
|
leaked = scope_burst.frames_acquired(scope) - baseline
|
||||||
|
|
||||||
|
ctrl.arm_scan_gate(AXIS_X, True)
|
||||||
|
baseline = scope_burst.start_burst(scope, self._max_frames)
|
||||||
|
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
|
||||||
|
ctrl.arm_scan_gate(AXIS_X, False)
|
||||||
|
scope_burst.stop_burst(scope)
|
||||||
|
gated = scope_burst.frames_acquired(scope) - baseline
|
||||||
|
|
||||||
|
if gated <= 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Burst preflight: no frames acquired with the gate armed. "
|
||||||
|
"Check that the Genesis laser is pulsing (CH2) and that the "
|
||||||
|
"BBD X trigger output reaches CH3 before scanning."
|
||||||
|
)
|
||||||
|
if leaked:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Burst preflight: {leaked} frame(s) acquired during a flyback "
|
||||||
|
"that should have been gated off — the BBD trigger output is "
|
||||||
|
"not idling low. Set apt_constants.TRIGOUT_GATE_OFF to "
|
||||||
|
"TriggerBitsServo.TRIGOUT_HIGH and retry, or use per-row "
|
||||||
|
"acquisition."
|
||||||
|
)
|
||||||
|
self._cb.on_status(
|
||||||
|
f"Burst preflight OK ({gated} frames gated on, 0 leaked).")
|
||||||
|
|
||||||
|
def _write_burst(self, scan_file, burst_start: int, first_row: int,
|
||||||
|
counts: list[int], n_frames: int, samples_per_frame: int):
|
||||||
|
"""Deinterleave one burst into the file's per-row, per-channel blocks.
|
||||||
|
|
||||||
|
The wire is channel-major (every row of CH1, then every row of CH4);
|
||||||
|
the file is row-major with channels inner. Writing one channel at a
|
||||||
|
time to strided offsets keeps peak memory at a single channel's burst
|
||||||
|
instead of the whole thing.
|
||||||
|
"""
|
||||||
|
scope = self._scope
|
||||||
|
ch_bytes = n_frames * samples_per_frame
|
||||||
|
row_bytes = len(SCAN_CHANNELS) * ch_bytes
|
||||||
|
total_frames = sum(counts)
|
||||||
|
|
||||||
|
for r, count in enumerate(counts):
|
||||||
|
self._check_frame_delta(first_row + r, count, n_frames)
|
||||||
|
|
||||||
|
for ch_idx, ch in enumerate(SCAN_CHANNELS):
|
||||||
|
if ch == 3:
|
||||||
|
self._cb.on_status("Writing zeroed CH3 frames …")
|
||||||
|
blob = None
|
||||||
|
else:
|
||||||
|
self._cb.on_status(
|
||||||
|
f"Fetching CH{ch} burst ({total_frames} frames) …")
|
||||||
|
blob = scope_burst.transfer_burst(scope, ch, total_frames,
|
||||||
|
samples_per_frame)
|
||||||
|
src = 0
|
||||||
|
zeros = bytes(ch_bytes) if blob is None else None
|
||||||
|
for r, count in enumerate(counts):
|
||||||
|
scan_file.seek(burst_start + r * row_bytes + ch_idx * ch_bytes)
|
||||||
|
if blob is None:
|
||||||
|
scan_file.write(zeros)
|
||||||
|
else:
|
||||||
|
row = scope_burst.normalize_row(
|
||||||
|
blob, src, count, n_frames, samples_per_frame)
|
||||||
|
if ch == 4:
|
||||||
|
self._cb.on_dc_bias(
|
||||||
|
first_row + r + 1,
|
||||||
|
scope_burst.frame_means_block(
|
||||||
|
row, 0, n_frames, samples_per_frame))
|
||||||
|
scan_file.write(row)
|
||||||
|
src += count * samples_per_frame
|
||||||
|
del blob
|
||||||
|
|
||||||
|
scan_file.seek(burst_start + len(counts) * row_bytes)
|
||||||
|
|
||||||
|
def _check_frame_delta(self, row_idx: int, count: int, n_frames: int):
|
||||||
|
"""Decide what to do with a row that did not acquire n_frames frames.
|
||||||
|
|
||||||
|
v6 declares n_frames per row in the header and has no per-row length
|
||||||
|
field, so a mismatched row cannot just be written as-is — that would
|
||||||
|
shift every later row in the file. The only two safe options are to
|
||||||
|
square it up or to stop, which is what strict_rows selects between.
|
||||||
|
|
||||||
|
Called before anything for the row is written (CH1 leads
|
||||||
|
SCAN_CHANNELS), so raising here leaves no partial row behind.
|
||||||
|
"""
|
||||||
|
if count == n_frames:
|
||||||
|
return
|
||||||
|
verb = "zero-padded" if count < n_frames else "truncated"
|
||||||
|
if self._strict_rows:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
|
||||||
|
f"expected. Strict row packing is on, so the scan stops here "
|
||||||
|
f"rather than writing a row that would be {verb}."
|
||||||
|
)
|
||||||
|
msg = (f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
|
||||||
|
f"expected — {verb} to keep the file layout intact.")
|
||||||
|
logger.warning(msg)
|
||||||
|
self._cb.on_status(msg)
|
||||||
|
|||||||
+12
-5
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from core.scan_engine import ResumeState, ResumeTarget
|
from core.scan_engine import ResumeState, ResumeTarget
|
||||||
from core.sras_format import SrasFile
|
from core.sras_format import WRITABLE_VERSIONS, SrasFile
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -44,8 +44,9 @@ def plan_resume(statuses, selected: set[int]) -> ResumePlan:
|
|||||||
final = set(selected)
|
final = set(selected)
|
||||||
|
|
||||||
targets = [
|
targets = [
|
||||||
ResumeTarget(angle_idx=s.index, data_offset=s.data_offset,
|
ResumeTarget(angle_idx=s.index, bg_offset=s.bg_offset,
|
||||||
n_rows=s.n_rows, angle_deg=s.angle_deg)
|
data_offset=s.data_offset, 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
|
||||||
]
|
]
|
||||||
return ResumePlan(targets=targets,
|
return ResumePlan(targets=targets,
|
||||||
@@ -55,9 +56,15 @@ def plan_resume(statuses, selected: set[int]) -> ResumePlan:
|
|||||||
|
|
||||||
def is_compatible(sras: SrasFile, *, velocity: float, laser_freq: float,
|
def is_compatible(sras: SrasFile, *, velocity: float, laser_freq: float,
|
||||||
sample_rate: float, n_channels: int) -> bool:
|
sample_rate: float, n_channels: int) -> bool:
|
||||||
"""Whether appending to this file with the current settings is safe."""
|
"""Whether appending to this file with the current settings is safe.
|
||||||
|
|
||||||
|
A legacy v6/v10 file is not: it has one background for the whole scan,
|
||||||
|
and every angle this engine acquires writes a background block of its
|
||||||
|
own, which the older layout has no room for.
|
||||||
|
"""
|
||||||
h = sras.header
|
h = sras.header
|
||||||
return (h.bytes_per_sample == 1
|
return (sras.version in WRITABLE_VERSIONS
|
||||||
|
and h.bytes_per_sample == 1
|
||||||
and h.n_channels == n_channels
|
and h.n_channels == n_channels
|
||||||
and abs(h.velocity - velocity) <= 1e-3
|
and abs(h.velocity - velocity) <= 1e-3
|
||||||
and abs(h.laser_freq - laser_freq) <= 1e-3
|
and abs(h.laser_freq - laser_freq) <= 1e-3
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Burst-mode FastFrame acquisition policy.
|
||||||
|
|
||||||
|
Per-row acquisition pays a full arm/stop/transfer round trip for every row,
|
||||||
|
and the transfer alone is one IEEE-488.2 block read per frame (~16k frames a
|
||||||
|
row). A burst instead runs one FastFrame acquisition across as many complete
|
||||||
|
rows as the scope's frame memory holds, then pulls the whole thing in a single
|
||||||
|
transaction — amortising the round trip over `rows_per_burst` rows.
|
||||||
|
|
||||||
|
The scope reports its capacity with ``HORizontal:FASTframe:MAXFRames?`` once
|
||||||
|
the horizontal settings are fixed; ``rows_per_burst`` turns that into a row
|
||||||
|
count. Everything here that computes rather than talks to hardware is a free
|
||||||
|
function, so the row-splitting logic is testable without a rig.
|
||||||
|
|
||||||
|
The catch is that the burst contains no row markers: the scope hands back one
|
||||||
|
flat run of frames. Boundaries come from polling ``ACQuire:NUMFRAMESACQuired?``
|
||||||
|
after each row's acquiring pass, while the stage gate is already low — see
|
||||||
|
``split_row_counts``.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Peak transfer buffer, per channel. The writer holds one channel at a time
|
||||||
|
# (see ScanEngine._scan_rows_burst), so this is the real high-water mark.
|
||||||
|
BURST_MEMORY_BUDGET_BYTES = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
# Extra frames budgeted per row on top of n_frames. 0 gives the plain
|
||||||
|
# floor(max_frames / n_frames) row count; raise it if the acquiring pass
|
||||||
|
# routinely over-triggers (watch the pad/truncate warnings).
|
||||||
|
BURST_FRAME_HEADROOM = 0
|
||||||
|
|
||||||
|
BURST_ARM_SETTLE_S = 0.05 # after ACQuire:STATE RUN, before the first move
|
||||||
|
BURST_ROW_SETTLE_S = 0.05 # after the gate drops, before reading the counter
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pure helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def rows_per_burst(max_frames: int, n_frames: int, samples_per_frame: int,
|
||||||
|
rows_remaining: int,
|
||||||
|
memory_budget: int = BURST_MEMORY_BUDGET_BYTES,
|
||||||
|
headroom: int = BURST_FRAME_HEADROOM) -> int:
|
||||||
|
"""How many complete rows fit in one acquisition.
|
||||||
|
|
||||||
|
Rounds down — a partial row is worthless, since a row must be transferred
|
||||||
|
whole to be written. Clamped by the transfer buffer budget and by the rows
|
||||||
|
actually left in the angle, and never below 1 (a single row always goes,
|
||||||
|
even if it exceeds the budget, so the scan can still make progress).
|
||||||
|
"""
|
||||||
|
if n_frames < 1 or samples_per_frame < 1:
|
||||||
|
raise ValueError(f"n_frames={n_frames} samples_per_frame={samples_per_frame}")
|
||||||
|
|
||||||
|
by_scope = max_frames // (n_frames + headroom)
|
||||||
|
by_memory = memory_budget // (n_frames * samples_per_frame)
|
||||||
|
return max(1, min(by_scope, by_memory, rows_remaining))
|
||||||
|
|
||||||
|
|
||||||
|
def split_row_counts(cumulative: list[int]) -> list[int]:
|
||||||
|
"""Per-row frame counts from the cumulative counter sampled after each row.
|
||||||
|
|
||||||
|
``cumulative`` is ``ACQuire:NUMFRAMESACQuired?`` read once per row, already
|
||||||
|
rebased on the value at burst start.
|
||||||
|
"""
|
||||||
|
counts = []
|
||||||
|
prev = 0
|
||||||
|
for i, c in enumerate(cumulative):
|
||||||
|
if c < prev:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"FastFrame counter went backwards at row {i} ({prev} → {c}) — "
|
||||||
|
"the acquisition was restarted mid-burst"
|
||||||
|
)
|
||||||
|
counts.append(c - prev)
|
||||||
|
prev = c
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_row(buf, offset: int, count: int, n_frames: int,
|
||||||
|
samples_per_frame: int):
|
||||||
|
"""Coerce one row's frames to exactly ``n_frames``.
|
||||||
|
|
||||||
|
The v6 format commits to n_frames per row in the header and has no per-row
|
||||||
|
length field, so a row that over- or under-triggers must be squared up or
|
||||||
|
every later row in the file shifts. Short rows are zero-padded, long rows
|
||||||
|
lose their trailing frames. Returns something writable directly.
|
||||||
|
"""
|
||||||
|
want = n_frames * samples_per_frame
|
||||||
|
end = min(offset + count * samples_per_frame, offset + want, len(buf))
|
||||||
|
chunk = memoryview(buf)[offset:end]
|
||||||
|
if len(chunk) == want:
|
||||||
|
return chunk
|
||||||
|
return bytes(chunk) + bytes(want - len(chunk))
|
||||||
|
|
||||||
|
|
||||||
|
def frame_means_block(buf, offset: int, n_frames: int,
|
||||||
|
samples_per_frame: int) -> list[float]:
|
||||||
|
"""Per-frame DC mean over one row's slice of a burst buffer."""
|
||||||
|
n = n_frames * samples_per_frame
|
||||||
|
block = np.frombuffer(buf, dtype=np.int8, count=n, offset=offset)
|
||||||
|
return block.reshape(n_frames, samples_per_frame).mean(
|
||||||
|
axis=1, dtype=np.float32).tolist()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Instrument control ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def max_frames(scope) -> int:
|
||||||
|
"""Frames the scope can hold under the current horizontal settings."""
|
||||||
|
try:
|
||||||
|
m = scope.get_fastframe_max_frames()
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Scope did not answer HORizontal:FASTframe:MAXFRames? — burst mode "
|
||||||
|
"cannot size a burst without it. Use per-row acquisition on this "
|
||||||
|
f"firmware. ({exc})"
|
||||||
|
) from exc
|
||||||
|
if m < 1:
|
||||||
|
raise RuntimeError(f"Scope reports a FastFrame capacity of {m} frames")
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def start_burst(scope, frame_count: int) -> int:
|
||||||
|
"""Arm one burst; returns the counter baseline to subtract from later reads.
|
||||||
|
|
||||||
|
Reading the baseline back beats assuming the counter resets to 0 on RUN —
|
||||||
|
any residual is simply subtracted out instead of being misattributed to the
|
||||||
|
first row.
|
||||||
|
"""
|
||||||
|
scope.set_fastframe_count(frame_count)
|
||||||
|
scope.write("ACQuire:STATE RUN")
|
||||||
|
time.sleep(BURST_ARM_SETTLE_S)
|
||||||
|
return frames_acquired(scope)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_burst(scope) -> None:
|
||||||
|
time.sleep(BURST_ROW_SETTLE_S)
|
||||||
|
scope.write("ACQuire:STATE STOP")
|
||||||
|
|
||||||
|
|
||||||
|
def frames_acquired(scope) -> int:
|
||||||
|
return int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_burst(scope, ch: int, frame_count: int, samples_per_frame: int):
|
||||||
|
"""Pull a whole burst for one channel in a single CURVe? transaction."""
|
||||||
|
scope.set_data_source(ch)
|
||||||
|
return scope.transfer_fastframe_bulk(frame_count, samples_per_frame)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Oscilloscope configuration for pre-scan angle inspection.
|
||||||
|
|
||||||
|
Inspection is read-on-the-instrument: nothing in this module transfers or
|
||||||
|
plots waveform data. The app puts the scope into a free-running, edge-
|
||||||
|
triggered state and drives the stage to the point being inspected; the
|
||||||
|
operator judges the SAW response and the bias levels on the scope screen.
|
||||||
|
|
||||||
|
That split is deliberate. A scan's acquisition trigger is the logic AND of
|
||||||
|
the laser pulse and the stage's max-velocity gate, and its transfers are
|
||||||
|
FastFrame blocks — neither is useful for looking at one point by eye. Here
|
||||||
|
the trigger is a plain edge on the laser pulse, FastFrame is off, and the
|
||||||
|
acquisition free-runs, so the display updates continuously while the stage
|
||||||
|
sits still.
|
||||||
|
|
||||||
|
CH1 keeps the acquisition front-end so what is on screen is what a scan would
|
||||||
|
record. CH3 and CH4 are rescaled as DC bias monitors (see BIAS_* below).
|
||||||
|
|
||||||
|
``read_bias_mv`` is the one exception to "nothing is transferred": it reads
|
||||||
|
the two bias levels back as scalars, not waveforms, because the auto-align
|
||||||
|
procedure (core.auto_align) has to close a loop on them. The operator still
|
||||||
|
watches the same screen this configures.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from core.scope_sras import SAMPLE_RATE_HZ, SRAS_CHANNELS, configure_channels
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# CH2 carries the laser pulse. The scan triggers it at 0.5 V as one term of a
|
||||||
|
# logic AND; inspection triggers well above that so a slow edge or a noisy
|
||||||
|
# baseline cannot free-run the display.
|
||||||
|
INSPECT_TRIG_LEVEL_V = 2.0
|
||||||
|
|
||||||
|
# CH3/CH4 are the DC bias monitors during inspection. The signal never goes
|
||||||
|
# negative and spans roughly 0–700 mV, so both channels get the *same* scale
|
||||||
|
# and position — the point of inspecting them is comparing the two by eye, and
|
||||||
|
# that only works if a division means the same thing on each.
|
||||||
|
#
|
||||||
|
# Ground sits BIAS_POSITION_DIV divisions below centre, which puts the whole
|
||||||
|
# 0–700 mV range above the centre line with a little room underneath for
|
||||||
|
# undershoot. With 100 mV/div and ground 3.5 divisions low, the visible window
|
||||||
|
# runs from about -50 mV to +750 mV on an 8-division display and wider on a
|
||||||
|
# 10-division one, so 0–700 mV sits comfortably inside either.
|
||||||
|
BIAS_CHANNELS = (3, 4)
|
||||||
|
BIAS_WINDOW_V = 0.700
|
||||||
|
BIAS_SCALE_V_DIV = 0.100
|
||||||
|
BIAS_POSITION_DIV = -3.5
|
||||||
|
|
||||||
|
BIAS_LABELS = {3: "Bias - A", 4: "Bias - B"}
|
||||||
|
|
||||||
|
# One MEAN measurement carries the shot-to-shot noise of a single record, and
|
||||||
|
# the alignment loop has to resolve 5 mV. The median of a handful of reads
|
||||||
|
# rejects the odd outlier without the averaging acquisition mode, which would
|
||||||
|
# hide exactly the intermittent response the operator is watching CH1 for.
|
||||||
|
BIAS_READS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_channel_profiles() -> dict:
|
||||||
|
"""Channel front-end config for inspection.
|
||||||
|
|
||||||
|
CH1 and CH2 are the acquisition profiles verbatim. CH3 and CH4 differ
|
||||||
|
only in label, scale and position — termination, coupling and bandwidth
|
||||||
|
stay as the scan sets them, so the bias reading is the same measurement
|
||||||
|
the scan records, just displayed usefully.
|
||||||
|
"""
|
||||||
|
profiles = dict(SRAS_CHANNELS)
|
||||||
|
for ch in BIAS_CHANNELS:
|
||||||
|
profiles[ch] = replace(
|
||||||
|
SRAS_CHANNELS[ch],
|
||||||
|
label=BIAS_LABELS[ch],
|
||||||
|
scale_v_div=BIAS_SCALE_V_DIV,
|
||||||
|
position_div=BIAS_POSITION_DIV,
|
||||||
|
)
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
def configure_inspection(scope) -> None:
|
||||||
|
"""Put the scope into free-running inspection mode.
|
||||||
|
|
||||||
|
Leaves the acquisition running, so the display stays live while the
|
||||||
|
operator moves between angles and points.
|
||||||
|
"""
|
||||||
|
configure_channels(scope, inspect_channel_profiles())
|
||||||
|
|
||||||
|
# Plain edge trigger on the laser pulse — no logic pattern, so the stage
|
||||||
|
# gate plays no part and a stationary stage still triggers.
|
||||||
|
scope.write("TRIGger:A:TYPe EDGE")
|
||||||
|
scope.set_trigger_source(2)
|
||||||
|
scope.set_trigger_slope("RISE")
|
||||||
|
scope.set_trigger_level(2, INSPECT_TRIG_LEVEL_V)
|
||||||
|
scope.set_trigger_mode("NORMAL")
|
||||||
|
|
||||||
|
# No averaging: a weak or intermittent SAW response is exactly what the
|
||||||
|
# operator is looking for, and averaging would hide it.
|
||||||
|
scope.set_acquire_mode("SAMPLE")
|
||||||
|
scope.set_fastframe_state(False)
|
||||||
|
|
||||||
|
scope.set_sample_rate(SAMPLE_RATE_HZ)
|
||||||
|
scope.write("HORizontal:POSition 30")
|
||||||
|
|
||||||
|
# Free-run rather than single-sequence, so the trace keeps updating.
|
||||||
|
scope.write("ACQuire:STOPAfter RUNSTop")
|
||||||
|
scope.write("ACQuire:STATE RUN")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_inspection(scope) -> None:
|
||||||
|
"""Halt the free-running acquisition.
|
||||||
|
|
||||||
|
The next scan reconfigures the scope from scratch, so this only needs to
|
||||||
|
stop the sweep — it does not try to restore the acquisition profile.
|
||||||
|
"""
|
||||||
|
scope.write("ACQuire:STATE STOP")
|
||||||
|
|
||||||
|
|
||||||
|
def read_bias_mv(scope, reads: int = BIAS_READS) -> tuple[float, float]:
|
||||||
|
"""Read the two DC bias levels in millivolts.
|
||||||
|
|
||||||
|
Returns ``(ch3_mv, ch4_mv)`` — DC 1 and DC 2 in the auto-align channel
|
||||||
|
map. Each channel is read ``reads`` times and reduced by the median.
|
||||||
|
|
||||||
|
The two channels are read in separate batches rather than interleaved:
|
||||||
|
switching the immediate-measurement source costs a round trip, and these
|
||||||
|
are DC levels, so the few milliseconds between the batches are not a
|
||||||
|
source of error the way they would be for a transient.
|
||||||
|
"""
|
||||||
|
if reads < 1:
|
||||||
|
raise ValueError("read_bias_mv needs at least one read per channel")
|
||||||
|
|
||||||
|
levels = []
|
||||||
|
for ch in BIAS_CHANNELS:
|
||||||
|
samples = sorted(scope.measure_immediate(ch, "MEAN") for _ in range(reads))
|
||||||
|
levels.append(samples[len(samples) // 2] * 1000.0)
|
||||||
|
return levels[0], levels[1]
|
||||||
+16
-27
@@ -10,8 +10,6 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SAMPLE_RATE_HZ = 6.25e9 # 6.25 GS/s → 160 ps/sample
|
SAMPLE_RATE_HZ = 6.25e9 # 6.25 GS/s → 160 ps/sample
|
||||||
@@ -53,11 +51,13 @@ def configure_channels(scope, profiles=None) -> None:
|
|||||||
scope.set_channel_bandwidth(ch, p.bandwidth_hz)
|
scope.set_channel_bandwidth(ch, p.bandwidth_hz)
|
||||||
|
|
||||||
|
|
||||||
def configure_acquisition(scope) -> int:
|
def configure_background_trigger(scope) -> None:
|
||||||
"""Program the edge trigger and timebase; returns samples per frame.
|
"""Program the edge trigger used for a background capture.
|
||||||
|
|
||||||
Edge trigger on the rising edge of CH2 (laser pulse). FastFrame stays
|
Edge trigger on the rising edge of CH2 (laser pulse), FastFrame off so
|
||||||
off here so the background capture runs as a single record.
|
the capture runs as a single record. Every angle starts with a fresh
|
||||||
|
background, so the scan comes back here between angles from the
|
||||||
|
logic-AND trigger configure_scan_trigger leaves behind.
|
||||||
"""
|
"""
|
||||||
scope.write("TRIGger:A:TYPe EDGE")
|
scope.write("TRIGger:A:TYPe EDGE")
|
||||||
scope.set_trigger_source(2)
|
scope.set_trigger_source(2)
|
||||||
@@ -66,6 +66,16 @@ def configure_acquisition(scope) -> int:
|
|||||||
scope.set_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep)
|
scope.set_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep)
|
||||||
scope.set_acquire_mode("SAMPLE")
|
scope.set_acquire_mode("SAMPLE")
|
||||||
scope.set_fastframe_state(False)
|
scope.set_fastframe_state(False)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_acquisition(scope) -> int:
|
||||||
|
"""Program the edge trigger and timebase; returns samples per frame."""
|
||||||
|
configure_background_trigger(scope)
|
||||||
|
# Pin the transfer format instead of inheriting front-panel state — the
|
||||||
|
# file header hardcodes bytes_per_sample=1, and a scope left on 2 bytes
|
||||||
|
# would corrupt every frame written.
|
||||||
|
scope.set_data_encoding("RIBinary")
|
||||||
|
scope.set_data_width(1)
|
||||||
scope.set_sample_rate(SAMPLE_RATE_HZ)
|
scope.set_sample_rate(SAMPLE_RATE_HZ)
|
||||||
scope.write("HORizontal:POSition 30") # 10 % trigger offset
|
scope.write("HORizontal:POSition 30") # 10 % trigger offset
|
||||||
time.sleep(0.3) # let the timebase settle before reading back
|
time.sleep(0.3) # let the timebase settle before reading back
|
||||||
@@ -140,28 +150,7 @@ def finish_row(scope) -> None:
|
|||||||
scope.write("ACQuire:STATE STOP")
|
scope.write("ACQuire:STATE STOP")
|
||||||
|
|
||||||
|
|
||||||
def frames_acquired(scope) -> int:
|
|
||||||
return int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
|
||||||
|
|
||||||
|
|
||||||
def transfer_channel(scope, ch: int) -> list[bytes]:
|
def transfer_channel(scope, ch: int) -> list[bytes]:
|
||||||
"""Fetch one channel's FastFrame block as raw int8 frames."""
|
"""Fetch one channel's FastFrame block as raw int8 frames."""
|
||||||
scope.set_data_source(ch)
|
scope.set_data_source(ch)
|
||||||
return scope.transfer_fastframe(parse=False)
|
return scope.transfer_fastframe(parse=False)
|
||||||
|
|
||||||
|
|
||||||
def frame_means(waveforms: list[bytes]) -> list[float]:
|
|
||||||
"""Per-frame DC mean of a raw int8 FastFrame block.
|
|
||||||
|
|
||||||
numpy over the joined buffer: the per-frame struct.unpack this replaces
|
|
||||||
allocated a tuple of Python ints per frame (~16k frames per row).
|
|
||||||
"""
|
|
||||||
if not waveforms:
|
|
||||||
return []
|
|
||||||
n = len(waveforms[0])
|
|
||||||
if n == 0 or any(len(w) != n for w in waveforms):
|
|
||||||
# Ragged block (shouldn't happen) — fall back to per-frame means.
|
|
||||||
return [float(np.frombuffer(w, dtype=np.int8).mean()) if len(w) else 0.0
|
|
||||||
for w in waveforms]
|
|
||||||
block = np.frombuffer(b"".join(waveforms), dtype=np.int8).reshape(len(waveforms), n)
|
|
||||||
return block.mean(axis=1, dtype=np.float32).tolist()
|
|
||||||
|
|||||||
+178
-36
@@ -1,4 +1,5 @@
|
|||||||
"""SRAS v6 binary scan-file format — the single implementation.
|
"""SRAS binary scan-file format (v7/v11, reading v6/v10 too) — the single
|
||||||
|
implementation.
|
||||||
|
|
||||||
Full byte-level spec: scan_format.md. Summary:
|
Full byte-level spec: scan_format.md. Summary:
|
||||||
|
|
||||||
@@ -9,14 +10,33 @@ Full byte-level spec: scan_format.md. Summary:
|
|||||||
geometry table n_angles × >ffIH (x_start x_delta n_frames n_rows)
|
geometry table n_angles × >ffIH (x_start x_delta n_frames n_rows)
|
||||||
row tables (ragged) per angle: n_rows × >f (y positions)
|
row tables (ragged) per angle: n_rows × >f (y positions)
|
||||||
preambles n_channels × (>H length + utf-8 WFMOutpre string)
|
preambles n_channels × (>H length + utf-8 WFMOutpre string)
|
||||||
background block >I length + raw int8 CH1 average
|
data block per angle: [background][rows]
|
||||||
waveform data angle-major, row-minor, channel-inner:
|
background = >I length + raw int8 CH1 average
|
||||||
for each angle, for each row, for each channel,
|
rows = for each row, for each channel,
|
||||||
n_frames × samples_per_frame × bytes_per_sample
|
n_frames × samples_per_frame × bytes_per_sample
|
||||||
|
|
||||||
|
Every angle carries its own background: the operator switches the Genesis
|
||||||
|
laser off before each angle and the engine averages a fresh CH1 record, so
|
||||||
|
the reference a reader subtracts was taken minutes — not hours — from the
|
||||||
|
data it is subtracted from. That is the whole difference between v7 and v6,
|
||||||
|
which held a single background for the entire file, ahead of the data block.
|
||||||
|
|
||||||
Incomplete files are valid: the data block is one contiguous append-only
|
Incomplete files are valid: the data block is one contiguous append-only
|
||||||
stream, so the readable prefix defines a single frontier past which nothing
|
stream, so the readable prefix defines a single frontier past which nothing
|
||||||
has been written yet (see ``SrasFile.angle_status``).
|
has been written yet (see ``SrasFile.angle_status``). Because the background
|
||||||
|
blocks are length-prefixed, the per-angle offsets come from a walk of that
|
||||||
|
stream at parse time rather than from arithmetic on the geometry table.
|
||||||
|
|
||||||
|
Version 11 is the SAW quality check (core.saw_check): byte layout identical
|
||||||
|
to v7, but every angle declares exactly one row — the row-wise middle of the
|
||||||
|
ROI. The version byte is the whole difference, and it exists so a reader can
|
||||||
|
tell a one-row-per-angle check from a full scan that was aborted after its
|
||||||
|
first row. ``create_scan_file`` enforces the one-row rule at write time.
|
||||||
|
|
||||||
|
v6 and v10 are the pre-per-angle-background versions of the same two files.
|
||||||
|
They are still read (every scan taken before this change is one); nothing
|
||||||
|
writes them any more, and a v6 file cannot be resumed into, since appending
|
||||||
|
v7 blocks to it would shift its data.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -31,11 +51,21 @@ import numpy as np
|
|||||||
from core.scan_geometry import AngleGeometry, ScanPlan
|
from core.scan_geometry import AngleGeometry, ScanPlan
|
||||||
|
|
||||||
MAGIC = b"SRAS"
|
MAGIC = b"SRAS"
|
||||||
VERSION = 6
|
VERSION = 7
|
||||||
|
# One row per angle, taken from the middle of the ROI — see core.saw_check.
|
||||||
|
VERSION_SAW_CHECK = 11
|
||||||
|
# v6/v10: the same two files with one background for the whole scan, written
|
||||||
|
# ahead of the data block instead of once per angle. Read-only.
|
||||||
|
LEGACY_VERSIONS = (6, 10)
|
||||||
|
WRITABLE_VERSIONS = (VERSION, VERSION_SAW_CHECK)
|
||||||
|
SUPPORTED_VERSIONS = tuple(sorted(WRITABLE_VERSIONS + LEGACY_VERSIONS))
|
||||||
|
SAW_CHECK_VERSIONS = (10, VERSION_SAW_CHECK)
|
||||||
HDR_FMT = ">4sBHfffffffIdBB"
|
HDR_FMT = ">4sBHfffffffIdBB"
|
||||||
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
|
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
|
||||||
GEOM_FMT = ">ffIH"
|
GEOM_FMT = ">ffIH"
|
||||||
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
|
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
|
||||||
|
BG_LEN_FMT = ">I"
|
||||||
|
BG_LEN_SIZE = struct.calcsize(BG_LEN_FMT) # 4 bytes
|
||||||
|
|
||||||
# Oscilloscope channels recorded, in on-disk order.
|
# Oscilloscope channels recorded, in on-disk order.
|
||||||
SCAN_CHANNELS = [1, 3, 4]
|
SCAN_CHANNELS = [1, 3, 4]
|
||||||
@@ -69,7 +99,8 @@ class AngleStatus:
|
|||||||
angle_deg: float
|
angle_deg: float
|
||||||
n_rows: int # declared
|
n_rows: int # declared
|
||||||
row_bytes: int
|
row_bytes: int
|
||||||
data_offset: int
|
bg_offset: int # start of this angle's background block
|
||||||
|
data_offset: int # start of its rows, i.e. just past that block
|
||||||
n_rows_available: int
|
n_rows_available: int
|
||||||
status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING
|
status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING
|
||||||
|
|
||||||
@@ -77,19 +108,46 @@ class AngleStatus:
|
|||||||
def complete(self) -> bool:
|
def complete(self) -> bool:
|
||||||
return self.status == STATUS_OK
|
return self.status == STATUS_OK
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bg_bytes(self) -> int:
|
||||||
|
"""Size of the background block ahead of the rows (0 on v6/v10)."""
|
||||||
|
return self.data_offset - self.bg_offset
|
||||||
|
|
||||||
|
|
||||||
def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
|
def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
|
||||||
sample_rate: float, preambles: list[str],
|
sample_rate: float, preambles: list[str],
|
||||||
background_waveform: bytes) -> BinaryIO:
|
version: int = VERSION) -> BinaryIO:
|
||||||
"""Create a new .sras file and write the v6 header + tables.
|
"""Create a new .sras file and write the header + tables.
|
||||||
|
|
||||||
Returns an open binary file positioned at the start of the data block;
|
``version`` selects which kind of file this is — VERSION for a full scan,
|
||||||
the caller appends waveform rows and must close it (try/finally).
|
VERSION_SAW_CHECK for a middle-row quality check. The layout is the same
|
||||||
|
either way; the one-row-per-angle rule that gives v11 its meaning is
|
||||||
|
checked here, since nothing downstream can recover from a v11 file that
|
||||||
|
breaks it.
|
||||||
|
|
||||||
|
Returns an open binary file positioned at the start of the data block.
|
||||||
|
The caller writes each angle as ``write_background_block()`` followed by
|
||||||
|
that angle's rows, and must close the file (try/finally).
|
||||||
"""
|
"""
|
||||||
|
if version not in WRITABLE_VERSIONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot write SRAS format version {version} "
|
||||||
|
f"(writable: {', '.join(str(v) for v in WRITABLE_VERSIONS)})"
|
||||||
|
)
|
||||||
|
if version == VERSION_SAW_CHECK:
|
||||||
|
bad = [f"{pa.angle_deg:.1f}° has {pa.n_rows}"
|
||||||
|
for pa in plan.per_angle if pa.n_rows != 1]
|
||||||
|
if bad:
|
||||||
|
raise ValueError(
|
||||||
|
"A v11 SAW-check file holds exactly one row per angle, but "
|
||||||
|
+ ", ".join(bad) + " — build the plan with "
|
||||||
|
"core.saw_check.middle_row_plan()."
|
||||||
|
)
|
||||||
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
f = open(path, "wb")
|
f = open(path, "wb")
|
||||||
f.write(struct.pack(
|
f.write(struct.pack(
|
||||||
HDR_FMT, MAGIC, VERSION,
|
HDR_FMT, MAGIC, version,
|
||||||
plan.n_angles,
|
plan.n_angles,
|
||||||
plan.x_start_nominal, plan.y_start_nominal,
|
plan.x_start_nominal, plan.y_start_nominal,
|
||||||
plan.x_delta_nominal, plan.y_delta_nominal,
|
plan.x_delta_nominal, plan.y_delta_nominal,
|
||||||
@@ -109,26 +167,41 @@ def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
|
|||||||
enc = p.encode("utf-8")
|
enc = p.encode("utf-8")
|
||||||
f.write(struct.pack(">H", len(enc)))
|
f.write(struct.pack(">H", len(enc)))
|
||||||
f.write(enc)
|
f.write(enc)
|
||||||
f.write(struct.pack(">I", len(background_waveform)))
|
|
||||||
f.write(background_waveform)
|
|
||||||
return f
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def write_background_block(f: BinaryIO, waveform: bytes) -> int:
|
||||||
|
"""Write one angle's background block; returns the bytes written.
|
||||||
|
|
||||||
|
Every angle's rows are preceded by one of these, so a reader walking the
|
||||||
|
data block knows where that angle's frames start.
|
||||||
|
"""
|
||||||
|
f.write(struct.pack(BG_LEN_FMT, len(waveform)))
|
||||||
|
f.write(waveform)
|
||||||
|
return BG_LEN_SIZE + len(waveform)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SrasFile:
|
class SrasFile:
|
||||||
"""Parsed v6 .sras file: header, tables, and lazy (memmap) data access.
|
"""Parsed .sras file (v7/v11, or legacy v6/v10): header, tables, and lazy
|
||||||
|
(memmap) access.
|
||||||
|
|
||||||
Parsing reads only the header/tables — never the waveform block — so
|
Parsing reads only the header/tables and the per-angle background blocks
|
||||||
opening a multi-GB file is cheap. ``load_angle``/``load_row`` return
|
— never the waveform block — so opening a multi-GB file is cheap.
|
||||||
read-only numpy views backed by a shared mmap; no data is copied until
|
``load_angle``/``load_row`` return read-only numpy views backed by a
|
||||||
the caller computes on it.
|
shared mmap; no data is copied until the caller computes on it.
|
||||||
|
|
||||||
|
``backgrounds[i]`` is angle *i*'s own background (v7/v11) or the file's
|
||||||
|
single background repeated for every angle (v6/v10), so a reader never
|
||||||
|
has to branch on the version to subtract the right one.
|
||||||
"""
|
"""
|
||||||
path: Path
|
path: Path
|
||||||
|
version: int = field(init=False)
|
||||||
header: ScanHeader = field(init=False)
|
header: ScanHeader = field(init=False)
|
||||||
per_angle: list[AngleGeometry] = field(init=False)
|
per_angle: list[AngleGeometry] = field(init=False)
|
||||||
preambles: list[str] = field(init=False)
|
preambles: list[str] = field(init=False)
|
||||||
preambles_raw: list[bytes] = field(init=False)
|
preambles_raw: list[bytes] = field(init=False)
|
||||||
background: bytes = field(init=False)
|
backgrounds: list[bytes] = field(init=False)
|
||||||
data_start_offset: int = field(init=False)
|
data_start_offset: int = field(init=False)
|
||||||
file_size: int = field(init=False)
|
file_size: int = field(init=False)
|
||||||
|
|
||||||
@@ -149,11 +222,12 @@ class SrasFile:
|
|||||||
n_channels) = struct.unpack(HDR_FMT, raw)
|
n_channels) = struct.unpack(HDR_FMT, raw)
|
||||||
if magic != MAGIC:
|
if magic != MAGIC:
|
||||||
raise ValueError(f"{self.path.name}: not a valid SRAS file (bad magic)")
|
raise ValueError(f"{self.path.name}: not a valid SRAS file (bad magic)")
|
||||||
if version != VERSION:
|
if version not in SUPPORTED_VERSIONS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{self.path.name}: unsupported SRAS format version {version} "
|
f"{self.path.name}: unsupported SRAS format version {version} "
|
||||||
f"(only version {VERSION} is supported)"
|
f"(supported: {', '.join(str(v) for v in SUPPORTED_VERSIONS)})"
|
||||||
)
|
)
|
||||||
|
self.version = version
|
||||||
self.header = ScanHeader(
|
self.header = ScanHeader(
|
||||||
n_angles=n_angles,
|
n_angles=n_angles,
|
||||||
x_start_nominal=x_start_nominal, y_start_nominal=y_start_nominal,
|
x_start_nominal=x_start_nominal, y_start_nominal=y_start_nominal,
|
||||||
@@ -182,10 +256,65 @@ class SrasFile:
|
|||||||
self.preambles_raw.append(f.read(plen))
|
self.preambles_raw.append(f.read(plen))
|
||||||
self.preambles = [p.decode("utf-8", errors="replace") for p in self.preambles_raw]
|
self.preambles = [p.decode("utf-8", errors="replace") for p in self.preambles_raw]
|
||||||
|
|
||||||
(n_bg,) = struct.unpack(">I", f.read(4))
|
shared_bg = None
|
||||||
self.background = f.read(n_bg)
|
if self.is_legacy_layout:
|
||||||
|
# v6/v10: one background for the whole file, ahead of the data.
|
||||||
|
(n_bg,) = struct.unpack(BG_LEN_FMT, f.read(BG_LEN_SIZE))
|
||||||
|
shared_bg = f.read(n_bg)
|
||||||
|
|
||||||
self.data_start_offset = f.tell()
|
self.data_start_offset = f.tell()
|
||||||
|
self._walk_data_block(f, shared_bg)
|
||||||
|
|
||||||
|
def _walk_data_block(self, f, shared_bg: bytes | None):
|
||||||
|
"""Locate every angle's background block and the rows behind it.
|
||||||
|
|
||||||
|
v7 interleaves a length-prefixed background ahead of each angle's
|
||||||
|
rows, so the offsets are no longer pure arithmetic over the geometry
|
||||||
|
table — the walk reads each prefix as it goes. Past the frontier of
|
||||||
|
a partial file there is nothing to read, so the remaining offsets are
|
||||||
|
predicted from the block a writer would have produced (a full record),
|
||||||
|
which is exactly where a resumed scan writes.
|
||||||
|
"""
|
||||||
|
expected_bg = BG_LEN_SIZE + self.header.samples_per_frame
|
||||||
|
self.backgrounds, self._bg_present = [], []
|
||||||
|
self._bg_offsets, self._data_offsets = [], []
|
||||||
|
cursor = self.data_start_offset
|
||||||
|
for ai, pa in enumerate(self.per_angle):
|
||||||
|
if shared_bg is not None:
|
||||||
|
bg, bg_bytes, present = shared_bg, 0, True
|
||||||
|
else:
|
||||||
|
bg, bg_bytes, present = self._read_background(f, cursor, expected_bg)
|
||||||
|
self.backgrounds.append(bg)
|
||||||
|
self._bg_present.append(present)
|
||||||
|
self._bg_offsets.append(cursor)
|
||||||
|
cursor += bg_bytes
|
||||||
|
self._data_offsets.append(cursor)
|
||||||
|
cursor += self.row_bytes(ai) * pa.n_rows
|
||||||
|
|
||||||
|
def _read_background(self, f, offset: int, expected_bytes: int):
|
||||||
|
"""One angle's background block as (waveform, block_bytes, present).
|
||||||
|
|
||||||
|
A block that runs past the end of the file was never written: the
|
||||||
|
walk keeps going with the size a writer would have used, and the
|
||||||
|
angle is reported MISSING.
|
||||||
|
"""
|
||||||
|
if offset + BG_LEN_SIZE > self.file_size:
|
||||||
|
return b"", expected_bytes, False
|
||||||
|
f.seek(offset)
|
||||||
|
(n_bg,) = struct.unpack(BG_LEN_FMT, f.read(BG_LEN_SIZE))
|
||||||
|
if offset + BG_LEN_SIZE + n_bg > self.file_size:
|
||||||
|
return b"", expected_bytes, False
|
||||||
|
return f.read(n_bg), BG_LEN_SIZE + n_bg, True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_saw_check(self) -> bool:
|
||||||
|
"""True for a middle-row SAW quality check rather than a scan."""
|
||||||
|
return self.version in SAW_CHECK_VERSIONS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_legacy_layout(self) -> bool:
|
||||||
|
"""True for v6/v10: one background for the file, not one per angle."""
|
||||||
|
return self.version in LEGACY_VERSIONS
|
||||||
|
|
||||||
# ── Frontier / truncation analysis ───────────────────────────────────────
|
# ── Frontier / truncation analysis ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -199,40 +328,48 @@ class SrasFile:
|
|||||||
|
|
||||||
Because the data is one contiguous append-only stream, once an angle
|
Because the data is one contiguous append-only stream, once an angle
|
||||||
is found short every later angle is necessarily absent too — there is
|
is found short every later angle is necessarily absent too — there is
|
||||||
a single frontier past which nothing has been written yet.
|
a single frontier past which nothing has been written yet. An angle
|
||||||
|
whose background block never made it to disk is short by definition,
|
||||||
|
even though no row of it was due yet.
|
||||||
"""
|
"""
|
||||||
statuses = []
|
statuses = []
|
||||||
cursor = self.data_start_offset
|
|
||||||
frontier_seen = False
|
frontier_seen = False
|
||||||
for ai, pa in enumerate(self.per_angle):
|
for ai, pa in enumerate(self.per_angle):
|
||||||
row_bytes = self.row_bytes(ai)
|
row_bytes = self.row_bytes(ai)
|
||||||
data_offset = cursor
|
data_offset = self._data_offsets[ai]
|
||||||
if frontier_seen:
|
if frontier_seen or not self._bg_present[ai]:
|
||||||
n_rows_available = 0
|
n_rows_available = 0
|
||||||
status = STATUS_MISSING
|
status = STATUS_MISSING
|
||||||
|
frontier_seen = True
|
||||||
else:
|
else:
|
||||||
declared_bytes = row_bytes * pa.n_rows
|
declared_bytes = row_bytes * pa.n_rows
|
||||||
if row_bytes > 0 and cursor + declared_bytes <= self.file_size:
|
if row_bytes > 0 and data_offset + declared_bytes <= self.file_size:
|
||||||
n_rows_available = pa.n_rows
|
n_rows_available = pa.n_rows
|
||||||
status = STATUS_OK
|
status = STATUS_OK
|
||||||
cursor += declared_bytes
|
|
||||||
else:
|
else:
|
||||||
remaining = max(0, self.file_size - cursor)
|
remaining = max(0, self.file_size - data_offset)
|
||||||
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
|
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
|
||||||
status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED
|
status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED
|
||||||
frontier_seen = True
|
frontier_seen = True
|
||||||
statuses.append(AngleStatus(
|
statuses.append(AngleStatus(
|
||||||
index=ai, angle_deg=pa.angle_deg, n_rows=pa.n_rows,
|
index=ai, angle_deg=pa.angle_deg, n_rows=pa.n_rows,
|
||||||
row_bytes=row_bytes, data_offset=data_offset,
|
row_bytes=row_bytes, bg_offset=self._bg_offsets[ai],
|
||||||
|
data_offset=data_offset,
|
||||||
n_rows_available=n_rows_available, status=status,
|
n_rows_available=n_rows_available, status=status,
|
||||||
))
|
))
|
||||||
return statuses
|
return statuses
|
||||||
|
|
||||||
def angle_data_offset(self, angle_idx: int) -> int:
|
def angle_data_offset(self, angle_idx: int) -> int:
|
||||||
offset = self.data_start_offset
|
"""Where angle ``angle_idx``'s rows start (past its background)."""
|
||||||
for ai in range(angle_idx):
|
return self._data_offsets[angle_idx]
|
||||||
offset += self.row_bytes(ai) * self.per_angle[ai].n_rows
|
|
||||||
return offset
|
def angle_block_offset(self, angle_idx: int) -> int:
|
||||||
|
"""Where angle ``angle_idx``'s block starts, background included.
|
||||||
|
|
||||||
|
Equal to ``angle_data_offset`` on v6/v10, which have no per-angle
|
||||||
|
background block.
|
||||||
|
"""
|
||||||
|
return self._bg_offsets[angle_idx]
|
||||||
|
|
||||||
# ── Lazy data access ─────────────────────────────────────────────────────
|
# ── Lazy data access ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -248,6 +385,11 @@ class SrasFile:
|
|||||||
def _dtype(self) -> np.dtype:
|
def _dtype(self) -> np.dtype:
|
||||||
return np.dtype(np.int16 if self.header.bytes_per_sample == 2 else np.int8)
|
return np.dtype(np.int16 if self.header.bytes_per_sample == 2 else np.int8)
|
||||||
|
|
||||||
|
def background_array(self, angle_idx: int) -> np.ndarray | None:
|
||||||
|
"""One angle's background as float32 ADC counts, or None if absent."""
|
||||||
|
bg = np.frombuffer(self.backgrounds[angle_idx], dtype=self._dtype())
|
||||||
|
return bg.astype(np.float32) if bg.size else None
|
||||||
|
|
||||||
def load_angle(self, angle_idx: int, n_rows: int | None = None) -> np.ndarray:
|
def load_angle(self, angle_idx: int, n_rows: int | None = None) -> np.ndarray:
|
||||||
"""Read-only view of one angle's data block, shape
|
"""Read-only view of one angle's data block, shape
|
||||||
(n_rows, n_channels, n_frames, samples_per_frame).
|
(n_rows, n_channels, n_frames, samples_per_frame).
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Qt bridge over the headless AutoAligner.
|
||||||
|
|
||||||
|
The procedure is two long blocking runs with an operator decision between
|
||||||
|
them — ``prepare()`` puts the rig in a known state and reads the reference
|
||||||
|
levels, the operator confirms the camera image, then ``run()`` spends a minute
|
||||||
|
or two moving the stage and the tilt platform. Both belong on a worker
|
||||||
|
thread; the window only enqueues and reacts to signals.
|
||||||
|
|
||||||
|
Stopping cannot go through the command queue: while ``run()`` is executing,
|
||||||
|
the worker is inside a handler and will not look at the queue until it
|
||||||
|
returns. The stop request is therefore a threading.Event the core polls
|
||||||
|
between moves (``should_abort``), and the queued "stop" command only handles
|
||||||
|
the tidy-up afterwards.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from PyQt6.QtCore import pyqtSignal
|
||||||
|
|
||||||
|
from core.auto_align import AlignCallbacks, AutoAligner, AutoAlignAborted
|
||||||
|
from gui.qt_workers import QueueWorker
|
||||||
|
|
||||||
|
|
||||||
|
class QtAutoAligner(QueueWorker):
|
||||||
|
"""Runs an AutoAligner on its own QThread and republishes its events."""
|
||||||
|
|
||||||
|
prepared = pyqtSignal(object) # Reading — the candidate reference
|
||||||
|
prepare_failed = pyqtSignal(str)
|
||||||
|
status_msg = pyqtSignal(str)
|
||||||
|
reading_taken = pyqtSignal(object) # Reading
|
||||||
|
busy_changed = pyqtSignal(bool)
|
||||||
|
offset_done = pyqtSignal(object) # OffsetResult
|
||||||
|
axis_done = pyqtSignal(object) # AxisResult
|
||||||
|
finished = pyqtSignal(object) # AlignResult
|
||||||
|
failed = pyqtSignal(str)
|
||||||
|
aborted = pyqtSignal()
|
||||||
|
stopped = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, stage, scope, t3r, settings=None, on_align_active=None):
|
||||||
|
super().__init__()
|
||||||
|
self._on_align_active = on_align_active
|
||||||
|
self._abort = threading.Event()
|
||||||
|
|
||||||
|
callbacks = AlignCallbacks(
|
||||||
|
on_status=self.status_msg.emit,
|
||||||
|
on_reading=self.reading_taken.emit,
|
||||||
|
on_busy=self.busy_changed.emit,
|
||||||
|
on_offset_done=self.offset_done.emit,
|
||||||
|
on_axis_done=self.axis_done.emit,
|
||||||
|
)
|
||||||
|
kwargs = {"settings": settings} if settings is not None else {}
|
||||||
|
self._aligner = AutoAligner(stage, scope, t3r, callbacks=callbacks,
|
||||||
|
should_abort=self._abort.is_set, **kwargs)
|
||||||
|
self._handlers = {
|
||||||
|
"prepare": self._do_prepare,
|
||||||
|
"run": self._do_run,
|
||||||
|
"stop": self._do_stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Command submission (GUI thread) ───────────────────────────────────────
|
||||||
|
|
||||||
|
def request_prepare(self):
|
||||||
|
self._abort.clear()
|
||||||
|
self._enqueue("prepare")
|
||||||
|
|
||||||
|
def request_run(self):
|
||||||
|
self._enqueue("run")
|
||||||
|
|
||||||
|
def request_abort(self):
|
||||||
|
"""Stop the procedure at the next move, wherever it has got to."""
|
||||||
|
self._abort.set()
|
||||||
|
|
||||||
|
def request_stop(self):
|
||||||
|
self._abort.set()
|
||||||
|
self._enqueue("stop")
|
||||||
|
|
||||||
|
# ── Handlers (worker thread) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _do_prepare(self):
|
||||||
|
if self._on_align_active is not None:
|
||||||
|
self._on_align_active(True)
|
||||||
|
try:
|
||||||
|
reading = self._aligner.prepare()
|
||||||
|
except Exception as exc:
|
||||||
|
traceback.print_exc()
|
||||||
|
if self._on_align_active is not None:
|
||||||
|
self._on_align_active(False)
|
||||||
|
self.prepare_failed.emit(str(exc))
|
||||||
|
return
|
||||||
|
self.prepared.emit(reading)
|
||||||
|
|
||||||
|
def _do_run(self):
|
||||||
|
try:
|
||||||
|
result = self._aligner.run()
|
||||||
|
except AutoAlignAborted:
|
||||||
|
self.status_msg.emit("Auto-align stopped.")
|
||||||
|
self.aborted.emit()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
traceback.print_exc()
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
return
|
||||||
|
self.finished.emit(result)
|
||||||
|
|
||||||
|
def _do_stop(self):
|
||||||
|
try:
|
||||||
|
self._aligner.stop()
|
||||||
|
finally:
|
||||||
|
if self._on_align_active is not None:
|
||||||
|
self._on_align_active(False)
|
||||||
|
self.stopped.emit()
|
||||||
|
|
||||||
|
def _on_stop(self):
|
||||||
|
"""Worker loop exiting — leave the rig parked even if the window went
|
||||||
|
away without a clean stop command reaching the queue."""
|
||||||
|
try:
|
||||||
|
self._aligner.stop()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
if self._on_align_active is not None:
|
||||||
|
self._on_align_active(False)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Qt bridge over the headless AngleInspector.
|
||||||
|
|
||||||
|
Inspection is command-driven rather than one long run: the operator clicks an
|
||||||
|
angle, waits for the stage to park, looks at the scope, clicks again. That is
|
||||||
|
exactly the shape QueueWorker exists for — it blocks on the queue between
|
||||||
|
commands instead of polling, so an inspection window left open costs nothing.
|
||||||
|
|
||||||
|
Every stage move and rotation blocks for seconds, so all of it runs on this
|
||||||
|
worker's thread; the window only ever enqueues and reacts to signals.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from PyQt6.QtCore import pyqtSignal
|
||||||
|
|
||||||
|
from core.angle_inspect import AngleInspector, InspectCallbacks
|
||||||
|
from gui.qt_workers import QueueWorker
|
||||||
|
|
||||||
|
|
||||||
|
class QtAngleInspector(QueueWorker):
|
||||||
|
"""Runs an AngleInspector on its own QThread and republishes its events."""
|
||||||
|
|
||||||
|
ready = pyqtSignal(object) # InspectionPoint — start() succeeded
|
||||||
|
start_failed = pyqtSignal(str)
|
||||||
|
point_changed = pyqtSignal(object) # InspectionPoint
|
||||||
|
status_msg = pyqtSignal(str)
|
||||||
|
busy_changed = pyqtSignal(bool) # True while a move is in flight
|
||||||
|
stopped = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, stage, scope, rotator, plan, on_inspect_active=None):
|
||||||
|
super().__init__()
|
||||||
|
self._on_inspect_active = on_inspect_active
|
||||||
|
|
||||||
|
callbacks = InspectCallbacks(
|
||||||
|
on_status=self.status_msg.emit,
|
||||||
|
on_point=self.point_changed.emit,
|
||||||
|
on_busy=self.busy_changed.emit,
|
||||||
|
)
|
||||||
|
self._inspector = AngleInspector(stage, scope, rotator, plan,
|
||||||
|
callbacks=callbacks)
|
||||||
|
self._handlers = {
|
||||||
|
"start": self._do_start,
|
||||||
|
"goto": self._do_goto,
|
||||||
|
"new_point": self._do_new_point,
|
||||||
|
"stop": self._do_stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Introspection (safe from the GUI thread: reads the plan, not the rig) ──
|
||||||
|
|
||||||
|
def angle_labels(self) -> list[str]:
|
||||||
|
return self._inspector.angle_labels()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def n_angles(self) -> int:
|
||||||
|
return self._inspector.n_angles
|
||||||
|
|
||||||
|
# ── Command submission (GUI thread) ───────────────────────────────────────
|
||||||
|
|
||||||
|
def request_start(self):
|
||||||
|
self._enqueue("start")
|
||||||
|
|
||||||
|
def request_goto(self, angle_idx: int):
|
||||||
|
self._enqueue("goto", angle_idx=angle_idx)
|
||||||
|
|
||||||
|
def request_new_point(self):
|
||||||
|
self._enqueue("new_point")
|
||||||
|
|
||||||
|
def request_stop(self):
|
||||||
|
self._enqueue("stop")
|
||||||
|
|
||||||
|
# ── Handlers (worker thread) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _do_start(self):
|
||||||
|
if self._on_inspect_active is not None:
|
||||||
|
self._on_inspect_active(True)
|
||||||
|
try:
|
||||||
|
point = self._inspector.start()
|
||||||
|
except Exception as exc:
|
||||||
|
traceback.print_exc()
|
||||||
|
if self._on_inspect_active is not None:
|
||||||
|
self._on_inspect_active(False)
|
||||||
|
self.start_failed.emit(str(exc))
|
||||||
|
return
|
||||||
|
self.ready.emit(point)
|
||||||
|
|
||||||
|
def _do_goto(self, angle_idx: int):
|
||||||
|
self._inspector.goto_angle(angle_idx)
|
||||||
|
|
||||||
|
def _do_new_point(self):
|
||||||
|
self._inspector.new_point()
|
||||||
|
|
||||||
|
def _do_stop(self):
|
||||||
|
try:
|
||||||
|
self._inspector.stop()
|
||||||
|
finally:
|
||||||
|
if self._on_inspect_active is not None:
|
||||||
|
self._on_inspect_active(False)
|
||||||
|
self.stopped.emit()
|
||||||
|
|
||||||
|
def _on_stop(self):
|
||||||
|
"""Worker loop exiting — make sure the rig is left in a safe state.
|
||||||
|
|
||||||
|
Covers the case where the window is closed without a clean stop
|
||||||
|
command reaching the queue.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._inspector.stop()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
if self._on_inspect_active is not None:
|
||||||
|
self._on_inspect_active(False)
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
"""Jog controls for the T3R axes and the BBD202 stage.
|
||||||
|
|
||||||
|
These sit beside the camera image. Focusing the T-axis and framing the
|
||||||
|
sample on the XY stage are both done by eye, so the controls have to be
|
||||||
|
reachable without looking away from the video. Both panels drive the same
|
||||||
|
driver/worker the main window uses, so a jog here is the same command as a
|
||||||
|
jog there.
|
||||||
|
|
||||||
|
The T3R panel jogs while the button is held (the controller's JOG command is
|
||||||
|
a continuous velocity move, ended by STOP). The BBD202 has no such command,
|
||||||
|
so a held button repeats a short relative move, the way the main window
|
||||||
|
already does it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QCheckBox, QComboBox, QDoubleSpinBox, QFrame, QGridLayout, QGroupBox,
|
||||||
|
QLabel, QPushButton, QSpinBox,
|
||||||
|
)
|
||||||
|
|
||||||
|
import hardware.t3r_protocol as proto
|
||||||
|
from gui.widgets import mono_font
|
||||||
|
from hardware.t3r_driver import T3RDriver
|
||||||
|
|
||||||
|
# BBD202 jog defaults, shared with the main window's worker.
|
||||||
|
BBD_JOG_STEP_MM = 0.5 # default jog step
|
||||||
|
BBD_JOG_SPEED_MM_S = 10.0
|
||||||
|
BBD_JOG_ACCEL_MM_S2 = 50.0
|
||||||
|
|
||||||
|
# T3R jog defaults, matching the T3R control panel's own spin boxes.
|
||||||
|
T3R_JOG_VELOCITY = 8000 # steps/s
|
||||||
|
T3R_JOG_ACCEL = 4000 # steps/s²
|
||||||
|
T3R_MICROSTEPS = 16 # shown until the device reports its own
|
||||||
|
|
||||||
|
# A held BBD button repeats a relative move at this interval; the move itself
|
||||||
|
# is short, so a faster repeat would just queue up moves the stage can't
|
||||||
|
# finish (the main window uses the same 200 ms).
|
||||||
|
BBD_JOG_REPEAT_MS = 200
|
||||||
|
|
||||||
|
# Applying velocity on every spin-box click would flood the command queue, so
|
||||||
|
# the write is debounced until the operator stops adjusting.
|
||||||
|
BBD_VELOCITY_DEBOUNCE_MS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _hline() -> QFrame:
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _jog_button(text: str, tip: str) -> QPushButton:
|
||||||
|
btn = QPushButton(text)
|
||||||
|
btn.setToolTip(tip)
|
||||||
|
btn.setFixedWidth(38)
|
||||||
|
btn.setAutoRepeat(False) # the repeat is ours, on a timer
|
||||||
|
return btn
|
||||||
|
|
||||||
|
|
||||||
|
# ── T3R ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class T3RJogPanel(QGroupBox):
|
||||||
|
"""Enable, microstep and jog controls for the four T3R axes.
|
||||||
|
|
||||||
|
Jog velocity and acceleration are shared by every axis; microstepping is
|
||||||
|
per-channel, because that is how the controller stores it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, driver, parent=None):
|
||||||
|
super().__init__("T3R Axes", parent)
|
||||||
|
self._driver = driver
|
||||||
|
self._jogging: set[int] = set()
|
||||||
|
# Microsteps the operator picked but the device hasn't confirmed yet.
|
||||||
|
# Without this, an info poll already in flight carrying the old value
|
||||||
|
# would snap the combo back and look like the click was ignored.
|
||||||
|
self._pending_micro: dict[int, int] = {}
|
||||||
|
self._enable_chks: dict[int, QCheckBox] = {}
|
||||||
|
self._micro_combos: dict[int, QComboBox] = {}
|
||||||
|
self._pos_lbls: dict[int, QLabel] = {}
|
||||||
|
self._jog_btns: dict[tuple[int, int], QPushButton] = {}
|
||||||
|
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
driver.handshake_ok.connect(lambda *_: self._set_online(True))
|
||||||
|
driver.disconnected.connect(lambda *_: self._set_online(False))
|
||||||
|
driver.info_updated.connect(self._on_info)
|
||||||
|
self._set_online(driver.is_open)
|
||||||
|
|
||||||
|
# ── Construction ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
grid = QGridLayout(self)
|
||||||
|
grid.setVerticalSpacing(4)
|
||||||
|
grid.setHorizontalSpacing(6)
|
||||||
|
row = 0
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Velocity"), row, 0)
|
||||||
|
self.vel_spin = QSpinBox()
|
||||||
|
self.vel_spin.setRange(1, proto.MAX_VELOCITY)
|
||||||
|
self.vel_spin.setValue(T3R_JOG_VELOCITY)
|
||||||
|
self.vel_spin.setGroupSeparatorShown(True)
|
||||||
|
self.vel_spin.setSuffix(" steps/s")
|
||||||
|
grid.addWidget(self.vel_spin, row, 1, 1, 3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Accel"), row, 0)
|
||||||
|
self.accel_spin = QSpinBox()
|
||||||
|
self.accel_spin.setRange(0, proto.MAX_ACCEL)
|
||||||
|
self.accel_spin.setValue(T3R_JOG_ACCEL)
|
||||||
|
self.accel_spin.setGroupSeparatorShown(True)
|
||||||
|
self.accel_spin.setSuffix(" steps/s²")
|
||||||
|
grid.addWidget(self.accel_spin, row, 1, 1, 3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(_hline(), row, 0, 1, 5)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
hdr = QLabel("hold a jog button to move")
|
||||||
|
hdr.setStyleSheet("color: gray;")
|
||||||
|
grid.addWidget(hdr, row, 0, 1, 3)
|
||||||
|
grid.addWidget(QLabel("µsteps"), row, 3)
|
||||||
|
grid.addWidget(QLabel("pos"), row, 4)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
for ch, name in enumerate(T3RDriver.CHANNEL_NAMES):
|
||||||
|
chk = QCheckBox(name)
|
||||||
|
chk.setToolTip(f"Energise axis {ch} — a disabled axis ignores jogs")
|
||||||
|
chk.toggled.connect(lambda on, c=ch: self._on_enable_toggled(c, on))
|
||||||
|
grid.addWidget(chk, row, 0)
|
||||||
|
self._enable_chks[ch] = chk
|
||||||
|
|
||||||
|
for col, (text, direction, way) in enumerate(
|
||||||
|
(("◀", -1, "negative"), ("▶", +1, "positive")), start=1):
|
||||||
|
btn = _jog_button(text, f"Jog {way} while held")
|
||||||
|
btn.pressed.connect(
|
||||||
|
lambda c=ch, d=direction: self._start_jog(c, d))
|
||||||
|
btn.released.connect(lambda c=ch: self._stop_jog(c))
|
||||||
|
grid.addWidget(btn, row, col)
|
||||||
|
self._jog_btns[(ch, direction)] = btn
|
||||||
|
|
||||||
|
combo = QComboBox()
|
||||||
|
for m in proto.MICROSTEPS:
|
||||||
|
combo.addItem(str(m), m)
|
||||||
|
combo.setCurrentIndex(combo.findData(T3R_MICROSTEPS))
|
||||||
|
combo.setToolTip("SET_MICROSTEP — applied immediately, axis must be idle")
|
||||||
|
combo.activated.connect(lambda _i, c=ch: self._on_micro_selected(c))
|
||||||
|
grid.addWidget(combo, row, 3)
|
||||||
|
self._micro_combos[ch] = combo
|
||||||
|
|
||||||
|
pos_lbl = QLabel("—")
|
||||||
|
pos_lbl.setFont(mono_font(11))
|
||||||
|
pos_lbl.setMinimumWidth(76)
|
||||||
|
pos_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
grid.addWidget(pos_lbl, row, 4)
|
||||||
|
self._pos_lbls[ch] = pos_lbl
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Recovery for a jog whose button-release never arrived (window hidden
|
||||||
|
# or focus stolen mid-press). It only stops axes this panel started,
|
||||||
|
# so it can never cut a scan's rotation short.
|
||||||
|
self.stop_btn = QPushButton("Stop jogging")
|
||||||
|
self.stop_btn.clicked.connect(self.stop_jogs)
|
||||||
|
grid.addWidget(self.stop_btn, row, 0, 1, 5)
|
||||||
|
|
||||||
|
grid.setColumnStretch(4, 1)
|
||||||
|
|
||||||
|
# ── Commands ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_enable_toggled(self, ch: int, on: bool):
|
||||||
|
if on:
|
||||||
|
self._driver.enable(ch)
|
||||||
|
else:
|
||||||
|
self._driver.disable(ch)
|
||||||
|
|
||||||
|
def _on_micro_selected(self, ch: int):
|
||||||
|
micro = self._micro_combos[ch].currentData()
|
||||||
|
self._pending_micro[ch] = micro
|
||||||
|
self._driver.set_microstep(ch, micro)
|
||||||
|
|
||||||
|
def _start_jog(self, ch: int, direction: int):
|
||||||
|
self._jogging.add(ch)
|
||||||
|
self._driver.jog(ch, direction * self.vel_spin.value(),
|
||||||
|
self.accel_spin.value())
|
||||||
|
|
||||||
|
def _stop_jog(self, ch: int):
|
||||||
|
if ch in self._jogging:
|
||||||
|
self._jogging.discard(ch)
|
||||||
|
self._driver.stop(ch, False)
|
||||||
|
|
||||||
|
def stop_jogs(self):
|
||||||
|
"""Stop every axis this panel is jogging. Safe to call when idle."""
|
||||||
|
for ch in sorted(self._jogging):
|
||||||
|
self._driver.stop(ch, False)
|
||||||
|
self._jogging.clear()
|
||||||
|
|
||||||
|
# ── Device updates ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_info(self, ch: int, info):
|
||||||
|
lbl = self._pos_lbls.get(ch)
|
||||||
|
if lbl is None:
|
||||||
|
return
|
||||||
|
lbl.setText(f"{info.position}")
|
||||||
|
|
||||||
|
chk = self._enable_chks[ch]
|
||||||
|
if chk.isChecked() != info.enabled:
|
||||||
|
chk.blockSignals(True)
|
||||||
|
chk.setChecked(info.enabled)
|
||||||
|
chk.blockSignals(False)
|
||||||
|
|
||||||
|
pending = self._pending_micro.get(ch)
|
||||||
|
if pending is not None:
|
||||||
|
if info.microsteps != pending:
|
||||||
|
return # change still in flight
|
||||||
|
del self._pending_micro[ch]
|
||||||
|
|
||||||
|
combo = self._micro_combos[ch]
|
||||||
|
idx = combo.findData(info.microsteps)
|
||||||
|
if idx >= 0 and idx != combo.currentIndex():
|
||||||
|
combo.blockSignals(True)
|
||||||
|
combo.setCurrentIndex(idx)
|
||||||
|
combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _set_online(self, on: bool):
|
||||||
|
if not on:
|
||||||
|
self._jogging.clear()
|
||||||
|
self._pending_micro.clear()
|
||||||
|
for lbl in self._pos_lbls.values():
|
||||||
|
lbl.setText("—")
|
||||||
|
self.setEnabled(on)
|
||||||
|
|
||||||
|
|
||||||
|
# ── BBD202 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BBDJogPanel(QGroupBox):
|
||||||
|
"""X/Y jog controls for the BBD202 stage.
|
||||||
|
|
||||||
|
``worker`` is the main window's BBD202Worker; it is duck-typed here to
|
||||||
|
keep this module free of an import cycle with the app. The stage runs
|
||||||
|
closed-loop brushless servos, so there is no microstepping to set —
|
||||||
|
velocity and acceleration are the equivalent knobs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, worker, parent=None):
|
||||||
|
super().__init__("BBD202 XY Stage", parent)
|
||||||
|
self._worker = worker
|
||||||
|
self._active: tuple[str, int] | None = None
|
||||||
|
|
||||||
|
self._repeat = QTimer(self)
|
||||||
|
self._repeat.setInterval(BBD_JOG_REPEAT_MS)
|
||||||
|
self._repeat.timeout.connect(self._jog_tick)
|
||||||
|
|
||||||
|
self._vel_debounce = QTimer(self)
|
||||||
|
self._vel_debounce.setSingleShot(True)
|
||||||
|
self._vel_debounce.setInterval(BBD_VELOCITY_DEBOUNCE_MS)
|
||||||
|
self._vel_debounce.timeout.connect(self.apply_velocity)
|
||||||
|
|
||||||
|
self._build()
|
||||||
|
|
||||||
|
worker.connected.connect(self._on_connected)
|
||||||
|
worker.disconnected.connect(lambda: self._set_online(False))
|
||||||
|
worker.position_updated.connect(self._on_position)
|
||||||
|
self._set_online(worker.is_connected)
|
||||||
|
|
||||||
|
# ── Construction ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build(self):
|
||||||
|
grid = QGridLayout(self)
|
||||||
|
grid.setVerticalSpacing(4)
|
||||||
|
grid.setHorizontalSpacing(6)
|
||||||
|
row = 0
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("X"), row, 0)
|
||||||
|
self.x_pos_lbl = QLabel("---.---")
|
||||||
|
self.x_pos_lbl.setFont(mono_font(11))
|
||||||
|
grid.addWidget(self.x_pos_lbl, row, 1)
|
||||||
|
grid.addWidget(QLabel("Y"), row, 2)
|
||||||
|
self.y_pos_lbl = QLabel("---.---")
|
||||||
|
self.y_pos_lbl.setFont(mono_font(11))
|
||||||
|
grid.addWidget(self.y_pos_lbl, row, 3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(_hline(), row, 0, 1, 4)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Jog pad: Y over X, laid out the way the stage moves.
|
||||||
|
self.y_pos_btn = _jog_button("▲", "Jog +Y while held")
|
||||||
|
grid.addWidget(self.y_pos_btn, row, 1, 1, 2, Qt.AlignmentFlag.AlignHCenter)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
self.x_neg_btn = _jog_button("◀", "Jog −X while held")
|
||||||
|
grid.addWidget(self.x_neg_btn, row, 1, Qt.AlignmentFlag.AlignRight)
|
||||||
|
self.x_pos_btn = _jog_button("▶", "Jog +X while held")
|
||||||
|
grid.addWidget(self.x_pos_btn, row, 2, Qt.AlignmentFlag.AlignLeft)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
self.y_neg_btn = _jog_button("▼", "Jog −Y while held")
|
||||||
|
grid.addWidget(self.y_neg_btn, row, 1, 1, 2, Qt.AlignmentFlag.AlignHCenter)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
for btn, axis, direction in (
|
||||||
|
(self.x_pos_btn, "x", +1), (self.x_neg_btn, "x", -1),
|
||||||
|
(self.y_pos_btn, "y", +1), (self.y_neg_btn, "y", -1),
|
||||||
|
):
|
||||||
|
btn.pressed.connect(lambda a=axis, d=direction: self._start_jog(a, d))
|
||||||
|
btn.released.connect(self.stop_jogs)
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Step"), row, 0)
|
||||||
|
self.step_spin = QDoubleSpinBox()
|
||||||
|
self.step_spin.setRange(0.001, 25.0)
|
||||||
|
self.step_spin.setDecimals(3)
|
||||||
|
self.step_spin.setSingleStep(0.1)
|
||||||
|
self.step_spin.setValue(BBD_JOG_STEP_MM)
|
||||||
|
self.step_spin.setSuffix(" mm")
|
||||||
|
grid.addWidget(self.step_spin, row, 1, 1, 3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Velocity"), row, 0)
|
||||||
|
self.vel_spin = QDoubleSpinBox()
|
||||||
|
self.vel_spin.setRange(0.1, 100.0)
|
||||||
|
self.vel_spin.setDecimals(1)
|
||||||
|
self.vel_spin.setValue(BBD_JOG_SPEED_MM_S)
|
||||||
|
self.vel_spin.setSuffix(" mm/s")
|
||||||
|
self.vel_spin.valueChanged.connect(lambda _v: self._vel_debounce.start())
|
||||||
|
grid.addWidget(self.vel_spin, row, 1, 1, 3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
grid.addWidget(QLabel("Accel"), row, 0)
|
||||||
|
self.accel_spin = QDoubleSpinBox()
|
||||||
|
self.accel_spin.setRange(0.1, 500.0)
|
||||||
|
self.accel_spin.setDecimals(1)
|
||||||
|
self.accel_spin.setValue(BBD_JOG_ACCEL_MM_S2)
|
||||||
|
self.accel_spin.setSuffix(" mm/s²")
|
||||||
|
self.accel_spin.valueChanged.connect(lambda _v: self._vel_debounce.start())
|
||||||
|
grid.addWidget(self.accel_spin, row, 1, 1, 3)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
note = QLabel("Microstepping: n/a — closed-loop servo")
|
||||||
|
note.setStyleSheet("color: gray;")
|
||||||
|
note.setWordWrap(True)
|
||||||
|
grid.addWidget(note, row, 0, 1, 4)
|
||||||
|
|
||||||
|
grid.setColumnStretch(3, 1)
|
||||||
|
|
||||||
|
# ── Commands ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_jog(self, axis: str, direction: int):
|
||||||
|
self._active = (axis, direction)
|
||||||
|
self._jog_tick()
|
||||||
|
self._repeat.start()
|
||||||
|
|
||||||
|
def _jog_tick(self):
|
||||||
|
if self._active is None:
|
||||||
|
return
|
||||||
|
axis, direction = self._active
|
||||||
|
self._worker.queue_jog(axis, direction, step_mm=self.step_spin.value())
|
||||||
|
|
||||||
|
def stop_jogs(self):
|
||||||
|
"""Stop the repeat. A move already sent runs to its (short) end."""
|
||||||
|
self._repeat.stop()
|
||||||
|
self._active = None
|
||||||
|
|
||||||
|
def apply_velocity(self):
|
||||||
|
"""Push the panel's velocity/acceleration to both axes."""
|
||||||
|
if self._worker.is_connected:
|
||||||
|
self._worker.queue_set_velocity(self.vel_spin.value(),
|
||||||
|
self.accel_spin.value())
|
||||||
|
|
||||||
|
# ── Device updates ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_connected(self):
|
||||||
|
self._set_online(True)
|
||||||
|
self.apply_velocity()
|
||||||
|
|
||||||
|
def _on_position(self, x_mm: float, y_mm: float):
|
||||||
|
self.x_pos_lbl.setText(f"{x_mm:07.3f}")
|
||||||
|
self.y_pos_lbl.setText(f"{y_mm:07.3f}")
|
||||||
|
|
||||||
|
def _set_online(self, on: bool):
|
||||||
|
if not on:
|
||||||
|
self.stop_jogs()
|
||||||
|
self.x_pos_lbl.setText("---.---")
|
||||||
|
self.y_pos_lbl.setText("---.---")
|
||||||
|
self.setEnabled(on)
|
||||||
@@ -44,6 +44,18 @@ class QueueWorker(QObject):
|
|||||||
def stop_worker(self):
|
def stop_worker(self):
|
||||||
self._cmd_q.put(_STOP)
|
self._cmd_q.put(_STOP)
|
||||||
|
|
||||||
|
# ── Worker-side helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _work_pending(self) -> bool:
|
||||||
|
"""True if the operator is waiting on something.
|
||||||
|
|
||||||
|
A poll is one queue item that can hold the port for hundreds of
|
||||||
|
milliseconds; a button pressed during one should not have to wait
|
||||||
|
for the whole sweep to finish. A poll that checks this between
|
||||||
|
reads gives the port up and picks the rest up next time round.
|
||||||
|
"""
|
||||||
|
return not self._cmd_q.empty()
|
||||||
|
|
||||||
# ── Worker loop ───────────────────────────────────────────────────────────
|
# ── Worker loop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
|
|||||||
+7
-2
@@ -12,6 +12,7 @@ import traceback
|
|||||||
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
||||||
|
|
||||||
from core.scan_engine import ScanAborted, ScanCallbacks, ScanEngine
|
from core.scan_engine import ScanAborted, ScanCallbacks, ScanEngine
|
||||||
|
from core.sras_format import VERSION
|
||||||
|
|
||||||
|
|
||||||
class QtScanController(QObject):
|
class QtScanController(QObject):
|
||||||
@@ -32,7 +33,8 @@ class QtScanController(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, stage, scope, rotator, plan, out_path,
|
def __init__(self, stage, scope, rotator, plan, out_path,
|
||||||
resume=None, on_scan_active=None):
|
resume=None, on_scan_active=None, burst_mode=False,
|
||||||
|
strict_rows=False, file_version=VERSION):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._prompt_event = threading.Event()
|
self._prompt_event = threading.Event()
|
||||||
self._on_scan_active = on_scan_active
|
self._on_scan_active = on_scan_active
|
||||||
@@ -47,7 +49,10 @@ class QtScanController(QObject):
|
|||||||
prompt=self._blocking_prompt,
|
prompt=self._blocking_prompt,
|
||||||
)
|
)
|
||||||
self._engine = ScanEngine(stage, scope, rotator, plan, out_path,
|
self._engine = ScanEngine(stage, scope, rotator, plan, out_path,
|
||||||
resume=resume, callbacks=callbacks)
|
resume=resume, callbacks=callbacks,
|
||||||
|
burst_mode=burst_mode,
|
||||||
|
strict_rows=strict_rows,
|
||||||
|
file_version=file_version)
|
||||||
|
|
||||||
# ── Engine control (called from the GUI thread) ───────────────────────────
|
# ── Engine control (called from the GUI thread) ───────────────────────────
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -17,6 +17,14 @@ from PyQt6.QtWidgets import (
|
|||||||
from hardware.serial_util import scored_ports
|
from hardware.serial_util import scored_ports
|
||||||
|
|
||||||
|
|
||||||
|
def mono_font(size: int = 11) -> QFont:
|
||||||
|
"""Monospace font for numeric read-outs, so columns of digits line up."""
|
||||||
|
font = QFont("Menlo")
|
||||||
|
font.setStyleHint(QFont.StyleHint.Monospace)
|
||||||
|
font.setPointSize(size)
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
def set_toggle(btn, checked: bool, text: str, enabled: bool = True):
|
def set_toggle(btn, checked: bool, text: str, enabled: bool = True):
|
||||||
"""Update a checkable button without re-triggering its toggled signal."""
|
"""Update a checkable button without re-triggering its toggled signal."""
|
||||||
btn.blockSignals(True)
|
btn.blockSignals(True)
|
||||||
@@ -138,10 +146,7 @@ class LogConsole(QWidget):
|
|||||||
self.view = QPlainTextEdit()
|
self.view = QPlainTextEdit()
|
||||||
self.view.setReadOnly(True)
|
self.view.setReadOnly(True)
|
||||||
self.view.setMaximumBlockCount(max_blocks)
|
self.view.setMaximumBlockCount(max_blocks)
|
||||||
font = QFont("Menlo")
|
self.view.setFont(mono_font())
|
||||||
font.setStyleHint(QFont.StyleHint.Monospace)
|
|
||||||
font.setPointSize(11)
|
|
||||||
self.view.setFont(font)
|
|
||||||
layout.addWidget(self.view)
|
layout.addWidget(self.view)
|
||||||
|
|
||||||
row = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
|
|||||||
+220
-40
@@ -34,6 +34,7 @@ class HeliosLaser:
|
|||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.serial = None
|
self.serial = None
|
||||||
self.is_connected = False
|
self.is_connected = False
|
||||||
|
self._rx = bytearray() # bytes read off the port, not yet a line
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_available_ports() -> List[str]:
|
def list_available_ports() -> List[str]:
|
||||||
@@ -52,6 +53,7 @@ class HeliosLaser:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.serial = open_8n1(self.port, baudrate=9600, timeout=self.timeout)
|
self.serial = open_8n1(self.port, baudrate=9600, timeout=self.timeout)
|
||||||
|
self._rx.clear()
|
||||||
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}")
|
||||||
@@ -92,44 +94,210 @@ class HeliosLaser:
|
|||||||
logger.error(f"Failed to send command '{command}': {e}")
|
logger.error(f"Failed to send command '{command}': {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# A reply can run to more than one line. Every status-register query
|
||||||
|
# answers with the value and then a decode line:
|
||||||
|
#
|
||||||
|
# LCE = 2
|
||||||
|
# Bit 15..0: 0000 0000 0000 0010
|
||||||
|
#
|
||||||
|
# At 9600 baud those trailing ~30 characters are still on the wire when
|
||||||
|
# read_until() returns the first line, so reset_input_buffer() cannot
|
||||||
|
# drop them. Left there they become the next query's "answer", and
|
||||||
|
# every reply after that is one line behind — a register read reported
|
||||||
|
# as a "Bit 15..0" string, and the reads around it timing out on a
|
||||||
|
# leading blank line. So: match a reply to the command that asked for
|
||||||
|
# it, and read off the rest of it before the next command goes out.
|
||||||
|
# How long the line has to stay silent before a reply counts as over.
|
||||||
|
# It is waited out once per query, so it sets the pace of the whole
|
||||||
|
# status poll: at 50 ms that was 400 ms of a 590 ms poll spent listening
|
||||||
|
# to nothing. A reply streams at the baud rate — ~1 ms between bytes,
|
||||||
|
# no measurable gap between its lines — and the deadline restarts on
|
||||||
|
# every line, so 20 ms is twenty times the gap it has to outlast. A
|
||||||
|
# tail that still arrives late is caught by _discard_input() rather
|
||||||
|
# than by waiting longer here.
|
||||||
|
TRAILING_QUIET_S = 0.02
|
||||||
|
MAX_REPLY_LINES = 8
|
||||||
|
|
||||||
|
def _discard_input(self):
|
||||||
|
"""Drop anything unread, on the wire and already taken off it."""
|
||||||
|
self._rx.clear()
|
||||||
|
self.serial.reset_input_buffer()
|
||||||
|
|
||||||
|
def _read_line(self) -> Optional[str]:
|
||||||
|
"""One line, however it is framed; None if nothing came in time.
|
||||||
|
|
||||||
|
The controller ends every line with CRLF and pads a reply with a
|
||||||
|
blank line or two:
|
||||||
|
|
||||||
|
b'LDS = 100 mA\r\n\r\n'
|
||||||
|
|
||||||
|
Reading up to CR alone leaves the trailing LF behind, and the next
|
||||||
|
read then waits out the whole port timeout for a CR that will not
|
||||||
|
come until some later command is answered. That was a second of
|
||||||
|
dead air per query — a status poll took ~8.6 s against the 1 s
|
||||||
|
interval that schedules it — and worse, a query that spends its
|
||||||
|
deadline blocked gives up while its own reply is still arriving.
|
||||||
|
The next query then flushes the port mid-line, and the fragment it
|
||||||
|
reads is a bare number: "LCE = 32" cut after the "=" is where a
|
||||||
|
diode current of 32 mA came from.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + self.timeout
|
||||||
|
while True:
|
||||||
|
cut = min((i for i in (self._rx.find(b'\r'), self._rx.find(b'\n'))
|
||||||
|
if i >= 0), default=-1)
|
||||||
|
if cut >= 0:
|
||||||
|
line = bytes(self._rx[:cut])
|
||||||
|
# CRLF is one terminator, not an empty line between two.
|
||||||
|
end = cut + (2 if self._rx[cut:cut + 2] == b'\r\n' else 1)
|
||||||
|
del self._rx[:end]
|
||||||
|
return line.decode('ascii', errors='replace').strip()
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
return None
|
||||||
|
chunk = self.serial.read(self.serial.in_waiting or 1)
|
||||||
|
if not chunk:
|
||||||
|
return None # port timeout: nothing more is coming
|
||||||
|
self._rx += chunk
|
||||||
|
|
||||||
|
def _read_pending_lines(self) -> List[str]:
|
||||||
|
"""Every further line the controller sends before the line goes quiet."""
|
||||||
|
lines: List[str] = []
|
||||||
|
deadline = time.monotonic() + self.TRAILING_QUIET_S
|
||||||
|
while True:
|
||||||
|
# What has already arrived is read whatever the quiet window
|
||||||
|
# says: the window is for deciding when to stop waiting, not
|
||||||
|
# for leaving a line in the buffer to confuse the next query.
|
||||||
|
if self._rx or self.serial.in_waiting:
|
||||||
|
line = self._read_line()
|
||||||
|
if line is None:
|
||||||
|
return lines # a partial line, nothing behind it
|
||||||
|
if line:
|
||||||
|
lines.append(line)
|
||||||
|
deadline = time.monotonic() + self.TRAILING_QUIET_S
|
||||||
|
continue
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
return lines
|
||||||
|
time.sleep(0.005)
|
||||||
|
|
||||||
|
# The only replies that come back without naming what they answer.
|
||||||
|
# Every other line has to identify itself: an unlabelled number is not
|
||||||
|
# evidence that it is *this* register's number, and taking one on faith
|
||||||
|
# is how a status register's value ends up displayed as a diode current.
|
||||||
|
UNLABELLED_REPLIES = frozenset({"CSR", "HSR"})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _value_in(cls, line: str, mnemonic: str) -> Optional[str]:
|
||||||
|
"""The value `line` holds for `mnemonic`, or None if it isn't its reply.
|
||||||
|
|
||||||
|
The controller answers "LDF = 20000 ns". A line naming a different
|
||||||
|
mnemonic is the tail of an earlier reply, and "Bit 15..0: ..." is a
|
||||||
|
status register's decode line; neither is an answer to this query.
|
||||||
|
A line naming nothing counts only for the serial numbers, which is
|
||||||
|
the one reply known to come back bare.
|
||||||
|
"""
|
||||||
|
head, sep, tail = line.partition('=')
|
||||||
|
if sep:
|
||||||
|
named = head.split()
|
||||||
|
if named and named[0].upper() != mnemonic:
|
||||||
|
return None
|
||||||
|
fields = tail.split() # drop the unit suffix ("ns", "mA", "m°C")
|
||||||
|
return fields[0] if fields else None
|
||||||
|
if line.lower().startswith("bit"):
|
||||||
|
return None
|
||||||
|
fields = line.split()
|
||||||
|
if fields and fields[0].upper() == mnemonic:
|
||||||
|
return fields[1] if len(fields) > 1 else None
|
||||||
|
if mnemonic in cls.UNLABELLED_REPLIES:
|
||||||
|
return line
|
||||||
|
return None
|
||||||
|
|
||||||
def _query(self, command: str) -> Optional[str]:
|
def _query(self, command: str) -> Optional[str]:
|
||||||
"""Send a query and return the value from its response.
|
"""Send a query and return the value from its response.
|
||||||
|
|
||||||
Reads until the CR terminator rather than sleeping a fixed interval:
|
Reads until this command's reply arrives rather than sleeping a fixed
|
||||||
the device usually answers in a few ms, so the old unconditional
|
interval: the device usually answers in a few ms, so the old
|
||||||
0.05 + 0.2 s cost ~250 ms per query and made an 8-query status poll
|
unconditional 0.05 + 0.2 s cost ~250 ms per query and made an 8-query
|
||||||
take ~2 s — longer than the 1 s interval that scheduled it.
|
status poll take ~2 s — longer than the 1 s interval that scheduled
|
||||||
|
it.
|
||||||
"""
|
"""
|
||||||
|
fields = command.split()
|
||||||
|
mnemonic = fields[0].upper() if fields else ""
|
||||||
try:
|
try:
|
||||||
# Clear any stale bytes so a previous timed-out reply can't be
|
# Anything volunteered while the port was idle answers no command.
|
||||||
# mistaken for this command's response.
|
self._discard_input()
|
||||||
self.serial.reset_input_buffer()
|
|
||||||
|
|
||||||
if not self._send_command(command):
|
if not self._send_command(command):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip()
|
deadline = time.monotonic() + self.timeout
|
||||||
if not response:
|
for _ in range(self.MAX_REPLY_LINES):
|
||||||
|
line = self._read_line()
|
||||||
|
if line is None:
|
||||||
|
break # nothing arrived within the timeout
|
||||||
|
if line:
|
||||||
|
logger.debug(f"Query '{command}' line: {line!r}")
|
||||||
|
value = self._value_in(line, mnemonic)
|
||||||
|
if value is not None:
|
||||||
|
for extra in self._read_pending_lines():
|
||||||
|
logger.debug(f"Query '{command}' trailing: {extra!r}")
|
||||||
|
return value
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
break
|
||||||
|
|
||||||
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
|
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
|
||||||
|
self._read_pending_lines()
|
||||||
return None
|
return None
|
||||||
logger.debug(f"Query '{command}' response: {response}")
|
|
||||||
|
|
||||||
# Helios format: "COMMAND = VALUE UNIT" — take just the value
|
|
||||||
if '=' in response:
|
|
||||||
parts = response.split('=')
|
|
||||||
if len(parts) >= 2:
|
|
||||||
value_part = parts[1].strip()
|
|
||||||
# Strip the unit suffix if present (e.g. "ns", "mA", "mW")
|
|
||||||
fields = value_part.split()
|
|
||||||
if fields:
|
|
||||||
return fields[0]
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to read response for '{command}': {e}")
|
logger.error(f"Failed to read response for '{command}': {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _write_command(self, command: str) -> bool:
|
||||||
|
"""Send a command with no value to read back, and clear whatever the
|
||||||
|
controller prints in acknowledgement — left in the buffer, that is
|
||||||
|
what the next query would read as its own answer.
|
||||||
|
"""
|
||||||
|
if not self._send_command(command):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
for line in self._read_pending_lines():
|
||||||
|
logger.debug(f"Command '{command}' reply: {line!r}")
|
||||||
|
except Exception as e:
|
||||||
|
# The command went out; only the tidy-up failed.
|
||||||
|
logger.error(f"Failed to read the reply to '{command}': {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Section 6 of the operator's manual, under Syntax:
|
||||||
|
#
|
||||||
|
# Commands or set values can be discarded by the controller
|
||||||
|
# unintentionally. It is recommended to query the set value after
|
||||||
|
# the command is entered to confirm the actual value.
|
||||||
|
#
|
||||||
|
# (The command table repeats it: "Query the command to confirm it was
|
||||||
|
# accepted.") A setter that only writes therefore cannot report whether
|
||||||
|
# it worked, and the panel's next status poll reads back the old value —
|
||||||
|
# which looks exactly like the GUI refusing the operator's number.
|
||||||
|
SET_RETRIES = 3
|
||||||
|
SET_SETTLE_S = 0.02 # let the controller store it before reading
|
||||||
|
|
||||||
|
def _write_verified(self, mnemonic: str, value: int) -> bool:
|
||||||
|
"""Write `value` to `mnemonic`, and confirm the controller took it.
|
||||||
|
|
||||||
|
Returns False if the read-back never matches, leaving the controller
|
||||||
|
holding whatever value it kept — the caller is expected to say so
|
||||||
|
rather than let the discarded write pass for a successful one.
|
||||||
|
"""
|
||||||
|
for attempt in range(1, self.SET_RETRIES + 1):
|
||||||
|
if not self._write_command(f"{mnemonic} {value}"):
|
||||||
|
return False
|
||||||
|
time.sleep(self.SET_SETTLE_S)
|
||||||
|
readback = self._query_int(mnemonic)
|
||||||
|
if readback == value:
|
||||||
|
return True
|
||||||
|
logger.warning(
|
||||||
|
f"'{mnemonic} {value}' not accepted: controller reports "
|
||||||
|
f"{readback} (attempt {attempt}/{self.SET_RETRIES})")
|
||||||
|
return False
|
||||||
|
|
||||||
def _query_int(self, command: str) -> Optional[int]:
|
def _query_int(self, command: str) -> Optional[int]:
|
||||||
"""Query a value that should parse as an int; None if absent/unparseable."""
|
"""Query a value that should parse as an int; None if absent/unparseable."""
|
||||||
raw = self._query(command)
|
raw = self._query(command)
|
||||||
@@ -155,27 +323,35 @@ class HeliosLaser:
|
|||||||
logger.error(f"Period {period_ns} ns out of range (8000-60000)")
|
logger.error(f"Period {period_ns} ns out of range (8000-60000)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
command = f"LDF {period_ns}"
|
return self._write_verified("LDF", period_ns)
|
||||||
return self._send_command(command)
|
|
||||||
|
|
||||||
def set_current_ma(self, current: int) -> bool:
|
def set_current_ma(self, current: int) -> bool:
|
||||||
"""Set pump diode current in mA."""
|
"""Set pump diode pulse current (LDS) in mA.
|
||||||
|
|
||||||
|
False means the controller did not take the value — see
|
||||||
|
_write_verified. The manual's range for LDS is 0-7000 mA; the
|
||||||
|
2000 mA ceiling here is this rig's limit, not the protocol's.
|
||||||
|
"""
|
||||||
if not (0 <= current <= 2000):
|
if not (0 <= current <= 2000):
|
||||||
logger.error(f"Current {current} mA out of range (0-2000)")
|
logger.error(f"Current {current} mA out of range (0-2000)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
command = f"LDS {current}"
|
return self._write_verified("LDS", current)
|
||||||
return self._send_command(command)
|
|
||||||
|
|
||||||
def set_pulse_mode(self, mode: PulseMode) -> bool:
|
def set_pulse_mode(self, mode: PulseMode) -> bool:
|
||||||
"""Set pulse mode."""
|
"""Set pulse mode.
|
||||||
|
|
||||||
|
Note from the manual's LDG entry: "LDF has to be set again after LDG
|
||||||
|
is changed, except for single pulse triggering" — so a caller that
|
||||||
|
changes the mode has to re-send the frequency.
|
||||||
|
"""
|
||||||
command = f"LDG {mode.value}"
|
command = f"LDG {mode.value}"
|
||||||
return self._send_command(command)
|
return self._write_command(command)
|
||||||
|
|
||||||
def set_laser_enable(self, enable: bool) -> bool:
|
def set_laser_enable(self, enable: bool) -> bool:
|
||||||
"""Enable or disable laser emission."""
|
"""Enable or disable laser emission."""
|
||||||
command = f"LDO {1 if enable else 0}"
|
command = f"LDO {1 if enable else 0}"
|
||||||
success = self._send_command(command)
|
success = self._write_command(command)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
state = "enabled" if enable else "disabled"
|
state = "enabled" if enable else "disabled"
|
||||||
@@ -243,11 +419,11 @@ class HeliosLaser:
|
|||||||
True if all three commands sent successfully
|
True if all three commands sent successfully
|
||||||
"""
|
"""
|
||||||
ok = True
|
ok = True
|
||||||
ok = self._send_command("CCE 0") and ok
|
ok = self._write_command("CCE 0") and ok
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
ok = self._send_command("LCE 0") and ok
|
ok = self._write_command("LCE 0") and ok
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
ok = self._send_command("LER 0") and ok
|
ok = self._write_command("LER 0") and ok
|
||||||
if ok:
|
if ok:
|
||||||
logger.info("Fault reset sequence sent")
|
logger.info("Fault reset sequence sent")
|
||||||
return ok
|
return ok
|
||||||
@@ -258,18 +434,22 @@ class HeliosLaser:
|
|||||||
return None if value is None else value == 1
|
return None if value is None else value == 1
|
||||||
|
|
||||||
def send_raw_command(self, command: str) -> Optional[str]:
|
def send_raw_command(self, command: str) -> Optional[str]:
|
||||||
"""Send a raw command and return the unparsed response (diagnostics)."""
|
"""Send a raw command and return its whole unparsed reply (diagnostics).
|
||||||
|
|
||||||
|
Every line comes back, the "Bit 15..0: ..." decode line included:
|
||||||
|
seeing the entire reply is the point of the raw console.
|
||||||
|
"""
|
||||||
if not self.is_connected or not self.serial:
|
if not self.is_connected or not self.serial:
|
||||||
logger.error("Not connected to laser")
|
logger.error("Not connected to laser")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
self.serial.reset_input_buffer()
|
self._discard_input()
|
||||||
if not self._send_command(command):
|
if not self._send_command(command):
|
||||||
return None
|
return None
|
||||||
raw = self.serial.read_until(b'\r')
|
first = self._read_line()
|
||||||
if not raw:
|
lines = [first] if first else []
|
||||||
raw = self.serial.read(self.serial.in_waiting)
|
lines += self._read_pending_lines()
|
||||||
return raw.decode('ascii', errors='replace').strip()
|
return "\n".join(lines)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"send_raw_command error: {e}")
|
logger.error(f"send_raw_command error: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -277,7 +457,7 @@ class HeliosLaser:
|
|||||||
def set_remote_enable(self, enable: bool) -> bool:
|
def set_remote_enable(self, enable: bool) -> bool:
|
||||||
"""Set the remote enable state (LRE - utility connector pin 8)."""
|
"""Set the remote enable state (LRE - utility connector pin 8)."""
|
||||||
command = f"LRE {1 if enable else 0}"
|
command = f"LRE {1 if enable else 0}"
|
||||||
return self._send_command(command)
|
return self._write_command(command)
|
||||||
|
|
||||||
# No __del__: it used to call disconnect(), which disables the laser and
|
# No __del__: it used to call disconnect(), which disables the laser and
|
||||||
# writes to the serial port from the garbage collector at an
|
# writes to the serial port from the garbage collector at an
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Helios status-register bit definitions (Tables 8-1, 8-2, 8-3).
|
||||||
|
|
||||||
|
Kept out of the driver and out of the Qt apps so that a command-line
|
||||||
|
diagnostic can decode a register without importing either.
|
||||||
|
|
||||||
|
Each entry: bit_number -> (severity, description, comment)
|
||||||
|
severity: 'C' = critical error, 'S' = status, 'I' = input error, '' = none
|
||||||
|
"""
|
||||||
|
|
||||||
|
LER_FLAGS = {
|
||||||
|
0: ('C', 'Controller temperature failure (resonator/SHG/q-switch)',
|
||||||
|
'Check CCE register for details'),
|
||||||
|
1: ('S', 'Trigger input active',
|
||||||
|
'High when trigger signal applied or laser in continuous pulsing'),
|
||||||
|
2: ('I', 'Command error',
|
||||||
|
'Unknown command sent to controller'),
|
||||||
|
3: ('C', 'Laser disable pin open (utility connector)',
|
||||||
|
'Shuts down pump diodes; reset LER 0 required to restart'),
|
||||||
|
4: ('C', 'Internal hardware failure',
|
||||||
|
'Contact Coherent'),
|
||||||
|
5: ('C', 'Over voltage laser diode',
|
||||||
|
'Check for open circuit or voltage spikes'),
|
||||||
|
6: ('C', 'Internal hardware failure',
|
||||||
|
'Contact Coherent'),
|
||||||
|
7: ('C', 'Controller temperature failure at pump diodes',
|
||||||
|
'Check LCE register for details'),
|
||||||
|
8: ('S', 'Laser start delay (60 s warmup)',
|
||||||
|
'Laser cannot be started yet; status error LED flashing'),
|
||||||
|
9: ('C', 'Internal hardware failure',
|
||||||
|
'Check environment for strong EMI; contact Coherent'),
|
||||||
|
10: ('C', 'Internal hardware failure',
|
||||||
|
'Check environment for strong EMI; contact Coherent'),
|
||||||
|
11: ('C', 'Internal hardware failure',
|
||||||
|
'Check environment for strong EMI; contact Coherent'),
|
||||||
|
12: ('S', 'Slave controller error (remote input)',
|
||||||
|
'Valid only for master controller coupled with a slave'),
|
||||||
|
13: ('', 'Laserhead not found',
|
||||||
|
'Head not connected / not found; check EMI; ignore for double-electronic slave'),
|
||||||
|
14: ('', 'Laserhead I\u00b2C acknowledge error',
|
||||||
|
'Check environment for strong EMI; ignore for double-electronic slave'),
|
||||||
|
15: ('S', 'Range-Error (not critical)',
|
||||||
|
'Input value out of range'),
|
||||||
|
}
|
||||||
|
|
||||||
|
LCE_FLAGS = {
|
||||||
|
0: ('C', 'Pump diode over/under temperature',
|
||||||
|
'Limit exceeded (<10\u00b0C or >60\u00b0C); check head cooling'),
|
||||||
|
1: ('C', 'Internal hardware failure',
|
||||||
|
'Contact Coherent'),
|
||||||
|
2: ('C', 'Pump diode temperature out of range',
|
||||||
|
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
||||||
|
3: ('C', 'Pump diode current critical',
|
||||||
|
'Current set too close to current limit'),
|
||||||
|
4: ('C', 'Pump diode temperature out of limit',
|
||||||
|
'Pump diode temperature is out of limit'),
|
||||||
|
5: ('S', 'Door switch open',
|
||||||
|
'Close utility connector pin 2 permanently to pin 9 (GND)'),
|
||||||
|
7: ('C', 'Pump diode NTC error',
|
||||||
|
'Invalid temperature measured or NTC broken'),
|
||||||
|
8: ('C', 'Laser diode power stage over temperature',
|
||||||
|
'Temp <10\u00b0C or >65\u00b0C at controller; check controller cooling'),
|
||||||
|
9: ('C', 'Internal hardware failure',
|
||||||
|
'Check environment for strong EMI; contact Coherent'),
|
||||||
|
10: ('C', 'Internal hardware failure',
|
||||||
|
'Check environment for strong EMI; contact Coherent'),
|
||||||
|
11: ('C', 'Internal hardware failure',
|
||||||
|
'Check environment for strong EMI; contact Coherent'),
|
||||||
|
15: ('S', 'Range-Error (not critical)',
|
||||||
|
'Input value out of range'),
|
||||||
|
}
|
||||||
|
|
||||||
|
CCE_FLAGS = {
|
||||||
|
0: ('C', 'Resonator/SHG under/over temperature',
|
||||||
|
'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'),
|
||||||
|
1: ('C', 'Resonator/SHG NTC failure',
|
||||||
|
'Temperature sensor broken or disconnected'),
|
||||||
|
2: ('C', 'Resonator/SHG temperature out of range',
|
||||||
|
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
||||||
|
3: ('C', 'Q-switch ADC / temperature readout failure',
|
||||||
|
'Internal hardware error or no NTC connected'),
|
||||||
|
4: ('C', 'Q-switch temperature out of range',
|
||||||
|
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
||||||
|
5: ('C', 'Q-switch under/over temperature',
|
||||||
|
'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'),
|
||||||
|
7: ('C', 'Q-switch NTC failure',
|
||||||
|
'Internal hardware error or no NTC connected'),
|
||||||
|
8: ('C', 'Internal hardware failure',
|
||||||
|
'Contact Coherent'),
|
||||||
|
15: ('S', 'Range-Error (not critical)',
|
||||||
|
'Input value out of range'),
|
||||||
|
}
|
||||||
|
|
||||||
|
SEVERITY_LABEL = {'C': '[CRIT]', 'S': '[STAT]', 'I': '[INPT]', '': '[INFO]'}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_register(flags_dict: dict, value: int) -> list:
|
||||||
|
"""Return list of (bit, severity, description, comment) for each set bit."""
|
||||||
|
active = []
|
||||||
|
for bit, (sev, desc, comment) in flags_dict.items():
|
||||||
|
if value & (1 << bit):
|
||||||
|
active.append((bit, sev, desc, comment))
|
||||||
|
return active
|
||||||
@@ -45,5 +45,20 @@ class TriggerBitsServo(IntFlag):
|
|||||||
TRIGOUT_MAXV = TRIGOUT_HIGH | TRIGOUT_MAXVELOCITY
|
TRIGOUT_MAXV = TRIGOUT_HIGH | TRIGOUT_MAXVELOCITY
|
||||||
|
|
||||||
|
|
||||||
|
# Gate off: no trigger-out function selected, so the pin idles inactive.
|
||||||
|
#
|
||||||
|
# Treat this as unverified until it has been checked on the rig. §7.6 of
|
||||||
|
# docs/hardware/BBD203_Communications_Protocol.md documents `mode` as an
|
||||||
|
# enumeration capping at 0x11, which flatly contradicts the bitmask this
|
||||||
|
# driver actually sends (TRIGOUT_MAXV = 0x90, known working), so the doc
|
||||||
|
# cannot settle what makes the pin idle low. Under the bitmask reading 0x00
|
||||||
|
# clears everything and the pin sits low. If instead TRIGOUT_HIGH is an
|
||||||
|
# active-high *polarity* bit, clearing it means active-low and the pin idles
|
||||||
|
# HIGH — which in burst mode floods the acquisition with flyback frames.
|
||||||
|
# ScanEngine's gate-off preflight catches that; if it trips, change this to
|
||||||
|
# TriggerBitsServo.TRIGOUT_HIGH.
|
||||||
|
TRIGOUT_GATE_OFF = TriggerBitsServo(0)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import time
|
import time
|
||||||
from threading import Thread, Event
|
from threading import Thread, Event
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
from .apt_constants import StatusBits, TriggerBitsServo
|
from .apt_constants import StatusBits, TriggerBitsServo, TRIGOUT_GATE_OFF
|
||||||
from .apt_messages import APTProtocol
|
from .apt_messages import APTProtocol
|
||||||
from .serial_comms import SerialSnooper
|
from .serial_comms import SerialSnooper
|
||||||
|
|
||||||
@@ -465,3 +465,42 @@ class ThorlabsServoDriver():
|
|||||||
def set_trigger_trigout_maxv(self, axis):
|
def set_trigger_trigout_maxv(self, axis):
|
||||||
'''Set trigger output high + pulse at max velocity (TRIGOUT_MAXV).'''
|
'''Set trigger output high + pulse at max velocity (TRIGOUT_MAXV).'''
|
||||||
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MAXV)
|
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MAXV)
|
||||||
|
|
||||||
|
def set_trigger_gate_off(self, axis):
|
||||||
|
'''Drive the trigger output inactive, so no pulses reach the gate.'''
|
||||||
|
self.set_trigger(axis, TRIGOUT_GATE_OFF)
|
||||||
|
|
||||||
|
def arm_scan_gate(self, axis, armed, verify=True):
|
||||||
|
'''
|
||||||
|
arm_scan_gate(axis, armed): Arms or drops the max-velocity trigger
|
||||||
|
output the oscilloscope AND-gate uses.
|
||||||
|
|
||||||
|
Burst acquisition runs one scope acquisition across many rows, so
|
||||||
|
the gate must be armed only for the acquiring pass and dropped for
|
||||||
|
the flyback — otherwise the return move hits max velocity and
|
||||||
|
injects frames between rows.
|
||||||
|
'''
|
||||||
|
mode = TriggerBitsServo.TRIGOUT_MAXV if armed else TRIGOUT_GATE_OFF
|
||||||
|
if verify:
|
||||||
|
self.set_trigger_verified(axis, mode)
|
||||||
|
else:
|
||||||
|
self.set_trigger(axis, mode)
|
||||||
|
|
||||||
|
def set_trigger_verified(self, axis, mode, timeout=5.0, retries=2):
|
||||||
|
'''
|
||||||
|
set_trigger_verified(axis, mode): Sets the trigger mode and reads
|
||||||
|
it back to confirm it landed.
|
||||||
|
|
||||||
|
set_trigger is fire-and-forget over the shared TX queue. Burst
|
||||||
|
acquisition toggles the gate between every row, and a dropped
|
||||||
|
change there silently fills the acquisition with flyback frames —
|
||||||
|
so confirm rather than assume.
|
||||||
|
'''
|
||||||
|
for _ in range(retries + 1):
|
||||||
|
self.set_trigger(axis, mode)
|
||||||
|
if int(self.get_trigger(axis, timeout=timeout)) == int(mode):
|
||||||
|
return
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Axis 0x{axis:02X} did not accept trigger mode 0x{int(mode):02X} "
|
||||||
|
f"after {retries + 1} attempts"
|
||||||
|
)
|
||||||
|
|||||||
+155
-20
@@ -183,6 +183,10 @@ class TektronixOscilloscopeBase:
|
|||||||
|
|
||||||
self.write(f"HORizontal:FASTframe:COUNt {count}")
|
self.write(f"HORizontal:FASTframe:COUNt {count}")
|
||||||
|
|
||||||
|
def get_fastframe_max_frames(self):
|
||||||
|
"""Query how many FastFrame frames the current horizontal settings allow."""
|
||||||
|
return int(self.query("HORizontal:FASTframe:MAXFRames?"))
|
||||||
|
|
||||||
def get_record_length(self):
|
def get_record_length(self):
|
||||||
"""Query the current horizontal record length."""
|
"""Query the current horizontal record length."""
|
||||||
response = self.query("HORizontal:MODe:RECOrdlength?")
|
response = self.query("HORizontal:MODe:RECOrdlength?")
|
||||||
@@ -288,7 +292,7 @@ class TektronixOscilloscopeBase:
|
|||||||
|
|
||||||
# Validate coupling
|
# Validate coupling
|
||||||
valid = False
|
valid = False
|
||||||
for variants in self.CHANNEL_COUPLING.items():
|
for variants in self.CHANNEL_COUPLING.values():
|
||||||
if coupling_upper in [v.upper() for v in variants]:
|
if coupling_upper in [v.upper() for v in variants]:
|
||||||
valid = True
|
valid = True
|
||||||
break
|
break
|
||||||
@@ -340,6 +344,72 @@ class TektronixOscilloscopeBase:
|
|||||||
|
|
||||||
self.write(f"CH{channel}:TERmination {termination}")
|
self.write(f"CH{channel}:TERmination {termination}")
|
||||||
|
|
||||||
|
# ========== Measurement Methods ==========
|
||||||
|
|
||||||
|
# Immediate measurements the alignment/inspection code asks for. The
|
||||||
|
# instrument accepts many more; this list is what has been exercised here,
|
||||||
|
# and an unlisted type is far more likely to be a typo than a deliberate
|
||||||
|
# choice.
|
||||||
|
MEASUREMENT_TYPES = {
|
||||||
|
'MEAN': ['MEAN'],
|
||||||
|
'AMPLITUDE': ['AMPlitude', 'AMPLITUDE'],
|
||||||
|
'MAXIMUM': ['MAXimum', 'MAXIMUM'],
|
||||||
|
'MINIMUM': ['MINImum', 'MINIMUM'],
|
||||||
|
'PK2PK': ['PK2pk', 'PK2PK'],
|
||||||
|
'RMS': ['RMS'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tektronix returns this sentinel when a measurement cannot be made (no
|
||||||
|
# acquisition yet, source off, signal outside the graticule). It is a
|
||||||
|
# valid float, so it has to be caught explicitly or it reads as a
|
||||||
|
# 1e38 V measurement.
|
||||||
|
MEASUREMENT_INVALID = 9.9e37
|
||||||
|
|
||||||
|
def measure_immediate(self, channel, measurement_type='MEAN'):
|
||||||
|
"""Take an immediate measurement on one channel and return it in volts.
|
||||||
|
|
||||||
|
"Immediate" measurements are computed on demand and are not added to
|
||||||
|
the scope's on-screen measurement badges, so this leaves whatever the
|
||||||
|
operator has set up on the front panel untouched.
|
||||||
|
|
||||||
|
Raises ValueError if the instrument reports the measurement as
|
||||||
|
unavailable, which on a triggered-acquisition scope usually means it
|
||||||
|
has not acquired anything yet.
|
||||||
|
"""
|
||||||
|
channel = self._normalize_channel(channel)
|
||||||
|
|
||||||
|
if measurement_type.upper() not in self.MEASUREMENT_TYPES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid measurement type: {measurement_type}. "
|
||||||
|
f"Valid options: {', '.join(self.MEASUREMENT_TYPES)}")
|
||||||
|
|
||||||
|
self.write(f"MEASUrement:IMMed:SOUrce1 CH{channel}")
|
||||||
|
self.write(f"MEASUrement:IMMed:TYPe {measurement_type}")
|
||||||
|
response = self.query("MEASUrement:IMMed:VALue?")
|
||||||
|
try:
|
||||||
|
value = float(response)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unparseable {measurement_type} measurement on CH{channel}: "
|
||||||
|
f"{response!r}") from exc
|
||||||
|
|
||||||
|
if abs(value) >= self.MEASUREMENT_INVALID:
|
||||||
|
raise ValueError(
|
||||||
|
f"CH{channel} {measurement_type} is unavailable (the scope "
|
||||||
|
f"returned its no-measurement sentinel). Check that the "
|
||||||
|
f"channel is on and that the acquisition is triggering.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_acquisition_count(self):
|
||||||
|
"""Number of acquisitions since the acquisition was last started.
|
||||||
|
|
||||||
|
A caller polling a free-running scope uses this to tell a fresh
|
||||||
|
reading from a stale one: if the count has not moved, the record has
|
||||||
|
not changed and every measurement taken off it is the previous
|
||||||
|
answer.
|
||||||
|
"""
|
||||||
|
return int(float(self.query("ACQuire:NUMACq?")))
|
||||||
|
|
||||||
# ========== Waveform Transfer Methods ==========
|
# ========== Waveform Transfer Methods ==========
|
||||||
|
|
||||||
def set_data_source(self, source):
|
def set_data_source(self, source):
|
||||||
@@ -361,6 +431,21 @@ class TektronixOscilloscopeBase:
|
|||||||
|
|
||||||
self.write(f"DATa:SOUrce {source}")
|
self.write(f"DATa:SOUrce {source}")
|
||||||
|
|
||||||
|
def set_data_encoding(self, encoding):
|
||||||
|
"""Set the curve transfer encoding (e.g. RIBinary = signed int, MSB first)."""
|
||||||
|
valid = ('ASCII', 'RIBinary', 'RPBinary', 'FPBinary',
|
||||||
|
'SRIbinary', 'SRPbinary', 'SFPbinary')
|
||||||
|
if encoding.upper() not in [v.upper() for v in valid]:
|
||||||
|
raise ValueError(f"Invalid data encoding: {encoding}. "
|
||||||
|
f"Valid options: {', '.join(valid)}")
|
||||||
|
self.write(f"DATa:ENCdg {encoding}")
|
||||||
|
|
||||||
|
def set_data_width(self, width):
|
||||||
|
"""Set bytes per sample for curve transfers."""
|
||||||
|
if width not in (1, 2):
|
||||||
|
raise ValueError(f"Invalid data width: {width}. Must be 1 or 2")
|
||||||
|
self.write(f"DATa:WIDth {width}")
|
||||||
|
|
||||||
def query_wfmoutpre(self):
|
def query_wfmoutpre(self):
|
||||||
"""Query all waveform output preamble parameters."""
|
"""Query all waveform output preamble parameters."""
|
||||||
return self.query("WFMOutpre?")
|
return self.query("WFMOutpre?")
|
||||||
@@ -455,6 +540,43 @@ class TektronixOscilloscopeBase:
|
|||||||
|
|
||||||
return waveforms
|
return waveforms
|
||||||
|
|
||||||
|
def transfer_fastframe_bulk(self, frame_count, samples_per_frame,
|
||||||
|
bytes_per_sample=1):
|
||||||
|
"""Transfer a whole FastFrame burst as one contiguous buffer.
|
||||||
|
|
||||||
|
Unlike transfer_fastframe this does not care how the scope frames the
|
||||||
|
response — it accumulates blocks until it has the expected byte count,
|
||||||
|
so one large IEEE block and one block per frame both work. Returns a
|
||||||
|
bytearray of frame_count * samples_per_frame * bytes_per_sample bytes.
|
||||||
|
"""
|
||||||
|
if not self.get_fastframe_state():
|
||||||
|
raise RuntimeError("FastFrame is not enabled. Enable it with set_fastframe_state(True)")
|
||||||
|
|
||||||
|
expected = frame_count * samples_per_frame * bytes_per_sample
|
||||||
|
if expected <= 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Nothing to transfer: {frame_count} frames × "
|
||||||
|
f"{samples_per_frame} samples × {bytes_per_sample} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.write("CURVe?")
|
||||||
|
|
||||||
|
buf = bytearray()
|
||||||
|
while len(buf) < expected:
|
||||||
|
block = self.read_raw(expected_bytes=expected - len(buf))
|
||||||
|
if not block:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Scope returned an empty block {len(buf)}/{expected} bytes "
|
||||||
|
"into the burst transfer"
|
||||||
|
)
|
||||||
|
buf += block
|
||||||
|
|
||||||
|
if len(buf) != expected:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Burst transfer overran: got {len(buf)} bytes, expected {expected}"
|
||||||
|
)
|
||||||
|
return buf
|
||||||
|
|
||||||
def parse_curve_data(self, curve_bytes, byte_count=1, signed=True, byte_order='MSB'):
|
def parse_curve_data(self, curve_bytes, byte_count=1, signed=True, byte_order='MSB'):
|
||||||
"""Parse raw curve data into integer array."""
|
"""Parse raw curve data into integer array."""
|
||||||
if byte_count not in (1, 2):
|
if byte_count not in (1, 2):
|
||||||
@@ -526,7 +648,19 @@ class TektronixOscilloscopeBase:
|
|||||||
|
|
||||||
return response.decode('ascii').strip()
|
return response.decode('ascii').strip()
|
||||||
|
|
||||||
def read_raw(self):
|
def _recv_exact(self, count):
|
||||||
|
"""Read exactly `count` bytes; recv() is free to return fewer."""
|
||||||
|
chunks = []
|
||||||
|
remaining = count
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = self.socket.recv(min(remaining, 65536))
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("Connection closed while reading data")
|
||||||
|
chunks.append(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
return b''.join(chunks)
|
||||||
|
|
||||||
|
def read_raw(self, expected_bytes=None):
|
||||||
"""
|
"""
|
||||||
Read raw binary data from the instrument.
|
Read raw binary data from the instrument.
|
||||||
|
|
||||||
@@ -535,6 +669,10 @@ class TektronixOscilloscopeBase:
|
|||||||
where N is a digit indicating how many digits follow,
|
where N is a digit indicating how many digits follow,
|
||||||
and those digits specify the length of the data block.
|
and those digits specify the length of the data block.
|
||||||
|
|
||||||
|
`#0` announces an indeterminate-length block, normally delimited by EOI
|
||||||
|
— which a raw socket never sees. Pass expected_bytes to say how many
|
||||||
|
bytes to take in that case.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bytes: Raw binary data (without IEEE 488.2 header)
|
bytes: Raw binary data (without IEEE 488.2 header)
|
||||||
|
|
||||||
@@ -564,27 +702,24 @@ class TektronixOscilloscopeBase:
|
|||||||
|
|
||||||
num_digits = int(length_of_length)
|
num_digits = int(length_of_length)
|
||||||
|
|
||||||
# Read the data length
|
if num_digits == 0:
|
||||||
length_bytes = self.socket.recv(num_digits)
|
# Indeterminate length: no byte count follows, and the EOI that
|
||||||
if len(length_bytes) != num_digits:
|
# would delimit it does not exist on a raw socket.
|
||||||
raise RuntimeError("Failed to read data length")
|
if expected_bytes is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Scope returned an indeterminate-length block (#0); "
|
||||||
|
"read_raw needs expected_bytes to size it over a socket"
|
||||||
|
)
|
||||||
|
data_length = expected_bytes
|
||||||
|
else:
|
||||||
|
data_length = int(self._recv_exact(num_digits))
|
||||||
|
|
||||||
data_length = int(length_bytes)
|
data = self._recv_exact(data_length)
|
||||||
|
|
||||||
# Read the actual binary data
|
# Read the trailing newline / block separator
|
||||||
chunks = []
|
self._recv_exact(1)
|
||||||
remaining = data_length
|
|
||||||
while remaining > 0:
|
|
||||||
chunk = self.socket.recv(min(remaining, 65536))
|
|
||||||
if not chunk:
|
|
||||||
raise RuntimeError("Connection closed while reading data")
|
|
||||||
chunks.append(chunk)
|
|
||||||
remaining -= len(chunk)
|
|
||||||
|
|
||||||
# Read the trailing newline
|
return data
|
||||||
self.socket.recv(1)
|
|
||||||
|
|
||||||
return b''.join(chunks)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_connected(self):
|
def is_connected(self):
|
||||||
|
|||||||
+8
-105
@@ -16,106 +16,9 @@ from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
|
|||||||
from PyQt6.QtGui import QFont
|
from PyQt6.QtGui import QFont
|
||||||
|
|
||||||
from hardware.helios_laser import HeliosLaser, PulseMode
|
from hardware.helios_laser import HeliosLaser, PulseMode
|
||||||
|
from hardware.helios_registers import (
|
||||||
# ---------------------------------------------------------------------------
|
CCE_FLAGS, LCE_FLAGS, LER_FLAGS, SEVERITY_LABEL, decode_register,
|
||||||
# Status register bit definitions (Tables 8-1, 8-2, 8-3 — Helios manual)
|
)
|
||||||
# Each entry: bit_number -> (severity, description, comment)
|
|
||||||
# severity: 'C' = critical error, 'S' = status, 'I' = input error, '' = none
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_LER_FLAGS = {
|
|
||||||
0: ('C', 'Controller temperature failure (resonator/SHG/q-switch)',
|
|
||||||
'Check CCE register for details'),
|
|
||||||
1: ('S', 'Trigger input active',
|
|
||||||
'High when trigger signal applied or laser in continuous pulsing'),
|
|
||||||
2: ('I', 'Command error',
|
|
||||||
'Unknown command sent to controller'),
|
|
||||||
3: ('C', 'Laser disable pin open (utility connector)',
|
|
||||||
'Shuts down pump diodes; reset LER 0 required to restart'),
|
|
||||||
4: ('C', 'Internal hardware failure',
|
|
||||||
'Contact Coherent'),
|
|
||||||
5: ('C', 'Over voltage laser diode',
|
|
||||||
'Check for open circuit or voltage spikes'),
|
|
||||||
6: ('C', 'Internal hardware failure',
|
|
||||||
'Contact Coherent'),
|
|
||||||
7: ('C', 'Controller temperature failure at pump diodes',
|
|
||||||
'Check LCE register for details'),
|
|
||||||
8: ('S', 'Laser start delay (60 s warmup)',
|
|
||||||
'Laser cannot be started yet; status error LED flashing'),
|
|
||||||
9: ('C', 'Internal hardware failure',
|
|
||||||
'Check environment for strong EMI; contact Coherent'),
|
|
||||||
10: ('C', 'Internal hardware failure',
|
|
||||||
'Check environment for strong EMI; contact Coherent'),
|
|
||||||
11: ('C', 'Internal hardware failure',
|
|
||||||
'Check environment for strong EMI; contact Coherent'),
|
|
||||||
12: ('S', 'Slave controller error (remote input)',
|
|
||||||
'Valid only for master controller coupled with a slave'),
|
|
||||||
13: ('', 'Laserhead not found',
|
|
||||||
'Head not connected / not found; check EMI; ignore for double-electronic slave'),
|
|
||||||
14: ('', 'Laserhead I\u00b2C acknowledge error',
|
|
||||||
'Check environment for strong EMI; ignore for double-electronic slave'),
|
|
||||||
15: ('S', 'Range-Error (not critical)',
|
|
||||||
'Input value out of range'),
|
|
||||||
}
|
|
||||||
|
|
||||||
_LCE_FLAGS = {
|
|
||||||
0: ('C', 'Pump diode over/under temperature',
|
|
||||||
'Limit exceeded (<10\u00b0C or >60\u00b0C); check head cooling'),
|
|
||||||
1: ('C', 'Internal hardware failure',
|
|
||||||
'Contact Coherent'),
|
|
||||||
2: ('C', 'Pump diode temperature out of range',
|
|
||||||
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
|
||||||
3: ('C', 'Pump diode current critical',
|
|
||||||
'Current set too close to current limit'),
|
|
||||||
4: ('C', 'Pump diode temperature out of limit',
|
|
||||||
'Pump diode temperature is out of limit'),
|
|
||||||
5: ('S', 'Door switch open',
|
|
||||||
'Close utility connector pin 2 permanently to pin 9 (GND)'),
|
|
||||||
7: ('C', 'Pump diode NTC error',
|
|
||||||
'Invalid temperature measured or NTC broken'),
|
|
||||||
8: ('C', 'Laser diode power stage over temperature',
|
|
||||||
'Temp <10\u00b0C or >65\u00b0C at controller; check controller cooling'),
|
|
||||||
9: ('C', 'Internal hardware failure',
|
|
||||||
'Check environment for strong EMI; contact Coherent'),
|
|
||||||
10: ('C', 'Internal hardware failure',
|
|
||||||
'Check environment for strong EMI; contact Coherent'),
|
|
||||||
11: ('C', 'Internal hardware failure',
|
|
||||||
'Check environment for strong EMI; contact Coherent'),
|
|
||||||
15: ('S', 'Range-Error (not critical)',
|
|
||||||
'Input value out of range'),
|
|
||||||
}
|
|
||||||
|
|
||||||
_CCE_FLAGS = {
|
|
||||||
0: ('C', 'Resonator/SHG under/over temperature',
|
|
||||||
'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'),
|
|
||||||
1: ('C', 'Resonator/SHG NTC failure',
|
|
||||||
'Temperature sensor broken or disconnected'),
|
|
||||||
2: ('C', 'Resonator/SHG temperature out of range',
|
|
||||||
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
|
||||||
3: ('C', 'Q-switch ADC / temperature readout failure',
|
|
||||||
'Internal hardware error or no NTC connected'),
|
|
||||||
4: ('C', 'Q-switch temperature out of range',
|
|
||||||
'Actual temp >2\u00b0C off setpoint for >1 min'),
|
|
||||||
5: ('C', 'Q-switch under/over temperature',
|
|
||||||
'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'),
|
|
||||||
7: ('C', 'Q-switch NTC failure',
|
|
||||||
'Internal hardware error or no NTC connected'),
|
|
||||||
8: ('C', 'Internal hardware failure',
|
|
||||||
'Contact Coherent'),
|
|
||||||
15: ('S', 'Range-Error (not critical)',
|
|
||||||
'Input value out of range'),
|
|
||||||
}
|
|
||||||
|
|
||||||
_SEVERITY_LABEL = {'C': '[CRIT]', 'S': '[STAT]', 'I': '[INPT]', '': '[INFO]'}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_register(flags_dict: dict, value: int) -> list:
|
|
||||||
"""Return list of (bit, severity, description, comment) for each set bit."""
|
|
||||||
active = []
|
|
||||||
for bit, (sev, desc, comment) in flags_dict.items():
|
|
||||||
if value & (1 << bit):
|
|
||||||
active.append((bit, sev, desc, comment))
|
|
||||||
return active
|
|
||||||
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -945,20 +848,20 @@ class HeliosTestApp(QMainWindow):
|
|||||||
# Decode and display individual flags
|
# Decode and display individual flags
|
||||||
lines = []
|
lines = []
|
||||||
for reg_name, value, flags_dict in (
|
for reg_name, value, flags_dict in (
|
||||||
("LER", ler, _LER_FLAGS),
|
("LER", ler, LER_FLAGS),
|
||||||
("LCE", lce, _LCE_FLAGS),
|
("LCE", lce, LCE_FLAGS),
|
||||||
("CCE", cce, _CCE_FLAGS),
|
("CCE", cce, CCE_FLAGS),
|
||||||
):
|
):
|
||||||
if value is None:
|
if value is None:
|
||||||
lines.append(f"{reg_name}: <read error>")
|
lines.append(f"{reg_name}: <read error>")
|
||||||
continue
|
continue
|
||||||
active = _decode_register(flags_dict, value)
|
active = decode_register(flags_dict, value)
|
||||||
if not active:
|
if not active:
|
||||||
lines.append(f"{reg_name} (raw={value}): OK — no flags set")
|
lines.append(f"{reg_name} (raw={value}): OK — no flags set")
|
||||||
else:
|
else:
|
||||||
lines.append(f"{reg_name} (raw={value}):")
|
lines.append(f"{reg_name} (raw={value}):")
|
||||||
for bit, sev, desc, comment in active:
|
for bit, sev, desc, comment in active:
|
||||||
label = _SEVERITY_LABEL.get(sev, '[ ]')
|
label = SEVERITY_LABEL.get(sev, '[ ]')
|
||||||
lines.append(f" {label} bit {bit:2d} ({1 << bit:>5}): {desc}")
|
lines.append(f" {label} bit {bit:2d} ({1 << bit:>5}): {desc}")
|
||||||
lines.append(f" → {comment}")
|
lines.append(f" → {comment}")
|
||||||
self.text_register_decode.setPlainText("\n".join(lines))
|
self.text_register_decode.setPlainText("\n".join(lines))
|
||||||
|
|||||||
Executable
+678
@@ -0,0 +1,678 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SAW Check Viewer — every angle's frequency on one graph.
|
||||||
|
|
||||||
|
Opens a middle-row SAW check (written by the main app's "SAW Quality
|
||||||
|
Check") and plots the peak SAW frequency along each angle's row, all angles
|
||||||
|
on the same axes. ``core.saw_check`` explains why that answers an alignment
|
||||||
|
question: every angle's middle row crosses the same ROI centre, so the angles
|
||||||
|
all measure the same material and a spread between them belongs to the rig.
|
||||||
|
|
||||||
|
Two readings share the window:
|
||||||
|
|
||||||
|
* the main graph — frequency along the row, one curve per angle. Curves
|
||||||
|
that lie on top of each other and run flat are what a well-aligned rig
|
||||||
|
looks like; a curve offset from the rest indicts its angle, and a sloped
|
||||||
|
curve indicts the ROI (tilt or defocus across it, at that angle).
|
||||||
|
* the summary — each angle's median with ±1σ, plotted against angle, plus
|
||||||
|
the same numbers per angle in a table.
|
||||||
|
|
||||||
|
A full scan opens too: the same middle row is pulled out of it, so a scan
|
||||||
|
can be re-examined with the check's own read-out after the fact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QObject
|
||||||
|
from PyQt6.QtGui import QColor
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFrame,
|
||||||
|
QGroupBox, QHBoxLayout, QHeaderView, QLabel, QListWidget, QListWidgetItem,
|
||||||
|
QMainWindow, QMessageBox, QPushButton, QSizePolicy, QSpinBox, QSplitter,
|
||||||
|
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
from matplotlib import colormaps
|
||||||
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from core.saw_check import alignment_summary, frequency_traces
|
||||||
|
from core.sras_analysis import ChannelCalibration
|
||||||
|
from core.sras_format import SrasFile
|
||||||
|
|
||||||
|
RECOMPUTE_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
|
# Angle curve colours, sampled across the sequence so the legend reads as the
|
||||||
|
# progression 0° → 180° rather than as an arbitrary set.
|
||||||
|
ANGLE_CMAP = "viridis"
|
||||||
|
|
||||||
|
VERDICT_STYLE = {
|
||||||
|
"good": ("#1b5e20", "#c8e6c9", "Alignment looks good"),
|
||||||
|
"marginal": ("#7a4f01", "#ffe0b2", "Alignment is marginal"),
|
||||||
|
"poor": ("#7f1d1d", "#ffcdd2", "Alignment needs attention"),
|
||||||
|
}
|
||||||
|
|
||||||
|
X_AXIS_MODES = [
|
||||||
|
("Offset from row centre", "offset"),
|
||||||
|
("Absolute stage X", "absolute"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def angle_colors(n: int) -> list:
|
||||||
|
cmap = colormaps[ANGLE_CMAP]
|
||||||
|
if n <= 1:
|
||||||
|
return [cmap(0.5)]
|
||||||
|
return [cmap(i / (n - 1)) for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def nan_moving_mean(y: np.ndarray, window: int) -> np.ndarray:
|
||||||
|
"""Moving mean over `window` frames that steps over masked pixels.
|
||||||
|
|
||||||
|
A plain convolution would let one NaN swallow a whole window, which on a
|
||||||
|
sparsely-masked row erases most of the trace; this divides by the number
|
||||||
|
of samples that actually contributed instead.
|
||||||
|
"""
|
||||||
|
if window <= 1:
|
||||||
|
return y
|
||||||
|
valid = np.isfinite(y)
|
||||||
|
kernel = np.ones(int(window))
|
||||||
|
num = np.convolve(np.where(valid, y, 0.0), kernel, mode="same")
|
||||||
|
den = np.convolve(valid.astype(float), kernel, mode="same")
|
||||||
|
return np.divide(num, den, out=np.full(num.shape, np.nan), where=den > 0)
|
||||||
|
|
||||||
|
|
||||||
|
class LoadedCheck:
|
||||||
|
"""A parsed check file plus the traces currently computed from it."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self.sras = SrasFile(path)
|
||||||
|
self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
|
||||||
|
# Each angle carries its own background (v7/v11); the read-out pulls
|
||||||
|
# the right one per angle, so the viewer only needs to know whether
|
||||||
|
# there is anything to subtract at all.
|
||||||
|
self.has_background = any(self.sras.background_array(ai) is not None
|
||||||
|
for ai in range(self.sras.header.n_angles))
|
||||||
|
self.traces = []
|
||||||
|
self.summary = None
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
h = self.sras.header
|
||||||
|
kind = (f"v{self.sras.version} SAW check" if self.sras.is_saw_check
|
||||||
|
else f"v{self.sras.version} scan — middle row of each angle")
|
||||||
|
return (f"{self.sras.path.name}\n{kind}\n"
|
||||||
|
f"{h.n_angles} angle(s) · {h.samples_per_frame} samples/frame · "
|
||||||
|
f"{h.sample_rate / 1e9:.2f} GS/s")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.sras.close()
|
||||||
|
|
||||||
|
|
||||||
|
class FnWorker(QObject):
|
||||||
|
"""Runs a callable on a QThread; emits its return value or the error."""
|
||||||
|
finished = pyqtSignal(object)
|
||||||
|
error = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, fn):
|
||||||
|
super().__init__()
|
||||||
|
self._fn = fn
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
self.finished.emit(self._fn())
|
||||||
|
except Exception as exc:
|
||||||
|
self.error.emit(str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
class TraceCanvas(FigureCanvasQTAgg):
|
||||||
|
"""Frequency along the row, one curve per angle, all on one axes."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
fig = Figure(figsize=(8, 5), tight_layout=True)
|
||||||
|
self.ax = fig.add_subplot(111)
|
||||||
|
super().__init__(fig)
|
||||||
|
self.setParent(parent)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
self.clear("Open a SAW check file to begin.")
|
||||||
|
|
||||||
|
def clear(self, message: str):
|
||||||
|
self.ax.clear()
|
||||||
|
self.ax.text(0.5, 0.5, message, ha="center", va="center",
|
||||||
|
transform=self.ax.transAxes, color="#888888")
|
||||||
|
self.ax.set_xticks([])
|
||||||
|
self.ax.set_yticks([])
|
||||||
|
self.draw_idle()
|
||||||
|
|
||||||
|
def plot(self, traces, colors, visible, x_mode, scale, unit, y_label,
|
||||||
|
smoothing, show_median):
|
||||||
|
self.ax.clear()
|
||||||
|
shown = 0
|
||||||
|
for trace, color in zip(traces, colors, strict=True):
|
||||||
|
if not visible.get(trace.angle_idx, True):
|
||||||
|
continue
|
||||||
|
x = trace.offset_mm if x_mode == "offset" else trace.x_mm
|
||||||
|
y = nan_moving_mean(trace.freq_mhz, smoothing) * scale
|
||||||
|
self.ax.plot(x, y, color=color, linewidth=1.0,
|
||||||
|
label=f"{trace.angle_deg:+.1f}° "
|
||||||
|
f"med {trace.median_mhz * scale:.2f}")
|
||||||
|
shown += 1
|
||||||
|
|
||||||
|
if shown == 0:
|
||||||
|
self.clear("No angle selected.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if show_median:
|
||||||
|
medians = [t.median_mhz for t in traces
|
||||||
|
if visible.get(t.angle_idx, True) and t.n_valid]
|
||||||
|
if medians:
|
||||||
|
self.ax.axhline(float(np.median(medians)) * scale, color="#555555",
|
||||||
|
linestyle="--", linewidth=1.0,
|
||||||
|
label="median of shown angles")
|
||||||
|
|
||||||
|
self.ax.set_xlabel("Offset from row centre (mm)" if x_mode == "offset"
|
||||||
|
else "Stage X (mm)")
|
||||||
|
self.ax.set_ylabel(y_label)
|
||||||
|
self.ax.grid(True, alpha=0.25)
|
||||||
|
self.ax.legend(fontsize=7, ncol=2, loc="best", framealpha=0.85)
|
||||||
|
self.draw_idle()
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryCanvas(FigureCanvasQTAgg):
|
||||||
|
"""Each angle's median frequency, ±1σ, against the GR angle."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
fig = Figure(figsize=(8, 2.6), tight_layout=True)
|
||||||
|
self.ax = fig.add_subplot(111)
|
||||||
|
super().__init__(fig)
|
||||||
|
self.setParent(parent)
|
||||||
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||||
|
|
||||||
|
def plot(self, traces, colors, scale, unit):
|
||||||
|
self.ax.clear()
|
||||||
|
usable = [(t, c) for t, c in zip(traces, colors, strict=True) if t.n_valid]
|
||||||
|
if not usable:
|
||||||
|
self.ax.set_xticks([])
|
||||||
|
self.ax.set_yticks([])
|
||||||
|
self.draw_idle()
|
||||||
|
return
|
||||||
|
|
||||||
|
order = sorted(usable, key=lambda tc: tc[0].angle_deg)
|
||||||
|
angles = [t.angle_deg for t, _ in order]
|
||||||
|
medians = np.array([t.median_mhz for t, _ in order]) * scale
|
||||||
|
sigmas = np.array([0.0 if not np.isfinite(t.std_mhz) else t.std_mhz
|
||||||
|
for t, _ in order]) * scale
|
||||||
|
|
||||||
|
self.ax.plot(angles, medians, color="#999999", linewidth=1.0, zorder=1)
|
||||||
|
self.ax.errorbar(angles, medians, yerr=sigmas, fmt="none",
|
||||||
|
ecolor="#999999", capsize=3, zorder=2)
|
||||||
|
for (_, color), angle, median in zip(order, angles, medians, strict=True):
|
||||||
|
self.ax.plot([angle], [median], marker="o", markersize=6,
|
||||||
|
color=color, zorder=3)
|
||||||
|
self.ax.axhline(float(np.median(medians)), color="#555555",
|
||||||
|
linestyle="--", linewidth=1.0)
|
||||||
|
|
||||||
|
self.ax.set_xlabel("GR angle (deg)")
|
||||||
|
self.ax.set_ylabel(f"Median ({unit})")
|
||||||
|
self.ax.grid(True, alpha=0.25)
|
||||||
|
self.draw_idle()
|
||||||
|
|
||||||
|
|
||||||
|
class SawCheckWindow(QMainWindow):
|
||||||
|
"""Left: what to compute and what to show. Right: the graphs."""
|
||||||
|
|
||||||
|
TABLE_COLUMNS = ["Angle (°)", "Y (mm)", "Median", "σ", "Drift (/mm)", "Valid (%)"]
|
||||||
|
|
||||||
|
def __init__(self, initial_path: str | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("SAW Check Viewer")
|
||||||
|
self.resize(1280, 860)
|
||||||
|
|
||||||
|
self._check: LoadedCheck | None = None
|
||||||
|
self._colors: list = []
|
||||||
|
self._visible: dict[int, bool] = {}
|
||||||
|
self._compute_thread: QThread | None = None
|
||||||
|
self._compute_worker: FnWorker | None = None
|
||||||
|
self._pending_recompute = False
|
||||||
|
|
||||||
|
self._debounce = QTimer(self)
|
||||||
|
self._debounce.setSingleShot(True)
|
||||||
|
self._debounce.setInterval(RECOMPUTE_DEBOUNCE_MS)
|
||||||
|
self._debounce.timeout.connect(self._recompute)
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
if initial_path:
|
||||||
|
self._load(Path(initial_path))
|
||||||
|
|
||||||
|
# ── Layout ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||||
|
splitter.addWidget(self._build_controls())
|
||||||
|
splitter.addWidget(self._build_plots())
|
||||||
|
splitter.setStretchFactor(0, 0)
|
||||||
|
splitter.setStretchFactor(1, 1)
|
||||||
|
splitter.setSizes([340, 940])
|
||||||
|
self.setCentralWidget(splitter)
|
||||||
|
|
||||||
|
def _build_controls(self) -> QWidget:
|
||||||
|
panel = QWidget(self)
|
||||||
|
layout = QVBoxLayout(panel)
|
||||||
|
|
||||||
|
# File
|
||||||
|
grp_file = QGroupBox("File")
|
||||||
|
fl = QVBoxLayout(grp_file)
|
||||||
|
self.btn_open = QPushButton("Open SAW Check…")
|
||||||
|
self.btn_open.clicked.connect(self._on_open)
|
||||||
|
fl.addWidget(self.btn_open)
|
||||||
|
self.lbl_file = QLabel("No file loaded.")
|
||||||
|
self.lbl_file.setWordWrap(True)
|
||||||
|
self.lbl_file.setStyleSheet("color: #666; font-size: 11px;")
|
||||||
|
fl.addWidget(self.lbl_file)
|
||||||
|
layout.addWidget(grp_file)
|
||||||
|
|
||||||
|
# Analysis — anything here changes the numbers, so it recomputes.
|
||||||
|
self.grp_analysis = QGroupBox("Analysis")
|
||||||
|
al = QVBoxLayout(self.grp_analysis)
|
||||||
|
|
||||||
|
thr_row = QHBoxLayout()
|
||||||
|
thr_row.addWidget(QLabel("CH4 DC threshold:"))
|
||||||
|
self.spin_threshold_mv = QDoubleSpinBox()
|
||||||
|
self.spin_threshold_mv.setRange(-500.0, 500.0)
|
||||||
|
self.spin_threshold_mv.setDecimals(1)
|
||||||
|
self.spin_threshold_mv.setSingleStep(5.0)
|
||||||
|
self.spin_threshold_mv.setSuffix(" mV")
|
||||||
|
self.spin_threshold_mv.setValue(50.0)
|
||||||
|
self.spin_threshold_mv.setToolTip(
|
||||||
|
"Pixels whose CH4 DC mean falls below this are dropped from the "
|
||||||
|
"trace — the detection beam was off the sample or out of focus there."
|
||||||
|
)
|
||||||
|
self.spin_threshold_mv.valueChanged.connect(self._queue_recompute)
|
||||||
|
thr_row.addWidget(self.spin_threshold_mv)
|
||||||
|
al.addLayout(thr_row)
|
||||||
|
|
||||||
|
self.chk_bg_sub = QCheckBox("Subtract background waveform")
|
||||||
|
self.chk_bg_sub.setChecked(True)
|
||||||
|
self.chk_bg_sub.toggled.connect(self._queue_recompute)
|
||||||
|
al.addWidget(self.chk_bg_sub)
|
||||||
|
|
||||||
|
self.chk_gate = QCheckBox("Time gate before FFT")
|
||||||
|
self.chk_gate.toggled.connect(self._on_gate_toggled)
|
||||||
|
al.addWidget(self.chk_gate)
|
||||||
|
|
||||||
|
gate_row = QHBoxLayout()
|
||||||
|
gate_row.addWidget(QLabel("Start:"))
|
||||||
|
self.spin_gate_start = QDoubleSpinBox()
|
||||||
|
self.spin_gate_start.setRange(0.0, 100000.0)
|
||||||
|
self.spin_gate_start.setDecimals(1)
|
||||||
|
self.spin_gate_start.setSingleStep(10.0)
|
||||||
|
self.spin_gate_start.setSuffix(" ns")
|
||||||
|
self.spin_gate_start.setValue(50.0)
|
||||||
|
self.spin_gate_start.setEnabled(False)
|
||||||
|
self.spin_gate_start.valueChanged.connect(self._queue_recompute)
|
||||||
|
gate_row.addWidget(self.spin_gate_start)
|
||||||
|
gate_row.addWidget(QLabel("End:"))
|
||||||
|
self.spin_gate_end = QDoubleSpinBox()
|
||||||
|
self.spin_gate_end.setRange(0.0, 100000.0)
|
||||||
|
self.spin_gate_end.setDecimals(1)
|
||||||
|
self.spin_gate_end.setSingleStep(10.0)
|
||||||
|
self.spin_gate_end.setSuffix(" ns")
|
||||||
|
self.spin_gate_end.setValue(200.0)
|
||||||
|
self.spin_gate_end.setEnabled(False)
|
||||||
|
self.spin_gate_end.valueChanged.connect(self._queue_recompute)
|
||||||
|
gate_row.addWidget(self.spin_gate_end)
|
||||||
|
al.addLayout(gate_row)
|
||||||
|
layout.addWidget(self.grp_analysis)
|
||||||
|
|
||||||
|
# Display — cheap, so these only redraw.
|
||||||
|
grp_display = QGroupBox("Display")
|
||||||
|
dl = QVBoxLayout(grp_display)
|
||||||
|
|
||||||
|
x_row = QHBoxLayout()
|
||||||
|
x_row.addWidget(QLabel("X axis:"))
|
||||||
|
self.combo_x = QComboBox()
|
||||||
|
for label, _ in X_AXIS_MODES:
|
||||||
|
self.combo_x.addItem(label)
|
||||||
|
self.combo_x.setToolTip(
|
||||||
|
"Every angle's row is centred on the same ROI centre, so offset "
|
||||||
|
"puts the angles over the same piece of sample; absolute shows "
|
||||||
|
"where each rotated bounding box actually sat on the stage."
|
||||||
|
)
|
||||||
|
self.combo_x.currentIndexChanged.connect(self._redraw)
|
||||||
|
x_row.addWidget(self.combo_x)
|
||||||
|
dl.addLayout(x_row)
|
||||||
|
|
||||||
|
y_row = QHBoxLayout()
|
||||||
|
y_row.addWidget(QLabel("Y axis:"))
|
||||||
|
self.combo_y = QComboBox()
|
||||||
|
self.combo_y.addItems(["Frequency (MHz)", "Velocity (m/s)"])
|
||||||
|
self.combo_y.currentIndexChanged.connect(self._on_y_mode_changed)
|
||||||
|
y_row.addWidget(self.combo_y)
|
||||||
|
dl.addLayout(y_row)
|
||||||
|
|
||||||
|
grat_row = QHBoxLayout()
|
||||||
|
grat_row.addWidget(QLabel("Grating:"))
|
||||||
|
self.spin_grating_um = QDoubleSpinBox()
|
||||||
|
self.spin_grating_um.setRange(0.1, 1000.0)
|
||||||
|
self.spin_grating_um.setDecimals(2)
|
||||||
|
self.spin_grating_um.setSingleStep(0.5)
|
||||||
|
self.spin_grating_um.setSuffix(" µm")
|
||||||
|
self.spin_grating_um.setValue(12.5)
|
||||||
|
self.spin_grating_um.setEnabled(False)
|
||||||
|
self.spin_grating_um.setToolTip("v (m/s) = freq (MHz) × grating (µm)")
|
||||||
|
self.spin_grating_um.valueChanged.connect(self._redraw)
|
||||||
|
grat_row.addWidget(self.spin_grating_um)
|
||||||
|
dl.addLayout(grat_row)
|
||||||
|
|
||||||
|
smooth_row = QHBoxLayout()
|
||||||
|
smooth_row.addWidget(QLabel("Smoothing:"))
|
||||||
|
self.spin_smoothing = QSpinBox()
|
||||||
|
self.spin_smoothing.setRange(1, 2001)
|
||||||
|
self.spin_smoothing.setSingleStep(10)
|
||||||
|
self.spin_smoothing.setSuffix(" frames")
|
||||||
|
self.spin_smoothing.setValue(1)
|
||||||
|
self.spin_smoothing.setToolTip(
|
||||||
|
"Moving average along the row, masked pixels skipped. Display "
|
||||||
|
"only — the table's statistics always use the unsmoothed trace."
|
||||||
|
)
|
||||||
|
self.spin_smoothing.valueChanged.connect(self._redraw)
|
||||||
|
smooth_row.addWidget(self.spin_smoothing)
|
||||||
|
dl.addLayout(smooth_row)
|
||||||
|
|
||||||
|
self.chk_median_line = QCheckBox("Show median of shown angles")
|
||||||
|
self.chk_median_line.setChecked(True)
|
||||||
|
self.chk_median_line.toggled.connect(self._redraw)
|
||||||
|
dl.addWidget(self.chk_median_line)
|
||||||
|
layout.addWidget(grp_display)
|
||||||
|
|
||||||
|
# Angles
|
||||||
|
grp_angles = QGroupBox("Angles")
|
||||||
|
gl = QVBoxLayout(grp_angles)
|
||||||
|
self.list_angles = QListWidget()
|
||||||
|
self.list_angles.setMaximumHeight(190)
|
||||||
|
self.list_angles.itemChanged.connect(self._on_angle_toggled)
|
||||||
|
gl.addWidget(self.list_angles)
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_all = QPushButton("All")
|
||||||
|
btn_all.clicked.connect(lambda: self._set_all_angles(True))
|
||||||
|
btn_none = QPushButton("None")
|
||||||
|
btn_none.clicked.connect(lambda: self._set_all_angles(False))
|
||||||
|
btn_row.addWidget(btn_all)
|
||||||
|
btn_row.addWidget(btn_none)
|
||||||
|
gl.addLayout(btn_row)
|
||||||
|
layout.addWidget(grp_angles)
|
||||||
|
|
||||||
|
# Verdict
|
||||||
|
self.lbl_verdict = QLabel("—")
|
||||||
|
self.lbl_verdict.setWordWrap(True)
|
||||||
|
self.lbl_verdict.setFrameShape(QFrame.Shape.StyledPanel)
|
||||||
|
self.lbl_verdict.setMinimumHeight(92)
|
||||||
|
self.lbl_verdict.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
layout.addWidget(self.lbl_verdict)
|
||||||
|
|
||||||
|
self.lbl_status = QLabel("")
|
||||||
|
self.lbl_status.setStyleSheet("color: #666; font-size: 11px;")
|
||||||
|
layout.addWidget(self.lbl_status)
|
||||||
|
|
||||||
|
layout.addStretch(1)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def _build_plots(self) -> QWidget:
|
||||||
|
splitter = QSplitter(Qt.Orientation.Vertical, self)
|
||||||
|
|
||||||
|
top = QWidget()
|
||||||
|
tl = QVBoxLayout(top)
|
||||||
|
tl.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.trace_canvas = TraceCanvas(top)
|
||||||
|
tl.addWidget(NavigationToolbar2QT(self.trace_canvas, top))
|
||||||
|
tl.addWidget(self.trace_canvas)
|
||||||
|
splitter.addWidget(top)
|
||||||
|
|
||||||
|
tabs = QTabWidget()
|
||||||
|
self.summary_canvas = SummaryCanvas(tabs)
|
||||||
|
tabs.addTab(self.summary_canvas, "Frequency vs angle")
|
||||||
|
|
||||||
|
self.table = QTableWidget(0, len(self.TABLE_COLUMNS))
|
||||||
|
self.table.setHorizontalHeaderLabels(self.TABLE_COLUMNS)
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(
|
||||||
|
QHeaderView.ResizeMode.Stretch)
|
||||||
|
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
tabs.addTab(self.table, "Per-angle statistics")
|
||||||
|
splitter.addWidget(tabs)
|
||||||
|
|
||||||
|
splitter.setStretchFactor(0, 3)
|
||||||
|
splitter.setStretchFactor(1, 1)
|
||||||
|
# Stretch factors alone leave the summary too short to fit its own
|
||||||
|
# axis label on first show; give it a real starting height.
|
||||||
|
splitter.setSizes([540, 300])
|
||||||
|
return splitter
|
||||||
|
|
||||||
|
# ── Loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_open(self):
|
||||||
|
start = str(self._check.sras.path.parent) if self._check else ""
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, "Open SAW Check File", start, "SRAS Files (*.sras)")
|
||||||
|
if path:
|
||||||
|
self._load(Path(path))
|
||||||
|
|
||||||
|
def _load(self, path: Path):
|
||||||
|
try:
|
||||||
|
check = LoadedCheck(path)
|
||||||
|
except Exception as exc:
|
||||||
|
QMessageBox.critical(self, "Cannot Open File",
|
||||||
|
f"Could not read {path.name}:\n\n{exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._check is not None:
|
||||||
|
self._check.close()
|
||||||
|
self._check = check
|
||||||
|
self.setWindowTitle(f"SAW Check Viewer — {path.name}")
|
||||||
|
self.lbl_file.setText(check.describe())
|
||||||
|
self.chk_bg_sub.setEnabled(check.has_background)
|
||||||
|
|
||||||
|
if not check.sras.is_saw_check:
|
||||||
|
self.lbl_status.setText(
|
||||||
|
"Not a SAW check file — reading the middle row of each angle "
|
||||||
|
"out of this scan instead.")
|
||||||
|
else:
|
||||||
|
self.lbl_status.setText("")
|
||||||
|
|
||||||
|
self._colors = angle_colors(check.sras.header.n_angles)
|
||||||
|
self._visible = {i: True for i in range(check.sras.header.n_angles)}
|
||||||
|
self._recompute()
|
||||||
|
|
||||||
|
# ── Compute ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _queue_recompute(self):
|
||||||
|
if self._check is not None:
|
||||||
|
self._debounce.start()
|
||||||
|
|
||||||
|
def _on_gate_toggled(self, enabled: bool):
|
||||||
|
self.spin_gate_start.setEnabled(enabled)
|
||||||
|
self.spin_gate_end.setEnabled(enabled)
|
||||||
|
self._queue_recompute()
|
||||||
|
|
||||||
|
def _recompute(self):
|
||||||
|
if self._check is None:
|
||||||
|
return
|
||||||
|
if self._compute_thread is not None and self._compute_thread.isRunning():
|
||||||
|
# One worker owns the mmap at a time; fold this request into the
|
||||||
|
# one already in flight rather than racing it.
|
||||||
|
self._pending_recompute = True
|
||||||
|
return
|
||||||
|
|
||||||
|
check = self._check
|
||||||
|
gated = self.chk_gate.isChecked()
|
||||||
|
kwargs = dict(
|
||||||
|
dc_threshold_mv=self.spin_threshold_mv.value(),
|
||||||
|
subtract_background=self.chk_bg_sub.isChecked(),
|
||||||
|
gate_start_ns=self.spin_gate_start.value() if gated else None,
|
||||||
|
gate_end_ns=self.spin_gate_end.value() if gated else None,
|
||||||
|
calib=check.calib,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.grp_analysis.setEnabled(False)
|
||||||
|
self.lbl_status.setText("Computing frequency traces …")
|
||||||
|
|
||||||
|
self._compute_thread = QThread(self)
|
||||||
|
self._compute_worker = FnWorker(
|
||||||
|
lambda: frequency_traces(check.sras, **kwargs))
|
||||||
|
self._compute_worker.moveToThread(self._compute_thread)
|
||||||
|
self._compute_thread.started.connect(self._compute_worker.run)
|
||||||
|
self._compute_worker.finished.connect(self._on_traces_ready)
|
||||||
|
self._compute_worker.error.connect(self._on_compute_error)
|
||||||
|
self._compute_thread.start()
|
||||||
|
|
||||||
|
def _finish_compute(self):
|
||||||
|
if self._compute_thread is not None:
|
||||||
|
self._compute_thread.quit()
|
||||||
|
self._compute_thread.wait(5000)
|
||||||
|
self._compute_thread = None
|
||||||
|
self._compute_worker = None
|
||||||
|
self.grp_analysis.setEnabled(True)
|
||||||
|
if self._pending_recompute:
|
||||||
|
self._pending_recompute = False
|
||||||
|
self._queue_recompute()
|
||||||
|
|
||||||
|
def _on_compute_error(self, message: str):
|
||||||
|
self._finish_compute()
|
||||||
|
self.lbl_status.setText("")
|
||||||
|
QMessageBox.critical(self, "Analysis Failed", message)
|
||||||
|
|
||||||
|
def _on_traces_ready(self, traces):
|
||||||
|
self._finish_compute()
|
||||||
|
if self._check is None:
|
||||||
|
return
|
||||||
|
self._check.traces = traces
|
||||||
|
self._check.summary = alignment_summary(traces)
|
||||||
|
self.lbl_status.setText(
|
||||||
|
f"{len(traces)} of {self._check.sras.header.n_angles} angle(s) "
|
||||||
|
f"produced a trace.")
|
||||||
|
self._rebuild_angle_list()
|
||||||
|
self._redraw()
|
||||||
|
|
||||||
|
# ── Display ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _scale(self) -> tuple[float, str, str]:
|
||||||
|
"""Display factor, unit and axis label.
|
||||||
|
|
||||||
|
The file only ever holds a frequency; velocity is that frequency times
|
||||||
|
the grating period, applied at display time so switching units never
|
||||||
|
costs a recompute.
|
||||||
|
"""
|
||||||
|
if self.combo_y.currentIndex() == 1:
|
||||||
|
return self.spin_grating_um.value(), "m/s", "SAW velocity (m/s)"
|
||||||
|
return 1.0, "MHz", "Peak SAW frequency (MHz)"
|
||||||
|
|
||||||
|
def _on_y_mode_changed(self):
|
||||||
|
self.spin_grating_um.setEnabled(self.combo_y.currentIndex() == 1)
|
||||||
|
self._redraw()
|
||||||
|
|
||||||
|
def _rebuild_angle_list(self):
|
||||||
|
self.list_angles.blockSignals(True)
|
||||||
|
self.list_angles.clear()
|
||||||
|
for trace in self._check.traces:
|
||||||
|
item = QListWidgetItem(
|
||||||
|
f"{trace.angle_deg:+7.2f}° Y={trace.y_mm:.3f} mm")
|
||||||
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(
|
||||||
|
Qt.CheckState.Checked if self._visible.get(trace.angle_idx, True)
|
||||||
|
else Qt.CheckState.Unchecked)
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, trace.angle_idx)
|
||||||
|
r, g, b, _ = self._colors[trace.angle_idx]
|
||||||
|
item.setForeground(QColor(int(r * 255), int(g * 255), int(b * 255)))
|
||||||
|
self.list_angles.addItem(item)
|
||||||
|
self.list_angles.blockSignals(False)
|
||||||
|
|
||||||
|
def _on_angle_toggled(self, item: QListWidgetItem):
|
||||||
|
self._visible[item.data(Qt.ItemDataRole.UserRole)] = (
|
||||||
|
item.checkState() == Qt.CheckState.Checked)
|
||||||
|
self._redraw()
|
||||||
|
|
||||||
|
def _set_all_angles(self, visible: bool):
|
||||||
|
self.list_angles.blockSignals(True)
|
||||||
|
for row in range(self.list_angles.count()):
|
||||||
|
item = self.list_angles.item(row)
|
||||||
|
item.setCheckState(Qt.CheckState.Checked if visible
|
||||||
|
else Qt.CheckState.Unchecked)
|
||||||
|
self._visible[item.data(Qt.ItemDataRole.UserRole)] = visible
|
||||||
|
self.list_angles.blockSignals(False)
|
||||||
|
self._redraw()
|
||||||
|
|
||||||
|
def _redraw(self):
|
||||||
|
if self._check is None or not self._check.traces:
|
||||||
|
self.trace_canvas.clear("No angle in this file has data on disk.")
|
||||||
|
return
|
||||||
|
traces = self._check.traces
|
||||||
|
colors = [self._colors[t.angle_idx] for t in traces]
|
||||||
|
scale, unit, y_label = self._scale()
|
||||||
|
|
||||||
|
self.trace_canvas.plot(
|
||||||
|
traces, colors, self._visible,
|
||||||
|
X_AXIS_MODES[self.combo_x.currentIndex()][1], scale, unit, y_label,
|
||||||
|
self.spin_smoothing.value(), self.chk_median_line.isChecked())
|
||||||
|
self.summary_canvas.plot(traces, colors, scale, unit)
|
||||||
|
self._fill_table(traces, scale, unit)
|
||||||
|
self._show_verdict(scale, unit)
|
||||||
|
|
||||||
|
def _fill_table(self, traces, scale: float, unit: str):
|
||||||
|
headers = list(self.TABLE_COLUMNS)
|
||||||
|
headers[2] = f"Median ({unit})"
|
||||||
|
headers[3] = f"σ ({unit})"
|
||||||
|
headers[4] = f"Drift ({unit}/mm)"
|
||||||
|
self.table.setHorizontalHeaderLabels(headers)
|
||||||
|
|
||||||
|
self.table.setRowCount(len(traces))
|
||||||
|
for row, trace in enumerate(traces):
|
||||||
|
values = [
|
||||||
|
f"{trace.angle_deg:+.2f}",
|
||||||
|
f"{trace.y_mm:.3f}",
|
||||||
|
f"{trace.median_mhz * scale:.3f}",
|
||||||
|
f"{trace.std_mhz * scale:.3f}",
|
||||||
|
f"{trace.drift_mhz_per_mm * scale:+.4f}",
|
||||||
|
f"{trace.valid_fraction * 100:.1f}",
|
||||||
|
]
|
||||||
|
for col, text in enumerate(values):
|
||||||
|
item = QTableWidgetItem(text)
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignRight
|
||||||
|
| Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
if col == 0:
|
||||||
|
r, g, b, _ = self._colors[trace.angle_idx]
|
||||||
|
item.setForeground(QColor(int(r * 255), int(g * 255), int(b * 255)))
|
||||||
|
self.table.setItem(row, col, item)
|
||||||
|
|
||||||
|
def _show_verdict(self, scale: float, unit: str):
|
||||||
|
summary = self._check.summary
|
||||||
|
fg, bg, headline = VERDICT_STYLE[summary.level]
|
||||||
|
detail = summary.describe()
|
||||||
|
if scale != 1.0 and summary.n_angles:
|
||||||
|
detail += (f"\nIn {unit}: spread {summary.spread_mhz * scale:.3f} "
|
||||||
|
f"about {summary.median_mhz * scale:.1f}.")
|
||||||
|
self.lbl_verdict.setText(f"{headline}\n\n{detail}")
|
||||||
|
self.lbl_verdict.setStyleSheet(
|
||||||
|
f"color: {fg}; background: {bg}; padding: 8px; font-size: 11px;")
|
||||||
|
|
||||||
|
# ── Teardown ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
self._debounce.stop()
|
||||||
|
if self._compute_thread is not None:
|
||||||
|
self._compute_thread.quit()
|
||||||
|
self._compute_thread.wait(5000)
|
||||||
|
if self._check is not None:
|
||||||
|
self._check.close()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
window = SawCheckWindow(sys.argv[1] if len(sys.argv) > 1 else None)
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -78,6 +78,16 @@
|
|||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="uc480_auto_align_btn">
|
||||||
|
<property name="text">
|
||||||
|
<string>Auto-Align…</string>
|
||||||
|
</property>
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Level the sample: step the stage 1.5 mm each way and re-tilt the T-axes until the DC bias levels read what they read here.</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QPushButton" name="uc480_close_window_btn">
|
<widget class="QPushButton" name="uc480_close_window_btn">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
|
|||||||
@@ -109,6 +109,29 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="helios_current_readback_label">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>The diode current the controller reports. The spin box is the value that will be sent when Set is pressed.</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>laser: --- mA</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="horizontalSpacer_current">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -997,6 +997,46 @@
|
|||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="burst_mode_check">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Acquire as many whole rows per FastFrame acquisition as the scope can hold, and transfer each burst in one CURVe? transaction. The stage trigger output is gated off for the flyback between rows.</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Burst acquisition (multi-row FastFrame)</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QCheckBox" name="strict_rows_check">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Stop the scan if a row does not acquire the expected number of frames, instead of zero-padding a short row or truncating a long one. Use for data runs where a silently squared-up row would be worse than a failed scan.</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Strict row packing (abort on frame-count mismatch)</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="saw_check_btn">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Acquire one row per angle — the row-wise middle of the ROI — and save it as a v10 .sras SAW check. Costs one row-time per angle instead of a full scan, and every angle's row crosses the same ROI centre, so the per-angle frequencies can be compared in the SAW Check Viewer to judge the alignment.</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>SAW Quality Check…</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="inspect_angles_btn">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Rotate through the angles of the scan currently entered, parking at a random point in each so the SAW response can be checked on the oscilloscope before committing to the run.</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Inspect Angles…</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QPushButton" name="start_scan_btn">
|
<widget class="QPushButton" name="start_scan_btn">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
@@ -1056,6 +1096,7 @@
|
|||||||
<tabstop>bbd_set_current_start_btn</tabstop>
|
<tabstop>bbd_set_current_start_btn</tabstop>
|
||||||
<tabstop>bbd_set_delta_current_btn</tabstop>
|
<tabstop>bbd_set_delta_current_btn</tabstop>
|
||||||
<tabstop>show_camera_toggle</tabstop>
|
<tabstop>show_camera_toggle</tabstop>
|
||||||
|
<tabstop>saw_check_btn</tabstop>
|
||||||
<tabstop>start_scan_btn</tabstop>
|
<tabstop>start_scan_btn</tabstop>
|
||||||
</tabstops>
|
</tabstops>
|
||||||
<resources/>
|
<resources/>
|
||||||
|
|||||||
+795
-27
File diff suppressed because it is too large
Load Diff
+184
-34
@@ -1,8 +1,25 @@
|
|||||||
# SRAS Scan Binary Format — Version 6
|
# SRAS Scan Binary Format — Versions 7 and 11 (reading 6 and 10)
|
||||||
|
|
||||||
Each `.sras` file contains **one complete scan**: all GR rotation angles and all
|
Each `.sras` file contains **one complete scan**: all GR rotation angles and all
|
||||||
Y rows. Files are named `{prefix}.sras`.
|
Y rows. Files are named `{prefix}.sras`.
|
||||||
|
|
||||||
|
Two versions are written, sharing this layout byte for byte — only the version
|
||||||
|
field differs, and with it what the file means:
|
||||||
|
|
||||||
|
| Version | Meaning | Rows per angle |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| 7 | A full scan. | Whatever the ROI needs. |
|
||||||
|
| 11 | A middle-row SAW quality check (`{prefix}-sawcheck.sras`). | Exactly 1. |
|
||||||
|
|
||||||
|
See [SAW Quality Check (v11)](#saw-quality-check-v11) below.
|
||||||
|
|
||||||
|
**Versions 6 and 10** are the same two files as they were written before each
|
||||||
|
angle carried its own background: a single background block sat between the
|
||||||
|
preambles and the data block, and the data block held nothing but rows. They
|
||||||
|
are still read — see [Legacy layout (v6/v10)](#legacy-layout-v6v10) — but
|
||||||
|
nothing writes them any more, and a v6 file cannot be resumed into, since the
|
||||||
|
background block a resumed angle writes has no room in it.
|
||||||
|
|
||||||
Starting in v6, each angle only scans the **bounding box of the nominal ROI
|
Starting in v6, each angle only scans the **bounding box of the nominal ROI
|
||||||
rotated by that specific angle** — not the worst case across all angles — so
|
rotated by that specific angle** — not the worst case across all angles — so
|
||||||
`x_start`, `x_delta` (and therefore `n_frames`, the points/row count) and
|
`x_start`, `x_delta` (and therefore `n_frames`, the points/row count) and
|
||||||
@@ -20,10 +37,14 @@ instead of forcing every angle to the largest bounding box.
|
|||||||
[Per-Angle Geometry Table— n_angles × 14 bytes (x_start f32, x_delta f32, n_frames u32, n_rows u16)]
|
[Per-Angle Geometry Table— n_angles × 14 bytes (x_start f32, x_delta f32, n_frames u32, n_rows u16)]
|
||||||
[Row Table (ragged) — sum(n_rows) × 4 bytes (float32 per row, angle-major)]
|
[Row Table (ragged) — sum(n_rows) × 4 bytes (float32 per row, angle-major)]
|
||||||
[Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)]
|
[Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)]
|
||||||
|
[Data Block (ragged) — per angle: [Background Block][Waveform Data]]
|
||||||
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
|
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
|
||||||
[Waveform Data (ragged) — per angle: n_rows[a] × n_channels × n_frames[a] × samples_per_frame × bps bytes]
|
[Waveform Data — n_rows[a] × n_channels × n_frames[a] × samples_per_frame × bps bytes]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
So the data block reads `[background][scan][background][scan] …`, one pair per
|
||||||
|
angle, in angle-table order.
|
||||||
|
|
||||||
All multi-byte integers and floats use **big-endian** byte order
|
All multi-byte integers and floats use **big-endian** byte order
|
||||||
(`>` in Python's `struct` module).
|
(`>` in Python's `struct` module).
|
||||||
|
|
||||||
@@ -34,7 +55,7 @@ All multi-byte integers and floats use **big-endian** byte order
|
|||||||
| Offset | Size | Type | Field | Description |
|
| Offset | Size | Type | Field | Description |
|
||||||
|--------|------|-----------|--------------------|--------------------------------------------------|
|
|--------|------|-----------|--------------------|--------------------------------------------------|
|
||||||
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
|
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
|
||||||
| 4 | 1 | `uint8` | `version` | Format version — `6` |
|
| 4 | 1 | `uint8` | `version` | Format version — `7` (scan) or `11` (SAW check) |
|
||||||
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
|
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
|
||||||
| 7 | 4 | `float32` | `x_start_nominal` | Nominal (pre-rotation) X scan start, mm |
|
| 7 | 4 | `float32` | `x_start_nominal` | Nominal (pre-rotation) X scan start, mm |
|
||||||
| 11 | 4 | `float32` | `y_start_nominal` | Nominal (pre-rotation) Y scan start, mm |
|
| 11 | 4 | `float32` | `y_start_nominal` | Nominal (pre-rotation) Y scan start, mm |
|
||||||
@@ -117,54 +138,77 @@ to convert raw ADC values to volts.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background Block
|
## Data Block (ragged)
|
||||||
|
|
||||||
Immediately after the preamble blocks: a single CH1 waveform captured with the
|
Immediately after the preamble blocks, and running to the end of the file:
|
||||||
**Helios (generation) laser enabled** and the **Genesis (detection) laser
|
for each angle in angle-table order, that angle's **background block**
|
||||||
disabled**. This provides a noise/background reference for subtraction during
|
followed by that angle's **waveform data**.
|
||||||
post-processing.
|
|
||||||
|
```
|
||||||
|
for angle a in 0 … n_angles-1:
|
||||||
|
uint32 n_bg_samples # background block
|
||||||
|
int8[] bg_data
|
||||||
|
for row in 0 … n_rows[a]-1: # waveform data
|
||||||
|
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
|
||||||
|
for frame in 0 … n_frames[a]-1:
|
||||||
|
samples[0 … samples_per_frame-1] # bps bytes each
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background block
|
||||||
|
|
||||||
|
One CH1 waveform captured with the **Helios (generation) laser enabled** and
|
||||||
|
the **Genesis (detection) laser disabled**, averaged over 1024 shots
|
||||||
|
(`core/scope_sras.py`, `BACKGROUND_AVERAGES`). It is a noise/background
|
||||||
|
reference for subtraction during post-processing.
|
||||||
|
|
||||||
```
|
```
|
||||||
uint32 n_bg_samples — number of samples in the background waveform
|
uint32 n_bg_samples — number of samples in the background waveform
|
||||||
int8[] bg_data — raw ADC samples (same encoding as waveform data)
|
int8[] bg_data — raw ADC samples (same encoding as waveform data)
|
||||||
```
|
```
|
||||||
|
|
||||||
`n_bg_samples` equals `samples_per_frame` under normal acquisition settings.
|
`n_bg_samples` equals `samples_per_frame` under normal acquisition settings,
|
||||||
|
but is **not** assumed to: readers take the per-angle offsets from a walk of
|
||||||
|
the data block, reading each length prefix as they go, rather than from
|
||||||
|
arithmetic over the geometry table alone.
|
||||||
|
|
||||||
---
|
Every angle carries its own. The operator is prompted to switch the Genesis
|
||||||
|
laser off before each angle and back on after the capture, so the reference is
|
||||||
|
taken minutes from the data it will be subtracted from — a multi-angle scan
|
||||||
|
runs for hours, and one background captured at the first angle has drifted by
|
||||||
|
the last. It also makes the angles comparable, which is the entire point of a
|
||||||
|
multi-angle scan: each is referenced against its own noise floor rather than
|
||||||
|
against whichever angle happened to be scanned first.
|
||||||
|
|
||||||
## Waveform Data (ragged)
|
### Waveform data
|
||||||
|
|
||||||
Immediately after the background block. Data is stored in **angle-major,
|
Stored in **angle-major, row-minor** order, and each angle contributes a
|
||||||
row-minor** order, but unlike earlier versions each angle contributes a
|
|
||||||
different number of rows (`n_rows[a]`) and a different number of frames per
|
different number of rows (`n_rows[a]`) and a different number of frames per
|
||||||
row (`n_frames[a]`), both taken from that angle's Per-Angle Geometry Table
|
row (`n_frames[a]`), both taken from that angle's Per-Angle Geometry Table
|
||||||
entry. Within each row, channels are interleaved in ascending channel-index
|
entry. Within each row, channels are interleaved in ascending channel-index
|
||||||
order, with each channel's FastFrame data written in frame order.
|
order, with each channel's FastFrame data written in frame order.
|
||||||
|
|
||||||
```
|
|
||||||
for angle a in 0 … n_angles-1:
|
|
||||||
for row in 0 … n_rows[a]-1:
|
|
||||||
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
|
|
||||||
for frame in 0 … n_frames[a]-1:
|
|
||||||
samples[0 … samples_per_frame-1] # bps bytes each
|
|
||||||
```
|
|
||||||
|
|
||||||
Each sample is a raw signed ADC value. With `bytes_per_sample = 1` this is
|
Each sample is a raw signed ADC value. With `bytes_per_sample = 1` this is
|
||||||
**int8** (−128 … +127). With `bytes_per_sample = 2` this is **big-endian
|
**int8** (−128 … +127). With `bytes_per_sample = 2` this is **big-endian
|
||||||
int16**.
|
int16**.
|
||||||
|
|
||||||
Total data size:
|
Total data-block size:
|
||||||
```
|
```
|
||||||
sum over angles a of: n_rows[a] × 3 × n_frames[a] × samples_per_frame × bytes_per_sample
|
sum over angles a of: 4 + n_bg_samples[a]
|
||||||
|
+ n_rows[a] × 3 × n_frames[a] × samples_per_frame × bytes_per_sample
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Incomplete files:** If a scan is aborted the file is closed immediately and
|
> **Incomplete files:** If a scan is aborted the file is closed immediately and
|
||||||
> the data block will be shorter than the expected size. Readers should
|
> the data block will be shorter than the expected size. Readers should walk
|
||||||
> reconstruct the expected per-angle byte offsets from the Per-Angle Geometry
|
> the data block from its start — background length prefix, then that angle's
|
||||||
> Table and check `file_size` against the running total before reshaping —
|
> declared row bytes from the Per-Angle Geometry Table — checking `file_size`
|
||||||
> a fixed `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not
|
> against the running total before reshaping. A fixed
|
||||||
> work since row/frame counts are no longer uniform across angles.
|
> `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not work since
|
||||||
|
> row/frame counts are no longer uniform across angles.
|
||||||
|
>
|
||||||
|
> An angle whose background block is not fully on disk has nothing of itself
|
||||||
|
> written yet: it is *missing*, not truncated, and the walk continues past it
|
||||||
|
> assuming the block a writer would have produced
|
||||||
|
> (`4 + samples_per_frame` bytes), which is where a resumed scan writes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,19 +227,122 @@ using that angle's `x_start` from the Per-Angle Geometry Table (not
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Acquisition Settings (fixed by sc3_aui_app.py)
|
## Acquisition Settings (fixed by core/scope_sras.py)
|
||||||
|
|
||||||
| Parameter | Value |
|
| Parameter | Value |
|
||||||
|-----------------------|------------------------------|
|
|-----------------------|------------------------------------------|
|
||||||
| Oscilloscope trigger | CH2, rising edge, 1.24 V |
|
| Background trigger | CH2, rising edge, 0.500 V (`TRIG_LEVEL_V`), FastFrame off |
|
||||||
| Trigger offset | 0 % (trigger at left edge) |
|
| Scan trigger | Logic AND, CH2 HIGH ∧ CH3 HIGH, 0.500 V |
|
||||||
|
| Background average | 1024 shots (`BACKGROUND_AVERAGES`), once per angle |
|
||||||
|
| Horizontal position | 30 (`HORizontal:POSition`) |
|
||||||
| Sample rate | 6.25 GS/s (160 ps/sample) |
|
| Sample rate | 6.25 GS/s (160 ps/sample) |
|
||||||
|
| Transfer format | `DATa:ENCdg RIBinary`, `DATa:WIDth 1` |
|
||||||
| Channels recorded | CH1, CH3, CH4 |
|
| Channels recorded | CH1, CH3, CH4 |
|
||||||
| Stage X velocity | 100 mm/s |
|
| Stage X velocity | 100 mm/s |
|
||||||
| Stage X acceleration | 1500 mm/s² |
|
| Stage X acceleration | 1500 mm/s² |
|
||||||
| Stage X trigger out | Logic-high at max velocity |
|
| Stage X trigger out | Logic-high at max velocity (`TRIGOUT_MAXV`) |
|
||||||
| Acquisition mode | FastFrame, Normal trigger |
|
| Acquisition mode | FastFrame, Normal trigger |
|
||||||
|
|
||||||
|
None of these are stored in the file, so they do not affect byte layout — but
|
||||||
|
they do set where the acoustic packet lands inside each frame. Read them from
|
||||||
|
`core/scope_sras.py`; earlier revisions of this table drifted from the code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acquisition Paths
|
||||||
|
|
||||||
|
Two acquisition strategies write **byte-identical** files; the choice is a
|
||||||
|
runtime flag (`ScanEngine(burst_mode=…)`, exposed as a checkbox in the app) and
|
||||||
|
is not recorded in the file.
|
||||||
|
|
||||||
|
| | Per-row (default) | Burst |
|
||||||
|
|---|---|---|
|
||||||
|
| FastFrame acquisitions | one per row | one per `floor(max_frames / n_frames)` rows |
|
||||||
|
| Curve transfers | one per channel per row | one per channel per burst |
|
||||||
|
| Stage X trigger out | armed for the whole scan | armed per acquiring pass, dropped for the flyback |
|
||||||
|
|
||||||
|
Both paths take the same per-angle background: the scope returns to the
|
||||||
|
single-record edge trigger for the capture and back to the logic-AND trigger
|
||||||
|
before the angle's rows, so the two paths still produce byte-identical files.
|
||||||
|
|
||||||
|
Burst mode runs a single acquisition across several rows, so the return move
|
||||||
|
must not trigger: the trigger output is dropped before each flyback and
|
||||||
|
re-armed for each acquiring pass. Row boundaries inside the burst come from
|
||||||
|
`ACQuire:NUMFRAMESACQuired?` sampled after each pass — the burst itself carries
|
||||||
|
no row markers. See `core/scope_burst.py`.
|
||||||
|
|
||||||
|
### Row packing
|
||||||
|
|
||||||
|
The format has no per-row length field, so a row that over- or under-triggers
|
||||||
|
cannot be written as it arrived — that would shift every later row. Two
|
||||||
|
policies are selectable (`ScanEngine(strict_rows=…)`, a checkbox in the app),
|
||||||
|
and the choice is not recorded in the file:
|
||||||
|
|
||||||
|
| | Pad (default) | Strict |
|
||||||
|
|---|---|---|
|
||||||
|
| Short row | zero-padded to `n_frames`, warned | scan stops |
|
||||||
|
| Long row | trailing frames dropped, warned | scan stops |
|
||||||
|
|
||||||
|
Pad keeps a scan running through an occasional mis-trigger, at the cost that
|
||||||
|
the affected row is indistinguishable from a good one afterwards — nothing in
|
||||||
|
the file records that it was padded. Strict is for data runs where that
|
||||||
|
ambiguity is worse than a failed scan: it aborts before writing the row, so
|
||||||
|
the file always ends on a whole-row boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SAW Quality Check (v11)
|
||||||
|
|
||||||
|
A full multi-angle scan takes hours, and a rig whose angles disagree produces
|
||||||
|
all of them before anyone finds out. The SAW quality check acquires **one row
|
||||||
|
per angle — the row-wise middle of the ROI** — and writes it as a v11 file.
|
||||||
|
The cost is one row-time per angle instead of `n_rows` of them.
|
||||||
|
|
||||||
|
Nothing about the byte layout changes. A v11 file is a v7 file in which every
|
||||||
|
angle's Per-Angle Geometry Table entry declares `n_rows = 1`, and its Row Table
|
||||||
|
holds that angle's single middle Y position. Every v7 reader that works from
|
||||||
|
the geometry table (rather than assuming a uniform shape) reads a v11 file
|
||||||
|
unchanged. Each angle still carries its own background, so a check costs the
|
||||||
|
same two operator prompts per angle a scan does.
|
||||||
|
|
||||||
|
The version byte earns its keep because the two are otherwise
|
||||||
|
indistinguishable: **a v7 scan aborted after its first row is not a check**,
|
||||||
|
even though both hold one row per angle. A reader that guessed from the row
|
||||||
|
count would treat a failed scan as a deliberate measurement.
|
||||||
|
|
||||||
|
Why the middle row in particular: `core/scan_geometry.py` centres every
|
||||||
|
angle's rotated bounding box on the same nominal ROI centre, so each angle's
|
||||||
|
middle row crosses that one point on the sample. All the angles therefore
|
||||||
|
measure the same material, and a spread in their SAW frequencies is a property
|
||||||
|
of the rig — which is what makes it an alignment check. `saw_check_viewer.py`
|
||||||
|
plots every angle's frequency on one graph for exactly that comparison.
|
||||||
|
|
||||||
|
Writers must honour the one-row rule; `core.sras_format.create_scan_file`
|
||||||
|
refuses a v11 write for any plan that breaks it. Producing the plan is
|
||||||
|
`core.saw_check.middle_row_plan(plan)`, and `n_rows // 2` is the middle-row
|
||||||
|
rule (the upper of the two central rows when the count is even).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legacy layout (v6/v10)
|
||||||
|
|
||||||
|
A v6 or v10 file differs in one place: the background block sits **once**,
|
||||||
|
between the preamble blocks and the data block, and the data block is
|
||||||
|
waveform data alone.
|
||||||
|
|
||||||
|
```
|
||||||
|
[Preamble Blocks]
|
||||||
|
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
|
||||||
|
[Waveform Data (ragged) — per angle: rows, as above, with no background between]
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything else — header, tables, row order, spatial mapping — is identical,
|
||||||
|
which is why `core.sras_format.SrasFile` reads both: it hands the one legacy
|
||||||
|
background to every angle, so a reader that asks for angle *a*'s background
|
||||||
|
never has to know which layout it is looking at. Nothing writes v6/v10 any
|
||||||
|
more, and a resume refuses them, because a re-acquired angle writes a
|
||||||
|
background block the layout has no room for.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
@@ -208,4 +355,7 @@ using that angle's `x_start` from the Per-Angle Geometry Table (not
|
|||||||
| 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. |
|
| 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. |
|
||||||
| 5 | (skipped) |
|
| 5 | (skipped) |
|
||||||
| 6 | Each angle now scans only the bounding box of the nominal ROI rotated by that angle instead of the AABB-expanded worst case across all angles. Header no longer carries a single global `x_start`/`x_delta`/`n_rows` — replaced with `*_nominal` reference fields plus a new Per-Angle Geometry Table (`x_start`, `x_delta`, `n_frames`, `n_rows` per angle) and a ragged Row Table / Waveform Data block sized per angle. **Not compatible with v4 readers** (e.g. `sras_viewer.py`, which has not yet been updated for v6). |
|
| 6 | Each angle now scans only the bounding box of the nominal ROI rotated by that angle instead of the AABB-expanded worst case across all angles. Header no longer carries a single global `x_start`/`x_delta`/`n_rows` — replaced with `*_nominal` reference fields plus a new Per-Angle Geometry Table (`x_start`, `x_delta`, `n_frames`, `n_rows` per angle) and a ragged Row Table / Waveform Data block sized per angle. **Not compatible with v4 readers** (e.g. `sras_viewer.py`, which has not yet been updated for v6). |
|
||||||
|
| 7 | Background moved into the data block, one per angle: the block now reads `[background][scan][background][scan] …`. Each angle is preceded by its own `uint32` + `int8[]` background, captured (Genesis off, Helios on) just before that angle is scanned, so the reference is contemporary with the data and the angles are comparable to each other. Per-angle offsets therefore come from a walk of the data block rather than arithmetic over the geometry table. **v6 files still read; v6 files cannot be resumed into.** |
|
||||||
|
| 8–9 | (skipped) |
|
||||||
|
| 10 | Middle-row SAW quality check on the v6 layout. Byte layout identical to v6, with every angle declaring exactly one row — the row-wise middle of the ROI. Superseded by v11; still read. |
|
||||||
|
| 11 | Middle-row SAW quality check on the v7 layout: identical to v7 with every angle declaring exactly one row, per-angle backgrounds included. The version byte exists so a check is not confused with a scan aborted after its first row. Written by the main app's *SAW Quality Check*, read by `saw_check_viewer.py`. |
|
||||||
|
|||||||
+42
-20
@@ -1,18 +1,22 @@
|
|||||||
#!/opt/srasenv/bin/python3
|
#!/opt/srasenv/bin/python3
|
||||||
"""
|
"""
|
||||||
SRAS Scan Manager
|
SRAS Scan Manager
|
||||||
Command-line / interactive TUI for inspecting v6 .sras files.
|
Command-line / interactive TUI for inspecting .sras files.
|
||||||
|
|
||||||
A .sras file (see scan_format.md) holds one acquisition run across several
|
A .sras file (see scan_format.md) holds one acquisition run across several
|
||||||
GR rotation angles, each with its own geometry (x_start, x_delta, n_frames,
|
GR rotation angles, each with its own geometry (x_start, x_delta, n_frames,
|
||||||
n_rows) and waveform data block. This tool lists those per-angle sub-scans
|
n_rows), background waveform, and waveform data block. This tool lists those
|
||||||
and lets you export a subset to a new .sras file, or delete a subset from
|
per-angle sub-scans and lets you export a subset to a new .sras file, or
|
||||||
the file in place — both operations rewrite the angle/geometry/row tables
|
delete a subset from the file in place — both operations rewrite the
|
||||||
and stream-copy only the selected angles' waveform data, producing a file
|
angle/geometry/row tables, carry each kept angle's background across, and
|
||||||
that is itself a valid v6 .sras readable by sras_viewer.py-style tools
|
stream-copy only the selected angles' waveform data, producing a file that is
|
||||||
(once updated for v6) or sc3_aui_app.py.
|
itself a valid .sras readable by sras_viewer.py or sc3_aui_app.py.
|
||||||
|
|
||||||
Only format version 6 is supported.
|
Format versions 7 (full scan) and 11 (middle-row SAW check) are supported,
|
||||||
|
as are their pre-per-angle-background predecessors 6 and 10. A subset keeps
|
||||||
|
the version — and therefore the background layout — of the file it came
|
||||||
|
from: a v11 check exports as a v11 check, since dropping angles from one
|
||||||
|
leaves it one row per angle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -24,7 +28,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
from core.sras_format import GEOM_FMT, HDR_FMT, MAGIC, VERSION as BLOB_VERSION, SrasFile
|
from core.sras_format import (
|
||||||
|
BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, SrasFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -37,7 +43,8 @@ class AngleEntry:
|
|||||||
n_rows_declared: int
|
n_rows_declared: int
|
||||||
y_positions: list # declared length; may exceed what's actually on disk
|
y_positions: list # declared length; may exceed what's actually on disk
|
||||||
row_bytes: int
|
row_bytes: int
|
||||||
data_offset: int # byte offset into the file where this angle's data starts
|
background: bytes # this angle's own background (v7/v11)
|
||||||
|
data_offset: int # byte offset into the file where this angle's rows start
|
||||||
n_rows_available: int = 0
|
n_rows_available: int = 0
|
||||||
data_size_available: int = 0
|
data_size_available: int = 0
|
||||||
complete: bool = True
|
complete: bool = True
|
||||||
@@ -48,7 +55,7 @@ class AngleEntry:
|
|||||||
|
|
||||||
|
|
||||||
class SrasScanFile:
|
class SrasScanFile:
|
||||||
"""Parsed view of a v6 .sras file's header/tables plus per-angle data offsets."""
|
"""Parsed view of a .sras file's header/tables plus per-angle data offsets."""
|
||||||
|
|
||||||
def __init__(self, path: Path):
|
def __init__(self, path: Path):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
@@ -57,6 +64,8 @@ class SrasScanFile:
|
|||||||
def _parse(self):
|
def _parse(self):
|
||||||
sras = SrasFile(self.path)
|
sras = SrasFile(self.path)
|
||||||
h = sras.header
|
h = sras.header
|
||||||
|
self.version = sras.version
|
||||||
|
self.is_saw_check = sras.is_saw_check
|
||||||
self.x_start_nominal = h.x_start_nominal
|
self.x_start_nominal = h.x_start_nominal
|
||||||
self.y_start_nominal = h.y_start_nominal
|
self.y_start_nominal = h.y_start_nominal
|
||||||
self.x_delta_nominal = h.x_delta_nominal
|
self.x_delta_nominal = h.x_delta_nominal
|
||||||
@@ -69,7 +78,10 @@ class SrasScanFile:
|
|||||||
self.bytes_per_sample = h.bytes_per_sample
|
self.bytes_per_sample = h.bytes_per_sample
|
||||||
self.n_channels = h.n_channels
|
self.n_channels = h.n_channels
|
||||||
self.preambles_raw = sras.preambles_raw
|
self.preambles_raw = sras.preambles_raw
|
||||||
self.background_raw = sras.background
|
self.legacy_layout = sras.is_legacy_layout
|
||||||
|
# v6/v10 keep one background ahead of the data block; v7/v11 keep one
|
||||||
|
# per angle inside it. Either way sras.backgrounds is per angle.
|
||||||
|
self.shared_background = sras.backgrounds[0] if sras.is_legacy_layout else b""
|
||||||
self.data_start_offset = sras.data_start_offset
|
self.data_start_offset = sras.data_start_offset
|
||||||
self.file_size = sras.file_size
|
self.file_size = sras.file_size
|
||||||
|
|
||||||
@@ -79,12 +91,13 @@ class SrasScanFile:
|
|||||||
x_start=pa.x_start, x_delta=pa.x_delta,
|
x_start=pa.x_start, x_delta=pa.x_delta,
|
||||||
n_frames=pa.n_frames, n_rows_declared=pa.n_rows,
|
n_frames=pa.n_frames, n_rows_declared=pa.n_rows,
|
||||||
y_positions=pa.y_positions, row_bytes=st.row_bytes,
|
y_positions=pa.y_positions, row_bytes=st.row_bytes,
|
||||||
data_offset=st.data_offset,
|
background=bg, data_offset=st.data_offset,
|
||||||
n_rows_available=st.n_rows_available,
|
n_rows_available=st.n_rows_available,
|
||||||
data_size_available=st.n_rows_available * st.row_bytes,
|
data_size_available=st.n_rows_available * st.row_bytes,
|
||||||
complete=st.complete,
|
complete=st.complete,
|
||||||
)
|
)
|
||||||
for pa, st in zip(sras.per_angle, sras.angle_status(), strict=True)
|
for pa, st, bg in zip(sras.per_angle, sras.angle_status(),
|
||||||
|
sras.backgrounds, strict=True)
|
||||||
]
|
]
|
||||||
|
|
||||||
def get(self, index: int) -> AngleEntry:
|
def get(self, index: int) -> AngleEntry:
|
||||||
@@ -96,7 +109,7 @@ class SrasScanFile:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
||||||
"""Write a new v6 .sras file containing only the given angle indices
|
"""Write a new .sras file containing only the given angle indices
|
||||||
(in the given order). Returns a list of warning strings (e.g. for
|
(in the given order). Returns a list of warning strings (e.g. for
|
||||||
angles that were truncated on disk and thus exported with fewer rows
|
angles that were truncated on disk and thus exported with fewer rows
|
||||||
than declared).
|
than declared).
|
||||||
@@ -105,7 +118,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, MAGIC, BLOB_VERSION, len(selected),
|
HDR_FMT, MAGIC, sf.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,
|
||||||
@@ -134,10 +147,18 @@ def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
|||||||
dst.write(struct.pack(">H", len(praw)))
|
dst.write(struct.pack(">H", len(praw)))
|
||||||
dst.write(praw)
|
dst.write(praw)
|
||||||
|
|
||||||
dst.write(struct.pack(">I", len(sf.background_raw)))
|
if sf.legacy_layout:
|
||||||
dst.write(sf.background_raw)
|
dst.write(struct.pack(BG_LEN_FMT, len(sf.shared_background)))
|
||||||
|
dst.write(sf.shared_background)
|
||||||
|
|
||||||
for e in selected:
|
for e in selected:
|
||||||
|
if not sf.legacy_layout:
|
||||||
|
if not e.background:
|
||||||
|
warnings.append(
|
||||||
|
f"angle[{e.index}] ({e.angle_deg:.2f} deg): no background "
|
||||||
|
"on disk — exported with an empty background block")
|
||||||
|
dst.write(struct.pack(BG_LEN_FMT, len(e.background)))
|
||||||
|
dst.write(e.background)
|
||||||
src.seek(e.data_offset)
|
src.seek(e.data_offset)
|
||||||
remaining = e.data_size_available
|
remaining = e.data_size_available
|
||||||
chunk_size = 1 << 20
|
chunk_size = 1 << 20
|
||||||
@@ -221,7 +242,8 @@ def parse_index_spec(spec: str, max_index: int) -> list:
|
|||||||
|
|
||||||
def print_summary(sf: SrasScanFile, selected: set):
|
def print_summary(sf: SrasScanFile, selected: set):
|
||||||
print()
|
print()
|
||||||
print(f"File: {sf.path} (v{BLOB_VERSION}, {_human_size(sf.file_size)})")
|
kind = " SAW check" if sf.is_saw_check else ""
|
||||||
|
print(f"File: {sf.path} (v{sf.version}{kind}, {_human_size(sf.file_size)})")
|
||||||
print(f"Nominal ROI: x_start={sf.x_start_nominal:.4f} x_delta={sf.x_delta_nominal:.4f} "
|
print(f"Nominal ROI: x_start={sf.x_start_nominal:.4f} x_delta={sf.x_delta_nominal:.4f} "
|
||||||
f"y_start={sf.y_start_nominal:.4f} y_delta={sf.y_delta_nominal:.4f} mm "
|
f"y_start={sf.y_start_nominal:.4f} y_delta={sf.y_delta_nominal:.4f} mm "
|
||||||
f"row_spacing={sf.row_spacing_mm:.4f} mm")
|
f"row_spacing={sf.row_spacing_mm:.4f} mm")
|
||||||
@@ -342,7 +364,7 @@ def interactive_loop(path: Path):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description="Inspect, export, or delete per-angle sub-scans in a v6 .sras file.")
|
description="Inspect, export, or delete per-angle sub-scans in a .sras file.")
|
||||||
ap.add_argument("file", type=Path, help="path to a .sras file")
|
ap.add_argument("file", type=Path, help="path to a .sras file")
|
||||||
ap.add_argument("--list", action="store_true", help="print the angle table and exit")
|
ap.add_argument("--list", action="store_true", help="print the angle table and exit")
|
||||||
ap.add_argument("--export", metavar="SPEC", help="angle index spec to export, e.g. '0,2,4-6' or 'all'")
|
ap.add_argument("--export", metavar="SPEC", help="angle index spec to export, e.g. '0,2,4-6' or 'all'")
|
||||||
|
|||||||
+24
-12
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
SRAS Scan File Viewer
|
SRAS Scan File Viewer
|
||||||
PyQt6 application for visualizing channel data from v6 .sras scan files.
|
PyQt6 application for visualizing channel data from .sras scan files.
|
||||||
|
|
||||||
Channel semantics (fixed by sc3_aui_app.py acquisition settings):
|
Channel semantics (fixed by sc3_aui_app.py acquisition settings):
|
||||||
CH1 — RF Acoustic Packet: FFT → peak frequency
|
CH1 — RF Acoustic Packet: FFT → peak frequency
|
||||||
@@ -76,11 +76,18 @@ class LoadedScan:
|
|||||||
def __init__(self, path: str):
|
def __init__(self, path: str):
|
||||||
self.sras = SrasFile(Path(path))
|
self.sras = SrasFile(Path(path))
|
||||||
self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
|
self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
|
||||||
bg = np.frombuffer(self.sras.background, dtype=np.int8)
|
# One background per angle (v7/v11), captured just before that angle
|
||||||
self.background = bg.astype(np.float32) if len(bg) else None
|
# was scanned. A legacy v6/v10 file has a single one, which SrasFile
|
||||||
|
# repeats for every angle, so nothing here branches on the version.
|
||||||
|
self.backgrounds = [self.sras.background_array(ai)
|
||||||
|
for ai in range(self.sras.header.n_angles)]
|
||||||
# Rows actually on disk per angle (aborted/resumed scans)
|
# Rows actually on disk per angle (aborted/resumed scans)
|
||||||
self.rows_available = [s.n_rows_available for s in self.sras.angle_status()]
|
self.rows_available = [s.n_rows_available for s in self.sras.angle_status()]
|
||||||
|
|
||||||
|
def background(self, angle_idx: int) -> np.ndarray | None:
|
||||||
|
"""That angle's background waveform, or None if it has none."""
|
||||||
|
return self.backgrounds[angle_idx]
|
||||||
|
|
||||||
def angle_view(self, angle_idx: int) -> np.ndarray:
|
def angle_view(self, angle_idx: int) -> np.ndarray:
|
||||||
"""Available rows of one angle: (rows, n_ch, n_frames, spf) int8 view."""
|
"""Available rows of one angle: (rows, n_ch, n_frames, spf) int8 view."""
|
||||||
return self.sras.load_angle(angle_idx, n_rows=self.rows_available[angle_idx])
|
return self.sras.load_angle(angle_idx, n_rows=self.rows_available[angle_idx])
|
||||||
@@ -123,7 +130,7 @@ def compute_image(scan: LoadedScan, angle_idx: int, ch_idx: int,
|
|||||||
if view.shape[0] == 0:
|
if view.shape[0] == 0:
|
||||||
return np.zeros((0, 0), dtype=np.float32)
|
return np.zeros((0, 0), dtype=np.float32)
|
||||||
sras = scan.sras
|
sras = scan.sras
|
||||||
bg = scan.background if apply_bg_sub else None
|
bg = scan.background(angle_idx) if apply_bg_sub else None
|
||||||
|
|
||||||
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
|
if ch_idx in (CH1_IDX, VELOCITY_MODE_IDX):
|
||||||
return compute_rf_image(
|
return compute_rf_image(
|
||||||
@@ -232,7 +239,7 @@ class WaveformCanvas(FigureCanvasQTAgg):
|
|||||||
dc4_val = float(sras.load_row(angle_idx, row_idx, CH4_IDX)[frame_idx]
|
dc4_val = float(sras.load_row(angle_idx, row_idx, CH4_IDX)[frame_idx]
|
||||||
.mean(dtype=np.float32))
|
.mean(dtype=np.float32))
|
||||||
|
|
||||||
bg = scan.background if apply_bg_sub else None
|
bg = scan.background(angle_idx) if apply_bg_sub else None
|
||||||
waveform_plot = waveform - bg if bg is not None else waveform
|
waveform_plot = waveform - bg if bg is not None else waveform
|
||||||
|
|
||||||
self.ax_wave.cla()
|
self.ax_wave.cla()
|
||||||
@@ -696,7 +703,7 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self.chk_bg_sub.setChecked(True)
|
self.chk_bg_sub.setChecked(True)
|
||||||
self.chk_bg_sub.setEnabled(False)
|
self.chk_bg_sub.setEnabled(False)
|
||||||
self.chk_bg_sub.setToolTip(
|
self.chk_bg_sub.setToolTip(
|
||||||
"Subtract the stored background waveform from each CH1 frame\n"
|
"Subtract this angle's stored background waveform from each CH1 frame\n"
|
||||||
"before computing the FFT."
|
"before computing the FFT."
|
||||||
)
|
)
|
||||||
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
|
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
|
||||||
@@ -1043,7 +1050,7 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
self._on_view_changed()
|
self._on_view_changed()
|
||||||
|
|
||||||
def _update_angle_info(self):
|
def _update_angle_info(self):
|
||||||
"""Per-angle info fields (v6 geometry is ragged across angles)."""
|
"""Per-angle info fields (geometry is ragged across angles)."""
|
||||||
scan = self._scan
|
scan = self._scan
|
||||||
if scan is None:
|
if scan is None:
|
||||||
return
|
return
|
||||||
@@ -1060,8 +1067,9 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
if avail < pa.n_rows:
|
if avail < pa.n_rows:
|
||||||
notes.append(f"! Angle {ai}: only {avail}/{pa.n_rows} rows on disk "
|
notes.append(f"! Angle {ai}: only {avail}/{pa.n_rows} rows on disk "
|
||||||
"(scan was aborted or is still running)")
|
"(scan was aborted or is still running)")
|
||||||
if scan.background is not None:
|
bg = scan.background(ai)
|
||||||
notes.append(f"Background waveform: {len(scan.background)} samples")
|
if bg is not None:
|
||||||
|
notes.append(f"Background waveform (angle {ai}): {len(bg)} samples")
|
||||||
self.lbl_frame_warn.setText("\n".join(notes))
|
self.lbl_frame_warn.setText("\n".join(notes))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1083,7 +1091,7 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES
|
is_ch1 = enabled and ch_idx in CH1_DERIVED_MODES
|
||||||
is_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
|
is_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
|
||||||
self.spin_threshold_mv.setEnabled(is_ch1)
|
self.spin_threshold_mv.setEnabled(is_ch1)
|
||||||
has_bg = has_file and scan.background is not None
|
has_bg = has_file and scan.background(self.spin_angle.value()) is not None
|
||||||
self.chk_bg_sub.setEnabled(has_bg and is_ch1)
|
self.chk_bg_sub.setEnabled(has_bg and is_ch1)
|
||||||
# Time gate only for FFT modes (the SAW pipeline has its own gating)
|
# Time gate only for FFT modes (the SAW pipeline has its own gating)
|
||||||
self.chk_gate.setEnabled(is_fft)
|
self.chk_gate.setEnabled(is_fft)
|
||||||
@@ -1197,6 +1205,9 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
idx = self.spin_angle.value()
|
idx = self.spin_angle.value()
|
||||||
self.lbl_angle_deg.setText(f"({self._scan.sras.per_angle[idx].angle_deg:.1f}°)")
|
self.lbl_angle_deg.setText(f"({self._scan.sras.per_angle[idx].angle_deg:.1f}°)")
|
||||||
self._update_angle_info()
|
self._update_angle_info()
|
||||||
|
# Backgrounds are per angle, so whether there is one to subtract can
|
||||||
|
# change with the angle (a truncated file may be missing later ones).
|
||||||
|
self._update_controls_enabled(True)
|
||||||
self._request_compute()
|
self._request_compute()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1382,7 +1393,8 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
angle_idx = self.spin_angle.value()
|
angle_idx = self.spin_angle.value()
|
||||||
n_shots_req = self.spin_saw_n_shots.value()
|
n_shots_req = self.spin_saw_n_shots.value()
|
||||||
row_sel, frame_sel = self._last_row, self._last_frame
|
row_sel, frame_sel = self._last_row, self._last_frame
|
||||||
apply_bg = scan.background is not None and self.chk_bg_sub.isChecked()
|
background = scan.background(angle_idx)
|
||||||
|
apply_bg = background is not None and self.chk_bg_sub.isChecked()
|
||||||
|
|
||||||
if row_sel is not None and frame_sel is not None:
|
if row_sel is not None and frame_sel is not None:
|
||||||
src_desc = f"selected pixel (row={row_sel}, frame={frame_sel})"
|
src_desc = f"selected pixel (row={row_sel}, frame={frame_sel})"
|
||||||
@@ -1405,7 +1417,7 @@ class SrasViewerWindow(QMainWindow):
|
|||||||
rows, frames = np.divmod(flat, n_frames)
|
rows, frames = np.divmod(flat, n_frames)
|
||||||
shots = view[rows, CH1_IDX, frames].astype(np.float32)
|
shots = view[rows, CH1_IDX, frames].astype(np.float32)
|
||||||
if apply_bg:
|
if apply_bg:
|
||||||
shots -= scan.background
|
shots -= background
|
||||||
pipeline.build_template(shots)
|
pipeline.build_template(shots)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+220
-17
@@ -3,9 +3,24 @@
|
|||||||
Each fake records an ordered call trace, so a test can assert the exact
|
Each fake records an ordered call trace, so a test can assert the exact
|
||||||
command sequence the engine issues — the property that matters when the
|
command sequence the engine issues — the property that matters when the
|
||||||
real rig isn't available.
|
real rig isn't available.
|
||||||
|
|
||||||
|
The stage and scope are wired together the way the rig is: an X move at scan
|
||||||
|
velocity with the trigger gate armed feeds frames into a running acquisition,
|
||||||
|
at the real 20 kHz / 100 mm/s rate. Per-row and burst acquisition therefore
|
||||||
|
get their frame counts from the same model, which is what makes a
|
||||||
|
byte-identity comparison between the two paths meaningful — and it means a
|
||||||
|
gate the engine forgets to drop shows up as extra frames instead of passing
|
||||||
|
silently.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.scan_engine import (
|
||||||
|
AXIS_X, LASER_FREQ_HZ, SCAN_RAMP_BUFFER_MM, SCAN_RAMP_MM,
|
||||||
|
SCAN_VELOCITY_MM_S,
|
||||||
|
)
|
||||||
|
|
||||||
|
RAMP_TOTAL_MM = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
|
||||||
|
|
||||||
|
|
||||||
class Trace:
|
class Trace:
|
||||||
"""Ordered record of hardware calls, shared by all fakes in one test."""
|
"""Ordered record of hardware calls, shared by all fakes in one test."""
|
||||||
@@ -29,29 +44,69 @@ class Trace:
|
|||||||
class FakeStage:
|
class FakeStage:
|
||||||
"""Stands in for ThorlabsServoDriver."""
|
"""Stands in for ThorlabsServoDriver."""
|
||||||
|
|
||||||
def __init__(self, trace: Trace, homed=(True, True), enabled=(True, True)):
|
def __init__(self, trace: Trace, homed=(True, True), enabled=(True, True),
|
||||||
|
scope=None):
|
||||||
self._t = trace
|
self._t = trace
|
||||||
self.am_homed = list(homed)
|
self.am_homed = list(homed)
|
||||||
self.am_enabled = list(enabled)
|
self.am_enabled = list(enabled)
|
||||||
self.positions = [0.0, 0.0]
|
self.positions = [0.0, 0.0]
|
||||||
|
self._scope = scope
|
||||||
|
self.gate_armed = False
|
||||||
|
|
||||||
|
def attach_scope(self, scope):
|
||||||
|
"""Route gated motion into `scope`, as the TRIGOUT pin does on the rig."""
|
||||||
|
self._scope = scope
|
||||||
|
|
||||||
def enable_axis(self, axis):
|
def enable_axis(self, axis):
|
||||||
self._t.record("enable_axis", axis)
|
self._t.record("enable_axis", axis)
|
||||||
self.am_enabled[0 if axis == 0x21 else 1] = True
|
self.am_enabled[0 if axis == AXIS_X else 1] = True
|
||||||
|
|
||||||
def home_axis(self, axis, timeout=0.0):
|
def home_axis(self, axis, timeout=0.0):
|
||||||
self._t.record("home_axis", axis)
|
self._t.record("home_axis", axis)
|
||||||
self.am_homed[0 if axis == 0x21 else 1] = True
|
self.am_homed[0 if axis == AXIS_X else 1] = True
|
||||||
|
|
||||||
def set_velocity_params(self, axis, max_velocity=None, acceleration=None):
|
def set_velocity_params(self, axis, max_velocity=None, acceleration=None):
|
||||||
self._t.record("set_velocity_params", axis, max_velocity, acceleration)
|
self._t.record("set_velocity_params", axis, max_velocity, acceleration)
|
||||||
|
|
||||||
def set_trigger_trigout_maxv(self, axis):
|
def set_trigger_trigout_maxv(self, axis):
|
||||||
self._t.record("set_trigger_trigout_maxv", axis)
|
self._t.record("set_trigger_trigout_maxv", axis)
|
||||||
|
if axis == AXIS_X:
|
||||||
|
self.gate_armed = True
|
||||||
|
|
||||||
|
def set_trigger_gate_off(self, axis):
|
||||||
|
self._t.record("set_trigger_gate_off", axis)
|
||||||
|
if axis == AXIS_X:
|
||||||
|
self.gate_armed = False
|
||||||
|
|
||||||
|
def arm_scan_gate(self, axis, armed, verify=True):
|
||||||
|
self._t.record("arm_scan_gate", axis, bool(armed))
|
||||||
|
if axis == AXIS_X:
|
||||||
|
self.gate_armed = bool(armed)
|
||||||
|
|
||||||
def move_axis_absolute(self, axis, pos, timeout=0.0):
|
def move_axis_absolute(self, axis, pos, timeout=0.0):
|
||||||
|
idx = 0 if axis == AXIS_X else 1
|
||||||
|
prev = self.positions[idx]
|
||||||
self._t.record("move_axis_absolute", axis, round(pos, 6))
|
self._t.record("move_axis_absolute", axis, round(pos, 6))
|
||||||
self.positions[0 if axis == 0x21 else 1] = pos
|
self.positions[idx] = pos
|
||||||
|
|
||||||
|
# The gate is high only at max velocity, i.e. over the move minus its
|
||||||
|
# two ramps — direction-agnostic, so a flyback the engine failed to
|
||||||
|
# gate off produces frames instead of quietly producing none.
|
||||||
|
if axis == AXIS_X and self.gate_armed and self._scope is not None:
|
||||||
|
at_speed_mm = abs(pos - prev) - 2 * RAMP_TOTAL_MM
|
||||||
|
if at_speed_mm > 0:
|
||||||
|
self._scope.acquire_frames(
|
||||||
|
round(at_speed_mm * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S))
|
||||||
|
|
||||||
|
|
||||||
|
def background_record(n: int, samples_per_frame: int) -> bytes:
|
||||||
|
"""The waveform FakeScope returns from its n-th background capture.
|
||||||
|
|
||||||
|
Every angle captures its own, so the tests need to tell one from the
|
||||||
|
next: a background that landed under the wrong angle would otherwise
|
||||||
|
look exactly like the right one.
|
||||||
|
"""
|
||||||
|
return bytes((n * 17 + s) % 256 for s in range(samples_per_frame))
|
||||||
|
|
||||||
|
|
||||||
class FakeScope:
|
class FakeScope:
|
||||||
@@ -61,24 +116,43 @@ class FakeScope:
|
|||||||
against an expected byte pattern.
|
against an expected byte pattern.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, trace: Trace, samples_per_frame=8, n_frames=4):
|
def __init__(self, trace: Trace, samples_per_frame=8, max_frames=4096):
|
||||||
self._t = trace
|
self._t = trace
|
||||||
self.samples_per_frame = samples_per_frame
|
self.samples_per_frame = samples_per_frame
|
||||||
self._n_frames = n_frames
|
self.max_frames = max_frames
|
||||||
self._acq_polls = 0
|
self._acq_polls = 0
|
||||||
self.frame_seq = 0
|
self._running = False
|
||||||
|
self._acquired = 0
|
||||||
|
self._backgrounds_taken = 0
|
||||||
|
# Per-channel running frame index. Frame content is a function of
|
||||||
|
# (channel, index) alone, so the same total frame sequence yields the
|
||||||
|
# same bytes however it is chopped into transfers.
|
||||||
|
self._next_frame: dict[int, int] = {}
|
||||||
|
|
||||||
|
# -- driven by FakeStage ------------------------------------------------
|
||||||
|
def acquire_frames(self, n):
|
||||||
|
if self._running:
|
||||||
|
self._acquired += n
|
||||||
|
|
||||||
# -- writes / queries ---------------------------------------------------
|
# -- writes / queries ---------------------------------------------------
|
||||||
def write(self, cmd):
|
def write(self, cmd):
|
||||||
self._t.record("write", cmd)
|
self._t.record("write", cmd)
|
||||||
|
if cmd == "ACQuire:STATE RUN":
|
||||||
|
self._running = True
|
||||||
|
self._acquired = 0
|
||||||
|
elif cmd == "ACQuire:STATE STOP":
|
||||||
|
self._running = False
|
||||||
|
|
||||||
def query(self, cmd):
|
def query(self, cmd):
|
||||||
self._t.record("query", cmd)
|
self._t.record("query", cmd)
|
||||||
if cmd == "ACQuire:STATE?":
|
if cmd == "ACQuire:STATE?":
|
||||||
self._acq_polls += 1
|
self._acq_polls += 1
|
||||||
|
# STOPAfter SEQuence self-stops when the sequence completes, so
|
||||||
|
# reporting "stopped" and staying armed would be inconsistent.
|
||||||
|
self._running = False
|
||||||
return "0" # background average finished
|
return "0" # background average finished
|
||||||
if cmd == "ACQuire:NUMFRAMESACQuired?":
|
if cmd == "ACQuire:NUMFRAMESACQuired?":
|
||||||
return str(self._n_frames)
|
return str(self._acquired)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# -- typed setters used by core.scope_sras ------------------------------
|
# -- typed setters used by core.scope_sras ------------------------------
|
||||||
@@ -102,7 +176,13 @@ class FakeScope:
|
|||||||
|
|
||||||
def set_fastframe_count(self, n):
|
def set_fastframe_count(self, n):
|
||||||
self._t.record("set_fastframe_count", n)
|
self._t.record("set_fastframe_count", n)
|
||||||
self._n_frames = n
|
|
||||||
|
def get_fastframe_state(self):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def get_fastframe_max_frames(self):
|
||||||
|
self._t.record("get_fastframe_max_frames")
|
||||||
|
return self.max_frames
|
||||||
|
|
||||||
def set_sample_rate(self, sr):
|
def set_sample_rate(self, sr):
|
||||||
self._t.record("set_sample_rate", sr)
|
self._t.record("set_sample_rate", sr)
|
||||||
@@ -114,21 +194,37 @@ class FakeScope:
|
|||||||
self._t.record("set_data_source", ch)
|
self._t.record("set_data_source", ch)
|
||||||
self._source = ch
|
self._source = ch
|
||||||
|
|
||||||
|
def set_data_encoding(self, encoding):
|
||||||
|
self._t.record("set_data_encoding", encoding)
|
||||||
|
|
||||||
|
def set_data_width(self, width):
|
||||||
|
self._t.record("set_data_width", width)
|
||||||
|
|
||||||
def query_wfmoutpre(self):
|
def query_wfmoutpre(self):
|
||||||
return f"WFMOUTPRE:CH{self._source};YMULT 1.5625E-3;YOFF -87.04;YZERO 0.0"
|
return f"WFMOUTPRE:CH{self._source};YMULT 1.5625E-3;YOFF -87.04;YZERO 0.0"
|
||||||
|
|
||||||
def transfer_curve(self):
|
def transfer_curve(self):
|
||||||
self._t.record("transfer_curve")
|
self._t.record("transfer_curve")
|
||||||
return bytes(range(self.samples_per_frame))
|
n = self._backgrounds_taken
|
||||||
|
self._backgrounds_taken += 1
|
||||||
|
return background_record(n, self.samples_per_frame)
|
||||||
|
|
||||||
def transfer_fastframe(self, parse=True):
|
def _frames(self, ch, count):
|
||||||
|
spf = self.samples_per_frame
|
||||||
|
start = self._next_frame.get(ch, 0)
|
||||||
|
self._next_frame[ch] = start + count
|
||||||
|
return [bytes((ch * 31 + g + s) % 256 for s in range(spf))
|
||||||
|
for g in range(start, start + count)]
|
||||||
|
|
||||||
|
def transfer_fastframe(self, parse=True, byte_count=1, signed=True,
|
||||||
|
byte_order='MSB'):
|
||||||
self._t.record("transfer_fastframe", self._source)
|
self._t.record("transfer_fastframe", self._source)
|
||||||
frames = []
|
return self._frames(self._source, self._acquired)
|
||||||
for i in range(self._n_frames):
|
|
||||||
frames.append(bytes((self.frame_seq + i + s) % 256
|
def transfer_fastframe_bulk(self, frame_count, samples_per_frame,
|
||||||
for s in range(self.samples_per_frame)))
|
bytes_per_sample=1):
|
||||||
self.frame_seq += 1
|
self._t.record("transfer_fastframe_bulk", self._source, frame_count)
|
||||||
return frames
|
return bytearray(b"".join(self._frames(self._source, frame_count)))
|
||||||
|
|
||||||
# channel config (only used by configure_channels)
|
# channel config (only used by configure_channels)
|
||||||
def set_channel_label_name(self, ch, name):
|
def set_channel_label_name(self, ch, name):
|
||||||
@@ -162,6 +258,9 @@ class FakeT3R:
|
|||||||
self._t = trace
|
self._t = trace
|
||||||
self.is_open = is_open
|
self.is_open = is_open
|
||||||
self._motion_completes = motion_completes
|
self._motion_completes = motion_completes
|
||||||
|
# Microsteps commanded per channel, so a test can read the tilt the
|
||||||
|
# platform ended up at rather than replaying the move trace.
|
||||||
|
self.positions = {ch: 0 for ch in range(4)}
|
||||||
|
|
||||||
def set_microstep(self, ch, micro):
|
def set_microstep(self, ch, micro):
|
||||||
self._t.record("t3r_set_microstep", ch, micro)
|
self._t.record("t3r_set_microstep", ch, micro)
|
||||||
@@ -177,9 +276,113 @@ class FakeT3R:
|
|||||||
return round(self.MOTOR_FULL_STEPS_PER_REV * microsteps * ratio
|
return round(self.MOTOR_FULL_STEPS_PER_REV * microsteps * ratio
|
||||||
* angle_deg / 360.0)
|
* angle_deg / 360.0)
|
||||||
|
|
||||||
|
def move(self, ch, steps, velocity, accel):
|
||||||
|
self._t.record("t3r_move", ch, steps)
|
||||||
|
self.positions[ch] += steps
|
||||||
|
|
||||||
def rotate_stage(self, angle_deg, microsteps, velocity, accel):
|
def rotate_stage(self, angle_deg, microsteps, velocity, accel):
|
||||||
self._t.record("t3r_rotate", round(angle_deg, 6))
|
self._t.record("t3r_rotate", round(angle_deg, 6))
|
||||||
|
|
||||||
def wait_motion_done(self, ch, timeout):
|
def wait_motion_done(self, ch, timeout):
|
||||||
self._t.record("t3r_wait_motion_done", ch)
|
self._t.record("t3r_wait_motion_done", ch)
|
||||||
return self._motion_completes
|
return self._motion_completes
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAlignRig:
|
||||||
|
"""A tilted sample on the tilt platform, as the DC levels would read it.
|
||||||
|
|
||||||
|
The detection beam is fixed and the stage carries the sample under it, so
|
||||||
|
the height error under the beam is the sample's slope times how far the
|
||||||
|
stage has moved off the reference point. The T-axes tilt the sample the
|
||||||
|
other way: their three heights define a plane, and its slope adds to the
|
||||||
|
sample's. Nulling the split-detector difference therefore means cancelling
|
||||||
|
the sample slope — which is exactly what an aligner has to work out.
|
||||||
|
|
||||||
|
The plane is fitted by least squares here, rather than reusing
|
||||||
|
core.auto_align's closed form, so the two are independent statements of
|
||||||
|
the same geometry.
|
||||||
|
|
||||||
|
``curvature_mv_per_mm2`` bends the surface: a curved sample needs opposite
|
||||||
|
corrections at +1.5 mm and -1.5 mm, which is the disagreement the
|
||||||
|
procedure is supposed to report instead of averaging away.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Actuator azimuths on the platform, in degrees from stage +X.
|
||||||
|
AZIMUTH_DEG = {0: 120.0, 1: 0.0, 2: 240.0}
|
||||||
|
|
||||||
|
def __init__(self, stage, t3r, ref_mm=(50.0, 40.0),
|
||||||
|
x_slope_mv_per_mm=40.0, y_slope_mv_per_mm=-25.0,
|
||||||
|
tilt_gain_mv_per_mm=0.2, base_mv=400.0,
|
||||||
|
curvature_mv_per_mm2=0.0, jitter_mv=0.0):
|
||||||
|
self._stage = stage
|
||||||
|
self._t3r = t3r
|
||||||
|
self.ref_mm = ref_mm
|
||||||
|
self.x_slope_mv_per_mm = x_slope_mv_per_mm
|
||||||
|
self.y_slope_mv_per_mm = y_slope_mv_per_mm
|
||||||
|
self.tilt_gain_mv_per_mm = tilt_gain_mv_per_mm
|
||||||
|
self.base_mv = base_mv
|
||||||
|
self.curvature_mv_per_mm2 = curvature_mv_per_mm2
|
||||||
|
self.jitter_mv = jitter_mv
|
||||||
|
self._reads = 0
|
||||||
|
|
||||||
|
# -- geometry -----------------------------------------------------------
|
||||||
|
def platform_tilt(self):
|
||||||
|
"""(x_tilt, y_tilt) of the plane through the three actuator heights."""
|
||||||
|
import numpy as np
|
||||||
|
rows, heights = [], []
|
||||||
|
for ch, azimuth in self.AZIMUTH_DEG.items():
|
||||||
|
theta = np.radians(azimuth)
|
||||||
|
rows.append([1.0, np.cos(theta), np.sin(theta)])
|
||||||
|
heights.append(float(self._t3r.positions[ch]))
|
||||||
|
_, x_tilt, y_tilt = np.linalg.lstsq(np.array(rows), np.array(heights),
|
||||||
|
rcond=None)[0]
|
||||||
|
return float(x_tilt), float(y_tilt)
|
||||||
|
|
||||||
|
def slopes_mv_per_mm(self):
|
||||||
|
"""The residual sample slope the beam sees, after the platform tilt."""
|
||||||
|
x_tilt, y_tilt = self.platform_tilt()
|
||||||
|
return (self.x_slope_mv_per_mm + self.tilt_gain_mv_per_mm * x_tilt,
|
||||||
|
self.y_slope_mv_per_mm + self.tilt_gain_mv_per_mm * y_tilt)
|
||||||
|
|
||||||
|
def difference_mv(self):
|
||||||
|
x_off = self._stage.positions[0] - self.ref_mm[0]
|
||||||
|
y_off = self._stage.positions[1] - self.ref_mm[1]
|
||||||
|
slope_x, slope_y = self.slopes_mv_per_mm()
|
||||||
|
return (slope_x * x_off + slope_y * y_off
|
||||||
|
+ self.curvature_mv_per_mm2 * (x_off ** 2 + y_off ** 2))
|
||||||
|
|
||||||
|
# -- what the scope reports ---------------------------------------------
|
||||||
|
def level_v(self, channel):
|
||||||
|
"""CH3 and CH4 as volts: the difference straddling a constant sum.
|
||||||
|
|
||||||
|
The sum is fixed because tilt steers the beam across the detector
|
||||||
|
rather than changing how much light comes back — so a nulled
|
||||||
|
difference does put both levels back where they were.
|
||||||
|
"""
|
||||||
|
self._reads += 1
|
||||||
|
# A deterministic alternating wobble, so a test can check the median
|
||||||
|
# of several reads is what keeps the loop stable.
|
||||||
|
jitter = self.jitter_mv * (1 if self._reads % 2 else -1)
|
||||||
|
half = 0.5 * self.difference_mv()
|
||||||
|
mv = self.base_mv + (half if channel == 3 else -half) + jitter
|
||||||
|
return mv / 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAlignScope(FakeScope):
|
||||||
|
"""FakeScope that also answers the DC measurements auto-align reads."""
|
||||||
|
|
||||||
|
def __init__(self, trace: Trace, rig: FakeAlignRig, samples_per_frame=8,
|
||||||
|
acquisitions_advance=True):
|
||||||
|
super().__init__(trace, samples_per_frame=samples_per_frame)
|
||||||
|
self._rig = rig
|
||||||
|
self._acq = 0
|
||||||
|
self._advance = acquisitions_advance
|
||||||
|
|
||||||
|
def measure_immediate(self, channel, measurement_type="MEAN"):
|
||||||
|
self._t.record("measure_immediate", channel, measurement_type)
|
||||||
|
return self._rig.level_v(channel)
|
||||||
|
|
||||||
|
def get_acquisition_count(self):
|
||||||
|
if self._advance:
|
||||||
|
self._acq += 1
|
||||||
|
return self._acq
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
"angle_deg": -180.0,
|
"angle_deg": -180.0,
|
||||||
"n_rows": 3,
|
"n_rows": 3,
|
||||||
"row_bytes": 96,
|
"row_bytes": 96,
|
||||||
"data_offset": 265,
|
"data_offset": 553,
|
||||||
"n_rows_available": 0,
|
"n_rows_available": 0,
|
||||||
"status": "MISSING"
|
"status": "MISSING"
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
"angle_deg": -180.0,
|
"angle_deg": -180.0,
|
||||||
"n_rows": 3,
|
"n_rows": 3,
|
||||||
"row_bytes": 96,
|
"row_bytes": 96,
|
||||||
"data_offset": 265,
|
"data_offset": 553,
|
||||||
"n_rows_available": 0,
|
"n_rows_available": 0,
|
||||||
"status": "MISSING"
|
"status": "MISSING"
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-4
@@ -1,9 +1,18 @@
|
|||||||
"""Shared constants for the golden .sras fixtures.
|
"""Shared constants and writers for the .sras test fixtures.
|
||||||
|
|
||||||
These mirror the values tests/gen_goldens.py used when the fixtures were
|
The constants mirror the values tests/gen_goldens.py used when the committed
|
||||||
generated against the pre-refactor code (commit d185676); they must never
|
golden files were generated against the pre-refactor code (commit d185676);
|
||||||
change, or the byte-identical comparisons stop meaning anything.
|
they must never change, or the comparisons against those files stop meaning
|
||||||
|
anything. Those goldens are legacy v6 files — the one background per file
|
||||||
|
layout — and are now read-only fixtures for the parser.
|
||||||
|
|
||||||
|
``write_v7`` builds the current layout (one background per angle, inside the
|
||||||
|
data block) over the same geometry, for the tests that need a file this
|
||||||
|
version of the app could actually have written.
|
||||||
"""
|
"""
|
||||||
|
from core.scan_geometry import build_plan
|
||||||
|
from core.sras_format import VERSION, create_scan_file, write_background_block
|
||||||
|
|
||||||
SPF = 8
|
SPF = 8
|
||||||
SAMPLE_RATE = 6.25e9
|
SAMPLE_RATE = 6.25e9
|
||||||
CHANNELS = [1, 3, 4]
|
CHANNELS = [1, 3, 4]
|
||||||
@@ -19,3 +28,31 @@ VELOCITY_MM_S = 100.0
|
|||||||
|
|
||||||
def synthetic_frame(ai, ri, ci, fi):
|
def synthetic_frame(ai, ri, ci, fi):
|
||||||
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
|
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
|
||||||
|
|
||||||
|
|
||||||
|
def tiny_plan():
|
||||||
|
"""The fixture geometry: 2 angles × 3 rows × 4 frames."""
|
||||||
|
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ,
|
||||||
|
velocity_mm_s=VELOCITY_MM_S)
|
||||||
|
|
||||||
|
|
||||||
|
def angle_background(ai):
|
||||||
|
"""A background that differs per angle, so tests can tell them apart."""
|
||||||
|
return bytes((ai * 11 + s) % 256 for s in range(SPF))
|
||||||
|
|
||||||
|
|
||||||
|
def write_v7(path, plan=None, version=None):
|
||||||
|
"""Write a complete current-format file: [background][rows] per angle."""
|
||||||
|
plan = plan if plan is not None else tiny_plan()
|
||||||
|
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES,
|
||||||
|
version=VERSION if version is None else version)
|
||||||
|
try:
|
||||||
|
for ai, pa in enumerate(plan.per_angle):
|
||||||
|
write_background_block(f, angle_background(ai))
|
||||||
|
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()
|
||||||
|
return plan
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Pre-scan angle inspection, driven entirely by fake hardware.
|
||||||
|
|
||||||
|
The feature's defining constraint is that it reads nothing back from the
|
||||||
|
scope — the operator looks at the instrument. These tests pin that, the scope
|
||||||
|
state the app is responsible for putting the instrument into, and the motion
|
||||||
|
sequence across angles.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.angle_inspect import AngleInspector, InspectCallbacks
|
||||||
|
from core.rotation import RotationAxis, RotationSettings
|
||||||
|
from core.scan_engine import AXIS_X, AXIS_Y
|
||||||
|
from core.scan_geometry import build_plan
|
||||||
|
from core.scope_inspect import (
|
||||||
|
BIAS_CHANNELS, BIAS_POSITION_DIV, BIAS_SCALE_V_DIV, BIAS_WINDOW_V,
|
||||||
|
INSPECT_TRIG_LEVEL_V, inspect_channel_profiles,
|
||||||
|
)
|
||||||
|
from core.scope_sras import SRAS_CHANNELS
|
||||||
|
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
||||||
|
|
||||||
|
SPF = 8
|
||||||
|
|
||||||
|
|
||||||
|
def make_plan(num_angles=3):
|
||||||
|
return build_plan(40.0, 30.0, 2.0, 1.0, num_angles, 0.25,
|
||||||
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def build(num_angles=3, seed=1234, callbacks=None, rotator_open=True):
|
||||||
|
trace = Trace()
|
||||||
|
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||||
|
stage = FakeStage(trace, scope=scope)
|
||||||
|
t3r = FakeT3R(trace, is_open=rotator_open)
|
||||||
|
rotator = RotationAxis(t3r, RotationSettings())
|
||||||
|
plan = make_plan(num_angles)
|
||||||
|
insp = AngleInspector(stage, scope, rotator, plan,
|
||||||
|
callbacks=callbacks or InspectCallbacks(),
|
||||||
|
rng=random.Random(seed))
|
||||||
|
return insp, trace, plan
|
||||||
|
|
||||||
|
|
||||||
|
def writes(trace):
|
||||||
|
return [c[1] for c in trace.of("write")]
|
||||||
|
|
||||||
|
|
||||||
|
# ── The defining constraint ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_inspection_never_reads_a_waveform_back():
|
||||||
|
"""The operator reads the scope; the app must not pull data off it.
|
||||||
|
|
||||||
|
If this fails, someone has added a transfer path to a feature whose whole
|
||||||
|
premise is that there isn't one.
|
||||||
|
"""
|
||||||
|
insp, trace, plan = build()
|
||||||
|
insp.start()
|
||||||
|
for i in range(plan.n_angles):
|
||||||
|
insp.goto_angle(i)
|
||||||
|
insp.new_point()
|
||||||
|
insp.stop()
|
||||||
|
|
||||||
|
forbidden = {"transfer_fastframe", "transfer_fastframe_bulk",
|
||||||
|
"transfer_curve", "set_data_source", "query_wfmoutpre"}
|
||||||
|
assert forbidden.isdisjoint(set(trace.names()))
|
||||||
|
assert "CURVe?" not in writes(trace)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scope configuration ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_start_sets_an_edge_trigger_on_ch2_above_the_scan_level():
|
||||||
|
insp, trace, _ = build()
|
||||||
|
insp.start()
|
||||||
|
|
||||||
|
assert "TRIGger:A:TYPe EDGE" in writes(trace)
|
||||||
|
assert trace.of("set_trigger_source")[-1][1] == 2
|
||||||
|
assert trace.of("set_trigger_slope")[-1][1] == "RISE"
|
||||||
|
ch, level = trace.of("set_trigger_level")[-1][1:3]
|
||||||
|
assert (ch, level) == (2, INSPECT_TRIG_LEVEL_V)
|
||||||
|
assert INSPECT_TRIG_LEVEL_V >= 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_disables_fastframe_averaging_and_the_logic_trigger():
|
||||||
|
"""Everything the scan needs and inspection must not inherit."""
|
||||||
|
insp, trace, _ = build()
|
||||||
|
insp.start()
|
||||||
|
|
||||||
|
assert trace.of("set_fastframe_state")[-1][1] is False
|
||||||
|
assert trace.of("set_acquire_mode")[-1][1] == "SAMPLE"
|
||||||
|
w = writes(trace)
|
||||||
|
assert not any("LOGIc" in cmd or "LOGICPattern" in cmd for cmd in w)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_leaves_the_acquisition_free_running():
|
||||||
|
"""The display has to keep updating while the operator looks at it."""
|
||||||
|
insp, trace, _ = build()
|
||||||
|
insp.start()
|
||||||
|
|
||||||
|
w = writes(trace)
|
||||||
|
assert "ACQuire:STOPAfter RUNSTop" in w
|
||||||
|
assert w.index("ACQuire:STOPAfter RUNSTop") < w.index("ACQuire:STATE RUN")
|
||||||
|
assert "ACQuire:STATE STOP" not in w
|
||||||
|
|
||||||
|
|
||||||
|
def test_bias_channels_are_directly_comparable():
|
||||||
|
"""CH3/CH4 must share scale and position or the eye comparison is a lie."""
|
||||||
|
profiles = inspect_channel_profiles()
|
||||||
|
a, b = (profiles[ch] for ch in BIAS_CHANNELS)
|
||||||
|
assert a.scale_v_div == b.scale_v_div
|
||||||
|
assert a.position_div == b.position_div
|
||||||
|
# Same front end as the scan records — only the display changes.
|
||||||
|
for ch in BIAS_CHANNELS:
|
||||||
|
assert profiles[ch].termination_ohm == SRAS_CHANNELS[ch].termination_ohm
|
||||||
|
assert profiles[ch].coupling == SRAS_CHANNELS[ch].coupling
|
||||||
|
assert profiles[ch].bandwidth_hz == SRAS_CHANNELS[ch].bandwidth_hz
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("n_divisions", [8, 10])
|
||||||
|
def test_bias_window_shows_zero_to_700mv_with_headroom(n_divisions):
|
||||||
|
"""0–700 mV must fit on screen, above ground, on either graticule size.
|
||||||
|
|
||||||
|
Ground sits BIAS_POSITION_DIV divisions below centre, so the visible
|
||||||
|
window runs from (-N/2 - pos)*scale to (+N/2 - pos)*scale.
|
||||||
|
"""
|
||||||
|
half = n_divisions / 2
|
||||||
|
bottom = (-half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV
|
||||||
|
top = (half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV
|
||||||
|
|
||||||
|
assert bottom < 0.0, "no room below ground for undershoot"
|
||||||
|
assert top > BIAS_WINDOW_V, "700 mV is clipped or sitting on the top edge"
|
||||||
|
# The point of moving the trace down: most of the screen is above ground.
|
||||||
|
assert abs(bottom) < top
|
||||||
|
|
||||||
|
|
||||||
|
def test_ch1_keeps_the_acquisition_front_end():
|
||||||
|
"""What you see at a point is what a scan would record there."""
|
||||||
|
assert inspect_channel_profiles()[1] == SRAS_CHANNELS[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stage and rotation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_start_parks_on_the_first_angle():
|
||||||
|
insp, _, plan = build()
|
||||||
|
point = insp.start()
|
||||||
|
|
||||||
|
assert point.angle_idx == 0
|
||||||
|
assert point.angle_deg == plan.per_angle[0].angle_deg
|
||||||
|
assert insp.current_point == point
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gate_is_off_for_the_whole_inspection():
|
||||||
|
"""Nothing here is gated, and an armed output keeps driving the line."""
|
||||||
|
insp, trace, _ = build()
|
||||||
|
insp.start()
|
||||||
|
insp.goto_angle(2)
|
||||||
|
insp.new_point()
|
||||||
|
|
||||||
|
assert trace.count("set_trigger_gate_off") >= 1
|
||||||
|
assert trace.count("set_trigger_trigout_maxv") == 0
|
||||||
|
assert [c[2] for c in trace.of("arm_scan_gate") if c[2]] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_points_land_on_the_scan_grid():
|
||||||
|
"""A point the scan would never sample tells you nothing about the scan."""
|
||||||
|
insp, _, plan = build()
|
||||||
|
insp.start()
|
||||||
|
|
||||||
|
for i in range(plan.n_angles):
|
||||||
|
pa = plan.per_angle[i]
|
||||||
|
for _ in range(5):
|
||||||
|
pt = insp.new_point() if insp.angle_idx == i else insp.goto_angle(i)
|
||||||
|
assert pt.angle_idx == i
|
||||||
|
assert pt.y_mm in pa.y_positions
|
||||||
|
assert pa.x_start <= pt.x_mm <= pa.x_start + pa.x_delta
|
||||||
|
|
||||||
|
|
||||||
|
def test_goto_angle_rotates_then_moves():
|
||||||
|
insp, trace, plan = build()
|
||||||
|
insp.start()
|
||||||
|
trace.calls.clear()
|
||||||
|
|
||||||
|
insp.goto_angle(2)
|
||||||
|
|
||||||
|
# t3r_rotate carries the delta, so assert the resulting absolute angle.
|
||||||
|
assert trace.count("t3r_rotate") == 1, "expected exactly one rotation"
|
||||||
|
assert insp._rotator.current_deg == pytest.approx(plan.per_angle[2].angle_deg)
|
||||||
|
moves = trace.of("move_axis_absolute")
|
||||||
|
assert [m[1] for m in moves] == [AXIS_Y, AXIS_X], "Y then X, as the scan does"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_point_re_rolls_without_rotating():
|
||||||
|
"""Distinguishing a bad spot from a bad angle depends on not rotating."""
|
||||||
|
insp, trace, _ = build()
|
||||||
|
insp.start()
|
||||||
|
insp.goto_angle(1)
|
||||||
|
trace.calls.clear()
|
||||||
|
|
||||||
|
first = insp.current_point
|
||||||
|
second = insp.new_point()
|
||||||
|
|
||||||
|
assert second.angle_idx == first.angle_idx == 1
|
||||||
|
assert (second.x_mm, second.y_mm) != (first.x_mm, first.y_mm)
|
||||||
|
assert trace.count("t3r_rotate") == 0, "new_point must not rotate"
|
||||||
|
assert [m[1] for m in trace.of("move_axis_absolute")] == [AXIS_Y, AXIS_X]
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_and_prev_wrap_around():
|
||||||
|
insp, _, plan = build(num_angles=3)
|
||||||
|
insp.start()
|
||||||
|
|
||||||
|
assert insp.next_angle().angle_idx == 1
|
||||||
|
assert insp.next_angle().angle_idx == 2
|
||||||
|
assert insp.next_angle().angle_idx == 0, "should wrap forward"
|
||||||
|
assert insp.prev_angle().angle_idx == plan.n_angles - 1, "should wrap back"
|
||||||
|
|
||||||
|
|
||||||
|
def test_angle_labels_cover_every_angle():
|
||||||
|
insp, _, plan = build(num_angles=9)
|
||||||
|
labels = insp.angle_labels()
|
||||||
|
assert len(labels) == 9
|
||||||
|
assert labels[0].startswith("Angle 1/9")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Guards ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_multi_angle_inspection_requires_the_rotator():
|
||||||
|
insp, _, _ = build(num_angles=3, rotator_open=False)
|
||||||
|
with pytest.raises(RuntimeError, match="T3R rotation stage"):
|
||||||
|
insp.start()
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_angle_inspection_works_without_the_rotator():
|
||||||
|
insp, _, _ = build(num_angles=1, rotator_open=False)
|
||||||
|
point = insp.start()
|
||||||
|
assert point.angle_idx == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_before_start_is_rejected():
|
||||||
|
insp, _, _ = build()
|
||||||
|
with pytest.raises(RuntimeError, match="not been started"):
|
||||||
|
insp.goto_angle(1)
|
||||||
|
with pytest.raises(RuntimeError, match="not been started"):
|
||||||
|
insp.new_point()
|
||||||
|
|
||||||
|
|
||||||
|
def test_out_of_range_angle_is_rejected():
|
||||||
|
insp, _, _ = build(num_angles=3)
|
||||||
|
insp.start()
|
||||||
|
with pytest.raises(IndexError):
|
||||||
|
insp.goto_angle(3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_halts_the_sweep_and_sends_the_rotator_home():
|
||||||
|
insp, trace, _ = build()
|
||||||
|
insp.start()
|
||||||
|
insp.goto_angle(2)
|
||||||
|
trace.calls.clear()
|
||||||
|
|
||||||
|
insp.stop()
|
||||||
|
|
||||||
|
assert "ACQuire:STATE STOP" in writes(trace)
|
||||||
|
assert trace.count("t3r_rotate") == 1, "GR not sent home"
|
||||||
|
assert insp._rotator.current_deg == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_is_idempotent():
|
||||||
|
insp, trace, _ = build()
|
||||||
|
insp.start()
|
||||||
|
insp.stop()
|
||||||
|
trace.calls.clear()
|
||||||
|
|
||||||
|
insp.stop() # must not re-issue anything or raise
|
||||||
|
|
||||||
|
assert trace.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_busy_callback_brackets_every_move():
|
||||||
|
"""The window disables its controls on this, so it has to pair up."""
|
||||||
|
events = []
|
||||||
|
insp, _, _ = build(callbacks=InspectCallbacks(on_busy=events.append))
|
||||||
|
insp.start()
|
||||||
|
insp.goto_angle(1)
|
||||||
|
insp.new_point()
|
||||||
|
insp.stop()
|
||||||
|
|
||||||
|
assert events, "no busy events emitted"
|
||||||
|
assert events[0] is True and events[-1] is False
|
||||||
|
depth = 0
|
||||||
|
for e in events:
|
||||||
|
depth += 1 if e else -1
|
||||||
|
assert depth in (0, 1), f"unbalanced busy events: {events}"
|
||||||
|
assert depth == 0
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
"""Auto-align, driven entirely by fake hardware.
|
||||||
|
|
||||||
|
The feature is a closed loop over hardware, so the tests are built round a
|
||||||
|
model of the rig (fakes.FakeAlignRig): a sample at a known tilt, a platform
|
||||||
|
whose three actuators tilt it, and DC levels that follow from both. A test
|
||||||
|
therefore asks the question the operator does — is the sample level now? —
|
||||||
|
rather than replaying a command sequence.
|
||||||
|
|
||||||
|
The other half is geometry. Which axis moves for which stage direction is
|
||||||
|
the one thing here that cannot be discovered at run time, and getting it
|
||||||
|
wrong would still converge (on the wrong axis, at the wrong point), so it is
|
||||||
|
pinned separately and explicitly.
|
||||||
|
"""
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.auto_align import (
|
||||||
|
AutoAligner, AutoAlignAborted, AutoAlignError, DEFAULT_ALIGN,
|
||||||
|
T_AXIS_AZIMUTH_DEG, X_TILT, Y_TILT, tilt_response,
|
||||||
|
)
|
||||||
|
from core.scan_engine import AXIS_X, AXIS_Y
|
||||||
|
from core.scope_inspect import BIAS_CHANNELS, BIAS_SCALE_V_DIV, INSPECT_TRIG_LEVEL_V
|
||||||
|
from fakes import FakeAlignRig, FakeAlignScope, FakeStage, FakeT3R, Trace
|
||||||
|
|
||||||
|
REF_MM = (50.0, 40.0)
|
||||||
|
|
||||||
|
# No settle: the sleeps are there for the instrument, and every test here
|
||||||
|
# takes a few dozen readings.
|
||||||
|
FAST = replace(DEFAULT_ALIGN, settle_s=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def build(*, settings=FAST, acquisitions_advance=True, ref_mm=REF_MM,
|
||||||
|
should_abort=lambda: False, **rig_kwargs):
|
||||||
|
trace = Trace()
|
||||||
|
stage = FakeStage(trace)
|
||||||
|
stage.positions = list(ref_mm)
|
||||||
|
t3r = FakeT3R(trace)
|
||||||
|
rig = FakeAlignRig(stage, t3r, ref_mm=ref_mm, **rig_kwargs)
|
||||||
|
scope = FakeAlignScope(trace, rig, acquisitions_advance=acquisitions_advance)
|
||||||
|
aligner = AutoAligner(stage, scope, t3r, settings=settings,
|
||||||
|
should_abort=should_abort)
|
||||||
|
return aligner, rig, trace, stage, t3r
|
||||||
|
|
||||||
|
|
||||||
|
def moves(trace, ch=None):
|
||||||
|
return [c for c in trace.of("t3r_move") if ch is None or c[1] == ch]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Geometry: the half that cannot be discovered at run time ─────────────────
|
||||||
|
|
||||||
|
def test_tilt_groups_are_the_moves_they_claim_to_be():
|
||||||
|
"""X tilts along X only, Y along Y only — otherwise the phases interfere.
|
||||||
|
|
||||||
|
If this fails, the azimuth map and the groups have drifted apart and the
|
||||||
|
Y phase would be undoing the X phase's correction.
|
||||||
|
"""
|
||||||
|
x_piston, x_x, x_y = tilt_response(X_TILT)
|
||||||
|
y_piston, y_x, y_y = tilt_response(Y_TILT)
|
||||||
|
|
||||||
|
assert x_x != 0 and x_y == pytest.approx(0.0, abs=1e-9)
|
||||||
|
assert y_y != 0 and y_x == pytest.approx(0.0, abs=1e-9)
|
||||||
|
# The Y pair is equal and opposite, so it lifts nothing on average; the
|
||||||
|
# single X axis unavoidably lifts the platform as well as tilting it.
|
||||||
|
assert y_piston == pytest.approx(0.0, abs=1e-9)
|
||||||
|
assert x_piston != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_x_is_corrected_by_the_axis_lying_along_x():
|
||||||
|
"""T1 sits at 0°, so it is the one that tilts the platform along X."""
|
||||||
|
assert T_AXIS_AZIMUTH_DEG[1] == 0.0
|
||||||
|
assert set(X_TILT.weights) == {1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_y_is_corrected_by_the_other_two_as_an_opposed_pair():
|
||||||
|
assert set(Y_TILT.weights) == {0, 2}
|
||||||
|
assert Y_TILT.weights[0] == -Y_TILT.weights[2]
|
||||||
|
|
||||||
|
|
||||||
|
# ── The loop does what it is for ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_alignment_cancels_the_sample_slope_on_both_axes():
|
||||||
|
"""The point of the whole procedure: a level sample when it finishes."""
|
||||||
|
aligner, rig, _, _, _ = build()
|
||||||
|
aligner.prepare()
|
||||||
|
result = aligner.run()
|
||||||
|
|
||||||
|
slope_x, slope_y = rig.slopes_mv_per_mm()
|
||||||
|
# Residual slope over the +/-1.5 mm the scan cares about, in mV.
|
||||||
|
assert abs(slope_x * DEFAULT_ALIGN.offset_mm) <= DEFAULT_ALIGN.tolerance_mv
|
||||||
|
assert abs(slope_y * DEFAULT_ALIGN.offset_mm) <= DEFAULT_ALIGN.tolerance_mv
|
||||||
|
assert result.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_search_ends_inside_the_tolerance():
|
||||||
|
aligner, _, _, _, _ = build()
|
||||||
|
reference = aligner.prepare()
|
||||||
|
result = aligner.run()
|
||||||
|
|
||||||
|
for axis in result.axes:
|
||||||
|
for offset in axis.offsets:
|
||||||
|
assert offset.converged, offset.describe()
|
||||||
|
assert offset.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
|
||||||
|
assert result.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flat_sample_gives_the_same_answer_in_both_directions():
|
||||||
|
"""Both offsets measure one angle, so on a plane they must agree.
|
||||||
|
|
||||||
|
The agreement is what licenses averaging them; see the curved case below
|
||||||
|
for what happens when it does not hold.
|
||||||
|
"""
|
||||||
|
aligner, _, _, _, _ = build()
|
||||||
|
aligner.prepare()
|
||||||
|
result = aligner.run()
|
||||||
|
|
||||||
|
for axis in result.axes:
|
||||||
|
plus, minus = axis.offsets
|
||||||
|
assert plus.correction_steps == pytest.approx(minus.correction_steps,
|
||||||
|
rel=0.02, abs=5.0)
|
||||||
|
assert axis.disagreement_steps < 10.0
|
||||||
|
assert axis.applied
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_curved_sample_is_reported_rather_than_averaged_away():
|
||||||
|
"""Curvature needs opposite corrections either side, and says so."""
|
||||||
|
# Big enough that the near side is still outside the tolerance once the
|
||||||
|
# far side's error has been curved past it — otherwise one search has
|
||||||
|
# nothing to do and the disagreement never shows up.
|
||||||
|
aligner, _, _, _, _ = build(curvature_mv_per_mm2=60.0)
|
||||||
|
aligner.prepare()
|
||||||
|
result = aligner.run()
|
||||||
|
|
||||||
|
x_axis = result.axes[0]
|
||||||
|
plus, minus = x_axis.offsets
|
||||||
|
assert plus.correction_steps * minus.correction_steps < 0 # opposite signs
|
||||||
|
assert x_axis.disagreement_steps > 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_search_survives_a_noisy_detector():
|
||||||
|
"""Five reads and a median, so a wobbling level still converges."""
|
||||||
|
aligner, _, _, _, _ = build(jitter_mv=1.5)
|
||||||
|
reference = aligner.prepare()
|
||||||
|
result = aligner.run()
|
||||||
|
assert result.final.matches(reference, DEFAULT_ALIGN.tolerance_mv)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Which hardware moves, and how ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_the_x_phase_moves_t1_and_the_y_phase_moves_t0_and_t2():
|
||||||
|
"""The phases stay on their own axes, in the order X then Y."""
|
||||||
|
aligner, _, trace, _, t3r = build()
|
||||||
|
aligner.prepare()
|
||||||
|
aligner.run()
|
||||||
|
|
||||||
|
channels = [c[1] for c in moves(trace)]
|
||||||
|
first_y = next(i for i, ch in enumerate(channels) if ch in (0, 2))
|
||||||
|
assert set(channels[:first_y]) == {1}, "the X phase moved something else"
|
||||||
|
assert set(channels[first_y:]) == {0, 2}, "the Y phase moved something else"
|
||||||
|
|
||||||
|
# The Y pair ends equal and opposite: anything else is a tilt along X the
|
||||||
|
# Y phase had no business applying.
|
||||||
|
assert t3r.positions[0] == -t3r.positions[2]
|
||||||
|
assert t3r.positions[1] != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_rotation_axis_is_never_touched():
|
||||||
|
"""GR carries the scan's angle; an alignment that moved it would silently
|
||||||
|
re-datum every subsequent scan."""
|
||||||
|
aligner, _, trace, _, t3r = build()
|
||||||
|
aligner.prepare()
|
||||||
|
aligner.run()
|
||||||
|
|
||||||
|
assert moves(trace, ch=3) == []
|
||||||
|
assert t3r.positions[3] == 0
|
||||||
|
assert [c for c in trace.of("t3r_enable") if c[1] == 3] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_t_axes_are_configured_before_they_are_moved():
|
||||||
|
"""32 microsteps and 600 mA, applied rather than assumed — a correction is
|
||||||
|
reported in microsteps, so what a microstep means has to be pinned."""
|
||||||
|
aligner, _, trace, _, _ = build()
|
||||||
|
aligner.prepare()
|
||||||
|
|
||||||
|
for ch in (0, 1, 2):
|
||||||
|
assert ("t3r_set_microstep", ch, 32) in trace.calls
|
||||||
|
run_ma = [c for c in trace.of("t3r_set_current") if c[1] == ch]
|
||||||
|
assert run_ma and run_ma[0][2] == 600
|
||||||
|
assert ("t3r_enable", ch) in trace.calls
|
||||||
|
|
||||||
|
names = trace.names()
|
||||||
|
assert "t3r_move" not in names[:names.index("t3r_enable")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_stage_steps_either_side_and_comes_back():
|
||||||
|
aligner, _, trace, stage, _ = build()
|
||||||
|
aligner.prepare()
|
||||||
|
aligner.run()
|
||||||
|
aligner.stop()
|
||||||
|
|
||||||
|
x_targets = [c[2] for c in trace.of("move_axis_absolute") if c[1] == AXIS_X]
|
||||||
|
y_targets = [c[2] for c in trace.of("move_axis_absolute") if c[1] == AXIS_Y]
|
||||||
|
off = DEFAULT_ALIGN.offset_mm
|
||||||
|
assert REF_MM[0] + off in x_targets and REF_MM[0] - off in x_targets
|
||||||
|
assert REF_MM[1] + off in y_targets and REF_MM[1] - off in y_targets
|
||||||
|
assert stage.positions == list(REF_MM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_scope_is_put_into_the_bias_reading_state():
|
||||||
|
"""The same free-running, edge-triggered state the angle inspector uses:
|
||||||
|
the operator has to be able to read CH1 while this runs."""
|
||||||
|
aligner, _, trace, _, _ = build()
|
||||||
|
aligner.prepare()
|
||||||
|
|
||||||
|
scales = {c[1]: c[2] for c in trace.of("set_channel_scale")}
|
||||||
|
for ch in BIAS_CHANNELS:
|
||||||
|
assert scales[ch] == BIAS_SCALE_V_DIV
|
||||||
|
assert ("set_trigger_level", 2, INSPECT_TRIG_LEVEL_V) in trace.calls
|
||||||
|
assert ("set_fastframe_state", False) in trace.calls
|
||||||
|
assert "ACQuire:STATE RUN" in [c[1] for c in trace.of("write")]
|
||||||
|
|
||||||
|
# Only the two bias channels are ever measured.
|
||||||
|
assert {c[1] for c in trace.of("measure_immediate")} == set(BIAS_CHANNELS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_gate_is_dropped_before_anything_moves():
|
||||||
|
"""An armed TRIGOUT would drive the scan gate on every positioning move."""
|
||||||
|
aligner, _, trace, _, _ = build()
|
||||||
|
aligner.prepare()
|
||||||
|
assert ("set_trigger_gate_off", AXIS_X) in trace.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── Refusals ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_a_dead_axis_stops_the_procedure():
|
||||||
|
"""No response to a probe, however large: something is wrong upstream of
|
||||||
|
the tilt platform, and stepping the actuators further will not find it."""
|
||||||
|
aligner, _, _, _, _ = build(tilt_gain_mv_per_mm=0.0)
|
||||||
|
aligner.prepare()
|
||||||
|
with pytest.raises(AutoAlignError, match="laser"):
|
||||||
|
aligner.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_scope_that_never_retriggers_stops_the_procedure():
|
||||||
|
"""A stale record reads as a rock-steady measurement — the one failure the
|
||||||
|
loop cannot see for itself."""
|
||||||
|
aligner, _, _, _, _ = build(acquisitions_advance=False)
|
||||||
|
with pytest.raises(AutoAlignError, match="not triggered"):
|
||||||
|
aligner.prepare()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_axis_that_would_run_out_of_travel_stops_the_procedure():
|
||||||
|
aligner, _, _, _, _ = build(x_slope_mv_per_mm=200.0,
|
||||||
|
tilt_gain_mv_per_mm=0.01)
|
||||||
|
aligner.prepare()
|
||||||
|
with pytest.raises(AutoAlignError, match="safety limit"):
|
||||||
|
aligner.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_has_to_be_room_either_side_of_the_reference_point():
|
||||||
|
aligner, _, _, _, _ = build(ref_mm=(0.5, 40.0))
|
||||||
|
with pytest.raises(AutoAlignError, match="either side"):
|
||||||
|
aligner.prepare()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_before_the_operator_confirms_is_refused():
|
||||||
|
aligner, _, _, _, _ = build()
|
||||||
|
with pytest.raises(AutoAlignError, match="prepare"):
|
||||||
|
aligner.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_hardware_is_named():
|
||||||
|
trace = Trace()
|
||||||
|
stage = FakeStage(trace)
|
||||||
|
t3r = FakeT3R(trace, is_open=False)
|
||||||
|
rig = FakeAlignRig(stage, t3r)
|
||||||
|
scope = FakeAlignScope(trace, rig)
|
||||||
|
|
||||||
|
with pytest.raises(AutoAlignError, match="T3R"):
|
||||||
|
AutoAligner(stage, scope, t3r, settings=FAST).prepare()
|
||||||
|
with pytest.raises(AutoAlignError, match="Oscilloscope"):
|
||||||
|
AutoAligner(stage, None, t3r, settings=FAST).prepare()
|
||||||
|
with pytest.raises(AutoAlignError, match="BBD202"):
|
||||||
|
AutoAligner(None, scope, t3r, settings=FAST).prepare()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_abort_stops_the_run_and_still_parks_the_stage():
|
||||||
|
"""Stopping is the operator's, so it must not leave the stage 1.5 mm off
|
||||||
|
the point they were looking at."""
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def abort_after_a_few_moves():
|
||||||
|
calls["n"] += 1
|
||||||
|
return calls["n"] > 12
|
||||||
|
|
||||||
|
aligner, _, _, stage, _ = build(should_abort=abort_after_a_few_moves)
|
||||||
|
aligner.prepare()
|
||||||
|
with pytest.raises(AutoAlignAborted):
|
||||||
|
aligner.run()
|
||||||
|
|
||||||
|
aligner.stop()
|
||||||
|
assert stage.positions == list(REF_MM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_leaves_the_correction_applied():
|
||||||
|
"""The tilt is the result — a stop parks the stage, not the platform."""
|
||||||
|
aligner, _, _, _, t3r = build()
|
||||||
|
aligner.prepare()
|
||||||
|
aligner.run()
|
||||||
|
applied = dict(t3r.positions)
|
||||||
|
aligner.stop()
|
||||||
|
assert t3r.positions == applied
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""HeliosLaser: replies that span more than one line.
|
||||||
|
|
||||||
|
Regression: every status-register query answers with the value *and* a
|
||||||
|
"Bit 15..0: ..." decode line. The driver read one line per query and threw
|
||||||
|
the rest away with reset_input_buffer(), which at 9600 baud cannot drop
|
||||||
|
bytes that are still on the wire — so from the first LER read onward every
|
||||||
|
reply was one line behind, and the panel showed a register as
|
||||||
|
"Bit 15..0: 0000 0000 0000 0010" with the reads around it timing out.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hardware.helios_laser import HeliosLaser, PulseMode
|
||||||
|
|
||||||
|
|
||||||
|
# What the controller actually sends back, transcribed from a session with
|
||||||
|
# the laser (tools/helios_lds_probe.py): CRLF line ends, the value padded
|
||||||
|
# out to a fixed width, and one or two blank lines closing every reply.
|
||||||
|
#
|
||||||
|
# b'LDS = 100 mA\r\n\r\n'
|
||||||
|
# b'LCE = 32\r\nBit 15..0: 0000 0000 0010 0000\r\n\r\n\r\n'
|
||||||
|
#
|
||||||
|
# The blank lines matter: a reader that stops at CR leaves the LF of the
|
||||||
|
# last one behind, and the next read waits out the port timeout for a CR
|
||||||
|
# that only the next command will bring.
|
||||||
|
_PAD = [""]
|
||||||
|
_REGISTER_PAD = ["", ""]
|
||||||
|
|
||||||
|
REPLIES = {
|
||||||
|
"LDO": ["LDO = 1 "] + _PAD,
|
||||||
|
"LDF": ["LDF = 20000 ns"] + _PAD,
|
||||||
|
"LDS": ["LDS = 1500 mA"] + _PAD,
|
||||||
|
"LDG": ["LDG = 14 "] + _PAD,
|
||||||
|
"LRE": ["LRE = 0 "] + _PAD,
|
||||||
|
"LTA": ["LTA = 25400 m°C"] + _PAD,
|
||||||
|
"LTT": ["LTT = 31200 m°C"] + _PAD,
|
||||||
|
"EOA": ["EOA = 40100 m°C"] + _PAD,
|
||||||
|
"CSR": ["CSR = 1234567"] + _PAD,
|
||||||
|
"HSR": ["HSR = 7654321"] + _PAD,
|
||||||
|
# The registers are the multi-line ones.
|
||||||
|
"LER": ["LER = 0", "Bit 15..0: 0000 0000 0000 0000"] + _REGISTER_PAD,
|
||||||
|
"LCE": ["LCE = 2", "Bit 15..0: 0000 0000 0000 0010"] + _REGISTER_PAD,
|
||||||
|
"CCE": ["CCE = 0", "Bit 15..0: 0000 0000 0000 0000"] + _REGISTER_PAD,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakePort:
|
||||||
|
"""Serial stand-in that answers like the Helios controller.
|
||||||
|
|
||||||
|
``reset_input_buffer`` is deliberately a no-op: the rest of a reply is
|
||||||
|
still in flight when the driver has read its first line, so a flush
|
||||||
|
cannot remove it. The driver has to stay in step by reading what it
|
||||||
|
asked for, not by discarding what it happens to find.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, replies=None, timeout=1.0, discard_writes=False):
|
||||||
|
self.replies = dict(REPLIES) if replies is None else replies
|
||||||
|
self.timeout = timeout
|
||||||
|
self.is_open = True
|
||||||
|
# "Commands or set values can be discarded by the controller
|
||||||
|
# unintentionally" (manual, Section 6) — the case a verified write
|
||||||
|
# exists to catch.
|
||||||
|
self.discard_writes = discard_writes
|
||||||
|
self.written: list[str] = []
|
||||||
|
self._buf = bytearray()
|
||||||
|
|
||||||
|
def _store(self, mnemonic: str, value: str):
|
||||||
|
"""Keep a written value, so a later query reads back what was set."""
|
||||||
|
if self.discard_writes:
|
||||||
|
return
|
||||||
|
previous = self.replies.get(mnemonic, [f"{mnemonic} = 0"])[0]
|
||||||
|
unit = previous.split()[3:] # "LDS = 1500 mA" -> ["mA"]
|
||||||
|
self.replies[mnemonic] = [" ".join([f"{mnemonic} =", value, *unit])]
|
||||||
|
|
||||||
|
# ── the bits of pyserial.Serial the driver uses ──────────────────────────
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> int:
|
||||||
|
text = data.decode("ascii").strip()
|
||||||
|
self.written.append(text)
|
||||||
|
fields = text.split()
|
||||||
|
if len(fields) > 1:
|
||||||
|
self._store(fields[0].upper(), fields[1])
|
||||||
|
for line in self.replies.get(fields[0].upper() if fields else "", []):
|
||||||
|
self._buf += line.encode("utf-8") + b"\r\n"
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def in_waiting(self) -> int:
|
||||||
|
return len(self._buf)
|
||||||
|
|
||||||
|
def read(self, size: int = 1) -> bytes:
|
||||||
|
chunk = bytes(self._buf[:size])
|
||||||
|
del self._buf[:size]
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def read_until(self, expected: bytes = b"\n", size=None) -> bytes:
|
||||||
|
# No terminator in the buffer models the read timing out: pyserial
|
||||||
|
# returns whatever it has, which is b"" when nothing is pending.
|
||||||
|
cut = self._buf.find(expected)
|
||||||
|
cut = len(self._buf) if cut < 0 else cut + len(expected)
|
||||||
|
chunk = bytes(self._buf[:cut])
|
||||||
|
del self._buf[:cut]
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def reset_input_buffer(self):
|
||||||
|
"""No-op — see the class docstring."""
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.is_open = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def laser():
|
||||||
|
"""A connected driver on a fake port, with the idle wait taken out.
|
||||||
|
|
||||||
|
TRAILING_QUIET_S covers the ~30 ms a decode line spends on the wire at
|
||||||
|
9600 baud; the fake answers instantly, so waiting for it only slows the
|
||||||
|
suite down. Zeroing it also keeps the tests honest: they pass because
|
||||||
|
the driver reads the whole reply, not because it waited long enough.
|
||||||
|
"""
|
||||||
|
drv = HeliosLaser(timeout=1.0)
|
||||||
|
drv.serial = FakePort()
|
||||||
|
drv.is_connected = True
|
||||||
|
drv.TRAILING_QUIET_S = 0.0
|
||||||
|
drv.SET_SETTLE_S = 0.0
|
||||||
|
return drv
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stubborn_laser():
|
||||||
|
"""A controller that answers every query but keeps its own set values."""
|
||||||
|
drv = HeliosLaser(timeout=1.0)
|
||||||
|
drv.serial = FakePort(discard_writes=True)
|
||||||
|
drv.is_connected = True
|
||||||
|
drv.TRAILING_QUIET_S = 0.0
|
||||||
|
drv.SET_SETTLE_S = 0.0
|
||||||
|
return drv
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_registers_are_read_in_step(laser):
|
||||||
|
"""The regression: each register gets its own value, not the previous
|
||||||
|
register's decode line."""
|
||||||
|
assert laser.get_status_registers() == (0, 2, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_after_a_register_are_not_a_line_behind(laser):
|
||||||
|
"""A whole status poll, in the order HeliosWorker._poll_once issues it."""
|
||||||
|
assert laser.get_status_registers() == (0, 2, 0)
|
||||||
|
assert laser.is_laser_enabled() is True
|
||||||
|
assert laser.get_current_ma() == 1500
|
||||||
|
assert laser.get_diode_temp_c() == pytest.approx(25.4)
|
||||||
|
assert laser.get_power_stage_temp_c() == pytest.approx(31.2)
|
||||||
|
assert laser.get_qswitch_temp_c() == pytest.approx(40.1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_line_is_consumed_not_left_behind(laser):
|
||||||
|
laser.get_status_registers()
|
||||||
|
assert laser.serial.in_waiting == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_suffix_is_stripped(laser):
|
||||||
|
assert laser.get_frequency_hz() == 50000 # "LDF = 20000 ns"
|
||||||
|
assert laser.get_current_ma() == 1500 # "LDS = 1500 mA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reply_from_an_earlier_command_is_skipped(laser):
|
||||||
|
"""An answer already in the buffer when the query goes out belongs to
|
||||||
|
whoever asked for it, and must not be returned as this query's value."""
|
||||||
|
laser.serial._buf += b"LER = 8\r\nBit 15..0: 0000 0000 0000 1000\r\n"
|
||||||
|
assert laser.get_current_ma() == 1500
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_without_a_mnemonic_is_taken_as_the_value(laser):
|
||||||
|
"""The serial numbers come back as a bare string on some firmware."""
|
||||||
|
laser.serial.replies = {"CSR": ["A1B2C3D4"]}
|
||||||
|
assert laser.get_controller_serial() == "A1B2C3D4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_silent_device_reports_a_timeout(laser):
|
||||||
|
laser.serial.replies = {}
|
||||||
|
assert laser.get_current_ma() is None
|
||||||
|
assert laser._query("LTA") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_commands_clear_their_acknowledgement(laser):
|
||||||
|
"""A setter that leaves the controller's echo in the buffer desynchronises
|
||||||
|
the next query just as a decode line does."""
|
||||||
|
assert laser.set_laser_enable(True) is True
|
||||||
|
assert laser.serial.in_waiting == 0
|
||||||
|
assert laser.set_pulse_mode(PulseMode.CONTINUOUS_PULSING) is True
|
||||||
|
assert laser.get_current_ma() == 1500
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_command_returns_every_line(laser):
|
||||||
|
"""The diagnostics console is where a multi-line reply should be visible."""
|
||||||
|
assert laser.send_raw_command("LCE") == (
|
||||||
|
"LCE = 2\nBit 15..0: 0000 0000 0000 0010"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Set values the controller may discard ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Section 6 of the manual: "Commands or set values can be discarded by the
|
||||||
|
# controller unintentionally. It is recommended to query the set value
|
||||||
|
# after the command is entered to confirm the actual value." A write that
|
||||||
|
# reports success without reading back leaves the panel showing a setpoint
|
||||||
|
# the laser never took, until the next poll replaces it with the old value.
|
||||||
|
|
||||||
|
def test_a_set_current_is_read_back(laser):
|
||||||
|
assert laser.set_current_ma(900) is True
|
||||||
|
assert laser.get_current_ma() == 900
|
||||||
|
assert "LDS 900" in laser.serial.written
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_discarded_set_current_is_retried_then_reported(stubborn_laser):
|
||||||
|
assert stubborn_laser.set_current_ma(900) is False
|
||||||
|
# Retried, not given up on after one write.
|
||||||
|
assert stubborn_laser.serial.written.count("LDS 900") == \
|
||||||
|
stubborn_laser.SET_RETRIES
|
||||||
|
# And the controller's own value is what it still holds.
|
||||||
|
assert stubborn_laser.get_current_ma() == 1500
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_set_that_takes_on_a_retry_succeeds(laser):
|
||||||
|
"""One dropped write, then the controller accepts — still a success."""
|
||||||
|
real_write = laser.serial.write
|
||||||
|
state = {"drops": 1}
|
||||||
|
|
||||||
|
def flaky(data: bytes) -> int:
|
||||||
|
if data.decode("ascii").strip().startswith("LDS ") and state["drops"]:
|
||||||
|
state["drops"] -= 1
|
||||||
|
laser.serial.written.append(data.decode("ascii").strip())
|
||||||
|
return len(data) # swallowed: nothing stored, no reply
|
||||||
|
return real_write(data)
|
||||||
|
|
||||||
|
laser.serial.write = flaky
|
||||||
|
assert laser.set_current_ma(900) is True
|
||||||
|
assert laser.get_current_ma() == 900
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_set_frequency_is_read_back(laser):
|
||||||
|
assert laser.set_frequency_hz(25000) is True # 40000 ns
|
||||||
|
assert laser.get_frequency_hz() == 25000
|
||||||
|
assert "LDF 40000" in laser.serial.written
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_out_of_range_current_is_not_sent(laser):
|
||||||
|
assert laser.set_current_ma(9000) is False
|
||||||
|
assert laser.serial.written == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unlabelled_number_is_not_taken_as_a_register_value(laser):
|
||||||
|
"""A bare number answers nothing in particular.
|
||||||
|
|
||||||
|
The controller's status registers read 32 when bit 5 is set (LER "Over
|
||||||
|
voltage laser diode", LCE "Door switch open"), and a diode current of
|
||||||
|
32 mA is a perfectly ordinary-looking value — so a stray "32" must not
|
||||||
|
be allowed to pass for the answer to LDS.
|
||||||
|
"""
|
||||||
|
laser.serial.replies = {"LDS": ["32"]}
|
||||||
|
assert laser.get_current_ma() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_serial_number_still_comes_back_bare(laser):
|
||||||
|
"""The one reply that legitimately names nothing."""
|
||||||
|
laser.serial.replies = {"CSR": ["A1B2C3D4"], "HSR": ["7654321"]}
|
||||||
|
assert laser.get_controller_serial() == "A1B2C3D4"
|
||||||
|
assert laser.get_head_serial() == "7654321"
|
||||||
|
|
||||||
|
|
||||||
|
class SplitReplyPort(FakePort):
|
||||||
|
"""Answers LCE in two pieces, the tail arriving after the next command.
|
||||||
|
|
||||||
|
That is what the wire looks like when a query gives up early: at 9600
|
||||||
|
baud the rest of the reply is still coming, and reset_input_buffer()
|
||||||
|
cannot drop bytes that have not arrived. The fragment left over is
|
||||||
|
" 32" — the value half of "LCE = 32", which is a plausible
|
||||||
|
diode current and was read as one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._late = b""
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> int:
|
||||||
|
text = data.decode("ascii").strip()
|
||||||
|
self.written.append(text)
|
||||||
|
mnemonic = text.split()[0].upper() if text.split() else ""
|
||||||
|
# Whatever is asked next, the last reply's tail lands in front of it.
|
||||||
|
self._buf += self._late
|
||||||
|
self._late = b""
|
||||||
|
if mnemonic == "LCE":
|
||||||
|
self._buf += b"LCE =" # ...and no line ending yet
|
||||||
|
self._late = b" 32\r\n\r\n\r\n"
|
||||||
|
return len(data)
|
||||||
|
for line in self.replies.get(mnemonic, []):
|
||||||
|
self._buf += line.encode("utf-8") + b"\r\n"
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_late_fragment_is_not_the_next_query_s_value():
|
||||||
|
"""The regression this branch exists for.
|
||||||
|
|
||||||
|
LCE's reply is cut in half, so the register read gives up. The tail
|
||||||
|
arrives while the *next* query is being answered, and "32" is what the
|
||||||
|
panel showed as the pump diode current — LCE bit 5, "Door switch open",
|
||||||
|
read as milliamps.
|
||||||
|
"""
|
||||||
|
drv = HeliosLaser(timeout=1.0)
|
||||||
|
drv.serial = SplitReplyPort()
|
||||||
|
drv.is_connected = True
|
||||||
|
drv.TRAILING_QUIET_S = 0.0
|
||||||
|
|
||||||
|
assert drv._query_int("LCE") is None # cut off mid-reply
|
||||||
|
assert drv.get_current_ma() == 1500 # not 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reply_is_read_without_waiting_out_the_port(laser):
|
||||||
|
"""Nothing is left in either buffer once a reply has been read.
|
||||||
|
|
||||||
|
A leftover LF costs a whole port timeout on the next read, which is
|
||||||
|
what made a status poll take ~8.6 s against a 1 s interval.
|
||||||
|
"""
|
||||||
|
laser.timeout = 0.01 # a wait would show up as a failure below
|
||||||
|
assert laser.get_status_registers() == (0, 2, 0)
|
||||||
|
assert laser.get_current_ma() == 1500
|
||||||
|
assert laser.serial.in_waiting == 0
|
||||||
|
assert laser._rx == bytearray()
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""gui.jog_panel: the camera window's T3R and BBD202 jog controls.
|
||||||
|
|
||||||
|
The panels are the only place these devices are driven by a held button, so
|
||||||
|
what matters here is that press/release map onto the right pair of commands
|
||||||
|
and that the operator's velocity/microstep settings ride along.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
import hardware.t3r_protocol as proto
|
||||||
|
from gui.jog_panel import BBDJogPanel, T3RJogPanel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qapp():
|
||||||
|
yield QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
class FakeT3R(QObject):
|
||||||
|
"""The slice of QtT3RAdapter the T3R panel touches."""
|
||||||
|
handshake_ok = pyqtSignal(int, int, int)
|
||||||
|
disconnected = pyqtSignal(str)
|
||||||
|
info_updated = pyqtSignal(int, object)
|
||||||
|
|
||||||
|
def __init__(self, is_open=True):
|
||||||
|
super().__init__()
|
||||||
|
self.is_open = is_open
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def enable(self, ch):
|
||||||
|
self.calls.append(("enable", ch))
|
||||||
|
|
||||||
|
def disable(self, ch):
|
||||||
|
self.calls.append(("disable", ch))
|
||||||
|
|
||||||
|
def set_microstep(self, ch, microsteps):
|
||||||
|
self.calls.append(("set_microstep", ch, microsteps))
|
||||||
|
|
||||||
|
def jog(self, ch, velocity, accel):
|
||||||
|
self.calls.append(("jog", ch, velocity, accel))
|
||||||
|
|
||||||
|
def stop(self, ch, hard):
|
||||||
|
self.calls.append(("stop", ch, hard))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBBD(QObject):
|
||||||
|
"""The slice of BBD202Worker the BBD panel touches."""
|
||||||
|
connected = pyqtSignal()
|
||||||
|
disconnected = pyqtSignal()
|
||||||
|
position_updated = pyqtSignal(float, float)
|
||||||
|
|
||||||
|
def __init__(self, is_connected=True):
|
||||||
|
super().__init__()
|
||||||
|
self.is_connected = is_connected
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def queue_jog(self, axis, direction, step_mm=None):
|
||||||
|
self.calls.append(("jog", axis, direction, step_mm))
|
||||||
|
|
||||||
|
def queue_set_velocity(self, max_velocity, acceleration):
|
||||||
|
self.calls.append(("velocity", max_velocity, acceleration))
|
||||||
|
|
||||||
|
|
||||||
|
def _info(ch, position=0, microsteps=16, enabled=True):
|
||||||
|
return proto.Info(ch=ch, state=0, position=position, velocity=0,
|
||||||
|
microsteps=microsteps, run_ma=800, hold_ma=400,
|
||||||
|
enabled=enabled, comms_ok=True, fault_mask=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── T3R ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_t3r_jog_holds_then_stops(qapp):
|
||||||
|
drv = FakeT3R()
|
||||||
|
panel = T3RJogPanel(drv)
|
||||||
|
panel.vel_spin.setValue(1234)
|
||||||
|
panel.accel_spin.setValue(99)
|
||||||
|
|
||||||
|
btn = panel._jog_btns[(0, -1)]
|
||||||
|
btn.pressed.emit()
|
||||||
|
assert drv.calls == [("jog", 0, -1234, 99)]
|
||||||
|
|
||||||
|
btn.released.emit()
|
||||||
|
assert drv.calls[-1] == ("stop", 0, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_t3r_release_without_press_sends_nothing(qapp):
|
||||||
|
"""A stray release must not stop an axis a scan is driving."""
|
||||||
|
drv = FakeT3R()
|
||||||
|
panel = T3RJogPanel(drv)
|
||||||
|
panel._jog_btns[(3, 1)].released.emit()
|
||||||
|
assert drv.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_t3r_stop_jogs_covers_a_lost_release(qapp):
|
||||||
|
drv = FakeT3R()
|
||||||
|
panel = T3RJogPanel(drv)
|
||||||
|
panel._jog_btns[(1, 1)].pressed.emit()
|
||||||
|
drv.calls.clear()
|
||||||
|
|
||||||
|
panel.stop_jogs()
|
||||||
|
assert drv.calls == [("stop", 1, False)]
|
||||||
|
panel.stop_jogs() # already stopped: no repeat command
|
||||||
|
assert drv.calls == [("stop", 1, False)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_t3r_microstep_applies_and_survives_a_stale_poll(qapp):
|
||||||
|
drv = FakeT3R()
|
||||||
|
panel = T3RJogPanel(drv)
|
||||||
|
combo = panel._micro_combos[2]
|
||||||
|
|
||||||
|
combo.setCurrentIndex(combo.findData(64))
|
||||||
|
combo.activated.emit(combo.currentIndex())
|
||||||
|
assert drv.calls == [("set_microstep", 2, 64)]
|
||||||
|
|
||||||
|
# An info frame already in flight still carries the old value.
|
||||||
|
drv.info_updated.emit(2, _info(2, microsteps=16))
|
||||||
|
assert combo.currentData() == 64
|
||||||
|
|
||||||
|
# Once the device confirms, the combo tracks it again.
|
||||||
|
drv.info_updated.emit(2, _info(2, microsteps=64))
|
||||||
|
assert combo.currentData() == 64
|
||||||
|
drv.info_updated.emit(2, _info(2, microsteps=8))
|
||||||
|
assert combo.currentData() == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_t3r_enable_checkbox_follows_the_device(qapp):
|
||||||
|
drv = FakeT3R()
|
||||||
|
panel = T3RJogPanel(drv)
|
||||||
|
|
||||||
|
panel._enable_chks[0].setChecked(True)
|
||||||
|
assert drv.calls == [("enable", 0)]
|
||||||
|
|
||||||
|
# A device-side state change updates the box without echoing a command.
|
||||||
|
drv.info_updated.emit(0, _info(0, position=-42, enabled=False))
|
||||||
|
assert not panel._enable_chks[0].isChecked()
|
||||||
|
assert drv.calls == [("enable", 0)]
|
||||||
|
assert panel._pos_lbls[0].text() == "-42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_t3r_panel_tracks_connection(qapp):
|
||||||
|
drv = FakeT3R(is_open=False)
|
||||||
|
panel = T3RJogPanel(drv)
|
||||||
|
assert not panel.isEnabled()
|
||||||
|
|
||||||
|
drv.handshake_ok.emit(1, 1, 4)
|
||||||
|
assert panel.isEnabled()
|
||||||
|
|
||||||
|
drv.info_updated.emit(0, _info(0, position=7))
|
||||||
|
drv.disconnected.emit("cable")
|
||||||
|
assert not panel.isEnabled()
|
||||||
|
assert panel._pos_lbls[0].text() == "—"
|
||||||
|
|
||||||
|
|
||||||
|
# ── BBD202 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_bbd_jog_repeats_while_held(qapp):
|
||||||
|
worker = FakeBBD()
|
||||||
|
panel = BBDJogPanel(worker)
|
||||||
|
panel.step_spin.setValue(0.25)
|
||||||
|
|
||||||
|
panel.x_pos_btn.pressed.emit()
|
||||||
|
assert worker.calls == [("jog", "x", 1, 0.25)]
|
||||||
|
assert panel._repeat.isActive()
|
||||||
|
|
||||||
|
panel._jog_tick() # what the repeat timer fires
|
||||||
|
assert worker.calls[-1] == ("jog", "x", 1, 0.25)
|
||||||
|
|
||||||
|
panel.x_pos_btn.released.emit()
|
||||||
|
assert not panel._repeat.isActive()
|
||||||
|
panel._jog_tick() # a late tick moves nothing
|
||||||
|
assert len(worker.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbd_jog_directions(qapp):
|
||||||
|
worker = FakeBBD()
|
||||||
|
panel = BBDJogPanel(worker)
|
||||||
|
for btn, expected in ((panel.x_neg_btn, ("jog", "x", -1, 0.5)),
|
||||||
|
(panel.y_pos_btn, ("jog", "y", 1, 0.5)),
|
||||||
|
(panel.y_neg_btn, ("jog", "y", -1, 0.5))):
|
||||||
|
btn.pressed.emit()
|
||||||
|
btn.released.emit()
|
||||||
|
assert worker.calls[-1] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbd_velocity_is_debounced_then_applied(qapp):
|
||||||
|
worker = FakeBBD()
|
||||||
|
panel = BBDJogPanel(worker)
|
||||||
|
|
||||||
|
panel.vel_spin.setValue(4.0)
|
||||||
|
panel.accel_spin.setValue(20.0)
|
||||||
|
assert worker.calls == [] # nothing sent mid-adjustment
|
||||||
|
assert panel._vel_debounce.isActive()
|
||||||
|
|
||||||
|
panel.apply_velocity()
|
||||||
|
assert worker.calls == [("velocity", 4.0, 20.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbd_panel_tracks_connection(qapp):
|
||||||
|
worker = FakeBBD(is_connected=False)
|
||||||
|
panel = BBDJogPanel(worker)
|
||||||
|
assert not panel.isEnabled()
|
||||||
|
|
||||||
|
worker.position_updated.emit(12.0, 34.5)
|
||||||
|
assert panel.x_pos_lbl.text() == "012.000"
|
||||||
|
assert panel.y_pos_lbl.text() == "034.500"
|
||||||
|
|
||||||
|
worker.is_connected = True
|
||||||
|
worker.connected.emit()
|
||||||
|
assert panel.isEnabled()
|
||||||
|
assert worker.calls == [("velocity", panel.vel_spin.value(),
|
||||||
|
panel.accel_spin.value())]
|
||||||
|
|
||||||
|
worker.disconnected.emit()
|
||||||
|
assert not panel.isEnabled()
|
||||||
|
assert panel.x_pos_lbl.text() == "---.---"
|
||||||
@@ -137,6 +137,55 @@ def test_polling_never_overlaps_or_backs_up(qapp):
|
|||||||
assert queued <= 1, f"{queued} stale polls queued up"
|
assert queued <= 1, f"{queued} stale polls queued up"
|
||||||
|
|
||||||
|
|
||||||
|
class _YieldingPoller(PollingQueueWorker):
|
||||||
|
"""A poll made of several reads that gives up as soon as work arrives."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(poll_interval_s=0.02)
|
||||||
|
self.reads = 0
|
||||||
|
self.handled = []
|
||||||
|
self.is_connected = True
|
||||||
|
self._handlers["click"] = self._click
|
||||||
|
|
||||||
|
def _click(self, value):
|
||||||
|
self.handled.append(value)
|
||||||
|
|
||||||
|
def _poll_once(self):
|
||||||
|
for _ in range(6):
|
||||||
|
if self._work_pending():
|
||||||
|
return
|
||||||
|
time.sleep(0.02)
|
||||||
|
self.reads += 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_queued_command_interrupts_a_poll(qapp):
|
||||||
|
"""A button pressed mid-poll should not wait out the whole sweep.
|
||||||
|
|
||||||
|
The Helios sweep is eight serial queries; before this, a command queued
|
||||||
|
behind one waited for every last read to finish.
|
||||||
|
"""
|
||||||
|
w = _YieldingPoller()
|
||||||
|
t = threading.Thread(target=w.run, daemon=True)
|
||||||
|
t.start()
|
||||||
|
w.start_polling()
|
||||||
|
time.sleep(0.03) # a poll is now in progress
|
||||||
|
|
||||||
|
pressed = time.monotonic()
|
||||||
|
w._enqueue("click", value="set current")
|
||||||
|
deadline = pressed + 2.0
|
||||||
|
while not w.handled and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.002)
|
||||||
|
waited = time.monotonic() - pressed
|
||||||
|
|
||||||
|
w.stop_polling()
|
||||||
|
w.stop_worker()
|
||||||
|
t.join(timeout=5)
|
||||||
|
|
||||||
|
assert w.handled == ["set current"]
|
||||||
|
# A full sweep is 6 x 20 ms; the command must not have waited for it.
|
||||||
|
assert waited < 0.08, f"command waited {waited * 1000:.0f} ms for the poll"
|
||||||
|
|
||||||
|
|
||||||
def test_stop_polling_halts_the_cycle(qapp):
|
def test_stop_polling_halts_the_cycle(qapp):
|
||||||
w = _Poller()
|
w = _Poller()
|
||||||
t = threading.Thread(target=w.run, daemon=True)
|
t = threading.Thread(target=w.run, daemon=True)
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Middle-row SAW quality check: plan reduction, the v11 file, and the read-out.
|
||||||
|
|
||||||
|
The acquisition half runs on the same fake rig as the scan tests; the
|
||||||
|
analysis half runs on a synthetic v11 file whose CH1 is a pure sine at a
|
||||||
|
known FFT bin, so the frequency a trace reports is a number the test knows
|
||||||
|
in advance rather than one it copies from the implementation.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.rotation import RotationAxis, RotationSettings
|
||||||
|
from core.saw_check import (
|
||||||
|
SPREAD_GOOD_PCT, alignment_summary, frequency_traces, middle_row_index,
|
||||||
|
middle_row_plan,
|
||||||
|
)
|
||||||
|
from core.scan_engine import ScanCallbacks, ScanEngine
|
||||||
|
from core.scan_geometry import ScanGeometryError, build_plan
|
||||||
|
from core.sras_format import (
|
||||||
|
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, create_scan_file,
|
||||||
|
write_background_block,
|
||||||
|
)
|
||||||
|
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
||||||
|
|
||||||
|
SAMPLE_RATE = 6.25e9
|
||||||
|
SPF = 256
|
||||||
|
LASER_FREQ_HZ = 20000.0
|
||||||
|
VELOCITY_MM_S = 100.0
|
||||||
|
PREAMBLES = [f"WFMOUTPRE:CH{ch};YMULT 1.5625E-3;YOFF -87.04;YZERO 0.0"
|
||||||
|
for ch in SCAN_CHANNELS]
|
||||||
|
# adc_to_mv with those constants maps 0 → +136 mV and -120 → -51 mV, so a
|
||||||
|
# frame of zeros passes a 50 mV CH4 gate and a frame of -120 does not.
|
||||||
|
DC_THRESHOLD_MV = 50.0
|
||||||
|
CH4_PASS = bytes(SPF)
|
||||||
|
CH4_FAIL = bytes([256 - 120]) * SPF
|
||||||
|
|
||||||
|
|
||||||
|
def full_plan(num_angles=3, y_delta=0.05):
|
||||||
|
"""A small ROI, well inside the stage limits, with several rows per angle."""
|
||||||
|
return build_plan(40.0, 30.0, 0.02, y_delta, num_angles, 0.01,
|
||||||
|
laser_freq_hz=LASER_FREQ_HZ, velocity_mm_s=VELOCITY_MM_S)
|
||||||
|
|
||||||
|
|
||||||
|
def bin_mhz(k: int) -> float:
|
||||||
|
return k * SAMPLE_RATE / SPF / 1e6
|
||||||
|
|
||||||
|
|
||||||
|
def sine_frame(k: int) -> bytes:
|
||||||
|
"""One frame holding a pure sine at FFT bin `k`."""
|
||||||
|
n = np.arange(SPF)
|
||||||
|
return np.round(100 * np.sin(2 * math.pi * k * n / SPF)).astype(np.int8).tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def write_check(path, bins, n_masked_frames=0, plan=None, backgrounds=None):
|
||||||
|
"""A synthetic v11 file: angle `i`'s CH1 is a sine at FFT bin `bins[i]`.
|
||||||
|
|
||||||
|
``backgrounds`` supplies each angle's own background block; the default
|
||||||
|
is a flat zero one per angle, which subtracts to nothing.
|
||||||
|
"""
|
||||||
|
plan = plan if plan is not None else middle_row_plan(full_plan(len(bins)))
|
||||||
|
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES,
|
||||||
|
version=VERSION_SAW_CHECK)
|
||||||
|
try:
|
||||||
|
for ai, pa in enumerate(plan.per_angle):
|
||||||
|
write_background_block(
|
||||||
|
f, bytes(SPF) if backgrounds is None else backgrounds[ai])
|
||||||
|
wave = sine_frame(bins[ai])
|
||||||
|
for ch in SCAN_CHANNELS:
|
||||||
|
for fi in range(pa.n_frames):
|
||||||
|
if ch == 1:
|
||||||
|
f.write(wave)
|
||||||
|
elif ch == 3:
|
||||||
|
f.write(bytes(SPF))
|
||||||
|
else:
|
||||||
|
f.write(CH4_FAIL if fi < n_masked_frames else CH4_PASS)
|
||||||
|
finally:
|
||||||
|
f.close()
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plan reduction ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_middle_row_plan_keeps_one_middle_row_per_angle():
|
||||||
|
plan = full_plan(num_angles=3)
|
||||||
|
check = middle_row_plan(plan)
|
||||||
|
|
||||||
|
assert check.n_angles == plan.n_angles
|
||||||
|
assert [pa.n_rows for pa in check.per_angle] == [1] * plan.n_angles
|
||||||
|
for original, reduced in zip(plan.per_angle, check.per_angle, strict=True):
|
||||||
|
mid = original.n_rows // 2
|
||||||
|
assert reduced.y_positions == [original.y_positions[mid]]
|
||||||
|
# The row is scanned exactly as the full scan would have scanned it.
|
||||||
|
assert reduced.angle_deg == original.angle_deg
|
||||||
|
assert reduced.x_start == original.x_start
|
||||||
|
assert reduced.x_delta == original.x_delta
|
||||||
|
assert reduced.n_frames == original.n_frames
|
||||||
|
|
||||||
|
|
||||||
|
def test_middle_row_plan_does_not_mutate_its_input():
|
||||||
|
plan = full_plan(num_angles=3)
|
||||||
|
before = [(pa.n_rows, list(pa.y_positions)) for pa in plan.per_angle]
|
||||||
|
middle_row_plan(plan)
|
||||||
|
assert [(pa.n_rows, pa.y_positions) for pa in plan.per_angle] == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_angles_middle_row_crosses_the_roi_centre():
|
||||||
|
"""The premise the whole comparison rests on: one shared point on the sample."""
|
||||||
|
plan = full_plan(num_angles=5)
|
||||||
|
check = middle_row_plan(plan)
|
||||||
|
cx = plan.x_start_nominal + plan.x_delta_nominal / 2
|
||||||
|
cy = plan.y_start_nominal + plan.y_delta_nominal / 2
|
||||||
|
for pa in check.per_angle:
|
||||||
|
assert pa.x_start + pa.x_delta / 2 == pytest.approx(cx, abs=1e-6)
|
||||||
|
# Within one row spacing — the middle row is a grid point, not exact.
|
||||||
|
assert abs(pa.y_positions[0] - cy) <= plan.row_spacing
|
||||||
|
|
||||||
|
|
||||||
|
def test_middle_row_index_rule():
|
||||||
|
assert [middle_row_index(n) for n in (1, 2, 3, 4, 6)] == [0, 1, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_middle_row_plan_rejects_an_empty_plan():
|
||||||
|
plan = full_plan(num_angles=1)
|
||||||
|
plan.per_angle = []
|
||||||
|
with pytest.raises(ScanGeometryError, match="no angles"):
|
||||||
|
middle_row_plan(plan)
|
||||||
|
|
||||||
|
|
||||||
|
def test_middle_row_plan_rejects_an_angle_with_no_rows():
|
||||||
|
plan = full_plan(num_angles=1)
|
||||||
|
plan.per_angle[0].y_positions = []
|
||||||
|
with pytest.raises(ScanGeometryError, match="no middle row"):
|
||||||
|
middle_row_plan(plan)
|
||||||
|
|
||||||
|
|
||||||
|
# ── The v11 file ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_saw_check_write_read_roundtrip(tmp_path):
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
plan = write_check(out, bins=(8, 8, 8))
|
||||||
|
|
||||||
|
sras = SrasFile(out)
|
||||||
|
assert sras.version == VERSION_SAW_CHECK
|
||||||
|
assert sras.is_saw_check
|
||||||
|
assert [s.status for s in sras.angle_status()] == ["OK"] * plan.n_angles
|
||||||
|
assert [pa.n_rows for pa in sras.per_angle] == [1] * plan.n_angles
|
||||||
|
sras.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_saw_check_rejects_a_multi_row_plan(tmp_path):
|
||||||
|
plan = full_plan(num_angles=2)
|
||||||
|
assert any(pa.n_rows > 1 for pa in plan.per_angle)
|
||||||
|
with pytest.raises(ValueError, match="exactly one row per angle"):
|
||||||
|
create_scan_file(tmp_path / "bad.sras", plan, SPF, SAMPLE_RATE,
|
||||||
|
PREAMBLES, version=VERSION_SAW_CHECK)
|
||||||
|
assert not (tmp_path / "bad.sras").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_version_rejected_at_write(tmp_path):
|
||||||
|
with pytest.raises(ValueError, match="version 99"):
|
||||||
|
create_scan_file(tmp_path / "bad.sras", middle_row_plan(full_plan(1)),
|
||||||
|
SPF, SAMPLE_RATE, PREAMBLES, version=99)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_scan_is_not_a_saw_check():
|
||||||
|
"""Legacy and current scans alike: only the check versions say check."""
|
||||||
|
assert not SrasFile("tests/golden/complete.sras").is_saw_check
|
||||||
|
assert VERSION not in (10, VERSION_SAW_CHECK)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Acquisition through the engine ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def run_engine(tmp_path, num_angles=3):
|
||||||
|
trace = Trace()
|
||||||
|
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||||
|
stage = FakeStage(trace, scope=scope)
|
||||||
|
rotator = RotationAxis(FakeT3R(trace), RotationSettings())
|
||||||
|
plan = full_plan(num_angles)
|
||||||
|
check = middle_row_plan(plan)
|
||||||
|
engine = ScanEngine(stage, scope, rotator, check, tmp_path / "check.sras",
|
||||||
|
callbacks=ScanCallbacks(),
|
||||||
|
file_version=VERSION_SAW_CHECK)
|
||||||
|
return engine.run(), plan, check, trace
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_writes_a_complete_saw_check(tmp_path):
|
||||||
|
result, plan, check, _ = run_engine(tmp_path)
|
||||||
|
|
||||||
|
assert not result.aborted
|
||||||
|
assert result.rows_written == check.n_angles # exactly one row per angle
|
||||||
|
assert result.angles_acquired == list(range(check.n_angles))
|
||||||
|
|
||||||
|
sras = SrasFile(result.path)
|
||||||
|
assert sras.is_saw_check
|
||||||
|
assert [s.status for s in sras.angle_status()] == ["OK"] * check.n_angles
|
||||||
|
assert [pa.y_positions for pa in sras.per_angle] == [
|
||||||
|
[pytest.approx(original.y_positions[original.n_rows // 2], abs=1e-4)]
|
||||||
|
for original in plan.per_angle
|
||||||
|
]
|
||||||
|
sras.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_visits_each_middle_row_once(tmp_path):
|
||||||
|
_, _, check, trace = run_engine(tmp_path)
|
||||||
|
y_moves = [round(c[2], 4) for c in trace.of("move_axis_absolute")
|
||||||
|
if c[1] == 0x22]
|
||||||
|
assert y_moves == [round(pa.y_positions[0], 4) for pa in check.per_angle]
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_still_writes_a_full_scan_by_default(tmp_path):
|
||||||
|
trace = Trace()
|
||||||
|
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||||
|
stage = FakeStage(trace, scope=scope)
|
||||||
|
rotator = RotationAxis(FakeT3R(trace), RotationSettings())
|
||||||
|
engine = ScanEngine(stage, scope, rotator, full_plan(1),
|
||||||
|
tmp_path / "scan.sras", callbacks=ScanCallbacks())
|
||||||
|
result = engine.run()
|
||||||
|
assert SrasFile(result.path).version == VERSION
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analysis ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_traces_report_the_injected_frequency(tmp_path):
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
bins = (8, 9, 10)
|
||||||
|
write_check(out, bins=bins)
|
||||||
|
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||||
|
|
||||||
|
assert len(traces) == len(bins)
|
||||||
|
for trace, k in zip(traces, bins, strict=True):
|
||||||
|
assert np.allclose(trace.freq_mhz, bin_mhz(k))
|
||||||
|
assert trace.median_mhz == pytest.approx(bin_mhz(k))
|
||||||
|
assert trace.valid_fraction == 1.0
|
||||||
|
assert trace.drift_mhz_per_mm == pytest.approx(0.0, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_subtraction_uses_each_angles_own(tmp_path):
|
||||||
|
"""Every angle is referenced against its own background, not angle 1's.
|
||||||
|
|
||||||
|
Each angle's background here is a copy of that angle's own CH1 wave, so
|
||||||
|
subtracting the right one leaves nothing to read at any angle — where
|
||||||
|
reusing angle 1's everywhere would leave angles 2 and 3 reporting their
|
||||||
|
sine unchanged.
|
||||||
|
"""
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
bins = (8, 9, 10)
|
||||||
|
backgrounds = [sine_frame(k) for k in bins]
|
||||||
|
write_check(out, bins=bins, backgrounds=backgrounds)
|
||||||
|
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
assert sras.backgrounds == backgrounds
|
||||||
|
plain = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||||
|
subtracted = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV,
|
||||||
|
subtract_background=True)
|
||||||
|
|
||||||
|
assert [t.median_mhz for t in plain] == [pytest.approx(bin_mhz(k)) for k in bins]
|
||||||
|
for trace in subtracted:
|
||||||
|
assert np.isnan(trace.freq_mhz).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_masked_pixels_become_nan_not_zero(tmp_path):
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
write_check(out, bins=(8, 8, 8), n_masked_frames=2)
|
||||||
|
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||||
|
|
||||||
|
for trace in traces:
|
||||||
|
assert np.isnan(trace.freq_mhz[:2]).all()
|
||||||
|
assert np.isfinite(trace.freq_mhz[2:]).all()
|
||||||
|
# A masked pixel must not drag the median toward 0 MHz.
|
||||||
|
assert trace.median_mhz == pytest.approx(bin_mhz(8))
|
||||||
|
assert trace.valid_fraction < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_traces_are_centred_on_a_common_offset(tmp_path):
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
write_check(out, bins=(8, 9, 10))
|
||||||
|
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||||
|
|
||||||
|
# Absolute X differs per angle (different bounding boxes); the offset the
|
||||||
|
# viewer plots against does not, which is what puts the curves together.
|
||||||
|
assert len({round(t.x_mm[0], 6) for t in traces}) > 1
|
||||||
|
for trace in traces:
|
||||||
|
assert trace.offset_mm[0] == pytest.approx(-trace.offset_mm[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_angles_with_no_data_are_skipped(tmp_path):
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
write_check(out, bins=(8, 8, 8))
|
||||||
|
full = out.read_bytes()
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
last_offset = sras.angle_data_offset(2)
|
||||||
|
out.write_bytes(full[:last_offset]) # angle 3 never acquired
|
||||||
|
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||||
|
assert [t.angle_idx for t in traces] == [0, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_flags_agreeing_angles_as_good(tmp_path):
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
write_check(out, bins=(8, 8, 8))
|
||||||
|
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
summary = alignment_summary(
|
||||||
|
frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV))
|
||||||
|
|
||||||
|
assert summary.n_angles == 3
|
||||||
|
assert summary.median_mhz == pytest.approx(bin_mhz(8))
|
||||||
|
assert summary.spread_mhz == pytest.approx(0.0)
|
||||||
|
assert summary.spread_pct <= SPREAD_GOOD_PCT
|
||||||
|
assert summary.level == "good"
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_flags_disagreeing_angles(tmp_path):
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
write_check(out, bins=(8, 9, 10))
|
||||||
|
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
|
||||||
|
summary = alignment_summary(traces)
|
||||||
|
|
||||||
|
assert summary.spread_mhz == pytest.approx(bin_mhz(10) - bin_mhz(8))
|
||||||
|
assert summary.level == "poor"
|
||||||
|
assert summary.worst_angle_deg == traces[0].angle_deg # lowest median
|
||||||
|
assert summary.best_angle_deg == traces[2].angle_deg # highest median
|
||||||
|
assert f"{summary.spread_mhz:.3f} MHz" in summary.describe()
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_calls_out_a_mostly_masked_row(tmp_path):
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
plan = middle_row_plan(full_plan(3))
|
||||||
|
# Mask nearly every frame of every angle: the spread is meaningless then.
|
||||||
|
write_check(out, bins=(8, 8, 8), plan=plan,
|
||||||
|
n_masked_frames=max(pa.n_frames for pa in plan.per_angle) - 1)
|
||||||
|
|
||||||
|
with SrasFile(out) as sras:
|
||||||
|
summary = alignment_summary(
|
||||||
|
frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV))
|
||||||
|
|
||||||
|
assert summary.level == "poor"
|
||||||
|
assert "DC threshold" in summary.describe()
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_of_nothing_is_not_a_crash():
|
||||||
|
summary = alignment_summary([])
|
||||||
|
assert summary.n_angles == 0 and summary.level == "poor"
|
||||||
|
assert "No angle" in summary.describe()
|
||||||
|
|
||||||
|
|
||||||
|
def test_middle_row_of_a_full_v6_scan_is_readable():
|
||||||
|
"""The check's read-out applied to a finished scan, after the fact."""
|
||||||
|
with SrasFile("tests/golden/complete.sras") as sras:
|
||||||
|
traces = frequency_traces(sras, dc_threshold_mv=-1e6)
|
||||||
|
assert len(traces) == sras.header.n_angles
|
||||||
|
for trace, pa in zip(traces, sras.per_angle, strict=True):
|
||||||
|
assert trace.row_idx == pa.n_rows // 2
|
||||||
|
assert len(trace.freq_mhz) == pa.n_frames
|
||||||
+285
-18
@@ -14,28 +14,31 @@ from core.scan_engine import (
|
|||||||
ResumeTarget,
|
ResumeTarget,
|
||||||
)
|
)
|
||||||
from core.scan_geometry import ScanGeometryError, build_plan
|
from core.scan_geometry import ScanGeometryError, build_plan
|
||||||
from core.sras_format import SCAN_CHANNELS, SrasFile
|
from core.sras_format import BG_LEN_SIZE, SCAN_CHANNELS, SrasFile
|
||||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
from fakes import FakeScope, FakeStage, FakeT3R, Trace, background_record
|
||||||
|
|
||||||
SPF = 8
|
SPF = 8
|
||||||
|
|
||||||
|
|
||||||
def make_plan(num_angles=1):
|
def make_plan(num_angles=1, y_delta=0.005):
|
||||||
# Small ROI well inside the stage limits: 1 row, few frames per angle.
|
# Small ROI well inside the stage limits: few rows, few frames per angle.
|
||||||
return build_plan(40.0, 30.0, 0.02, 0.005, num_angles, 0.01,
|
return build_plan(40.0, 30.0, 0.02, y_delta, num_angles, 0.01,
|
||||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
|
|
||||||
|
|
||||||
def build(tmp_path, num_angles=1, callbacks=None, resume=None, **kw):
|
def build(tmp_path, num_angles=1, callbacks=None, resume=None, plan=None,
|
||||||
|
burst_mode=False, max_frames=4096, out_name="out.sras",
|
||||||
|
strict_rows=False, **kw):
|
||||||
trace = Trace()
|
trace = Trace()
|
||||||
stage = FakeStage(trace)
|
scope = FakeScope(trace, samples_per_frame=SPF, max_frames=max_frames)
|
||||||
scope = FakeScope(trace, samples_per_frame=SPF)
|
stage = FakeStage(trace, scope=scope)
|
||||||
t3r = FakeT3R(trace, **kw)
|
t3r = FakeT3R(trace, **kw)
|
||||||
rotator = RotationAxis(t3r, RotationSettings())
|
rotator = RotationAxis(t3r, RotationSettings())
|
||||||
plan = make_plan(num_angles)
|
plan = plan if plan is not None else make_plan(num_angles)
|
||||||
engine = ScanEngine(stage, scope, rotator, plan, tmp_path / "out.sras",
|
engine = ScanEngine(stage, scope, rotator, plan, tmp_path / out_name,
|
||||||
resume=resume,
|
resume=resume,
|
||||||
callbacks=callbacks or ScanCallbacks())
|
callbacks=callbacks or ScanCallbacks(),
|
||||||
|
burst_mode=burst_mode, strict_rows=strict_rows)
|
||||||
return engine, trace, plan
|
return engine, trace, plan
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +57,41 @@ def test_single_angle_scan_writes_readable_file(tmp_path):
|
|||||||
# File is complete: every declared row present on disk
|
# File is complete: every declared row present on disk
|
||||||
assert [s.status for s in sras.angle_status()] == ["OK"]
|
assert [s.status for s in sras.angle_status()] == ["OK"]
|
||||||
assert len(sras.preambles) == 3
|
assert len(sras.preambles) == 3
|
||||||
assert sras.background == bytes(range(SPF))
|
assert sras.backgrounds == [background_record(0, SPF)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_angle_captures_and_stores_its_own_background(tmp_path):
|
||||||
|
"""One background per angle, taken after the rotation, kept ahead of it."""
|
||||||
|
prompts = []
|
||||||
|
engine, trace, plan = build(
|
||||||
|
tmp_path, num_angles=3,
|
||||||
|
callbacks=ScanCallbacks(prompt=lambda title, msg: prompts.append(title)))
|
||||||
|
engine.run()
|
||||||
|
|
||||||
|
sras = SrasFile(tmp_path / "out.sras")
|
||||||
|
assert trace.count("transfer_curve") == 3
|
||||||
|
assert sras.backgrounds == [background_record(i, SPF) for i in range(3)]
|
||||||
|
for st in sras.angle_status():
|
||||||
|
assert st.bg_bytes == BG_LEN_SIZE + SPF
|
||||||
|
assert st.status == "OK"
|
||||||
|
|
||||||
|
# Each capture follows the rotation to the angle it belongs to (the last
|
||||||
|
# rotation is the return to home after the final angle).
|
||||||
|
assert [c[0] for c in trace.calls
|
||||||
|
if c[0] in ("t3r_rotate", "transfer_curve")] == [
|
||||||
|
"transfer_curve", "t3r_rotate", "transfer_curve",
|
||||||
|
"t3r_rotate", "transfer_curve", "t3r_rotate"]
|
||||||
|
|
||||||
|
# Two prompts per angle: Genesis off for the capture, back on to scan.
|
||||||
|
assert prompts == [title for i in range(3) for title in
|
||||||
|
(f"Background Capture — Angle {i + 1}/3",
|
||||||
|
f"Begin Angle {i + 1}/3")]
|
||||||
|
|
||||||
|
# The scope goes back to the scan trigger after every capture, not just
|
||||||
|
# once at the start — the capture needs the single-record edge trigger.
|
||||||
|
cmds = [c[1] for c in trace.of("write")]
|
||||||
|
assert cmds.count("TRIGger:A:TYPe EDGE") == 4 # prepare + one per angle
|
||||||
|
assert cmds.count("TRIGger:A:TYPe LOGIc") == 4
|
||||||
|
|
||||||
|
|
||||||
def test_command_sequence_order(tmp_path):
|
def test_command_sequence_order(tmp_path):
|
||||||
@@ -192,8 +229,8 @@ def test_dc_bias_callback_reports_per_frame_means(tmp_path):
|
|||||||
|
|
||||||
def test_offstage_plan_rejected_before_touching_hardware(tmp_path):
|
def test_offstage_plan_rejected_before_touching_hardware(tmp_path):
|
||||||
trace = Trace()
|
trace = Trace()
|
||||||
stage = FakeStage(trace)
|
|
||||||
scope = FakeScope(trace, samples_per_frame=SPF)
|
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||||
|
stage = FakeStage(trace, scope=scope)
|
||||||
# X range that runs off the 110 mm stage once ramps are added
|
# X range that runs off the 110 mm stage once ramps are added
|
||||||
plan = build_plan(80.0, 30.0, 40.0, 5.0, 1, 0.25,
|
plan = build_plan(80.0, 30.0, 40.0, 5.0, 1, 0.25,
|
||||||
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
laser_freq_hz=20000.0, velocity_mm_s=100.0)
|
||||||
@@ -233,8 +270,9 @@ def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
|
|||||||
target = statuses[1]
|
target = statuses[1]
|
||||||
resume = ResumeState(
|
resume = ResumeState(
|
||||||
path=path,
|
path=path,
|
||||||
targets=[ResumeTarget(target.index, target.data_offset,
|
targets=[ResumeTarget(target.index, target.bg_offset,
|
||||||
target.n_rows, target.angle_deg)],
|
target.data_offset, target.n_rows,
|
||||||
|
target.angle_deg)],
|
||||||
samples_per_frame=SPF,
|
samples_per_frame=SPF,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -246,25 +284,254 @@ def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
|
|||||||
assert result.rows_written == plan.per_angle[1].n_rows
|
assert result.rows_written == plan.per_angle[1].n_rows
|
||||||
rewritten = path.read_bytes()
|
rewritten = path.read_bytes()
|
||||||
assert len(rewritten) == len(original)
|
assert len(rewritten) == len(original)
|
||||||
# Angle 0's block is untouched; angle 1's changed (fresh frame data)
|
# Angle 0's block is untouched; angle 1's changed — background included,
|
||||||
a1_start, a1_end = target.data_offset, target.data_offset + target.row_bytes * target.n_rows
|
# since a re-acquired angle captures a fresh one over the old.
|
||||||
|
a1_start = target.bg_offset
|
||||||
|
a1_end = target.data_offset + target.row_bytes * target.n_rows
|
||||||
assert rewritten[:a1_start] == original[:a1_start]
|
assert rewritten[:a1_start] == original[:a1_start]
|
||||||
assert rewritten[a1_start:a1_end] != original[a1_start:a1_end]
|
assert rewritten[a1_start:a1_end] != original[a1_start:a1_end]
|
||||||
assert rewritten[a1_end:] == original[a1_end:]
|
assert rewritten[a1_end:] == original[a1_end:]
|
||||||
|
|
||||||
|
# The new background is angle 1's own, written in place of its old one.
|
||||||
|
reread = SrasFile(path)
|
||||||
|
assert reread.backgrounds[1] == background_record(0, SPF)
|
||||||
|
assert reread.backgrounds[0] == SrasFile(path).backgrounds[0]
|
||||||
|
assert [s.status for s in reread.angle_status()] == ["OK"] * 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_rejects_a_background_that_would_shift_the_file(tmp_path):
|
||||||
|
"""A re-acquired angle's background must fit the room the file has.
|
||||||
|
|
||||||
|
Nothing else in the file records where an angle's rows begin, so a longer
|
||||||
|
or shorter background would push every row behind it out of position.
|
||||||
|
"""
|
||||||
|
engine, _, _ = build(tmp_path, num_angles=2)
|
||||||
|
engine.run()
|
||||||
|
path = tmp_path / "out.sras"
|
||||||
|
target = SrasFile(path).angle_status()[0]
|
||||||
|
resume = ResumeState(
|
||||||
|
path=path,
|
||||||
|
targets=[ResumeTarget(0, target.bg_offset, target.data_offset,
|
||||||
|
target.n_rows, target.angle_deg)],
|
||||||
|
samples_per_frame=SPF,
|
||||||
|
)
|
||||||
|
engine2, _, _ = build(tmp_path, num_angles=2, resume=resume)
|
||||||
|
# A scope that hands back a longer record than the file was written with
|
||||||
|
engine2._scope.transfer_curve = lambda: bytes(SPF + 4)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="shift every row"):
|
||||||
|
engine2.run()
|
||||||
|
|
||||||
|
|
||||||
def test_resume_record_length_mismatch_rejected(tmp_path):
|
def test_resume_record_length_mismatch_rejected(tmp_path):
|
||||||
engine, trace, plan = build(tmp_path)
|
engine, trace, plan = build(tmp_path)
|
||||||
engine.run()
|
engine.run()
|
||||||
path = tmp_path / "out.sras"
|
path = tmp_path / "out.sras"
|
||||||
resume = ResumeState(path=path,
|
resume = ResumeState(path=path,
|
||||||
targets=[ResumeTarget(0, 0, 1, 0.0)],
|
targets=[ResumeTarget(0, 0, 0, 1, 0.0)],
|
||||||
samples_per_frame=SPF + 1) # scope changed
|
samples_per_frame=SPF + 1) # scope changed
|
||||||
engine2, _, _ = build(tmp_path, resume=resume)
|
engine2, _, _ = build(tmp_path, resume=resume)
|
||||||
with pytest.raises(RuntimeError, match="record length"):
|
with pytest.raises(RuntimeError, match="record length"):
|
||||||
engine2.run()
|
engine2.run()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Burst acquisition ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 6 rows × 4 frames/row; max_frames=14 gives 14//4 = 3 rows per burst, so the
|
||||||
|
# angle needs two bursts and the second is not a whole burst wide.
|
||||||
|
BURST_PLAN = dict(y_delta=0.05)
|
||||||
|
BURST_MAX_FRAMES = 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_and_serial_produce_identical_files(tmp_path):
|
||||||
|
"""The whole point: burst mode must be a pure acquisition optimisation."""
|
||||||
|
plan = make_plan(**BURST_PLAN)
|
||||||
|
assert plan.per_angle[0].n_rows == 6 and plan.per_angle[0].n_frames == 4
|
||||||
|
|
||||||
|
serial, _, _ = build(tmp_path, plan=plan, out_name="serial.sras")
|
||||||
|
serial.run()
|
||||||
|
burst, _, _ = build(tmp_path, plan=plan, out_name="burst.sras",
|
||||||
|
burst_mode=True, max_frames=BURST_MAX_FRAMES)
|
||||||
|
burst.run()
|
||||||
|
|
||||||
|
assert (tmp_path / "burst.sras").read_bytes() == \
|
||||||
|
(tmp_path / "serial.sras").read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_multi_angle_file_is_complete(tmp_path):
|
||||||
|
plan = make_plan(num_angles=3, **BURST_PLAN)
|
||||||
|
engine, trace, _ = build(tmp_path, plan=plan, burst_mode=True,
|
||||||
|
max_frames=BURST_MAX_FRAMES)
|
||||||
|
result = engine.run()
|
||||||
|
|
||||||
|
assert result.rows_written == plan.total_rows
|
||||||
|
assert result.angles_acquired == [0, 1, 2]
|
||||||
|
sras = SrasFile(tmp_path / "out.sras")
|
||||||
|
assert [s.status for s in sras.angle_status()] == ["OK"] * 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_gates_the_flyback_and_runs_once_per_burst(tmp_path):
|
||||||
|
plan = make_plan(**BURST_PLAN)
|
||||||
|
engine, trace, _ = build(tmp_path, plan=plan, burst_mode=True,
|
||||||
|
max_frames=BURST_MAX_FRAMES)
|
||||||
|
engine.run()
|
||||||
|
|
||||||
|
# Two bursts (3 + 3 rows), two preflight acquisitions, one background.
|
||||||
|
runs = [c for c in trace.of("write") if c[1] == "ACQuire:STATE RUN"]
|
||||||
|
assert len(runs) == 5
|
||||||
|
|
||||||
|
# Every acquiring pass is bracketed by an arm/disarm, so the gate is low
|
||||||
|
# for each flyback. 6 rows + 1 preflight pass = 7 arms.
|
||||||
|
gate = [c[2] for c in trace.of("arm_scan_gate")]
|
||||||
|
assert gate.count(True) == 7
|
||||||
|
# No two arms without a disarm between them — that is what would let a
|
||||||
|
# flyback into the acquisition. (A repeated disarm is just defensive.)
|
||||||
|
for a, b in zip(gate, gate[1:], strict=False):
|
||||||
|
assert not (a and b), f"acquiring pass with no disarm before it: {gate}"
|
||||||
|
assert gate[-1] is False, "scan left the gate armed"
|
||||||
|
|
||||||
|
# One bulk transfer per data channel per burst, none per row.
|
||||||
|
assert [(c[1], c[2]) for c in trace.of("transfer_fastframe_bulk")] == [
|
||||||
|
(1, 12), (4, 12), (1, 12), (4, 12)]
|
||||||
|
assert trace.count("transfer_fastframe") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_preflight_rejects_a_leaky_gate(tmp_path):
|
||||||
|
plan = make_plan(**BURST_PLAN)
|
||||||
|
engine, trace, _ = build(tmp_path, plan=plan, burst_mode=True,
|
||||||
|
max_frames=BURST_MAX_FRAMES)
|
||||||
|
stage = engine._stage
|
||||||
|
|
||||||
|
# A gate that ignores the disable request — the failure mode the preflight
|
||||||
|
# exists to catch (TRIGOUT_GATE_OFF set to the wrong mode value).
|
||||||
|
def stuck_gate(axis, armed, verify=True):
|
||||||
|
trace.record("arm_scan_gate", axis, bool(armed))
|
||||||
|
stage.gate_armed = True
|
||||||
|
stage.arm_scan_gate = stuck_gate
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="not idling low"):
|
||||||
|
engine.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_burst_preflight_rejects_a_dark_laser(tmp_path):
|
||||||
|
"""A gate that never fires would let a leak check pass vacuously."""
|
||||||
|
plan = make_plan(**BURST_PLAN)
|
||||||
|
engine, trace, _ = build(tmp_path, plan=plan, burst_mode=True,
|
||||||
|
max_frames=BURST_MAX_FRAMES)
|
||||||
|
engine._stage.attach_scope(None) # no pulses ever reach the scope
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="no frames acquired"):
|
||||||
|
engine.run()
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_one_row(engine, which_pass=2, lost=1):
|
||||||
|
"""Make one acquiring pass come up `lost` frames short.
|
||||||
|
|
||||||
|
`which_pass` counts acquiring passes from 1, so the default clips the
|
||||||
|
second data row (row 2) — far enough in that a mishandled short row shows
|
||||||
|
up as a shift in the rows after it.
|
||||||
|
"""
|
||||||
|
scope = engine._scope
|
||||||
|
real_acquire = scope.acquire_frames
|
||||||
|
passes = {"n": 0}
|
||||||
|
|
||||||
|
def clipped(n):
|
||||||
|
if scope._running:
|
||||||
|
passes["n"] += 1
|
||||||
|
if passes["n"] == which_pass:
|
||||||
|
n -= lost
|
||||||
|
real_acquire(n)
|
||||||
|
scope.acquire_frames = clipped
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("burst_mode", [False, True])
|
||||||
|
def test_short_row_is_padded_to_declared_frame_count(tmp_path, burst_mode):
|
||||||
|
"""A clipped row must not shift every later row in the file.
|
||||||
|
|
||||||
|
v6 declares n_frames per row up front and has no per-row length, so an
|
||||||
|
under-triggered row has to be squared up. In burst mode this also proves
|
||||||
|
the splitter advances by what actually arrived, not by n_frames.
|
||||||
|
"""
|
||||||
|
warnings = []
|
||||||
|
plan = make_plan(**BURST_PLAN)
|
||||||
|
engine, trace, _ = build(
|
||||||
|
tmp_path, plan=plan, burst_mode=burst_mode,
|
||||||
|
max_frames=BURST_MAX_FRAMES,
|
||||||
|
callbacks=ScanCallbacks(on_status=warnings.append))
|
||||||
|
# The preflight is covered by its own tests; skipping it keeps the
|
||||||
|
# acquiring-pass count below identical in both modes.
|
||||||
|
engine._preflight_done = True
|
||||||
|
|
||||||
|
_clip_one_row(engine)
|
||||||
|
|
||||||
|
result = engine.run()
|
||||||
|
|
||||||
|
assert result.rows_written == 6
|
||||||
|
assert any("Row 2: 3 frames acquired, 4 expected" in w for w in warnings)
|
||||||
|
assert any("zero-padded" in w for w in warnings)
|
||||||
|
sras = SrasFile(tmp_path / "out.sras")
|
||||||
|
assert [s.status for s in sras.angle_status()] == ["OK"]
|
||||||
|
# The padding lands at the end of the short row, not in the next one.
|
||||||
|
assert bytes(sras.load_row(0, 1, 0)[-1]) == bytes(SPF)
|
||||||
|
assert bytes(sras.load_row(0, 2, 0)[0]) != bytes(SPF)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Strict row packing ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("burst_mode", [False, True])
|
||||||
|
def test_strict_row_packing_aborts_on_a_short_row(tmp_path, burst_mode):
|
||||||
|
"""Strict mode fails the scan instead of silently squaring a row up.
|
||||||
|
|
||||||
|
The default padding keeps the file readable but makes a mis-triggered row
|
||||||
|
indistinguishable from a good one after the fact, since v6 records no
|
||||||
|
per-row frame count. Strict mode trades the salvaged rows for knowing.
|
||||||
|
"""
|
||||||
|
plan = make_plan(**BURST_PLAN)
|
||||||
|
engine, _, _ = build(tmp_path, plan=plan, burst_mode=burst_mode,
|
||||||
|
max_frames=BURST_MAX_FRAMES, strict_rows=True)
|
||||||
|
engine._preflight_done = True
|
||||||
|
_clip_one_row(engine)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Row 2: 3 frames acquired, 4 expected"):
|
||||||
|
engine.run()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("burst_mode", [False, True])
|
||||||
|
def test_strict_row_packing_does_not_disturb_a_clean_scan(tmp_path, burst_mode):
|
||||||
|
"""Strict mode is inert when every row acquires what it declared."""
|
||||||
|
plan = make_plan(**BURST_PLAN)
|
||||||
|
engine, _, _ = build(tmp_path, plan=plan, burst_mode=burst_mode,
|
||||||
|
max_frames=BURST_MAX_FRAMES, strict_rows=True)
|
||||||
|
engine._preflight_done = True
|
||||||
|
|
||||||
|
result = engine.run()
|
||||||
|
|
||||||
|
assert result.rows_written == plan.total_rows
|
||||||
|
sras = SrasFile(tmp_path / "out.sras")
|
||||||
|
assert [s.status for s in sras.angle_status()] == ["OK"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_strict_row_packing_writes_nothing_for_the_failed_row(tmp_path):
|
||||||
|
"""The abort must not leave a half-written row behind.
|
||||||
|
|
||||||
|
CH1 leads SCAN_CHANNELS, so the frame count is known before any of the
|
||||||
|
row's channels are written — the file should end on a whole-row boundary.
|
||||||
|
"""
|
||||||
|
plan = make_plan(**BURST_PLAN)
|
||||||
|
engine, _, _ = build(tmp_path, plan=plan, strict_rows=True)
|
||||||
|
engine._preflight_done = True
|
||||||
|
_clip_one_row(engine)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Strict row packing"):
|
||||||
|
engine.run()
|
||||||
|
|
||||||
|
# Row 1 was written in full; row 2 aborted before writing anything, so
|
||||||
|
# the file ends exactly on a row boundary.
|
||||||
|
sras = SrasFile(tmp_path / "out.sras")
|
||||||
|
written = (tmp_path / "out.sras").stat().st_size - sras.angle_data_offset(0)
|
||||||
|
assert written == sras.row_bytes(0)
|
||||||
|
|
||||||
|
|
||||||
def test_engine_imports_without_qt():
|
def test_engine_imports_without_qt():
|
||||||
"""The engine must be usable from a non-Qt front end."""
|
"""The engine must be usable from a non-Qt front end."""
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from core.scan_resume import is_compatible, plan_resume
|
from core.scan_resume import is_compatible, plan_resume
|
||||||
from core.sras_format import SrasFile
|
from core.sras_format import SrasFile
|
||||||
|
from golden_util import write_v7
|
||||||
|
|
||||||
GOLDEN = Path(__file__).parent / "golden"
|
GOLDEN = Path(__file__).parent / "golden"
|
||||||
|
|
||||||
@@ -47,11 +48,17 @@ def test_selecting_only_a_later_angle_pulls_in_the_frontier():
|
|||||||
assert plan.auto_added == [0]
|
assert plan.auto_added == [0]
|
||||||
|
|
||||||
|
|
||||||
def test_targets_carry_offsets_and_rows():
|
def test_targets_carry_offsets_and_rows(tmp_path):
|
||||||
st = _statuses("complete.sras")
|
out = tmp_path / "v7.sras"
|
||||||
|
write_v7(out)
|
||||||
|
st = SrasFile(out).angle_status()
|
||||||
plan = plan_resume(st, selected={0, 1})
|
plan = plan_resume(st, selected={0, 1})
|
||||||
for target, status in zip(plan.targets, st, strict=True):
|
for target, status in zip(plan.targets, st, strict=True):
|
||||||
|
# A re-acquired angle rewrites its background too, so a target has to
|
||||||
|
# know where the block starts as well as where the rows do.
|
||||||
|
assert target.bg_offset == status.bg_offset
|
||||||
assert target.data_offset == status.data_offset
|
assert target.data_offset == status.data_offset
|
||||||
|
assert target.bg_offset < target.data_offset
|
||||||
assert target.n_rows == status.n_rows
|
assert target.n_rows == status.n_rows
|
||||||
assert target.angle_deg == status.angle_deg
|
assert target.angle_deg == status.angle_deg
|
||||||
assert plan.total_rows == sum(s.n_rows for s in st)
|
assert plan.total_rows == sum(s.n_rows for s in st)
|
||||||
@@ -65,9 +72,19 @@ def test_to_state_carries_samples_per_frame():
|
|||||||
assert state.target_indices == {0}
|
assert state.target_indices == {0}
|
||||||
|
|
||||||
|
|
||||||
def test_is_compatible_checks_acquisition_settings():
|
def test_is_compatible_rejects_a_legacy_file():
|
||||||
|
"""A v6 file has no room for the background block each angle now writes."""
|
||||||
sras = SrasFile(GOLDEN / "complete.sras")
|
sras = SrasFile(GOLDEN / "complete.sras")
|
||||||
h = sras.header
|
h = sras.header
|
||||||
|
assert not is_compatible(sras, velocity=h.velocity, laser_freq=h.laser_freq,
|
||||||
|
sample_rate=h.sample_rate, n_channels=h.n_channels)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_compatible_checks_acquisition_settings(tmp_path):
|
||||||
|
out = tmp_path / "v7.sras"
|
||||||
|
write_v7(out)
|
||||||
|
sras = SrasFile(out)
|
||||||
|
h = sras.header
|
||||||
ok = dict(velocity=h.velocity, laser_freq=h.laser_freq,
|
ok = dict(velocity=h.velocity, laser_freq=h.laser_freq,
|
||||||
sample_rate=h.sample_rate, n_channels=h.n_channels)
|
sample_rate=h.sample_rate, n_channels=h.n_channels)
|
||||||
assert is_compatible(sras, **ok)
|
assert is_compatible(sras, **ok)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Burst sizing and row-splitting, exercised without any instrument."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.scope_burst import (
|
||||||
|
frame_means_block, normalize_row, rows_per_burst, split_row_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
SPF = 8
|
||||||
|
|
||||||
|
|
||||||
|
# ── rows_per_burst ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_rows_per_burst_rounds_down():
|
||||||
|
# 9.7 rows' worth of capacity is 9 rows: a partial row is unusable.
|
||||||
|
assert rows_per_burst(97, 10, SPF, rows_remaining=100) == 9
|
||||||
|
assert rows_per_burst(100, 10, SPF, rows_remaining=100) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_per_burst_clamped_by_rows_remaining():
|
||||||
|
assert rows_per_burst(1000, 10, SPF, rows_remaining=3) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_per_burst_clamped_by_memory_budget():
|
||||||
|
# Budget holds 4 rows of 10 frames × 8 samples; the scope would hold 100.
|
||||||
|
assert rows_per_burst(1000, 10, SPF, rows_remaining=100,
|
||||||
|
memory_budget=4 * 10 * SPF) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_per_burst_headroom_reserves_slack_per_row():
|
||||||
|
assert rows_per_burst(100, 10, SPF, rows_remaining=100, headroom=0) == 10
|
||||||
|
assert rows_per_burst(100, 10, SPF, rows_remaining=100, headroom=2) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_per_burst_never_returns_zero():
|
||||||
|
"""A row too big for any budget still goes, or the scan cannot progress."""
|
||||||
|
assert rows_per_burst(5, 10, SPF, rows_remaining=100) == 1
|
||||||
|
assert rows_per_burst(1000, 10, SPF, rows_remaining=100,
|
||||||
|
memory_budget=1) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_per_burst_rejects_degenerate_geometry():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
rows_per_burst(100, 0, SPF, rows_remaining=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── split_row_counts ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_split_row_counts_differences_the_cumulative_counter():
|
||||||
|
assert split_row_counts([4, 8, 12]) == [4, 4, 4]
|
||||||
|
assert split_row_counts([4, 7, 12]) == [4, 3, 5]
|
||||||
|
assert split_row_counts([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_row_counts_rejects_a_counter_that_went_backwards():
|
||||||
|
# Only happens if the acquisition restarted mid-burst, which would
|
||||||
|
# misattribute every later row.
|
||||||
|
with pytest.raises(RuntimeError, match="backwards"):
|
||||||
|
split_row_counts([8, 4])
|
||||||
|
|
||||||
|
|
||||||
|
# ── normalize_row ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_normalize_row_passes_an_exact_row_through():
|
||||||
|
buf = bytes(range(4 * SPF))
|
||||||
|
assert bytes(normalize_row(buf, 0, 4, 4, SPF)) == buf
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_row_pads_a_short_row():
|
||||||
|
buf = bytes(range(3 * SPF))
|
||||||
|
out = bytes(normalize_row(buf, 0, 3, 4, SPF))
|
||||||
|
assert len(out) == 4 * SPF
|
||||||
|
assert out[:3 * SPF] == buf
|
||||||
|
assert out[3 * SPF:] == bytes(SPF)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_row_truncates_a_long_row():
|
||||||
|
buf = bytes(range(6 * SPF))
|
||||||
|
out = bytes(normalize_row(buf, 0, 6, 4, SPF))
|
||||||
|
assert out == buf[:4 * SPF]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_row_reads_at_an_offset():
|
||||||
|
buf = bytes(range(8 * SPF))
|
||||||
|
out = bytes(normalize_row(buf, 2 * SPF, 4, 4, SPF))
|
||||||
|
assert out == buf[2 * SPF:6 * SPF]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_row_pads_a_buffer_that_ends_early():
|
||||||
|
"""Defensive: a truncated transfer must not shorten the row on disk."""
|
||||||
|
out = bytes(normalize_row(bytes(2 * SPF), 0, 4, 4, SPF))
|
||||||
|
assert len(out) == 4 * SPF
|
||||||
|
|
||||||
|
|
||||||
|
# ── frame_means_block ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_frame_means_block_is_per_frame():
|
||||||
|
buf = bytes([1] * SPF + [3] * SPF)
|
||||||
|
assert frame_means_block(buf, 0, 2, SPF) == [1.0, 3.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_means_block_reads_signed_samples_at_an_offset():
|
||||||
|
buf = bytes([0] * SPF) + bytes([0xFF] * SPF) # 0xFF == -1 as int8
|
||||||
|
assert frame_means_block(buf, SPF, 1, SPF) == [-1.0]
|
||||||
@@ -38,6 +38,51 @@ def test_sc3_aui_main_window(qapp):
|
|||||||
_pump(qapp)
|
_pump(qapp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_window_without_hardware_is_a_plain_viewer(qapp):
|
||||||
|
"""No stage, no scope, no auto-align button to press."""
|
||||||
|
import sc3_aui_app
|
||||||
|
win = sc3_aui_app.CameraWindow()
|
||||||
|
_pump(qapp)
|
||||||
|
try:
|
||||||
|
assert not win.uc480_auto_align_btn.isVisible()
|
||||||
|
finally:
|
||||||
|
win.deleteLater()
|
||||||
|
_pump(qapp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_window_auto_align_needs_every_device(qapp):
|
||||||
|
"""The button appears once the three devices exist, and says which one is
|
||||||
|
missing rather than starting and failing on the rig."""
|
||||||
|
import sc3_aui_app
|
||||||
|
from gui.qt_t3r import QtT3RAdapter
|
||||||
|
win = sc3_aui_app.CameraWindow(QtT3RAdapter(), sc3_aui_app.BBD202Worker(),
|
||||||
|
sc3_aui_app.OscopeWorker())
|
||||||
|
_pump(qapp)
|
||||||
|
try:
|
||||||
|
assert win.uc480_auto_align_btn.isVisibleTo(win)
|
||||||
|
assert "oscilloscope" in win._align_prerequisite_problem()
|
||||||
|
finally:
|
||||||
|
win.deleteLater()
|
||||||
|
_pump(qapp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_align_window_logs_a_result(qapp):
|
||||||
|
import sc3_aui_app
|
||||||
|
from core.auto_align import AlignResult, Reading
|
||||||
|
win = sc3_aui_app.AutoAlignWindow()
|
||||||
|
_pump(qapp)
|
||||||
|
try:
|
||||||
|
reference = Reading(400.0, 400.0)
|
||||||
|
win.set_reference(reference)
|
||||||
|
win.on_reading(Reading(403.0, 397.0))
|
||||||
|
win.on_finished(AlignResult(reference=reference, final=reference))
|
||||||
|
assert "Reference" in win.log.toPlainText()
|
||||||
|
assert win.close_btn.isEnabled()
|
||||||
|
finally:
|
||||||
|
win.deleteLater()
|
||||||
|
_pump(qapp)
|
||||||
|
|
||||||
|
|
||||||
def test_sras_viewer_window(qapp):
|
def test_sras_viewer_window(qapp):
|
||||||
import sras_viewer
|
import sras_viewer
|
||||||
win = sras_viewer.SrasViewerWindow()
|
win = sras_viewer.SrasViewerWindow()
|
||||||
@@ -49,6 +94,17 @@ def test_sras_viewer_window(qapp):
|
|||||||
_pump(qapp)
|
_pump(qapp)
|
||||||
|
|
||||||
|
|
||||||
|
def test_saw_check_viewer_window(qapp):
|
||||||
|
import saw_check_viewer
|
||||||
|
win = saw_check_viewer.SawCheckWindow()
|
||||||
|
_pump(qapp)
|
||||||
|
try:
|
||||||
|
assert win.windowTitle()
|
||||||
|
finally:
|
||||||
|
win.deleteLater()
|
||||||
|
_pump(qapp)
|
||||||
|
|
||||||
|
|
||||||
def test_helios_test_app(qapp):
|
def test_helios_test_app(qapp):
|
||||||
import helios_test_app
|
import helios_test_app
|
||||||
win = helios_test_app.HeliosTestApp()
|
win = helios_test_app.HeliosTestApp()
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ def _loaded_scan(name="complete.sras"):
|
|||||||
def test_loaded_scan_basics():
|
def test_loaded_scan_basics():
|
||||||
scan = _loaded_scan()
|
scan = _loaded_scan()
|
||||||
assert scan.rows_available == [3, 3]
|
assert scan.rows_available == [3, 3]
|
||||||
assert scan.background is not None and len(scan.background) == 8
|
# A legacy v6 fixture: its one background stands in for every angle.
|
||||||
|
assert all(scan.background(ai) is not None and len(scan.background(ai)) == 8
|
||||||
|
for ai in (0, 1))
|
||||||
assert len(scan.calib.ymult_mv) == 3
|
assert len(scan.calib.ymult_mv) == 3
|
||||||
view = scan.angle_view(0)
|
view = scan.angle_view(0)
|
||||||
assert view.shape == (3, 3, 4, 8)
|
assert view.shape == (3, 3, 4, 8)
|
||||||
|
|||||||
+140
-27
@@ -1,21 +1,27 @@
|
|||||||
"""core.sras_format vs the pre-refactor golden fixtures.
|
"""core.sras_format: the current writer against the spec, and the parser
|
||||||
|
against the pre-refactor golden fixtures.
|
||||||
|
|
||||||
The goldens were produced by the original sc3_aui_app implementation; the
|
The goldens are legacy v6 files produced by the original sc3_aui_app
|
||||||
extracted module must reproduce them byte-for-byte (writer) and
|
implementation — one background for the whole file. Nothing writes that
|
||||||
field-for-field (parser + frontier walk).
|
layout any more, so they lock the parser (field-for-field, including the
|
||||||
|
frontier walk) rather than the writer. The writer is locked instead against
|
||||||
|
bytes this test lays out from scan_format.md itself.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import struct
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.scan_geometry import build_plan
|
from core.sras_format import (
|
||||||
from core.sras_format import SrasFile, create_scan_file
|
BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, VERSION, VERSION_SAW_CHECK,
|
||||||
|
SrasFile, create_scan_file,
|
||||||
|
)
|
||||||
from golden_util import (
|
from golden_util import (
|
||||||
BACKGROUND, CHANNELS, LASER_FREQ_HZ, PREAMBLES, SAMPLE_RATE, SPF,
|
BACKGROUND, CHANNELS, PREAMBLES, SAMPLE_RATE, SPF, angle_background,
|
||||||
TINY_PLAN_ARGS, VELOCITY_MM_S, synthetic_frame,
|
synthetic_frame, tiny_plan, write_v7,
|
||||||
)
|
)
|
||||||
|
|
||||||
GOLDEN = Path(__file__).parent / "golden"
|
GOLDEN = Path(__file__).parent / "golden"
|
||||||
@@ -27,28 +33,43 @@ def expected():
|
|||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
def _tiny_plan():
|
def spec_bytes(plan):
|
||||||
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ,
|
"""The v7 layout spelled out from scan_format.md, writer not involved."""
|
||||||
velocity_mm_s=VELOCITY_MM_S)
|
buf = bytearray()
|
||||||
|
buf += struct.pack(HDR_FMT, MAGIC, VERSION, plan.n_angles,
|
||||||
|
plan.x_start_nominal, plan.y_start_nominal,
|
||||||
def _write_complete(path):
|
plan.x_delta_nominal, plan.y_delta_nominal,
|
||||||
plan = _tiny_plan()
|
plan.row_spacing, plan.velocity_mm_s, plan.laser_freq_hz,
|
||||||
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND)
|
SPF, SAMPLE_RATE, 1, len(CHANNELS))
|
||||||
try:
|
buf += struct.pack(f">{plan.n_angles}f", *plan.angles)
|
||||||
|
for pa in plan.per_angle:
|
||||||
|
buf += struct.pack(GEOM_FMT, pa.x_start, pa.x_delta, pa.n_frames, pa.n_rows)
|
||||||
|
for pa in plan.per_angle:
|
||||||
|
buf += struct.pack(f">{pa.n_rows}f", *pa.y_positions)
|
||||||
|
for pre in PREAMBLES:
|
||||||
|
buf += struct.pack(">H", len(pre)) + pre.encode("utf-8")
|
||||||
for ai, pa in enumerate(plan.per_angle):
|
for ai, pa in enumerate(plan.per_angle):
|
||||||
|
bg = angle_background(ai)
|
||||||
|
buf += struct.pack(BG_LEN_FMT, len(bg)) + bg
|
||||||
for ri in range(pa.n_rows):
|
for ri in range(pa.n_rows):
|
||||||
for ci in range(len(CHANNELS)):
|
for ci in range(len(CHANNELS)):
|
||||||
for fi in range(pa.n_frames):
|
for fi in range(pa.n_frames):
|
||||||
f.write(synthetic_frame(ai, ri, ci, fi))
|
buf += synthetic_frame(ai, ri, ci, fi)
|
||||||
finally:
|
return bytes(buf)
|
||||||
f.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_writer_byte_identical_to_golden(tmp_path):
|
def test_writer_matches_the_spec_byte_for_byte(tmp_path):
|
||||||
out = tmp_path / "rewrite.sras"
|
out = tmp_path / "v7.sras"
|
||||||
_write_complete(out)
|
plan = write_v7(out)
|
||||||
assert out.read_bytes() == (GOLDEN / "complete.sras").read_bytes()
|
assert out.read_bytes() == spec_bytes(plan)
|
||||||
|
|
||||||
|
|
||||||
|
def test_writer_refuses_the_legacy_versions(tmp_path):
|
||||||
|
for version in (6, 10):
|
||||||
|
with pytest.raises(ValueError, match=f"version {version}"):
|
||||||
|
create_scan_file(tmp_path / "bad.sras", tiny_plan(), SPF,
|
||||||
|
SAMPLE_RATE, PREAMBLES, version=version)
|
||||||
|
assert not (tmp_path / "bad.sras").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_header_matches_golden(expected):
|
def test_header_matches_golden(expected):
|
||||||
@@ -75,15 +96,107 @@ def test_header_matches_golden(expected):
|
|||||||
|
|
||||||
|
|
||||||
def test_frontier_all_truncation_variants(expected):
|
def test_frontier_all_truncation_variants(expected):
|
||||||
|
"""Every field the goldens recorded, plus the one added since.
|
||||||
|
|
||||||
|
The expectations predate AngleStatus.bg_offset, so they are compared key
|
||||||
|
by key; a v6 angle has no background block of its own, which is exactly
|
||||||
|
what bg_offset == data_offset says.
|
||||||
|
|
||||||
|
Two of the recorded data_offsets were corrected when the walk moved into
|
||||||
|
the parser: an angle past the frontier used to report the frontier's own
|
||||||
|
offset, because the old walk stopped advancing its cursor there, which
|
||||||
|
handed a resumed scan the same write position for every missing angle.
|
||||||
|
They are now the declared position each angle will be written at.
|
||||||
|
"""
|
||||||
for name, exp_statuses in expected["statuses"].items():
|
for name, exp_statuses in expected["statuses"].items():
|
||||||
statuses = SrasFile(GOLDEN / name).angle_status()
|
statuses = SrasFile(GOLDEN / name).angle_status()
|
||||||
assert [asdict(s) for s in statuses] == exp_statuses, f"mismatch for {name}"
|
assert len(statuses) == len(exp_statuses), f"mismatch for {name}"
|
||||||
|
for status, exp in zip(statuses, exp_statuses, strict=True):
|
||||||
|
got = asdict(status)
|
||||||
|
assert {k: got[k] for k in exp} == exp, f"mismatch for {name}"
|
||||||
|
assert status.bg_offset == status.data_offset
|
||||||
|
assert status.bg_bytes == 0
|
||||||
|
|
||||||
|
|
||||||
def test_preambles_and_background_roundtrip():
|
def test_legacy_preambles_and_shared_background():
|
||||||
|
"""A v6 file's one background stands in for every angle's."""
|
||||||
sras = SrasFile(GOLDEN / "complete.sras")
|
sras = SrasFile(GOLDEN / "complete.sras")
|
||||||
assert sras.preambles == PREAMBLES
|
assert sras.preambles == PREAMBLES
|
||||||
assert sras.background == BACKGROUND
|
assert sras.is_legacy_layout
|
||||||
|
assert sras.backgrounds == [BACKGROUND] * sras.header.n_angles
|
||||||
|
assert np.array_equal(sras.background_array(1),
|
||||||
|
np.frombuffer(BACKGROUND, dtype=np.int8))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-angle backgrounds (v7/v11) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_each_angle_keeps_its_own_background(tmp_path):
|
||||||
|
out = tmp_path / "v7.sras"
|
||||||
|
plan = write_v7(out)
|
||||||
|
|
||||||
|
sras = SrasFile(out)
|
||||||
|
assert not sras.is_legacy_layout
|
||||||
|
assert sras.backgrounds == [angle_background(ai)
|
||||||
|
for ai in range(plan.n_angles)]
|
||||||
|
assert [s.status for s in sras.angle_status()] == ["OK"] * plan.n_angles
|
||||||
|
# Every angle's rows start just past its own background block …
|
||||||
|
for st in sras.angle_status():
|
||||||
|
assert st.bg_bytes == 4 + SPF
|
||||||
|
assert st.data_offset == st.bg_offset + st.bg_bytes
|
||||||
|
# … and the data itself still reads back frame for frame.
|
||||||
|
assert sras.load_row(1, 2, 0).tobytes() == b"".join(
|
||||||
|
synthetic_frame(1, 2, 0, fi) for fi in range(plan.per_angle[1].n_frames))
|
||||||
|
sras.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_saw_check_version_also_carries_per_angle_backgrounds(tmp_path):
|
||||||
|
from core.saw_check import middle_row_plan
|
||||||
|
out = tmp_path / "check.sras"
|
||||||
|
plan = write_v7(out, plan=middle_row_plan(tiny_plan()),
|
||||||
|
version=VERSION_SAW_CHECK)
|
||||||
|
|
||||||
|
sras = SrasFile(out)
|
||||||
|
assert sras.is_saw_check and not sras.is_legacy_layout
|
||||||
|
assert sras.backgrounds == [angle_background(ai)
|
||||||
|
for ai in range(plan.n_angles)]
|
||||||
|
sras.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_angle_missing_its_background_is_the_frontier(tmp_path):
|
||||||
|
"""A file cut inside a background block stops at that angle.
|
||||||
|
|
||||||
|
Nothing of that angle is on disk yet — not even the reference its rows
|
||||||
|
would be read against — so it is MISSING rather than TRUNCATED, and its
|
||||||
|
predicted offsets are where a resumed scan would write.
|
||||||
|
"""
|
||||||
|
out = tmp_path / "v7.sras"
|
||||||
|
write_v7(out)
|
||||||
|
whole = out.read_bytes()
|
||||||
|
bg1 = SrasFile(out).angle_status()[1].bg_offset
|
||||||
|
|
||||||
|
for cut, expected_status in ((bg1, "MISSING"), (bg1 + 4 + SPF // 2, "MISSING")):
|
||||||
|
out.write_bytes(whole[:cut])
|
||||||
|
statuses = SrasFile(out).angle_status()
|
||||||
|
assert [s.status for s in statuses] == ["OK", expected_status]
|
||||||
|
assert statuses[1].n_rows_available == 0
|
||||||
|
assert statuses[1].bg_offset == bg1
|
||||||
|
# The absent block is predicted at a full record's worth of bytes,
|
||||||
|
# which is what the writer will produce when the scan resumes.
|
||||||
|
assert statuses[1].data_offset == bg1 + 4 + SPF
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_after_a_background_still_truncate_by_row(tmp_path):
|
||||||
|
out = tmp_path / "v7.sras"
|
||||||
|
plan = write_v7(out)
|
||||||
|
whole = out.read_bytes()
|
||||||
|
st1 = SrasFile(out).angle_status()[1]
|
||||||
|
|
||||||
|
out.write_bytes(whole[:st1.data_offset + 2 * st1.row_bytes])
|
||||||
|
statuses = SrasFile(out).angle_status()
|
||||||
|
assert [s.status for s in statuses] == ["OK", "TRUNCATED"]
|
||||||
|
assert statuses[1].n_rows_available == 2
|
||||||
|
assert SrasFile(out).load_angle(1, n_rows=2).shape[0] == 2
|
||||||
|
assert plan.per_angle[1].n_rows == 3
|
||||||
|
|
||||||
|
|
||||||
def test_load_angle_memmap_equals_eager():
|
def test_load_angle_memmap_equals_eager():
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""TektronixOscilloscopeBase: the SCPI setters that validate their input.
|
||||||
|
|
||||||
|
These run against the real class with only the socket replaced. The scan
|
||||||
|
tests use FakeScope, which stubs the setters out entirely — so a setter
|
||||||
|
could raise on every call and nothing in the suite would notice, which is
|
||||||
|
what happened: set_channel_coupling() iterated CHANNEL_COUPLING.items()
|
||||||
|
instead of .values(), called .upper() on the list half of each pair, and
|
||||||
|
raised AttributeError for any coupling at all. configure_channels() runs
|
||||||
|
on connect, so the scope could not be connected to.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.scope_sras import SRAS_CHANNELS, configure_channels
|
||||||
|
from hardware.tektronix_base import TektronixOscilloscopeBase
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingScope(TektronixOscilloscopeBase):
|
||||||
|
"""The real instrument class with the wire replaced by a list."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(resource_name="192.0.2.1")
|
||||||
|
self._connected = True
|
||||||
|
self.written: list[str] = []
|
||||||
|
|
||||||
|
def write(self, command):
|
||||||
|
self.written.append(command)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scope():
|
||||||
|
return RecordingScope()
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_time_channel_setup_reaches_the_wire(scope):
|
||||||
|
"""The regression: every command configure_channels() sends must go out."""
|
||||||
|
configure_channels(scope)
|
||||||
|
|
||||||
|
for ch, profile in SRAS_CHANNELS.items():
|
||||||
|
assert f"SELect:CH{ch} ON" in scope.written
|
||||||
|
assert f"CH{ch}:COUPling {profile.coupling}" in scope.written
|
||||||
|
assert f"CH{ch}:TERmination {profile.termination_ohm}" in scope.written
|
||||||
|
assert f"CH{ch}:SCAle {profile.scale_v_div}" in scope.written
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("coupling", ["DC", "AC", "dc", "ac"])
|
||||||
|
def test_channel_coupling_accepts_both_modes_in_any_case(scope, coupling):
|
||||||
|
scope.set_channel_coupling(1, coupling)
|
||||||
|
assert scope.written == [f"CH1:COUPling {coupling}"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_coupling_rejects_an_unknown_mode(scope):
|
||||||
|
"""Rejection has to be the documented ValueError, not an AttributeError
|
||||||
|
raised while building the list of valid options."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid coupling mode"):
|
||||||
|
scope.set_channel_coupling(1, "GND")
|
||||||
|
assert scope.written == []
|
||||||
|
|
||||||
|
|
||||||
|
# The other setters share this shape; a table keeps them honest together.
|
||||||
|
@pytest.mark.parametrize("setter,good,bad,sent", [
|
||||||
|
("set_acquire_mode", "AVERAGE", "SMOOTH", "ACQuire:MODe AVERAGE"),
|
||||||
|
("set_acquire_mode", "SAM", "SMOOTH", "ACQuire:MODe SAM"),
|
||||||
|
("set_trigger_slope", "RISE", "SIDEWAYS", "TRIGger:A:EDGE:SLOpe RISE"),
|
||||||
|
("set_trigger_mode", "NORMAL", "SOMETIMES", "TRIGger:A:MODe NORMAL"),
|
||||||
|
])
|
||||||
|
def test_validated_setters_take_valid_values_and_reject_the_rest(
|
||||||
|
scope, setter, good, bad, sent):
|
||||||
|
getattr(scope, setter)(good)
|
||||||
|
assert scope.written == [sent]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
getattr(scope, setter)(bad)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Raw-wire probe for the Helios pump-diode current (LDS).
|
||||||
|
|
||||||
|
Why this exists: the laser panel reports a diode current of 32 mA that no
|
||||||
|
Set will change — and 32 is also what a status register reads with bit 5
|
||||||
|
set (LER: "Over voltage laser diode", LCE: "Door switch open", CCE:
|
||||||
|
"Q-switch under/over temperature"). So either the controller really holds
|
||||||
|
LDS = 32 and is refusing to take a new value, or the line the driver reads
|
||||||
|
as LDS's answer belongs to some other query. Only the wire can say which,
|
||||||
|
and the driver cannot show it: it parses replies, and parsing is the thing
|
||||||
|
in question.
|
||||||
|
|
||||||
|
Nothing here reuses the driver's reply matching. Every byte the controller
|
||||||
|
sends is printed as it arrives, with the command that preceded it, so the
|
||||||
|
transcript answers "what does LDS actually reply?" directly.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 tools/helios_lds_probe.py --port /dev/ttyUSB0
|
||||||
|
python3 tools/helios_lds_probe.py --port /dev/ttyUSB0 --current 900
|
||||||
|
python3 tools/helios_lds_probe.py --port /dev/ttyUSB0 --read-only
|
||||||
|
|
||||||
|
Safety: LDS sets the pump diode's pulse current. It does not start
|
||||||
|
emission — that needs LDO 1 — and this probe never writes LDO. If it finds
|
||||||
|
the laser already enabled it refuses to write anything unless --force is
|
||||||
|
given, since changing the current under emission changes the output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from hardware.helios_registers import ( # noqa: E402
|
||||||
|
CCE_FLAGS, LCE_FLAGS, LER_FLAGS, SEVERITY_LABEL, decode_register,
|
||||||
|
)
|
||||||
|
from hardware.serial_util import open_8n1 # noqa: E402
|
||||||
|
|
||||||
|
REGISTERS = {"LER": LER_FLAGS, "LCE": LCE_FLAGS, "CCE": CCE_FLAGS}
|
||||||
|
|
||||||
|
# Reads that should not change anything, in the order the panel's poll
|
||||||
|
# issues them, plus the two the panel never asks for: LDG (pulse mode, which
|
||||||
|
# is what decides whether LDS is applied at all) and LMA — whose unit column
|
||||||
|
# in the manual says mA, but which this controller answers in m°C, so it is
|
||||||
|
# a resonator temperature and not a second current reading.
|
||||||
|
READ_SWEEP = ["LER", "LCE", "CCE", "LDO", "LDG", "LDF", "LDS", "LMA", "HTR"]
|
||||||
|
|
||||||
|
QUIET_S = 0.4 # a reply is over once the line is idle this long
|
||||||
|
LISTEN_S = 2.5 # ...but never wait longer than this for one
|
||||||
|
|
||||||
|
|
||||||
|
def exchange(ser, command: str, quiet_s: float = QUIET_S) -> list[tuple[float, bytes]]:
|
||||||
|
"""Send `command` and return every chunk that comes back, with timings.
|
||||||
|
|
||||||
|
No parsing, no line matching: the point is to see what the controller
|
||||||
|
sends, including anything the driver would have discarded.
|
||||||
|
"""
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
ser.reset_output_buffer()
|
||||||
|
t0 = time.monotonic()
|
||||||
|
ser.write((command + "\r").encode("ascii"))
|
||||||
|
ser.flush()
|
||||||
|
|
||||||
|
chunks: list[tuple[float, bytes]] = []
|
||||||
|
last = time.monotonic()
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - t0 >= LISTEN_S:
|
||||||
|
break
|
||||||
|
waiting = ser.in_waiting
|
||||||
|
if waiting:
|
||||||
|
chunks.append((now - t0, ser.read(waiting)))
|
||||||
|
last = time.monotonic()
|
||||||
|
elif now - last >= quiet_s:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
time.sleep(0.01)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def show(command: str, chunks) -> str:
|
||||||
|
"""Print one exchange and return the reply as text."""
|
||||||
|
raw = b"".join(c for _, c in chunks)
|
||||||
|
print(f"\n > {command}")
|
||||||
|
if not raw:
|
||||||
|
print(" (no reply)")
|
||||||
|
return ""
|
||||||
|
for offset, chunk in chunks:
|
||||||
|
print(f" +{offset * 1000:6.0f} ms {chunk!r}")
|
||||||
|
text = raw.decode("ascii", errors="replace")
|
||||||
|
lines = [ln.strip() for ln in text.replace("\r", "\n").split("\n") if ln.strip()]
|
||||||
|
for line in lines:
|
||||||
|
print(f" line: {line!r}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def answers_for(command: str, reply: str) -> bool:
|
||||||
|
"""True if some line of `reply` names `command` — i.e. it is its answer."""
|
||||||
|
mnemonic = command.split()[0].upper()
|
||||||
|
for line in reply.replace("\r", "\n").split("\n"):
|
||||||
|
head = line.strip().split("=")[0].split()
|
||||||
|
if head and head[0].upper() == mnemonic:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def value_of(command: str, reply: str) -> int | None:
|
||||||
|
"""The integer this reply reports for `command`, if it reports one."""
|
||||||
|
mnemonic = command.split()[0].upper()
|
||||||
|
for line in reply.replace("\r", "\n").split("\n"):
|
||||||
|
head, sep, tail = line.strip().partition("=")
|
||||||
|
if not sep or head.split()[:1] != [mnemonic]:
|
||||||
|
continue
|
||||||
|
fields = tail.split()
|
||||||
|
if fields:
|
||||||
|
try:
|
||||||
|
return int(fields[0])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decode(name: str, value: int | None):
|
||||||
|
if value is None:
|
||||||
|
print(f" {name}: no numeric value in the reply")
|
||||||
|
return
|
||||||
|
active = decode_register(REGISTERS[name], value)
|
||||||
|
print(f" {name} = {value} (0x{value:04X})"
|
||||||
|
+ (" — no flags set" if not active else ""))
|
||||||
|
for bit, sev, desc, comment in active:
|
||||||
|
print(f" bit {bit:>2} ({1 << bit:>5}) {SEVERITY_LABEL.get(sev, '[ ]')} "
|
||||||
|
f"{desc} — {comment}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--port", required=True, help="serial device, e.g. /dev/ttyUSB0")
|
||||||
|
ap.add_argument("--current", type=int, default=900,
|
||||||
|
help="LDS value to try writing (mA, default 900)")
|
||||||
|
ap.add_argument("--read-only", action="store_true",
|
||||||
|
help="query only; write nothing")
|
||||||
|
ap.add_argument("--force", action="store_true",
|
||||||
|
help="write LDS even if the laser reports itself enabled")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
ser = open_8n1(args.port, baudrate=9600, timeout=1.0)
|
||||||
|
time.sleep(0.2)
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
print(f"Helios probe on {args.port} — 9600 8N1\n")
|
||||||
|
print("=" * 70)
|
||||||
|
print("READ SWEEP — what each query actually answers")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
replies: dict[str, str] = {}
|
||||||
|
for command in READ_SWEEP:
|
||||||
|
replies[command] = show(command, exchange(ser, command))
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("STATUS REGISTERS")
|
||||||
|
print("=" * 70)
|
||||||
|
before = {}
|
||||||
|
for name in REGISTERS:
|
||||||
|
before[name] = value_of(name, replies[name])
|
||||||
|
decode(name, before[name])
|
||||||
|
|
||||||
|
lds_before = value_of("LDS", replies["LDS"])
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("LDS")
|
||||||
|
print("=" * 70)
|
||||||
|
if not replies["LDS"]:
|
||||||
|
print(" LDS answered nothing — it may be write-only on this firmware,")
|
||||||
|
print(" and the panel's read-back is coming from somewhere else.")
|
||||||
|
elif not answers_for("LDS", replies["LDS"]):
|
||||||
|
print(" The reply to LDS does not name LDS. That line belongs to")
|
||||||
|
print(" another command: the read-back is misaligned, not the laser.")
|
||||||
|
print(f" Reply was: {replies['LDS']!r}")
|
||||||
|
else:
|
||||||
|
print(f" LDS reads back as {lds_before} mA, and the reply names LDS,")
|
||||||
|
print(" so this is the controller's own value — not a stray line.")
|
||||||
|
|
||||||
|
if args.read_only:
|
||||||
|
ser.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
enabled = value_of("LDO", replies["LDO"])
|
||||||
|
if enabled == 1 and not args.force:
|
||||||
|
print("\nLDO reads 1 — the laser is enabled and emitting. Not writing")
|
||||||
|
print("LDS; re-run with --force if changing the current now is intended.")
|
||||||
|
ser.close()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(f"WRITE TEST — LDS {args.current}")
|
||||||
|
print("=" * 70)
|
||||||
|
show(f"LDS {args.current}", exchange(ser, f"LDS {args.current}"))
|
||||||
|
time.sleep(0.3)
|
||||||
|
after_reply = show("LDS", exchange(ser, "LDS"))
|
||||||
|
lds_after = value_of("LDS", after_reply)
|
||||||
|
|
||||||
|
print("\n Registers after the write (bit 2 = command error, bit 15 = range error):")
|
||||||
|
for name in REGISTERS:
|
||||||
|
value = value_of(name, show(name, exchange(ser, name)))
|
||||||
|
decode(name, value)
|
||||||
|
if before[name] is not None and value is not None and value != before[name]:
|
||||||
|
print(f" ^ changed from {before[name]} — the write set this")
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("VERDICT")
|
||||||
|
print("=" * 70)
|
||||||
|
if lds_after == args.current:
|
||||||
|
print(f" The controller took {args.current} mA. If the panel still shows")
|
||||||
|
print(" the old value, the problem is in the GUI, not on the wire.")
|
||||||
|
elif lds_after == lds_before:
|
||||||
|
print(f" The controller kept {lds_before} mA and ignored the write.")
|
||||||
|
print(" Check the flags above: a latched critical error (reset with")
|
||||||
|
print(" CCE 0 / LCE 0 / LER 0) or a pulse mode that does not apply a")
|
||||||
|
print(" pulse current are the two documented reasons for that.")
|
||||||
|
else:
|
||||||
|
print(f" LDS went from {lds_before} to {lds_after} — neither the old")
|
||||||
|
print(f" value nor the {args.current} mA that was asked for.")
|
||||||
|
|
||||||
|
ser.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user