Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dff7048429 | |||
| 43337bfa67 | |||
| 92bbf02c8e | |||
| b473aac6aa | |||
| 76ed7828cc | |||
| eff589d901 | |||
| c7eb33891c | |||
| aeac5fe4d6 | |||
| ff68901c57 | |||
| 6e8c1cb7a2 | |||
| 083cbdaa34 | |||
| aa06fa1460 | |||
| 83744c3337 | |||
| f823e2eb42 | |||
| c588be523e | |||
| 844fcd0297 | |||
| dfd6c9e2b8 |
+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
|
||||
and the handler reports the gap instead.
|
||||
|
||||
**Bench check:** find the power-read command in the Helios manual (the
|
||||
other reads are three-letter mnemonics like `LDO`, `LDS`, `LTA`). If one
|
||||
exists, add `get_power_mw()` to `hardware/helios_laser.py` using
|
||||
`_query_int`, then re-enable the button. If it doesn't, delete the Power
|
||||
Monitoring group from the test app and fix the README.
|
||||
**Answered (2026-09-08):** the mnemonic is `HMP` (Table 6-3, "Laser Power
|
||||
Monitor", read-only, 0-5000 mW) — but the table adds "not available on all
|
||||
models", and the operator confirms this rig's laser has no power meter. So
|
||||
the button stays disabled; what remains is to delete the Power Monitoring
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`libueye_api64.so.3.82` came from (IDS SDK version) and how the loader is
|
||||
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,8 +12,16 @@ scanengine-3 is a unified platform for scanning acoustic microscopy and precisio
|
||||
- **Laser Systems**: Helios pulsed laser and Genesis CW laser control
|
||||
- **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support
|
||||
- **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
|
||||
|
||||
## Hardware Components
|
||||
@@ -58,10 +66,12 @@ scanengine-3/
|
||||
│ ├── scan_resume.py # Resume planning (frontier rule)
|
||||
│ ├── scope_sras.py # Oscilloscope SCPI policy for SRAS
|
||||
│ ├── scope_burst.py # Burst-mode FastFrame sizing + row splitting
|
||||
│ ├── scope_inspect.py # Scope setup for pre-scan angle inspection
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
│ └── config.py # ScanDefaults ⇄ aui_defaults.json
|
||||
│
|
||||
@@ -78,12 +88,15 @@ scanengine-3/
|
||||
├── gui/ # Shared PyQt6 layer
|
||||
│ ├── 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_workers.py # QueueWorker / PollingQueueWorker bases
|
||||
│ ├── jog_panel.py # T3R + BBD202 jog controls (camera window)
|
||||
│ └── widgets.py # ConnectionBar, LogConsole, PortSelector…
|
||||
│
|
||||
├── sc3_aui_app.py # Main acquisition application
|
||||
├── 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
|
||||
├── t3r_control_panel.py # T3R panel (used by the main app)
|
||||
├── helios_test_app.py # Per-device test benches
|
||||
@@ -92,7 +105,7 @@ scanengine-3/
|
||||
├── sc3-aui-*.ui # Qt Designer files loaded at runtime
|
||||
│
|
||||
├── tests/ # pytest suite
|
||||
│ ├── golden/ # v6 .sras + geometry fixtures
|
||||
│ ├── golden/ # legacy v6 .sras + geometry fixtures
|
||||
│ ├── fakes.py # Recording fake stage/scope/rotator
|
||||
│ └── test_*.py
|
||||
│
|
||||
@@ -133,6 +146,9 @@ python sc3_aui_app.py
|
||||
# Scan data viewer
|
||||
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
|
||||
python sras_scan_manager.py path/to/scan.sras
|
||||
|
||||
@@ -208,6 +224,59 @@ result = engine.run() # blocking; engine.abort() is thread-safe
|
||||
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
|
||||
|
||||
`SrasFile` memory-maps the data block, so opening a multi-gigabyte scan
|
||||
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
)
|
||||
+79
-28
@@ -18,7 +18,10 @@ from typing import Callable
|
||||
from core import scope_burst, scope_sras
|
||||
from core.rotation import RotationAxis
|
||||
from core.scan_geometry import ScanPlan, validate_plan
|
||||
from core.sras_format import SCAN_CHANNELS, create_scan_file
|
||||
from core.sras_format import (
|
||||
BG_LEN_SIZE, SCAN_CHANNELS, VERSION, create_scan_file,
|
||||
write_background_block,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,8 +45,15 @@ class ScanAborted(Exception):
|
||||
|
||||
@dataclass
|
||||
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
|
||||
bg_offset: int
|
||||
data_offset: int
|
||||
n_rows: int
|
||||
angle_deg: float
|
||||
@@ -96,7 +106,8 @@ class ScanEngine:
|
||||
plan: ScanPlan, out_path: Path,
|
||||
resume: ResumeState | None = None,
|
||||
callbacks: ScanCallbacks | None = None,
|
||||
burst_mode: bool = False, strict_rows: bool = False):
|
||||
burst_mode: bool = False, strict_rows: bool = False,
|
||||
file_version: int = VERSION):
|
||||
self._stage = stage
|
||||
self._scope = scope
|
||||
self._rotator = rotator
|
||||
@@ -110,6 +121,11 @@ class ScanEngine:
|
||||
# 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
|
||||
|
||||
@@ -274,27 +290,14 @@ class ScanEngine:
|
||||
|
||||
if self._resume is None:
|
||||
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS)
|
||||
self._prompt(
|
||||
"Background Capture",
|
||||
"Please ensure the Helios laser is ON and the Genesis laser is OFF,\n"
|
||||
"then click OK to capture the background waveform."
|
||||
)
|
||||
self._background = scope_sras.capture_background(
|
||||
self._scope, should_abort=self._abort.is_set,
|
||||
on_status=self._cb.on_status)
|
||||
self._prompt(
|
||||
"Begin Scanning",
|
||||
"Background captured successfully.\n\n"
|
||||
"Please ensure the Genesis laser is back ON,\n"
|
||||
"then click OK to begin scanning."
|
||||
)
|
||||
else:
|
||||
# Resuming: the file's existing background waveform and channel
|
||||
# preambles are reused as-is (the format has no way to replace
|
||||
# them without rewriting the whole file), so background capture is
|
||||
# skipped. Sanity-check that this scope still produces the same
|
||||
# record length the file was started with — a mismatch would
|
||||
# silently corrupt the ragged per-row byte layout on append.
|
||||
# Resuming: the file's channel preambles are reused as-is (the
|
||||
# format has no way to replace them without rewriting the whole
|
||||
# file). Each re-acquired angle still captures its own fresh
|
||||
# background, which is rewritten in place over the old one.
|
||||
# Sanity-check that this scope still produces the same record
|
||||
# length the file was started with — a mismatch would silently
|
||||
# corrupt the ragged per-row byte layout on append.
|
||||
if samples_per_frame != self._resume.samples_per_frame:
|
||||
raise RuntimeError(
|
||||
f"Oscilloscope record length ({samples_per_frame} samples/frame) "
|
||||
@@ -308,8 +311,8 @@ class ScanEngine:
|
||||
f"angle(s) {targets} of {self._plan.n_angles}.\n\n"
|
||||
"Please re-home the GR axis to 0° before continuing — the scan "
|
||||
"will rotate it directly from angle to angle before scanning resumes.\n\n"
|
||||
"Please ensure the Genesis laser is ON,\n"
|
||||
"then click OK to continue scanning."
|
||||
"Each angle begins with its own background capture, so you will "
|
||||
"be asked to switch the Genesis laser off and on again per angle."
|
||||
)
|
||||
|
||||
scope_sras.configure_scan_trigger(self._scope)
|
||||
@@ -335,7 +338,8 @@ class ScanEngine:
|
||||
return open(self._resume.path, "r+b")
|
||||
return create_scan_file(
|
||||
self._out_path, self._plan, samples_per_frame,
|
||||
scope_sras.SAMPLE_RATE_HZ, self._preambles, self._background,
|
||||
scope_sras.SAMPLE_RATE_HZ, self._preambles,
|
||||
version=self._file_version,
|
||||
)
|
||||
|
||||
def _scan_loop(self, scan_file, samples_per_frame: int, result: ScanResult):
|
||||
@@ -356,11 +360,12 @@ class ScanEngine:
|
||||
continue # not selected for (re)acquisition
|
||||
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,
|
||||
# so seek to this angle's fixed offset rather than relying on
|
||||
# the file's current position.
|
||||
scan_file.seek(targets_by_ai[ai].data_offset)
|
||||
scan_file.seek(target.bg_offset)
|
||||
|
||||
if self._rotator is not None and self._rotator.is_available:
|
||||
delta = pa.angle_deg - self._rotator.current_deg
|
||||
@@ -369,6 +374,9 @@ class ScanEngine:
|
||||
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
|
||||
self._rotator.rotate_to(pa.angle_deg)
|
||||
|
||||
self._write_angle_background(scan_file, ai, n_angles,
|
||||
pa.angle_deg, target)
|
||||
|
||||
if self._burst_mode:
|
||||
# Burst mode sizes the FastFrame count from the scope's whole
|
||||
# capacity instead (see scope_burst.start_burst), so there is
|
||||
@@ -384,6 +392,49 @@ class ScanEngine:
|
||||
|
||||
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,
|
||||
|
||||
+12
-5
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from core.scan_engine import ResumeState, ResumeTarget
|
||||
from core.sras_format import SrasFile
|
||||
from core.sras_format import WRITABLE_VERSIONS, SrasFile
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,8 +44,9 @@ def plan_resume(statuses, selected: set[int]) -> ResumePlan:
|
||||
final = set(selected)
|
||||
|
||||
targets = [
|
||||
ResumeTarget(angle_idx=s.index, data_offset=s.data_offset,
|
||||
n_rows=s.n_rows, angle_deg=s.angle_deg)
|
||||
ResumeTarget(angle_idx=s.index, bg_offset=s.bg_offset,
|
||||
data_offset=s.data_offset, n_rows=s.n_rows,
|
||||
angle_deg=s.angle_deg)
|
||||
for s in statuses if s.index in final
|
||||
]
|
||||
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,
|
||||
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
|
||||
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 abs(h.velocity - velocity) <= 1e-3
|
||||
and abs(h.laser_freq - laser_freq) <= 1e-3
|
||||
|
||||
@@ -14,6 +14,11 @@ 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
|
||||
|
||||
@@ -46,6 +51,12 @@ 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.
|
||||
@@ -102,3 +113,24 @@ def stop_inspection(scope) -> None:
|
||||
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]
|
||||
|
||||
+11
-4
@@ -51,11 +51,13 @@ def configure_channels(scope, profiles=None) -> None:
|
||||
scope.set_channel_bandwidth(ch, p.bandwidth_hz)
|
||||
|
||||
|
||||
def configure_acquisition(scope) -> int:
|
||||
"""Program the edge trigger and timebase; returns samples per frame.
|
||||
def configure_background_trigger(scope) -> None:
|
||||
"""Program the edge trigger used for a background capture.
|
||||
|
||||
Edge trigger on the rising edge of CH2 (laser pulse). FastFrame stays
|
||||
off here so the background capture runs as a single record.
|
||||
Edge trigger on the rising edge of CH2 (laser pulse), FastFrame off so
|
||||
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.set_trigger_source(2)
|
||||
@@ -64,6 +66,11 @@ def configure_acquisition(scope) -> int:
|
||||
scope.set_trigger_mode("NORMAL") # wait for trigger (don't auto-sweep)
|
||||
scope.set_acquire_mode("SAMPLE")
|
||||
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.
|
||||
|
||||
+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:
|
||||
|
||||
@@ -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)
|
||||
row tables (ragged) per angle: n_rows × >f (y positions)
|
||||
preambles n_channels × (>H length + utf-8 WFMOutpre string)
|
||||
background block >I length + raw int8 CH1 average
|
||||
waveform data angle-major, row-minor, channel-inner:
|
||||
for each angle, for each row, for each channel,
|
||||
data block per angle: [background][rows]
|
||||
background = >I length + raw int8 CH1 average
|
||||
rows = for each row, for each channel,
|
||||
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
|
||||
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
|
||||
|
||||
@@ -31,11 +51,21 @@ import numpy as np
|
||||
from core.scan_geometry import AngleGeometry, ScanPlan
|
||||
|
||||
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_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
|
||||
GEOM_FMT = ">ffIH"
|
||||
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.
|
||||
SCAN_CHANNELS = [1, 3, 4]
|
||||
@@ -69,7 +99,8 @@ class AngleStatus:
|
||||
angle_deg: float
|
||||
n_rows: int # declared
|
||||
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
|
||||
status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING
|
||||
|
||||
@@ -77,19 +108,46 @@ class AngleStatus:
|
||||
def complete(self) -> bool:
|
||||
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,
|
||||
sample_rate: float, preambles: list[str],
|
||||
background_waveform: bytes) -> BinaryIO:
|
||||
"""Create a new .sras file and write the v6 header + tables.
|
||||
version: int = VERSION) -> BinaryIO:
|
||||
"""Create a new .sras file and write the header + tables.
|
||||
|
||||
Returns an open binary file positioned at the start of the data block;
|
||||
the caller appends waveform rows and must close it (try/finally).
|
||||
``version`` selects which kind of file this is — VERSION for a full scan,
|
||||
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)
|
||||
f = open(path, "wb")
|
||||
f.write(struct.pack(
|
||||
HDR_FMT, MAGIC, VERSION,
|
||||
HDR_FMT, MAGIC, version,
|
||||
plan.n_angles,
|
||||
plan.x_start_nominal, plan.y_start_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")
|
||||
f.write(struct.pack(">H", len(enc)))
|
||||
f.write(enc)
|
||||
f.write(struct.pack(">I", len(background_waveform)))
|
||||
f.write(background_waveform)
|
||||
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
|
||||
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
|
||||
opening a multi-GB file is cheap. ``load_angle``/``load_row`` return
|
||||
read-only numpy views backed by a shared mmap; no data is copied until
|
||||
the caller computes on it.
|
||||
Parsing reads only the header/tables and the per-angle background blocks
|
||||
— never the waveform block — so opening a multi-GB file is cheap.
|
||||
``load_angle``/``load_row`` return read-only numpy views backed by a
|
||||
shared mmap; no data is copied until the caller computes on it.
|
||||
|
||||
``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
|
||||
version: int = field(init=False)
|
||||
header: ScanHeader = field(init=False)
|
||||
per_angle: list[AngleGeometry] = field(init=False)
|
||||
preambles: list[str] = field(init=False)
|
||||
preambles_raw: list[bytes] = field(init=False)
|
||||
background: bytes = field(init=False)
|
||||
backgrounds: list[bytes] = field(init=False)
|
||||
data_start_offset: int = field(init=False)
|
||||
file_size: int = field(init=False)
|
||||
|
||||
@@ -149,11 +222,12 @@ class SrasFile:
|
||||
n_channels) = struct.unpack(HDR_FMT, raw)
|
||||
if magic != MAGIC:
|
||||
raise ValueError(f"{self.path.name}: not a valid SRAS file (bad magic)")
|
||||
if version != VERSION:
|
||||
if version not in SUPPORTED_VERSIONS:
|
||||
raise ValueError(
|
||||
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(
|
||||
n_angles=n_angles,
|
||||
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 = [p.decode("utf-8", errors="replace") for p in self.preambles_raw]
|
||||
|
||||
(n_bg,) = struct.unpack(">I", f.read(4))
|
||||
self.background = f.read(n_bg)
|
||||
shared_bg = None
|
||||
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._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 ───────────────────────────────────────
|
||||
|
||||
@@ -199,40 +328,48 @@ class SrasFile:
|
||||
|
||||
Because the data is one contiguous append-only stream, once an angle
|
||||
is found short every later angle is necessarily absent too — there is
|
||||
a single frontier past which nothing has been written yet.
|
||||
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 = []
|
||||
cursor = self.data_start_offset
|
||||
frontier_seen = False
|
||||
for ai, pa in enumerate(self.per_angle):
|
||||
row_bytes = self.row_bytes(ai)
|
||||
data_offset = cursor
|
||||
if frontier_seen:
|
||||
data_offset = self._data_offsets[ai]
|
||||
if frontier_seen or not self._bg_present[ai]:
|
||||
n_rows_available = 0
|
||||
status = STATUS_MISSING
|
||||
frontier_seen = True
|
||||
else:
|
||||
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
|
||||
status = STATUS_OK
|
||||
cursor += declared_bytes
|
||||
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
|
||||
status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED
|
||||
frontier_seen = True
|
||||
statuses.append(AngleStatus(
|
||||
index=ai, angle_deg=pa.angle_deg, n_rows=pa.n_rows,
|
||||
row_bytes=row_bytes, data_offset=data_offset,
|
||||
row_bytes=row_bytes, bg_offset=self._bg_offsets[ai],
|
||||
data_offset=data_offset,
|
||||
n_rows_available=n_rows_available, status=status,
|
||||
))
|
||||
return statuses
|
||||
|
||||
def angle_data_offset(self, angle_idx: int) -> int:
|
||||
offset = self.data_start_offset
|
||||
for ai in range(angle_idx):
|
||||
offset += self.row_bytes(ai) * self.per_angle[ai].n_rows
|
||||
return offset
|
||||
"""Where angle ``angle_idx``'s rows start (past its background)."""
|
||||
return self._data_offsets[angle_idx]
|
||||
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -248,6 +385,11 @@ class SrasFile:
|
||||
def _dtype(self) -> np.dtype:
|
||||
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:
|
||||
"""Read-only view of one angle's data block, shape
|
||||
(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,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):
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@pyqtSlot()
|
||||
|
||||
+4
-2
@@ -12,6 +12,7 @@ import traceback
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
||||
|
||||
from core.scan_engine import ScanAborted, ScanCallbacks, ScanEngine
|
||||
from core.sras_format import VERSION
|
||||
|
||||
|
||||
class QtScanController(QObject):
|
||||
@@ -33,7 +34,7 @@ class QtScanController(QObject):
|
||||
|
||||
def __init__(self, stage, scope, rotator, plan, out_path,
|
||||
resume=None, on_scan_active=None, burst_mode=False,
|
||||
strict_rows=False):
|
||||
strict_rows=False, file_version=VERSION):
|
||||
super().__init__()
|
||||
self._prompt_event = threading.Event()
|
||||
self._on_scan_active = on_scan_active
|
||||
@@ -50,7 +51,8 @@ class QtScanController(QObject):
|
||||
self._engine = ScanEngine(stage, scope, rotator, plan, out_path,
|
||||
resume=resume, callbacks=callbacks,
|
||||
burst_mode=burst_mode,
|
||||
strict_rows=strict_rows)
|
||||
strict_rows=strict_rows,
|
||||
file_version=file_version)
|
||||
|
||||
# ── Engine control (called from the GUI thread) ───────────────────────────
|
||||
|
||||
|
||||
+9
-4
@@ -17,6 +17,14 @@ from PyQt6.QtWidgets import (
|
||||
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):
|
||||
"""Update a checkable button without re-triggering its toggled signal."""
|
||||
btn.blockSignals(True)
|
||||
@@ -138,10 +146,7 @@ class LogConsole(QWidget):
|
||||
self.view = QPlainTextEdit()
|
||||
self.view.setReadOnly(True)
|
||||
self.view.setMaximumBlockCount(max_blocks)
|
||||
font = QFont("Menlo")
|
||||
font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
font.setPointSize(11)
|
||||
self.view.setFont(font)
|
||||
self.view.setFont(mono_font())
|
||||
layout.addWidget(self.view)
|
||||
|
||||
row = QHBoxLayout()
|
||||
|
||||
+220
-40
@@ -34,6 +34,7 @@ class HeliosLaser:
|
||||
self.timeout = timeout
|
||||
self.serial = None
|
||||
self.is_connected = False
|
||||
self._rx = bytearray() # bytes read off the port, not yet a line
|
||||
|
||||
@staticmethod
|
||||
def list_available_ports() -> List[str]:
|
||||
@@ -52,6 +53,7 @@ class HeliosLaser:
|
||||
|
||||
try:
|
||||
self.serial = open_8n1(self.port, baudrate=9600, timeout=self.timeout)
|
||||
self._rx.clear()
|
||||
time.sleep(0.1) # Allow time for connection to stabilize
|
||||
self.is_connected = True
|
||||
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}")
|
||||
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]:
|
||||
"""Send a query and return the value from its response.
|
||||
|
||||
Reads until the CR terminator rather than sleeping a fixed interval:
|
||||
the device usually answers in a few ms, so the old unconditional
|
||||
0.05 + 0.2 s cost ~250 ms per query and made an 8-query status poll
|
||||
take ~2 s — longer than the 1 s interval that scheduled it.
|
||||
Reads until this command's reply arrives rather than sleeping a fixed
|
||||
interval: the device usually answers in a few ms, so the old
|
||||
unconditional 0.05 + 0.2 s cost ~250 ms per query and made an 8-query
|
||||
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:
|
||||
# Clear any stale bytes so a previous timed-out reply can't be
|
||||
# mistaken for this command's response.
|
||||
self.serial.reset_input_buffer()
|
||||
# Anything volunteered while the port was idle answers no command.
|
||||
self._discard_input()
|
||||
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
|
||||
response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip()
|
||||
if not response:
|
||||
deadline = time.monotonic() + self.timeout
|
||||
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")
|
||||
self._read_pending_lines()
|
||||
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:
|
||||
logger.error(f"Failed to read response for '{command}': {e}")
|
||||
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]:
|
||||
"""Query a value that should parse as an int; None if absent/unparseable."""
|
||||
raw = self._query(command)
|
||||
@@ -155,27 +323,35 @@ class HeliosLaser:
|
||||
logger.error(f"Period {period_ns} ns out of range (8000-60000)")
|
||||
return False
|
||||
|
||||
command = f"LDF {period_ns}"
|
||||
return self._send_command(command)
|
||||
return self._write_verified("LDF", period_ns)
|
||||
|
||||
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):
|
||||
logger.error(f"Current {current} mA out of range (0-2000)")
|
||||
return False
|
||||
|
||||
command = f"LDS {current}"
|
||||
return self._send_command(command)
|
||||
return self._write_verified("LDS", current)
|
||||
|
||||
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}"
|
||||
return self._send_command(command)
|
||||
return self._write_command(command)
|
||||
|
||||
def set_laser_enable(self, enable: bool) -> bool:
|
||||
"""Enable or disable laser emission."""
|
||||
command = f"LDO {1 if enable else 0}"
|
||||
success = self._send_command(command)
|
||||
success = self._write_command(command)
|
||||
|
||||
if success:
|
||||
state = "enabled" if enable else "disabled"
|
||||
@@ -243,11 +419,11 @@ class HeliosLaser:
|
||||
True if all three commands sent successfully
|
||||
"""
|
||||
ok = True
|
||||
ok = self._send_command("CCE 0") and ok
|
||||
ok = self._write_command("CCE 0") and ok
|
||||
time.sleep(0.1)
|
||||
ok = self._send_command("LCE 0") and ok
|
||||
ok = self._write_command("LCE 0") and ok
|
||||
time.sleep(0.1)
|
||||
ok = self._send_command("LER 0") and ok
|
||||
ok = self._write_command("LER 0") and ok
|
||||
if ok:
|
||||
logger.info("Fault reset sequence sent")
|
||||
return ok
|
||||
@@ -258,18 +434,22 @@ class HeliosLaser:
|
||||
return None if value is None else value == 1
|
||||
|
||||
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:
|
||||
logger.error("Not connected to laser")
|
||||
return None
|
||||
try:
|
||||
self.serial.reset_input_buffer()
|
||||
self._discard_input()
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
raw = self.serial.read_until(b'\r')
|
||||
if not raw:
|
||||
raw = self.serial.read(self.serial.in_waiting)
|
||||
return raw.decode('ascii', errors='replace').strip()
|
||||
first = self._read_line()
|
||||
lines = [first] if first else []
|
||||
lines += self._read_pending_lines()
|
||||
return "\n".join(lines)
|
||||
except Exception as e:
|
||||
logger.error(f"send_raw_command error: {e}")
|
||||
return None
|
||||
@@ -277,7 +457,7 @@ class HeliosLaser:
|
||||
def set_remote_enable(self, enable: bool) -> bool:
|
||||
"""Set the remote enable state (LRE - utility connector pin 8)."""
|
||||
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
|
||||
# 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
|
||||
@@ -292,7 +292,7 @@ class TektronixOscilloscopeBase:
|
||||
|
||||
# Validate coupling
|
||||
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]:
|
||||
valid = True
|
||||
break
|
||||
@@ -344,6 +344,72 @@ class TektronixOscilloscopeBase:
|
||||
|
||||
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 ==========
|
||||
|
||||
def set_data_source(self, source):
|
||||
|
||||
+8
-105
@@ -16,106 +16,9 @@ from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from hardware.helios_laser import HeliosLaser, PulseMode
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
from hardware.helios_registers import (
|
||||
CCE_FLAGS, LCE_FLAGS, LER_FLAGS, SEVERITY_LABEL, decode_register,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -945,20 +848,20 @@ class HeliosTestApp(QMainWindow):
|
||||
# Decode and display individual flags
|
||||
lines = []
|
||||
for reg_name, value, flags_dict in (
|
||||
("LER", ler, _LER_FLAGS),
|
||||
("LCE", lce, _LCE_FLAGS),
|
||||
("CCE", cce, _CCE_FLAGS),
|
||||
("LER", ler, LER_FLAGS),
|
||||
("LCE", lce, LCE_FLAGS),
|
||||
("CCE", cce, CCE_FLAGS),
|
||||
):
|
||||
if value is None:
|
||||
lines.append(f"{reg_name}: <read error>")
|
||||
continue
|
||||
active = _decode_register(flags_dict, value)
|
||||
active = decode_register(flags_dict, value)
|
||||
if not active:
|
||||
lines.append(f"{reg_name} (raw={value}): OK — no flags set")
|
||||
else:
|
||||
lines.append(f"{reg_name} (raw={value}):")
|
||||
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" → {comment}")
|
||||
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>
|
||||
</layout>
|
||||
</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>
|
||||
<widget class="QPushButton" name="uc480_close_window_btn">
|
||||
<property name="text">
|
||||
|
||||
@@ -109,6 +109,29 @@
|
||||
</property>
|
||||
</widget>
|
||||
</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>
|
||||
</widget>
|
||||
</item>
|
||||
|
||||
@@ -1017,6 +1017,16 @@
|
||||
</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">
|
||||
@@ -1086,6 +1096,7 @@
|
||||
<tabstop>bbd_set_current_start_btn</tabstop>
|
||||
<tabstop>bbd_set_delta_current_btn</tabstop>
|
||||
<tabstop>show_camera_toggle</tabstop>
|
||||
<tabstop>saw_check_btn</tabstop>
|
||||
<tabstop>start_scan_btn</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
|
||||
+614
-30
@@ -6,6 +6,7 @@ and wires up T3R, BBD202, oscilloscope, and camera hardware workers.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -17,8 +18,8 @@ from PyQt6.QtCore import QThread, QTimer, Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QImage, QPixmap
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
|
||||
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton,
|
||||
QSizePolicy, QVBoxLayout, QWidget,
|
||||
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
from matplotlib.figure import Figure
|
||||
@@ -26,20 +27,32 @@ from matplotlib.figure import Figure
|
||||
ROOT = Path(__file__).parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from core.auto_align import DEFAULT_ALIGN, DEFAULT_T_AXIS
|
||||
from core.config import ScanDefaults
|
||||
from core.rotation import DEFAULT_ROTATION, RotationAxis
|
||||
from core.scan_engine import (
|
||||
ResumeState,
|
||||
LASER_FREQ_HZ, SCAN_VELOCITY_MM_S,
|
||||
)
|
||||
from core.saw_check import middle_row_plan
|
||||
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta
|
||||
from core.scan_resume import is_compatible, plan_resume
|
||||
from core.scope_inspect import BIAS_LABELS
|
||||
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
|
||||
from core.sras_format import SCAN_CHANNELS, SrasFile, plan_from_header
|
||||
from core.sras_format import (
|
||||
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, WRITABLE_VERSIONS, SrasFile,
|
||||
plan_from_header,
|
||||
)
|
||||
from gui.align_bridge import QtAutoAligner
|
||||
from gui.jog_panel import (
|
||||
BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM,
|
||||
BBDJogPanel, T3RJogPanel,
|
||||
)
|
||||
from gui.qt_t3r import QtT3RAdapter
|
||||
from gui.qt_workers import PollingQueueWorker, QueueWorker
|
||||
from gui.inspect_bridge import QtAngleInspector
|
||||
from gui.scan_bridge import QtScanController
|
||||
from gui.widgets import mono_font
|
||||
from hardware.helios_laser import HeliosLaser
|
||||
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
|
||||
from hardware.tektronix_base import TektronixOscilloscopeBase
|
||||
@@ -50,7 +63,10 @@ from t3r_control_panel import T3RControlPanel
|
||||
|
||||
DEFAULTS = ScanDefaults.load()
|
||||
|
||||
BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202
|
||||
# A SAW check is written beside the scan it belongs to, under the same prefix.
|
||||
# The suffix keeps it from overwriting the scan itself, which is the one file
|
||||
# in the directory that cost hours to acquire.
|
||||
SAW_CHECK_SUFFIX = "-sawcheck"
|
||||
|
||||
|
||||
class DCBiasImageWidget(FigureCanvas):
|
||||
@@ -128,9 +144,6 @@ class DCBiasImageWidget(FigureCanvas):
|
||||
self.draw_idle()
|
||||
|
||||
|
||||
BBD_JOG_SPEED_MM_S = 10.0
|
||||
BBD_JOG_ACCEL_MM_S2 = 50.0
|
||||
|
||||
# ── BBD202 worker ─────────────────────────────────────────────────────────────
|
||||
|
||||
class BBD202Worker(PollingQueueWorker):
|
||||
@@ -144,13 +157,14 @@ class BBD202Worker(PollingQueueWorker):
|
||||
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
|
||||
self.controller: ThorlabsServoDriver | None = None
|
||||
self.scanning_active = False # pause polling during a scan
|
||||
self._jog_step = BBD_DEFAULT_JOG_MM
|
||||
self._jog_step = BBD_JOG_STEP_MM
|
||||
self._last_pos: tuple[float, float] | None = None
|
||||
self._last_homed: tuple[bool, bool] | None = None
|
||||
self._handlers.update({
|
||||
"connect": self._do_connect,
|
||||
"disconnect": self._do_disconnect,
|
||||
"jog": self._do_jog,
|
||||
"set_velocity": self._do_set_velocity,
|
||||
"home_all": self._do_home_all,
|
||||
"enable_all": self._do_enable_all,
|
||||
})
|
||||
@@ -192,15 +206,23 @@ class BBD202Worker(PollingQueueWorker):
|
||||
self.is_connected = False
|
||||
self.disconnected.emit()
|
||||
|
||||
def _do_jog(self, axis: str, direction: int):
|
||||
def _do_jog(self, axis: str, direction: int, step_mm: float | None = None):
|
||||
if not self.controller:
|
||||
return
|
||||
dest = AXIS_X if axis == "x" else AXIS_Y
|
||||
step = self._jog_step if step_mm is None else step_mm
|
||||
try:
|
||||
self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0)
|
||||
self.controller.move_axis_relative(dest, step * direction, timeout=2.0)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
def _do_set_velocity(self, max_velocity: float, acceleration: float):
|
||||
if not self.controller:
|
||||
return
|
||||
for dest in (AXIS_X, AXIS_Y):
|
||||
self.controller.set_velocity_params(dest, max_velocity=max_velocity,
|
||||
acceleration=acceleration)
|
||||
|
||||
def _do_home_all(self):
|
||||
if not self.controller:
|
||||
return
|
||||
@@ -242,8 +264,12 @@ class BBD202Worker(PollingQueueWorker):
|
||||
def queue_disconnect(self):
|
||||
self._enqueue("disconnect")
|
||||
|
||||
def queue_jog(self, axis: str, direction: int):
|
||||
self._enqueue("jog", axis=axis, direction=direction)
|
||||
def queue_jog(self, axis: str, direction: int, step_mm: float | None = None):
|
||||
self._enqueue("jog", axis=axis, direction=direction, step_mm=step_mm)
|
||||
|
||||
def queue_set_velocity(self, max_velocity: float, acceleration: float):
|
||||
self._enqueue("set_velocity", max_velocity=max_velocity,
|
||||
acceleration=acceleration)
|
||||
|
||||
def queue_home_all(self):
|
||||
self._enqueue("home_all")
|
||||
@@ -311,12 +337,15 @@ class HeliosWorker(PollingQueueWorker):
|
||||
"""
|
||||
enabled_updated = pyqtSignal(bool)
|
||||
current_updated = pyqtSignal(int)
|
||||
current_unknown = pyqtSignal()
|
||||
diode_temp_updated = pyqtSignal(float)
|
||||
pstage_temp_updated = pyqtSignal(float)
|
||||
qswitch_temp_updated = pyqtSignal(float)
|
||||
status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None)
|
||||
|
||||
POLL_INTERVAL_S = 1.0
|
||||
# A full sweep is eight queries, ~360 ms of port time. The interval is
|
||||
# the gap between sweeps, so the panel refreshes about every 0.65 s.
|
||||
POLL_INTERVAL_S = 0.3
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
|
||||
@@ -373,28 +402,61 @@ class HeliosWorker(PollingQueueWorker):
|
||||
def _do_set_current(self, current_ma: int):
|
||||
if not self._laser:
|
||||
return
|
||||
self._laser.set_current_ma(current_ma)
|
||||
if self._laser.set_current_ma(current_ma):
|
||||
self.current_updated.emit(current_ma)
|
||||
return
|
||||
# The manual warns the controller drops set values unintentionally,
|
||||
# so the driver verifies the write. Say when it did not take: this
|
||||
# used to emit the requested value regardless, and the next poll
|
||||
# then quietly restored the old one.
|
||||
actual = self._laser.get_current_ma()
|
||||
if actual is not None:
|
||||
self.current_updated.emit(actual)
|
||||
self.error_occurred.emit(
|
||||
f"Diode current: controller kept {actual} mA, "
|
||||
f"did not accept {current_ma} mA")
|
||||
|
||||
def _poll_once(self):
|
||||
"""Read the full status set. Individual reads are allowed to fail
|
||||
(a timed-out register shouldn't suppress the rest of the panel)."""
|
||||
(a timed-out register shouldn't suppress the rest of the panel).
|
||||
|
||||
Abandoned as soon as the operator queues something: the rest of the
|
||||
sweep is worth less than a button that responds now, and the next
|
||||
poll will pick it up.
|
||||
"""
|
||||
if not self._laser or not self._laser.is_connected:
|
||||
return
|
||||
try:
|
||||
self.status_registers_updated.emit(*self._laser.get_status_registers())
|
||||
except Exception:
|
||||
pass
|
||||
if self._work_pending():
|
||||
return
|
||||
try:
|
||||
self.enabled_updated.emit(self._laser.is_laser_enabled())
|
||||
except Exception:
|
||||
pass
|
||||
if self._work_pending():
|
||||
return
|
||||
# A failed LDS read must not leave the last good value on screen
|
||||
# looking live: that is indistinguishable from a setpoint that
|
||||
# refuses to move, which is the failure this panel exists to show.
|
||||
try:
|
||||
current = self._laser.get_current_ma()
|
||||
except Exception:
|
||||
current = None
|
||||
if current is None:
|
||||
self.current_unknown.emit()
|
||||
else:
|
||||
self.current_updated.emit(current)
|
||||
|
||||
for getter, signal in (
|
||||
(self._laser.get_current_ma, self.current_updated),
|
||||
(self._laser.get_diode_temp_c, self.diode_temp_updated),
|
||||
(self._laser.get_power_stage_temp_c, self.pstage_temp_updated),
|
||||
(self._laser.get_qswitch_temp_c, self.qswitch_temp_updated),
|
||||
):
|
||||
if self._work_pending():
|
||||
return
|
||||
try:
|
||||
value = getter()
|
||||
if value is not None:
|
||||
@@ -417,12 +479,63 @@ class HeliosWorker(PollingQueueWorker):
|
||||
|
||||
# ── Camera window popup ───────────────────────────────────────────────────────
|
||||
|
||||
# The channel map auto-align assumes. Nothing reconfigures the scope inputs —
|
||||
# CH3 is the max-velocity gate during a scan and the DC 1 monitor here — so the
|
||||
# operator is asked to confirm the cabling instead of being trusted to
|
||||
# remember it.
|
||||
ALIGN_SCOPE_CHANNELS = ((1, "SAW"), (2, "Trigger"), (3, "DC 1"), (4, "DC 2"))
|
||||
|
||||
|
||||
def _align_scope_prompt() -> str:
|
||||
channels = "\n".join(f" CH{ch} — {role}" for ch, role in ALIGN_SCOPE_CHANNELS)
|
||||
# The channels arrive on screen carrying the inspection labels, which are
|
||||
# the names for the same two monitors — say so, or the operator is left
|
||||
# comparing "DC 1" here against "Bias - A" on the instrument.
|
||||
labels = " and ".join(BIAS_LABELS[ch] for ch in sorted(BIAS_LABELS))
|
||||
return (
|
||||
"Auto-align reads the DC levels off CH3 and CH4. Check the scope is "
|
||||
"cabled this way before starting:\n\n"
|
||||
f"{channels}\n\n"
|
||||
f"The scope will label the last two {labels}.\n\n"
|
||||
"The laser must be pulsing and the detection beam on the sample — the "
|
||||
"procedure stops rather than servo on a scope that is not triggering.\n\n"
|
||||
"Start auto-align?"
|
||||
)
|
||||
|
||||
|
||||
def _align_reference_prompt(reading) -> str:
|
||||
return (
|
||||
"The detector reads, where the stage is standing:\n\n"
|
||||
f" DC 1 {reading.dc1_mv:8.1f} mV\n"
|
||||
f" DC 2 {reading.dc2_mv:8.1f} mV\n\n"
|
||||
"Is the image correct?\n\n"
|
||||
f"Yes takes these as the good values and holds them to within "
|
||||
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV at "
|
||||
f"{DEFAULT_ALIGN.offset_mm:.2f} mm either side of here, first on X "
|
||||
f"(T1) and then on Y (T0/T2 as a pair)."
|
||||
)
|
||||
|
||||
|
||||
class CameraWindow(QWidget):
|
||||
"""Camera display popup — auto-connects on show, auto-disconnects on close."""
|
||||
"""Camera display popup — auto-connects on show, auto-disconnects on close.
|
||||
|
||||
Focusing and framing are done by eye, so the T3R and BBD202 jog controls
|
||||
live beside the image. Both take the objects the main window already
|
||||
owns; passing neither leaves the window as a plain viewer.
|
||||
|
||||
Auto-align lives here for the same reason: the operator judges the image
|
||||
to decide the rig is on a good spot, and that judgement is the first step
|
||||
of the procedure. It needs the oscilloscope as well, so the button is
|
||||
only offered when all three are on hand.
|
||||
"""
|
||||
|
||||
closed = pyqtSignal()
|
||||
align_active = pyqtSignal(bool) # lock the scan panel while aligning
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
def __init__(self, t3r_driver: QtT3RAdapter | None = None,
|
||||
bbd_worker: BBD202Worker | None = None,
|
||||
oscope_worker: "OscopeWorker | None" = None,
|
||||
parent: QWidget | None = None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
uic.loadUi(ROOT / "sc3-aui-camera.ui", self)
|
||||
self.setWindowTitle("uC480 Camera")
|
||||
@@ -430,6 +543,14 @@ class CameraWindow(QWidget):
|
||||
self._camera: UC480Camera | None = None
|
||||
self._stream: CameraStreamThread | None = None
|
||||
|
||||
self._t3r_driver = t3r_driver
|
||||
self._bbd_worker = bbd_worker
|
||||
self._oscope_worker = oscope_worker
|
||||
self._align_thread: QThread | None = None
|
||||
self._align_worker: QtAutoAligner | None = None
|
||||
self._align_window: "AutoAlignWindow | None" = None
|
||||
self._align_available = True
|
||||
|
||||
# Banner warning when the camera shares a USB controller with serial
|
||||
# adapters — opening any of those ports (T3R, BBD202, …) collapses
|
||||
# the delivered frame rate to <2 fps (USB split-transaction
|
||||
@@ -460,14 +581,46 @@ class CameraWindow(QWidget):
|
||||
self.uc480_stop_btn.clicked.connect(self._stop_stream)
|
||||
self.uc480_close_window_btn.clicked.connect(self.close)
|
||||
|
||||
# Every one of the three is load-bearing: the T3R moves the tilt
|
||||
# platform, the BBD202 makes the 1.5 mm steps, and the scope is the
|
||||
# only thing that can say whether either helped.
|
||||
self.uc480_auto_align_btn.clicked.connect(self._on_auto_align)
|
||||
self.uc480_auto_align_btn.setVisible(
|
||||
None not in (t3r_driver, bbd_worker, oscope_worker))
|
||||
|
||||
self.t3r_jog_panel = self.bbd_jog_panel = None
|
||||
self._build_jog_column(t3r_driver, bbd_worker)
|
||||
|
||||
self._update_controls()
|
||||
|
||||
def _build_jog_column(self, t3r_driver, bbd_worker):
|
||||
"""Add the stage controls to the right of the image."""
|
||||
if t3r_driver is None and bbd_worker is None:
|
||||
return
|
||||
column = QVBoxLayout()
|
||||
column.setContentsMargins(0, 0, 0, 0)
|
||||
if t3r_driver is not None:
|
||||
self.t3r_jog_panel = T3RJogPanel(t3r_driver, self)
|
||||
column.addWidget(self.t3r_jog_panel)
|
||||
if bbd_worker is not None:
|
||||
self.bbd_jog_panel = BBDJogPanel(bbd_worker, self)
|
||||
column.addWidget(self.bbd_jog_panel)
|
||||
column.addStretch()
|
||||
self.horizontalLayout.addLayout(column)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._connect_camera()
|
||||
self._start_stream()
|
||||
|
||||
def closeEvent(self, event):
|
||||
# An alignment outlives this window otherwise, and it owns the stage.
|
||||
self._teardown_align()
|
||||
# A jog whose button-release lands after the window is gone would
|
||||
# otherwise leave an axis running.
|
||||
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
|
||||
if panel is not None:
|
||||
panel.stop_jogs()
|
||||
self._stop_stream()
|
||||
self._disconnect_camera()
|
||||
super().closeEvent(event)
|
||||
@@ -557,6 +710,298 @@ class CameraWindow(QWidget):
|
||||
self.uc480_exposure_slider.setEnabled(camera_ok)
|
||||
self.uc480_gain_slider.setEnabled(camera_ok)
|
||||
|
||||
# ── Auto-align ────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_auto_align(self):
|
||||
if self._align_thread is not None and self._align_thread.isRunning():
|
||||
if self._align_window is not None:
|
||||
self._align_window.raise_()
|
||||
self._align_window.activateWindow()
|
||||
return
|
||||
|
||||
problem = self._align_prerequisite_problem()
|
||||
if problem:
|
||||
QMessageBox.warning(self, "Cannot Auto-Align", problem)
|
||||
return
|
||||
|
||||
if QMessageBox.question(self, "Check the Oscilloscope",
|
||||
_align_scope_prompt()) \
|
||||
!= QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
self._align_thread = QThread(self)
|
||||
self._align_worker = QtAutoAligner(
|
||||
stage=self._bbd_worker.controller,
|
||||
scope=self._oscope_worker.scope,
|
||||
t3r=self._t3r_driver.driver,
|
||||
# Same reason the scan and the inspector do it: the procedure
|
||||
# drives the stage from its own thread, and the position poll
|
||||
# shares the BBD TX queue.
|
||||
on_align_active=lambda active: setattr(
|
||||
self._bbd_worker, "scanning_active", active),
|
||||
)
|
||||
self._align_worker.moveToThread(self._align_thread)
|
||||
|
||||
window = AutoAlignWindow(self)
|
||||
self._align_window = window
|
||||
worker = self._align_worker
|
||||
worker.status_msg.connect(window.on_status)
|
||||
worker.reading_taken.connect(window.on_reading)
|
||||
worker.offset_done.connect(window.on_offset_done)
|
||||
worker.axis_done.connect(window.on_axis_done)
|
||||
worker.prepared.connect(self._on_align_prepared)
|
||||
worker.prepare_failed.connect(self._on_align_prepare_failed)
|
||||
worker.finished.connect(self._on_align_finished)
|
||||
worker.failed.connect(self._on_align_failed)
|
||||
worker.aborted.connect(self._on_align_aborted)
|
||||
worker.stopped.connect(self._release_align_thread)
|
||||
worker.error_occurred.connect(window.on_failed)
|
||||
window.abort_requested.connect(worker.request_abort)
|
||||
window.close_requested.connect(self._teardown_align)
|
||||
|
||||
self._align_thread.started.connect(worker.run)
|
||||
self._set_align_ui_active(True)
|
||||
window.show()
|
||||
self._align_thread.start()
|
||||
worker.request_prepare()
|
||||
|
||||
def _align_prerequisite_problem(self) -> str:
|
||||
"""Why auto-align cannot start, or "" if it can."""
|
||||
if self._oscope_worker is None or not self._oscope_worker.is_connected:
|
||||
return ("The oscilloscope is not connected, and it is the only "
|
||||
"thing that can read the DC levels. Connect it from the "
|
||||
"main window and try again.")
|
||||
if self._bbd_worker is None or not self._bbd_worker.is_connected:
|
||||
return ("The BBD202 stage is not connected, so the 1.5 mm steps "
|
||||
"cannot be made. Connect it from the main window and try "
|
||||
"again.")
|
||||
if self._t3r_driver is None or not self._t3r_driver.is_open:
|
||||
return ("The T3R is not connected, so the T-axes cannot be moved. "
|
||||
"Connect it from the T3R panel and try again.")
|
||||
return ""
|
||||
|
||||
def _on_align_prepared(self, reading):
|
||||
"""The rig is configured and standing on the point — ask the operator."""
|
||||
if self._align_window is None:
|
||||
return
|
||||
confirmed = QMessageBox.question(
|
||||
self, "Is the Image Correct?", _align_reference_prompt(reading)
|
||||
) == QMessageBox.StandardButton.Yes
|
||||
|
||||
if not confirmed:
|
||||
self._align_window.on_status(
|
||||
"Cancelled: the image was not confirmed. Nothing has moved — "
|
||||
"re-align by hand and start again.")
|
||||
self._finish_align()
|
||||
return
|
||||
|
||||
self._align_window.set_reference(reading)
|
||||
self._align_worker.request_run()
|
||||
|
||||
def _on_align_prepare_failed(self, message: str):
|
||||
if self._align_window is not None:
|
||||
self._align_window.on_failed(message)
|
||||
QMessageBox.warning(self, "Cannot Auto-Align", message)
|
||||
self._finish_align()
|
||||
|
||||
def _on_align_finished(self, result):
|
||||
if self._align_window is not None:
|
||||
self._align_window.on_finished(result)
|
||||
self._finish_align()
|
||||
|
||||
def _on_align_failed(self, message: str):
|
||||
if self._align_window is not None:
|
||||
self._align_window.on_failed(message)
|
||||
QMessageBox.warning(self, "Auto-Align Failed", message)
|
||||
self._finish_align()
|
||||
|
||||
def _on_align_aborted(self):
|
||||
if self._align_window is not None:
|
||||
self._align_window.on_aborted()
|
||||
self._finish_align()
|
||||
|
||||
def _finish_align(self):
|
||||
"""Park the rig and release the thread, leaving the summary on screen."""
|
||||
if self._align_worker is not None:
|
||||
self._align_worker.request_stop()
|
||||
|
||||
def _release_align_thread(self):
|
||||
"""Worker idle — take its thread down. Safe to call more than once."""
|
||||
if self._align_worker is not None:
|
||||
self._align_worker.stop_worker()
|
||||
if self._align_thread is not None:
|
||||
self._align_thread.quit()
|
||||
self._align_thread.wait(30000)
|
||||
self._align_thread = None
|
||||
self._align_worker = None
|
||||
self._set_align_ui_active(False)
|
||||
|
||||
def _teardown_align(self):
|
||||
"""Stop an alignment and close its window — the window or camera is
|
||||
going away, so the procedure cannot be left running."""
|
||||
window, self._align_window = self._align_window, None
|
||||
if self._align_worker is not None:
|
||||
self._align_worker.request_stop()
|
||||
self._release_align_thread()
|
||||
if window is not None:
|
||||
window.close()
|
||||
|
||||
def set_align_available(self, available: bool):
|
||||
"""The main window handing the rig over, or taking it back.
|
||||
|
||||
A scan or an angle inspection owns the same stage, so auto-align has
|
||||
to be off the table while either runs.
|
||||
"""
|
||||
self._align_available = available
|
||||
self._refresh_align_button()
|
||||
|
||||
def _refresh_align_button(self):
|
||||
self.uc480_auto_align_btn.setEnabled(
|
||||
self._align_available and self._align_thread is None)
|
||||
|
||||
def _set_align_ui_active(self, active: bool):
|
||||
"""A running alignment owns the stage and the T-axes; nothing else
|
||||
may drive them, here or in the main window."""
|
||||
self._refresh_align_button()
|
||||
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
|
||||
if panel is not None:
|
||||
panel.stop_jogs()
|
||||
panel.setEnabled(not active)
|
||||
self.align_active.emit(active)
|
||||
|
||||
|
||||
class AutoAlignWindow(QWidget):
|
||||
"""Live progress for one auto-align run, and the summary it ends with.
|
||||
|
||||
Shows the deviation from the good values rather than the raw levels: the
|
||||
procedure is a null search, so how far off it is says more than what it
|
||||
reads, and 5 mV out of ~400 mV does not show up in the raw number.
|
||||
"""
|
||||
|
||||
abort_requested = pyqtSignal()
|
||||
close_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
self.setWindowTitle("Auto-Align")
|
||||
self.resize(560, 480)
|
||||
self._reference = None
|
||||
self._done = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.header_label = QLabel(
|
||||
f"Stepping {DEFAULT_ALIGN.offset_mm:.2f} mm either side of this "
|
||||
f"point on X (T1), then on Y (T0/T2 as a pair), re-tilting until "
|
||||
f"the DC levels come back to within "
|
||||
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV.\n"
|
||||
f"T-axes: {DEFAULT_T_AXIS.microsteps} µsteps, "
|
||||
f"{DEFAULT_T_AXIS.run_current_ma} mA.")
|
||||
self.header_label.setWordWrap(True)
|
||||
layout.addWidget(self.header_label)
|
||||
|
||||
self.reading_label = QLabel("—")
|
||||
self.reading_label.setFont(mono_font(13))
|
||||
self.reading_label.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(self.reading_label)
|
||||
|
||||
self.status_label = QLabel("Configuring the rig …")
|
||||
self.status_label.setWordWrap(True)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# The verdict outlives the status line, which keeps reporting the
|
||||
# parking moves after the answer is known.
|
||||
self.verdict_label = QLabel()
|
||||
self.verdict_label.setWordWrap(True)
|
||||
self.verdict_label.setVisible(False)
|
||||
layout.addWidget(self.verdict_label)
|
||||
|
||||
self.log = QPlainTextEdit(self)
|
||||
self.log.setReadOnly(True)
|
||||
self.log.setFont(mono_font(11))
|
||||
layout.addWidget(self.log, 1)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self.stop_btn = QPushButton("Stop")
|
||||
self.stop_btn.setToolTip(
|
||||
"Stop at the next move and put the stage back on the reference "
|
||||
"point. Any tilt already applied stays applied.")
|
||||
self.stop_btn.clicked.connect(self._on_stop_clicked)
|
||||
self.close_btn = QPushButton("Close")
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
self.close_btn.setEnabled(False)
|
||||
buttons.addWidget(self.stop_btn)
|
||||
buttons.addWidget(self.close_btn)
|
||||
layout.addLayout(buttons)
|
||||
|
||||
# ── Worker → window ───────────────────────────────────────────────────────
|
||||
|
||||
def set_reference(self, reading):
|
||||
self._reference = reading
|
||||
self._append(f"Reference: {reading.describe()}")
|
||||
|
||||
def on_status(self, msg: str):
|
||||
self.status_label.setText(msg)
|
||||
|
||||
def on_reading(self, reading):
|
||||
if self._reference is None:
|
||||
self.reading_label.setText(reading.describe())
|
||||
return
|
||||
d1, d2 = reading.error_vs(self._reference)
|
||||
self.reading_label.setText(
|
||||
f"DC1 {reading.dc1_mv:8.1f} mV ({d1:+6.1f}) "
|
||||
f"DC2 {reading.dc2_mv:8.1f} mV ({d2:+6.1f})")
|
||||
|
||||
def on_offset_done(self, result):
|
||||
self._append(f" {result.describe()}")
|
||||
|
||||
def on_axis_done(self, result):
|
||||
self._append(result.describe())
|
||||
|
||||
def on_finished(self, result):
|
||||
# The per-axis lines are already in the log, put there as they
|
||||
# happened; only the closing verdict is new.
|
||||
self._append("")
|
||||
self._append(result.verdict())
|
||||
self._set_verdict(result.verdict(), ok=result.ok)
|
||||
|
||||
def on_failed(self, message: str):
|
||||
self._append(f"FAILED: {message}")
|
||||
self._set_verdict(message, ok=False)
|
||||
|
||||
def on_aborted(self):
|
||||
message = ("Stopped by the operator. Any tilt already applied stays "
|
||||
"applied.")
|
||||
self._append(message)
|
||||
self._set_verdict(message, ok=False)
|
||||
|
||||
# ── Window → worker ───────────────────────────────────────────────────────
|
||||
|
||||
def _on_stop_clicked(self):
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.on_status("Stopping at the next move …")
|
||||
self.abort_requested.emit()
|
||||
|
||||
def _set_verdict(self, text: str, ok: bool):
|
||||
self.verdict_label.setText(text)
|
||||
self.verdict_label.setStyleSheet(
|
||||
f"font-weight: bold; color: {'green' if ok else '#b8860b'};")
|
||||
self.verdict_label.setVisible(True)
|
||||
self._done = True
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.close_btn.setEnabled(True)
|
||||
|
||||
def _append(self, line: str):
|
||||
self.log.appendPlainText(line)
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Closing part-way through is a stop: the procedure owns the stage,
|
||||
# and nothing else can call it off once this window is gone.
|
||||
if not self._done:
|
||||
self.abort_requested.emit()
|
||||
self.close_requested.emit()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
# ── Helios laser panel ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -573,6 +1018,7 @@ class HeliosWindow(QWidget):
|
||||
self._worker = worker
|
||||
# Polling is driven by the worker itself (self-rescheduling), so
|
||||
# there is no timer here to outpace the device.
|
||||
self._seed_current_spin = False
|
||||
self._wire_signals()
|
||||
self._update_controls(False)
|
||||
self._set_emission_indicator(False)
|
||||
@@ -594,6 +1040,9 @@ class HeliosWindow(QWidget):
|
||||
|
||||
self.helios_set_current_btn.clicked.connect(self._on_set_current)
|
||||
self._worker.current_updated.connect(self._on_current_updated)
|
||||
self._worker.current_unknown.connect(
|
||||
lambda: self.helios_current_readback_label.setText("laser: ? mA")
|
||||
)
|
||||
self._worker.diode_temp_updated.connect(
|
||||
lambda t: self.helios_temp_diode_label.setText(f"{t:.1f} °C")
|
||||
)
|
||||
@@ -620,6 +1069,8 @@ class HeliosWindow(QWidget):
|
||||
self._worker.queue_disconnect()
|
||||
|
||||
def _on_connected(self):
|
||||
# Take the setpoint from the laser once, on connect.
|
||||
self._seed_current_spin = True
|
||||
self.helios_connect_toggle.setEnabled(True)
|
||||
self.helios_connect_toggle.setText("Disconnect")
|
||||
self.helios_status_label.setText("Connected")
|
||||
@@ -668,6 +1119,17 @@ class HeliosWindow(QWidget):
|
||||
self._worker.queue_set_current(self.helios_current_spin.value())
|
||||
|
||||
def _on_current_updated(self, current_ma: int):
|
||||
"""Show what the laser reports; leave the spin box to the operator.
|
||||
|
||||
The 1 Hz poll used to write its reading straight into the spin box,
|
||||
so a number typed there was overwritten within a second — the
|
||||
setpoint appeared to snap back to the controller's value before it
|
||||
could be sent. The spin box is now seeded once per connection and
|
||||
is the operator's alone after that; the label is the read-back.
|
||||
"""
|
||||
self.helios_current_readback_label.setText(f"laser: {current_ma} mA")
|
||||
if self._seed_current_spin:
|
||||
self._seed_current_spin = False
|
||||
self.helios_current_spin.blockSignals(True)
|
||||
self.helios_current_spin.setValue(current_ma)
|
||||
self.helios_current_spin.blockSignals(False)
|
||||
@@ -692,6 +1154,8 @@ class HeliosWindow(QWidget):
|
||||
self.helios_enable_btn.setText("Enable Laser")
|
||||
self.helios_enable_btn.blockSignals(False)
|
||||
self._set_emission_indicator(False)
|
||||
self._seed_current_spin = False
|
||||
self.helios_current_readback_label.setText("laser: --- mA")
|
||||
self._reset_temperatures()
|
||||
self.helios_ler_label.setText("---")
|
||||
self.helios_lce_label.setText("---")
|
||||
@@ -972,11 +1436,17 @@ class MainWindow(QMainWindow):
|
||||
self._helios_thread.started.connect(self._helios_worker.run)
|
||||
|
||||
# ── Popup windows ─────────────────────────────────────────────────────
|
||||
self._camera_win = CameraWindow()
|
||||
self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker,
|
||||
self._oscope_worker)
|
||||
self._helios_win = HeliosWindow(self._helios_worker)
|
||||
self._scan_progress = ScanProgressWindow()
|
||||
|
||||
self._scan_worker: QtScanController | None = None
|
||||
# A SAW check runs through the same worker as a scan; this says which,
|
||||
# since the two finish very differently (a check hands the operator a
|
||||
# file to look at; a scan shuts the rig down).
|
||||
self._scan_is_saw_check = False
|
||||
self._saw_check_path: Path | None = None
|
||||
self._inspect_thread: QThread | None = None
|
||||
self._inspect_worker: QtAngleInspector | None = None
|
||||
self._inspect_window: AngleInspectWindow | None = None
|
||||
@@ -1009,7 +1479,7 @@ class MainWindow(QMainWindow):
|
||||
self.bbd202_comport_edit.setText(DEFAULTS.bbd_port)
|
||||
self.oscope_ip_edit.setText(DEFAULTS.oscope_ip)
|
||||
self._helios_win.helios_port_edit.setText(DEFAULTS.helios_port)
|
||||
self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM))
|
||||
self.lineEdit_10.setText(str(BBD_JOG_STEP_MM))
|
||||
|
||||
self.x_start_edit.setText("10.000")
|
||||
self.y_start_edit.setText("10.000")
|
||||
@@ -1089,6 +1559,7 @@ class MainWindow(QMainWindow):
|
||||
self._camera_win.closed.connect(
|
||||
lambda: self._set_toggle(self.show_camera_toggle, False, "Show Camera Window")
|
||||
)
|
||||
self._camera_win.align_active.connect(self._on_camera_align_active)
|
||||
|
||||
# Helios
|
||||
self.show_helios_toggle.toggled.connect(self._on_helios_toggle)
|
||||
@@ -1101,6 +1572,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Scan
|
||||
self.start_scan_btn.clicked.connect(self._on_start_scan)
|
||||
self.saw_check_btn.clicked.connect(self._on_saw_check)
|
||||
self.inspect_angles_btn.clicked.connect(self._on_inspect_angles)
|
||||
self.save_dir_browse_btn.clicked.connect(self._on_browse_save_dir)
|
||||
self._scan_progress.abort_requested.connect(self._on_abort_scan)
|
||||
@@ -1174,10 +1646,12 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
axis, direction = self._bbd_active_jog
|
||||
try:
|
||||
self._bbd_worker._jog_step = float(self.lineEdit_10.text())
|
||||
step = float(self.lineEdit_10.text())
|
||||
except ValueError:
|
||||
self._bbd_worker._jog_step = BBD_DEFAULT_JOG_MM
|
||||
self._bbd_worker.queue_jog(axis, direction)
|
||||
step = BBD_JOG_STEP_MM
|
||||
# The step rides along with the command: writing it onto the worker
|
||||
# from here would be a cross-thread poke at a field the worker reads.
|
||||
self._bbd_worker.queue_jog(axis, direction, step_mm=step)
|
||||
|
||||
def _stop_bbd_jog(self):
|
||||
self._bbd_jog_timer.stop()
|
||||
@@ -1267,6 +1741,18 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# ── Scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _set_scan_buttons_enabled(self, enabled: bool):
|
||||
"""Both entry points drive the same rig, so they lock and unlock together."""
|
||||
self.start_scan_btn.setEnabled(enabled)
|
||||
self.saw_check_btn.setEnabled(enabled)
|
||||
# Auto-align lives in the camera window but drives this same stage.
|
||||
self._camera_win.set_align_available(enabled)
|
||||
|
||||
def _on_camera_align_active(self, active: bool):
|
||||
"""An alignment started or finished in the camera window."""
|
||||
self._set_scan_buttons_enabled(not active)
|
||||
self.inspect_angles_btn.setEnabled(not active)
|
||||
|
||||
def _on_browse_save_dir(self):
|
||||
d = QFileDialog.getExistingDirectory(
|
||||
self, "Select Scan Save Directory", self.scan_save_dir_edit.text()
|
||||
@@ -1275,6 +1761,84 @@ class MainWindow(QMainWindow):
|
||||
self.scan_save_dir_edit.setText(d)
|
||||
self._persist_defaults()
|
||||
|
||||
def _on_saw_check(self):
|
||||
"""Acquire the middle row of the current ROI at every angle.
|
||||
|
||||
Same engine, same hardware sequence, same file format as a scan — the
|
||||
plan is just reduced to one row per angle and the result is tagged v10
|
||||
so the viewer knows it is a check rather than a scan cut short.
|
||||
"""
|
||||
if self._scan_thread is not None and self._scan_thread.isRunning():
|
||||
QMessageBox.warning(
|
||||
self, "Scan In Progress",
|
||||
"A scan is running — abort it before starting a SAW check."
|
||||
)
|
||||
return
|
||||
try:
|
||||
plan, prefix, save_dir = self._build_scan_plan()
|
||||
check_plan = middle_row_plan(plan) # ScanGeometryError is a ValueError
|
||||
except ValueError as e:
|
||||
QMessageBox.warning(self, "Invalid Scan Parameters", str(e))
|
||||
return
|
||||
|
||||
check_prefix = f"{prefix}{SAW_CHECK_SUFFIX}"
|
||||
out_path = Path(save_dir) / f"{check_prefix}.sras"
|
||||
rows = ", ".join(f"{pa.angle_deg:.1f}°: Y={pa.y_positions[0]:.3f} mm"
|
||||
for pa in check_plan.per_angle)
|
||||
overwrite = ("\n\nThis will overwrite the existing file."
|
||||
if out_path.exists() else "")
|
||||
reply = QMessageBox.question(
|
||||
self, "SAW Quality Check",
|
||||
f"Acquire the middle row of the ROI at {check_plan.n_angles} angle(s)?\n\n"
|
||||
f"{rows}\n\n"
|
||||
"Each angle starts with its own background capture, so you will be "
|
||||
"asked to switch the Genesis laser off and back on at every angle."
|
||||
f"\n\nSave → {out_path.name}{overwrite}",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
self._saw_check_path = out_path
|
||||
# Burst mode is deliberately not offered here: one row per angle means
|
||||
# every burst would be a single row, so it buys nothing and still pays
|
||||
# for the gate preflight.
|
||||
self._launch_scan_worker(check_plan, check_prefix, save_dir, saw_check=True)
|
||||
|
||||
def _on_saw_check_complete(self):
|
||||
"""A check is a thing to look at, not a run to shut down after."""
|
||||
path = self._saw_check_path
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Icon.Information)
|
||||
box.setWindowTitle("SAW Check Complete")
|
||||
box.setText(
|
||||
f"Middle-row SAW check written to:\n{path}\n\n"
|
||||
"Open it in the SAW Check Viewer to compare each angle's "
|
||||
"frequency and judge the alignment."
|
||||
)
|
||||
open_btn = box.addButton("Open Viewer", QMessageBox.ButtonRole.AcceptRole)
|
||||
box.addButton(QMessageBox.StandardButton.Close)
|
||||
box.exec()
|
||||
if box.clickedButton() is open_btn:
|
||||
self._launch_saw_check_viewer(path)
|
||||
|
||||
def _launch_saw_check_viewer(self, path: Path):
|
||||
"""Open the viewer as its own process.
|
||||
|
||||
Deliberately not in-process: the acquisition app owns the hardware and
|
||||
must stay responsive, and the viewer is a separate entry point that
|
||||
outlives any one scan session.
|
||||
"""
|
||||
try:
|
||||
subprocess.Popen([sys.executable,
|
||||
str(ROOT / "saw_check_viewer.py"), str(path)])
|
||||
except OSError as e:
|
||||
QMessageBox.warning(
|
||||
self, "Could Not Open Viewer",
|
||||
f"Could not start the SAW Check Viewer:\n\n{e}\n\n"
|
||||
f"Run it manually: python saw_check_viewer.py {path}"
|
||||
)
|
||||
|
||||
def _on_inspect_angles(self):
|
||||
"""Open the pre-scan angle inspector for the plan currently entered."""
|
||||
if self._scan_thread is not None and self._scan_thread.isRunning():
|
||||
@@ -1323,7 +1887,7 @@ class MainWindow(QMainWindow):
|
||||
lambda m: QMessageBox.warning(self, "Inspection Error", m))
|
||||
|
||||
self._inspect_thread.started.connect(self._inspect_worker.run)
|
||||
self.start_scan_btn.setEnabled(False)
|
||||
self._set_scan_buttons_enabled(False)
|
||||
self.inspect_angles_btn.setEnabled(False)
|
||||
window.show()
|
||||
self._inspect_thread.start()
|
||||
@@ -1345,7 +1909,7 @@ class MainWindow(QMainWindow):
|
||||
self._inspect_thread = None
|
||||
self._inspect_worker = None
|
||||
self._inspect_window = None
|
||||
self.start_scan_btn.setEnabled(True)
|
||||
self._set_scan_buttons_enabled(True)
|
||||
self.inspect_angles_btn.setEnabled(True)
|
||||
|
||||
def _on_start_scan(self):
|
||||
@@ -1379,6 +1943,18 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.warning(self, "Cannot Resume Scan", f"Could not read scan file:\n\n{e}")
|
||||
return
|
||||
|
||||
if sras.version not in WRITABLE_VERSIONS:
|
||||
QMessageBox.warning(
|
||||
self, "Cannot Resume Scan",
|
||||
f"{path.name} is a v{sras.version} file, written before each "
|
||||
"angle carried its own background waveform. Every angle this "
|
||||
"app acquires now writes a background block that the older "
|
||||
"layout has no room for, so resuming would shift the file's "
|
||||
"data. Start a new scan instead — the old file still opens in "
|
||||
"the viewer."
|
||||
)
|
||||
return
|
||||
|
||||
if not is_compatible(sras, velocity=SCAN_VELOCITY_MM_S,
|
||||
laser_freq=LASER_FREQ_HZ, sample_rate=SAMPLE_RATE_HZ,
|
||||
n_channels=len(SCAN_CHANNELS)):
|
||||
@@ -1424,8 +2000,10 @@ class MainWindow(QMainWindow):
|
||||
str(path.parent), resume_plan.to_state(sras))
|
||||
|
||||
def _launch_scan_worker(self, plan: ScanPlan, prefix: str, save_dir: str,
|
||||
resume: ResumeState | None = None):
|
||||
resume: ResumeState | None = None,
|
||||
saw_check: bool = False):
|
||||
rotator = RotationAxis(self._t3r_driver.driver, DEFAULT_ROTATION)
|
||||
self._scan_is_saw_check = saw_check
|
||||
|
||||
self._scan_thread = QThread(self)
|
||||
self._scan_worker = QtScanController(
|
||||
@@ -1439,8 +2017,9 @@ class MainWindow(QMainWindow):
|
||||
# a worker concern, not the engine's.
|
||||
on_scan_active=lambda active: setattr(
|
||||
self._bbd_worker, "scanning_active", active),
|
||||
burst_mode=self.burst_mode_check.isChecked(),
|
||||
burst_mode=self.burst_mode_check.isChecked() and not saw_check,
|
||||
strict_rows=self.strict_rows_check.isChecked(),
|
||||
file_version=VERSION_SAW_CHECK if saw_check else VERSION,
|
||||
)
|
||||
self._scan_worker.moveToThread(self._scan_thread)
|
||||
self._scan_thread.started.connect(self._scan_worker.run)
|
||||
@@ -1453,7 +2032,7 @@ class MainWindow(QMainWindow):
|
||||
self._scan_worker.user_prompt.connect(self._on_scan_user_prompt)
|
||||
self._scan_worker.paused_changed.connect(self._scan_progress.on_worker_paused)
|
||||
|
||||
self.start_scan_btn.setEnabled(False)
|
||||
self._set_scan_buttons_enabled(False)
|
||||
self._scan_progress.reset_pause_btn()
|
||||
ai0 = 0 if resume is None else resume.targets[0].angle_idx
|
||||
self._scan_progress.update_progress(
|
||||
@@ -1494,7 +2073,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _on_scan_complete(self):
|
||||
self._scan_progress.close()
|
||||
self.start_scan_btn.setEnabled(True)
|
||||
self._set_scan_buttons_enabled(True)
|
||||
if self._scan_is_saw_check:
|
||||
self._scan_is_saw_check = False
|
||||
self._on_saw_check_complete()
|
||||
return
|
||||
QMessageBox.information(
|
||||
self, "Scan Complete",
|
||||
"All rows and angles have been acquired.\n\n"
|
||||
@@ -1504,7 +2087,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _on_scan_failed(self, msg: str):
|
||||
self._scan_progress.close()
|
||||
self.start_scan_btn.setEnabled(True)
|
||||
self._set_scan_buttons_enabled(True)
|
||||
self._scan_is_saw_check = False
|
||||
if "aborted" in msg.lower():
|
||||
QMessageBox.warning(self, "Scan Aborted", msg)
|
||||
else:
|
||||
|
||||
+136
-30
@@ -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
|
||||
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
|
||||
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
|
||||
@@ -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)]
|
||||
[Row Table (ragged) — sum(n_rows) × 4 bytes (float32 per row, angle-major)]
|
||||
[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]
|
||||
[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
|
||||
(`>` 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 |
|
||||
|--------|------|-----------|--------------------|--------------------------------------------------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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
|
||||
**Helios (generation) laser enabled** and the **Genesis (detection) laser
|
||||
disabled**. This provides a noise/background reference for subtraction during
|
||||
post-processing.
|
||||
Immediately after the preamble blocks, and running to the end of the file:
|
||||
for each angle in angle-table order, that angle's **background block**
|
||||
followed by that angle's **waveform data**.
|
||||
|
||||
```
|
||||
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
|
||||
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,
|
||||
row-minor** order, but unlike earlier versions each angle contributes a
|
||||
Stored in **angle-major, row-minor** order, and each angle contributes a
|
||||
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
|
||||
entry. Within each row, channels are interleaved in ascending channel-index
|
||||
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
|
||||
**int8** (−128 … +127). With `bytes_per_sample = 2` this is **big-endian
|
||||
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
|
||||
> the data block will be shorter than the expected size. Readers should
|
||||
> reconstruct the expected per-angle byte offsets from the Per-Angle Geometry
|
||||
> Table and check `file_size` against the running total before reshaping —
|
||||
> a fixed `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not
|
||||
> work since row/frame counts are no longer uniform across angles.
|
||||
> the data block will be shorter than the expected size. Readers should walk
|
||||
> the data block from its start — background length prefix, then that angle's
|
||||
> declared row bytes from the Per-Angle Geometry Table — checking `file_size`
|
||||
> against the running total before reshaping. A fixed
|
||||
> `(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.
|
||||
|
||||
---
|
||||
|
||||
@@ -187,8 +231,9 @@ using that angle's `x_start` from the Per-Angle Geometry Table (not
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------------------|------------------------------------------|
|
||||
| Setup trigger | CH2, rising edge, 0.500 V (`TRIG_LEVEL_V`) |
|
||||
| Background trigger | CH2, rising edge, 0.500 V (`TRIG_LEVEL_V`), FastFrame off |
|
||||
| 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) |
|
||||
| Transfer format | `DATa:ENCdg RIBinary`, `DATa:WIDth 1` |
|
||||
@@ -216,6 +261,10 @@ is not recorded in the file.
|
||||
| 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
|
||||
@@ -242,6 +291,60 @@ 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 | Change |
|
||||
@@ -252,4 +355,7 @@ the file always ends on a whole-row boundary.
|
||||
| 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) |
|
||||
| 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
|
||||
"""
|
||||
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
|
||||
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
|
||||
and lets you export a subset to a new .sras file, or delete a subset from
|
||||
the file in place — both operations rewrite the angle/geometry/row tables
|
||||
and stream-copy only the selected angles' waveform data, producing a file
|
||||
that is itself a valid v6 .sras readable by sras_viewer.py-style tools
|
||||
(once updated for v6) or sc3_aui_app.py.
|
||||
n_rows), background waveform, and waveform data block. This tool lists those
|
||||
per-angle sub-scans and lets you export a subset to a new .sras file, or
|
||||
delete a subset from the file in place — both operations rewrite the
|
||||
angle/geometry/row tables, carry each kept angle's background across, and
|
||||
stream-copy only the selected angles' waveform data, producing a file that is
|
||||
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
|
||||
@@ -24,7 +28,9 @@ from pathlib import Path
|
||||
|
||||
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
|
||||
@@ -37,7 +43,8 @@ class AngleEntry:
|
||||
n_rows_declared: int
|
||||
y_positions: list # declared length; may exceed what's actually on disk
|
||||
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
|
||||
data_size_available: int = 0
|
||||
complete: bool = True
|
||||
@@ -48,7 +55,7 @@ class AngleEntry:
|
||||
|
||||
|
||||
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):
|
||||
self.path = Path(path)
|
||||
@@ -57,6 +64,8 @@ class SrasScanFile:
|
||||
def _parse(self):
|
||||
sras = SrasFile(self.path)
|
||||
h = sras.header
|
||||
self.version = sras.version
|
||||
self.is_saw_check = sras.is_saw_check
|
||||
self.x_start_nominal = h.x_start_nominal
|
||||
self.y_start_nominal = h.y_start_nominal
|
||||
self.x_delta_nominal = h.x_delta_nominal
|
||||
@@ -69,7 +78,10 @@ class SrasScanFile:
|
||||
self.bytes_per_sample = h.bytes_per_sample
|
||||
self.n_channels = h.n_channels
|
||||
self.preambles_raw = sras.preambles_raw
|
||||
self.background_raw = sras.background
|
||||
self.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.file_size = sras.file_size
|
||||
|
||||
@@ -79,12 +91,13 @@ class SrasScanFile:
|
||||
x_start=pa.x_start, x_delta=pa.x_delta,
|
||||
n_frames=pa.n_frames, n_rows_declared=pa.n_rows,
|
||||
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,
|
||||
data_size_available=st.n_rows_available * st.row_bytes,
|
||||
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:
|
||||
@@ -96,7 +109,7 @@ class SrasScanFile:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
angles that were truncated on disk and thus exported with fewer rows
|
||||
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]
|
||||
|
||||
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_delta_nominal, sf.y_delta_nominal,
|
||||
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(praw)
|
||||
|
||||
dst.write(struct.pack(">I", len(sf.background_raw)))
|
||||
dst.write(sf.background_raw)
|
||||
if sf.legacy_layout:
|
||||
dst.write(struct.pack(BG_LEN_FMT, len(sf.shared_background)))
|
||||
dst.write(sf.shared_background)
|
||||
|
||||
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)
|
||||
remaining = e.data_size_available
|
||||
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):
|
||||
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} "
|
||||
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")
|
||||
@@ -342,7 +364,7 @@ def interactive_loop(path: Path):
|
||||
|
||||
def main():
|
||||
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("--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'")
|
||||
|
||||
+24
-12
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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):
|
||||
CH1 — RF Acoustic Packet: FFT → peak frequency
|
||||
@@ -76,11 +76,18 @@ class LoadedScan:
|
||||
def __init__(self, path: str):
|
||||
self.sras = SrasFile(Path(path))
|
||||
self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
|
||||
bg = np.frombuffer(self.sras.background, dtype=np.int8)
|
||||
self.background = bg.astype(np.float32) if len(bg) else None
|
||||
# One background per angle (v7/v11), captured just before that angle
|
||||
# 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)
|
||||
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:
|
||||
"""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])
|
||||
@@ -123,7 +130,7 @@ def compute_image(scan: LoadedScan, angle_idx: int, ch_idx: int,
|
||||
if view.shape[0] == 0:
|
||||
return np.zeros((0, 0), dtype=np.float32)
|
||||
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):
|
||||
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]
|
||||
.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
|
||||
|
||||
self.ax_wave.cla()
|
||||
@@ -696,7 +703,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
self.chk_bg_sub.setChecked(True)
|
||||
self.chk_bg_sub.setEnabled(False)
|
||||
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."
|
||||
)
|
||||
self.chk_bg_sub.toggled.connect(self._on_bg_sub_toggled)
|
||||
@@ -1043,7 +1050,7 @@ class SrasViewerWindow(QMainWindow):
|
||||
self._on_view_changed()
|
||||
|
||||
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
|
||||
if scan is None:
|
||||
return
|
||||
@@ -1060,8 +1067,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
if avail < pa.n_rows:
|
||||
notes.append(f"! Angle {ai}: only {avail}/{pa.n_rows} rows on disk "
|
||||
"(scan was aborted or is still running)")
|
||||
if scan.background is not None:
|
||||
notes.append(f"Background waveform: {len(scan.background)} samples")
|
||||
bg = scan.background(ai)
|
||||
if bg is not None:
|
||||
notes.append(f"Background waveform (angle {ai}): {len(bg)} samples")
|
||||
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_fft = enabled and ch_idx in (CH1_IDX, VELOCITY_MODE_IDX)
|
||||
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)
|
||||
# Time gate only for FFT modes (the SAW pipeline has its own gating)
|
||||
self.chk_gate.setEnabled(is_fft)
|
||||
@@ -1197,6 +1205,9 @@ class SrasViewerWindow(QMainWindow):
|
||||
idx = self.spin_angle.value()
|
||||
self.lbl_angle_deg.setText(f"({self._scan.sras.per_angle[idx].angle_deg:.1f}°)")
|
||||
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()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1382,7 +1393,8 @@ class SrasViewerWindow(QMainWindow):
|
||||
angle_idx = self.spin_angle.value()
|
||||
n_shots_req = self.spin_saw_n_shots.value()
|
||||
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:
|
||||
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)
|
||||
shots = view[rows, CH1_IDX, frames].astype(np.float32)
|
||||
if apply_bg:
|
||||
shots -= scan.background
|
||||
shots -= background
|
||||
pipeline.build_template(shots)
|
||||
return None
|
||||
|
||||
|
||||
+121
-1
@@ -99,6 +99,16 @@ class FakeStage:
|
||||
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:
|
||||
"""Stands in for TektronixOscilloscopeBase.
|
||||
|
||||
@@ -113,6 +123,7 @@ class FakeScope:
|
||||
self._acq_polls = 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.
|
||||
@@ -194,7 +205,9 @@ class FakeScope:
|
||||
|
||||
def transfer_curve(self):
|
||||
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 _frames(self, ch, count):
|
||||
spf = self.samples_per_frame
|
||||
@@ -245,6 +258,9 @@ class FakeT3R:
|
||||
self._t = trace
|
||||
self.is_open = is_open
|
||||
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):
|
||||
self._t.record("t3r_set_microstep", ch, micro)
|
||||
@@ -260,9 +276,113 @@ class FakeT3R:
|
||||
return round(self.MOTOR_FULL_STEPS_PER_REV * microsteps * ratio
|
||||
* 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):
|
||||
self._t.record("t3r_rotate", round(angle_deg, 6))
|
||||
|
||||
def wait_motion_done(self, ch, timeout):
|
||||
self._t.record("t3r_wait_motion_done", ch)
|
||||
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,
|
||||
"n_rows": 3,
|
||||
"row_bytes": 96,
|
||||
"data_offset": 265,
|
||||
"data_offset": 553,
|
||||
"n_rows_available": 0,
|
||||
"status": "MISSING"
|
||||
}
|
||||
@@ -161,7 +161,7 @@
|
||||
"angle_deg": -180.0,
|
||||
"n_rows": 3,
|
||||
"row_bytes": 96,
|
||||
"data_offset": 265,
|
||||
"data_offset": 553,
|
||||
"n_rows_available": 0,
|
||||
"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
|
||||
generated against the pre-refactor code (commit d185676); they must never
|
||||
change, or the byte-identical comparisons stop meaning anything.
|
||||
The constants mirror the values tests/gen_goldens.py used when the committed
|
||||
golden files were generated against the pre-refactor code (commit d185676);
|
||||
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
|
||||
SAMPLE_RATE = 6.25e9
|
||||
CHANNELS = [1, 3, 4]
|
||||
@@ -19,3 +28,31 @@ VELOCITY_MM_S = 100.0
|
||||
|
||||
def synthetic_frame(ai, ri, ci, fi):
|
||||
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
|
||||
|
||||
|
||||
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,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"
|
||||
|
||||
|
||||
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):
|
||||
w = _Poller()
|
||||
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
|
||||
@@ -14,8 +14,8 @@ from core.scan_engine import (
|
||||
ResumeTarget,
|
||||
)
|
||||
from core.scan_geometry import ScanGeometryError, build_plan
|
||||
from core.sras_format import SCAN_CHANNELS, SrasFile
|
||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
||||
from core.sras_format import BG_LEN_SIZE, SCAN_CHANNELS, SrasFile
|
||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace, background_record
|
||||
|
||||
SPF = 8
|
||||
|
||||
@@ -57,7 +57,41 @@ def test_single_angle_scan_writes_readable_file(tmp_path):
|
||||
# File is complete: every declared row present on disk
|
||||
assert [s.status for s in sras.angle_status()] == ["OK"]
|
||||
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):
|
||||
@@ -236,8 +270,9 @@ def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
|
||||
target = statuses[1]
|
||||
resume = ResumeState(
|
||||
path=path,
|
||||
targets=[ResumeTarget(target.index, target.data_offset,
|
||||
target.n_rows, target.angle_deg)],
|
||||
targets=[ResumeTarget(target.index, target.bg_offset,
|
||||
target.data_offset, target.n_rows,
|
||||
target.angle_deg)],
|
||||
samples_per_frame=SPF,
|
||||
)
|
||||
|
||||
@@ -249,19 +284,51 @@ def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
|
||||
assert result.rows_written == plan.per_angle[1].n_rows
|
||||
rewritten = path.read_bytes()
|
||||
assert len(rewritten) == len(original)
|
||||
# Angle 0's block is untouched; angle 1's changed (fresh frame data)
|
||||
a1_start, a1_end = target.data_offset, target.data_offset + target.row_bytes * target.n_rows
|
||||
# Angle 0's block is untouched; angle 1's changed — background included,
|
||||
# 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:a1_end] != original[a1_start: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):
|
||||
engine, trace, plan = build(tmp_path)
|
||||
engine.run()
|
||||
path = tmp_path / "out.sras"
|
||||
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
|
||||
engine2, _, _ = build(tmp_path, resume=resume)
|
||||
with pytest.raises(RuntimeError, match="record length"):
|
||||
@@ -461,7 +528,7 @@ def test_strict_row_packing_writes_nothing_for_the_failed_row(tmp_path):
|
||||
# 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.data_start_offset
|
||||
written = (tmp_path / "out.sras").stat().st_size - sras.angle_data_offset(0)
|
||||
assert written == sras.row_bytes(0)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
from core.scan_resume import is_compatible, plan_resume
|
||||
from core.sras_format import SrasFile
|
||||
from golden_util import write_v7
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def test_targets_carry_offsets_and_rows():
|
||||
st = _statuses("complete.sras")
|
||||
def test_targets_carry_offsets_and_rows(tmp_path):
|
||||
out = tmp_path / "v7.sras"
|
||||
write_v7(out)
|
||||
st = SrasFile(out).angle_status()
|
||||
plan = plan_resume(st, selected={0, 1})
|
||||
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.bg_offset < target.data_offset
|
||||
assert target.n_rows == status.n_rows
|
||||
assert target.angle_deg == status.angle_deg
|
||||
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}
|
||||
|
||||
|
||||
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")
|
||||
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,
|
||||
sample_rate=h.sample_rate, n_channels=h.n_channels)
|
||||
assert is_compatible(sras, **ok)
|
||||
|
||||
@@ -38,6 +38,51 @@ def test_sc3_aui_main_window(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):
|
||||
import sras_viewer
|
||||
win = sras_viewer.SrasViewerWindow()
|
||||
@@ -49,6 +94,17 @@ def test_sras_viewer_window(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):
|
||||
import helios_test_app
|
||||
win = helios_test_app.HeliosTestApp()
|
||||
|
||||
@@ -22,7 +22,9 @@ def _loaded_scan(name="complete.sras"):
|
||||
def test_loaded_scan_basics():
|
||||
scan = _loaded_scan()
|
||||
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
|
||||
view = scan.angle_view(0)
|
||||
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
|
||||
extracted module must reproduce them byte-for-byte (writer) and
|
||||
field-for-field (parser + frontier walk).
|
||||
The goldens are legacy v6 files produced by the original sc3_aui_app
|
||||
implementation — one background for the whole file. Nothing writes that
|
||||
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 struct
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.scan_geometry import build_plan
|
||||
from core.sras_format import SrasFile, create_scan_file
|
||||
from core.sras_format import (
|
||||
BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, VERSION, VERSION_SAW_CHECK,
|
||||
SrasFile, create_scan_file,
|
||||
)
|
||||
from golden_util import (
|
||||
BACKGROUND, CHANNELS, LASER_FREQ_HZ, PREAMBLES, SAMPLE_RATE, SPF,
|
||||
TINY_PLAN_ARGS, VELOCITY_MM_S, synthetic_frame,
|
||||
BACKGROUND, CHANNELS, PREAMBLES, SAMPLE_RATE, SPF, angle_background,
|
||||
synthetic_frame, tiny_plan, write_v7,
|
||||
)
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
@@ -27,28 +33,43 @@ def expected():
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _tiny_plan():
|
||||
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ,
|
||||
velocity_mm_s=VELOCITY_MM_S)
|
||||
|
||||
|
||||
def _write_complete(path):
|
||||
plan = _tiny_plan()
|
||||
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND)
|
||||
try:
|
||||
def spec_bytes(plan):
|
||||
"""The v7 layout spelled out from scan_format.md, writer not involved."""
|
||||
buf = bytearray()
|
||||
buf += struct.pack(HDR_FMT, MAGIC, VERSION, plan.n_angles,
|
||||
plan.x_start_nominal, plan.y_start_nominal,
|
||||
plan.x_delta_nominal, plan.y_delta_nominal,
|
||||
plan.row_spacing, plan.velocity_mm_s, plan.laser_freq_hz,
|
||||
SPF, SAMPLE_RATE, 1, len(CHANNELS))
|
||||
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):
|
||||
bg = angle_background(ai)
|
||||
buf += struct.pack(BG_LEN_FMT, len(bg)) + bg
|
||||
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()
|
||||
buf += synthetic_frame(ai, ri, ci, fi)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def test_writer_byte_identical_to_golden(tmp_path):
|
||||
out = tmp_path / "rewrite.sras"
|
||||
_write_complete(out)
|
||||
assert out.read_bytes() == (GOLDEN / "complete.sras").read_bytes()
|
||||
def test_writer_matches_the_spec_byte_for_byte(tmp_path):
|
||||
out = tmp_path / "v7.sras"
|
||||
plan = write_v7(out)
|
||||
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):
|
||||
@@ -75,15 +96,107 @@ def test_header_matches_golden(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():
|
||||
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")
|
||||
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():
|
||||
|
||||
@@ -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