15 Commits

Author SHA1 Message Date
Thomas Ales dff7048429 Merge fix-scope-channel-coupling: the scope could not be connected to
set_channel_coupling() looped over CHANNEL_COUPLING.items(), so it called
.upper() on the list half of each pair and raised "'list' object has no
attribute 'upper'" before looking at its argument.  configure_channels()
sets the coupling on every channel as part of connecting, so no IP would
work.  Fixed to .values(), with a test that drives the real instrument
class rather than the FakeScope that stubbed the setters out.
2026-09-08 09:45:33 -05:00
Thomas Ales 43337bfa67 Scope: set_channel_coupling() iterated items() and raised on every call
Connecting to the oscilloscope failed with "'list' object has no attribute
'upper'" whatever the IP was.  configure_channels() runs on connect and
sets the coupling on all four channels; set_channel_coupling() looped over
CHANNEL_COUPLING.items(), so `variants` was the whole ('AC', ['AC']) pair
and the comprehension called .upper() on the list half.  It raised before
looking at the value it was given, so no coupling could ever be accepted.

The tuple unpacking was dropped from `for long_form, variants in ...items()`
in 709dc529 without changing .items() to .values(); the four sibling
validators (acquire mode, trigger slope, trigger mode, data encoding) all
use .values().

The suite missed it because tests/fakes.py FakeScope replaces these setters
with recording stubs, so nothing exercised the real class.  The new test
drives TektronixOscilloscopeBase itself with only the socket replaced,
covering the connect-time path and the other validated setters alongside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:17:23 -05:00
Thomas Ales 92bbf02c8e Merge dev-helios-current-setpoint: the panel's diode current was a register
The current field read 32 mA and would not move.  32 was LCE — bit 5, door
switch open — landing in the diode current field: replies are CRLF framed
and padded with blank lines, the reader stopped at CR, and the LF left
behind cost a full port timeout on the next read.  A query that spends its
deadline blocked gives up while its own reply is still on the wire, the
next flush cuts a line in half, and "     32" is what the fragment reads
as.  The laser itself was holding 100 mA and took a write of 900 mA first
time.

Lines are framed on CR, LF or CRLF now, out of a buffer that is cleared
along with the port; only CSR and HSR may answer without naming
themselves; writes are read back and retried, as the manual asks; and the
spin box belongs to the operator, with the laser's own value beside it.
A status sweep went from 8.6 s to 356 ms on the way through.
2026-09-08 08:59:13 -05:00
Thomas Ales b473aac6aa Helios panel: refresh about every 0.65 s, and yield the port to the operator
Three things were making the panel feel slow, all of them waiting rather
than working.

The trailing quiet window is waited out once per query, so it set the pace
of the whole sweep: at 50 ms that was 400 ms of a 590 ms poll spent
listening to silence. A reply streams at the baud rate — ~1 ms between
bytes, no measurable gap between its lines — and the deadline restarts on
every line read, so 20 ms outlasts the gap it exists for twenty times over.
A tail that still arrives late is caught by _discard_input(), which is what
actually protects the next query. Replayed against the rig transcript, a
sweep goes from 590 ms to 356 ms.

The poll interval was 1 s on top of that, so a value could be 1.6 s stale.
At 0.3 s the panel comes round about every 0.65 s.

And a poll held the port for its whole sweep, so a button pressed during
one waited for all eight queries. _work_pending() on the worker base lets a
poll drop what is left as soon as the operator queues something: a click
now waits ~135 ms for the register read in progress instead of the full
sweep, and the rest is picked up next time round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:36:57 -05:00
Thomas Ales 76ed7828cc Helios: read CRLF replies as lines, not as CR plus dead air
The rig transcript (tools/helios_lds_probe.py) settles where the panel's
32 mA came from, and it was never the laser: LDS reads 100 mA, answers for
itself, and takes a write of 900 mA on the first attempt. 32 is LCE — bit
5, "Door switch open" — arriving in the diode-current field.

Every reply is CRLF-terminated and padded with a blank line or two:

    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'

_read_line() read up to CR, so the final LF of every reply stayed in the
buffer, and the next read waited out the whole port timeout for a CR that
only the next command would bring. A second of dead air per query: replayed
against the transcript's byte timing, one status poll took 8.6 s against
the 1 s interval that schedules it. That is also what let the values drift
apart — a query whose deadline goes to a blocked read gives up while its
own reply is still on the wire, the next query flushes the port mid-line,
and the fragment it reads is "     32", the value half of LCE's reply.

Lines are now framed on CR, LF or CRLF out of a receive buffer that
_discard_input() clears along with the port, so nothing survives a flush
half-read. The same replay now polls in 0.59 s.

Tests carry the transcript's real framing (padded values, trailing blank
lines) instead of the tidied "LER = 0" it was guessed to be, plus the two
regressions: a late fragment must not become the next query's value, and a
reply must be readable without waiting out the port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:01:27 -05:00
Thomas Ales eff589d901 Helios: don't let an unlabelled reply pass for a register's value
_value_in()'s last resort was to accept any line it could not attribute as
the answer to whatever had just been asked.  That fallback exists for the
serial numbers, which come back bare — but it applied to every query, so a
stray "32" could be read as a diode current of 32 mA.  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"), which is
exactly the value the panel is stuck on.

Only CSR and HSR now accept an unlabelled reply; every other read has to
see its own mnemonic in the line.  A read that cannot be attributed returns
None, and the panel shows "laser: ? mA" instead of leaving the last good
value on screen looking live — a stale reading and a setpoint that refuses
to move are indistinguishable otherwise.

tools/helios_lds_probe.py is the diagnostic for the underlying question:
it talks to the controller with no reply parsing at all and prints every
byte, so the transcript says whether LDS answers for itself, whether the
write is taken, and which flags the registers hold before and after.  The
status-register tables move to hardware/helios_registers.py so the probe
can decode them without importing the Qt app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:48:06 -05:00
Thomas Ales c7eb33891c Helios diode current: stop the status poll overwriting the setpoint
The current spin box could not hold a typed value: the 1 Hz status poll
read LDS and wrote it straight into the spin box, so the operator's number
was replaced by the laser's within a second — before Set could be pressed.
The spin box is now seeded once on connect and belongs to the operator
after that; the poll's reading goes to a read-back label beside the Set
button, so the value about to be sent and the value the laser holds are
separate readouts.

The write itself was also unverified. Section 6 of the operator's 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." set_current_ma() wrote LDS and returned True
regardless, so a discarded write looked exactly like a good one.
_write_verified() now writes, reads back, and retries up to three times;
set_current_ma() and set_frequency_hz() use it, and HeliosWorker reports a
refusal on the status line instead of echoing the requested value.

Also from the manual, recorded but not acted on: LDS accepts 0-7000 mA
(the driver's 2000 mA ceiling is this rig's, not the protocol's), LDF has
to be re-sent after LDG changes, and the power-monitor mnemonic is HMP —
which this laser does not implement, per the operator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:37:10 -05:00
Thomas Ales aeac5fe4d6 commit 2026-09-05 20:29:53 -05:00
Thomas Ales ff68901c57 Merge dev-auto-align: level the sample from the camera window 2026-09-04 14:02:04 -05:00
Thomas Ales 6e8c1cb7a2 Auto-align: level the sample on the DC bias levels from the camera window
The operator frames a good spot, confirms the two DC levels the detector
reads there, and the rig then measures its own tilt: step 1.5 mm either side
on X and then on Y, and tilt the platform until those levels come back.  The
correction that fixes an offset point is the correction that levels the whole
travel — height error and tilt effect are both proportional to the offset —
so the procedure ends by applying it and leaving it applied.

Both directions are measured from the same starting tilt and averaged, which
makes their disagreement a flatness read-out rather than something averaged
away silently.

core/auto_align.py holds the geometry and the search, Qt-free.  The three
T-axes' azimuths are the whole geometry: T1 lies along +X so it alone tilts
along X, and T0/T2 move as an equal-and-opposite pair to tilt along Y without
touching X (tilt_response derives that, and the tests pin it — an axis map
that drifts would still converge, on the wrong axis).  The search is a secant
null on the split-detector difference: probe once to learn what a microstep
is worth, sign included, then step at the null.  It refuses to servo on a
scope that has not re-triggered, escalates a probe that reads as no response
before calling an axis dead, and stops at a per-axis travel limit.

gui/align_bridge.py runs it on a worker thread; stopping is a threading.Event
rather than a queued command, because the worker is inside a long handler for
the whole run.  The camera window carries the button and the progress window,
and locks the scan panel and the jog pads while a run owns the stage.

Adds immediate MEAN measurements and an acquisition count to the scope
driver, and read_bias_mv to core/scope_inspect — the one scalar the
inspection state was missing.

KNOWN_ISSUES.md records what only the rig can settle: the probe step, the
travel limit, the hold current, and whether the piston the X phase applies
alongside its tilt matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:00:36 -05:00
Thomas Ales 083cbdaa34 Merge dev-per-angle-background: one background per angle in the data block 2026-09-04 12:44:29 -05:00
Thomas Ales aa06fa1460 Per-angle background capture: v7/v11 .sras layout
A multi-angle scan runs for hours, but every angle was referenced against
one background captured before the first row of the first angle. That
reference has drifted by the last angle, and comparing angles — the whole
point of a multi-angle scan — was comparing each one against a noise floor
measured at whichever angle came first.

Every angle now captures its own. Before each angle's rows, the operator is
prompted to switch the Genesis laser off, the engine averages a fresh CH1
record, and the operator switches it back on. The data block therefore reads
[background][scan][background][scan] …, one pair per angle.

Format v7 (scan) and v11 (SAW check) carry the background inside the data
block, one length-prefixed block ahead of each angle's rows; the single
block that sat between the preambles and the data is gone. Per-angle offsets
now come from a walk of the data block at parse time rather than arithmetic
over the geometry table, and an angle whose background is not fully on disk
is the frontier — nothing of it was written yet.

v6/v10 files still read: SrasFile hands their one background to every angle,
so readers never branch on the version. Nothing writes them, and a resume
refuses them, since a re-acquired angle writes a block the old layout has no
room for. A resumed v7 angle rewrites its background in place, and the
engine checks the new block fits the room the file has before writing it —
anything else would shift every row behind it.

Two fixes made along the way:

  * QtScanController never accepted file_version, so every scan launched
    from the app raised TypeError at construction.
  * angle_status() left its cursor parked at the frontier, so every angle
    past it reported the frontier's own data_offset — which handed a resumed
    scan the same write position for several angles. Two recorded offsets in
    tests/golden/sras_expected.json are corrected accordingly.

The v6 goldens stay as parser fixtures; the writer is now locked against
bytes the test lays out from scan_format.md itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:44:25 -05:00
Thomas Ales 83744c3337 Merge dev-camera-jog: jog controls in the camera window 2026-09-04 08:48:33 -05:00
Thomas Ales f823e2eb42 Camera window: T3R and BBD202 jog controls beside the image
Focusing the T-axis and framing the sample on the XY stage are both done
by eye, but the controls were in the main window and the T3R panel, so
the operator had to look away from the video to move anything.

Adds gui/jog_panel.py with two panels, laid out in a column to the right
of the camera image:

  T3RJogPanel   per-axis enable, hold-to-jog ◀/▶, live position, and a
                per-channel microstep combo (SET_MICROSTEP is per channel
                on this controller).  Jog velocity and acceleration are
                shared by the four axes.
  BBDJogPanel   an X/Y jog pad, step size, and velocity/acceleration.
                The stage runs closed-loop servos, so there is no
                microstepping to set — the panel says so rather than
                offering a control that does nothing.

The T3R's JOG is a continuous velocity move, so the button holds it and
the release stops it; the BBD has no such command, so a held button
repeats a short relative move the way the main window already does.
Only axes this panel started are ever stopped — closing the window or
hitting "Stop jogging" can't cut a scan's rotation short.

Both panels take the driver and worker the main window already owns, so
a jog here is the same command as a jog there.  The BBD202 worker grows
a set_velocity command, and its jog now carries the step with it instead
of the caller writing _jog_step onto the worker from the GUI thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:48:29 -05:00
Thomas Ales c588be523e Merge dev-saw-check: middle-row SAW quality check 2026-09-04 08:15:47 -05:00
40 changed files with 4464 additions and 424 deletions
+72 -5
View File
@@ -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.
+39 -5
View File
@@ -12,11 +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 v10 `.sras`, then compare every angle's SAW frequency on one
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
@@ -61,11 +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/v10 .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
│
@@ -82,8 +88,10 @@ 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
@@ -97,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
│
@@ -219,7 +227,7 @@ 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 v10 so the viewer knows it is a check rather
angle and the result is tagged v11 so the viewer knows it is a check rather
than a scan cut short:
```python
@@ -243,6 +251,32 @@ with SrasFile("/data/SRAS/demo-sawcheck.sras") as sras:
`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
+708
View File
@@ -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")
+12 -5
View File
@@ -6,7 +6,7 @@ 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 v10 .sras file
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.
@@ -55,7 +55,7 @@ def middle_row_plan(plan: ScanPlan) -> ScanPlan:
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 v6 scan.
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")
@@ -181,18 +181,24 @@ class AlignmentSummary:
def frequency_traces(sras: SrasFile, *, dc_threshold_mv: float = 0.0,
background: np.ndarray | None = None,
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 v10 check (one row per angle, so the middle row is the only
row) and on a full v6 scan alike — the same middle row the check would
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.
"""
@@ -210,6 +216,7 @@ def frequency_traces(sras: SrasFile, *, dc_threshold_mv: float = 0.0,
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,
+71 -27
View File
@@ -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, VERSION, 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
@@ -280,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) "
@@ -314,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)
@@ -341,7 +338,7 @@ 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,
)
@@ -363,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
@@ -376,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
@@ -391,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
View File
@@ -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
+32
View File
@@ -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
View File
@@ -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.
+147 -43
View File
@@ -1,4 +1,5 @@
"""SRAS binary scan-file format (v6 and v10) — 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,20 +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 10 is the SAW quality check (core.saw_check): byte layout identical
to v6, but every angle declares exactly one row — the row-wise middle of the
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
@@ -37,14 +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 = 10
SUPPORTED_VERSIONS = (VERSION, VERSION_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]
@@ -78,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
@@ -86,33 +108,38 @@ 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,
version: int = VERSION) -> BinaryIO:
"""Create a new .sras file and write the header + tables.
``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 v10 its meaning is
checked here, since nothing downstream can recover from a v10 file that
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 appends waveform rows and must close it (try/finally).
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 SUPPORTED_VERSIONS:
if version not in WRITABLE_VERSIONS:
raise ValueError(
f"Cannot write SRAS format version {version} "
f"(supported: {', '.join(str(v) for v in SUPPORTED_VERSIONS)})"
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 v10 SAW-check file holds exactly one row per angle, but "
"A v11 SAW-check file holds exactly one row per angle, but "
+ ", ".join(bad) + " — build the plan with "
"core.saw_check.middle_row_plan()."
)
@@ -140,19 +167,33 @@ 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 .sras file (v6 or v10): header, tables, and lazy (memmap) 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)
@@ -160,7 +201,7 @@ class SrasFile:
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)
@@ -215,15 +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 v10 middle-row SAW quality check rather than a scan."""
return self.version == VERSION_SAW_CHECK
"""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 ───────────────────────────────────────
@@ -237,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 ─────────────────────────────────────────────────────
@@ -286,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).
+124
View File
@@ -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)
+385
View File
@@ -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)
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+221 -41
View File
@@ -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:
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
return None
logger.debug(f"Query '{command}' response: {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
# 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
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
self._read_pending_lines()
return None
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
+102
View File
@@ -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
+67 -1
View File
@@ -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
View File
@@ -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))
+11 -7
View File
@@ -2,7 +2,7 @@
"""
SAW Check Viewer — every angle's frequency on one graph.
Opens a v10 middle-row SAW check (written by the main app's "SAW Quality
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
@@ -17,7 +17,7 @@ Two readings share the window:
* the summary — each angle's median with ±1σ, plotted against angle, plus
the same numbers per angle in a table.
A full v6 scan opens too: the same middle row is pulled out of it, so a scan
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.
"""
@@ -91,14 +91,17 @@ class LoadedCheck:
def __init__(self, path: Path):
self.sras = SrasFile(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
# 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 = ("v10 SAW check" if self.sras.is_saw_check
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 · "
@@ -468,10 +471,11 @@ class SawCheckWindow(QMainWindow):
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 v10 check — reading the middle row of each angle "
"Not a SAW check file — reading the middle row of each angle "
"out of this scan instead.")
else:
self.lbl_status.setText("")
@@ -504,7 +508,7 @@ class SawCheckWindow(QMainWindow):
gated = self.chk_gate.isChecked()
kwargs = dict(
dc_threshold_mv=self.spin_threshold_mv.value(),
background=check.background if self.chk_bg_sub.isChecked() else None,
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,
+10
View File
@@ -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">
+23
View File
@@ -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>
+509 -28
View File
@@ -18,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
@@ -27,6 +27,7 @@ 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 (
@@ -36,14 +37,22 @@ from core.scan_engine import (
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, VERSION, VERSION_SAW_CHECK, SrasFile, plan_from_header,
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
@@ -54,7 +63,6 @@ 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.
@@ -136,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):
@@ -152,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,
})
@@ -200,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
@@ -250,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")
@@ -319,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)
@@ -381,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)
self.current_updated.emit(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:
@@ -425,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")
@@ -438,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
@@ -468,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)
@@ -565,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 ────────────────────────────────────────────────────────
@@ -581,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)
@@ -602,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")
)
@@ -628,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")
@@ -676,9 +1119,20 @@ class HeliosWindow(QWidget):
self._worker.queue_set_current(self.helios_current_spin.value())
def _on_current_updated(self, current_ma: int):
self.helios_current_spin.blockSignals(True)
self.helios_current_spin.setValue(current_ma)
self.helios_current_spin.blockSignals(False)
"""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)
def _reset_temperatures(self):
self.helios_temp_diode_label.setText("--- °C")
@@ -700,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("---")
@@ -980,7 +1436,8 @@ 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()
@@ -1022,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")
@@ -1102,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)
@@ -1188,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()
@@ -1285,6 +1745,13 @@ class MainWindow(QMainWindow):
"""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(
@@ -1324,7 +1791,9 @@ class MainWindow(QMainWindow):
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"
f"Save → {out_path.name}{overwrite}",
"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:
@@ -1474,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)):
+108 -45
View File
@@ -1,17 +1,24 @@
# SRAS Scan Binary Format — Versions 6 and 10
# 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 share this layout byte for byte — only the version field differs,
and with it what the file means:
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 |
|---------|---------|----------------|
| 6 | A full scan. | Whatever the ROI needs. |
| 10 | A middle-row SAW quality check (`{prefix}-sawcheck.sras`). | Exactly 1. |
| 7 | A full scan. | Whatever the ROI needs. |
| 11 | A middle-row SAW quality check (`{prefix}-sawcheck.sras`). | Exactly 1. |
See [SAW Quality Check (v10)](#saw-quality-check-v10) below.
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
@@ -30,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)]
[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]
[Data Block (ragged) — per angle: [Background Block][Waveform Data]]
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 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).
@@ -44,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` (scan) or `10` (SAW check) |
| 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 |
@@ -127,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.
---
@@ -197,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` |
@@ -226,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
@@ -252,21 +291,22 @@ the file always ends on a whole-row boundary.
---
## SAW Quality Check (v10)
## 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 v10 file.
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 v10 file is a v6 file in which every
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 v6 reader that works from
the geometry table (rather than assuming a uniform shape) reads a v10 file
unchanged.
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 v6 scan aborted after its first row is not a check**,
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.
@@ -278,12 +318,33 @@ 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 v10 write for any plan that breaks it. Producing the plan is
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 |
@@ -294,5 +355,7 @@ rule (the upper of the two central rows when the count is even).
| 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–9 | (skipped) |
| 10 | Middle-row SAW quality check. Byte layout identical to v6, with every angle declaring exactly one row — the row-wise middle of the ROI. A v6 reader that derives its shape from the Per-Angle Geometry Table reads these unchanged; 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`. |
| 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`. |
+37 -19
View File
@@ -1,20 +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 .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.
Format versions 6 (full scan) and 10 (middle-row SAW check) are supported.
A subset keeps the version of the file it came from — a v10 check exports as
a v10 check, since dropping angles from one leaves it one row per angle.
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
@@ -26,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_SAW_CHECK, SrasFile
from core.sras_format import (
BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, SrasFile,
)
@dataclass
@@ -39,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
@@ -60,6 +65,7 @@ class SrasScanFile:
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
@@ -72,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
@@ -82,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:
@@ -137,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
@@ -224,7 +242,7 @@ def parse_index_spec(spec: str, max_index: int) -> list:
def print_summary(sf: SrasScanFile, selected: set):
print()
kind = " SAW check" if sf.version == VERSION_SAW_CHECK else ""
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 "
@@ -346,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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+312
View File
@@ -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
+327
View File
@@ -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()
+216
View File
@@ -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() == "---.---"
+49
View File
@@ -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)
+48 -16
View File
@@ -1,7 +1,7 @@
"""Middle-row SAW quality check: plan reduction, the v10 file, and the read-out.
"""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 v10 file whose CH1 is a pure sine at a
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.
"""
@@ -19,6 +19,7 @@ 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
@@ -51,13 +52,19 @@ def sine_frame(k: int) -> bytes:
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):
"""A synthetic v10 file: angle `i`'s CH1 is a sine at FFT bin `bins[i]`."""
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, bytes(SPF),
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):
@@ -127,9 +134,9 @@ def test_middle_row_plan_rejects_an_angle_with_no_rows():
middle_row_plan(plan)
# ── The v10 file ─────────────────────────────────────────────────────────────
# ── The v11 file ─────────────────────────────────────────────────────────────
def test_v10_write_read_roundtrip(tmp_path):
def test_saw_check_write_read_roundtrip(tmp_path):
out = tmp_path / "check.sras"
plan = write_check(out, bins=(8, 8, 8))
@@ -141,24 +148,25 @@ def test_v10_write_read_roundtrip(tmp_path):
sras.close()
def test_v10_rejects_a_multi_row_plan(tmp_path):
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, bytes(SPF), version=VERSION_SAW_CHECK)
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 7"):
with pytest.raises(ValueError, match="version 99"):
create_scan_file(tmp_path / "bad.sras", middle_row_plan(full_plan(1)),
SPF, SAMPLE_RATE, PREAMBLES, bytes(SPF), version=7)
SPF, SAMPLE_RATE, PREAMBLES, version=99)
def test_v6_file_is_not_a_saw_check():
sras = SrasFile("tests/golden/complete.sras")
assert sras.version == VERSION and not sras.is_saw_check
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 ───────────────────────────────────────────
@@ -176,7 +184,7 @@ def run_engine(tmp_path, num_angles=3):
return engine.run(), plan, check, trace
def test_engine_writes_a_complete_v10_check(tmp_path):
def test_engine_writes_a_complete_saw_check(tmp_path):
result, plan, check, _ = run_engine(tmp_path)
assert not result.aborted
@@ -200,7 +208,7 @@ def test_engine_visits_each_middle_row_once(tmp_path):
assert y_moves == [round(pa.y_positions[0], 4) for pa in check.per_angle]
def test_engine_still_writes_v6_by_default(tmp_path):
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)
@@ -229,6 +237,30 @@ def test_traces_report_the_injected_frequency(tmp_path):
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)
+76 -9
View File
@@ -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)
+20 -3
View File
@@ -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)
+45
View File
@@ -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()
+3 -1
View File
@@ -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)
+142 -29
View File
@@ -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 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):
buf += synthetic_frame(ai, ri, ci, fi)
return bytes(buf)
def _write_complete(path):
plan = _tiny_plan()
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND)
try:
for ai, pa in enumerate(plan.per_angle):
for ri in range(pa.n_rows):
for ci in range(len(CHANNELS)):
for fi in range(pa.n_frames):
f.write(synthetic_frame(ai, ri, ci, fi))
finally:
f.close()
def test_writer_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_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_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():
+71
View File
@@ -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)
+232
View File
@@ -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())