26 Commits

Author SHA1 Message Date
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
Thomas Ales 844fcd0297 SAW quality check: one middle row per angle, and a viewer that overlays them
A full multi-angle scan takes hours, and a rig whose angles disagree produces
all of them before anyone finds out. This adds a test mode that acquires one
row per angle — the row-wise middle of the ROI — and a viewer that puts every
angle's SAW frequency on one graph. The default 80×50 mm ROI at 5 angles goes
from 1461 rows to 5.

Why the middle row answers an alignment question at all: build_plan centres
every angle's rotated bounding box on the same nominal ROI centre, so each
angle's middle row crosses that one point on the sample. All the angles
measure the same material, so a spread in their frequencies belongs to the rig
rather than to where each row happened to land. test_every_angles_middle_row_
crosses_the_roi_centre pins that premise, since the whole comparison rests on
it and nothing else in the geometry code would notice it breaking.

core/saw_check.py — both halves of the mode, kept together because neither is
much use alone. middle_row_plan() reduces a ScanPlan to one row per angle
(n_rows // 2, the upper of two centre rows when even); frequency_traces() and
alignment_summary() turn the resulting file back into per-angle frequency
traces and the scalars an operator is actually asking about — the spread of
the per-angle medians, the worst drift along a row, the sparsest row. The
verdict thresholds are labelled as rules of thumb, not physics: an anisotropic
sample genuinely varies with angle, so a wide spread is a prompt to look at
the curves rather than a verdict.

Format v10: byte-identical to v6, one row per angle. The version byte earns
its keep because the two are otherwise indistinguishable — a v6 scan aborted
after its first row is not a check, and a reader guessing from the row count
would read a failed scan as a deliberate measurement. create_scan_file()
enforces the one-row rule at write time, since nothing downstream can recover
from a v10 file that breaks it. ScanEngine gains file_version and is otherwise
untouched: the acquisition, the abort/pause path and the background capture
are the scan's, unchanged.

sras_scan_manager.py now carries the source file's version through an export
instead of stamping v6 on everything, which the wider reader would otherwise
have made a lie.

saw_check_viewer.py — frequency along the row, one curve per angle, over a
common offset axis so the curves lie on the same piece of sample; a summary of
each angle's median ±1σ against angle; and the per-angle numbers in a table.
Analysis parameters (DC threshold, background, time gate) recompute on a
worker thread; display ones (smoothing, axis, MHz↔m/s) only redraw. A full v6
scan opens too — the same middle row is pulled out of it — so a finished scan
can be re-examined with the check's own read-out.

In the app, a check finishes by handing the operator the file and an "Open
Viewer" button rather than shutting the rig down the way a completed scan
does. Burst mode is not offered: one row per angle means every burst would be
a single row, so it buys nothing and still pays for the gate preflight.

137 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:13:54 -05:00
Thomas Ales dfd6c9e2b8 Merge dev-angle-inspect: pre-scan angle inspection
Walk a plan's angles before committing to the run, parking the rig at a random
point in each so the SAW response can be judged on the oscilloscope. A weak
angle otherwise produces rows that look structurally fine in the file with no
usable packet in them, which is only discoverable hours later.

The app configures the scope and drives the motion; it never reads a waveform
back. That is the feature's premise rather than an omission, so a test asserts
no transfer path exists.

- core/scope_inspect.py  free-running edge trigger on CH2 at 2.0 V, FastFrame
  and averaging off, CH1 on the acquisition front-end, CH3/CH4 rescaled as
  bias monitors sharing one scale and position
- core/angle_inspect.py  headless AngleInspector; points land on the angle's
  own scan grid, and New Point re-rolls without rotating
- gui/inspect_bridge.py  QtAngleInspector on the QueueWorker base
- sc3_aui_app.py         AngleInspectWindow, driven off the entered plan

The bias scaling (100 mV/div, ground 3.5 divisions low) is derived to fit
0-700 mV on an 8- or 10-division graticule, not measured on the rig; expect to
tune BIAS_POSITION_DIV against the bench-tuned values in SRAS_CHANNELS.

114 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 07:21:56 -05:00
Thomas Ales 23546a03f7 Pre-scan angle inspection: park the rig per angle, read the response on the scope
A 9-angle scan takes hours, and an angle that responds poorly still produces
rows that look structurally fine in the file — the SAW packet is just not
there. This lets the operator walk the angles first, parking the rig at a
random point in each, and judge the response before committing to the run.

Nothing reads the scope. The operator inspects the instrument directly, so
there is no transfer path, no plotting, and no waveform crossing the module
boundary — test_inspection_never_reads_a_waveform_back pins that, since it is
the kind of premise a later change erodes without noticing.

core/scope_inspect.py — the scope state worth looking at, which is not the
scan's state:
- plain rising-edge trigger on CH2 at 2.0 V, not the scan's logic AND of the
  laser pulse and the stage gate, so a stationary stage still triggers
- FastFrame off, SAMPLE (no averaging) — a weak or intermittent response is
  exactly what is being looked for, and averaging would hide it
- free-running (STOPAfter RUNSTop + STATE RUN) so the trace keeps updating
  while the operator looks at it
- CH1 keeps the acquisition front-end verbatim, so what is on screen is what
  a scan would record
- CH3/CH4 become bias monitors sharing one scale and position, since the
  comparison is by eye and only works if a division means the same on each.
  100 mV/div with ground 3.5 divisions below centre puts 0–700 mV on screen
  with headroom on an 8- or 10-division graticule (the signal never goes
  negative, hence moving the trace down).

core/angle_inspect.py — AngleInspector, headless and Qt-free like ScanEngine.
Points are drawn from the angle's own bounding box: Y from its actual row
positions and X uniformly across its data window, so the point is somewhere
the scan would really sample rather than merely inside the box. New Point
re-rolls without rotating, which is what separates a bad spot on the sample
from a bad angle. The stage gate is held off throughout, and the rotator goes
home on stop.

gui/inspect_bridge.py — QtAngleInspector on the existing QueueWorker base.
Inspection is click-driven rather than one long run, so the worker blocks on
its queue between commands and an open window costs nothing. BBD position
polling is suppressed while inspecting, for the same reason the scan does it:
the shared TX queue.

sc3_aui_app.py — AngleInspectWindow (angle list, prev/next, New Point) driven
off the plan currently entered in the scan panel, so it inspects exactly the
scan about to be run. Navigation locks while the stage moves. The list syncs
via itemClicked rather than currentRowChanged, so echoing the worker's
position back does not re-trigger the move it is reporting.

README picks up the new modules, and scope_burst.py which the previous merge
left out of the structure listing. 114 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:41:52 -05:00
Thomas Ales 817da0160c Merge dev-rowpacking: selectable row packing policy
Row packing is now a choice rather than a fixed behaviour. Pad (default,
unchanged) squares a mis-triggered row up to the declared n_frames and warns;
strict stops the scan instead, so a data run cannot quietly contain a padded
row that nothing in the file marks as padded.

Both paths check the frame count before writing any of the row's channels, so
a strict abort ends the file on a whole-row boundary.

92 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:32:15 -05:00
Thomas Ales 52fdcdd9f3 Make row packing toggleable: pad (default) or strict abort
A mis-triggered row cannot be written as it arrived — v6 declares n_frames per
row in the header and has no per-row length field, so a short or long row
would shift every later row in the file. Until now the only policy was to
square it up, which keeps the scan running but leaves the affected row
indistinguishable from a good one afterwards: nothing in the file records that
it was padded.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:32:09 -05:00
Thomas Ales ab5f3166f4 Merge dev-burst-mode: opt-in burst acquisition
Adds a second acquisition path that runs one FastFrame acquisition across as
many whole rows as the scope's frame memory holds, transferring each burst in
a single CURVe? transaction instead of one block read per frame per row.
Off by default; both paths write byte-identical files.

- d6a5626 driver support (bulk transfer, MAXFRames?, per-row trigger gating)
  and two read_raw fixes: a short read on the length digits, and #0
  indeterminate-length blocks, which a raw socket cannot delimit by EOI
- 116c9c0 core/scope_burst.py, the split row loop, and the on-rig gate-off
  preflight that resolves the undocumented BBD trigger-idle value
- ef8c0fe GUI checkbox, persisted default, and corrected scan_format docs

Also fixes, on both paths: rows are squared up to the declared n_frames
(v6 has no per-row length field, so a mis-triggered row shifted every later
row in the file), the transfer format is pinned rather than inherited from
the front panel (the header hardcodes bytes_per_sample=1), and the X trigger
output is returned to idle when a scan ends.

87 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:19:31 -05:00
Thomas Ales ef8c0feb91 Expose burst acquisition in the app; refresh scan_format acquisition docs
Wires the burst_mode flag through QtScanController to ScanEngine and adds a
checkbox to the scan panel. The setting persists via ScanDefaults like the
other scan fields, defaulting to off — per-row acquisition stays the default
path until burst mode has run on the rig and the gate-off preflight has
settled which TRIGOUT value idles the pin low.

scan_format.md — the acquisition settings table had drifted from the code it
claimed to describe: it attributed the settings to sc3_aui_app.py (they moved
to core/scope_sras.py in the Phase 2 extraction), listed a 1.24 V trigger
level and 0 % offset where the code sets 0.500 V and HORizontal:POSition 30,
and did not mention the logic-AND scan trigger at all. Corrected, pointed at
the module that actually owns them, and noted that none of it affects byte
layout — only where the acoustic packet lands inside a frame.

Added an acquisition-paths section: the two paths write byte-identical files
and the choice is a runtime flag that is not recorded in the file, so a
reader never needs to care which produced it. Documents where row boundaries
come from in a burst and that either path squares rows up to n_frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:18:23 -05:00
Thomas Ales 116c9c07c7 Burst acquisition: many whole rows per FastFrame acquisition
Per-row acquisition pays a full arm/stop/transfer round trip for every row,
and the transfer is one IEEE-488.2 block read per frame (~16k frames a row).
Burst mode runs one FastFrame acquisition across as many complete rows as the
scope's frame memory holds and pulls each burst in a single CURVe?
transaction, amortising the round trip over the whole burst.

It is opt-in (ScanEngine(burst_mode=...), default False) and writes
byte-identical files to the per-row path — test_burst_and_serial_produce_
identical_files runs the same plan both ways and compares the bytes, which is
the property the whole feature rests on.

core/scope_burst.py — the new policy module. Everything that computes rather
than talks to hardware is a free function, so sizing and row-splitting are
testable without a rig: rows_per_burst() (rounds down, since a partial row
can't be written, and clamps to a transfer-buffer budget), split_row_counts(),
normalize_row(), frame_means_block().

The hard part is that a burst carries no row markers — the scope returns one
flat run of frames. Boundaries come from ACQuire:NUMFRAMESACQuired? sampled
after each acquiring pass while the stage gate is already low, rebased on a
baseline read back at RUN rather than assuming the counter resets. A counter
that goes backwards means the acquisition restarted mid-burst and is now a
hard error instead of silently misattributing every later row.

core/scan_engine.py — the row loop splits into _scan_rows_serial and
_scan_rows_burst. The wire is channel-major and the file is row-major with
channels inner, so _write_burst deinterleaves by writing one channel at a
time to strided offsets; peak memory stays at a single channel's burst
instead of the whole thing.

_gate_off_preflight is what makes this trustworthy on real hardware. The BBD
value that idles the trigger output low is not settled by the protocol docs
(see TRIGOUT_GATE_OFF), and getting it wrong fills every burst with flyback
frames that silently shift the file. The scope already measures the gate on
CH3, so the check needs no bench probe: one gated-off flyback must acquire
nothing, and one gated pass must acquire something — the second half is what
stops a dark laser from making the first half pass vacuously. It runs once
per scan and costs two row-times.

Two fixes fall out of this work and apply to both paths:
- Rows are now squared up to the declared n_frames (short rows zero-padded,
  long rows truncated, both warned). v6 commits to n_frames per row in the
  header and has no per-row length field, so an over- or under-triggered row
  used to shift every later row in the file.
- The X trigger output is returned to idle in the run() finally block. The
  per-row path left TRIGOUT_MAXV armed for the rest of the session, so the
  gate line kept being driven on every later jog.

core/scope_sras.py — pins DATa:ENCdg RIBinary and DATa:WIDth 1 during setup
instead of inheriting front-panel state. The file header hardcodes
bytes_per_sample=1; a scope left on 2 bytes would have corrupted every frame
written. frames_acquired/frame_means move to scope_burst, where the offset-
based variants serve both paths.

tests/fakes.py — FakeStage and FakeScope are now wired together the way the
rig is: a gated X move at scan velocity feeds frames into a running
acquisition at the real 20 kHz / 100 mm/s rate, direction-agnostic. Both
paths therefore derive frame counts from one model, which is what makes the
byte-identity comparison meaningful, and a gate the engine forgets to drop
shows up as extra frames instead of passing silently. Frame content is a
function of (channel, index) alone, so the same frame sequence yields the
same bytes however it is chopped into transfers.

87 tests passing, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:17:24 -05:00
Thomas Ales d6a56266b7 Driver support for multi-row FastFrame bursts; fix read_raw block parsing
Groundwork for burst acquisition: the scope needs to report and transfer a
whole multi-row FastFrame acquisition, and the BBD needs to gate its trigger
output per row rather than staying armed for the scan.

tektronix_base
- get_fastframe_max_frames() exposes HORizontal:FASTframe:MAXFRames?, which
  is what sizes a burst once the horizontal settings are fixed.
- transfer_fastframe_bulk() pulls a burst as one contiguous buffer. Unlike
  transfer_fastframe it does not assume how the scope frames the response:
  it accumulates until the expected byte count is reached, so one large IEEE
  block and one block per frame both work.
- set_data_encoding() / set_data_width() make the transfer format settable
  instead of inherited from whatever the front panel was left on.
- read_raw() had two real defects. The length-digit read used a bare recv()
  and only checked the length afterwards, so a short read raised "Failed to
  read data length" on a perfectly good transfer; it now goes through a
  _recv_exact() helper, as does the trailing separator. And a #0
  indeterminate-length block was parsed as int("") -> ValueError. #0 is
  normally delimited by EOI, which a raw socket never sees, so read_raw now
  takes expected_bytes to size it. The bulk transfer relies on this.

pybbd202
- arm_scan_gate(axis, armed) raises and drops the max-velocity trigger
  output the scope's AND-gate uses. A burst spans several rows with the
  scope running throughout, so the gate must be low for the flyback or the
  return move reaches max velocity and injects frames between rows.
- set_trigger_verified() reads the mode back after setting it. set_trigger
  is fire-and-forget over the shared TX queue; burst mode toggles the gate
  between every row, where a dropped change silently corrupts the file
  rather than failing loudly.
- set_trigger_gate_off() so the scan can leave the output idle on exit.
- TRIGOUT_GATE_OFF is deliberately marked unverified. §7.6 of the BBD203
  protocol doc describes `mode` as an enumeration capping at 0x11, which
  contradicts the bitmask this driver actually sends (TRIGOUT_MAXV = 0x90,
  known working), so the doc cannot settle which value idles the pin low.
  The engine's preflight check resolves it on the rig instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:16:20 -05:00
Thomas Ales 278df9411e Ignore macOS .DS_Store files
hardware/.DS_Store kept showing up as untracked throughout the refactor;
.gitignore had no rule for it. The file itself is left on disk (Finder
regenerates it) — it is simply ignored now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 11:39:51 -05:00
Thomas Ales 709dc529df Phase 6: strip signature-restating docstrings; correct README/SETUP
- Collapsed Args:/Returns:/Raises: blocks that only restated the
  signature (364 lines): tektronix_base 48% -> ~20% doc density,
  helios_laser and uc480_camera likewise. Only docstrings whose entire
  body was those sections were touched.
- Preserved verbatim the comments that carry hardware knowledge the code
  can't express: uc480's USB split-transaction contention note (with its
  measured fps), the IS_ALLOW_STARTER_FW_UPLOAD segfault explanation, the
  QImage-copy rationale, and tektronix's NUMFRAMESACQuired warning.
- README: project structure, quick start, and every usage example now
  describe code that exists (they referenced hardware/bbd202.py,
  CoherentHOPSLaser, get_curve_binary, and 'python -m scanengine.app',
  none of which do). Added a headless-scan example and a read-a-scan-file
  example, since reuse without the GUI is the point of the refactor.
- SETUP: structure section defers to README instead of keeping a second
  stale copy; documents the vendored uEye SDK and the Genesis quarantine.
- ruff is now clean repo-wide: fixed the remaining raise-from, unused
  loop variables, placeholder f-strings, and a non-strict zip; the
  widget-layout semicolon idiom is an explicit config ignore rather than
  22 standing warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 11:27:36 -05:00
Thomas Ales 44febe34b8 Phase 5: shared worker base, self-rescheduling polls, driver robustness
gui/qt_workers.py — one QueueWorker base replaces the per-device command
queue + dispatch + signal boilerplate. The loop blocks on the queue
instead of waking 10-20x/second forever (test_idle_worker_does_not_spin
asserts an idle worker burns ~no CPU). PollingQueueWorker adds
self-rescheduling polling: the next poll is queued only after the
previous finishes, so a device slower than the interval can't accumulate
a backlog (test_polling_never_overlaps_or_backs_up).

Helios responsiveness — the concrete bug that motivated the above: a free
running 1 s QTimer queued a status poll that took ~2 s, so the queue grew
for as long as the panel stayed connected.
- helios_laser._query reads until the CR terminator instead of sleeping a
  fixed 0.05 + 0.2 s per query
- one _query_int() helper replaces five copies of parse-with-logging
- polling is now driven by the worker; HeliosWindow's QTimer is gone
- dropped __del__, which disabled the laser and wrote to the serial port
  from the garbage collector at an unpredictable time

helios_test_app.py — the worker was moveToThread'd but every call site
invoked its methods directly, so all serial I/O (including the sleeps)
ran on the GUI thread; Query All froze the UI for ~2 s. Calls now go
through a queued signal to a pyqtSlot. Also: connect/disconnect cycles
leaked a QThread + worker + 9 connections each time; 16 copies of the
not-connected guard collapse to _require_connection(); the Query Power
button called a method that has never existed (AttributeError popup) and
is now disabled and documented in KNOWN_ISSUES.

DCBiasImageWidget preallocates its image and uses set_data/set_clim, so
the live preview stops rebuilding the array and the whole artist tree per
row (O(rows^2) over a scan).

bbd20x: connect() now raises when no bays respond instead of reporting
success on the wrong port; disconnect() joins with a timeout so a wedged
reader can't hang shutdown; one _channel_for() helper replaces four
copy-pasted axis mappings; hardcoded travel limits become TRAVEL_MM; the
joke error strings are gone.

gui/widgets.py adds the shared ConnectionBar / PortSelector / bounded
LogConsole / StatusGrid for the test benches to adopt. 65 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 11:21:29 -05:00
Thomas Ales afe33249d1 Phase 4: extract headless ScanEngine; de-Qt the T3R driver
The headline of the refactor. Scan orchestration no longer lives inside a
QObject that reaches through Qt workers for its hardware handles.

core/scan_engine.py — ScanEngine(stage, scope, rotator, plan, out_path,
resume, callbacks). Takes the concrete drivers, blocks in run(), reports
via plain callables, and prompts through an injected blocking callable.
No Qt import anywhere in the path (test_engine_imports_without_qt proves
it), so a simpler GUI or a CLI can drive the identical acquisition.

Supporting extractions, all Qt-free:
- core/scope_sras.py  — SCPI policy: channel profiles, trigger programming,
  background average, per-row FastFrame transfer
- core/rotation.py    — RotationAxis + RotationSettings (the GR_* constants)
- core/scan_resume.py — frontier contiguity rule + settings compatibility
- gui/scan_bridge.py  — QtScanController, exposing exactly the signal
  surface the old ScanWorker had, so MainWindow's connections are unchanged

hardware/t3r_driver.py is now Qt-free: a plain Signal class, a threading
reader, and a polling thread instead of QObject/QThread/QTimer.
gui/qt_t3r.py re-emits its callbacks as queued Qt signals for the panels.

Fixes carried by the extraction:
- rotation waits on the driver's MOTION_DONE event instead of
  time.sleep(estimate + 0.5)
- abort during an operator prompt now takes effect; the old
  _prompt_event.wait() had no timeout and could not be interrupted
- the poll timer is a thread, so an I/O error tearing down the driver no
  longer calls QTimer.stop() from the wrong thread
- T3RDriver.disconnect() renamed close(); it shadowed QObject.disconnect()
- per-frame DC means use np.frombuffer over the joined block instead of
  struct.unpack per frame (~16k tuple allocations per row)

tests/fakes.py + test_scan_engine.py (14 tests) assert the exact command
sequence, file layout, resume seeking, abort/pause, and geometry
rejection before any hardware call; test_scan_resume.py covers the
frontier rule. 58 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 11:11:09 -05:00
Thomas Ales d2734c45d6 Phase 3: viewer reads v6 via mmap; extract analysis core
The viewer could only parse v2-v4 headers while the app has been writing
v6 for some time — it could not open ANY file the current app produces.
It now uses core.sras_format directly (v6 only, per user decision).

New core/sras_analysis.py (Qt-free): ChannelCalibration, image reducers,
and SawPipeline. sras_viewer.py keeps only Qt.

Memory (measured, 92 MB synthetic scan, separate processes):
  old eager path  +305 MB   read()+slice-copy+astype+float32 mean
  new mmap path   + 31 MB   zero-copy view + mean(dtype=)
  -> identical DC image; old scaled at ~3.3x file size, new at image size
- load_angle() returns a read-only mmap view instead of reading the whole
  data block, then copying it twice
- SAW sweeps keep one scalar per pixel (process_shot_metrics) instead of
  retaining 5 full arrays x pixel count in a results list
- CH1 float32 materializes only for pixels passing the DC mask
- matched filter caches the template FFT instead of recomputing per pixel
- opening a new file drops every reference to the old one (compute/
  template/diagnostic workers used to pin the previous multi-GB mapping)

Responsiveness:
- 250 ms debounce coalesces spinbox storms into one recompute
- grating change is a display-time scalar multiply, not a full FFT rerun
- colormap/clim reuse the AxesImage (set_data/set_clim) instead of
  clf() + rebuilding the colorbar; draw_idle() throughout
- SAW diagnostics (21 pipeline runs) and CSV export moved off the GUI thread

Also: ragged per-angle geometry is respected (v6 angles differ in rows/
frames), truncated scans show only rows present on disk, dead decimation
path and v2 fallback branch removed, scipy added to viewer requirements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 10:55:38 -05:00
Thomas Ales dff9f69d78 Phase 2: extract headless core modules (sras_format, scan_geometry, config)
- core/sras_format.py: THE v6 implementation — create_scan_file (writer,
  byte-identical to the old one, enforced against the Phase-0 goldens),
  SrasFile parser with frontier/truncation walk, and zero-copy mmap
  load_angle/load_row views for multi-GB files
- core/scan_geometry.py: ScanPlan/AngleGeometry dataclasses, build_plan
  (rotated-bbox trig from MainWindow._build_scan_params), travel-limit
  validate_plan (limits now a StageLimits dataclass, not literals buried
  in the worker), format_eta + EtaEstimator (bounded deque)
- core/config.py: ScanDefaults dataclass replaces the module-import-time
  dict globals. FIXES: editing any main-window port used to rewrite
  aui_defaults.json without helios_port, silently reverting the Helios
  port every time (test_helios_port_survives_partial_update covers it).
  Also drops the inert laser_freq_hz plumbing — scans always used the
  LASER_FREQ_HZ constant.
- hardware/serial_util.py: shared 8N1 open + scored port enumeration
  (promoted from t3r_control_panel); helios_laser and the panel use it
- sc3_aui_app.py and sras_scan_manager.py migrated onto core (three
  format implementations down to one); ScanWorker now takes a ScanPlan
- tests: byte-identical writer vs golden, frontier over every truncation
  variant, mmap==eager, geometry vs golden fixtures + invariants, config
  round-trip. 28 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:20:44 -05:00
Thomas Ales 67aabde4b6 Phase 1c: prune remaining dead functions, unused imports, quarantine Genesis
- uc480_camera: drop never-called _capture_paused/get_framerate (the
  hardware question _capture_paused encoded is now in KNOWN_ISSUES.md)
- t3r_protocol: drop read_reg/write_reg/decode_reg/Reg (commands never
  wired into the driver)
- bbd20x: drop _update0x0212 (never dispatched) and 8 of 9 unused
  trigger convenience wrappers; apt_constants: drop TriggerBitsStepper
  (servo-only rig)
- ruff --fix: 35 unused imports across all apps; drop unused T3R_BAUD
- genesis_core.py: quarantine warning header; docs/genesis_verification.md
  bench checklist for the 7 divergences vs tools/genesis_laser_gui.py

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:08:48 -05:00
Thomas Ales 5148f0bca2 Phase 1b: prune 45 never-called methods from tektronix_base (~850 lines)
Verified by repo-wide name search + transitive closure over internal
calls: the live apps use 25 methods (plus raw write/query); everything
else — cosmetic label styling, unused getters/setters, transfer_waveform,
acquire_waveform — had no callers. Also: linear-time chunk join in
read_raw instead of quadratic bytes += concat, and a typed except on its
debug path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:04:41 -05:00
Thomas Ales 1d4e65f8ac Phase 1a: delete dead parallel app stack (~4,600 lines)
app.py was an abandoned skeleton (15 'pass # TODO' handlers, loads a
deleted .qss); everything else was reachable only from it:
ui_mainwindow.py (pyuic6 artifact), sc3-new.ui, motion_worker.py,
genesis_worker.py, coherent_hops_laser.py (stubs), scanning/ (dead C#
port + unused plan generator), config.json, plus helios_diagnostic.py
(sends wrong protocol commands) and helios_terminal.py (worse duplicate
of helios_test_app's Terminal tab).

hardware/__init__.py no longer wildcard-imports every driver, so the
stage driver imports without the uEye camera SDK installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:02:36 -05:00
Thomas Ales d185676130 Phase 0: test scaffolding + golden fixtures
- ruff config, offscreen smoke tests for all 7 GUI apps/panels
- golden v6 .sras fixtures (complete + 4 truncation variants) generated
  by the pre-refactor writer, with expected header/frontier JSON
- golden geometry fixtures from the pre-refactor _build_scan_params
- consistency tests proving current code reproduces the goldens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:01:14 -05:00
Thomas Ales 7bcdff9756 Remove nonessential files
- photorec.ses / photorec.se2: accidentally committed PhotoRec
  data-recovery session files
- sc3-aui-focusing.py: dead module — imports a T3RStepperDriver that
  doesn't exist anywhere; superseded by t3r_control_panel.py
- adc_bug.md: stale debugging note for an already-applied fix
- app_style.qss: empty stylesheet; app.py already handles its absence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 09:27:30 -05:00
95 changed files with 18336 additions and 67195 deletions
Regular → Executable
+3
View File
@@ -177,3 +177,6 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/
# macOS
.DS_Store
+116
View File
@@ -0,0 +1,116 @@
# Known issues requiring on-rig verification
Questions that cannot be answered from the code alone. Check these the next
time the hardware is available; each one gates a small code change.
## uC480 camera: gain/exposure during active capture
The driver used to carry an (unused) `_capture_paused` context manager whose
docstring claimed many IDS cameras return `IS_CANT_COMMUNICATE_WITH_DRIVER`
(17) or `IS_NO_SUCCESS` (-1) when gain/exposure commands are issued during
active capture. `set_exposure()` and `set_gain()` never used it, and the
helper was deleted in the Phase-1 cleanup.
**Bench check:** with live streaming running, move the exposure and gain
sliders in `camera_test_app.py` and watch the log for those error codes.
If they appear, the setters need a stop-live/apply/restart sequence
(re-create the helper around the two call sites in
[uc480_camera.py](hardware/uc480_camera.py)).
## Helios: no output-power query
`docs/hardware/HELIOS_DRIVER_README.md` documents `driver.get_power_mw()`,
but `HeliosLaser` has no such method and no output-power mnemonic appears
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.
## Genesis laser: forked protocol implementations disagree
`hardware/genesis_core.py` and the reference implementation
`tools/genesis_laser_gui.py` disagree on ADC command bytes, LDD enable
polarity, shutter semantics, filtering, and scaling. Do not modify either
until the checklist in [docs/genesis_verification.md](docs/genesis_verification.md)
has been run on the bench.
## `lib/ueye_loader.so` — still needed?
`lib/ueye_loader.c` is an `LD_PRELOAD` shim that dlopens
`/usr/lib/libueye_api.so` — yet nothing in the repo references it, and the
vendored SDK copy is `lib/libueye_api64.so.3.82` (a different file). On the
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.
+252 -93
View File
@@ -9,9 +9,19 @@ scanengine-3 is a unified platform for scanning acoustic microscopy and precisio
### Key Features
- **Stage Control**: ThorLabs BBD202/BBD203 motor controller with 3-axis positioning
- **Laser Systems**: Helios and Coherent HOPS laser control
- **Laser Systems**: Helios pulsed laser and Genesis CW laser control
- **Data Acquisition**: Tektronix oscilloscope integration with fast-frame support
- **Scan Planning**: Automated raster scan generation and execution
- **Per-Angle Background**: every angle opens with its own background
capture (Genesis off, Helios on), stored ahead of that angle's data
- **Angle Inspection**: Park the rig at random points across a plan's angles
to check the SAW response on the scope before committing to a long scan
- **SAW Quality Check**: Acquire one row per angle — the row-wise middle of
the ROI — as a v11 `.sras`, then compare every angle's SAW frequency on one
graph to judge the alignment before a full run
- **Auto-Align**: Level the sample from the camera window — step the stage
1.5 mm either side on X and then Y, tilt the T-axes until the DC bias levels
read what they read at the reference point, and leave the correction applied
- **Real-time Monitoring**: Live status updates and progress tracking
## Hardware Components
@@ -30,11 +40,6 @@ scanengine-3 is a unified platform for scanning acoustic microscopy and precisio
- Multiple pulse modes
- Temperature and power monitoring
- **Coherent HOPS Laser**
- I2C/FTDI interface
- Power and modulation control
- Temperature monitoring
### Data Acquisition
- **Tektronix MSO/DPO Series Oscilloscopes**
- Direct socket communication (no VISA overhead)
@@ -42,69 +47,78 @@ scanengine-3 is a unified platform for scanning acoustic microscopy and precisio
- Multi-channel waveform capture
- Configurable triggering
### Microscope Systems
- **Genesis Microscope** (stub implementation)
- **T3R Timing Device** (stub implementation)
### Rotation / Focus
- **T3R four-channel stepper controller**
- Focus axis plus the GR rotation stage (12.5:1 gear train)
- Custom binary framing protocol over USB serial
## Project Structure
The codebase is split so that everything needed to run a scan is importable
without PyQt6 or any vendor SDK — `core/` is the headless engine, `gui/` is
the shared Qt layer, and the root scripts are entry points.
```
scanengine-3/
├── scanengine/ # Main application package
│ ├── __init__.py
│ ├── app.py # Main application entry point
│ ├── main_launcher.ui # Main launcher UI
│ ├── new_scan_wizard.ui # Scan wizard UI
│ └── options.ui # Options dialog UI
├── core/ # Headless: no PyQt6, no vendor SDKs
│ ├── scan_engine.py # ScanEngine — full acquisition sequence
│ ├── scan_geometry.py # ScanPlan, rotated-bbox planning, limits
│ ├── 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 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 # v7/v11 .sras writer, v6/v10 reader (mmap)
│ ├── sras_analysis.py # Image reducers + SAW matched filter
│ └── config.py # ScanDefaults ⇄ aui_defaults.json
│
├── hardware/ # Hardware driver package
│ ├── __init__.py
│ ├── bbd202.py # ThorLabs stage controller
│ ├── uc480_camera.py # IDS/ThorLabs camera
│ ├── tektronix_base.py # Tektronix oscilloscope
│ ├── coherent_hops_laser.py # Coherent HOPS laser
│ └── genesis_core.py # Genesis laser core logic
├── hardware/ # Device drivers (Qt-free)
│ ├── serial_util.py # Shared 8N1 open + port enumeration
│ ├── t3r_driver.py # T3R stepper controller
│ ├── t3r_protocol.py # T3R frame encode/decode
│ ├── helios_laser.py # Helios pulsed laser
│ ├── tektronix_base.py # Tektronix oscilloscope (raw SCPI)
│ ├── uc480_camera.py # IDS/ThorLabs uEye camera (returns QImage)
│ ├── genesis_core.py # Genesis laser — QUARANTINED, see below
│ └── pybbd202/ # ThorLabs BBD202 stage (APT protocol)
│
├── scanning/ # Scan planning package
│ ├── __init__.py
│ ├── sc3_scan_model.py # Scan model
│ └── stage_scan_plan_generator.py # Scan path planning
├── 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…
│
├── tools/ # Standalone executable tools
│ ├── genesis_laser_control.py # Standalone Genesis app
│ └── genesis_laser_gui.py # Alternative Genesis GUI
├── sc3_aui_app.py # Main acquisition application
├── sras_viewer.py # Scan data viewer
├── saw_check_viewer.py # SAW check viewer: every angle's frequency, one graph
├── sras_scan_manager.py # CLI: inspect/export/delete angles
├── t3r_control_panel.py # T3R panel (used by the main app)
├── helios_test_app.py # Per-device test benches
├── bbd202_test_app.py
├── camera_test_app.py
├── sc3-aui-*.ui # Qt Designer files loaded at runtime
│
├── tests/ # Test files
│ ├── __init__.py
│ ├── test_camera_integration.py
│ ├── test_genesis_connection.py
│ ├── test_genesis_protocol.py
│ ├── test_rotated_aoi.py
│ └── test_temperature_scaling.py
├── tests/ # pytest suite
│ ├── golden/ # legacy v6 .sras + geometry fixtures
│ ├── fakes.py # Recording fake stage/scope/rotator
│ └── test_*.py
│
├── docs/ # Documentation
│ ├── hardware/ # Hardware documentation
│ │ ├── BBD203_CONNECTION_GUIDE.md
│ │ ├── BBD203_Communications_Protocol.md
│ │ ├── BBD203_DRIVER_README.md
│ │ ├── HELIOS_DRIVER_README.md
│ │ ├── GENESIS_LASER_README.md
│ │ └── laser_control_implementation_guide.md
│ └── protocols/ # Protocol specifications
│ ├── apt_communications_protocol.pdf
│ ├── helios_comms_protocol.pdf
│ └── thorlabs_mls_protocol.pdf
├── docs/
│ ├── hardware/ # Driver notes
│ ├── protocols/ # Vendor protocol PDFs
│ └── genesis_verification.md # Bench checklist (see KNOWN_ISSUES.md)
│
├── lib/ # Binary libraries (not in git)
│ ├── libueye_api64.so.3.82
│ ├── ueye_loader.c
│ └── ueye_loader.so
│
├── config.json # System configuration
├── requirements.txt # Python dependencies
├── README.md # This file
├── SETUP.md # Setup instructions
└── LICENSE # License file
├── lib/ # Vendored IDS uEye SDK (not in git)
├── aui_defaults.json # Persisted ports / scope IP / save dir
├── scan_format.md # .sras binary format specification
├── KNOWN_ISSUES.md # Open questions needing the hardware
└── requirements.txt
```
## Quick Start
@@ -126,14 +140,35 @@ pip install -r requirements.txt
### Running the Application
```bash
# Main GUI application
python -m scanengine.app
# Main acquisition application
python sc3_aui_app.py
# Scan data viewer
python sras_viewer.py
# SAW quality check viewer (every angle's frequency on one graph)
python saw_check_viewer.py path/to/scan-sawcheck.sras
# Inspect / export / delete angles in a .sras file
python sras_scan_manager.py path/to/scan.sras
# Per-device test benches
python helios_test_app.py
python bbd202_test_app.py
python camera_test_app.py
# Genesis laser control tool
python tools/genesis_laser_control.py
```
# Alternative Genesis laser GUI
python tools/genesis_laser_gui.py
### Running the tests
The suite is hardware-free: fake drivers and committed fixtures stand in
for the rig.
```bash
pip install pytest ruff
python -m pytest tests/ -q
```
## Dependencies
@@ -143,66 +178,190 @@ python tools/genesis_laser_gui.py
- **pyvisa** (>=1.13.0) - VISA instrument control
- **pyvisa-py** (>=0.7.0) - Pure Python VISA backend
- **pyftdi** (>=0.54.0) - FTDI USB device support
- **numpy** (>=1.20.0) - Array processing
- **scipy** (>=1.10) - Signal processing (viewer SAW pipeline)
- **matplotlib** (>=3.7) - Plotting (viewer, live scan preview)
- **pyueye** (>=4.95.0) - IDS uEye camera SDK bindings (camera only)
## Known hardware caveats
`hardware/genesis_core.py` is quarantined: it diverges from the reference
implementation in `tools/genesis_laser_gui.py` in ways that need the laser
on the bench to settle. See [KNOWN_ISSUES.md](KNOWN_ISSUES.md) and
[docs/genesis_verification.md](docs/genesis_verification.md) before
changing either file.
## Usage Examples
### Stage Control
### Running a scan without any GUI
The acquisition sequence lives in `core.scan_engine` and takes plain
drivers plus callbacks, so a script (or a future simpler GUI) can drive the
identical scan the main app runs:
```python
from hardware.bbd202 import BBD202Controller
from pathlib import Path
from core.scan_engine import ScanCallbacks, ScanEngine
from core.scan_geometry import build_plan
from core.rotation import RotationAxis
from hardware.pybbd202 import ThorlabsServoDriver
from hardware.tektronix_base import TektronixOscilloscopeBase
from hardware.t3r_driver import T3RDriver
# BBD202/BBD203 controller example
controller = BBD202Controller()
controller.connect("/dev/ttyUSB0") # Serial port
# Use controller for stage operations
plan = build_plan(x_start=10.0, y_start=10.0, x_delta=20.0, y_delta=10.0,
num_angles=3, row_spacing=0.25,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
stage = ThorlabsServoDriver(); stage.connect("/dev/ttyUSB0")
scope = TektronixOscilloscopeBase("192.168.100.105"); scope.connect()
t3r = T3RDriver(); t3r.open("/dev/ttyACM0")
engine = ScanEngine(stage, scope, RotationAxis(t3r), plan,
Path("/data/SRAS/demo.sras"),
callbacks=ScanCallbacks(on_status=print,
prompt=lambda t, m: input(f"{t}: {m} ")))
result = engine.run() # blocking; engine.abort() is thread-safe
print(f"wrote {result.rows_written} rows to {result.path}")
```
### Oscilloscope Acquisition
### Running a SAW quality check
Same engine, same hardware sequence — the plan is reduced to one row per
angle and the result is tagged v11 so the viewer knows it is a check rather
than a scan cut short:
```python
from core.saw_check import alignment_summary, frequency_traces, middle_row_plan
from core.sras_format import VERSION_SAW_CHECK, SrasFile
check = middle_row_plan(plan) # the plan above: 163 rows → 3
engine = ScanEngine(stage, scope, RotationAxis(t3r), check,
Path("/data/SRAS/demo-sawcheck.sras"),
callbacks=ScanCallbacks(on_status=print),
file_version=VERSION_SAW_CHECK)
engine.run()
with SrasFile("/data/SRAS/demo-sawcheck.sras") as sras:
traces = frequency_traces(sras, dc_threshold_mv=50.0)
for t in traces:
print(f"{t.angle_deg:+7.1f}° {t.median_mhz:.2f} MHz "
f"drift {t.drift_mhz_per_mm:+.3f} MHz/mm")
print(alignment_summary(traces).describe())
```
`saw_check_viewer.py` is the same read-out with the curves drawn.
### Levelling the sample (auto-align)
Two phases, because the operator sits between them: `prepare()` configures
the rig and reads the DC levels where the stage stands, and `run()` only
starts once those levels have been confirmed as the ones to hold.
```python
from core.auto_align import AlignCallbacks, AutoAligner
aligner = AutoAligner(stage, scope, t3r,
callbacks=AlignCallbacks(on_status=print))
reference = aligner.prepare() # scope + T-axes configured, one reading
print(reference.describe()) # "is the image correct?" happens here
result = aligner.run() # X on T1, then Y on T0/T2
print(result.describe())
aligner.stop() # stage parked; the tilt stays applied
```
The scope has to be cabled CH1 SAW / CH2 trigger / CH3 DC 1 / CH4 DC 2 — the
same channels a scan uses, except that CH3 carries the DC monitor here rather
than the max-velocity gate. Nothing rewires it; the app asks the operator to
confirm the cabling, and refuses to servo on a scope that is not triggering.
In the main app the button is in the camera window, because judging the image
is the first step of the procedure.
### Reading a scan file
`SrasFile` memory-maps the data block, so opening a multi-gigabyte scan
costs only the pages actually touched:
```python
from core.sras_format import SrasFile
from core.sras_analysis import CH4_IDX, ChannelCalibration, compute_dc_image
with SrasFile("/data/SRAS/demo.sras") as sras:
print(sras.header.n_angles, "angles")
for st in sras.angle_status(): # handles aborted/partial files
print(f" angle {st.index}: {st.n_rows_available}/{st.n_rows} rows ({st.status})")
view = sras.load_angle(0) # (rows, channels, frames, samples)
calib = ChannelCalibration.from_preambles(sras.preambles)
dc_mv = calib.adc_to_mv(compute_dc_image(view, CH4_IDX), CH4_IDX)
```
### Stage control
```python
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
stage = ThorlabsServoDriver()
stage.connect("/dev/ttyUSB0") # raises if no bay responds
stage.enable_axis(AXIS_X)
stage.home_axis(AXIS_X, timeout=120.0)
stage.move_axis_absolute(AXIS_X, 25.0, timeout=30.0)
```
### Oscilloscope acquisition
```python
from core.scope_sras import configure_acquisition, configure_channels
from hardware.tektronix_base import TektronixOscilloscopeBase
scope = TektronixOscilloscopeBase()
scope.connect("192.168.1.100", 4000)
scope.set_acquire_mode("SAMPLE")
waveform = scope.get_curve_binary(1) # Channel 1
scope = TektronixOscilloscopeBase("192.168.100.105", port=4000)
scope.connect()
configure_channels(scope) # standard SRAS front-end setup
samples_per_frame = configure_acquisition(scope)
```
### Laser Control
### Laser control
```python
from hardware.coherent_hops_laser import CoherentHOPSLaser
from hardware.helios_laser import HeliosLaser
laser = CoherentHOPSLaser()
laser.connect()
laser.set_power_level(50.0) # 50% power
laser.enable_output(True)
laser = HeliosLaser()
laser.connect("/dev/ttyUSB1")
laser.set_current_ma(1200)
laser.set_laser_enable(True)
print(laser.get_diode_temp_c(), "°C")
laser.disconnect() # always explicit — no __del__
```
### Camera Control
### Camera control
```python
from hardware.uc480_camera import UC480Camera
from hardware.uc480_camera import UC480Camera, find_camera_bus_conflicts
camera = UC480Camera(camera_id=0)
find_camera_bus_conflicts() # warns about USB bus contention
camera = UC480Camera(camera_id=1)
camera.initialize()
camera.start_capture()
# Camera operations
```
## Configuration
### Stage Settings
Stage configuration is stored in `~/.nuescan/stage_settings.json`:
- Velocity and acceleration profiles
- Trigger configuration
- Axis limits and safety parameters
### Persisted settings
`aui_defaults.json` holds the ports, scope IP, and save directory the main
app last used. It is read and written through `core.config.ScanDefaults`,
which always writes every field — see KNOWN_ISSUES.md history for why
partial writes were a problem.
### Serial Port Configuration
Hardware devices are accessed via:
- **BBD202/203**: USB with automatic serial number detection
- **Helios**: RS-232 serial port (9600 baud, 8N1)
- **HOPS Laser**: FTDI USB (I2C interface)
### Fixed acquisition settings
Scan velocity, laser frequency, sample rate, and the ramp geometry are
constants in `core/scan_engine.py` and `core/scope_sras.py`, not user
settings; a `.sras` file records them so resume can refuse a mismatch.
### Serial port configuration
- **BBD202**: USB serial, APT protocol (`/dev/ttyUSB*`)
- **T3R**: USB serial, custom binary framing (`/dev/ttyACM*`)
- **Helios**: RS-232 (9600 baud, 8N1)
- **Genesis**: USB serial, I2C-over-serial
- **Oscilloscope**: Ethernet/LXI (TCP socket on port 4000)
## Development
+25 -65
View File
@@ -91,11 +91,10 @@ lsusb | grep -i thorlabs
**First-time setup:**
```bash
# Run the stage test application
python stage_test_app.py
python bbd202_test_app.py
# Enter your BBD203 serial number
# Click "Connect" to test the connection
# Use "Home All Axes" to verify operation
# Set the serial port, click Connect (it now fails loudly if no bay
# responds), then Home to verify operation.
```
### Helios Laser System
@@ -132,10 +131,15 @@ python -c "from pyftdi.ftdi import Ftdi; Ftdi.show_devices()"
**First-time setup:**
```bash
# Test laser connection
python -c "from hardware.coherent_hops_laser import CoherentHOPSLaser; laser = CoherentHOPSLaser(); print('Connected:', laser.connect())"
# Test the Genesis laser connection
python tools/genesis_laser_control.py
```
> Before changing any Genesis code, read
> [docs/genesis_verification.md](docs/genesis_verification.md) — the two
> implementations in the repo disagree on ADC scaling, LDD polarity, and
> shutter behaviour, and only the bench can settle it.
### Tektronix Oscilloscope
**Connection:**
@@ -228,65 +232,21 @@ Main window settings (geometry, last used values) are stored in Qt settings:
## Project Structure
```
scanengine-3/
│
├── scanengine/ # Main application package
│ ├── __init__.py
│ ├── app.py # Main application entry point
│ ├── main_launcher.ui # Main launcher UI
│ ├── new_scan_wizard.ui # Scan wizard UI
│ └── options.ui # Options dialog UI
│
├── hardware/ # Hardware driver package
│ ├── __init__.py
│ ├── bbd202.py # ThorLabs stage controller
│ ├── uc480_camera.py # IDS/ThorLabs camera
│ ├── tektronix_base.py # Tektronix oscilloscope
│ ├── coherent_hops_laser.py # Coherent HOPS laser
│ └── genesis_core.py # Genesis laser core logic
│
├── scanning/ # Scan planning package
│ ├── __init__.py
│ ├── sc3_scan_model.py # Scan model
│ └── stage_scan_plan_generator.py # Scan path planning
│
├── tools/ # Standalone executable tools
│ ├── genesis_laser_control.py # Standalone Genesis app
│ └── genesis_laser_gui.py # Alternative Genesis GUI
│
├── tests/ # Test files
│ ├── __init__.py
│ ├── test_camera_integration.py
│ ├── test_genesis_connection.py
│ ├── test_genesis_protocol.py
│ ├── test_rotated_aoi.py
│ └── test_temperature_scaling.py
│
├── docs/ # Documentation
│ ├── hardware/ # Hardware documentation
│ │ ├── BBD203_CONNECTION_GUIDE.md
│ │ ├── BBD203_Communications_Protocol.md
│ │ ├── BBD203_DRIVER_README.md
│ │ ├── HELIOS_DRIVER_README.md
│ │ ├── GENESIS_LASER_README.md
│ │ └── laser_control_implementation_guide.md
│ └── protocols/ # Protocol specifications
│ ├── apt_communications_protocol.pdf
│ ├── helios_comms_protocol.pdf
│ └── thorlabs_mls_protocol.pdf
│
├── lib/ # Binary libraries (not in git)
│ ├── libueye_api64.so.3.82
│ ├── ueye_loader.c
│ └── ueye_loader.so
│
├── config.json # System configuration
├── requirements.txt # Python dependencies
├── README.md # Project overview
├── SETUP.md # This file
└── LICENSE # License file
```
See the tree in [README.md](README.md#project-structure). In short: `core/`
is the headless scan engine and file format (no PyQt6, no vendor SDKs),
`hardware/` holds the Qt-free device drivers, `gui/` the shared PyQt6
adapters and widgets, and the root `*.py` files are the runnable apps.
## Vendored camera SDK (`lib/`)
`lib/` is gitignored, so a fresh clone does not have it. The IDS uEye
runtime (`libueye_api64.so.3.82`) must come from the IDS SDK installation
matching the camera firmware on this rig.
`lib/ueye_loader.{c,so}` is an `LD_PRELOAD` shim that dlopens
`/usr/lib/libueye_api.so` before Python starts. Nothing in the repo
references it and no launcher sets `LD_PRELOAD`, so whether it is still
needed is an open question — see [KNOWN_ISSUES.md](KNOWN_ISSUES.md).
## Troubleshooting
-37
View File
@@ -1,37 +0,0 @@
# ADC YOFF Sign Bug — sras_viewer.py
## Status
Fix applied, awaiting user testing.
## What was wrong
`DC_YOFF_ADC` in `sras_viewer.py` was `+87.04` instead of `-87.04`.
The Tektronix scope stores CH3/CH4 waveform data as **signed int8** (−128 to +127), where ADC 0 = screen center. The scope's vertical position for CH3/CH4 is set to `−2.72 div` in `sc3_aui_app.py`, which places 0 V **below** center at ADC count `−2.72 × 32 = −87.04`. The comment in the code had the formula as `-position × (256/8)` (sign flipped), producing `+87.04` instead of the correct `−87.04`.
## Effect of the bug
- `adc_to_mv` was off by 272 mV in the negative direction
- ADC −87 (true 0 V signal) → −272 mV (should be ≈ 0 mV)
- ADC 0 (screen center, above ground) → −136 mV (should be +136 mV)
- DC images for CH3/CH4 (Bias A/B) showed large negative voltages, physically impossible for DC bias signals
- RF mask threshold (`mv_to_adc`) was also broken: threshold ADC value ~+87 was being compared against pixel means clustered around −87, so nearly every pixel would have been incorrectly masked
## The fix
`sras_viewer.py` line 48:
```python
# Before
DC_YOFF_ADC = 87.04 # ADC count that represents 0 V
# After
DC_YOFF_ADC = -87.04 # ADC count that represents 0 V
```
Comment on line 47 also corrected from `-position × (256/8)` to `position × (256/8)`.
## What to verify during testing
1. CH3 and CH4 DC images show positive (or near-zero) voltages consistent with the bias signal levels
2. RF (CH1) image is not excessively masked — pixels with a genuine bias signal above the threshold should appear
3. `mv_to_adc(0.0)` should now return −87.04 (not +87.04)
4. The default threshold of 0.125 mV should correspond to ADC ≈ −87.0, not +87.1
-615
View File
@@ -1,615 +0,0 @@
#!/usr/bin/env python3
"""
Scanengine 3 Main Application
"""
import sys
import json
from pathlib import Path
from PyQt6 import QtWidgets, QtCore
from typing import Optional
import serial.tools.list_ports
from hardware.coherent_hops_laser import CoherentHOPSLaser, DummyLaser
from hardware.helios_laser import HeliosLaser, PulseMode
from hardware.uc480_camera import UC480Camera, CameraStreamThread
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y, TriggerBitsServo
from hardware.t3r_driver import T3RDriver
from motion_worker import MotionWorker
from scanning.stage_scan_plan_generator import StageScanPlanGenerator
from genesis_worker import GenesisWorker, GenesisCommand
from t3r_control_panel import T3RControlPanel
from ui_mainwindow import Ui_MainWindow
# Page indices in stackedWidget
PAGE_START = 0
PAGE_OPTIONS = 1
PAGE_NEWSCAN = 2
PAGE_CONTINUESCAN = 3
PAGE_SCAN_PROGRESS = 4
CONFIG_PATH = Path(__file__).parent / "config.json"
DEFAULT_CONFIG = {
"stage": {
"serial_port": "",
"trigger": "Disabled",
"scan_velocity_mm_s": 200.0,
"scan_acceleration_mm_s2": 500.0,
"optical_axis_x_mm": 0.0,
"optical_axis_y_mm": 0.0,
},
"fpga": {
"serial_port": "",
"pulse_divider": 1,
"rowpack_enabled": False,
},
"t3r": {
"serial_port": "",
"t_axis_current_ma": 0.0,
"gr_axis_current_ma": 0.0,
"t_axis_microstepping": "Full Step",
"gr_axis_microstepping": "Full Step",
},
"oscilloscope": {
"ip_address": "",
},
"generation_laser": {
"serial_port": "",
"pulse_frequency_hz": 125000,
"diode_pump_current_ma": 0.0,
},
"detection_laser": {
"power_mw": 0.0,
},
"genesis_laser": {
"com_port": "/dev/ttyUSB0",
},
}
# Fixed option lists for combo boxes
TRIGGER_OPTIONS = [
"Disabled",
"Trigger Out: In Motion",
"Trigger Out: Motion Complete",
"Trigger Out: Max Velocity",
"Trigger Out: High at Max Velocity",
]
MICROSTEPPING_OPTIONS = [
"Full Step",
"Half Step",
"1/4 Step",
"1/8 Step",
"1/16 Step",
"1/32 Step",
]
class ScanWorker(QtCore.QObject):
"""Worker object for handling scanning in a separate thread."""
scan_started = QtCore.pyqtSignal()
scan_completed = QtCore.pyqtSignal()
scan_failed = QtCore.pyqtSignal(str)
angle_started = QtCore.pyqtSignal(int, int)
line_started = QtCore.pyqtSignal(int, int, float)
current_progress = QtCore.pyqtSignal(int)
overall_progress = QtCore.pyqtSignal(int)
status_message = QtCore.pyqtSignal(str)
def __init__(self, scan_params, motion_worker, t3r_driver=None):
super().__init__()
self.scan_params = scan_params
self.motion_worker = motion_worker
self.t3r_driver = t3r_driver
self.should_stop = False
@QtCore.pyqtSlot()
def run_scan(self):
"""Execute the full scanning process."""
if self.motion_worker:
self.motion_worker.scanning_active = True
try:
self.scan_started.emit()
num_angles = self.scan_params.get("num_angles", 1)
angle_step = 360.0 / num_angles if num_angles > 1 else 0.0
gr_microsteps = self.scan_params.get("gr_axis_microsteps", 16)
for angle_idx in range(num_angles):
if self.should_stop:
break
self.angle_started.emit(angle_idx, num_angles)
self.status_message.emit(
f"Scanning angle {angle_idx + 1}/{num_angles}")
# TODO: execute scan lines for this angle via motion_worker
if angle_idx < num_angles - 1 and angle_step and self.t3r_driver:
if self.t3r_driver.is_open:
self.status_message.emit(
f"Rotating stage {angle_step:.3f}° for next angle…")
self.t3r_driver.rotate_stage(
angle_step,
gr_microsteps,
self.scan_params.get("rotation_velocity", 8000),
self.scan_params.get("rotation_accel", 4000),
)
# TODO: wait for MOTION_DONE event before proceeding
self.scan_completed.emit()
except Exception as e:
self.scan_failed.emit(str(e))
finally:
if self.motion_worker:
self.motion_worker.scanning_active = False
def stop(self):
self.should_stop = True
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.config = self._load_config()
# Hardware objects
self.motion_worker: Optional[MotionWorker] = None
self.motion_thread: Optional[QtCore.QThread] = None
self.genesis_worker: Optional[GenesisWorker] = None
self.genesis_thread: Optional[QtCore.QThread] = None
self.camera: Optional[UC480Camera] = None
self.camera_stream: Optional[CameraStreamThread] = None
self.vis_laser: Optional[CoherentHOPSLaser] = None
self.ir_laser: Optional[HeliosLaser] = None
self.scan_worker: Optional[ScanWorker] = None
self.scan_thread: Optional[QtCore.QThread] = None
# T3R focusing / rotation driver (lives in main thread; reader runs internally)
self.t3r_driver = T3RDriver(self)
self.t3r_panel: Optional[T3RControlPanel] = None
self._connect_signals()
self._init_genesis_worker()
self._init_t3r_menu()
self.ui.stackedWidget.setCurrentIndex(PAGE_START)
# ------------------------------------------------------------------
# Config
# ------------------------------------------------------------------
def _load_config(self) -> dict:
if CONFIG_PATH.exists():
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
for section, values in DEFAULT_CONFIG.items():
cfg.setdefault(section, {})
for key, val in values.items():
cfg[section].setdefault(key, val)
return cfg
except Exception:
pass
return {k: dict(v) for k, v in DEFAULT_CONFIG.items()}
def _save_config(self):
with open(CONFIG_PATH, "w") as f:
json.dump(self.config, f, indent=2)
# ------------------------------------------------------------------
# Signal wiring
# ------------------------------------------------------------------
def _connect_signals(self):
# Start page
self.ui.start_new_scan_btn.clicked.connect(self._go_to_newscan)
self.ui.resume_scan_btn.clicked.connect(self._go_to_continuescan)
self.ui.edit_options_btn.clicked.connect(self._go_to_options)
# Options page
self.ui.options_save_settings_btn.clicked.connect(self._on_options_save)
self.ui.options_cancel_btn.clicked.connect(self._go_to_start)
self.ui.stage_test_connection_btn.clicked.connect(self._on_test_stage_connection)
self.ui.fpga_connect_button.clicked.connect(self._on_fpga_connect)
self.ui.fpga_refresh_ports_btn.clicked.connect(self._on_fpga_refresh_ports)
self.ui.refresh_serial_ports_btn.clicked.connect(self._on_refresh_serial_ports)
self.ui.scope_connect_btn.clicked.connect(self._on_scope_connect)
self.ui.generation_connect_button.clicked.connect(self._on_generation_connect)
self.ui.detection_test_btn.clicked.connect(self._on_detection_test)
self.ui.t3r_connect_btn.clicked.connect(self._on_t3r_connect)
self.ui.t3r_refresh_ports_btn.clicked.connect(self._on_t3r_refresh_ports)
# New scan page
self.ui.newscan_browse_folders_btn.clicked.connect(self._on_newscan_browse)
self.ui.newscan_set_current_as_start_btn.clicked.connect(self._on_newscan_set_start)
self.ui.newscan_get_delta_from_current_btn.clicked.connect(self._on_newscan_get_delta)
self.ui.newscan_toggle_vis_laser_btn.clicked.connect(self._on_newscan_toggle_vis_laser)
self.ui.newscan_continue_to_next_btn.clicked.connect(self._on_newscan_start_scan)
self.ui.newscan_jog_x_pos_btn.pressed.connect(self._on_jog_x_pos_pressed)
self.ui.newscan_jog_x_pos_btn.released.connect(self._on_jog_stop)
self.ui.newscan_jog_x_neg_btn.pressed.connect(self._on_jog_x_neg_pressed)
self.ui.newscan_jog_x_neg_btn.released.connect(self._on_jog_stop)
self.ui.newscan_jog_y_pos_btn.pressed.connect(self._on_jog_y_pos_pressed)
self.ui.newscan_jog_y_pos_btn.released.connect(self._on_jog_stop)
self.ui.newscan_jog_y_neg_btn.pressed.connect(self._on_jog_y_neg_pressed)
self.ui.newscan_jog_y_neg_btn.released.connect(self._on_jog_stop)
# Continue scan page
self.ui.continuescan_resume_scans.clicked.connect(self._on_resume_scan)
# Scan progress page
self.ui.abort_scan_button.clicked.connect(self._on_abort_scan)
# ------------------------------------------------------------------
# Navigation
# ------------------------------------------------------------------
def _go_to_start(self):
self.ui.stackedWidget.setCurrentIndex(PAGE_START)
def _go_to_options(self):
self._populate_options_page()
self.ui.stackedWidget.setCurrentIndex(PAGE_OPTIONS)
def _go_to_newscan(self):
self._populate_newscan_page()
self.ui.stackedWidget.setCurrentIndex(PAGE_NEWSCAN)
def _go_to_continuescan(self):
self._populate_continuescan_page()
self.ui.stackedWidget.setCurrentIndex(PAGE_CONTINUESCAN)
def _go_to_scan_progress(self):
self.ui.stackedWidget.setCurrentIndex(PAGE_SCAN_PROGRESS)
# ------------------------------------------------------------------
# Options page
# ------------------------------------------------------------------
def _get_serial_ports(self) -> list[str]:
return sorted(p.device for p in serial.tools.list_ports.comports())
def _populate_combo(self, combo: QtWidgets.QComboBox, items: list[str], current: str):
"""Refill a combo box, re-selecting `current` if present."""
combo.blockSignals(True)
combo.clear()
combo.addItems(items)
idx = combo.findText(current)
if idx >= 0:
combo.setCurrentIndex(idx)
elif current:
combo.insertItem(0, current)
combo.setCurrentIndex(0)
combo.blockSignals(False)
def _populate_options_page(self):
cfg = self.config
ports = self._get_serial_ports()
# ---- Kinematics tab ----
self.ui.scan_velocity_edit.setText(str(cfg["stage"]["scan_velocity_mm_s"]))
self.ui.scan_accel_edit.setText(str(cfg["stage"]["scan_acceleration_mm_s2"]))
self.ui.optical_axis_x_edit.setText(str(cfg["stage"]["optical_axis_x_mm"]))
self.ui.optical_axis_y_edit.setText(str(cfg["stage"]["optical_axis_y_mm"]))
self.ui.stage_serial_edit.setText(cfg["stage"]["serial_port"])
self._populate_combo(self.ui.stage_trigger_combo, TRIGGER_OPTIONS, cfg["stage"]["trigger"])
# ---- Detection / VIS tab ----
self.ui.detection_power_edit.setText(str(cfg["detection_laser"]["power_mw"]))
# ---- Generation / IR tab ----
self._populate_combo(self.ui.comboBox, ports, cfg["generation_laser"]["serial_port"])
self.ui.generation_pulse_freq_edit.setText(str(cfg["generation_laser"]["pulse_frequency_hz"]))
self.ui.diode_pump_current_edit.setText(str(cfg["generation_laser"]["diode_pump_current_ma"]))
# ---- PulseDecimator tab ----
self._populate_combo(self.ui.fpga_serial_port, ports, cfg["fpga"]["serial_port"])
self.ui.fpga_divider_value_edit.setText(str(cfg["fpga"]["pulse_divider"]))
self.ui.checkBox.setChecked(cfg["fpga"]["rowpack_enabled"])
# ---- T3R-SL tab ----
self._populate_combo(self.ui.t3r_serial_port_edit, ports, cfg["t3r"]["serial_port"])
self.ui.lineEdit.setText(str(cfg["t3r"]["t_axis_current_ma"]))
self.ui.lineEdit_2.setText(str(cfg["t3r"]["gr_axis_current_ma"]))
self._populate_combo(self.ui.comboBox_2, MICROSTEPPING_OPTIONS, cfg["t3r"]["t_axis_microstepping"])
self._populate_combo(self.ui.comboBox_3, MICROSTEPPING_OPTIONS, cfg["t3r"]["gr_axis_microstepping"])
# ---- Oscilloscope tab ----
self.ui.scope_ip_address_edit.setText(cfg["oscilloscope"]["ip_address"])
def _on_options_save(self):
try:
# Kinematics
self.config["stage"]["scan_velocity_mm_s"] = float(self.ui.scan_velocity_edit.text())
self.config["stage"]["scan_acceleration_mm_s2"] = float(self.ui.scan_accel_edit.text())
self.config["stage"]["optical_axis_x_mm"] = float(self.ui.optical_axis_x_edit.text())
self.config["stage"]["optical_axis_y_mm"] = float(self.ui.optical_axis_y_edit.text())
self.config["stage"]["serial_port"] = self.ui.stage_serial_edit.text().strip()
self.config["stage"]["trigger"] = self.ui.stage_trigger_combo.currentText()
# Detection / VIS
self.config["detection_laser"]["power_mw"] = float(self.ui.detection_power_edit.text())
# Generation / IR
self.config["generation_laser"]["serial_port"] = self.ui.comboBox.currentText()
self.config["generation_laser"]["pulse_frequency_hz"] = int(self.ui.generation_pulse_freq_edit.text())
self.config["generation_laser"]["diode_pump_current_ma"] = float(self.ui.diode_pump_current_edit.text())
# PulseDecimator
self.config["fpga"]["serial_port"] = self.ui.fpga_serial_port.currentText()
self.config["fpga"]["pulse_divider"] = int(self.ui.fpga_divider_value_edit.text())
self.config["fpga"]["rowpack_enabled"] = self.ui.checkBox.isChecked()
# T3R-SL
self.config["t3r"]["serial_port"] = self.ui.t3r_serial_port_edit.currentText()
self.config["t3r"]["t_axis_current_ma"] = float(self.ui.lineEdit.text())
self.config["t3r"]["gr_axis_current_ma"] = float(self.ui.lineEdit_2.text())
self.config["t3r"]["t_axis_microstepping"] = self.ui.comboBox_2.currentText()
self.config["t3r"]["gr_axis_microstepping"] = self.ui.comboBox_3.currentText()
# Oscilloscope
self.config["oscilloscope"]["ip_address"] = self.ui.scope_ip_address_edit.text().strip()
except ValueError as e:
QtWidgets.QMessageBox.warning(self, "Invalid input", str(e))
return
self._save_config()
self._go_to_start()
def _refresh_serial_ports_for_combos(self, *combos: QtWidgets.QComboBox):
"""Re-populate serial port combos, preserving current selections."""
ports = self._get_serial_ports()
for combo in combos:
self._populate_combo(combo, ports, combo.currentText())
def _on_refresh_serial_ports(self):
self._refresh_serial_ports_for_combos(self.ui.comboBox)
def _on_fpga_refresh_ports(self):
self._refresh_serial_ports_for_combos(self.ui.fpga_serial_port)
def _on_t3r_refresh_ports(self):
self._refresh_serial_ports_for_combos(self.ui.t3r_serial_port_edit)
def _on_test_stage_connection(self):
pass # TODO
def _on_fpga_connect(self):
pass # TODO
def _on_scope_connect(self):
pass # TODO
def _on_generation_connect(self):
pass # TODO
def _on_detection_test(self):
pass # TODO
def _on_t3r_connect(self):
port = self.ui.t3r_serial_port_edit.currentText().split(" ")[0]
self._show_t3r_panel()
if port and not self.t3r_driver.is_open:
try:
self.t3r_driver.connect(port)
except Exception as exc:
QtWidgets.QMessageBox.warning(self, "T3R Connect", str(exc))
# ------------------------------------------------------------------
# New scan page
# ------------------------------------------------------------------
def _populate_newscan_page(self):
self.ui.newscan_save_directory_edit.setText(str(Path.home() / "scans"))
def _on_newscan_browse(self):
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select save directory")
if directory:
self.ui.newscan_save_directory_edit.setText(directory)
def _on_newscan_set_start(self):
pass # TODO: capture current stage position as scan start
def _on_newscan_get_delta(self):
pass # TODO: capture current stage position as scan end (compute delta)
def _on_newscan_toggle_vis_laser(self):
pass # TODO: toggle vis laser on/off
def _on_newscan_start_scan(self):
scan_params = self._build_scan_params()
if scan_params is None:
return
self._start_scan(scan_params)
def _build_scan_params(self) -> Optional[dict]:
"""Read newscan page widgets and return scan parameter dict, or None on error."""
try:
x_start = float(self.ui.newscan_start_x_coord_edit.text())
y_start = float(self.ui.newscan_start_y_coord_edit.text())
x_delta = float(self.ui.newscan_delta_x_coord_edit.text())
y_delta = float(self.ui.newscan_delta_y_coord_edit.text())
except ValueError:
QtWidgets.QMessageBox.warning(self, "Invalid input", "Scan coordinates must be numbers.")
return None
pixel_size_map = {
self.ui.newscan_50_micron_radio: 0.05,
self.ui.newscan_100_micron_radio: 0.10,
self.ui.newscan_250_micron_radio: 0.25,
}
row_spacing = next(
(v for btn, v in pixel_size_map.items() if btn.isChecked()), 0.10
)
return {
"x_start_mm": x_start,
"y_start_mm": y_start,
"x_delta_mm": x_delta,
"y_delta_mm": y_delta,
"row_spacing_mm": row_spacing,
"num_angles": int(self.ui.newscan_num_angles_combo.currentText()),
"friendly_name": self.ui.newscan_friendly_name_edit.text(),
"file_prefix": self.ui.newcsan_file_prefix_edit.text(),
"save_directory": self.ui.newscan_save_directory_edit.text(),
"scan_velocity_mm_s": self.config["stage"]["scan_velocity_mm_s"],
"scan_acceleration_mm_s2": self.config["stage"]["scan_acceleration_mm_s2"],
# T3R rotation between angles (GR-axis, ch1)
"gr_axis_microsteps": 16,
"rotation_velocity": 8000,
"rotation_accel": 4000,
}
# ------------------------------------------------------------------
# Jog controls
# ------------------------------------------------------------------
def _on_jog_x_pos_pressed(self):
pass # TODO
def _on_jog_x_neg_pressed(self):
pass # TODO
def _on_jog_y_pos_pressed(self):
pass # TODO
def _on_jog_y_neg_pressed(self):
pass # TODO
def _on_jog_stop(self):
pass # TODO
# ------------------------------------------------------------------
# Continue scan page
# ------------------------------------------------------------------
def _populate_continuescan_page(self):
pass # TODO: populate list of interrupted scans
def _on_resume_scan(self):
pass # TODO: resume selected scan
# ------------------------------------------------------------------
# Scan execution
# ------------------------------------------------------------------
def _start_scan(self, scan_params: dict):
self.scan_thread = QtCore.QThread()
self.scan_worker = ScanWorker(scan_params, self.motion_worker, self.t3r_driver)
self.scan_worker.moveToThread(self.scan_thread)
self.scan_thread.started.connect(self.scan_worker.run_scan)
self.scan_worker.scan_started.connect(self._on_scan_started)
self.scan_worker.scan_completed.connect(self._on_scan_completed)
self.scan_worker.scan_failed.connect(self._on_scan_failed)
self.scan_worker.current_progress.connect(self.ui.scanning_scan_progbar.setValue)
self.scan_worker.overall_progress.connect(self.ui.scanning_overall_progbar.setValue)
self.scan_worker.status_message.connect(self.ui.scanning_stage_state_label.setText)
self._go_to_scan_progress()
self.scan_thread.start()
@QtCore.pyqtSlot()
def _on_scan_started(self):
self.ui.abort_scan_button.setEnabled(True)
@QtCore.pyqtSlot()
def _on_scan_completed(self):
self._cleanup_scan_thread()
QtWidgets.QMessageBox.information(self, "Scan complete", "Scan finished successfully.")
self._go_to_start()
@QtCore.pyqtSlot(str)
def _on_scan_failed(self, error: str):
self._cleanup_scan_thread()
QtWidgets.QMessageBox.critical(self, "Scan failed", error)
self._go_to_start()
def _on_abort_scan(self):
if self.scan_worker:
self.scan_worker.stop()
def _cleanup_scan_thread(self):
if self.scan_thread:
self.scan_thread.quit()
self.scan_thread.wait()
self.scan_thread = None
self.scan_worker = None
# ------------------------------------------------------------------
# T3R focusing / rotation panel
# ------------------------------------------------------------------
def _init_t3r_menu(self):
"""Add a Hardware menu with a T3R panel toggle action."""
hw_menu = self.menuBar().addMenu("Hardware")
self._t3r_action = hw_menu.addAction("T3R Focusing && Rotation…")
self._t3r_action.setCheckable(True)
self._t3r_action.setShortcut("Ctrl+T")
self._t3r_action.triggered.connect(self._on_t3r_action_toggled)
def _show_t3r_panel(self):
if self.t3r_panel is None:
self.t3r_panel = T3RControlPanel(self.t3r_driver, self)
self.t3r_panel.finished.connect(
lambda: self._t3r_action.setChecked(False))
self.t3r_panel.show()
self.t3r_panel.raise_()
self._t3r_action.setChecked(True)
def _on_t3r_action_toggled(self, checked: bool):
if checked:
self._show_t3r_panel()
elif self.t3r_panel is not None:
self.t3r_panel.hide()
# ------------------------------------------------------------------
# Genesis laser worker
# ------------------------------------------------------------------
def _init_genesis_worker(self):
com_port = self.config.get("genesis_laser", {}).get("com_port", "/dev/ttyUSB0")
self.genesis_worker = GenesisWorker(com_port)
self.genesis_thread = QtCore.QThread()
self.genesis_worker.moveToThread(self.genesis_thread)
self.genesis_thread.started.connect(self.genesis_worker.run)
self.genesis_thread.start()
def _cleanup_genesis_worker(self):
if self.genesis_worker:
self.genesis_worker.stop()
if self.genesis_thread:
self.genesis_thread.quit()
self.genesis_thread.wait()
self.genesis_worker = None
self.genesis_thread = None
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def closeEvent(self, event):
if self.t3r_driver.is_open:
self.t3r_driver.disconnect()
self._cleanup_genesis_worker()
self._cleanup_scan_thread()
if self.motion_thread:
self.motion_thread.quit()
self.motion_thread.wait()
super().closeEvent(event)
def main():
app = QtWidgets.QApplication(sys.argv)
qss_path = Path(__file__).parent / "app_style.qss"
if qss_path.exists():
app.setStyleSheet(qss_path.read_text())
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
View File
+2 -2
View File
@@ -12,10 +12,10 @@ import time
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QDoubleSpinBox,
QStatusBar, QMessageBox, QGridLayout, QCheckBox, QFrame
QStatusBar, QMessageBox, QGridLayout
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject, QTimer
from PyQt6.QtGui import QFont, QKeySequence, QShortcut
from PyQt6.QtGui import QFont
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y
from hardware.pybbd202.apt_constants import TriggerBitsServo
+2 -2
View File
@@ -10,9 +10,9 @@ import logging
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGroupBox, QLabel, QPushButton, QDoubleSpinBox, QSpinBox,
QStatusBar, QSizePolicy
QSizePolicy
)
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QImage
from hardware.uc480_camera import UC480Camera, CameraStreamThread
-31
View File
@@ -1,31 +0,0 @@
{
"genesis_laser": {
"com_port": "/dev/ttyUSB0"
},
"detection_laser": {
"scan_power_mw": "125"
},
"generation_laser": {
"com_port": "/dev/ttyACM0",
"frequency_hz": "20000",
"pump_diode_current_ma": "750",
"focusing_frequency_hz": "20000",
"focusing_pump_current_ma": "300"
},
"scanning_stage": {
"scan_velocity_mm_s": "200",
"scan_acceleration_mm_s2": "1500",
"x_trigger_mode": 6,
"y_trigger_mode": 0,
"optical_axis_x_mm": "55",
"optical_axis_y_mm": "37.5"
},
"t3r": {
"com_port": "/dev/ttyUSB0"
},
"oscilloscope": {
"socket_address": "192.168.0.1",
"scratch_directory": "/opt/",
"save_location": "pc"
}
}
+1
View File
@@ -0,0 +1 @@
"""Headless scan-engine core: importable without PyQt6 or any vendor SDK."""
+251
View File
@@ -0,0 +1,251 @@
"""Pre-scan angle inspection: park the rig on a point and let the operator look.
A multi-angle scan can take hours, and an angle that responds poorly produces
rows that look fine in the file but carry no usable SAW packet. This drives
the rig through the same angles the scan will use, parking at a random point
inside each angle's own bounding box so the response can be judged on the
oscilloscope before committing to the run.
Headless and Qt-free, like ScanEngine: gui/inspect_bridge.py wraps it.
No waveform ever crosses this boundary. The operator reads the scope screen
directly; this module's job is only to put the hardware in the right place and
the scope in a state worth looking at (see core.scope_inspect).
"""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass
from typing import Callable
from core import scope_inspect
from core.rotation import RotationAxis
from core.scan_engine import (
AXIS_X, AXIS_Y, SCAN_ACCEL_MM_S2, SCAN_VELOCITY_MM_S,
)
from core.scan_geometry import DEFAULT_STAGE_LIMITS, ScanPlan, StageLimits
logger = logging.getLogger(__name__)
# Positioning moves only — no data is taken while moving, so there is no
# reason to cross the tray at full scan velocity.
INSPECT_VELOCITY_MM_S = SCAN_VELOCITY_MM_S / 2.0
@dataclass(frozen=True)
class InspectionPoint:
"""Where the rig is parked, and which angle it is parked for."""
angle_idx: int
angle_deg: float
x_mm: float
y_mm: float
def describe(self) -> str:
return (f"Angle {self.angle_idx + 1} ({self.angle_deg:.1f}°) "
f"X={self.x_mm:.3f} mm Y={self.y_mm:.3f} mm")
@dataclass
class InspectCallbacks:
"""Progress reporting. Defaults are no-ops so the core needs no front end."""
on_status: Callable[[str], None] = lambda msg: None
on_point: Callable[[InspectionPoint], None] = lambda pt: None
on_busy: Callable[[bool], None] = lambda busy: None
@dataclass
class _State:
angle_idx: int = 0
point: InspectionPoint | None = None
started: bool = False
rotator_ready: bool = False
class AngleInspector:
"""Drives stage + rotator to inspection points across a plan's angles."""
def __init__(self, stage, scope, rotator: RotationAxis | None,
plan: ScanPlan,
callbacks: InspectCallbacks | None = None,
limits: StageLimits = DEFAULT_STAGE_LIMITS,
rng: random.Random | None = None):
self._stage = stage
self._scope = scope
self._rotator = rotator
self._plan = plan
self._cb = callbacks if callbacks is not None else InspectCallbacks()
self._limits = limits
# Injectable so tests can pin the point selection.
self._rng = rng if rng is not None else random.Random()
self._st = _State()
# ── Introspection ─────────────────────────────────────────────────────────
@property
def n_angles(self) -> int:
return self._plan.n_angles
@property
def angle_idx(self) -> int:
return self._st.angle_idx
@property
def current_point(self) -> InspectionPoint | None:
return self._st.point
def angle_labels(self) -> list[str]:
return [f"Angle {i + 1}/{self.n_angles} — {pa.angle_deg:.2f}°"
for i, pa in enumerate(self._plan.per_angle)]
# ── Lifecycle ─────────────────────────────────────────────────────────────
def start(self) -> InspectionPoint:
"""Configure the hardware and park on the first angle."""
if self._stage is None:
raise RuntimeError("BBD202 not connected")
if self._scope is None:
raise RuntimeError("Oscilloscope not connected")
self._st.rotator_ready = (self._rotator is not None
and self._rotator.is_available)
if self.n_angles > 1 and not self._st.rotator_ready:
raise RuntimeError(
f"Inspecting {self.n_angles} angles requires the T3R rotation "
"stage (GR-axis), but it is not connected. Connect T3R from "
"the T3R panel, or inspect a single-angle plan."
)
self._cb.on_busy(True)
try:
self._cb.on_status("Configuring stage for inspection …")
ctrl = self._stage
for axis in (AXIS_X, AXIS_Y):
ctrl.set_velocity_params(axis,
max_velocity=INSPECT_VELOCITY_MM_S,
acceleration=SCAN_ACCEL_MM_S2)
# Nothing here is gated, and an armed trigger output would keep
# driving the gate line on every positioning move.
ctrl.set_trigger_gate_off(AXIS_X)
if self._st.rotator_ready:
self._cb.on_status("Configuring GR axis …")
self._rotator.configure()
self._cb.on_status("Configuring oscilloscope for inspection …")
scope_inspect.configure_inspection(self._scope)
self._st.started = True
return self._goto(0, new_point=True)
finally:
self._cb.on_busy(False)
def stop(self) -> None:
"""Stop the sweep and send the rotator home. Safe to call twice."""
if not self._st.started:
return
self._st.started = False
self._cb.on_busy(True)
try:
try:
scope_inspect.stop_inspection(self._scope)
except Exception:
logger.exception("Could not stop the inspection acquisition")
if self._st.rotator_ready and abs(self._rotator.current_deg) > 0.001:
self._cb.on_status("Returning GR to home …")
try:
self._rotator.return_to_zero()
except Exception:
logger.exception("GR return-to-home failed")
self._cb.on_status("Inspection finished.")
finally:
self._cb.on_busy(False)
# ── Navigation ────────────────────────────────────────────────────────────
def goto_angle(self, angle_idx: int) -> InspectionPoint:
"""Rotate to `angle_idx` and park on a fresh random point there."""
self._require_started()
self._cb.on_busy(True)
try:
return self._goto(angle_idx, new_point=True)
finally:
self._cb.on_busy(False)
def next_angle(self) -> InspectionPoint:
"""Advance one angle, wrapping at the end."""
return self.goto_angle((self._st.angle_idx + 1) % self.n_angles)
def prev_angle(self) -> InspectionPoint:
return self.goto_angle((self._st.angle_idx - 1) % self.n_angles)
def new_point(self) -> InspectionPoint:
"""Re-roll the point within the current angle, without rotating.
One point can be unrepresentative — a bad spot on the sample looks the
same as a bad angle. Re-rolling a few times is how you tell them
apart, so this deliberately skips the rotation.
"""
self._require_started()
self._cb.on_busy(True)
try:
return self._goto(self._st.angle_idx, new_point=True, rotate=False)
finally:
self._cb.on_busy(False)
# ── Internals ─────────────────────────────────────────────────────────────
def _require_started(self):
if not self._st.started:
raise RuntimeError("Inspection has not been started")
def _goto(self, angle_idx: int, new_point: bool,
rotate: bool = True) -> InspectionPoint:
if not 0 <= angle_idx < self.n_angles:
raise IndexError(
f"Angle {angle_idx} out of range (plan has {self.n_angles})")
pa = self._plan.per_angle[angle_idx]
self._st.angle_idx = angle_idx
if rotate and self._st.rotator_ready:
delta = pa.angle_deg - self._rotator.current_deg
if abs(delta) > 0.001:
self._cb.on_status(
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
self._rotator.rotate_to(pa.angle_deg)
point = self._pick_point(angle_idx) if new_point else self._st.point
self._cb.on_status(f"Moving to {point.describe()} …")
# Y first, then X — the same order the scan uses to reach a row.
self._stage.move_axis_absolute(AXIS_Y, point.y_mm, timeout=60.0)
self._stage.move_axis_absolute(AXIS_X, point.x_mm, timeout=60.0)
self._st.point = point
self._cb.on_point(point)
self._cb.on_status(f"Parked at {point.describe()}")
return point
def _pick_point(self, angle_idx: int) -> InspectionPoint:
"""A random point on this angle's scan grid.
Y is drawn from the angle's actual row positions and X uniformly from
its data window, so the point is somewhere the scan would really
sample — not merely inside the bounding box.
"""
pa = self._plan.per_angle[angle_idx]
if not pa.y_positions:
raise ValueError(f"Angle {angle_idx + 1} has no rows to inspect")
y = self._rng.choice(pa.y_positions)
x = self._rng.uniform(pa.x_start, pa.x_start + pa.x_delta)
lim = self._limits
if not (lim.x_min <= x <= lim.x_max and lim.y_min <= y <= lim.y_max):
raise ValueError(
f"Inspection point X={x:.3f} Y={y:.3f} is outside the stage "
f"travel ({lim.x_min}–{lim.x_max} × {lim.y_min}–{lim.y_max} mm)"
)
return InspectionPoint(angle_idx=angle_idx, angle_deg=pa.angle_deg,
x_mm=x, y_mm=y)
+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")
+49
View File
@@ -0,0 +1,49 @@
"""Persisted user defaults (ports, scope IP, save directory).
One flat JSON file, one dataclass. ``save()`` always writes every field, so
a partial UI update can never silently drop another field's saved value
(which is exactly what the old dict-based writer did to helios_port).
"""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass, fields
from pathlib import Path
logger = logging.getLogger(__name__)
DEFAULTS_PATH = Path(__file__).resolve().parent.parent / "aui_defaults.json"
@dataclass
class ScanDefaults:
t3r_port: str = "/dev/ttyUSB0"
bbd_port: str = "/dev/ttyUSB1"
oscope_ip: str = "192.168.0.1"
save_dir: str = str(DEFAULTS_PATH.parent / "scans")
helios_port: str = "/dev/ttyUSB2"
burst_mode: bool = False
strict_rows: bool = False
@classmethod
def load(cls, path: Path = DEFAULTS_PATH) -> "ScanDefaults":
"""Load defaults, tolerating a missing/corrupt file and unknown keys."""
if path.exists():
try:
with open(path) as f:
data = json.load(f)
known = {f.name for f in fields(cls)}
return cls(**{k: v for k, v in data.items() if k in known})
except (OSError, ValueError, TypeError) as e:
logger.warning("Could not load %s (%s); using fallback defaults", path, e)
inst = cls()
inst.save(path)
return inst
def save(self, path: Path = DEFAULTS_PATH) -> None:
try:
with open(path, "w") as f:
json.dump(asdict(self), f, indent=2)
except OSError as e:
logger.warning("Could not save defaults to %s: %s", path, e)
+87
View File
@@ -0,0 +1,87 @@
"""GR rotation axis: the T3R configuration and move policy for scanning.
Qt-free façade over hardware.t3r_driver.T3RDriver that owns the drive
settings the scan depends on, so the engine never re-derives them.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RotationSettings:
"""Drive settings for the GR axis during a scan."""
microsteps: int = 8 # microsteps/full-step on the GR axis (ch3)
velocity: int = 4000 # steps/s for inter-angle moves
accel: int = 2000 # steps/s² for inter-angle moves
run_current_ma: int = 1200 # drive current while moving
hold_current_ma: int = 400 # standstill current
ihold_delay: int = 6 # run→hold current ramp delay (TMC IHOLDDELAY units)
# The sample rotates CW instead of CCW to clear wiring and avoid a stall.
rotation_sign: int = -1
DEFAULT_ROTATION = RotationSettings()
class RotationAxis:
"""Blocking rotation control for the GR axis.
``configure()`` must run before any move: ``steps_for_angle()`` assumes
the configured microstep setting, so the device has to be told to match
rather than trusting whatever the T3R panel or firmware default left it
at.
"""
def __init__(self, driver, settings: RotationSettings = DEFAULT_ROTATION):
self.driver = driver
self.settings = settings
self._current_deg = 0.0
@property
def is_available(self) -> bool:
return self.driver is not None and self.driver.is_open
@property
def current_deg(self) -> float:
return self._current_deg
def configure(self) -> None:
s = self.settings
ch = self.driver.GR_AXIS_CH
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 estimate_move_secs(self, delta_deg: float) -> float:
"""Trapezoidal move time: cruise + accel/decel ramps."""
s = self.settings
steps = abs(self.driver.steps_for_angle(delta_deg, s.microsteps))
return steps / s.velocity + s.velocity / s.accel
def rotate_to(self, angle_deg: float, timeout_margin_s: float = 5.0) -> float:
"""Rotate to an absolute angle and block until the move completes.
Returns the estimated move time (for status reporting). Waits on the
driver's MOTION_DONE event rather than sleeping for a guessed
duration; falls back to the estimate only if the event never arrives.
"""
delta_deg = angle_deg - self._current_deg
if abs(delta_deg) <= 0.001:
return 0.0
s = self.settings
est_secs = self.estimate_move_secs(delta_deg)
self.driver.rotate_stage(delta_deg, s.microsteps, s.velocity, s.accel)
if not self.driver.wait_motion_done(self.driver.GR_AXIS_CH,
est_secs + timeout_margin_s):
logger.warning(
"GR axis did not report MOTION_DONE within %.1f s for a "
"%.1f° move; continuing", est_secs + timeout_margin_s, delta_deg)
self._current_deg = angle_deg
return est_secs
def return_to_zero(self) -> float:
return self.rotate_to(0.0)
+262
View File
@@ -0,0 +1,262 @@
"""Middle-row SAW quality check: acquire one row per angle, then read the
alignment off the frequencies it produces.
Two halves of one test mode, kept together because neither is much use
without the other:
*Acquisition* — ``middle_row_plan`` reduces a full ScanPlan to a single row
per angle, the row-wise middle of the ROI. ScanEngine runs the result
exactly like any other scan and writes it as a v11 .sras file
(``sras_format.VERSION_SAW_CHECK``), so a check costs one row-time per angle
instead of the hours a full multi-angle scan takes.
*Analysis* — ``frequency_traces`` turns such a file back into one peak-SAW-
frequency trace per angle, and ``alignment_summary`` reduces those to the
numbers the operator is actually asking about. Both are Qt-free; the plotting
lives in saw_check_viewer.py.
Why the middle row answers an alignment question: ``scan_geometry.build_plan``
centres every angle's rotated bounding box on the same nominal ROI centre, so
each angle's middle row crosses that one point on the sample. Every angle
therefore measures the same material, and a spread in the per-angle
frequencies is a property of the rig (or of a genuinely anisotropic sample),
not of where each row happened to land.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
import numpy as np
from core.scan_geometry import ScanGeometryError, ScanPlan
from core.sras_analysis import ChannelCalibration, compute_rf_image
from core.sras_format import SrasFile
# Rules of thumb for the read-out, not physics. A well-aligned rig on an
# isotropic sample reads the same frequency at every angle, so the spread of
# the per-angle medians is the alignment signal — but an anisotropic sample
# genuinely varies with angle, so a wide spread is a prompt to look at the
# curves, never a verdict on its own.
SPREAD_GOOD_PCT = 1.0
SPREAD_MARGINAL_PCT = 3.0
# Below this fraction of unmasked pixels a trace is too sparse to read.
VALID_FRACTION_FLOOR = 0.5
# ── Acquisition side ─────────────────────────────────────────────────────────
def middle_row_plan(plan: ScanPlan) -> ScanPlan:
"""Reduce a scan plan to its row-wise middle row at every angle.
Each angle keeps the geometry the full scan would have used — same
x_start, x_delta and n_frames from its own rotated bounding box — and
scans only the middle entry of its row list, so the check samples exactly
what the scan would along that row.
An even row count has no exact middle; the upper of the two central rows
is taken (``n_rows // 2``), which is also the row the viewer picks when it
reads the middle row out of a full scan.
"""
if plan.n_angles == 0:
raise ScanGeometryError("Cannot build a SAW check from a plan with no angles")
per_angle = []
for pa in plan.per_angle:
if not pa.y_positions:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}° has no rows, so it has no middle row to check"
)
per_angle.append(replace(pa, n_rows=1,
y_positions=[pa.y_positions[middle_row_index(pa.n_rows)]]))
return replace(plan, per_angle=per_angle)
def middle_row_index(n_rows: int) -> int:
"""The row this check calls the middle one. One rule, two callers."""
return max(0, n_rows // 2)
# ── Analysis side ────────────────────────────────────────────────────────────
@dataclass
class AngleTrace:
"""One angle's peak SAW frequency along its middle row.
``freq_mhz`` is NaN wherever the pixel was masked out (CH4 DC below the
threshold), so the gaps stay gaps instead of reading as 0 MHz.
"""
angle_idx: int
angle_deg: float
row_idx: int
y_mm: float
x_mm: np.ndarray # absolute stage X of each frame
freq_mhz: np.ndarray # NaN where masked
_valid: np.ndarray = field(init=False, repr=False)
def __post_init__(self):
self._valid = np.isfinite(self.freq_mhz)
@property
def offset_mm(self) -> np.ndarray:
"""X relative to the centre of this row.
Every angle's row is centred on the same ROI centre, so plotting
against this puts all the angles' curves over the same piece of
sample — which is the whole point of the comparison.
"""
if len(self.x_mm) == 0:
return self.x_mm
return self.x_mm - 0.5 * (self.x_mm[0] + self.x_mm[-1])
@property
def n_valid(self) -> int:
return int(self._valid.sum())
@property
def valid_fraction(self) -> float:
return self.n_valid / len(self.freq_mhz) if len(self.freq_mhz) else 0.0
@property
def median_mhz(self) -> float:
return float(np.median(self.freq_mhz[self._valid])) if self.n_valid else float("nan")
@property
def std_mhz(self) -> float:
return float(np.std(self.freq_mhz[self._valid])) if self.n_valid > 1 else float("nan")
@property
def drift_mhz_per_mm(self) -> float:
"""Least-squares slope of frequency along the row.
A flat trace means the response did not change across the ROI; a
sloped one is the signature of a tilt or a defocus the angle spread
alone would not show.
"""
if self.n_valid < 2:
return float("nan")
x = self.offset_mm[self._valid]
if np.ptp(x) == 0:
return float("nan")
return float(np.polyfit(x, self.freq_mhz[self._valid], 1)[0])
@dataclass
class AlignmentSummary:
"""What the per-angle traces say about the alignment, in scalars."""
n_angles: int
median_mhz: float
spread_mhz: float # max − min of the per-angle medians
spread_pct: float # that spread as a % of the overall median
best_angle_deg: float # angle reading the highest median
worst_angle_deg: float # angle reading the lowest median
worst_drift_mhz_per_mm: float
worst_drift_angle_deg: float
min_valid_fraction: float
@property
def level(self) -> str:
""""good" / "marginal" / "poor" — see the module's threshold note."""
if self.n_angles == 0 or not np.isfinite(self.spread_pct):
return "poor"
if self.min_valid_fraction < VALID_FRACTION_FLOOR:
return "poor"
if self.spread_pct <= SPREAD_GOOD_PCT:
return "good"
if self.spread_pct <= SPREAD_MARGINAL_PCT:
return "marginal"
return "poor"
def describe(self) -> str:
if self.n_angles == 0:
return "No angle produced a usable frequency trace."
if self.min_valid_fraction < VALID_FRACTION_FLOOR:
return (f"Only {self.min_valid_fraction * 100:.0f} % of the worst angle's row "
f"is above the DC threshold — check the detection beam and the "
f"threshold before reading the spread.")
return (f"Per-angle medians span {self.spread_mhz:.3f} MHz "
f"({self.spread_pct:.2f} % of {self.median_mhz:.3f} MHz), "
f"lowest at {self.worst_angle_deg:.1f}°, highest at {self.best_angle_deg:.1f}°. "
f"Largest drift along a row: {self.worst_drift_mhz_per_mm:+.3f} MHz/mm "
f"at {self.worst_drift_angle_deg:.1f}°.")
def frequency_traces(sras: SrasFile, *, dc_threshold_mv: float = 0.0,
subtract_background: bool = False,
gate_start_ns: float | None = None,
gate_end_ns: float | None = None,
calib: ChannelCalibration | None = None,
on_progress=lambda done, total: None) -> list[AngleTrace]:
"""Peak SAW frequency along the middle row of every angle in ``sras``.
Works on a v11 check (one row per angle, so the middle row is the only
row) and on a full scan alike — the same middle row the check would
have acquired is pulled out of the scan, which is what lets a finished
scan be re-examined with the check's own read-out.
``subtract_background`` takes each angle's own background out of its
frames (v6/v10 files have only the one, which every angle then shares).
Doing it per angle is the point of the per-angle capture: comparing
angles is exactly what this read-out is for, so they must not be
referenced against one background taken at whichever angle came first.
Angles with nothing on disk (an aborted file) are skipped rather than
reported as flat zero.
"""
calib = calib if calib is not None else ChannelCalibration.from_preambles(sras.preambles)
freq_axis = sras.freq_axis_mhz(sras.header.samples_per_frame)
time_axis = sras.time_axis_ns()
statuses = sras.angle_status()
traces: list[AngleTrace] = []
for st in statuses:
on_progress(st.index, len(statuses))
if st.n_rows_available < 1:
continue
pa = sras.per_angle[st.index]
row = middle_row_index(st.n_rows_available)
view = sras.load_angle(st.index, n_rows=st.n_rows_available)[row:row + 1]
background = sras.background_array(st.index) if subtract_background else None
img = compute_rf_image(view, calib, freq_axis, dc_threshold_mv,
background=background,
gate_start_ns=gate_start_ns, gate_end_ns=gate_end_ns,
time_axis_ns=time_axis)
# compute_rf_image zeroes masked pixels and its FFT never peaks in the
# suppressed DC bin, so 0 MHz means "no reading" and nothing else.
freq = img[0].astype(np.float64)
freq[freq <= 0.0] = np.nan
traces.append(AngleTrace(
angle_idx=st.index, angle_deg=pa.angle_deg, row_idx=row,
y_mm=pa.y_positions[row] if row < len(pa.y_positions) else float("nan"),
x_mm=sras.x_axis_mm(st.index), freq_mhz=freq,
))
on_progress(len(statuses), len(statuses))
return traces
def alignment_summary(traces: list[AngleTrace]) -> AlignmentSummary:
"""Reduce per-angle traces to the alignment read-out."""
usable = [t for t in traces if t.n_valid > 0]
if not usable:
nan = float("nan")
return AlignmentSummary(0, nan, nan, nan, nan, nan, nan, nan, 0.0)
medians = np.array([t.median_mhz for t in usable])
overall = float(np.median(medians))
spread = float(medians.max() - medians.min())
drifts = [(abs(t.drift_mhz_per_mm), t) for t in usable
if np.isfinite(t.drift_mhz_per_mm)]
worst_drift = max(drifts, key=lambda d: d[0])[1] if drifts else None
return AlignmentSummary(
n_angles=len(usable),
median_mhz=overall,
spread_mhz=spread,
spread_pct=spread / overall * 100.0 if overall else float("nan"),
best_angle_deg=usable[int(np.argmax(medians))].angle_deg,
worst_angle_deg=usable[int(np.argmin(medians))].angle_deg,
worst_drift_mhz_per_mm=worst_drift.drift_mhz_per_mm if worst_drift else float("nan"),
worst_drift_angle_deg=worst_drift.angle_deg if worst_drift else float("nan"),
min_valid_fraction=min(t.valid_fraction for t in usable),
)
+688
View File
@@ -0,0 +1,688 @@
"""Headless SRAS scan engine.
Takes plain hardware drivers, a ScanPlan, and callbacks — no Qt, no
widgets. ``run()`` blocks, so the caller owns the thread; GUIs wrap this
with gui.scan_bridge.QtScanController, which adapts the callbacks to Qt
signals. A CLI or a simpler GUI can drive the same engine with nothing but
functions.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from core import scope_burst, scope_sras
from core.rotation import RotationAxis
from core.scan_geometry import ScanPlan, validate_plan
from core.sras_format import (
BG_LEN_SIZE, SCAN_CHANNELS, VERSION, create_scan_file,
write_background_block,
)
logger = logging.getLogger(__name__)
SCAN_VELOCITY_MM_S = 100.0
SCAN_ACCEL_MM_S2 = 1500.0
LASER_FREQ_HZ = 20000.0 # laser pulse frequency during data acquisition
# Theoretical ramp distance: d = v² / (2a) = 100² / (2×1500) ≈ 3.33 mm
SCAN_RAMP_MM = SCAN_VELOCITY_MM_S**2 / (2.0 * SCAN_ACCEL_MM_S2)
# Extra buffer added to both ends of the ramp. The BBD202 controller begins
# decelerating slightly before the theoretical point to avoid overshoot,
# which drops TRIGOUT_MAXV early and clips the last few data points.
SCAN_RAMP_BUFFER_MM = 1.0
AXIS_X = 0x21
AXIS_Y = 0x22
class ScanAborted(Exception):
"""Raised inside the engine thread to unwind a scan cleanly."""
@dataclass
class ResumeTarget:
"""One angle selected for (re)acquisition in an existing file.
``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
@dataclass
class ResumeState:
path: Path
targets: list[ResumeTarget]
samples_per_frame: int
@property
def target_indices(self) -> set[int]:
return {t.angle_idx for t in self.targets}
@dataclass
class ScanCallbacks:
"""Progress reporting hooks. Every one is optional."""
on_status: Callable[[str], None] = lambda msg: None
on_started: Callable[[], None] = lambda: None
on_row_started: Callable[[int, int, int, int], None] = lambda r, nr, a, na: None
on_row_done: Callable[[int, int, int, int], None] = lambda r, nr, a, na: None
on_dc_bias: Callable[[int, list], None] = lambda row, means: None
on_paused_changed: Callable[[bool], None] = lambda paused: None
# Blocking operator prompt: must not return until acknowledged.
prompt: Callable[[str, str], None] = lambda title, msg: None
@dataclass
class ScanResult:
path: Path
rows_written: int = 0
aborted: bool = False
angles_acquired: list[int] = field(default_factory=list)
class ScanEngine:
"""Runs a full SRAS acquisition: stage, rotation, scope, and file output.
Constructed with the concrete drivers (not worker/queue wrappers), so
any front end can reuse it::
engine = ScanEngine(stage, scope, rotator, plan, out_path,
callbacks=ScanCallbacks(on_status=print))
result = engine.run() # blocking
"""
def __init__(self, stage, scope, rotator: RotationAxis | None,
plan: ScanPlan, out_path: Path,
resume: ResumeState | None = None,
callbacks: ScanCallbacks | None = None,
burst_mode: bool = False, strict_rows: bool = False,
file_version: int = VERSION):
self._stage = stage
self._scope = scope
self._rotator = rotator
self._plan = plan
self._out_path = Path(out_path)
self._resume = resume
self._cb = callbacks if callbacks is not None else ScanCallbacks()
# Burst mode acquires as many whole rows per FastFrame acquisition as
# the scope's frame memory holds, instead of one row per acquisition.
self._burst_mode = burst_mode
# Strict row packing stops the scan on a frame-count mismatch
# instead of squaring the row up (see _check_frame_delta).
self._strict_rows = strict_rows
# Which kind of file this run produces. The acquisition is identical
# either way; VERSION_SAW_CHECK only marks a one-row-per-angle plan
# (core.saw_check) as the quality check it is, so a reader does not
# mistake it for a scan that aborted after its first row.
self._file_version = file_version
self._max_frames = 0
self._preflight_done = False
self._abort = threading.Event()
self._resume_event = threading.Event()
self._resume_event.set() # set = running, cleared = pause requested
# ── External control (thread-safe) ────────────────────────────────────────
def abort(self):
self._abort.set()
self._resume_event.set() # unblock a paused scan so it can exit
def pause(self):
"""Request a pause; takes effect at the next row boundary."""
self._resume_event.clear()
def resume(self):
self._resume_event.set()
@property
def aborted(self) -> bool:
return self._abort.is_set()
# ── Internals ─────────────────────────────────────────────────────────────
def _check_abort(self):
if self._abort.is_set():
raise ScanAborted("Scan aborted by user.")
def _pause_point(self):
"""Block here (between rows, hardware idle) while a pause is requested."""
if self._resume_event.is_set():
self._check_abort()
return
self._cb.on_status(
"Scan paused — lasers may be switched off. "
"Turn lasers back on before resuming."
)
self._cb.on_paused_changed(True)
while not self._resume_event.wait(0.2):
if self._abort.is_set():
break
self._cb.on_paused_changed(False)
self._check_abort()
self._cb.on_status("Scan resumed.")
def _prompt(self, title: str, message: str):
self._cb.prompt(title, message)
self._check_abort()
# ── Main sequence ─────────────────────────────────────────────────────────
def run(self) -> ScanResult:
"""Execute the scan. Blocking; returns a ScanResult.
Raises ScanGeometryError for an unrunnable plan, RuntimeError for
missing/mismatched hardware, or ScanAborted if the operator aborts.
"""
plan = self._plan
per_angle = plan.per_angle
n_angles = plan.n_angles
result = ScanResult(path=self._out_path)
validate_plan(plan, SCAN_RAMP_MM, SCAN_RAMP_BUFFER_MM)
geometry_summary = ", ".join(
f"{pa.angle_deg:.1f}°: {pa.n_rows} row(s) × {pa.n_frames} pts/row"
for pa in per_angle
)
self._cb.on_status(
f"Scan geometry: {n_angles} angle(s), {plan.total_rows} row(s) total "
f"(per-angle bounding box) | save → {self._out_path.parent}\n"
f"{geometry_summary}"
)
self._cb.on_started()
if self._stage is None:
raise RuntimeError("BBD202 not connected")
if self._scope is None:
raise RuntimeError("Oscilloscope not connected")
rotator_ready = self._rotator is not None and self._rotator.is_available
if n_angles > 1 and not rotator_ready:
raise RuntimeError(
f"NumAngles={n_angles} requires the T3R rotation stage (GR-axis), "
"but it is not connected. Connect T3R from the T3R panel before "
"starting a multi-angle scan, or set NumAngles to 1."
)
if rotator_ready:
s = self._rotator.settings
self._cb.on_status(
f"Configuring GR axis: {s.microsteps} µsteps, "
f"{s.run_current_ma}/{s.hold_current_ma} mA run/hold …"
)
self._rotator.configure()
time.sleep(0.2)
self._prepare_stage()
samples_per_frame = self._prepare_scope()
scan_file = self._open_output(samples_per_frame, result)
try:
self._scan_loop(scan_file, samples_per_frame, result)
finally:
scan_file.close()
# Leave the X trigger output inactive. Burst mode toggles it every
# row and could exit from either state; the per-row path used to
# leave TRIGOUT_MAXV armed for the rest of the session, which keeps
# driving the gate line on every later jog.
try:
self._stage.set_trigger_gate_off(AXIS_X)
except Exception:
logger.exception("Could not return the X trigger output to idle")
# Return the GR axis home regardless of abort or error
if rotator_ready and abs(self._rotator.current_deg) > 0.001:
self._cb.on_status("Returning GR to home …")
try:
self._rotator.return_to_zero()
except Exception:
logger.exception("GR return-to-home failed")
if self._abort.is_set():
result.aborted = True
raise ScanAborted("Scan aborted by user.")
self._cb.on_status("Scan complete.")
return result
def _prepare_stage(self):
ctrl = self._stage
self._cb.on_status("Enabling stage axes …")
if not ctrl.am_enabled[0]:
ctrl.enable_axis(AXIS_X)
if not ctrl.am_enabled[1]:
ctrl.enable_axis(AXIS_Y)
time.sleep(0.2)
if not ctrl.am_homed[0] or not ctrl.am_homed[1]:
self._cb.on_status("Homing stage (may take up to 2 min) …")
if not ctrl.am_homed[0]:
ctrl.home_axis(AXIS_X, timeout=120.0)
if not ctrl.am_homed[1]:
ctrl.home_axis(AXIS_Y, timeout=120.0)
self._cb.on_status("Setting scan velocity …")
ctrl.set_velocity_params(AXIS_X, max_velocity=SCAN_VELOCITY_MM_S,
acceleration=SCAN_ACCEL_MM_S2)
ctrl.set_velocity_params(AXIS_Y, max_velocity=SCAN_VELOCITY_MM_S,
acceleration=SCAN_ACCEL_MM_S2)
# X trigger: logic-high output while the stage is at maximum velocity.
# Burst mode arms it per acquiring pass instead — a burst spans several
# rows with the scope running throughout, so leaving it armed would let
# the flyback trigger frames between rows.
if self._burst_mode:
ctrl.arm_scan_gate(AXIS_X, False)
else:
ctrl.set_trigger_trigout_maxv(AXIS_X)
def _prepare_scope(self) -> int:
self._cb.on_status("Configuring oscilloscope …")
samples_per_frame = scope_sras.configure_acquisition(self._scope)
if self._resume is None:
self._preambles = scope_sras.read_preambles(self._scope, SCAN_CHANNELS)
else:
# 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) "
f"does not match the {self._resume.samples_per_frame} samples/frame "
f"this scan file was started with — cannot safely resume."
)
targets = ", ".join(str(t.angle_idx + 1) for t in self._resume.targets)
self._prompt(
"Resume Scan",
f"Resuming {self._resume.path.name} — will (re)acquire "
f"angle(s) {targets} of {self._plan.n_angles}.\n\n"
"Please re-home the GR axis to 0° before continuing — the scan "
"will rotate it directly from angle to angle before scanning resumes.\n\n"
"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)
if self._burst_mode:
# Horizontal settings are fixed by now, so the capacity is stable
# for the whole scan; only rows-per-burst varies (n_frames is
# per-angle).
self._max_frames = scope_burst.max_frames(self._scope)
self._cb.on_status(
f"Burst mode: scope holds {self._max_frames} frames "
f"({samples_per_frame} samples/frame)"
)
return samples_per_frame
def _open_output(self, samples_per_frame: int, result: ScanResult):
if self._resume is not None:
result.path = self._resume.path
self._cb.on_status(
f"Resuming {self._resume.path.name} — "
f"{len(self._resume.targets)} angle(s) to (re)acquire …"
)
return open(self._resume.path, "r+b")
return create_scan_file(
self._out_path, self._plan, samples_per_frame,
scope_sras.SAMPLE_RATE_HZ, self._preambles,
version=self._file_version,
)
def _scan_loop(self, scan_file, samples_per_frame: int, result: ScanResult):
plan = self._plan
n_angles = plan.n_angles
x_ramp_total = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
scope = self._scope
targets_by_ai = None
if self._resume is not None:
targets_by_ai = {t.angle_idx: t for t in self._resume.targets}
# Both fresh and resumed scans assume the GR axis starts at home (0°)
# — the resume prompt instructs the operator to re-home it — so the
# first move always rotates directly from 0° to the starting angle.
for ai, pa in enumerate(plan.per_angle):
if targets_by_ai is not None and ai not in targets_by_ai:
continue # not selected for (re)acquisition
self._pause_point()
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(target.bg_offset)
if self._rotator is not None and self._rotator.is_available:
delta = pa.angle_deg - self._rotator.current_deg
if abs(delta) > 0.001:
self._cb.on_status(
f"Rotating GR to {pa.angle_deg:.1f}° (Δ{delta:+.1f}°) …")
self._rotator.rotate_to(pa.angle_deg)
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
# nothing to re-arm per angle here.
self._scan_rows_burst(scan_file, pa, ai, n_angles,
samples_per_frame, result, x_ramp_total)
else:
# Each angle's bounding box gives it its own points/row count,
# so the scope's FastFrame count must be re-armed per angle.
scope.set_fastframe_count(pa.n_frames)
self._scan_rows_serial(scan_file, pa, ai, n_angles,
samples_per_frame, result, x_ramp_total)
result.angles_acquired.append(ai)
def _write_angle_background(self, scan_file, ai: int, n_angles: int,
angle_deg: float, target: ResumeTarget | None):
"""Capture this angle's background and write it ahead of its rows.
The Genesis laser has to be off for the capture and back on for the
scan, so every angle costs two operator prompts and one averaged
record. That buys a background taken minutes from the data it will
be subtracted from, instead of one taken hours earlier at angle 1.
On resume the block is overwritten in place, so it has to be exactly
as long as the one already there — anything else would shift every
row behind it. Checked before the write, not after.
"""
scope = self._scope
scope_sras.configure_background_trigger(scope)
self._prompt(
f"Background Capture — Angle {ai + 1}/{n_angles}",
f"Angle {ai + 1} of {n_angles} ({angle_deg:.1f}°) starts with its "
"own background capture.\n\n"
"Please switch the Genesis laser OFF — leave the Helios laser ON —\n"
"then click OK to capture the background waveform."
)
background = scope_sras.capture_background(
scope, should_abort=self._abort.is_set, on_status=self._cb.on_status)
self._prompt(
f"Begin Angle {ai + 1}/{n_angles}",
"Background captured successfully.\n\n"
"Please switch the Genesis laser back ON,\n"
f"then click OK to scan angle {ai + 1} of {n_angles}."
)
scope_sras.configure_scan_trigger(scope)
if target is not None:
room = target.data_offset - target.bg_offset
if BG_LEN_SIZE + len(background) != room:
raise RuntimeError(
f"Angle {ai + 1}: the new background block is "
f"{BG_LEN_SIZE + len(background)} bytes but the file has room "
f"for {room} — writing it would shift every row behind it, "
"so the scan stops here."
)
write_background_block(scan_file, background)
# ── Per-row acquisition (one FastFrame acquisition per row) ───────────────
def _scan_rows_serial(self, scan_file, pa, ai: int, n_angles: int,
samples_per_frame: int, result: ScanResult,
x_ramp_total: float):
ctrl = self._stage
scope = self._scope
for ri, y_pos in enumerate(pa.y_positions):
self._pause_point()
self._cb.on_row_started(ri + 1, pa.n_rows, ai + 1, n_angles)
self._cb.on_status(
f"Angle {ai+1}/{n_angles} Row {ri+1}/{pa.n_rows} "
f"(Y={y_pos:.3f} mm)"
)
# Position the stage one ramp-length + buffer before the data
# window so it is at full velocity before x_start.
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
ctrl.move_axis_absolute(AXIS_X, pa.x_start - x_ramp_total, timeout=30.0)
scope_sras.arm_row(scope)
# Data window + ramp + buffer run-off, so the stage does not
# begin decelerating before the last point.
x_end = pa.x_start + pa.x_delta + x_ramp_total
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
scope_sras.finish_row(scope)
self._write_row(scan_file, samples_per_frame, ri, pa.n_frames)
result.rows_written += 1
self._cb.on_row_done(ri + 1, pa.n_rows, ai + 1, n_angles)
def _write_row(self, scan_file, samples_per_frame: int, row_idx: int,
n_frames: int):
"""Stream every channel from the scope into the file.
CH3 is the max-vel gate signal — no useful waveform data — so zeroed
frames are written to keep the file layout intact.
"""
scope = self._scope
ch_bytes = n_frames * samples_per_frame
for ch in SCAN_CHANNELS:
if ch == 3:
self._cb.on_status("Writing zeroed CH3 frames …")
scan_file.write(bytes(ch_bytes))
continue
self._cb.on_status(f"Fetching CH{ch} data …")
waveforms = scope_sras.transfer_channel(scope, ch)
if ch == SCAN_CHANNELS[0]:
self._check_frame_delta(row_idx, len(waveforms), n_frames)
row = scope_burst.normalize_row(
b"".join(waveforms), 0, len(waveforms), n_frames, samples_per_frame)
if ch == 4:
self._cb.on_dc_bias(row_idx + 1, scope_burst.frame_means_block(
row, 0, n_frames, samples_per_frame))
scan_file.write(row)
# ── Burst acquisition (many whole rows per FastFrame acquisition) ─────────
def _scan_rows_burst(self, scan_file, pa, ai: int, n_angles: int,
samples_per_frame: int, result: ScanResult,
x_ramp_total: float):
"""Acquire the angle in bursts of as many whole rows as the scope holds.
One ACQuire:STATE RUN spans the whole burst, so the gate is armed only
for each acquiring pass and dropped for the flyback — otherwise the
return move would reach max velocity and inject frames between rows.
"""
scope = self._scope
n_frames = pa.n_frames
x_lead_in = pa.x_start - x_ramp_total
x_end = pa.x_start + pa.x_delta + x_ramp_total
if not self._preflight_done:
# Once per scan: the gate wiring can't change between angles, and
# the check costs two row-times.
self._gate_off_preflight(x_lead_in, x_end)
self._preflight_done = True
row = 0
while row < pa.n_rows:
self._pause_point()
n_burst = scope_burst.rows_per_burst(
self._max_frames, n_frames, samples_per_frame, pa.n_rows - row)
self._cb.on_status(
f"Angle {ai+1}/{n_angles} Rows {row+1}-{row+n_burst}/{pa.n_rows} "
f"in one acquisition ({n_burst * n_frames} frames) …"
)
burst_start = scan_file.tell()
cumulative = []
baseline = scope_burst.start_burst(scope, self._max_frames)
try:
for r in range(n_burst):
self._check_abort()
self._cb.on_row_started(row + r + 1, pa.n_rows,
ai + 1, n_angles)
self._acquire_gated_row(pa.y_positions[row + r],
x_lead_in, x_end)
total = scope_burst.frames_acquired(scope)
if total >= self._max_frames:
raise RuntimeError(
f"FastFrame buffer full ({total}/{self._max_frames} "
f"frames) at row {row + r + 1} — later rows in this "
"burst would be misattributed. Raise "
"scope_burst.BURST_FRAME_HEADROOM and rerun."
)
cumulative.append(total - baseline)
finally:
scope_burst.stop_burst(scope)
counts = scope_burst.split_row_counts(cumulative)
self._write_burst(scan_file, burst_start, row, counts,
n_frames, samples_per_frame)
for r in range(n_burst):
result.rows_written += 1
self._cb.on_row_done(row + r + 1, pa.n_rows, ai + 1, n_angles)
row += n_burst
def _acquire_gated_row(self, y_pos: float, x_lead_in: float, x_end: float):
"""One row: step Y, fly back gated off, then acquire on the +X pass."""
ctrl = self._stage
ctrl.move_axis_absolute(AXIS_Y, y_pos, timeout=60.0)
ctrl.move_axis_absolute(AXIS_X, x_lead_in, timeout=30.0)
ctrl.arm_scan_gate(AXIS_X, True)
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
ctrl.arm_scan_gate(AXIS_X, False)
time.sleep(scope_burst.BURST_ROW_SETTLE_S)
def _gate_off_preflight(self, x_lead_in: float, x_end: float):
"""Prove the gate really gates before trusting a multi-row burst.
The value that makes the BBD trigger output idle low is not settled by
the protocol docs (see apt_constants.TRIGOUT_GATE_OFF), and getting it
wrong fills every burst with flyback frames that silently shift the
file. The scope already measures the gate on CH3, so this needs no
bench probe: one gated-off flyback must acquire nothing, and one gated
pass must acquire something — the second half is what stops a dark
laser from making the first half pass vacuously.
Leaves the stage parked at x_end, where the burst loop expects it.
"""
ctrl, scope = self._stage, self._scope
self._cb.on_status("Burst preflight: checking the stage gate …")
ctrl.arm_scan_gate(AXIS_X, False)
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
baseline = scope_burst.start_burst(scope, self._max_frames)
ctrl.move_axis_absolute(AXIS_X, x_lead_in, timeout=120.0)
scope_burst.stop_burst(scope)
leaked = scope_burst.frames_acquired(scope) - baseline
ctrl.arm_scan_gate(AXIS_X, True)
baseline = scope_burst.start_burst(scope, self._max_frames)
ctrl.move_axis_absolute(AXIS_X, x_end, timeout=120.0)
ctrl.arm_scan_gate(AXIS_X, False)
scope_burst.stop_burst(scope)
gated = scope_burst.frames_acquired(scope) - baseline
if gated <= 0:
raise RuntimeError(
"Burst preflight: no frames acquired with the gate armed. "
"Check that the Genesis laser is pulsing (CH2) and that the "
"BBD X trigger output reaches CH3 before scanning."
)
if leaked:
raise RuntimeError(
f"Burst preflight: {leaked} frame(s) acquired during a flyback "
"that should have been gated off — the BBD trigger output is "
"not idling low. Set apt_constants.TRIGOUT_GATE_OFF to "
"TriggerBitsServo.TRIGOUT_HIGH and retry, or use per-row "
"acquisition."
)
self._cb.on_status(
f"Burst preflight OK ({gated} frames gated on, 0 leaked).")
def _write_burst(self, scan_file, burst_start: int, first_row: int,
counts: list[int], n_frames: int, samples_per_frame: int):
"""Deinterleave one burst into the file's per-row, per-channel blocks.
The wire is channel-major (every row of CH1, then every row of CH4);
the file is row-major with channels inner. Writing one channel at a
time to strided offsets keeps peak memory at a single channel's burst
instead of the whole thing.
"""
scope = self._scope
ch_bytes = n_frames * samples_per_frame
row_bytes = len(SCAN_CHANNELS) * ch_bytes
total_frames = sum(counts)
for r, count in enumerate(counts):
self._check_frame_delta(first_row + r, count, n_frames)
for ch_idx, ch in enumerate(SCAN_CHANNELS):
if ch == 3:
self._cb.on_status("Writing zeroed CH3 frames …")
blob = None
else:
self._cb.on_status(
f"Fetching CH{ch} burst ({total_frames} frames) …")
blob = scope_burst.transfer_burst(scope, ch, total_frames,
samples_per_frame)
src = 0
zeros = bytes(ch_bytes) if blob is None else None
for r, count in enumerate(counts):
scan_file.seek(burst_start + r * row_bytes + ch_idx * ch_bytes)
if blob is None:
scan_file.write(zeros)
else:
row = scope_burst.normalize_row(
blob, src, count, n_frames, samples_per_frame)
if ch == 4:
self._cb.on_dc_bias(
first_row + r + 1,
scope_burst.frame_means_block(
row, 0, n_frames, samples_per_frame))
scan_file.write(row)
src += count * samples_per_frame
del blob
scan_file.seek(burst_start + len(counts) * row_bytes)
def _check_frame_delta(self, row_idx: int, count: int, n_frames: int):
"""Decide what to do with a row that did not acquire n_frames frames.
v6 declares n_frames per row in the header and has no per-row length
field, so a mismatched row cannot just be written as-is — that would
shift every later row in the file. The only two safe options are to
square it up or to stop, which is what strict_rows selects between.
Called before anything for the row is written (CH1 leads
SCAN_CHANNELS), so raising here leaves no partial row behind.
"""
if count == n_frames:
return
verb = "zero-padded" if count < n_frames else "truncated"
if self._strict_rows:
raise RuntimeError(
f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
f"expected. Strict row packing is on, so the scan stops here "
f"rather than writing a row that would be {verb}."
)
msg = (f"Row {row_idx + 1}: {count} frames acquired, {n_frames} "
f"expected — {verb} to keep the file layout intact.")
logger.warning(msg)
self._cb.on_status(msg)
+199
View File
@@ -0,0 +1,199 @@
"""Scan geometry planning: angle sequences, rotated bounding boxes, travel
limits, and ETA math. Pure Python — no Qt, no hardware.
"""
from __future__ import annotations
import math
import time
from collections import deque
from dataclasses import dataclass, field
class ScanGeometryError(ValueError):
"""Scan geometry that cannot be executed (bad inputs or off-stage)."""
@dataclass(frozen=True)
class StageLimits:
"""Usable travel of the scanning stage in mm (MLS203-1)."""
x_min: float = 0.0
x_max: float = 110.0
y_min: float = 0.0
y_max: float = 75.0
DEFAULT_STAGE_LIMITS = StageLimits()
@dataclass
class AngleGeometry:
"""One rotation angle's scan extent — also the on-disk v6 geometry row."""
angle_deg: float
x_start: float
x_delta: float
n_frames: int
n_rows: int
y_positions: list[float] = field(default_factory=list)
@dataclass
class ScanPlan:
x_start_nominal: float
y_start_nominal: float
x_delta_nominal: float
y_delta_nominal: float
row_spacing: float
velocity_mm_s: float
laser_freq_hz: float
per_angle: list[AngleGeometry] = field(default_factory=list)
@property
def n_angles(self) -> int:
return len(self.per_angle)
@property
def angles(self) -> list[float]:
return [pa.angle_deg for pa in self.per_angle]
@property
def total_rows(self) -> int:
return sum(pa.n_rows for pa in self.per_angle)
def build_plan(x_start: float, y_start: float, x_delta: float, y_delta: float,
num_angles: int, row_spacing: float, *,
laser_freq_hz: float, velocity_mm_s: float,
rotation_sign: int = -1) -> ScanPlan:
"""Compute the per-angle scan geometry for a nominal ROI.
Each angle only needs to physically scan the bounding box of the nominal
(x_start, y_start, x_delta, y_delta) rectangle rotated by THAT angle --
not the worst case across all angles -- so the X extent (and therefore
points/row) and the row count are computed per angle.
"""
if x_delta <= 0:
raise ScanGeometryError("XD must be > 0")
if row_spacing <= 0:
raise ScanGeometryError("RowSpacing must be > 0")
if num_angles < 1:
raise ScanGeometryError("NumAngles must be ≥ 1")
# Signed so the recorded/commanded angle sequence reflects the GR
# stage's actual physical rotation direction.
if num_angles > 1:
angles = [rotation_sign * i * 180.0 / (num_angles - 1) for i in range(num_angles)]
else:
angles = [0.0]
cx = x_start + x_delta / 2.0
cy = y_start + y_delta / 2.0
per_angle = []
for a in angles:
r = math.radians(a)
bb_w = abs(x_delta * math.cos(r)) + abs(y_delta * math.sin(r))
bb_h = abs(x_delta * math.sin(r)) + abs(y_delta * math.cos(r))
a_x_start = cx - bb_w / 2.0
a_y_start = cy - bb_h / 2.0
a_n_rows = max(1, round(bb_h / row_spacing) + 1) if bb_h > 0 else 1
a_n_frames = max(1, round(bb_w * laser_freq_hz / velocity_mm_s))
per_angle.append(AngleGeometry(
angle_deg=a,
x_start=a_x_start,
x_delta=bb_w,
n_frames=a_n_frames,
n_rows=a_n_rows,
y_positions=[a_y_start + i * row_spacing for i in range(a_n_rows)],
))
return ScanPlan(
x_start_nominal=x_start, y_start_nominal=y_start,
x_delta_nominal=x_delta, y_delta_nominal=y_delta,
row_spacing=row_spacing,
velocity_mm_s=velocity_mm_s, laser_freq_hz=laser_freq_hz,
per_angle=per_angle,
)
def validate_plan(plan: ScanPlan, ramp_mm: float, ramp_buffer_mm: float,
limits: StageLimits = DEFAULT_STAGE_LIMITS) -> None:
"""Raise ScanGeometryError if any angle's physical move leaves the stage.
The actual X move starts one ramp-length + buffer before x_start and ends
one ramp-length + buffer after x_start + x_delta, so the stage is at full
velocity across the whole data window.
"""
x_ramp_total = ramp_mm + ramp_buffer_mm
for pa in plan.per_angle:
x_move_start = pa.x_start - x_ramp_total
x_move_end = pa.x_start + pa.x_delta + x_ramp_total
if x_move_start < limits.x_min:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}°: scan pre-ramp start ({x_move_start:.3f} mm) "
f"is below the X axis minimum ({limits.x_min:g} mm). Reduce XD/YD or move XS/YS "
f"so every rotation angle's bounding box stays on-stage "
f"(SCAN_RAMP_MM={ramp_mm:.3f} + SCAN_RAMP_BUFFER_MM={ramp_buffer_mm:.3f})."
)
if x_move_end > limits.x_max:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}°: scan run-off end ({x_move_end:.3f} mm) "
f"exceeds the X axis maximum ({limits.x_max:g} mm). Reduce XD/YD or move XS/YS "
f"so every rotation angle's bounding box stays on-stage."
)
y_min = min(pa.y_positions)
y_max = max(pa.y_positions)
if y_min < limits.y_min:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}°: scan Y range starts at {y_min:.3f} mm, "
f"below the Y axis minimum ({limits.y_min:g} mm)."
)
if y_max > limits.y_max:
raise ScanGeometryError(
f"Angle {pa.angle_deg:.1f}°: scan Y range ends at {y_max:.3f} mm, "
f"exceeds the Y axis maximum ({limits.y_max:g} mm)."
)
def format_eta(secs: float) -> str:
secs = max(0.0, secs)
m, s = divmod(int(secs), 60)
h, m = divmod(m, 60)
if h > 0:
return f"{h}h {m:02d}m"
if m > 0:
return f"{m}m {s:02d}s"
return f"{s}s"
class EtaEstimator:
"""Rolling average of recent row durations → remaining-time estimate.
Duration history resets when the angle index changes, since different
angles have different row lengths.
"""
def __init__(self, window: int = 5):
self._durations: deque[float] = deque(maxlen=window)
self._row_start: float | None = None
self._last_angle_idx: int = -1
def reset(self) -> None:
self._durations.clear()
self._row_start = None
self._last_angle_idx = -1
def row_started(self, now: float | None = None) -> None:
self._row_start = time.monotonic() if now is None else now
def row_finished(self, angle_idx: int, now: float | None = None) -> None:
if angle_idx != self._last_angle_idx and self._last_angle_idx != -1:
self._durations.clear()
self._last_angle_idx = angle_idx
if self._row_start is not None:
end = time.monotonic() if now is None else now
self._durations.append(end - self._row_start)
self._row_start = None
def eta_secs(self, rows_left: int) -> float | None:
if not self._durations or rows_left <= 0:
return None
return sum(self._durations) / len(self._durations) * rows_left
+71
View File
@@ -0,0 +1,71 @@
"""Resume planning: turn a file's frontier into a set of angles to re-acquire.
Pure logic, no Qt and no file I/O beyond what SrasFile already parsed, so
the non-obvious contiguity rule is testable on its own.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from core.scan_engine import ResumeState, ResumeTarget
from core.sras_format import WRITABLE_VERSIONS, SrasFile
@dataclass
class ResumePlan:
targets: list[ResumeTarget]
auto_added: list[int] = field(default_factory=list) # indices forced in
frontier_idx: int = 0
@property
def total_rows(self) -> int:
return sum(t.n_rows for t in self.targets)
def to_state(self, sras: SrasFile) -> ResumeState:
return ResumeState(path=sras.path, targets=self.targets,
samples_per_frame=sras.header.samples_per_frame)
def plan_resume(statuses, selected: set[int]) -> ResumePlan:
"""Expand an operator's angle selection into a runnable resume plan.
Waveform data is one contiguous append-only stream, so nothing can be
written past a gap: if the operator picks an angle at or beyond the
frontier (the first incomplete angle), every angle from the frontier up
to it must be re-acquired too. Those extras are reported in
``auto_added`` so the UI can say so.
"""
frontier_idx = next((s.index for s in statuses if not s.complete), len(statuses))
at_or_past = {i for i in selected if i >= frontier_idx}
if at_or_past:
final = selected | set(range(frontier_idx, max(at_or_past) + 1))
else:
final = set(selected)
targets = [
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,
auto_added=sorted(final - set(selected)),
frontier_idx=frontier_idx)
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.
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 (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
and abs(h.sample_rate - sample_rate) <= 1.0)
+149
View File
@@ -0,0 +1,149 @@
"""Burst-mode FastFrame acquisition policy.
Per-row acquisition pays a full arm/stop/transfer round trip for every row,
and the transfer alone is one IEEE-488.2 block read per frame (~16k frames a
row). A burst instead runs one FastFrame acquisition across as many complete
rows as the scope's frame memory holds, then pulls the whole thing in a single
transaction — amortising the round trip over `rows_per_burst` rows.
The scope reports its capacity with ``HORizontal:FASTframe:MAXFRames?`` once
the horizontal settings are fixed; ``rows_per_burst`` turns that into a row
count. Everything here that computes rather than talks to hardware is a free
function, so the row-splitting logic is testable without a rig.
The catch is that the burst contains no row markers: the scope hands back one
flat run of frames. Boundaries come from polling ``ACQuire:NUMFRAMESACQuired?``
after each row's acquiring pass, while the stage gate is already low — see
``split_row_counts``.
"""
from __future__ import annotations
import logging
import time
import numpy as np
logger = logging.getLogger(__name__)
# Peak transfer buffer, per channel. The writer holds one channel at a time
# (see ScanEngine._scan_rows_burst), so this is the real high-water mark.
BURST_MEMORY_BUDGET_BYTES = 512 * 1024 * 1024
# Extra frames budgeted per row on top of n_frames. 0 gives the plain
# floor(max_frames / n_frames) row count; raise it if the acquiring pass
# routinely over-triggers (watch the pad/truncate warnings).
BURST_FRAME_HEADROOM = 0
BURST_ARM_SETTLE_S = 0.05 # after ACQuire:STATE RUN, before the first move
BURST_ROW_SETTLE_S = 0.05 # after the gate drops, before reading the counter
# ── Pure helpers ─────────────────────────────────────────────────────────────
def rows_per_burst(max_frames: int, n_frames: int, samples_per_frame: int,
rows_remaining: int,
memory_budget: int = BURST_MEMORY_BUDGET_BYTES,
headroom: int = BURST_FRAME_HEADROOM) -> int:
"""How many complete rows fit in one acquisition.
Rounds down — a partial row is worthless, since a row must be transferred
whole to be written. Clamped by the transfer buffer budget and by the rows
actually left in the angle, and never below 1 (a single row always goes,
even if it exceeds the budget, so the scan can still make progress).
"""
if n_frames < 1 or samples_per_frame < 1:
raise ValueError(f"n_frames={n_frames} samples_per_frame={samples_per_frame}")
by_scope = max_frames // (n_frames + headroom)
by_memory = memory_budget // (n_frames * samples_per_frame)
return max(1, min(by_scope, by_memory, rows_remaining))
def split_row_counts(cumulative: list[int]) -> list[int]:
"""Per-row frame counts from the cumulative counter sampled after each row.
``cumulative`` is ``ACQuire:NUMFRAMESACQuired?`` read once per row, already
rebased on the value at burst start.
"""
counts = []
prev = 0
for i, c in enumerate(cumulative):
if c < prev:
raise RuntimeError(
f"FastFrame counter went backwards at row {i} ({prev} → {c}) — "
"the acquisition was restarted mid-burst"
)
counts.append(c - prev)
prev = c
return counts
def normalize_row(buf, offset: int, count: int, n_frames: int,
samples_per_frame: int):
"""Coerce one row's frames to exactly ``n_frames``.
The v6 format commits to n_frames per row in the header and has no per-row
length field, so a row that over- or under-triggers must be squared up or
every later row in the file shifts. Short rows are zero-padded, long rows
lose their trailing frames. Returns something writable directly.
"""
want = n_frames * samples_per_frame
end = min(offset + count * samples_per_frame, offset + want, len(buf))
chunk = memoryview(buf)[offset:end]
if len(chunk) == want:
return chunk
return bytes(chunk) + bytes(want - len(chunk))
def frame_means_block(buf, offset: int, n_frames: int,
samples_per_frame: int) -> list[float]:
"""Per-frame DC mean over one row's slice of a burst buffer."""
n = n_frames * samples_per_frame
block = np.frombuffer(buf, dtype=np.int8, count=n, offset=offset)
return block.reshape(n_frames, samples_per_frame).mean(
axis=1, dtype=np.float32).tolist()
# ── Instrument control ───────────────────────────────────────────────────────
def max_frames(scope) -> int:
"""Frames the scope can hold under the current horizontal settings."""
try:
m = scope.get_fastframe_max_frames()
except Exception as exc:
raise RuntimeError(
"Scope did not answer HORizontal:FASTframe:MAXFRames? — burst mode "
"cannot size a burst without it. Use per-row acquisition on this "
f"firmware. ({exc})"
) from exc
if m < 1:
raise RuntimeError(f"Scope reports a FastFrame capacity of {m} frames")
return m
def start_burst(scope, frame_count: int) -> int:
"""Arm one burst; returns the counter baseline to subtract from later reads.
Reading the baseline back beats assuming the counter resets to 0 on RUN —
any residual is simply subtracted out instead of being misattributed to the
first row.
"""
scope.set_fastframe_count(frame_count)
scope.write("ACQuire:STATE RUN")
time.sleep(BURST_ARM_SETTLE_S)
return frames_acquired(scope)
def stop_burst(scope) -> None:
time.sleep(BURST_ROW_SETTLE_S)
scope.write("ACQuire:STATE STOP")
def frames_acquired(scope) -> int:
return int(scope.query("ACQuire:NUMFRAMESACQuired?"))
def transfer_burst(scope, ch: int, frame_count: int, samples_per_frame: int):
"""Pull a whole burst for one channel in a single CURVe? transaction."""
scope.set_data_source(ch)
return scope.transfer_fastframe_bulk(frame_count, samples_per_frame)
+136
View File
@@ -0,0 +1,136 @@
"""Oscilloscope configuration for pre-scan angle inspection.
Inspection is read-on-the-instrument: nothing in this module transfers or
plots waveform data. The app puts the scope into a free-running, edge-
triggered state and drives the stage to the point being inspected; the
operator judges the SAW response and the bias levels on the scope screen.
That split is deliberate. A scan's acquisition trigger is the logic AND of
the laser pulse and the stage's max-velocity gate, and its transfers are
FastFrame blocks — neither is useful for looking at one point by eye. Here
the trigger is a plain edge on the laser pulse, FastFrame is off, and the
acquisition free-runs, so the display updates continuously while the stage
sits still.
CH1 keeps the acquisition front-end so what is on screen is what a scan would
record. CH3 and CH4 are rescaled as DC bias monitors (see BIAS_* below).
``read_bias_mv`` is the one exception to "nothing is transferred": it reads
the two bias levels back as scalars, not waveforms, because the auto-align
procedure (core.auto_align) has to close a loop on them. The operator still
watches the same screen this configures.
"""
from __future__ import annotations
import logging
from dataclasses import replace
from core.scope_sras import SAMPLE_RATE_HZ, SRAS_CHANNELS, configure_channels
logger = logging.getLogger(__name__)
# CH2 carries the laser pulse. The scan triggers it at 0.5 V as one term of a
# logic AND; inspection triggers well above that so a slow edge or a noisy
# baseline cannot free-run the display.
INSPECT_TRIG_LEVEL_V = 2.0
# CH3/CH4 are the DC bias monitors during inspection. The signal never goes
# negative and spans roughly 0–700 mV, so both channels get the *same* scale
# and position — the point of inspecting them is comparing the two by eye, and
# that only works if a division means the same thing on each.
#
# Ground sits BIAS_POSITION_DIV divisions below centre, which puts the whole
# 0–700 mV range above the centre line with a little room underneath for
# undershoot. With 100 mV/div and ground 3.5 divisions low, the visible window
# runs from about -50 mV to +750 mV on an 8-division display and wider on a
# 10-division one, so 0–700 mV sits comfortably inside either.
BIAS_CHANNELS = (3, 4)
BIAS_WINDOW_V = 0.700
BIAS_SCALE_V_DIV = 0.100
BIAS_POSITION_DIV = -3.5
BIAS_LABELS = {3: "Bias - A", 4: "Bias - B"}
# One MEAN measurement carries the shot-to-shot noise of a single record, and
# the alignment loop has to resolve 5 mV. The median of a handful of reads
# rejects the odd outlier without the averaging acquisition mode, which would
# hide exactly the intermittent response the operator is watching CH1 for.
BIAS_READS = 5
def inspect_channel_profiles() -> dict:
"""Channel front-end config for inspection.
CH1 and CH2 are the acquisition profiles verbatim. CH3 and CH4 differ
only in label, scale and position — termination, coupling and bandwidth
stay as the scan sets them, so the bias reading is the same measurement
the scan records, just displayed usefully.
"""
profiles = dict(SRAS_CHANNELS)
for ch in BIAS_CHANNELS:
profiles[ch] = replace(
SRAS_CHANNELS[ch],
label=BIAS_LABELS[ch],
scale_v_div=BIAS_SCALE_V_DIV,
position_div=BIAS_POSITION_DIV,
)
return profiles
def configure_inspection(scope) -> None:
"""Put the scope into free-running inspection mode.
Leaves the acquisition running, so the display stays live while the
operator moves between angles and points.
"""
configure_channels(scope, inspect_channel_profiles())
# Plain edge trigger on the laser pulse — no logic pattern, so the stage
# gate plays no part and a stationary stage still triggers.
scope.write("TRIGger:A:TYPe EDGE")
scope.set_trigger_source(2)
scope.set_trigger_slope("RISE")
scope.set_trigger_level(2, INSPECT_TRIG_LEVEL_V)
scope.set_trigger_mode("NORMAL")
# No averaging: a weak or intermittent SAW response is exactly what the
# operator is looking for, and averaging would hide it.
scope.set_acquire_mode("SAMPLE")
scope.set_fastframe_state(False)
scope.set_sample_rate(SAMPLE_RATE_HZ)
scope.write("HORizontal:POSition 30")
# Free-run rather than single-sequence, so the trace keeps updating.
scope.write("ACQuire:STOPAfter RUNSTop")
scope.write("ACQuire:STATE RUN")
def stop_inspection(scope) -> None:
"""Halt the free-running acquisition.
The next scan reconfigures the scope from scratch, so this only needs to
stop the sweep — it does not try to restore the acquisition profile.
"""
scope.write("ACQuire:STATE STOP")
def read_bias_mv(scope, reads: int = BIAS_READS) -> tuple[float, float]:
"""Read the two DC bias levels in millivolts.
Returns ``(ch3_mv, ch4_mv)`` — DC 1 and DC 2 in the auto-align channel
map. Each channel is read ``reads`` times and reduced by the median.
The two channels are read in separate batches rather than interleaved:
switching the immediate-measurement source costs a round trip, and these
are DC levels, so the few milliseconds between the batches are not a
source of error the way they would be for a transient.
"""
if reads < 1:
raise ValueError("read_bias_mv needs at least one read per channel")
levels = []
for ch in BIAS_CHANNELS:
samples = sorted(scope.measure_immediate(ch, "MEAN") for _ in range(reads))
levels.append(samples[len(samples) // 2] * 1000.0)
return levels[0], levels[1]
+156
View File
@@ -0,0 +1,156 @@
"""Oscilloscope SCPI policy for SRAS acquisition.
All the Tektronix-specific instrument setup the scan depends on, in one
Qt-free place: per-channel display/coupling config, trigger programming,
the background average, and per-row FastFrame transfer.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
SAMPLE_RATE_HZ = 6.25e9 # 6.25 GS/s → 160 ps/sample
TRIG_LEVEL_V = 0.500
BACKGROUND_AVERAGES = 1024
BACKGROUND_TIMEOUT_S = 60.0
@dataclass(frozen=True)
class ChannelProfile:
"""Display/input configuration for one scope channel."""
label: str
scale_v_div: float
position_div: float
termination_ohm: int
coupling: str
bandwidth_hz: float
# Standard SRAS front-end configuration.
SRAS_CHANNELS = {
1: ChannelProfile("RF Acoustic Packet", 0.07, 0.0, 50, "DC", 250e6),
2: ChannelProfile("Trigger Signal", 0.5, -2.72, 1_000_000, "DC", 20e6),
3: ChannelProfile("Max Vel Gate", 1.0, -2.72, 1_000_000, "DC", 20e6),
4: ChannelProfile("Bias - B", 0.1, -2.72, 1_000_000, "DC", 20e6),
}
def configure_channels(scope, profiles=None) -> None:
"""Apply the standard SRAS channel configuration."""
profiles = profiles if profiles is not None else SRAS_CHANNELS
for ch, p in profiles.items():
scope.write(f"SELect:CH{ch} ON")
scope.set_channel_label_name(ch, p.label)
scope.set_channel_scale(ch, p.scale_v_div)
scope.set_channel_position(ch, p.position_div)
scope.set_channel_termination(ch, p.termination_ohm)
scope.set_channel_coupling(ch, p.coupling)
scope.set_channel_bandwidth(ch, p.bandwidth_hz)
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 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)
scope.set_trigger_slope("RISE")
scope.set_trigger_level(2, TRIG_LEVEL_V)
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.
scope.set_data_encoding("RIBinary")
scope.set_data_width(1)
scope.set_sample_rate(SAMPLE_RATE_HZ)
scope.write("HORizontal:POSition 30") # 10 % trigger offset
time.sleep(0.3) # let the timebase settle before reading back
return scope.get_record_length()
def read_preambles(scope, channels) -> list[str]:
"""Snapshot WFMOutpre per channel (captures YMULT/YOFF/YZERO)."""
preambles = []
for ch in channels:
scope.set_data_source(ch)
preambles.append(scope.query_wfmoutpre())
return preambles
def capture_background(scope, should_abort=lambda: False,
on_status=lambda msg: None) -> bytes:
"""Capture one CH1 waveform averaged over BACKGROUND_AVERAGES shots.
The scope auto-stops after the sequence; poll ACQuire:STATE until it
does rather than assuming a duration.
"""
on_status(f"Capturing background waveform ({BACKGROUND_AVERAGES}-average) …")
scope.set_acquire_mode("AVERAGE")
scope.write(f"ACQuire:NUMAVg {BACKGROUND_AVERAGES}")
scope.write("ACQuire:STOPAfter SEQuence")
scope.set_data_source(1)
scope.write("ACQuire:STATE RUN")
deadline = time.time() + BACKGROUND_TIMEOUT_S
while time.time() < deadline:
if should_abort():
break
if scope.query("ACQuire:STATE?").strip() == "0":
break
time.sleep(0.25)
else:
scope.write("ACQuire:STATE STOP")
on_status("Warning: background average timed out; stopping early.")
time.sleep(0.1)
return scope.transfer_curve()
def configure_scan_trigger(scope) -> None:
"""Switch to the scan-time logic-AND trigger (CH2 HIGH AND CH3 HIGH).
CH3 is the BBD202 TRIGOUT_MAXV gate, so frames only accumulate while the
stage is at full scan velocity.
"""
scope.write("ACQuire:STOPAfter RUNSTop")
scope.set_acquire_mode("SAMPLE")
scope.set_fastframe_state(True)
scope.write("TRIGger:A:TYPe LOGIc")
scope.write("TRIGger:A:LOGIc:FUNCtion AND")
scope.set_trigger_level(2, TRIG_LEVEL_V)
scope.set_trigger_level(3, TRIG_LEVEL_V)
scope.write("TRIGger:A:LOGICPattern:CH2 HIGH")
scope.write("TRIGger:A:LOGICPattern:CH3 HIGH")
time.sleep(0.2)
def arm_row(scope) -> None:
"""Start acquisition for one scan row."""
scope.write("ACQuire:STATE RUN")
time.sleep(0.05)
def finish_row(scope) -> None:
"""Wait for trailing frames, then stop acquisition."""
time.sleep(0.2)
scope.write("ACQuire:STATE STOP")
def transfer_channel(scope, ch: int) -> list[bytes]:
"""Fetch one channel's FastFrame block as raw int8 frames."""
scope.set_data_source(ch)
return scope.transfer_fastframe(parse=False)
+296
View File
@@ -0,0 +1,296 @@
"""SRAS analysis: scope calibration, image reducers, and the SAW
matched-filter pipeline. Qt-free; operates on the per-angle arrays
returned by core.sras_format.SrasFile.load_angle().
Channel semantics (fixed by the acquisition app):
CH1 — RF Acoustic Packet: FFT → peak frequency
CH3 — Bias A (DC): waveform mean
CH4 — Bias B (DC): waveform mean — also the RF valid-pixel mask source
"""
from __future__ import annotations
import os
import re
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
import numpy as np
from scipy.signal import butter, hilbert, sosfiltfilt
# Channel indices into the on-disk channel axis (order fixed by SCAN_CHANNELS)
CH1_IDX, CH3_IDX, CH4_IDX = 0, 1, 2
# Fallback scope calibration for preambles missing YMULT/YOFF/YZERO:
# 50 mV/div, 8 div full-scale, int8 ADC, position = -2.72 div
FALLBACK_YMULT_MV = 1.5625 # mV per ADC count
FALLBACK_YOFF_ADC = -87.04 # ADC count that represents 0 V
def parse_preamble(preamble: str) -> dict[str, float]:
"""Extract YMULT, YOFF, YZERO from a Tektronix WFMOutpre string."""
result = {}
for key in ("YMULT", "YOFF", "YZERO"):
m = re.search(rf'\b{key}\s+([-+]?\d*\.?\d+(?:[Ee][+-]?\d+)?)', preamble)
if m:
result[key] = float(m.group(1))
return result
@dataclass
class ChannelCalibration:
"""Per-channel ADC↔mV conversion, parsed from the file's preambles."""
ymult_mv: list[float]
yoff_adc: list[float]
yzero_mv: list[float]
@classmethod
def from_preambles(cls, preambles: list[str]) -> "ChannelCalibration":
cal = cls([], [], [])
for p in preambles:
vals = parse_preamble(p)
# YMULT/YZERO from the scope are in V; stored here in mV
cal.ymult_mv.append(vals.get("YMULT", FALLBACK_YMULT_MV / 1000) * 1000)
cal.yoff_adc.append(vals.get("YOFF", FALLBACK_YOFF_ADC))
cal.yzero_mv.append(vals.get("YZERO", 0.0) * 1000)
return cal
def adc_to_mv(self, adc, ch: int):
return (adc - self.yoff_adc[ch]) * self.ymult_mv[ch] + self.yzero_mv[ch]
def mv_to_adc(self, mv, ch: int):
return (mv - self.yzero_mv[ch]) / self.ymult_mv[ch] + self.yoff_adc[ch]
def power_spectrum(waveform: np.ndarray) -> np.ndarray:
"""FFT power with the DC bin suppressed."""
power = np.abs(np.fft.rfft(waveform)) ** 2
power[..., 0] = 0.0
return power
# ---------------------------------------------------------------------------
# Image reducers — all take the (n_rows, n_ch, n_frames, spf) angle view
# ---------------------------------------------------------------------------
def compute_dc_image(angle_view: np.ndarray, ch_idx: int) -> np.ndarray:
"""Mean of each waveform → (n_rows, n_frames) float32.
Computed directly on the int8 view — no float32 copy of the block.
"""
return angle_view[:, ch_idx].mean(axis=-1, dtype=np.float32)
def _valid_ch1_waveforms(angle_view: np.ndarray, calib: ChannelCalibration,
dc_threshold_mv: float,
background: np.ndarray | None,
) -> tuple[np.ndarray, np.ndarray]:
"""CH4-DC mask + float32 CH1 waveforms for only the pixels that pass.
Materializes float32 for the valid pixels alone (fancy-index on the int8
view first), so a mostly-masked angle costs almost nothing.
"""
dc4_mv = calib.adc_to_mv(compute_dc_image(angle_view, CH4_IDX), CH4_IDX)
valid = dc4_mv >= dc_threshold_mv
if not valid.any():
return valid, np.empty((0, angle_view.shape[-1]), dtype=np.float32)
waves = angle_view[:, CH1_IDX][valid].astype(np.float32) # (n_valid, spf)
if background is not None:
waves -= background
return valid, waves
def compute_rf_image(angle_view: np.ndarray, calib: ChannelCalibration,
freq_axis_mhz: np.ndarray,
dc_threshold_mv: float,
background: np.ndarray | None = None,
gate_start_ns: float | None = None,
gate_end_ns: float | None = None,
time_axis_ns: np.ndarray | None = None) -> np.ndarray:
"""FFT of each CH1 waveform; pixel = peak frequency in MHz.
Pixels whose CH4 DC mean (in mV) is below dc_threshold_mv are 0 and the
FFT is skipped for them. Optional time gate zeroes samples outside
[gate_start_ns, gate_end_ns] before the FFT.
"""
valid, waves = _valid_ch1_waveforms(angle_view, calib, dc_threshold_mv, background)
img = np.zeros(valid.shape, dtype=np.float32)
if len(waves):
if (gate_start_ns is not None or gate_end_ns is not None) and time_axis_ns is not None:
keep = np.ones(len(time_axis_ns), dtype=bool)
if gate_start_ns is not None:
keep &= time_axis_ns >= gate_start_ns
if gate_end_ns is not None:
keep &= time_axis_ns <= gate_end_ns
waves[:, ~keep] = 0.0
peak_bins = np.argmax(power_spectrum(waves), axis=-1)
img[valid] = freq_axis_mhz[peak_bins]
return img
def compute_saw_image(angle_view: np.ndarray, calib: ChannelCalibration,
dc_threshold_mv: float,
pipeline: "SawPipeline", mode: str,
background: np.ndarray | None = None) -> np.ndarray:
"""Matched-filter pipeline over every valid pixel.
mode : "amplitude" → MF envelope peak in the SAW window
"tof" → arrival time (ns) of that peak
Only the requested scalar is kept per pixel — the per-shot intermediate
arrays are dropped inside the worker instead of being accumulated.
"""
valid, waves = _valid_ch1_waveforms(angle_view, calib, dc_threshold_mv, background)
img = np.zeros(valid.shape, dtype=np.float32)
if len(waves):
key = "peak_amplitude" if mode == "amplitude" else "peak_time_ns"
def _scalar(w):
return pipeline.process_shot_metrics(w)[key]
n_workers = min(os.cpu_count() or 4, len(waves))
with ThreadPoolExecutor(max_workers=n_workers) as executor:
img[valid] = np.fromiter(executor.map(_scalar, waves),
dtype=np.float32, count=len(waves))
return img
# ---------------------------------------------------------------------------
# SAW signal processing pipeline
# ---------------------------------------------------------------------------
class SawPipeline:
"""EMI-cleaning and SAW extraction pipeline.
Stages (each independently bypassable):
1. EMI gate — cosine-taper the first `emi_gate_ns` ns to suppress the
laser-firing burst at t≈0; leaves the SAW packet alone.
2. Bandpass — 6th-order Butterworth zero-phase (sosfiltfilt).
3. Matched filter — FFT cross-correlation with a Hann-windowed template
built from the average of N clean shots.
4. Analytic — Hilbert transform of MF output → amplitude envelope.
"""
def __init__(self, sample_rate_hz: float,
emi_gate_ns: float = 50.0,
bp_lo_mhz: float = 85.0,
bp_hi_mhz: float = 200.0,
saw_window_ns: tuple[float, float] = (80.0, 350.0)):
self.sample_rate_hz = float(sample_rate_hz)
self.emi_gate_ns = float(emi_gate_ns)
self.bp_lo_mhz = float(bp_lo_mhz)
self.bp_hi_mhz = float(bp_hi_mhz)
self.saw_window_ns = (float(saw_window_ns[0]), float(saw_window_ns[1]))
self.template: np.ndarray | None = None
self._template_fft: dict[int, np.ndarray] = {} # nfft → rfft(template)
self._emi_gate_samples = max(1, int(round(
self.emi_gate_ns * 1e-9 * self.sample_rate_hz)))
nyq = self.sample_rate_hz / 2.0
lo = np.clip(self.bp_lo_mhz * 1e6 / nyq, 1e-6, 0.999)
hi = np.clip(self.bp_hi_mhz * 1e6 / nyq, lo + 1e-6, 0.9999)
# 6th-order Butterworth → 12th-order bandpass; ~120 dB/decade rolloff
self._sos = butter(6, [lo, hi], btype='bandpass', output='sos')
def gate_emi(self, signal: np.ndarray) -> np.ndarray:
"""Cosine-taper (raised cosine 0→1) the first emi_gate samples.
The taper rolls up smoothly from zero so the abrupt EMI burst is
suppressed without introducing a step discontinuity at the gate edge.
"""
n = min(self._emi_gate_samples, len(signal))
out = signal.copy()
out[:n] *= 0.5 * (1.0 - np.cos(np.pi * np.arange(n) / n))
return out
def bandpass(self, signal: np.ndarray) -> np.ndarray:
"""Zero-phase IIR Butterworth bandpass (sosfiltfilt), float32 in/out."""
return sosfiltfilt(self._sos, signal).astype(np.float32, copy=False)
def build_template(self, waveforms: np.ndarray) -> None:
"""Average N shots (EMI-gated + bandpassed), Hann-windowed to the
declared SAW window, to form the matched-filter template."""
processed = np.stack([
self.bandpass(self.gate_emi(np.asarray(w, dtype=np.float32)))
for w in waveforms
])
avg = processed.mean(axis=0)
n = len(avg)
t_ns = np.arange(n) / self.sample_rate_hz * 1e9
i0 = max(0, int(np.searchsorted(t_ns, self.saw_window_ns[0])))
i1 = min(n, int(np.searchsorted(t_ns, self.saw_window_ns[1])))
windowed = np.zeros(n, dtype=np.float32)
if i1 > i0:
windowed[i0:i1] = avg[i0:i1] * np.hanning(i1 - i0)
self.template = windowed
self._template_fft.clear()
def matched_filter(self, signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""FFT cross-correlation with the template → (mf_output, envelope)."""
if self.template is None:
raise RuntimeError("No template — call build_template() first")
n = len(signal)
nfft = 1 << (n + len(self.template) - 1).bit_length()
T = self._template_fft.get(nfft)
if T is None:
T = np.conj(np.fft.rfft(self.template, nfft))
self._template_fft[nfft] = T
S = np.fft.rfft(signal, nfft)
mf = np.fft.irfft(S * T, nfft)[:n]
env = np.abs(hilbert(mf))
return mf.astype(np.float32, copy=False), env.astype(np.float32, copy=False)
def process_shot(self, signal: np.ndarray) -> dict:
"""EMI gate → bandpass → matched filter on one shot; returns every
stage plus metrics (for diagnostics displays)."""
raw = np.asarray(signal, dtype=np.float32)
gated = self.gate_emi(raw)
filtered = self.bandpass(gated)
if self.template is not None:
mf_out, env = self.matched_filter(filtered)
else:
mf_out = filtered.copy()
env = np.abs(hilbert(filtered)).astype(np.float32)
metrics = self._envelope_metrics(env, filtered)
return {
"raw": raw,
"gated": gated,
"filtered": filtered,
"mf_output": mf_out,
"envelope": env,
"sample_rate_hz": self.sample_rate_hz,
**metrics,
}
def process_shot_metrics(self, signal: np.ndarray) -> dict:
"""Like process_shot but returns only the scalar metrics — used for
whole-image sweeps where retaining per-shot arrays would multiply
memory by the pixel count."""
filtered = self.bandpass(self.gate_emi(np.asarray(signal, dtype=np.float32)))
if self.template is not None:
_, env = self.matched_filter(filtered)
else:
env = np.abs(hilbert(filtered))
return self._envelope_metrics(env, filtered)
def _envelope_metrics(self, env: np.ndarray, filtered: np.ndarray) -> dict:
sr = self.sample_rate_hz
t_ns = np.arange(len(env)) / sr * 1e9
s0, s1 = self.saw_window_ns
roi = (t_ns >= s0) & (t_ns <= s1)
if roi.any():
peak_sample = int(np.where(roi)[0][np.argmax(env[roi])])
else:
peak_sample = int(np.argmax(env))
peak_amplitude = float(env[peak_sample])
peak_time_ns = float(peak_sample / sr * 1e9)
# SNR: peak / RMS of the noise floor inside the gated EMI region
noise_seg = filtered[:self._emi_gate_samples]
noise_rms = float(np.sqrt(np.mean(noise_seg ** 2))) if len(noise_seg) else 1.0
return {
"peak_amplitude": peak_amplitude,
"peak_sample": peak_sample,
"peak_time_ns": peak_time_ns,
"snr": peak_amplitude / noise_rms if noise_rms > 0 else 0.0,
}
+472
View File
@@ -0,0 +1,472 @@
"""SRAS binary scan-file format (v7/v11, reading v6/v10 too) — the single
implementation.
Full byte-level spec: scan_format.md. Summary:
header >4sBHfffffffIdBB magic ver n_angles xs_nom ys_nom xd_nom yd_nom
row_spacing velocity laser_freq spf sample_rate
bytes_per_sample n_channels
angle table n_angles × >f
geometry table n_angles × >ffIH (x_start x_delta n_frames n_rows)
row tables (ragged) per angle: n_rows × >f (y positions)
preambles n_channels × (>H length + utf-8 WFMOutpre string)
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``). Because the background
blocks are length-prefixed, the per-angle offsets come from a walk of that
stream at parse time rather than from arithmetic on the geometry table.
Version 11 is the SAW quality check (core.saw_check): byte layout identical
to v7, but every angle declares exactly one row — the row-wise middle of the
ROI. The version byte is the whole difference, and it exists so a reader can
tell a one-row-per-angle check from a full scan that was aborted after its
first row. ``create_scan_file`` enforces the one-row rule at write time.
v6 and v10 are the pre-per-angle-background versions of the same two files.
They are still read (every scan taken before this change is one); nothing
writes them any more, and a v6 file cannot be resumed into, since appending
v7 blocks to it would shift its data.
"""
from __future__ import annotations
import mmap
import struct
from dataclasses import dataclass, field
from pathlib import Path
from typing import BinaryIO
import numpy as np
from core.scan_geometry import AngleGeometry, ScanPlan
MAGIC = b"SRAS"
VERSION = 7
# One row per angle, taken from the middle of the ROI — see core.saw_check.
VERSION_SAW_CHECK = 11
# v6/v10: the same two files with one background for the whole scan, written
# ahead of the data block instead of once per angle. Read-only.
LEGACY_VERSIONS = (6, 10)
WRITABLE_VERSIONS = (VERSION, VERSION_SAW_CHECK)
SUPPORTED_VERSIONS = tuple(sorted(WRITABLE_VERSIONS + LEGACY_VERSIONS))
SAW_CHECK_VERSIONS = (10, VERSION_SAW_CHECK)
HDR_FMT = ">4sBHfffffffIdBB"
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
GEOM_FMT = ">ffIH"
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
BG_LEN_FMT = ">I"
BG_LEN_SIZE = struct.calcsize(BG_LEN_FMT) # 4 bytes
# Oscilloscope channels recorded, in on-disk order.
SCAN_CHANNELS = [1, 3, 4]
STATUS_OK = "OK"
STATUS_TRUNCATED = "TRUNCATED"
STATUS_MISSING = "MISSING"
@dataclass
class ScanHeader:
"""The fixed v6 global header (everything but magic/version)."""
n_angles: int
x_start_nominal: float
y_start_nominal: float
x_delta_nominal: float
y_delta_nominal: float
row_spacing: float
velocity: float
laser_freq: float
samples_per_frame: int
sample_rate: float
bytes_per_sample: int
n_channels: int
@dataclass
class AngleStatus:
"""How much of one angle's declared data is actually on disk."""
index: int
angle_deg: float
n_rows: int # declared
row_bytes: int
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
@property
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],
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 v11 its meaning is
checked here, since nothing downstream can recover from a v11 file that
breaks it.
Returns an open binary file positioned at the start of the data block.
The caller writes each angle as ``write_background_block()`` followed by
that angle's rows, and must close the file (try/finally).
"""
if version not in WRITABLE_VERSIONS:
raise ValueError(
f"Cannot write SRAS format version {version} "
f"(writable: {', '.join(str(v) for v in WRITABLE_VERSIONS)})"
)
if version == VERSION_SAW_CHECK:
bad = [f"{pa.angle_deg:.1f}° has {pa.n_rows}"
for pa in plan.per_angle if pa.n_rows != 1]
if bad:
raise ValueError(
"A v11 SAW-check file holds exactly one row per angle, but "
+ ", ".join(bad) + " — build the plan with "
"core.saw_check.middle_row_plan()."
)
path.parent.mkdir(parents=True, exist_ok=True)
f = open(path, "wb")
f.write(struct.pack(
HDR_FMT, MAGIC, version,
plan.n_angles,
plan.x_start_nominal, plan.y_start_nominal,
plan.x_delta_nominal, plan.y_delta_nominal,
plan.row_spacing,
plan.velocity_mm_s, plan.laser_freq_hz,
samples_per_frame,
sample_rate,
1, # bytes_per_sample: int8 from scope default
len(SCAN_CHANNELS),
))
f.write(struct.pack(f">{plan.n_angles}f", *plan.angles))
for pa in plan.per_angle:
f.write(struct.pack(GEOM_FMT, pa.x_start, pa.x_delta, pa.n_frames, pa.n_rows))
for pa in plan.per_angle:
f.write(struct.pack(f">{pa.n_rows}f", *pa.y_positions))
for p in preambles:
enc = p.encode("utf-8")
f.write(struct.pack(">H", len(enc)))
f.write(enc)
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 (v7/v11, or legacy v6/v10): header, tables, and lazy
(memmap) access.
Parsing reads only the header/tables and the per-angle background blocks
— never the waveform block — so opening a multi-GB file is cheap.
``load_angle``/``load_row`` return read-only numpy views backed by a
shared mmap; no data is copied until the caller computes on it.
``backgrounds[i]`` is angle *i*'s own background (v7/v11) or the file's
single background repeated for every angle (v6/v10), so a reader never
has to branch on the version to subtract the right one.
"""
path: Path
version: int = field(init=False)
header: ScanHeader = field(init=False)
per_angle: list[AngleGeometry] = field(init=False)
preambles: list[str] = field(init=False)
preambles_raw: list[bytes] = field(init=False)
backgrounds: list[bytes] = field(init=False)
data_start_offset: int = field(init=False)
file_size: int = field(init=False)
def __post_init__(self):
self.path = Path(self.path)
self._mmap: mmap.mmap | None = None
self._parse()
def _parse(self):
self.file_size = self.path.stat().st_size
with open(self.path, "rb") as f:
raw = f.read(HDR_SIZE)
if len(raw) < HDR_SIZE:
raise ValueError(f"{self.path.name}: file too short to contain a valid header")
(magic, version, n_angles, x_start_nominal, y_start_nominal,
x_delta_nominal, y_delta_nominal, row_spacing, velocity, laser_freq,
samples_per_frame, sample_rate, bytes_per_sample,
n_channels) = struct.unpack(HDR_FMT, raw)
if magic != MAGIC:
raise ValueError(f"{self.path.name}: not a valid SRAS file (bad magic)")
if version not in SUPPORTED_VERSIONS:
raise ValueError(
f"{self.path.name}: unsupported SRAS format version {version} "
f"(supported: {', '.join(str(v) for v in SUPPORTED_VERSIONS)})"
)
self.version = version
self.header = ScanHeader(
n_angles=n_angles,
x_start_nominal=x_start_nominal, y_start_nominal=y_start_nominal,
x_delta_nominal=x_delta_nominal, y_delta_nominal=y_delta_nominal,
row_spacing=row_spacing, velocity=velocity, laser_freq=laser_freq,
samples_per_frame=samples_per_frame, sample_rate=sample_rate,
bytes_per_sample=bytes_per_sample, n_channels=n_channels,
)
angles = struct.unpack(f">{n_angles}f", f.read(4 * n_angles))
self.per_angle = []
for a in angles:
x_start, x_delta, n_frames, n_rows = struct.unpack(GEOM_FMT, f.read(GEOM_SIZE))
self.per_angle.append(AngleGeometry(
angle_deg=a, x_start=x_start, x_delta=x_delta,
n_frames=n_frames, n_rows=n_rows,
))
for pa in self.per_angle:
pa.y_positions = list(struct.unpack(f">{pa.n_rows}f", f.read(4 * pa.n_rows)))
self.preambles_raw = []
for _ in range(n_channels):
(plen,) = struct.unpack(">H", f.read(2))
self.preambles_raw.append(f.read(plen))
self.preambles = [p.decode("utf-8", errors="replace") for p in self.preambles_raw]
shared_bg = None
if self.is_legacy_layout:
# v6/v10: one background for the whole file, ahead of the data.
(n_bg,) = struct.unpack(BG_LEN_FMT, f.read(BG_LEN_SIZE))
shared_bg = f.read(n_bg)
self.data_start_offset = f.tell()
self._walk_data_block(f, shared_bg)
def _walk_data_block(self, f, shared_bg: bytes | None):
"""Locate every angle's background block and the rows behind it.
v7 interleaves a length-prefixed background ahead of each angle's
rows, so the offsets are no longer pure arithmetic over the geometry
table — the walk reads each prefix as it goes. Past the frontier of
a partial file there is nothing to read, so the remaining offsets are
predicted from the block a writer would have produced (a full record),
which is exactly where a resumed scan writes.
"""
expected_bg = BG_LEN_SIZE + self.header.samples_per_frame
self.backgrounds, self._bg_present = [], []
self._bg_offsets, self._data_offsets = [], []
cursor = self.data_start_offset
for ai, pa in enumerate(self.per_angle):
if shared_bg is not None:
bg, bg_bytes, present = shared_bg, 0, True
else:
bg, bg_bytes, present = self._read_background(f, cursor, expected_bg)
self.backgrounds.append(bg)
self._bg_present.append(present)
self._bg_offsets.append(cursor)
cursor += bg_bytes
self._data_offsets.append(cursor)
cursor += self.row_bytes(ai) * pa.n_rows
def _read_background(self, f, offset: int, expected_bytes: int):
"""One angle's background block as (waveform, block_bytes, present).
A block that runs past the end of the file was never written: the
walk keeps going with the size a writer would have used, and the
angle is reported MISSING.
"""
if offset + BG_LEN_SIZE > self.file_size:
return b"", expected_bytes, False
f.seek(offset)
(n_bg,) = struct.unpack(BG_LEN_FMT, f.read(BG_LEN_SIZE))
if offset + BG_LEN_SIZE + n_bg > self.file_size:
return b"", expected_bytes, False
return f.read(n_bg), BG_LEN_SIZE + n_bg, True
@property
def is_saw_check(self) -> bool:
"""True for a middle-row SAW quality check rather than a scan."""
return self.version in SAW_CHECK_VERSIONS
@property
def is_legacy_layout(self) -> bool:
"""True for v6/v10: one background for the file, not one per angle."""
return self.version in LEGACY_VERSIONS
# ── Frontier / truncation analysis ───────────────────────────────────────
def row_bytes(self, angle_idx: int) -> int:
pa = self.per_angle[angle_idx]
return (self.header.n_channels * pa.n_frames
* self.header.samples_per_frame * self.header.bytes_per_sample)
def angle_status(self) -> list[AngleStatus]:
"""Walk declared per-row byte counts against the actual file size.
Because the data is one contiguous append-only stream, once an angle
is found short every later angle is necessarily absent too — there is
a single frontier past which nothing has been written yet. An angle
whose background block never made it to disk is short by definition,
even though no row of it was due yet.
"""
statuses = []
frontier_seen = False
for ai, pa in enumerate(self.per_angle):
row_bytes = self.row_bytes(ai)
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 data_offset + declared_bytes <= self.file_size:
n_rows_available = pa.n_rows
status = STATUS_OK
else:
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, 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:
"""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 ─────────────────────────────────────────────────────
def _ensure_mmap(self) -> mmap.mmap:
if self._mmap is None:
# The mapping stays valid after the file object is closed, so
# don't hold the descriptor open for the (long) life of a viewer
# session.
with open(self.path, "rb") as f:
self._mmap = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
return self._mmap
def _dtype(self) -> np.dtype:
return np.dtype(np.int16 if self.header.bytes_per_sample == 2 else np.int8)
def 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).
``n_rows`` limits the view to the rows actually on disk (pass
``AngleStatus.n_rows_available`` for truncated files); default is the
declared row count.
"""
pa = self.per_angle[angle_idx]
h = self.header
if n_rows is None:
n_rows = pa.n_rows
start = self.angle_data_offset(angle_idx)
count = n_rows * h.n_channels * pa.n_frames * h.samples_per_frame
arr = np.frombuffer(self._ensure_mmap(), dtype=self._dtype(),
count=count, offset=start)
arr = arr.reshape(n_rows, h.n_channels, pa.n_frames, h.samples_per_frame)
arr.flags.writeable = False
return arr
def load_row(self, angle_idx: int, row: int, channel_idx: int) -> np.ndarray:
"""Read-only view of one row/channel, shape (n_frames, samples_per_frame)."""
pa = self.per_angle[angle_idx]
h = self.header
ch_bytes = pa.n_frames * h.samples_per_frame * h.bytes_per_sample
start = (self.angle_data_offset(angle_idx) + row * self.row_bytes(angle_idx)
+ channel_idx * ch_bytes)
arr = np.frombuffer(self._ensure_mmap(), dtype=self._dtype(),
count=pa.n_frames * h.samples_per_frame, offset=start)
arr = arr.reshape(pa.n_frames, h.samples_per_frame)
arr.flags.writeable = False
return arr
def close(self):
"""Release this file's hold on the mapping.
Views handed out earlier stay valid — they keep the mapping alive
until they are garbage-collected, at which point the OS frees it.
"""
if self._mmap is not None:
try:
self._mmap.close()
except BufferError:
pass # live numpy views still reference the buffer
self._mmap = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# ── Axes helpers (viewer conveniences, derived from header fields) ───────
def pixel_pitch_x_mm(self) -> float:
"""Distance between adjacent frames along X."""
return self.header.velocity / self.header.laser_freq
def x_axis_mm(self, angle_idx: int) -> np.ndarray:
pa = self.per_angle[angle_idx]
return pa.x_start + np.arange(pa.n_frames) * self.pixel_pitch_x_mm()
def time_axis_ns(self) -> np.ndarray:
h = self.header
return np.arange(h.samples_per_frame) / h.sample_rate * 1e9
def freq_axis_mhz(self, nfft: int) -> np.ndarray:
return np.fft.rfftfreq(nfft, d=1.0 / self.header.sample_rate) / 1e6
def plan_from_header(sras: SrasFile) -> ScanPlan:
"""Reconstruct the ScanPlan a file was written with (for resume)."""
h = sras.header
return ScanPlan(
x_start_nominal=h.x_start_nominal, y_start_nominal=h.y_start_nominal,
x_delta_nominal=h.x_delta_nominal, y_delta_nominal=h.y_delta_nominal,
row_spacing=h.row_spacing,
velocity_mm_s=h.velocity, laser_freq_hz=h.laser_freq,
per_angle=list(sras.per_angle),
)
+25
View File
@@ -0,0 +1,25 @@
# Genesis laser — hardware verification checklist
`hardware/genesis_core.py` was extracted from `tools/genesis_laser_gui.py`,
but the extraction changed behavior in ways only the bench can adjudicate.
Until every row below is resolved, **both files stay in the repo unchanged**:
`genesis_laser_gui.py` is the reference implementation, `genesis_core.py`
(+ `tools/genesis_laser_control.py`) is the intended successor.
Run these with the Genesis laser connected, interlock chain accessible, and
a front-panel/manual reference for current and temperature readouts.
| # | Divergence | Bench test | Resolution |
|---|---|---|---|
| 1 | **ADS7828 command byte.** Reference passes raw command bytes (`0x84`, `0xe4`, `0x94` — `genesis_laser_gui.py:73-77`); core synthesizes `0x80 \| (ch<<4) \| 0x0c` → `0x8C` for ch0 (`genesis_core.py:413`), different PD1/PD0 power-down bits. | Read the same ADC channel through both implementations; compare against the front-panel current readout. Also check for settling differences right after power-up. | Keep whichever matches the panel; fix the other. |
| 2 | **LDD enable polarity.** Reference `get_ldd_enable()` returns `not bool(value & 0x01)` ("Inverted logic", `genesis_laser_gui.py:602-612`); core returns the un-inverted bit (`genesis_core.py:585-599`). Same register, opposite answers. | With emission verifiably OFF (keyswitch off), read LDD status via both. Exactly one will say "disabled". | Adopt the polarity that matches reality; document the register semantics inline. |
| 3 | **Shutter: manual or bit-controlled?** Reference docs say "this laser has a MANUAL shutter" and `emergency_stop()` deliberately leaves it alone; core `set_shutter()` toggles a PCA9555 bit and `emergency_stop()`/`enter_safe_state()` rely on it. | Toggle `set_shutter()` from core with the beam blocked; observe whether anything physical actuates. | If the bit is inert, remove `set_shutter` and fix the safe-state functions; if real, correct the `tools/` docs. |
| 4 | **ADC filtering dropped.** Reference reads 3× and takes median (`i2c_read_discard_high_low`, `genesis_laser_gui.py:341-364`) or retries until two reads agree; core does single unfiltered reads. | Log ~100 consecutive current readings through core; if the spread is more than display noise, filtering was load-bearing. | Port the median-of-3 helper into `genesis_core.I2CProtocol`. |
| 5 | **Scaling dropped.** Reference converts to Amps/Watts (`AMPS_FULLSCALE * ADC_TO_VOLTS`); core returns raw 0–4095 counts. | Compare a scaled reading against the front panel. | Port the scaling constants + conversion into core. |
| 6 | **Temperatures + power monitoring dropped.** `get_main_temp` / `get_etalon_temp` / `get_shg_temp` / `get_power_actual` exist only in the reference. | Confirm each channel's reading is sane vs. front panel. | Port the four getters into core. |
| 7 | **`pre_flight_check()` dropped.** Reference validates remote-enable + keyswitch + interlock before emission. | n/a — code review + one interlock-open test. | Port into core; call it from `genesis_laser_control.py` before enabling. |
When all rows are resolved: port the verified behavior into
`genesis_core.py`, update `tools/genesis_laser_control.py`, delete
`tools/genesis_laser_gui.py`, and remove this checklist plus the warning
header in `genesis_core.py`.
-193
View File
@@ -1,193 +0,0 @@
"""
Genesis Laser Worker Thread
Manages Genesis laser connection in a separate thread to keep the UI responsive.
Provides async querying and status monitoring via Qt signals.
"""
from PyQt6 import QtCore
from hardware.genesis_core import SerialComm, I2CProtocol, I2CDevices, LaserControl
import queue
import time
from typing import Optional
class GenesisCommand:
"""Represents a genesis laser command"""
def __init__(self, cmd_type: str, **kwargs):
self.cmd_type = cmd_type
self.params = kwargs
class GenesisWorker(QtCore.QObject):
"""
Worker object for handling Genesis laser control in a separate thread.
Signals:
connected: Emitted when laser connects successfully
disconnected: Emitted when laser disconnects
connection_failed: Emitted when connection fails (error_msg: str)
laser_info_updated: Emitted with laser status
query_completed: Emitted when a query operation completes (result: dict)
error_occurred: Emitted when an error occurs (error_msg: str)
"""
# Signals
connected = QtCore.pyqtSignal()
disconnected = QtCore.pyqtSignal()
connection_failed = QtCore.pyqtSignal(str)
laser_info_updated = QtCore.pyqtSignal(dict) # Status information
query_completed = QtCore.pyqtSignal(dict) # Query result
error_occurred = QtCore.pyqtSignal(str) # Error message
def __init__(self, port: str = "/dev/ttyUSB0", baudrate: int = 9600):
super().__init__()
self.serial_comm = SerialComm()
self.i2c_protocol = I2CProtocol(self.serial_comm)
self.i2c_devices = I2CDevices(self.i2c_protocol)
self.laser_control = LaserControl(self.i2c_devices)
self.port = port
self.baudrate = baudrate
self.is_connected = False
self.command_queue = queue.Queue()
self.running = True
# Last known laser state
self.last_laser_state = {}
# Update interval for status polling
self.last_status_update_time = 0
self.status_update_interval = 1.0 # seconds
@QtCore.pyqtSlot()
def run(self):
"""Main worker loop - processes commands from queue"""
print(f"Genesis laser worker thread started - connecting to {self.port}")
# Try to connect on startup
if self.connect():
self.connected.emit()
else:
error_msg = f"Failed to connect to Genesis laser on {self.port}"
print(error_msg)
self.connection_failed.emit(error_msg)
while self.running:
try:
# Check for commands with timeout to allow periodic status updates
try:
cmd = self.command_queue.get(timeout=0.05) # 50ms timeout
self.process_command(cmd)
except queue.Empty:
pass
# Periodically update status if connected
if self.is_connected:
current_time = time.time()
if current_time - self.last_status_update_time >= self.status_update_interval:
self.update_laser_status()
self.last_status_update_time = current_time
except Exception as e:
print(f"Error in genesis worker loop: {e}")
self.error_occurred.emit(str(e))
# Cleanup on exit
self.disconnect()
print("Genesis laser worker thread stopped")
def connect(self) -> bool:
"""Establish connection to the laser"""
try:
if self.serial_comm.connect(self.port, self.baudrate):
self.is_connected = True
print(f"Connected to Genesis laser on {self.port}")
return True
else:
print(f"Failed to open serial port {self.port}")
return False
except Exception as e:
print(f"Connection error: {e}")
return False
def disconnect(self):
"""Disconnect from the laser"""
if self.is_connected:
self.serial_comm.disconnect()
self.is_connected = False
self.disconnected.emit()
print("Disconnected from Genesis laser")
def process_command(self, cmd: GenesisCommand):
"""Process a command from the queue"""
if not self.is_connected:
self.error_occurred.emit("Laser not connected")
return
try:
if cmd.cmd_type == "query_all":
result = self.query_all_status()
self.query_completed.emit(result)
elif cmd.cmd_type == "query_current":
result = {"current": self.laser_control.get_current_actual()}
self.query_completed.emit(result)
elif cmd.cmd_type == "query_interlock":
result = {"interlock": self.laser_control.get_interlock_status()}
self.query_completed.emit(result)
elif cmd.cmd_type == "set_current":
value = cmd.params.get("value", 0)
success = self.laser_control.set_current(int(value))
self.query_completed.emit({"success": success})
elif cmd.cmd_type == "set_shutter":
state = cmd.params.get("state", False)
success = self.laser_control.set_shutter(state)
self.query_completed.emit({"success": success})
else:
self.error_occurred.emit(f"Unknown command: {cmd.cmd_type}")
except Exception as e:
self.error_occurred.emit(f"Command execution error: {e}")
def update_laser_status(self):
"""Query and emit current laser status"""
if not self.is_connected:
return
try:
status = {
"connected": True,
"current_actual": self.laser_control.get_current_actual(),
"interlock_status": self.laser_control.get_interlock_status(),
"ldd_enable_status": self.laser_control.get_ldd_enable_status(),
"psglue_in_status": self.laser_control.get_psglue_in_status(),
"psglue_out_status": self.laser_control.get_psglue_out_status(),
"head_dio_status": self.laser_control.get_head_dio_status(),
}
# Only emit if something changed
if status != self.last_laser_state:
self.last_laser_state = status
self.laser_info_updated.emit(status)
except Exception as e:
print(f"Error updating laser status: {e}")
def query_all_status(self) -> dict:
"""Query all laser status information"""
return {
"connected": True,
"current_actual": self.laser_control.get_current_actual(),
"interlock_status": self.laser_control.get_interlock_status(),
"ldd_enable_status": self.laser_control.get_ldd_enable_status(),
"psglue_in_status": self.laser_control.get_psglue_in_status(),
"psglue_out_status": self.laser_control.get_psglue_out_status(),
"head_dio_status": self.laser_control.get_head_dio_status(),
}
def queue_command(self, cmd: GenesisCommand):
"""Queue a command for execution"""
self.command_queue.put(cmd)
def stop(self):
"""Stop the worker thread"""
self.running = False
+1
View File
@@ -0,0 +1 @@
"""Shared PyQt6 layer: adapters and widgets used by more than one app."""
+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)
+113
View File
@@ -0,0 +1,113 @@
"""Qt bridge over the headless AngleInspector.
Inspection is command-driven rather than one long run: the operator clicks an
angle, waits for the stage to park, looks at the scope, clicks again. That is
exactly the shape QueueWorker exists for — it blocks on the queue between
commands instead of polling, so an inspection window left open costs nothing.
Every stage move and rotation blocks for seconds, so all of it runs on this
worker's thread; the window only ever enqueues and reacts to signals.
"""
from __future__ import annotations
import traceback
from PyQt6.QtCore import pyqtSignal
from core.angle_inspect import AngleInspector, InspectCallbacks
from gui.qt_workers import QueueWorker
class QtAngleInspector(QueueWorker):
"""Runs an AngleInspector on its own QThread and republishes its events."""
ready = pyqtSignal(object) # InspectionPoint — start() succeeded
start_failed = pyqtSignal(str)
point_changed = pyqtSignal(object) # InspectionPoint
status_msg = pyqtSignal(str)
busy_changed = pyqtSignal(bool) # True while a move is in flight
stopped = pyqtSignal()
def __init__(self, stage, scope, rotator, plan, on_inspect_active=None):
super().__init__()
self._on_inspect_active = on_inspect_active
callbacks = InspectCallbacks(
on_status=self.status_msg.emit,
on_point=self.point_changed.emit,
on_busy=self.busy_changed.emit,
)
self._inspector = AngleInspector(stage, scope, rotator, plan,
callbacks=callbacks)
self._handlers = {
"start": self._do_start,
"goto": self._do_goto,
"new_point": self._do_new_point,
"stop": self._do_stop,
}
# ── Introspection (safe from the GUI thread: reads the plan, not the rig) ──
def angle_labels(self) -> list[str]:
return self._inspector.angle_labels()
@property
def n_angles(self) -> int:
return self._inspector.n_angles
# ── Command submission (GUI thread) ───────────────────────────────────────
def request_start(self):
self._enqueue("start")
def request_goto(self, angle_idx: int):
self._enqueue("goto", angle_idx=angle_idx)
def request_new_point(self):
self._enqueue("new_point")
def request_stop(self):
self._enqueue("stop")
# ── Handlers (worker thread) ──────────────────────────────────────────────
def _do_start(self):
if self._on_inspect_active is not None:
self._on_inspect_active(True)
try:
point = self._inspector.start()
except Exception as exc:
traceback.print_exc()
if self._on_inspect_active is not None:
self._on_inspect_active(False)
self.start_failed.emit(str(exc))
return
self.ready.emit(point)
def _do_goto(self, angle_idx: int):
self._inspector.goto_angle(angle_idx)
def _do_new_point(self):
self._inspector.new_point()
def _do_stop(self):
try:
self._inspector.stop()
finally:
if self._on_inspect_active is not None:
self._on_inspect_active(False)
self.stopped.emit()
def _on_stop(self):
"""Worker loop exiting — make sure the rig is left in a safe state.
Covers the case where the window is closed without a clean stop
command reaching the queue.
"""
try:
self._inspector.stop()
except Exception:
traceback.print_exc()
finally:
if self._on_inspect_active is not None:
self._on_inspect_active(False)
+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)
+55
View File
@@ -0,0 +1,55 @@
"""Qt adapter over the (Qt-free) T3RDriver.
The driver fires its callbacks on the reader thread. This adapter turns
each one into a Qt signal emitted from that thread; because the adapter
lives on the GUI thread, Qt queues the delivery and slots run on the GUI
thread — which is what widget code requires.
Command methods are forwarded to the driver, so panels can hold the adapter
alone and use it exactly like the old QObject driver.
"""
from __future__ import annotations
from PyQt6.QtCore import QObject, pyqtSignal
from hardware.t3r_driver import T3RDriver
class QtT3RAdapter(QObject):
port_opened = pyqtSignal()
handshake_ok = pyqtSignal(int, int, int) # proto_ver, fw_ver, num_channels
disconnected = pyqtSignal(str) # reason ("" = user-initiated)
info_updated = pyqtSignal(int, object) # ch, proto.Info
drv_status_updated = pyqtSignal(int, object)
position_updated = pyqtSignal(int, int)
motion_done = pyqtSignal(int, int)
stopped = pyqtSignal(int, int)
fault_occurred = pyqtSignal(int, int)
ack_received = pyqtSignal(int, int)
frame_received = pyqtSignal(int, bytes)
_EVENTS = ("port_opened", "handshake_ok", "disconnected", "info_updated",
"drv_status_updated", "position_updated", "motion_done",
"stopped", "fault_occurred", "ack_received", "frame_received")
def __init__(self, driver: T3RDriver | None = None, parent=None):
super().__init__(parent)
self.driver = driver if driver is not None else T3RDriver()
for name in self._EVENTS:
getattr(self.driver, name).connect(getattr(self, name).emit)
# Class attributes (constants) the panels read off the driver
CHANNEL_NAMES = T3RDriver.CHANNEL_NAMES
GR_AXIS_CH = T3RDriver.GR_AXIS_CH
@property
def is_open(self) -> bool:
return self.driver.is_open
def __getattr__(self, name):
# Only reached for attributes this QObject doesn't define, i.e. the
# driver's command API (open/close/move/jog/enable/...).
if name.startswith("_"):
raise AttributeError(name)
return getattr(object.__getattribute__(self, "driver"), name)
+118
View File
@@ -0,0 +1,118 @@
"""Shared Qt worker base for hardware that must be driven off the GUI thread.
Every device worker in this project was the same shape: a command queue, a
`while running: get(timeout=…)` loop, an if/elif dispatch, and a standard
connected/disconnected/failed signal trio. The timeout-poll versions woke
10–20 times a second forever, even with nothing to do; this base blocks on
the queue instead and wakes only when there is work.
"""
from __future__ import annotations
import queue
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
_STOP = object()
class QueueWorker(QObject):
"""Base for a device worker living on its own QThread.
Subclasses register handlers in ``self._handlers`` (command name →
callable) and call ``self._enqueue(name, **kwargs)`` from the GUI thread.
Override ``_on_stop`` to release hardware when the loop exits.
"""
connected = pyqtSignal()
disconnected = pyqtSignal()
connection_failed = pyqtSignal(str)
error_occurred = pyqtSignal(str)
def __init__(self):
super().__init__()
self._cmd_q: queue.Queue = queue.Queue()
self._handlers: dict[str, callable] = {}
self._running = False
self.is_connected = False
# ── Command submission (GUI thread) ───────────────────────────────────────
def _enqueue(self, cmd_type: str, **kwargs):
self._cmd_q.put((cmd_type, kwargs))
def stop_worker(self):
self._cmd_q.put(_STOP)
# ── Worker loop ───────────────────────────────────────────────────────────
@pyqtSlot()
def run(self):
self._running = True
while self._running:
item = self._cmd_q.get() # blocks — no idle wake-ups
if item is _STOP:
break
cmd_type, kwargs = item
handler = self._handlers.get(cmd_type)
if handler is None:
self.error_occurred.emit(f"Unknown command: {cmd_type}")
continue
try:
handler(**kwargs)
except Exception as exc:
self.error_occurred.emit(str(exc))
self._running = False
self._on_stop()
def _on_stop(self):
"""Release hardware when the loop exits. Override as needed."""
class PollingQueueWorker(QueueWorker):
"""QueueWorker that also polls the device on an interval.
The poll is self-rescheduling: the next one is queued only after the
previous finishes, so a device slower than the interval can never
accumulate a backlog of stale poll commands (which is exactly what the
old free-running QTimer did to the Helios laser).
"""
POLL_CMD = "_poll"
def __init__(self, poll_interval_s: float = 1.0):
super().__init__()
self._poll_interval_s = poll_interval_s
self._polling = False
self._handlers[self.POLL_CMD] = self._poll_and_reschedule
def start_polling(self):
if not self._polling:
self._polling = True
self._enqueue(self.POLL_CMD)
def stop_polling(self):
self._polling = False
def _poll_and_reschedule(self):
if not self._polling or not self.is_connected:
self._polling = False
return
try:
self._poll_once()
finally:
if self._polling and self.is_connected:
self._schedule_next_poll()
def _schedule_next_poll(self):
# A timer thread rather than a sleep here, so the worker stays
# responsive to commands during the interval.
import threading
t = threading.Timer(self._poll_interval_s,
lambda: self._enqueue(self.POLL_CMD))
t.daemon = True
t.start()
self._poll_timer = t
def _poll_once(self):
"""Read device state and emit updates. Implemented by subclasses."""
+101
View File
@@ -0,0 +1,101 @@
"""Qt bridge over the headless ScanEngine.
Exposes exactly the signal surface the old in-GUI ScanWorker had, so window
code connects to it unchanged, and adapts prompts/progress to Qt. The
engine itself stays Qt-free and reusable by any other front end.
"""
from __future__ import annotations
import threading
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):
"""Runs a ScanEngine on the caller's QThread and republishes its events.
Move this to a QThread and connect `started` → `run`, exactly like the
previous ScanWorker.
"""
started = pyqtSignal()
completed = pyqtSignal()
dc_bias_updated = pyqtSignal(int, object) # row index, per-frame DC means
failed = pyqtSignal(str)
row_started = pyqtSignal(int, int, int, int) # row, n_rows, angle_idx, n_angles
row_done = pyqtSignal(int, int, int, int)
status_msg = pyqtSignal(str)
user_prompt = pyqtSignal(str, str) # title, message
paused_changed = pyqtSignal(bool) # True while paused at a row boundary
def __init__(self, stage, scope, rotator, plan, out_path,
resume=None, on_scan_active=None, burst_mode=False,
strict_rows=False, file_version=VERSION):
super().__init__()
self._prompt_event = threading.Event()
self._on_scan_active = on_scan_active
callbacks = ScanCallbacks(
on_status=self.status_msg.emit,
on_started=self.started.emit,
on_row_started=self.row_started.emit,
on_row_done=self.row_done.emit,
on_dc_bias=self.dc_bias_updated.emit,
on_paused_changed=self.paused_changed.emit,
prompt=self._blocking_prompt,
)
self._engine = ScanEngine(stage, scope, rotator, plan, out_path,
resume=resume, callbacks=callbacks,
burst_mode=burst_mode,
strict_rows=strict_rows,
file_version=file_version)
# ── Engine control (called from the GUI thread) ───────────────────────────
def abort(self):
self._engine.abort()
self._prompt_event.set() # release a scan parked on a prompt
def pause(self):
self._engine.pause()
def resume(self):
self._engine.resume()
def acknowledge_prompt(self):
"""Called from the GUI thread when the operator dismisses a prompt."""
self._prompt_event.set()
# ── Callback plumbing ─────────────────────────────────────────────────────
def _blocking_prompt(self, title: str, message: str):
"""Ask the GUI thread, then block the scan thread until answered.
Polls rather than waiting forever so an abort during a prompt takes
effect immediately instead of deadlocking the scan thread.
"""
self._prompt_event.clear()
self.user_prompt.emit(title, message)
while not self._prompt_event.wait(0.2):
if self._engine.aborted:
return
@pyqtSlot()
def run(self):
if self._on_scan_active is not None:
self._on_scan_active(True)
try:
self._engine.run()
self.completed.emit()
except ScanAborted as exc:
self.failed.emit(str(exc))
except Exception as exc:
traceback.print_exc()
self.failed.emit(str(exc))
finally:
if self._on_scan_active is not None:
self._on_scan_active(False)
+192
View File
@@ -0,0 +1,192 @@
"""Widgets shared by the main app and the per-device test benches.
Each of these was hand-rolled several times across the apps, with slightly
different behaviour every time (only one log console bounded its buffer,
only one port picker sorted by device type).
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QComboBox, QGroupBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget,
)
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)
btn.setChecked(checked)
btn.setText(text)
btn.setEnabled(enabled)
btn.blockSignals(False)
class PortSelector(QWidget):
"""Serial port combo + refresh button, likeliest device first."""
def __init__(self, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(0, 0, 0, 0)
self.combo = QComboBox()
self.combo.setMinimumWidth(220)
refresh = QPushButton("⟳")
refresh.setFixedWidth(30)
refresh.setToolTip("Rescan serial ports")
refresh.clicked.connect(self.refresh)
row.addWidget(self.combo, stretch=1)
row.addWidget(refresh)
self.refresh()
def refresh(self):
"""Repopulate the list, preserving the current selection if present."""
current = self.current_port()
self.combo.clear()
for device, label in scored_ports():
self.combo.addItem(label, device)
if self.combo.count() == 0:
self.combo.addItem("(no serial ports found)", None)
elif current:
idx = self.combo.findData(current)
if idx >= 0:
self.combo.setCurrentIndex(idx)
def current_port(self) -> str | None:
return self.combo.currentData()
def set_port(self, device: str):
idx = self.combo.findData(device)
if idx >= 0:
self.combo.setCurrentIndex(idx)
class ConnectionBar(QGroupBox):
"""Port picker + connect toggle + status label.
Emits connect_requested(port) / disconnect_requested(); the owner drives
the state back through on_connected/on_disconnected/on_failed so the
button can never disagree with the hardware.
"""
connect_requested = pyqtSignal(str)
disconnect_requested = pyqtSignal()
def __init__(self, title: str = "Connection", parent=None):
super().__init__(title, parent)
layout = QVBoxLayout(self)
self.port_selector = PortSelector()
layout.addWidget(self.port_selector)
row = QHBoxLayout()
self.toggle = QPushButton("Connect")
self.toggle.setCheckable(True)
self.toggle.toggled.connect(self._on_toggled)
self.status = QLabel("Disconnected")
self.status.setStyleSheet("font-weight: bold;")
row.addWidget(self.toggle)
row.addWidget(self.status, stretch=1)
layout.addLayout(row)
def _on_toggled(self, checked: bool):
if checked:
port = self.port_selector.current_port()
if not port:
set_toggle(self.toggle, False, "Connect")
self.status.setText("No port selected")
return
self.toggle.setText("Connecting…")
self.toggle.setEnabled(False)
self.connect_requested.emit(port)
else:
self.disconnect_requested.emit()
def on_connected(self, detail: str = "Connected"):
set_toggle(self.toggle, True, "Disconnect")
self.status.setText(detail)
self.status.setStyleSheet("font-weight: bold; color: green;")
def on_disconnected(self, detail: str = "Disconnected"):
set_toggle(self.toggle, False, "Connect")
self.status.setText(detail)
self.status.setStyleSheet("font-weight: bold;")
def on_failed(self, message: str):
set_toggle(self.toggle, False, "Connect")
self.status.setText(f"Failed: {message}")
self.status.setStyleSheet("font-weight: bold; color: red;")
class LogConsole(QWidget):
"""Bounded, monospace, auto-scrolling log view with a Clear button.
The block cap is the point: unbounded QTextEdit logs grew for the whole
session in every app that hand-rolled one.
"""
KINDS = {"tx": "→", "rx": "←", "info": "●", "err": "!"}
def __init__(self, max_blocks: int = 2000, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = QPlainTextEdit()
self.view.setReadOnly(True)
self.view.setMaximumBlockCount(max_blocks)
self.view.setFont(mono_font())
layout.addWidget(self.view)
row = QHBoxLayout()
row.addStretch(1)
clear = QPushButton("Clear")
clear.clicked.connect(self.view.clear)
row.addWidget(clear)
layout.addLayout(row)
def log(self, message: str, kind: str = "info"):
self.view.appendPlainText(f"{self.KINDS.get(kind, '●')} {message}")
self.view.verticalScrollBar().setValue(
self.view.verticalScrollBar().maximum())
class StatusGrid(QWidget):
"""Label/value rows with consistent ok/warn/error colouring."""
_COLORS = {"ok": "green", "warn": "#b8860b", "error": "red", "": ""}
def __init__(self, fields: list[str], parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._values: dict[str, QLabel] = {}
for name in fields:
row = QHBoxLayout()
label = QLabel(f"{name}:")
value = QLabel("—")
value.setAlignment(Qt.AlignmentFlag.AlignRight
| Qt.AlignmentFlag.AlignVCenter)
row.addWidget(label)
row.addWidget(value, stretch=1)
layout.addLayout(row)
self._values[name] = value
def set(self, name: str, text: str, state: str = ""):
label = self._values.get(name)
if label is None:
return
label.setText(text)
color = self._COLORS.get(state, "")
label.setStyleSheet(f"color: {color};" if color else "")
+6 -7
View File
@@ -1,7 +1,6 @@
"""Hardware driver modules for ScanEngine-3"""
from .pybbd202 import ThorlabsServoDriver, TriggerBitsServo, AXIS_X, AXIS_Y, CONTROLLER
from .t3r_driver import T3RDriver
from .uc480_camera import *
from .tektronix_base import *
from .coherent_hops_laser import *
from .genesis_core import *
"""Hardware driver modules for ScanEngine-3.
Import drivers by module (e.g. ``from hardware.t3r_driver import T3RDriver``);
nothing is re-exported here so that importing one driver never drags in
another driver's SDK (the uEye camera stack in particular).
"""
-45
View File
@@ -1,45 +0,0 @@
"""
Coherent HOPS Laser Driver - Stub Module
This is a temporary stub to allow testing camera integration.
"""
class CoherentHOPSLaser:
"""Stub class for Coherent HOPS Laser"""
pass
class DummyLaser:
"""Dummy laser for testing without hardware"""
def connect(self):
"""Simulate connection"""
pass
def disconnect(self):
"""Simulate disconnection"""
pass
def get_hardware_id(self):
"""Return simulated hardware ID"""
return "SIM-12345"
def get_laser_model(self):
"""Return simulated model"""
return "Genesis Simulator"
def get_interlock_status(self):
"""Return simulated interlock status"""
return "OK"
def get_key_switch_status(self):
"""Return simulated key switch status"""
return "ON"
def get_temperature_main(self):
"""Return simulated main temperature"""
return 25.5
def get_temperature_eta(self):
"""Return simulated ETA temperature"""
return 26.3
+10 -1
View File
@@ -2,6 +2,16 @@
Genesis SLM MX 532 Laser Core Hardware Control Module
======================================================
.. warning::
QUARANTINED — do not modify semantics or dedupe against
``tools/genesis_laser_gui.py`` until the bench checklist in
``docs/genesis_verification.md`` has been run. This module was
extracted from that GUI but diverges from it in ways only hardware can
adjudicate: ADS7828 command byte (0x84 vs 0x8C), LDD enable polarity
(inverted vs not), shutter semantics (manual vs bit-controlled),
dropped median-of-3 ADC filtering, dropped Amps/Watts scaling, dropped
temperature reads and pre-flight check.
This module provides low-level hardware control for the Genesis SLM MX 532 laser
using NXP I2C-over-serial protocol. It contains reusable classes for serial
communication, I2C protocol handling, device control, and laser operations.
@@ -34,7 +44,6 @@ from typing import Optional, List
from enum import IntEnum
import serial
from serial.tools import list_ports
# ============================================================================
+71 -208
View File
@@ -3,12 +3,13 @@ Helios Laser System Driver
Basic implementation for controlling the Helios pulsed laser.
"""
import serial
import time
import logging
from typing import Optional, List
from enum import Enum
from hardware.serial_util import open_8n1
logger = logging.getLogger(__name__)
@@ -28,13 +29,7 @@ class HeliosLaser:
"""
def __init__(self, port: str = None, timeout: float = 1.0):
"""
Initialize Helios laser driver.
Args:
port: Serial port (e.g., '/dev/ttyUSB0' or 'COM5')
timeout: Serial timeout in seconds
"""
"""Initialize Helios laser driver."""
self.port = port
self.timeout = timeout
self.serial = None
@@ -42,21 +37,12 @@ class HeliosLaser:
@staticmethod
def list_available_ports() -> List[str]:
"""List available serial ports"""
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
return [port.device for port in ports]
"""List available serial ports, likeliest devices first."""
from hardware.serial_util import list_port_devices
return list_port_devices()
def connect(self, port: str = None) -> bool:
"""
Connect to the Helios laser.
Args:
port: Serial port (uses stored port if None)
Returns:
True if connection successful
"""
"""Connect to the Helios laser."""
if port:
self.port = port
@@ -65,14 +51,7 @@ class HeliosLaser:
return False
try:
self.serial = serial.Serial(
port=self.port,
baudrate=9600,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout
)
self.serial = open_8n1(self.port, baudrate=9600, timeout=self.timeout)
time.sleep(0.1) # Allow time for connection to stabilize
self.is_connected = True
logger.info(f"Connected to Helios laser on {self.port}")
@@ -98,15 +77,7 @@ class HeliosLaser:
self.serial = None
def _send_command(self, command: str) -> bool:
"""
Send a command to the laser.
Args:
command: ASCII command string (without CR)
Returns:
True if sent successfully
"""
"""Send a command to the laser."""
if not self.is_connected or not self.serial:
logger.error("Not connected to laser")
return False
@@ -122,37 +93,36 @@ class HeliosLaser:
return False
def _query(self, command: str) -> Optional[str]:
"""
Send a query and read response.
"""Send a query and return the value from its response.
Args:
command: ASCII query command (without CR or ?)
Returns:
Response value string or None if error
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.
"""
try:
# Clear any pending data in the buffer
# Clear any stale bytes so a previous timed-out reply can't be
# mistaken for this command's response.
self.serial.reset_input_buffer()
time.sleep(0.05)
if not self._send_command(command):
return None
time.sleep(0.2) # Give device time to respond
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}")
# Helios format: "COMMAND = VALUE UNIT"
# Extract just the value part
# Helios format: "COMMAND = VALUE UNIT" — take just the value
if '=' in response:
parts = response.split('=')
if len(parts) >= 2:
value_part = parts[1].strip()
# Remove unit suffix if present (e.g., "ns", "mA", "mW")
value = value_part.split()[0]
return value
# Strip the unit suffix if present (e.g. "ns", "mA", "mW")
fields = value_part.split()
if fields:
return fields[0]
return response
@@ -160,16 +130,19 @@ class HeliosLaser:
logger.error(f"Failed to read response for '{command}': {e}")
return None
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)
if raw is None:
return None
try:
return int(raw)
except ValueError:
logger.error(f"Query '{command}' returned non-integer {raw!r}")
return None
def set_frequency_hz(self, frequency: int) -> bool:
"""
Set laser pulse frequency in Hz.
Args:
frequency: Frequency in Hz (16700 - 125000)
Returns:
True if successful
"""
"""Set laser pulse frequency in Hz."""
if not (16700 <= frequency <= 125000):
logger.error(f"Frequency {frequency} Hz out of range (16700-125000)")
return False
@@ -186,15 +159,7 @@ class HeliosLaser:
return self._send_command(command)
def set_current_ma(self, current: int) -> bool:
"""
Set pump diode current in mA.
Args:
current: Current in mA (0 - 2000 for this model)
Returns:
True if successful
"""
"""Set pump diode current in mA."""
if not (0 <= current <= 2000):
logger.error(f"Current {current} mA out of range (0-2000)")
return False
@@ -203,28 +168,12 @@ class HeliosLaser:
return self._send_command(command)
def set_pulse_mode(self, mode: PulseMode) -> bool:
"""
Set pulse mode.
Args:
mode: PulseMode enumeration value
Returns:
True if successful
"""
"""Set pulse mode."""
command = f"LDG {mode.value}"
return self._send_command(command)
def set_laser_enable(self, enable: bool) -> bool:
"""
Enable or disable laser emission.
Args:
enable: True to enable, False to disable
Returns:
True if successful
"""
"""Enable or disable laser emission."""
command = f"LDO {1 if enable else 0}"
success = self._send_command(command)
@@ -235,60 +184,24 @@ class HeliosLaser:
return success
def is_laser_enabled(self) -> bool:
"""
Check if laser is currently enabled.
Returns:
True if laser is enabled
"""
response = self._query("LDO")
if response:
try:
return int(response) == 1
except ValueError:
logger.error(f"Invalid response for LDO: {response}")
return False
"""True if laser emission is currently enabled."""
return self._query_int("LDO") == 1
def get_frequency_hz(self) -> Optional[int]:
"""
Get current laser frequency in Hz.
Returns:
Frequency in Hz or None if error
"""
response = self._query("LDF")
if response:
try:
period_ns = int(response)
return int(1e9 / period_ns)
except (ValueError, ZeroDivisionError):
logger.error(f"Invalid response for LDF: {response}")
return None
"""Current laser frequency in Hz, or None on error."""
period_ns = self._query_int("LDF")
if not period_ns:
return None
return int(1e9 / period_ns)
def get_current_ma(self) -> Optional[int]:
"""
Get current pump diode current in mA.
Returns:
Current in mA or None if error
"""
response = self._query("LDS")
if response:
try:
return int(response)
except ValueError:
logger.error(f"Invalid response for LDS: {response}")
return None
"""Current pump diode current in mA, or None on error."""
return self._query_int("LDS")
def _query_millicelsius(self, command: str) -> Optional[float]:
"""Query a temperature register (returns milli-°C) and convert to °C."""
response = self._query(command)
if response:
try:
return int(response) / 1000.0
except ValueError:
logger.error(f"Invalid response for {command}: {response}")
return None
"""Query a temperature register (milli-°C) and convert to °C."""
value = self._query_int(command)
return None if value is None else value / 1000.0
def get_diode_temp_c(self) -> Optional[float]:
"""Diode temperature in °C (LTA, 5000–50000 milli-°C)."""
@@ -303,46 +216,22 @@ class HeliosLaser:
return self._query_millicelsius("EOA")
def get_controller_serial(self) -> Optional[str]:
"""
Get controller serial number.
Returns:
Serial number string or None if error
"""
"""Controller serial number, or None on error."""
return self._query("CSR")
def get_head_serial(self) -> Optional[str]:
"""
Get laser head serial number.
Returns:
Serial number string or None if error
"""
"""Laser head serial number, or None on error."""
return self._query("HSR")
def get_status_registers(self) -> tuple:
"""Query the LER, LCE and CCE status registers.
Each is a bitmask (sum of flags); non-zero means active faults,
cleared with reset_faults(). Returns (ler, lce, cce), any of which
is None if that register could not be read.
"""
Query LER, LCE, and CCE status registers.
Each register is a bitmask (sum of flags). Non-zero values indicate
active faults. Reset with reset_faults().
Returns:
Tuple of (ler, lce, cce) as ints, or None for each on error.
"""
def _read_reg(cmd):
resp = self._query(cmd)
if resp is not None:
try:
return int(resp)
except ValueError:
logger.error(f"Invalid response for {cmd}: {resp}")
return None
ler = _read_reg("LER")
lce = _read_reg("LCE")
cce = _read_reg("CCE")
return (ler, lce, cce)
return (self._query_int("LER"), self._query_int("LCE"),
self._query_int("CCE"))
def reset_faults(self) -> bool:
"""
@@ -364,38 +253,19 @@ class HeliosLaser:
return ok
def get_remote_enable(self) -> Optional[bool]:
"""
Query the remote enable state (LRE - activates utility connector pin 8).
Returns:
True if remote enable is active, False if not, None on error
"""
response = self._query("LRE")
if response is not None:
try:
return int(response) == 1
except ValueError:
logger.error(f"Invalid response for LRE: {response}")
return None
"""Remote enable state (LRE — utility connector pin 8); None on error."""
value = self._query_int("LRE")
return None if value is None else value == 1
def send_raw_command(self, command: str) -> Optional[str]:
"""
Send a raw command string and return the raw response.
Useful for diagnostics. Sends *command* + CR, waits briefly,
then reads whatever the device returns (up to the first CR or timeout).
Returns:
Raw response string (decoded, stripped) or None on error.
"""
"""Send a raw command and return the unparsed response (diagnostics)."""
if not self.is_connected or not self.serial:
logger.error("Not connected to laser")
return None
try:
self.serial.reset_input_buffer()
time.sleep(0.05)
self.serial.write((command + '\r').encode('ascii'))
time.sleep(0.3)
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)
@@ -405,18 +275,11 @@ class HeliosLaser:
return None
def set_remote_enable(self, enable: bool) -> bool:
"""
Set the remote enable state (LRE - utility connector pin 8).
Args:
enable: True to activate remote enable, False to deactivate
Returns:
True if successful
"""
"""Set the remote enable state (LRE - utility connector pin 8)."""
command = f"LRE {1 if enable else 0}"
return self._send_command(command)
def __del__(self):
"""Destructor - ensure cleanup"""
self.disconnect()
# No __del__: it used to call disconnect(), which disables the laser and
# writes to the serial port from the garbage collector at an
# unpredictable time (including interpreter shutdown, when the port may
# already be torn down). Callers close the driver explicitly.
-2
View File
@@ -1,9 +1,7 @@
"""pybbd202 - Thorlabs BBD202 servo stage driver (pyserial-based)"""
from .bbd20x import ThorlabsServoDriver
from .apt_constants import TriggerBitsServo, StatusBits
# Axis address constants
AXIS_X = 0x21
AXIS_Y = 0x22
CONTROLLER = 0x11
+15 -10
View File
@@ -32,16 +32,6 @@ class StatusBits(IntFlag):
MOT_SB_COMMUTATIONERROR | MOT_SB_OVERLOAD |
MOT_SB_ERROR | MOT_SB_INSTRERROR)
class TriggerBitsStepper(IntFlag):
TRIGIN_ENABLE = 0x01,
TRIGOUT_ENABLE = 0x02,
TRIGOUT_MODEFOLLOW = 0x04,
TRIGOUT_MODEMOVEEND = 0x08,
TRIG_RELMOVE = 0x10,
TRIG_ABSMOVE = 0x20,
TRIG_HOMEMOVE = 0x40,
TRIGOUT_NOTRIGIN = 0x80
class TriggerBitsServo(IntFlag):
TRIGIN_HIGH = 0x01
TRIGIN_RELMOVE = 0x02
@@ -55,5 +45,20 @@ class TriggerBitsServo(IntFlag):
TRIGOUT_MAXV = TRIGOUT_HIGH | TRIGOUT_MAXVELOCITY
# Gate off: no trigger-out function selected, so the pin idles inactive.
#
# Treat this as unverified until it has been checked on the rig. §7.6 of
# docs/hardware/BBD203_Communications_Protocol.md documents `mode` as an
# enumeration capping at 0x11, which flatly contradicts the bitmask this
# driver actually sends (TRIGOUT_MAXV = 0x90, known working), so the doc
# cannot settle what makes the pin idle low. Under the bitmask reading 0x00
# clears everything and the pin sits low. If instead TRIGOUT_HIGH is an
# active-high *polarity* bit, clearing it means active-low and the pin idles
# HIGH — which in burst mode floods the acquisition with flyback frames.
# ScanEngine's gate-off preflight catches that; if it trips, change this to
# TriggerBitsServo.TRIGOUT_HIGH.
TRIGOUT_GATE_OFF = TriggerBitsServo(0)
+1 -3
View File
@@ -4,7 +4,6 @@
Version 1
'''
import struct
from .apt_constants import StatusBits as sb
class APTProtocol():
ADDRESSES = { 'HOST_PC': 0x01, 'CONTROLLER': 0x11,
@@ -281,8 +280,7 @@ class APTProtocol():
raise ValueError(f"No data fields have been defined for {msg_spec['name']}!")
unpacked_payload = struct.unpack(fmt_string, payload)
data = {field: value for field, value in zip(data_fields,
unpacked_payload)}
data = dict(zip(data_fields, unpacked_payload, strict=True))
data['destination'] = dest
data['source'] = src
+88 -92
View File
@@ -7,17 +7,23 @@
import time
from threading import Thread, Event
from queue import Queue, Empty
from .apt_constants import StatusBits, TriggerBitsServo
from .apt_constants import StatusBits, TriggerBitsServo, TRIGOUT_GATE_OFF
from .apt_messages import APTProtocol
from .serial_comms import SerialSnooper
# APT bay addresses for the two stage axes
AXIS_X_ADDR = 0x21
AXIS_Y_ADDR = 0x22
class ThorlabsServoDriver():
# These are specific to the MLS203-1
# change for a different application
counts_per_mm = 20000
accel_scaling = 13.744
velocity_scaling = 134217.73
TRAVEL_MM = (110.0, 75.0) # usable travel per channel (X, Y)
def __init__(self):
self.am_connected = False
@@ -83,10 +89,27 @@ class ThorlabsServoDriver():
pass
if not self.bays_present:
print(" [WARN] No bays detected!")
# Fail loudly: reporting success with no bays made connecting to
# the wrong port look like it worked, and every later command
# then silently went nowhere.
self.disconnect()
raise RuntimeError(
f"No BBD202 bays responded on {port}. Check the port, the "
f"controller power, and that no other process holds the device."
)
self.am_connected = True
@staticmethod
def _channel_for(axis):
"""Map an APT axis address to this driver's 0-based channel index."""
if axis == AXIS_X_ADDR:
return 0
if axis == AXIS_Y_ADDR:
return 1
raise ValueError(f"Unknown axis address 0x{axis:02x} "
f"(expected 0x{AXIS_X_ADDR:02x} or 0x{AXIS_Y_ADDR:02x})")
# ── Worker threads ───────────────────────────────────────────
def _tx_worker(self):
@@ -223,15 +246,18 @@ class ThorlabsServoDriver():
APTProtocol.build_message(0x0002, destination=addr,
source=0x01))
time.sleep(0.2) # let TX worker flush them out
# Stop all worker loops, then wait for threads to exit
# Stop all worker loops, then wait for threads to exit. Joins
# are bounded: disconnect() runs from closeEvent, and a wedged
# reader must not hang application shutdown.
self.am_listening = False
self.serial_snoop.stop()
self._rx_thread.join()
self._tx_thread.join()
self._poll_thread.join()
self.serial_snoop.join()
for thread in (self._rx_thread, self._tx_thread, self._poll_thread,
self.serial_snoop):
if thread is not None:
thread.join(timeout=2.0)
# Close port only after all threads are done
self.serial_snoop.close()
self.am_connected = False
# ── State update handlers ────────────────────────────────────
@@ -288,25 +314,6 @@ class ThorlabsServoDriver():
self.am_moving[ch] = False
return
def _update0x0212(self, msg):
'''
_update0x0212 - internal function that listens for CHANENABLESTATE
messages.
'''
if msg['source'] == 0x21:
ch = 0
elif msg['source'] == 0x22:
ch = 1
else:
raise ValueError("Wherever this message came from, it's WRONG!")
if msg['enable_state'] == 0x01:
self.am_enabled[ch] = True # enabled
elif msg['enable_state'] == 0x02:
self.am_enabled[ch] = False # disabled
else:
raise ValueError("Am I a joke to you? WTF did this even come from?!")
# ── Axis control ─────────────────────────────────────────────
def enable_axis(self, axis):
@@ -324,13 +331,8 @@ class ThorlabsServoDriver():
toggle_enabled_state(axis) - enables the axis if disabled. disables
if enabled. not much more to it.
'''
if axis == 0x21:
ch = 0
elif axis == 0x22:
ch = 1
else:
raise ValueError("I don't know that axis!")
# get the old state and flip it like a sample
ch = self._channel_for(axis)
# Read the cached state and invert it
new_state = not self.am_enabled[ch]
self.send_message(0x0210, chan_ident=1,
enable_state=0x01 if new_state else 0x02,
@@ -342,12 +344,7 @@ class ThorlabsServoDriver():
power up. Default timeout is 60s, but 20-30s is fine as well if
you're in that much of a hurry.
'''
if axis == 0x21:
ch = 0
elif axis == 0x22:
ch = 1
else:
raise ValueError("I don't know that axis!")
self._channel_for(axis) # validate the axis address
self.send_and_wait(0x0443, timeout=timeout, retries=0, chan_ident=1,
destination=axis, source=0x01)
@@ -359,11 +356,11 @@ class ThorlabsServoDriver():
moves the specified axis a specified distance in mm.
Timeout defaults to ten seconds.
'''
# sanity check
if axis == 0x21 and abs(distance_in_mm) > 110.0:
raise ValueError("You can't move farther than the stage is long.")
elif axis == 0x22 and abs(distance_in_mm) > 75.0:
raise ValueError("You can't move farther than the stage is wide.")
travel = self.TRAVEL_MM[self._channel_for(axis)]
if abs(distance_in_mm) > travel:
raise ValueError(
f"Relative move of {distance_in_mm:.3f} mm exceeds the "
f"{travel:g} mm travel of this axis.")
_distance_in_encoder = int(round(distance_in_mm * self.counts_per_mm))
self.send_and_wait(0x0448, timeout=timeout, chan_ident=1,
@@ -377,10 +374,12 @@ class ThorlabsServoDriver():
moves the specified axis to an absolute position in mm.
Timeout defaults to ten seconds.
'''
if axis == 0x21 and (position_in_mm < 0.0 or position_in_mm > 110.0):
raise ValueError("Position out of range for X axis (0-110 mm).")
elif axis == 0x22 and (position_in_mm < 0.0 or position_in_mm > 75.0):
raise ValueError("Position out of range for Y axis (0-75 mm).")
ch = self._channel_for(axis)
travel = self.TRAVEL_MM[ch]
if not 0.0 <= position_in_mm <= travel:
raise ValueError(
f"Position {position_in_mm:.3f} mm is out of range for the "
f"{'XY'[ch]} axis (0-{travel:g} mm).")
_position_in_encoder = int(round(position_in_mm * self.counts_per_mm))
self.send_and_wait(0x0453, timeout=timeout, chan_ident=1,
@@ -396,12 +395,7 @@ class ThorlabsServoDriver():
for the specified axis. Returns a dict with keys:
min_velocity (mm/s), acceleration (mm/s2), max_velocity (mm/s)
'''
if axis == 0x21:
ch = 0
elif axis == 0x22:
ch = 1
else:
raise ValueError("I don't know that axis!")
ch = self._channel_for(axis)
result = self.send_and_wait(0x0414, timeout=timeout, chan_ident=1,
zero_this=0x00, destination=axis,
@@ -424,12 +418,7 @@ class ThorlabsServoDriver():
Values are in mm/s and mm/s2 respectively. Any parameter
left as None keeps its current value.
'''
if axis == 0x21:
ch = 0
elif axis == 0x22:
ch = 1
else:
raise ValueError("I don't know that axis!")
ch = self._channel_for(axis)
# Only query current params if we need to fill in a missing value
if max_velocity is None or acceleration is None:
@@ -473,38 +462,45 @@ class ThorlabsServoDriver():
destination=axis, source=0x01)
return TriggerBitsServo(result['mode'])
def set_trigger_trigin_high(self, axis):
'''Set trigger input to logic high.'''
self.set_trigger(axis, TriggerBitsServo.TRIGIN_HIGH)
def set_trigger_trigin_relmove(self, axis):
'''Set trigger input to initiate a relative move.'''
self.set_trigger(axis, TriggerBitsServo.TRIGIN_RELMOVE)
def set_trigger_trigin_absmove(self, axis):
'''Set trigger input to initiate an absolute move.'''
self.set_trigger(axis, TriggerBitsServo.TRIGIN_ABSMOVE)
def set_trigger_trigin_homemove(self, axis):
'''Set trigger input to initiate a home move.'''
self.set_trigger(axis, TriggerBitsServo.TRIGIN_HOMEMOVE)
def set_trigger_trigout_high(self, axis):
'''Set trigger output to logic high.'''
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_HIGH)
def set_trigger_trigout_inmotion(self, axis):
'''Set trigger output high while axis is in motion.'''
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_INMOTION)
def set_trigger_trigout_motioncomplete(self, axis):
'''Set trigger output to pulse when motion completes.'''
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MOTIONCOMPLETE)
def set_trigger_trigout_maxvelocity(self, axis):
'''Set trigger output to pulse at max velocity.'''
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MAXVELOCITY)
def set_trigger_trigout_maxv(self, axis):
'''Set trigger output high + pulse at max velocity (TRIGOUT_MAXV).'''
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MAXV)
def set_trigger_gate_off(self, axis):
'''Drive the trigger output inactive, so no pulses reach the gate.'''
self.set_trigger(axis, TRIGOUT_GATE_OFF)
def arm_scan_gate(self, axis, armed, verify=True):
'''
arm_scan_gate(axis, armed): Arms or drops the max-velocity trigger
output the oscilloscope AND-gate uses.
Burst acquisition runs one scope acquisition across many rows, so
the gate must be armed only for the acquiring pass and dropped for
the flyback — otherwise the return move hits max velocity and
injects frames between rows.
'''
mode = TriggerBitsServo.TRIGOUT_MAXV if armed else TRIGOUT_GATE_OFF
if verify:
self.set_trigger_verified(axis, mode)
else:
self.set_trigger(axis, mode)
def set_trigger_verified(self, axis, mode, timeout=5.0, retries=2):
'''
set_trigger_verified(axis, mode): Sets the trigger mode and reads
it back to confirm it landed.
set_trigger is fire-and-forget over the shared TX queue. Burst
acquisition toggles the gate between every row, and a dropped
change there silently fills the acquisition with flyback frames —
so confirm rather than assume.
'''
for _ in range(retries + 1):
self.set_trigger(axis, mode)
if int(self.get_trigger(axis, timeout=timeout)) == int(mode):
return
raise RuntimeError(
f"Axis 0x{axis:02X} did not accept trigger mode 0x{int(mode):02X} "
f"after {retries + 1} attempts"
)
+44
View File
@@ -0,0 +1,44 @@
"""Shared serial-port helpers: 8N1 open and scored port enumeration.
Qt-free — GUI code adapts the (device, label) list into its own widgets.
"""
from __future__ import annotations
import serial
from serial.tools import list_ports
# Substrings that suggest a USB-serial adapter we actually talk to
# (ESP32-based T3R, CP210x/CH340 dongles, CDC-ACM devices); matching ports
# sort first in pickers.
DEVICE_HINTS = ("esp32", "jtag", "espressif", "usb serial", "cp210", "ch340", "cdc")
def open_8n1(port: str, baudrate: int, timeout: float,
write_timeout: float | None = None) -> serial.Serial:
"""Open a serial port with the 8N1 framing every device here uses."""
return serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=timeout,
write_timeout=write_timeout,
)
def scored_ports() -> list[tuple[str, str]]:
"""Enumerate serial ports as (device, human label), likeliest-first."""
ports = list(list_ports.comports())
def score(p):
text = f"{p.description} {p.manufacturer or ''} {p.product or ''}".lower()
return -sum(h in text for h in DEVICE_HINTS)
ports.sort(key=score)
return [(p.device, f"{p.device} — {p.description or p.device}") for p in ports]
def list_port_devices() -> list[str]:
"""Plain device-path list, likeliest-first."""
return [dev for dev, _ in scored_ports()]
+119 -63
View File
@@ -1,8 +1,12 @@
"""T3R Stepper Controller driver for ScanEngine-3.
Qt-based driver that owns the serial connection and an internal reader QThread.
All events arrive as Qt signals; all commands are fire-and-forget writes.
Create in the main (GUI) thread; no additional thread management required.
Owns the serial connection and an internal reader thread. Events are
delivered as plain-Python callbacks (see ``Signal``); commands are
fire-and-forget writes. No Qt — GUIs wrap this with gui.qt_t3r.QtT3RAdapter,
which re-emits every event as a queued Qt signal on the GUI thread.
Callbacks run on the reader thread. Keep them short, and never touch Qt
widgets from one directly.
Gear train (stage rotation via GR-axis, ch3):
Motor → 10T pinion → 30T idler → 125T index gear (stage)
@@ -11,23 +15,53 @@ Gear train (stage rotation via GR-axis, ch3):
from __future__ import annotations
import logging
import threading
import serial
from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal
from . import t3r_protocol as proto
from .serial_util import open_8n1
logger = logging.getLogger(__name__)
class _T3RReader(QThread):
"""Blocking read loop — runs on its own QThread."""
class Signal:
"""Minimal observer slot: ``connect(fn)`` then ``emit(*args)``.
frame = pyqtSignal(int, bytes) # (cmd, payload) for each valid frame
finished_reason = pyqtSignal(str) # "" = clean stop, else I/O error string
Mirrors the pyqtSignal API used by the existing panels so the same call
sites work against either this driver or a Qt adapter over it. A raising
subscriber is logged and skipped so one bad listener cannot kill the
reader thread.
"""
def __init__(self, ser: serial.Serial):
super().__init__()
__slots__ = ("_subs", "_name")
def __init__(self, name: str = ""):
self._subs: list = []
self._name = name
def connect(self, fn) -> None:
self._subs.append(fn)
def disconnect(self, fn) -> None:
if fn in self._subs:
self._subs.remove(fn)
def emit(self, *args) -> None:
for fn in list(self._subs):
try:
fn(*args)
except Exception:
logger.exception("T3R %s subscriber failed", self._name)
class _Reader(threading.Thread):
"""Blocking read loop — decodes frames and hands them to `on_frame`."""
def __init__(self, ser, on_frame, on_finished):
super().__init__(daemon=True, name="T3RReader")
self._ser = ser
self._on_frame = on_frame
self._on_finished = on_finished
self._running = True
self._parser = proto.FrameParser()
@@ -44,20 +78,20 @@ class _T3RReader(QThread):
break
if data:
for cmd, payload in self._parser.feed(data):
self.frame.emit(cmd, payload)
self.finished_reason.emit(reason)
self._on_frame(cmd, payload)
self._on_finished(reason)
class T3RDriver(QObject):
"""Qt-based driver for the T3R four-channel stepper controller.
class T3RDriver:
"""Driver for the T3R four-channel stepper controller.
Usage::
driver = T3RDriver()
driver.handshake_ok.connect(lambda pv, fw, nc: print("connected"))
driver.info_updated.connect(on_info)
driver.connect("/dev/ttyUSB0")
driver.handshake_ok.connect(lambda pv, fw, nc: ...)
driver.open("/dev/ttyUSB0")
driver.move(0, steps=3200, velocity=8000, accel=4000)
driver.wait_motion_done(0, timeout=30.0)
"""
CHANNEL_NAMES = ["T-axis (focus)", "Axis 1", "Axis 2", "GR-axis"]
@@ -68,32 +102,33 @@ class T3RDriver(QObject):
GEAR_TEETH_MOTOR = 10
GEAR_TEETH_STAGE = 125 # idler is 30T but does not change ratio
# ── Signals ───────────────────────────────────────────────────────────────
POLL_INTERVAL_S = 0.25
port_opened = pyqtSignal() # serial port open; PING sent
handshake_ok = pyqtSignal(int, int, int) # proto_ver, fw_ver, num_channels
disconnected = pyqtSignal(str) # reason ("" = user-initiated)
info_updated = pyqtSignal(int, object) # ch, proto.Info
drv_status_updated = pyqtSignal(int, object) # ch, proto.DrvStatus
position_updated = pyqtSignal(int, int) # ch, position (microsteps)
motion_done = pyqtSignal(int, int) # ch, final_position
stopped = pyqtSignal(int, int) # ch, final_position
fault_occurred = pyqtSignal(int, int) # ch, fault_mask
ack_received = pyqtSignal(int, int) # req_cmd, status (0=OK)
frame_received = pyqtSignal(int, bytes) # raw (cmd, payload) for log
def __init__(self, parent=None):
super().__init__(parent)
self._ser: serial.Serial | None = None
self._reader: _T3RReader | None = None
def __init__(self):
self._ser = None
self._reader: _Reader | None = None
self._write_lock = threading.Lock()
self._tearing_down = False
self._is_open = False
self._poll_timer = QTimer(self)
self._poll_timer.setInterval(250)
self._poll_timer.timeout.connect(self._poll)
self._poll_stop = threading.Event()
self._poll_thread: threading.Thread | None = None
# Per-channel motion-completion events, so a caller can block on a
# move finishing instead of guessing its duration.
self._motion_events = [threading.Event() for _ in range(proto.NUM_CHANNELS)]
self.port_opened = Signal("port_opened") # ()
self.handshake_ok = Signal("handshake_ok") # proto_ver, fw_ver, n_ch
self.disconnected = Signal("disconnected") # reason ("" = user)
self.info_updated = Signal("info_updated") # ch, proto.Info
self.drv_status_updated = Signal("drv_status_updated") # ch, proto.DrvStatus
self.position_updated = Signal("position_updated") # ch, position
self.motion_done = Signal("motion_done") # ch, final_position
self.stopped = Signal("stopped") # ch, final_position
self.fault_occurred = Signal("fault_occurred") # ch, fault_mask
self.ack_received = Signal("ack_received") # req_cmd, status
self.frame_received = Signal("frame_received") # raw cmd, payload
# ── Connection ────────────────────────────────────────────────────────────
@@ -101,26 +136,24 @@ class T3RDriver(QObject):
def is_open(self) -> bool:
return self._is_open
def connect(self, port: str, baud: int = 115200) -> None:
"""Open the serial port and start the reader. Emits port_opened on success."""
def open(self, port: str, baud: int = 115200) -> None:
"""Open the serial port and start the reader; emits port_opened."""
if self._is_open:
self.disconnect()
self.close()
try:
self._ser = serial.Serial(port, baudrate=baud, timeout=0.05)
self._ser = open_8n1(port, baudrate=baud, timeout=0.05)
except Exception as exc:
raise RuntimeError(f"Cannot open {port}: {exc}") from exc
self._tearing_down = False
self._is_open = True
self._reader = _T3RReader(self._ser)
self._reader.frame.connect(self._on_frame)
self._reader.finished_reason.connect(self._on_reader_finished)
self._reader = _Reader(self._ser, self._on_frame, self._on_reader_finished)
self._reader.start()
self.port_opened.emit()
self.send_frame(proto.ping()) # handshake; polling starts on PONG
def disconnect(self) -> None:
"""Close port and stop polling."""
def close(self) -> None:
"""Close the port and stop polling."""
self._teardown("")
def _on_reader_finished(self, reason: str):
@@ -131,16 +164,21 @@ class T3RDriver(QObject):
if self._tearing_down or not self._is_open:
return
self._tearing_down = True
self._poll_timer.stop()
self.stop_polling()
self._is_open = False
# Release anyone blocked in wait_motion_done so a disconnect during a
# move raises there instead of hanging until the timeout.
for ev in self._motion_events:
ev.set()
reader, self._reader = self._reader, None
ser, self._ser = self._ser, None
if reader is not None:
reader.stop()
if QThread.currentThread() is not reader:
reader.wait(1000)
if threading.current_thread() is not reader:
reader.join(1.0)
if ser is not None:
try:
ser.close()
@@ -186,6 +224,7 @@ class T3RDriver(QObject):
self.send_frame(proto.set_current(ch, run_ma, hold_ma, ihold_delay))
def move(self, ch: int, steps: int, velocity: int, accel: int):
self._motion_events[ch].clear()
self.send_frame(proto.move(ch, steps, velocity, accel))
def jog(self, ch: int, velocity: int, accel: int):
@@ -215,30 +254,44 @@ class T3RDriver(QObject):
# ── Rotation helpers ──────────────────────────────────────────────────────
def steps_for_angle(self, angle_deg: float, microsteps: int) -> int:
"""Compute GR-axis microsteps needed to rotate the stage by angle_deg."""
"""GR-axis microsteps needed to rotate the stage by angle_deg."""
gear_ratio = self.GEAR_TEETH_STAGE / self.GEAR_TEETH_MOTOR
steps_per_stage_rev = self.MOTOR_FULL_STEPS_PER_REV * microsteps * gear_ratio
return round(steps_per_stage_rev * angle_deg / 360.0)
def rotate_stage(self, angle_deg: float, microsteps: int,
velocity: int = 8000, accel: int = 4000):
"""Move GR-axis by the number of steps that rotate the stage by angle_deg."""
steps = self.steps_for_angle(angle_deg, microsteps)
self.move(self.GR_AXIS_CH, steps, velocity, accel)
"""Move GR-axis by the steps that rotate the stage by angle_deg."""
self.move(self.GR_AXIS_CH, self.steps_for_angle(angle_deg, microsteps),
velocity, accel)
def wait_motion_done(self, ch: int, timeout: float) -> bool:
"""Block until the channel reports MOTION_DONE. False on timeout.
Cleared by ``move()``, set by the MOTION_DONE event and by teardown,
so a disconnect mid-move unblocks immediately.
"""
return self._motion_events[ch].wait(timeout)
# ── Polling ───────────────────────────────────────────────────────────────
def start_polling(self):
self._poll_timer.start()
if self._poll_thread is not None and self._poll_thread.is_alive():
return
self._poll_stop.clear()
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True,
name="T3RPoll")
self._poll_thread.start()
def stop_polling(self):
self._poll_timer.stop()
self._poll_stop.set()
def _poll(self):
if not self._is_open:
return
for ch in range(proto.NUM_CHANNELS):
self.send_frame(proto.get_info(ch))
def _poll_loop(self):
while not self._poll_stop.wait(self.POLL_INTERVAL_S):
if not self._is_open:
break
for ch in range(proto.NUM_CHANNELS):
self.send_frame(proto.get_info(ch))
# ── Frame dispatcher ──────────────────────────────────────────────────────
@@ -274,14 +327,17 @@ class T3RDriver(QObject):
elif cmd == proto.EVT_MOTION_DONE:
ev = proto.decode_event_position(payload)
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
self._motion_events[ev.ch].set()
self.motion_done.emit(ev.ch, ev.position)
elif cmd == proto.EVT_STOPPED:
ev = proto.decode_event_position(payload)
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
self._motion_events[ev.ch].set()
self.stopped.emit(ev.ch, ev.position)
elif cmd == proto.EVT_FAULT:
ev = proto.decode_fault(payload)
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
self._motion_events[ev.ch].set()
self.fault_occurred.emit(ev.ch, ev.position)
-22
View File
@@ -193,14 +193,6 @@ def set_position(ch: int, position: int) -> bytes:
return build_frame(CMD_SET_POSITION, struct.pack("<Bi", ch, position))
def read_reg(ch: int, reg: int) -> bytes:
return build_frame(CMD_READ_REG, struct.pack("<BB", ch, reg))
def write_reg(ch: int, reg: int, value: int) -> bytes:
return build_frame(CMD_WRITE_REG, struct.pack("<BBI", ch, reg, value))
# ---------------------------------------------------------------------------
# Response / event decoders. Each returns a dataclass (or None on bad length).
# ---------------------------------------------------------------------------
@@ -261,13 +253,6 @@ class Position:
position: int
@dataclass
class Reg:
ch: int
reg: int
value: int
def decode_pong(p: bytes):
if len(p) < 4:
return None
@@ -304,13 +289,6 @@ def decode_position(p: bytes):
return Position(ch, pos)
def decode_reg(p: bytes):
if len(p) < 6:
return None
ch, reg, value = struct.unpack_from("<BBI", p, 0)
return Reg(ch, reg, value)
def decode_event_position(p: bytes):
"""MOTION_DONE / STOPPED share the (ch, position) layout."""
return decode_position(p)
+183 -1125
View File
File diff suppressed because it is too large Load Diff
+15 -126
View File
@@ -14,7 +14,6 @@ from PyQt6.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtGui import QImage
import logging
import threading
from contextlib import contextmanager
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
@@ -92,12 +91,7 @@ class UC480Camera(QObject):
error_occurred = pyqtSignal(str) # Emitted when an error occurs
def __init__(self, camera_id: int = 1):
"""
Initialize the uC480 camera driver.
Args:
camera_id: Camera ID (1-based; use is_GetCameraList to find IDs)
"""
"""Initialize the uC480 camera driver."""
super().__init__()
self.camera_id = camera_id
@@ -132,12 +126,7 @@ class UC480Camera(QObject):
self._settings_lock = threading.Lock()
def initialize(self) -> bool:
"""
Initialize the camera and allocate memory.
Returns:
True if successful, False otherwise
"""
"""Initialize the camera and allocate memory."""
try:
# Initialize camera. After is_ExitCamera the UI124x series
# resets and re-enumerates on USB (firmware reload), so retry
@@ -265,12 +254,7 @@ class UC480Camera(QObject):
logger.error(f"is_ExitCamera failed: {ret} — camera handle may still be held by daemon")
def start_capture(self) -> bool:
"""
Start continuous video capture.
Returns:
True if successful, False otherwise
"""
"""Start continuous video capture."""
if not self.is_initialized:
logger.error("Camera not initialized")
return False
@@ -312,12 +296,7 @@ class UC480Camera(QObject):
return ret == ueye.IS_SUCCESS
def stop_capture(self) -> bool:
"""
Stop continuous video capture.
Returns:
True if successful, False otherwise
"""
"""Stop continuous video capture."""
if not self.is_capturing:
return True
@@ -331,36 +310,8 @@ class UC480Camera(QObject):
logger.info("Video capture stopped")
return True
@contextmanager
def _capture_paused(self):
"""
Context manager that temporarily stops live video while a camera
parameter is being changed, then restarts it. Many IDS cameras
return IS_CANT_COMMUNICATE_WITH_DRIVER (17) or IS_NO_SUCCESS (-1)
when gain/exposure commands are issued during active capture.
"""
with self._settings_lock:
was_capturing = self.is_capturing
if was_capturing:
ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
self.is_capturing = False
try:
yield
finally:
if was_capturing:
ret = ueye.is_CaptureVideo(self.h_cam, ueye.IS_DONT_WAIT)
if ret == ueye.IS_SUCCESS:
self.is_capturing = True
else:
logger.error(f"Failed to restart capture after settings change: {ret}")
def get_frame(self) -> Optional[QImage]:
"""
Capture a single frame from the camera.
Returns:
QImage if successful, None otherwise
"""
"""Capture a single frame from the camera."""
if not self.is_initialized:
logger.error("Camera not initialized")
return None
@@ -401,15 +352,7 @@ class UC480Camera(QObject):
return None
def set_exposure(self, exposure_ms: float) -> bool:
"""
Set camera exposure time.
Args:
exposure_ms: Exposure time in milliseconds
Returns:
True if successful, False otherwise
"""
"""Set camera exposure time."""
if not self.is_initialized:
return False
@@ -429,12 +372,7 @@ class UC480Camera(QObject):
return False
def get_exposure(self) -> Optional[float]:
"""
Get current exposure time.
Returns:
Exposure time in milliseconds, or None if failed
"""
"""Get current exposure time."""
if not self.is_initialized:
return None
@@ -452,12 +390,7 @@ class UC480Camera(QObject):
return None
def get_pixel_clock_range(self) -> Optional[Tuple[int, int, int]]:
"""
Query the sensor's supported pixel clock range.
Returns:
(min_mhz, max_mhz, increment_mhz), or None if the query failed
"""
"""Query the sensor's supported pixel clock range."""
if not self.is_initialized:
return None
@@ -476,15 +409,7 @@ class UC480Camera(QObject):
return None
def set_pixel_clock(self, pixel_clock_mhz: int) -> bool:
"""
Set camera pixel clock.
Args:
pixel_clock_mhz: Pixel clock in MHz
Returns:
True if successful, False otherwise
"""
"""Set camera pixel clock."""
if not self.is_initialized:
return False
@@ -535,34 +460,8 @@ class UC480Camera(QObject):
logger.error(f"Failed to set framerate: {ret}")
return False
def get_framerate(self) -> Optional[float]:
"""
Get current framerate.
Returns:
Framerate in fps, or None if failed
"""
if not self.is_initialized:
return None
fps = ueye.c_double()
ret = ueye.is_GetFramesPerSecond(self.h_cam, fps)
if ret == ueye.IS_SUCCESS:
return fps.value
else:
return None
def set_gain(self, master_gain: int) -> bool:
"""
Set camera master gain.
Args:
master_gain: Gain value (0-100)
Returns:
True if successful, False otherwise
"""
"""Set camera master gain."""
if not self.is_initialized:
return False
@@ -583,9 +482,9 @@ class UC480Camera(QObject):
return True
elif ret == ueye.IS_CANT_COMMUNICATE_WITH_DRIVER:
logger.error(
f"Hardware gain not supported by this camera model "
f"(IS_CANT_COMMUNICATE_WITH_DRIVER). "
f"Consider using gain boost instead."
"Hardware gain not supported by this camera model "
"(IS_CANT_COMMUNICATE_WITH_DRIVER). "
"Consider using gain boost instead."
)
return False
else:
@@ -593,12 +492,7 @@ class UC480Camera(QObject):
return False
def get_sensor_info(self) -> dict:
"""
Get camera sensor information.
Returns:
Dictionary with sensor information
"""
"""Get camera sensor information."""
if not self.is_initialized:
return {}
@@ -624,12 +518,7 @@ class CameraStreamThread(QThread):
error_occurred = pyqtSignal(str)
def __init__(self, camera: UC480Camera):
"""
Initialize the camera stream thread.
Args:
camera: UC480Camera instance
"""
"""Initialize the camera stream thread."""
super().__init__()
self.camera = camera
self.running = False
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env python3
"""
Helios Laser Serial Communication Diagnostic Tool
Helps troubleshoot communication issues with the Helios laser.
"""
import serial
import time
import sys
def test_port(port, baudrate=9600):
"""Test basic communication on a serial port."""
print(f"\n{'='*60}")
print(f"Testing {port} at {baudrate} baud")
print(f"{'='*60}")
try:
ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1.0
)
print(f"✓ Port opened successfully")
time.sleep(0.1)
# Try to query the controller serial number
print("\nSending: 'SN?'")
ser.write(b'SN?\r')
time.sleep(0.5)
response = ser.readline().decode('ascii', errors='replace').strip()
print(f"Response: '{response}'")
if response:
print(f"✓ Got response: {response}")
return True, response
else:
print(f"✗ No response received")
# Try head serial number
print("\nSending: 'HSN?'")
ser.write(b'HSN?\r')
time.sleep(0.5)
response = ser.readline().decode('ascii', errors='replace').strip()
print(f"Response: '{response}'")
if response:
print(f"✓ Got response: {response}")
ser.close()
return True, response
else:
print(f"✗ No response received")
# Try laser enable status
print("\nSending: 'LE?'")
ser.write(b'LE?\r')
time.sleep(0.5)
response = ser.readline().decode('ascii', errors='replace').strip()
print(f"Response: '{response}'")
if response:
print(f"✓ Got response: {response}")
ser.close()
return True, response
else:
print(f"✗ No response received")
ser.close()
return False, "No response to any query"
except Exception as e:
print(f"✗ Error: {e}")
return False, str(e)
def test_raw_communication(port, baudrate=9600):
"""Test raw serial communication and display hex."""
print(f"\n{'='*60}")
print(f"Raw Communication Test: {port} at {baudrate} baud")
print(f"{'='*60}")
try:
ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=2.0
)
print(f"✓ Port opened successfully")
time.sleep(0.2)
# Send a simple query
command = b'SN?\r'
print(f"\nSending command (hex): {command.hex()}")
print(f"Sending command (ascii): {command}")
ser.write(command)
time.sleep(0.5)
# Read response byte by byte
response = b''
while True:
byte = ser.read(1)
if not byte:
break
response += byte
if byte == b'\n' or byte == b'\r':
break
print(f"\nRaw response (hex): {response.hex()}")
print(f"Raw response (ascii): {response}")
print(f"Response length: {len(response)} bytes")
# Check for common issues
if not response:
print("✗ No response - device may not be responding or wrong baud rate")
elif response == b'\r' or response == b'\n':
print("⚠ Only got line terminator - device may be echoing but not responding to command")
else:
print("✓ Got a response!")
ser.close()
return True
except Exception as e:
print(f"✗ Error: {e}")
return False
def test_echo(port, baudrate=9600):
"""Test if the device echoes commands back."""
print(f"\n{'='*60}")
print(f"Echo Test: {port} at {baudrate} baud")
print(f"{'='*60}")
try:
ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1.0
)
# Send a test character
test_char = b'T'
print(f"Sending test character: {test_char}")
ser.write(test_char)
time.sleep(0.1)
echo = ser.read(1)
if echo == test_char:
print(f"✓ Device echoes input")
elif echo:
print(f"⚠ Device sent something but not the same: {echo}")
else:
print(f"✗ No echo")
ser.close()
return True
except Exception as e:
print(f"✗ Error: {e}")
return False
def main():
"""Run diagnostic tests."""
port = "/dev/ttyUSB2"
if len(sys.argv) > 1:
port = sys.argv[1]
print(f"\n{'#'*60}")
print(f"# Helios Laser Serial Diagnostic Tool")
print(f"# Testing port: {port}")
print(f"{'#'*60}")
# Test standard baud rate
success, response = test_port(port, 9600)
if not success:
print("\n" + "="*60)
print("Standard baud rate (9600) failed. Trying alternatives...")
print("="*60)
# Try other common baud rates
for baudrate in [115200, 19200, 4800, 2400]:
success, response = test_port(port, baudrate)
if success:
print(f"\n✓ SUCCESS! Device responds at {baudrate} baud")
break
else:
print(f"\n✓ SUCCESS! Device responds at 9600 baud")
# Run additional diagnostics
print("\n")
test_raw_communication(port, 9600)
print("\n")
test_echo(port, 9600)
print(f"\n{'#'*60}")
print("# Diagnostic Tests Complete")
print(f"{'#'*60}\n")
if __name__ == "__main__":
main()
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""
Simple serial terminal for manual Helios laser testing.
Allows sending raw commands and viewing responses.
"""
import serial
import sys
from threading import Thread
import time
def read_from_port(ser):
"""Read data from serial port and display it."""
while True:
try:
if ser.in_waiting:
data = ser.read(ser.in_waiting)
print(f"\n[RX] {data.decode('ascii', errors='replace')}", end='')
sys.stdout.flush()
except:
break
time.sleep(0.01)
def main():
"""Run interactive serial terminal."""
port = "/dev/ttyUSB2"
if len(sys.argv) > 1:
port = sys.argv[1]
try:
ser = serial.Serial(
port=port,
baudrate=9600,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.1
)
print(f"Connected to {port} at 9600 baud")
print("Type commands and press Enter. Type 'quit' to exit.\n")
# Start reader thread
reader_thread = Thread(target=read_from_port, args=(ser,), daemon=True)
reader_thread.start()
while True:
try:
user_input = input("[TX] ")
if user_input.lower() == 'quit':
break
# Send command with carriage return
command = user_input + '\r'
ser.write(command.encode('ascii'))
time.sleep(0.1)
except KeyboardInterrupt:
break
except Exception as e:
print(f"Error: {e}")
ser.close()
print("\nDisconnected")
except Exception as e:
print(f"Failed to open {port}: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+88 -68
View File
@@ -6,15 +6,13 @@ Simple PyQt6 GUI for testing and controlling the Helios laser.
import sys
import logging
from typing import Optional
from enum import Enum
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox,
QStatusBar, QMessageBox, QTabWidget, QTextEdit
QMessageBox, QTabWidget, QTextEdit
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject
from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtGui import QFont
from hardware.helios_laser import HeliosLaser, PulseMode
@@ -142,6 +140,20 @@ class LaserWorker(QObject):
super().__init__()
self.laser = laser
@pyqtSlot(str, object)
def invoke(self, method_name: str, args: tuple):
"""Run one of this worker's methods on the worker thread.
Reached through a queued signal connection, so the serial I/O (and
its blocking reads) stays off the GUI thread. Calling the methods
directly, as this app used to, executes them in the caller's thread
and freezes the UI for the duration.
"""
try:
getattr(self, method_name)(*args)
except Exception as exc:
self.operation_complete.emit(False, str(exc))
def set_frequency(self, freq: int):
try:
success = self.laser.set_frequency_hz(freq)
@@ -199,15 +211,13 @@ class LaserWorker(QObject):
self.operation_complete.emit(False, str(e))
def query_power(self):
try:
power = self.laser.get_power_mw()
if power is not None:
self.power_updated.emit(power)
self.operation_complete.emit(True, f"Power: {power:.2f} mW")
else:
self.operation_complete.emit(False, "Failed to query power")
except Exception as e:
self.operation_complete.emit(False, str(e))
# HeliosLaser has no power query: the driver README advertises
# get_power_mw(), but no such method exists and the protocol
# mnemonic for an output-power read is not documented anywhere in
# this repo. This used to raise AttributeError into a popup.
# See KNOWN_ISSUES.md — needs the command from the Helios manual.
self.operation_complete.emit(
False, "Power query is not implemented (no known protocol command)")
def query_enabled(self):
try:
@@ -291,6 +301,9 @@ class LaserWorker(QObject):
class HeliosTestApp(QMainWindow):
"""Main application window for Helios laser testing."""
# Dispatches a worker method name + args across the thread boundary.
worker_call = pyqtSignal(str, object)
def __init__(self):
super().__init__()
self.laser = HeliosLaser()
@@ -588,6 +601,10 @@ class HeliosTestApp(QMainWindow):
power_layout = QHBoxLayout()
btn_get_power = QPushButton("Query Power")
btn_get_power.setEnabled(False)
btn_get_power.setToolTip(
"Not implemented: no documented Helios protocol command for "
"output power (see KNOWN_ISSUES.md).")
btn_get_power.clicked.connect(self.on_query_power)
power_layout.addWidget(btn_get_power)
@@ -699,6 +716,9 @@ class HeliosTestApp(QMainWindow):
self.worker = LaserWorker(self.laser)
self.worker_thread = QThread()
self.worker.moveToThread(self.worker_thread)
# Queued (cross-thread) connection: the worker's methods run on
# the worker thread, not on whichever thread emits.
self.worker_call.connect(self.worker.invoke)
self.worker.operation_complete.connect(self.on_operation_complete)
self.worker.frequency_updated.connect(self.on_frequency_updated)
self.worker.current_updated.connect(self.on_current_updated)
@@ -715,11 +735,27 @@ class HeliosTestApp(QMainWindow):
else:
QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}")
def _require_connection(self) -> bool:
"""Warn and return False when no laser is connected."""
if self.laser.is_connected and self.worker is not None:
return True
QMessageBox.warning(self, "Error", "Not connected to laser")
return False
def _call_worker(self, method_name: str, *args):
"""Run a worker method on the worker thread via a queued signal."""
if self.worker is not None:
self.worker_call.emit(method_name, args)
def disconnect_laser(self):
"""Disconnect from the laser."""
if self.worker_thread:
self.worker_thread.quit()
self.worker_thread.wait()
self.worker_thread.wait(2000)
# Drop both references: a reconnect used to leak the previous
# QThread, worker, and all nine signal connections.
self.worker = None
self.worker_thread = None
self.laser.disconnect()
self.lbl_status.setText("Status: Disconnected")
@@ -731,137 +767,121 @@ class HeliosTestApp(QMainWindow):
def on_set_frequency(self):
"""Set the laser frequency."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
freq = self.spin_frequency.value()
self.worker.set_frequency(freq)
self._call_worker("set_frequency", freq)
def on_query_frequency(self):
"""Query the laser frequency."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_frequency()
self._call_worker("query_frequency")
def on_set_current(self):
"""Set the laser current."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
current = self.spin_current.value()
self.worker.set_current(current)
self._call_worker("set_current", current)
def on_query_current(self):
"""Query the laser current."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_current()
self._call_worker("query_current")
def on_set_mode(self):
"""Set the laser pulse mode."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
mode = self.combo_mode.currentData()
self.worker.set_pulse_mode(mode)
self._call_worker("set_pulse_mode", mode)
def on_enable_laser(self):
"""Enable the laser."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.set_laser_enable(True)
self._call_worker("set_laser_enable", True)
def on_disable_laser(self):
"""Disable the laser."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.set_laser_enable(False)
self._call_worker("set_laser_enable", False)
def on_query_enabled(self):
"""Query if laser is enabled."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_enabled()
self._call_worker("query_enabled")
def on_query_power(self):
"""Query the laser output power."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_power()
self._call_worker("query_power")
def on_query_all(self):
"""Query all laser parameters."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_serials()
self.worker.query_frequency()
self.worker.query_current()
self.worker.query_power()
self.worker.query_enabled()
self.worker.query_status_registers()
self.worker.query_remote_enable()
self._call_worker("query_serials")
self._call_worker("query_frequency")
self._call_worker("query_current")
self._call_worker("query_power")
self._call_worker("query_enabled")
self._call_worker("query_status_registers")
self._call_worker("query_remote_enable")
def on_query_status(self):
"""Query LER/LCE/CCE status registers."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_status_registers()
self._call_worker("query_status_registers")
def on_reset_faults(self):
"""Send the fault reset sequence."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.do_reset_faults()
self._call_worker("do_reset_faults")
def on_query_remote_enable(self):
"""Query the remote enable (LRE) state."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.query_remote_enable()
self._call_worker("query_remote_enable")
def on_set_remote_enable(self, enable: bool):
"""Set the remote enable (LRE) state."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.set_remote_enable(enable)
self._call_worker("set_remote_enable", enable)
def on_ler_reset(self):
"""Send LER 0 only."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
self.worker.do_ler_reset()
self._call_worker("do_ler_reset")
def on_send_raw(self):
"""Send the raw command from the terminal input."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
if not self._require_connection():
return
cmd = self.le_raw_cmd.text().strip()
if not cmd:
return
self.worker.send_raw(cmd)
self._call_worker("send_raw", cmd)
def on_raw_response(self, cmd: str, response: str):
"""Display raw TX/RX pair in the terminal log."""
-395
View File
@@ -1,395 +0,0 @@
"""
Motion Controller Worker Thread
Handles all motion control operations in a separate thread to keep the UI responsive.
Provides async command queueing and position updates via Qt signals.
"""
from PyQt6 import QtCore
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y
import queue
import time
from typing import Optional, Dict, Any
class MotionCommand:
"""Represents a motion command"""
def __init__(self, cmd_type: str, **kwargs):
self.cmd_type = cmd_type
self.params = kwargs
class MotionWorker(QtCore.QObject):
"""
Worker object for handling motion control in a separate thread.
Signals:
connected: Emitted when controller connects successfully
disconnected: Emitted when controller disconnects
connection_failed: Emitted when connection fails (error_msg: str)
position_updated: Emitted when position changes (x: float, y: float)
homed_status: Emitted with home status (x_homed: bool, y_homed: bool)
move_completed: Emitted when a move completes (axis: str)
error_occurred: Emitted when an error occurs (error_msg: str)
"""
# Signals
connected = QtCore.pyqtSignal()
disconnected = QtCore.pyqtSignal()
connection_failed = QtCore.pyqtSignal(str)
position_updated = QtCore.pyqtSignal(float, float) # x, y in mm
homed_status = QtCore.pyqtSignal(bool, bool) # x_homed, y_homed
motion_status = QtCore.pyqtSignal(bool, bool) # x_moving, y_moving
move_completed = QtCore.pyqtSignal(str) # axis name
error_occurred = QtCore.pyqtSignal(str) # error message
def __init__(self):
super().__init__()
self.controller: Optional[ThorlabsServoDriver] = None
self.is_connected = False
self.command_queue = queue.Queue()
self.running = True
# Default parameters
self.jog_speed = 20.0 # mm/s
self.acceleration = 50.0 # mm/s^2
self.step_size = 1.0 # mm
# Position tracking
self.last_x = None
self.last_y = None
# Status tracking
self.last_x_homed = None
self.last_y_homed = None
self.last_x_moving = None
self.last_y_moving = None
# Position update throttling
self.last_position_update_time = 0
self.position_update_interval = 0.2 # seconds between position reads
# Flag to pause polling during scanning (scan worker handles its own position queries)
self.scanning_active = False
@QtCore.pyqtSlot()
def run(self):
"""Main worker loop - processes commands from queue"""
print("Motion worker thread started")
while self.running:
try:
# Check for commands with timeout to allow periodic position updates
try:
cmd = self.command_queue.get(timeout=0.05) # 50ms timeout
self.process_command(cmd)
except queue.Empty:
pass
# Periodically update position and status if connected
# Skip updates during scanning - scan worker handles its own position queries
if self.is_connected and self.controller and not self.scanning_active:
self.update_position()
self.update_home_status()
self.update_motion_status()
except Exception as e:
print(f"Error in motion worker loop: {e}")
self.error_occurred.emit(str(e))
# Cleanup on exit
if self.controller:
try:
self.controller.disconnect()
except:
pass
print("Motion worker thread stopped")
def process_command(self, cmd: MotionCommand):
"""Process a motion command"""
try:
if cmd.cmd_type == 'connect':
self.do_connect()
elif cmd.cmd_type == 'disconnect':
self.do_disconnect()
elif cmd.cmd_type == 'jog':
self.do_jog(cmd.params['axis'], cmd.params['direction'])
elif cmd.cmd_type == 'home':
self.do_home(cmd.params['axis'])
elif cmd.cmd_type == 'set_velocity':
self.do_set_velocity(cmd.params['speed'], cmd.params['accel'])
elif cmd.cmd_type == 'set_step_size':
self.step_size = cmd.params['step_size']
elif cmd.cmd_type == 'set_axis_enable':
self.do_set_axis_enable(cmd.params['axis'], cmd.params['enabled'])
elif cmd.cmd_type == 'stop':
self.running = False
except Exception as e:
print(f"Error processing command {cmd.cmd_type}: {e}")
self.error_occurred.emit(f"Command '{cmd.cmd_type}' failed: {str(e)}")
def do_connect(self):
"""Connect to the motion controller"""
try:
self.controller = ThorlabsServoDriver()
self.controller.connect()
# Enable channels
self.controller.enable_axis(AXIS_X)
self.controller.enable_axis(AXIS_Y)
# Start polling to populate cached state (positions, homed, moving, errors)
self.controller.start_polling(interval=0.2)
# Wait for first polling cycle to populate status
time.sleep(0.3)
# Set initial velocity parameters
for dest in [AXIS_X, AXIS_Y]:
self.controller.set_velocity_params(
dest,
max_velocity=self.jog_speed,
acceleration=self.acceleration
)
self.is_connected = True
# Force initial updates (they will be emitted because last values are None)
self.update_position()
self.update_home_status()
self.update_motion_status()
self.connected.emit()
print("Motion controller connected successfully")
except Exception as e:
print(f"Failed to connect to motion controller: {e}")
self.connection_failed.emit(str(e))
def do_disconnect(self):
"""Disconnect from the motion controller"""
if self.controller:
try:
self.controller.disconnect()
print("Motion controller disconnected")
except Exception as e:
print(f"Error during disconnect: {e}")
self.controller = None
self.is_connected = False
self.disconnected.emit()
def do_jog(self, axis: str, direction: int):
"""Execute a jog move"""
if not self.is_connected or not self.controller:
return
try:
dest = AXIS_X if axis == 'x' else AXIS_Y
# Calculate relative distance
distance = self.step_size * direction
# Execute the move (blocking, with short timeout for continuous jogging)
self.controller.move_axis_relative(dest, distance, timeout=0.5)
# Update position
self.update_position()
self.move_completed.emit(axis)
except TimeoutError:
# Timeout is expected during continuous jog - don't report as error
pass
except Exception as e:
print(f"Jog error: {e}")
self.error_occurred.emit(f"Jog failed: {str(e)}")
def do_home(self, axis: str):
"""Home an axis"""
if not self.is_connected or not self.controller:
return
try:
dest = AXIS_X if axis == 'x' else AXIS_Y
print(f"Homing {axis.upper()} axis...")
self.controller.home_axis(dest, timeout=60.0)
# Update position and status after homing
self.update_position()
self.update_home_status()
print(f"{axis.upper()} axis homed successfully")
except TimeoutError:
print(f"Home timeout: {axis.upper()} axis")
self.error_occurred.emit(f"Homing {axis.upper()} timed out")
except Exception as e:
print(f"Home error: {e}")
self.error_occurred.emit(f"Homing {axis.upper()} failed: {str(e)}")
def do_set_velocity(self, speed: float, accel: float):
"""Set velocity parameters"""
if not self.is_connected or not self.controller:
self.jog_speed = speed
self.acceleration = accel
return
try:
self.jog_speed = speed
self.acceleration = accel
for dest in [AXIS_X, AXIS_Y]:
self.controller.set_velocity_params(
dest,
max_velocity=self.jog_speed,
acceleration=self.acceleration
)
except Exception as e:
print(f"Set velocity error: {e}")
def do_set_axis_enable(self, axis: str, enabled: bool):
"""Enable or disable an axis for manual movement"""
if not self.is_connected or not self.controller:
return
try:
dest = AXIS_X if axis == 'x' else AXIS_Y
if enabled:
self.controller.enable_axis(dest)
else:
self.controller.disable_axis(dest)
state_str = "enabled" if enabled else "disabled"
print(f"{axis.upper()} axis {state_str}")
except Exception as e:
print(f"Set axis enable error: {e}")
self.error_occurred.emit(f"Failed to {'enable' if enabled else 'disable'} {axis.upper()} axis: {str(e)}")
def update_position(self):
"""Update current position and emit signal if changed"""
if not self.is_connected or not self.controller:
return
# Throttle position reads to avoid excessive signal emission
current_time = time.time()
if current_time - self.last_position_update_time < self.position_update_interval:
return
self.last_position_update_time = current_time
try:
# Read cached positions (populated by polling worker)
x_pos = self.controller.positions[0]
y_pos = self.controller.positions[1]
# Always emit on first update, or if position changed significantly (> 0.001mm)
if (self.last_x is None or self.last_y is None or
abs(x_pos - self.last_x) > 0.001 or abs(y_pos - self.last_y) > 0.001):
self.last_x = x_pos
self.last_y = y_pos
print(f"Position update: X={x_pos:.3f}mm, Y={y_pos:.3f}mm")
self.position_updated.emit(x_pos, y_pos)
except Exception as e:
print(f"Error updating position: {e}")
import traceback
traceback.print_exc()
def update_home_status(self):
"""Update home status and emit signal if changed"""
if not self.is_connected or not self.controller:
return
try:
x_homed = self.controller.am_homed[0]
y_homed = self.controller.am_homed[1]
# Only emit if status changed
if x_homed != self.last_x_homed or y_homed != self.last_y_homed:
self.last_x_homed = x_homed
self.last_y_homed = y_homed
self.homed_status.emit(x_homed, y_homed)
except Exception as e:
print(f"Error updating home status: {e}")
import traceback
traceback.print_exc()
def update_motion_status(self):
"""Update motion status and emit signal if changed.
The new driver's polling worker keeps am_moving[], am_error[]
up to date automatically via status update messages.
"""
if not self.is_connected or not self.controller:
return
try:
# Check for any error conditions
if self.controller.am_error[0]:
self.error_occurred.emit("X-axis error detected")
if self.controller.am_error[1]:
self.error_occurred.emit("Y-axis error detected")
# Read cached motion status (updated by polling worker)
x_moving = self.controller.am_moving[0]
y_moving = self.controller.am_moving[1]
# Only emit if status changed
if x_moving != self.last_x_moving or y_moving != self.last_y_moving:
self.last_x_moving = x_moving
self.last_y_moving = y_moving
self.motion_status.emit(x_moving, y_moving)
except Exception as e:
print(f"Error updating motion status: {e}")
import traceback
traceback.print_exc()
# Slot methods for queuing commands
@QtCore.pyqtSlot()
def queue_connect(self):
"""Queue a connect command"""
self.command_queue.put(MotionCommand('connect'))
@QtCore.pyqtSlot()
def queue_disconnect(self):
"""Queue a disconnect command"""
self.command_queue.put(MotionCommand('disconnect'))
@QtCore.pyqtSlot(str, int)
def queue_jog(self, axis: str, direction: int):
"""Queue a jog command"""
self.command_queue.put(MotionCommand('jog', axis=axis, direction=direction))
@QtCore.pyqtSlot(str)
def queue_home(self, axis: str):
"""Queue a home command"""
self.command_queue.put(MotionCommand('home', axis=axis))
@QtCore.pyqtSlot(float, float)
def queue_set_velocity(self, speed: float, accel: float):
"""Queue a set velocity command"""
self.command_queue.put(MotionCommand('set_velocity', speed=speed, accel=accel))
@QtCore.pyqtSlot(float)
def queue_set_step_size(self, step_size: float):
"""Queue a set step size command"""
self.command_queue.put(MotionCommand('set_step_size', step_size=step_size))
@QtCore.pyqtSlot(str, bool)
def queue_set_axis_enable(self, axis: str, enabled: bool):
"""Queue a command to enable or disable an axis"""
self.command_queue.put(MotionCommand('set_axis_enable', axis=axis, enabled=enabled))
@QtCore.pyqtSlot()
def stop(self):
"""Stop the worker thread"""
# Set running to False immediately so the main loop can exit
# even if it's blocked waiting for a response from the controller
self.running = False
# Also queue a stop command to ensure the command_queue.get() returns
self.command_queue.put(MotionCommand('stop'))
BIN
View File
Binary file not shown.
-56298
View File
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
target-version = "py311"
line-length = 120
[lint]
# F: pyflakes (unused imports/variables, undefined names)
# E7/E9: comparison and runtime-error prone constructs
# B: bugbear (mutable defaults, useless expressions)
select = ["F", "E7", "E9", "B"]
ignore = [
"E731", # lambda assignment — used deliberately for short Qt slot glue
"E741", # ambiguous single-letter names — used in math-heavy geometry code
"E702", # `w = QLabel(); w.setFont(f)` on one line — the widget-layout idiom here
]
[lint.per-file-ignores]
# Deliberate package re-exports
"hardware/pybbd202/__init__.py" = ["F401"]
# Quarantined pending hardware verification (docs/genesis_verification.md)
"tools/genesis_laser_gui.py" = ["B007"]
+678
View File
@@ -0,0 +1,678 @@
#!/usr/bin/env python3
"""
SAW Check Viewer — every angle's frequency on one graph.
Opens a middle-row SAW check (written by the main app's "SAW Quality
Check") and plots the peak SAW frequency along each angle's row, all angles
on the same axes. ``core.saw_check`` explains why that answers an alignment
question: every angle's middle row crosses the same ROI centre, so the angles
all measure the same material and a spread between them belongs to the rig.
Two readings share the window:
* the main graph — frequency along the row, one curve per angle. Curves
that lie on top of each other and run flat are what a well-aligned rig
looks like; a curve offset from the rest indicts its angle, and a sloped
curve indicts the ROI (tilt or defocus across it, at that angle).
* the summary — each angle's median with ±1σ, plotted against angle, plus
the same numbers per angle in a table.
A full scan opens too: the same middle row is pulled out of it, so a scan
can be re-examined with the check's own read-out after the fact.
"""
import sys
from pathlib import Path
import numpy as np
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal, QObject
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QFrame,
QGroupBox, QHBoxLayout, QHeaderView, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMessageBox, QPushButton, QSizePolicy, QSpinBox, QSplitter,
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from matplotlib import colormaps
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
sys.path.insert(0, str(Path(__file__).resolve().parent))
from core.saw_check import alignment_summary, frequency_traces
from core.sras_analysis import ChannelCalibration
from core.sras_format import SrasFile
RECOMPUTE_DEBOUNCE_MS = 250
# Angle curve colours, sampled across the sequence so the legend reads as the
# progression 0° → 180° rather than as an arbitrary set.
ANGLE_CMAP = "viridis"
VERDICT_STYLE = {
"good": ("#1b5e20", "#c8e6c9", "Alignment looks good"),
"marginal": ("#7a4f01", "#ffe0b2", "Alignment is marginal"),
"poor": ("#7f1d1d", "#ffcdd2", "Alignment needs attention"),
}
X_AXIS_MODES = [
("Offset from row centre", "offset"),
("Absolute stage X", "absolute"),
]
def angle_colors(n: int) -> list:
cmap = colormaps[ANGLE_CMAP]
if n <= 1:
return [cmap(0.5)]
return [cmap(i / (n - 1)) for i in range(n)]
def nan_moving_mean(y: np.ndarray, window: int) -> np.ndarray:
"""Moving mean over `window` frames that steps over masked pixels.
A plain convolution would let one NaN swallow a whole window, which on a
sparsely-masked row erases most of the trace; this divides by the number
of samples that actually contributed instead.
"""
if window <= 1:
return y
valid = np.isfinite(y)
kernel = np.ones(int(window))
num = np.convolve(np.where(valid, y, 0.0), kernel, mode="same")
den = np.convolve(valid.astype(float), kernel, mode="same")
return np.divide(num, den, out=np.full(num.shape, np.nan), where=den > 0)
class LoadedCheck:
"""A parsed check file plus the traces currently computed from it."""
def __init__(self, path: Path):
self.sras = SrasFile(path)
self.calib = ChannelCalibration.from_preambles(self.sras.preambles)
# Each angle carries its own background (v7/v11); the read-out pulls
# the right one per angle, so the viewer only needs to know whether
# there is anything to subtract at all.
self.has_background = any(self.sras.background_array(ai) is not None
for ai in range(self.sras.header.n_angles))
self.traces = []
self.summary = None
def describe(self) -> str:
h = self.sras.header
kind = (f"v{self.sras.version} SAW check" if self.sras.is_saw_check
else f"v{self.sras.version} scan — middle row of each angle")
return (f"{self.sras.path.name}\n{kind}\n"
f"{h.n_angles} angle(s) · {h.samples_per_frame} samples/frame · "
f"{h.sample_rate / 1e9:.2f} GS/s")
def close(self):
self.sras.close()
class FnWorker(QObject):
"""Runs a callable on a QThread; emits its return value or the error."""
finished = pyqtSignal(object)
error = pyqtSignal(str)
def __init__(self, fn):
super().__init__()
self._fn = fn
def run(self):
try:
self.finished.emit(self._fn())
except Exception as exc:
self.error.emit(str(exc))
class TraceCanvas(FigureCanvasQTAgg):
"""Frequency along the row, one curve per angle, all on one axes."""
def __init__(self, parent=None):
fig = Figure(figsize=(8, 5), tight_layout=True)
self.ax = fig.add_subplot(111)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.clear("Open a SAW check file to begin.")
def clear(self, message: str):
self.ax.clear()
self.ax.text(0.5, 0.5, message, ha="center", va="center",
transform=self.ax.transAxes, color="#888888")
self.ax.set_xticks([])
self.ax.set_yticks([])
self.draw_idle()
def plot(self, traces, colors, visible, x_mode, scale, unit, y_label,
smoothing, show_median):
self.ax.clear()
shown = 0
for trace, color in zip(traces, colors, strict=True):
if not visible.get(trace.angle_idx, True):
continue
x = trace.offset_mm if x_mode == "offset" else trace.x_mm
y = nan_moving_mean(trace.freq_mhz, smoothing) * scale
self.ax.plot(x, y, color=color, linewidth=1.0,
label=f"{trace.angle_deg:+.1f}° "
f"med {trace.median_mhz * scale:.2f}")
shown += 1
if shown == 0:
self.clear("No angle selected.")
return
if show_median:
medians = [t.median_mhz for t in traces
if visible.get(t.angle_idx, True) and t.n_valid]
if medians:
self.ax.axhline(float(np.median(medians)) * scale, color="#555555",
linestyle="--", linewidth=1.0,
label="median of shown angles")
self.ax.set_xlabel("Offset from row centre (mm)" if x_mode == "offset"
else "Stage X (mm)")
self.ax.set_ylabel(y_label)
self.ax.grid(True, alpha=0.25)
self.ax.legend(fontsize=7, ncol=2, loc="best", framealpha=0.85)
self.draw_idle()
class SummaryCanvas(FigureCanvasQTAgg):
"""Each angle's median frequency, ±1σ, against the GR angle."""
def __init__(self, parent=None):
fig = Figure(figsize=(8, 2.6), tight_layout=True)
self.ax = fig.add_subplot(111)
super().__init__(fig)
self.setParent(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def plot(self, traces, colors, scale, unit):
self.ax.clear()
usable = [(t, c) for t, c in zip(traces, colors, strict=True) if t.n_valid]
if not usable:
self.ax.set_xticks([])
self.ax.set_yticks([])
self.draw_idle()
return
order = sorted(usable, key=lambda tc: tc[0].angle_deg)
angles = [t.angle_deg for t, _ in order]
medians = np.array([t.median_mhz for t, _ in order]) * scale
sigmas = np.array([0.0 if not np.isfinite(t.std_mhz) else t.std_mhz
for t, _ in order]) * scale
self.ax.plot(angles, medians, color="#999999", linewidth=1.0, zorder=1)
self.ax.errorbar(angles, medians, yerr=sigmas, fmt="none",
ecolor="#999999", capsize=3, zorder=2)
for (_, color), angle, median in zip(order, angles, medians, strict=True):
self.ax.plot([angle], [median], marker="o", markersize=6,
color=color, zorder=3)
self.ax.axhline(float(np.median(medians)), color="#555555",
linestyle="--", linewidth=1.0)
self.ax.set_xlabel("GR angle (deg)")
self.ax.set_ylabel(f"Median ({unit})")
self.ax.grid(True, alpha=0.25)
self.draw_idle()
class SawCheckWindow(QMainWindow):
"""Left: what to compute and what to show. Right: the graphs."""
TABLE_COLUMNS = ["Angle (°)", "Y (mm)", "Median", "σ", "Drift (/mm)", "Valid (%)"]
def __init__(self, initial_path: str | None = None):
super().__init__()
self.setWindowTitle("SAW Check Viewer")
self.resize(1280, 860)
self._check: LoadedCheck | None = None
self._colors: list = []
self._visible: dict[int, bool] = {}
self._compute_thread: QThread | None = None
self._compute_worker: FnWorker | None = None
self._pending_recompute = False
self._debounce = QTimer(self)
self._debounce.setSingleShot(True)
self._debounce.setInterval(RECOMPUTE_DEBOUNCE_MS)
self._debounce.timeout.connect(self._recompute)
self._build_ui()
if initial_path:
self._load(Path(initial_path))
# ── Layout ────────────────────────────────────────────────────────────────
def _build_ui(self):
splitter = QSplitter(Qt.Orientation.Horizontal, self)
splitter.addWidget(self._build_controls())
splitter.addWidget(self._build_plots())
splitter.setStretchFactor(0, 0)
splitter.setStretchFactor(1, 1)
splitter.setSizes([340, 940])
self.setCentralWidget(splitter)
def _build_controls(self) -> QWidget:
panel = QWidget(self)
layout = QVBoxLayout(panel)
# File
grp_file = QGroupBox("File")
fl = QVBoxLayout(grp_file)
self.btn_open = QPushButton("Open SAW Check…")
self.btn_open.clicked.connect(self._on_open)
fl.addWidget(self.btn_open)
self.lbl_file = QLabel("No file loaded.")
self.lbl_file.setWordWrap(True)
self.lbl_file.setStyleSheet("color: #666; font-size: 11px;")
fl.addWidget(self.lbl_file)
layout.addWidget(grp_file)
# Analysis — anything here changes the numbers, so it recomputes.
self.grp_analysis = QGroupBox("Analysis")
al = QVBoxLayout(self.grp_analysis)
thr_row = QHBoxLayout()
thr_row.addWidget(QLabel("CH4 DC threshold:"))
self.spin_threshold_mv = QDoubleSpinBox()
self.spin_threshold_mv.setRange(-500.0, 500.0)
self.spin_threshold_mv.setDecimals(1)
self.spin_threshold_mv.setSingleStep(5.0)
self.spin_threshold_mv.setSuffix(" mV")
self.spin_threshold_mv.setValue(50.0)
self.spin_threshold_mv.setToolTip(
"Pixels whose CH4 DC mean falls below this are dropped from the "
"trace — the detection beam was off the sample or out of focus there."
)
self.spin_threshold_mv.valueChanged.connect(self._queue_recompute)
thr_row.addWidget(self.spin_threshold_mv)
al.addLayout(thr_row)
self.chk_bg_sub = QCheckBox("Subtract background waveform")
self.chk_bg_sub.setChecked(True)
self.chk_bg_sub.toggled.connect(self._queue_recompute)
al.addWidget(self.chk_bg_sub)
self.chk_gate = QCheckBox("Time gate before FFT")
self.chk_gate.toggled.connect(self._on_gate_toggled)
al.addWidget(self.chk_gate)
gate_row = QHBoxLayout()
gate_row.addWidget(QLabel("Start:"))
self.spin_gate_start = QDoubleSpinBox()
self.spin_gate_start.setRange(0.0, 100000.0)
self.spin_gate_start.setDecimals(1)
self.spin_gate_start.setSingleStep(10.0)
self.spin_gate_start.setSuffix(" ns")
self.spin_gate_start.setValue(50.0)
self.spin_gate_start.setEnabled(False)
self.spin_gate_start.valueChanged.connect(self._queue_recompute)
gate_row.addWidget(self.spin_gate_start)
gate_row.addWidget(QLabel("End:"))
self.spin_gate_end = QDoubleSpinBox()
self.spin_gate_end.setRange(0.0, 100000.0)
self.spin_gate_end.setDecimals(1)
self.spin_gate_end.setSingleStep(10.0)
self.spin_gate_end.setSuffix(" ns")
self.spin_gate_end.setValue(200.0)
self.spin_gate_end.setEnabled(False)
self.spin_gate_end.valueChanged.connect(self._queue_recompute)
gate_row.addWidget(self.spin_gate_end)
al.addLayout(gate_row)
layout.addWidget(self.grp_analysis)
# Display — cheap, so these only redraw.
grp_display = QGroupBox("Display")
dl = QVBoxLayout(grp_display)
x_row = QHBoxLayout()
x_row.addWidget(QLabel("X axis:"))
self.combo_x = QComboBox()
for label, _ in X_AXIS_MODES:
self.combo_x.addItem(label)
self.combo_x.setToolTip(
"Every angle's row is centred on the same ROI centre, so offset "
"puts the angles over the same piece of sample; absolute shows "
"where each rotated bounding box actually sat on the stage."
)
self.combo_x.currentIndexChanged.connect(self._redraw)
x_row.addWidget(self.combo_x)
dl.addLayout(x_row)
y_row = QHBoxLayout()
y_row.addWidget(QLabel("Y axis:"))
self.combo_y = QComboBox()
self.combo_y.addItems(["Frequency (MHz)", "Velocity (m/s)"])
self.combo_y.currentIndexChanged.connect(self._on_y_mode_changed)
y_row.addWidget(self.combo_y)
dl.addLayout(y_row)
grat_row = QHBoxLayout()
grat_row.addWidget(QLabel("Grating:"))
self.spin_grating_um = QDoubleSpinBox()
self.spin_grating_um.setRange(0.1, 1000.0)
self.spin_grating_um.setDecimals(2)
self.spin_grating_um.setSingleStep(0.5)
self.spin_grating_um.setSuffix(" µm")
self.spin_grating_um.setValue(12.5)
self.spin_grating_um.setEnabled(False)
self.spin_grating_um.setToolTip("v (m/s) = freq (MHz) × grating (µm)")
self.spin_grating_um.valueChanged.connect(self._redraw)
grat_row.addWidget(self.spin_grating_um)
dl.addLayout(grat_row)
smooth_row = QHBoxLayout()
smooth_row.addWidget(QLabel("Smoothing:"))
self.spin_smoothing = QSpinBox()
self.spin_smoothing.setRange(1, 2001)
self.spin_smoothing.setSingleStep(10)
self.spin_smoothing.setSuffix(" frames")
self.spin_smoothing.setValue(1)
self.spin_smoothing.setToolTip(
"Moving average along the row, masked pixels skipped. Display "
"only — the table's statistics always use the unsmoothed trace."
)
self.spin_smoothing.valueChanged.connect(self._redraw)
smooth_row.addWidget(self.spin_smoothing)
dl.addLayout(smooth_row)
self.chk_median_line = QCheckBox("Show median of shown angles")
self.chk_median_line.setChecked(True)
self.chk_median_line.toggled.connect(self._redraw)
dl.addWidget(self.chk_median_line)
layout.addWidget(grp_display)
# Angles
grp_angles = QGroupBox("Angles")
gl = QVBoxLayout(grp_angles)
self.list_angles = QListWidget()
self.list_angles.setMaximumHeight(190)
self.list_angles.itemChanged.connect(self._on_angle_toggled)
gl.addWidget(self.list_angles)
btn_row = QHBoxLayout()
btn_all = QPushButton("All")
btn_all.clicked.connect(lambda: self._set_all_angles(True))
btn_none = QPushButton("None")
btn_none.clicked.connect(lambda: self._set_all_angles(False))
btn_row.addWidget(btn_all)
btn_row.addWidget(btn_none)
gl.addLayout(btn_row)
layout.addWidget(grp_angles)
# Verdict
self.lbl_verdict = QLabel("—")
self.lbl_verdict.setWordWrap(True)
self.lbl_verdict.setFrameShape(QFrame.Shape.StyledPanel)
self.lbl_verdict.setMinimumHeight(92)
self.lbl_verdict.setAlignment(Qt.AlignmentFlag.AlignTop)
layout.addWidget(self.lbl_verdict)
self.lbl_status = QLabel("")
self.lbl_status.setStyleSheet("color: #666; font-size: 11px;")
layout.addWidget(self.lbl_status)
layout.addStretch(1)
return panel
def _build_plots(self) -> QWidget:
splitter = QSplitter(Qt.Orientation.Vertical, self)
top = QWidget()
tl = QVBoxLayout(top)
tl.setContentsMargins(0, 0, 0, 0)
self.trace_canvas = TraceCanvas(top)
tl.addWidget(NavigationToolbar2QT(self.trace_canvas, top))
tl.addWidget(self.trace_canvas)
splitter.addWidget(top)
tabs = QTabWidget()
self.summary_canvas = SummaryCanvas(tabs)
tabs.addTab(self.summary_canvas, "Frequency vs angle")
self.table = QTableWidget(0, len(self.TABLE_COLUMNS))
self.table.setHorizontalHeaderLabels(self.TABLE_COLUMNS)
self.table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
tabs.addTab(self.table, "Per-angle statistics")
splitter.addWidget(tabs)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 1)
# Stretch factors alone leave the summary too short to fit its own
# axis label on first show; give it a real starting height.
splitter.setSizes([540, 300])
return splitter
# ── Loading ───────────────────────────────────────────────────────────────
def _on_open(self):
start = str(self._check.sras.path.parent) if self._check else ""
path, _ = QFileDialog.getOpenFileName(
self, "Open SAW Check File", start, "SRAS Files (*.sras)")
if path:
self._load(Path(path))
def _load(self, path: Path):
try:
check = LoadedCheck(path)
except Exception as exc:
QMessageBox.critical(self, "Cannot Open File",
f"Could not read {path.name}:\n\n{exc}")
return
if self._check is not None:
self._check.close()
self._check = check
self.setWindowTitle(f"SAW Check Viewer — {path.name}")
self.lbl_file.setText(check.describe())
self.chk_bg_sub.setEnabled(check.has_background)
if not check.sras.is_saw_check:
self.lbl_status.setText(
"Not a SAW check file — reading the middle row of each angle "
"out of this scan instead.")
else:
self.lbl_status.setText("")
self._colors = angle_colors(check.sras.header.n_angles)
self._visible = {i: True for i in range(check.sras.header.n_angles)}
self._recompute()
# ── Compute ───────────────────────────────────────────────────────────────
def _queue_recompute(self):
if self._check is not None:
self._debounce.start()
def _on_gate_toggled(self, enabled: bool):
self.spin_gate_start.setEnabled(enabled)
self.spin_gate_end.setEnabled(enabled)
self._queue_recompute()
def _recompute(self):
if self._check is None:
return
if self._compute_thread is not None and self._compute_thread.isRunning():
# One worker owns the mmap at a time; fold this request into the
# one already in flight rather than racing it.
self._pending_recompute = True
return
check = self._check
gated = self.chk_gate.isChecked()
kwargs = dict(
dc_threshold_mv=self.spin_threshold_mv.value(),
subtract_background=self.chk_bg_sub.isChecked(),
gate_start_ns=self.spin_gate_start.value() if gated else None,
gate_end_ns=self.spin_gate_end.value() if gated else None,
calib=check.calib,
)
self.grp_analysis.setEnabled(False)
self.lbl_status.setText("Computing frequency traces …")
self._compute_thread = QThread(self)
self._compute_worker = FnWorker(
lambda: frequency_traces(check.sras, **kwargs))
self._compute_worker.moveToThread(self._compute_thread)
self._compute_thread.started.connect(self._compute_worker.run)
self._compute_worker.finished.connect(self._on_traces_ready)
self._compute_worker.error.connect(self._on_compute_error)
self._compute_thread.start()
def _finish_compute(self):
if self._compute_thread is not None:
self._compute_thread.quit()
self._compute_thread.wait(5000)
self._compute_thread = None
self._compute_worker = None
self.grp_analysis.setEnabled(True)
if self._pending_recompute:
self._pending_recompute = False
self._queue_recompute()
def _on_compute_error(self, message: str):
self._finish_compute()
self.lbl_status.setText("")
QMessageBox.critical(self, "Analysis Failed", message)
def _on_traces_ready(self, traces):
self._finish_compute()
if self._check is None:
return
self._check.traces = traces
self._check.summary = alignment_summary(traces)
self.lbl_status.setText(
f"{len(traces)} of {self._check.sras.header.n_angles} angle(s) "
f"produced a trace.")
self._rebuild_angle_list()
self._redraw()
# ── Display ───────────────────────────────────────────────────────────────
def _scale(self) -> tuple[float, str, str]:
"""Display factor, unit and axis label.
The file only ever holds a frequency; velocity is that frequency times
the grating period, applied at display time so switching units never
costs a recompute.
"""
if self.combo_y.currentIndex() == 1:
return self.spin_grating_um.value(), "m/s", "SAW velocity (m/s)"
return 1.0, "MHz", "Peak SAW frequency (MHz)"
def _on_y_mode_changed(self):
self.spin_grating_um.setEnabled(self.combo_y.currentIndex() == 1)
self._redraw()
def _rebuild_angle_list(self):
self.list_angles.blockSignals(True)
self.list_angles.clear()
for trace in self._check.traces:
item = QListWidgetItem(
f"{trace.angle_deg:+7.2f}° Y={trace.y_mm:.3f} mm")
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if self._visible.get(trace.angle_idx, True)
else Qt.CheckState.Unchecked)
item.setData(Qt.ItemDataRole.UserRole, trace.angle_idx)
r, g, b, _ = self._colors[trace.angle_idx]
item.setForeground(QColor(int(r * 255), int(g * 255), int(b * 255)))
self.list_angles.addItem(item)
self.list_angles.blockSignals(False)
def _on_angle_toggled(self, item: QListWidgetItem):
self._visible[item.data(Qt.ItemDataRole.UserRole)] = (
item.checkState() == Qt.CheckState.Checked)
self._redraw()
def _set_all_angles(self, visible: bool):
self.list_angles.blockSignals(True)
for row in range(self.list_angles.count()):
item = self.list_angles.item(row)
item.setCheckState(Qt.CheckState.Checked if visible
else Qt.CheckState.Unchecked)
self._visible[item.data(Qt.ItemDataRole.UserRole)] = visible
self.list_angles.blockSignals(False)
self._redraw()
def _redraw(self):
if self._check is None or not self._check.traces:
self.trace_canvas.clear("No angle in this file has data on disk.")
return
traces = self._check.traces
colors = [self._colors[t.angle_idx] for t in traces]
scale, unit, y_label = self._scale()
self.trace_canvas.plot(
traces, colors, self._visible,
X_AXIS_MODES[self.combo_x.currentIndex()][1], scale, unit, y_label,
self.spin_smoothing.value(), self.chk_median_line.isChecked())
self.summary_canvas.plot(traces, colors, scale, unit)
self._fill_table(traces, scale, unit)
self._show_verdict(scale, unit)
def _fill_table(self, traces, scale: float, unit: str):
headers = list(self.TABLE_COLUMNS)
headers[2] = f"Median ({unit})"
headers[3] = f"σ ({unit})"
headers[4] = f"Drift ({unit}/mm)"
self.table.setHorizontalHeaderLabels(headers)
self.table.setRowCount(len(traces))
for row, trace in enumerate(traces):
values = [
f"{trace.angle_deg:+.2f}",
f"{trace.y_mm:.3f}",
f"{trace.median_mhz * scale:.3f}",
f"{trace.std_mhz * scale:.3f}",
f"{trace.drift_mhz_per_mm * scale:+.4f}",
f"{trace.valid_fraction * 100:.1f}",
]
for col, text in enumerate(values):
item = QTableWidgetItem(text)
item.setTextAlignment(Qt.AlignmentFlag.AlignRight
| Qt.AlignmentFlag.AlignVCenter)
if col == 0:
r, g, b, _ = self._colors[trace.angle_idx]
item.setForeground(QColor(int(r * 255), int(g * 255), int(b * 255)))
self.table.setItem(row, col, item)
def _show_verdict(self, scale: float, unit: str):
summary = self._check.summary
fg, bg, headline = VERDICT_STYLE[summary.level]
detail = summary.describe()
if scale != 1.0 and summary.n_angles:
detail += (f"\nIn {unit}: spread {summary.spread_mhz * scale:.3f} "
f"about {summary.median_mhz * scale:.1f}.")
self.lbl_verdict.setText(f"{headline}\n\n{detail}")
self.lbl_verdict.setStyleSheet(
f"color: {fg}; background: {bg}; padding: 8px; font-size: 11px;")
# ── Teardown ──────────────────────────────────────────────────────────────
def closeEvent(self, event):
self._debounce.stop()
if self._compute_thread is not None:
self._compute_thread.quit()
self._compute_thread.wait(5000)
if self._check is not None:
self._check.close()
super().closeEvent(event)
def main():
app = QApplication(sys.argv)
window = SawCheckWindow(sys.argv[1] if len(sys.argv) > 1 else None)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+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">
-407
View File
@@ -1,407 +0,0 @@
"""Focusing Control Panel — Qt UI module for ScanEngine-3.
This module provides a dialog-based control panel for the T3R stepper
controller, allowing users to home, step, jog, and monitor the focus
axis in real time. It integrates with scanengine via the hardware
abstraction layer (hardware.t3r_driver).
Usage:
from sc3_aui_focusing import FocusingControlPanel
panel = FocusingControlPanel(parent_window)
panel.show()
Signals:
focus_position_changed(ch, position) — emitted after each move/jog completes
focus_error(ch, message) — emitted on controller errors
"""
from __future__ import annotations
import logging
from typing import Optional
try:
from PyQt6.QtWidgets import (
QDialog, QDoubleSpinBox, QPushButton, QLabel, QGroupBox, QVBoxLayout,
QHBoxLayout, QSpacerItem, QSizePolicy, QErrorMessage, QMessageBox,
)
from PyQt6.QtCore import Qt, pyqtSignal
except ImportError:
from PyQt5.QtWidgets import (
QDialog, QDoubleSpinBox, QPushButton, QLabel, QGroupBox, QVBoxLayout,
QHBoxLayout, QSpacerItem, QSizePolicy, QErrorMessage, QMessageBox,
)
from PyQt5.QtCore import Qt, pyqtSignal
from hardware import T3RStepperDriver
logger = logging.getLogger(__name__)
class FocusingControlPanel(QDialog):
"""Qt dialog for controlling the T3R focus stepper."""
focus_position_changed = pyqtSignal(int, float)
focus_error = pyqtSignal(int, str)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Focusing Control Panel")
self.setWindowFlags(Qt.Window | Qt.WindowCloseButtonHint)
self.setMinimumWidth(780)
self.setMinimumHeight(560)
self._driver = None
self._connected = False
self._build_ui()
def _build_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(10)
title_label = QLabel("Focusing Control Panel")
title_label.setFont(title_label.font().copy(size=Qt.FontSize.Fixed))
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_layout.addWidget(title_label)
status_groupbox = QGroupBox("Status")
status_layout = QHBoxLayout(status_groupbox)
self.status_label = QLabel("Not connected")
self.status_label.setMinimumWidth(200)
status_layout.addWidget(self.status_label)
main_layout.addWidget(status_groupbox)
axis_groupbox = QGroupBox("T-axis (Focus)")
axis_layout = QHBoxLayout(axis_groupbox)
self.position_label = QLabel("Position: 0")
self.position_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
axis_layout.addWidget(self.position_label)
main_layout.addWidget(axis_groupbox)
controls_groupbox = QGroupBox("Controls")
controls_layout = QVBoxLayout(controls_groupbox)
controls_layout.setSpacing(6)
home_row = QHBoxLayout()
self.home_button = QPushButton("Home")
home_row.addWidget(self.home_button)
spacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
home_row.addSpacerItem(spacer)
controls_layout.addLayout(home_row)
step_row = QHBoxLayout()
self.step_minus_button = QPushButton("< Step (-)")
self.steps_spinbox = QDoubleSpinBox()
self.steps_spinbox.setMinimum(-10000.0)
self.steps_spinbox.setMaximum(10000.0)
self.steps_spinbox.setSingleStep(100.0)
self.step_plus_button = QPushButton("Step (+) >")
step_row.addWidget(self.step_minus_button)
step_row.addWidget(self.steps_spinbox)
step_row.addWidget(self.step_plus_button)
controls_layout.addLayout(step_row)
jog_row = QHBoxLayout()
self.jog_minus_button = QPushButton("< Jog (-)")
self.velocity_spinbox = QDoubleSpinBox()
self.velocity_spinbox.setMinimum(-10000.0)
self.velocity_spinbox.setMaximum(10000.0)
self.velocity_spinbox.setSingleStep(500.0)
self.jog_plus_button = QPushButton("Jog (+) >")
jog_row.addWidget(self.jog_minus_button)
jog_row.addWidget(self.velocity_spinbox)
jog_row.addWidget(self.jog_plus_button)
controls_layout.addLayout(jog_row)
accel_row = QHBoxLayout()
self.accel_minus_button = QPushButton("< Accel (-)")
self.accel_spinbox = QDoubleSpinBox()
def connect(self, port="/dev/ttyUSB0", spd=115200):
"""Connect to the T3R controller and start polling."""
try:
self._driver = T3RStepperDriver()
self._driver.connect(port=port, spd=spd)
self._connected = True
self.status_label.setText("Connected")
self.position_label.setText(f"Position: {self._driver.get_position(0):,.0f}")
return True
except Exception as e:
logger.exception("Failed to connect to T3R controller")
self.status_label.setText(f"Connection error: {e!s}")
return False
def disconnect(self):
"""Disconnect from the controller."""
if self._driver is not None:
try:
self._driver.disconnect()
except Exception as e:
logger.warning("Error disconnecting: %s", e)
self._driver = None
self._connected = False
self.status_label.setText("Not connected")
def home(self):
"""Home the T-axis."""
if not self._connected or self._driver is None:
return
try:
self._driver.move(0, steps=0, velocity=8000, accel=4000)
self.position_label.setText("Position: 0")
except Exception as e:
logger.exception("Home failed")
self.focus_error.emit(0, f"Home error: {e!s}")
def step(self, direction=1):
"""Step by the amount in steps_spinbox."""
if not self._connected or self._driver is None:
return
try:
steps = int(round(self.steps_spinbox.value()))
if steps == 0:
return
sign = 1 if direction > 0 else -1
self._driver.move(0, steps=sign * steps, velocity=8000, accel=4000)
pos = self._driver.get_position(0)
self.position_label.setText(f"Position: {pos:,}")
except Exception as e:
logger.exception("Step failed")
self.focus_error.emit(0, f"Step error: {e!s}")
def jog(self, direction=1):
"""Jog at the velocity in velocity_spinbox."""
if not self._connected or self._driver is None:
return
try:
vel = int(round(self.velocity_spinbox.value()))
if vel == 0:
return
sign = 1 if direction > 0 else -1
accel = int(round(self.accel_spinbox.value()))
self._driver.jog(0, velocity=sign * vel, accel=accel)
except Exception as e:
logger.exception("Jog failed")
self.focus_error.emit(0, f"Jog error: {e!s}")
def stop(self):
"""Stop any motion."""
if not self._connected or self._driver is None:
return
try:
self._driver.stop(0)
except Exception as e:
logger.exception("Stop failed")
self.focus_error.emit(0, f"Stop error: {e!s}")
def refresh_position(self):
"""Update the position label (called from polling thread)."""
if not self._connected or self._driver is None:
return
try:
pos = self._driver.get_position(0)
self.position_label.setText(f"Position: {pos:,}")
except Exception:
pass
# ── Polling integration ──────────────────────────────────────────────────
def start_polling(self, interval=0.3):
"""Start periodic position polling (called after connect)."""
if self._driver is None:
return
self._driver.start_polling(interval)
def stop_polling(self):
"""Stop polling."""
if self._driver is not None:
self._driver.stop_polling()
def poll_loop(self):
"""Run the polling loop (typically in a separate thread)."""
self.refresh_position()
import time
while self._connected and self._driver is not None:
try:
self._driver.start_polling(0.3)
except Exception:
pass
time.sleep(0.5)
# ── Signal handlers ──────────────────────────────────────────────────────
def _on_home_clicked(self):
self.home()
def _on_step_minus_clicked(self):
self.step(-1)
def _on_step_plus_clicked(self):
self.step(+1)
def _on_jog_minus_clicked(self):
self.jog(-1)
def _on_jog_plus_clicked(self):
self.jog(+1)
def _on_accel_minus_clicked(self):
pass # accel is just a jog parameter, handled by jog()
def _on_accel_plus_clicked(self):
pass
def _on_stop_clicked(self):
self.stop()
def _on_close_clicked(self):
self.close()
# Wire up all buttons to their handlers
self.home_button.clicked.connect(_on_home_clicked)
self.step_minus_button.clicked.connect(_on_step_minus_clicked)
self.step_plus_button.clicked.connect(_on_step_plus_clicked)
self.jog_minus_button.clicked.connect(_on_jog_minus_clicked)
self.jog_plus_button.clicked.connect(_on_jog_plus_clicked)
self.accel_minus_button.clicked.connect(_on_accel_minus_clicked)
self.accel_plus_button.clicked.connect(_on_accel_plus_clicked)
self.stop_button.clicked.connect(_on_stop_clicked)
self.close_button.clicked.connect(_on_close_clicked)
# ── Signal handlers ──────────────────────────────────────────────────────
def _on_home_clicked(self):
self.home()
def _on_step_minus_clicked(self):
self.step(-1)
def _on_step_plus_clicked(self):
self.step(+1)
def _on_jog_minus_clicked(self):
self.jog(-1)
def _on_jog_plus_clicked(self):
self.jog(+1)
def _on_accel_minus_clicked(self):
pass # accel is just a jog parameter, handled by jog()
def _on_accel_plus_clicked(self):
pass
def _on_stop_clicked(self):
self.stop()
def _on_close_clicked(self):
self.close()
# Wire up all buttons to their handlers
self.home_button.clicked.connect(_on_home_clicked)
self.step_minus_button.clicked.connect(_on_step_minus_clicked)
self.step_plus_button.clicked.connect(_on_step_plus_clicked)
self.jog_minus_button.clicked.connect(_on_jog_minus_clicked)
self.jog_plus_button.clicked.connect(_on_jog_plus_clicked)
self.accel_minus_button.clicked.connect(_on_accel_minus_clicked)
self.accel_plus_button.clicked.connect(_on_accel_plus_clicked)
self.stop_button.clicked.connect(_on_stop_clicked)
self.close_button.clicked.connect(_on_close_clicked)
def closeEvent(self, event):
"""Save state and cleanup."""
self.disconnect()
# ── Polling integration ──────────────────────────────────────────────────
def start_polling(self, interval=0.3):
"""Start periodic position polling (called after connect)."""
if self._driver is None:
return
self._driver.start_polling(interval)
def stop_polling(self):
"""Stop polling."""
if self._driver is not None:
self._driver.stop_polling()
def poll_loop(self):
"""Run the polling loop (typically in a separate thread)."""
self.refresh_position()
import time
while self._connected and self._driver is not None:
try:
self._driver.start_polling(0.3)
except Exception:
pass
time.sleep(0.5)
# ── Module documentation ───────────────────────────────────────────────────
__all__ = ["FocusingControlPanel"]
if __name__ == "__main__":
import sys
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv)
panel = FocusingControlPanel()
panel.show()
sys.exit(app.exec())
# ── Polling integration ──────────────────────────────────────────────────
def start_polling(self, interval=0.3):
"""Start periodic position polling (called after connect)."""
if self._driver is None:
return
self._driver.start_polling(interval)
def stop_polling(self):
"""Stop polling."""
if self._driver is not None:
self._driver.stop_polling()
def poll_loop(self):
"""Run the polling loop (typically in a separate thread)."""
self.refresh_position()
import time
while self._connected and self._driver is not None:
try:
self._driver.start_polling(0.3)
except Exception:
pass
time.sleep(0.5)
event.accept()
self.accel_spinbox.setMinimum(-10000.0)
self.accel_spinbox.setMaximum(10000.0)
self.accel_spinbox.setSingleStep(500.0)
self.accel_plus_button = QPushButton("Accel (+) >")
accel_row.addWidget(self.accel_minus_button)
accel_row.addWidget(self.accel_spinbox)
accel_row.addWidget(self.accel_plus_button)
controls_layout.addLayout(accel_row)
stop_row = QHBoxLayout()
self.stop_button = QPushButton("Stop")
stop_row.addWidget(self.stop_button)
controls_layout.addLayout(stop_row)
main_layout.addWidget(controls_groupbox)
self.close_button = QPushButton("Close")
main_layout.addWidget(self.close_button)
# ── Module documentation ───────────────────────────────────────────────────
__all__ = ["FocusingControlPanel"]
if __name__ == "__main__":
import sys
from PyQt6.QtWidgets import QApplication
app = QApplication(sys.argv)
panel = FocusingControlPanel()
panel.show()
sys.exit(app.exec())
+41
View File
@@ -997,6 +997,46 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="burst_mode_check">
<property name="toolTip">
<string>Acquire as many whole rows per FastFrame acquisition as the scope can hold, and transfer each burst in one CURVe? transaction. The stage trigger output is gated off for the flyback between rows.</string>
</property>
<property name="text">
<string>Burst acquisition (multi-row FastFrame)</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="strict_rows_check">
<property name="toolTip">
<string>Stop the scan if a row does not acquire the expected number of frames, instead of zero-padding a short row or truncating a long one. Use for data runs where a silently squared-up row would be worse than a failed scan.</string>
</property>
<property name="text">
<string>Strict row packing (abort on frame-count mismatch)</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="saw_check_btn">
<property name="toolTip">
<string>Acquire one row per angle — the row-wise middle of the ROI — and save it as a v10 .sras SAW check. Costs one row-time per angle instead of a full scan, and every angle's row crosses the same ROI centre, so the per-angle frequencies can be compared in the SAW Check Viewer to judge the alignment.</string>
</property>
<property name="text">
<string>SAW Quality Check…</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="inspect_angles_btn">
<property name="toolTip">
<string>Rotate through the angles of the scan currently entered, parking at a random point in each so the SAW response can be checked on the oscilloscope before committing to the run.</string>
</property>
<property name="text">
<string>Inspect Angles…</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="start_scan_btn">
<property name="text">
@@ -1056,6 +1096,7 @@
<tabstop>bbd_set_current_start_btn</tabstop>
<tabstop>bbd_set_delta_current_btn</tabstop>
<tabstop>show_camera_toggle</tabstop>
<tabstop>saw_check_btn</tabstop>
<tabstop>start_scan_btn</tabstop>
</tabstops>
<resources/>
-2635
View File
File diff suppressed because it is too large Load Diff
+975 -1163
View File
File diff suppressed because it is too large Load Diff
+191 -41
View File
@@ -1,8 +1,25 @@
# SRAS Scan Binary Format — Version 6
# SRAS Scan Binary Format — Versions 7 and 11 (reading 6 and 10)
Each `.sras` file contains **one complete scan**: all GR rotation angles and all
Y rows. Files are named `{prefix}.sras`.
Two versions are written, sharing this layout byte for byte — only the version
field differs, and with it what the file means:
| Version | Meaning | Rows per angle |
|---------|---------|----------------|
| 7 | A full scan. | Whatever the ROI needs. |
| 11 | A middle-row SAW quality check (`{prefix}-sawcheck.sras`). | Exactly 1. |
See [SAW Quality Check (v11)](#saw-quality-check-v11) below.
**Versions 6 and 10** are the same two files as they were written before each
angle carried its own background: a single background block sat between the
preambles and the data block, and the data block held nothing but rows. They
are still read — see [Legacy layout (v6/v10)](#legacy-layout-v6v10) — but
nothing writes them any more, and a v6 file cannot be resumed into, since the
background block a resumed angle writes has no room in it.
Starting in v6, each angle only scans the **bounding box of the nominal ROI
rotated by that specific angle** — not the worst case across all angles — so
`x_start`, `x_delta` (and therefore `n_frames`, the points/row count) and
@@ -20,10 +37,14 @@ instead of forcing every angle to the largest bounding box.
[Per-Angle Geometry Table— n_angles × 14 bytes (x_start f32, x_delta f32, n_frames u32, n_rows u16)]
[Row Table (ragged) — sum(n_rows) × 4 bytes (float32 per row, angle-major)]
[Preamble Blocks — n_channels × (uint16 length + UTF-8 WFMOutpre string)]
[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).
@@ -34,7 +55,7 @@ All multi-byte integers and floats use **big-endian** byte order
| Offset | Size | Type | Field | Description |
|--------|------|-----------|--------------------|--------------------------------------------------|
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
| 4 | 1 | `uint8` | `version` | Format version — `6` |
| 4 | 1 | `uint8` | `version` | Format version — `7` (scan) or `11` (SAW check) |
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
| 7 | 4 | `float32` | `x_start_nominal` | Nominal (pre-rotation) X scan start, mm |
| 11 | 4 | `float32` | `y_start_nominal` | Nominal (pre-rotation) Y scan start, mm |
@@ -117,54 +138,77 @@ to convert raw ADC values to volts.
---
## Background Block
## Data Block (ragged)
Immediately after the preamble blocks: a single CH1 waveform captured with the
**Helios (generation) laser enabled** and the **Genesis (detection) laser
disabled**. This provides a noise/background reference for subtraction during
post-processing.
Immediately after the preamble blocks, and running to the end of the file:
for each angle in angle-table order, that angle's **background block**
followed by that angle's **waveform data**.
```
for angle a in 0 … n_angles-1:
uint32 n_bg_samples # background block
int8[] bg_data
for row in 0 … n_rows[a]-1: # waveform data
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
for frame in 0 … n_frames[a]-1:
samples[0 … samples_per_frame-1] # bps bytes each
```
### Background block
One CH1 waveform captured with the **Helios (generation) laser enabled** and
the **Genesis (detection) laser disabled**, averaged over 1024 shots
(`core/scope_sras.py`, `BACKGROUND_AVERAGES`). It is a noise/background
reference for subtraction during post-processing.
```
uint32 n_bg_samples — number of samples in the background waveform
int8[] bg_data — raw ADC samples (same encoding as waveform data)
```
`n_bg_samples` equals `samples_per_frame` under normal acquisition settings.
`n_bg_samples` equals `samples_per_frame` under normal acquisition settings,
but is **not** assumed to: readers take the per-angle offsets from a walk of
the data block, reading each length prefix as they go, rather than from
arithmetic over the geometry table alone.
---
Every angle carries its own. The operator is prompted to switch the Genesis
laser off before each angle and back on after the capture, so the reference is
taken minutes from the data it will be subtracted from — a multi-angle scan
runs for hours, and one background captured at the first angle has drifted by
the last. It also makes the angles comparable, which is the entire point of a
multi-angle scan: each is referenced against its own noise floor rather than
against whichever angle happened to be scanned first.
## Waveform Data (ragged)
### Waveform data
Immediately after the background block. Data is stored in **angle-major,
row-minor** order, but unlike earlier versions each angle contributes a
Stored in **angle-major, row-minor** order, and each angle contributes a
different number of rows (`n_rows[a]`) and a different number of frames per
row (`n_frames[a]`), both taken from that angle's Per-Angle Geometry Table
entry. Within each row, channels are interleaved in ascending channel-index
order, with each channel's FastFrame data written in frame order.
```
for angle a in 0 … n_angles-1:
for row in 0 … n_rows[a]-1:
for channel in [CH1, CH3, CH4]: # 3 channels, fixed order
for frame in 0 … n_frames[a]-1:
samples[0 … samples_per_frame-1] # bps bytes each
```
Each sample is a raw signed ADC value. With `bytes_per_sample = 1` this is
**int8** (−128 … +127). With `bytes_per_sample = 2` this is **big-endian
int16**.
Total data size:
Total data-block size:
```
sum over angles a of: n_rows[a] × 3 × n_frames[a] × samples_per_frame × bytes_per_sample
sum over angles a of: 4 + n_bg_samples[a]
+ n_rows[a] × 3 × n_frames[a] × samples_per_frame × bytes_per_sample
```
> **Incomplete files:** If a scan is aborted the file is closed immediately and
> the data block will be shorter than the expected size. Readers should
> reconstruct the expected per-angle byte offsets from the Per-Angle Geometry
> Table and check `file_size` against the running total before reshaping —
> a fixed `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not
> work since row/frame counts are no longer uniform across angles.
> the data block will be shorter than the expected size. Readers should walk
> the data block from its start — background length prefix, then that angle's
> declared row bytes from the Per-Angle Geometry Table — checking `file_size`
> against the running total before reshaping. A fixed
> `(n_angles, n_rows, ...)` reshape (as in pre-v6 readers) will not work since
> row/frame counts are no longer uniform across angles.
>
> An angle whose background block is not fully on disk has nothing of itself
> written yet: it is *missing*, not truncated, and the walk continues past it
> assuming the block a writer would have produced
> (`4 + samples_per_frame` bytes), which is where a resumed scan writes.
---
@@ -183,18 +227,121 @@ using that angle's `x_start` from the Per-Angle Geometry Table (not
---
## Acquisition Settings (fixed by sc3_aui_app.py)
## Acquisition Settings (fixed by core/scope_sras.py)
| Parameter | Value |
|-----------------------|------------------------------|
| Oscilloscope trigger | CH2, rising edge, 1.24 V |
| Trigger offset | 0 % (trigger at left edge) |
| Sample rate | 6.25 GS/s (160 ps/sample) |
| Channels recorded | CH1, CH3, CH4 |
| Stage X velocity | 100 mm/s |
| Stage X acceleration | 1500 mm/s² |
| Stage X trigger out | Logic-high at max velocity |
| Acquisition mode | FastFrame, Normal trigger |
| Parameter | Value |
|-----------------------|------------------------------------------|
| 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` |
| Channels recorded | CH1, CH3, CH4 |
| Stage X velocity | 100 mm/s |
| Stage X acceleration | 1500 mm/s² |
| Stage X trigger out | Logic-high at max velocity (`TRIGOUT_MAXV`) |
| Acquisition mode | FastFrame, Normal trigger |
None of these are stored in the file, so they do not affect byte layout — but
they do set where the acoustic packet lands inside each frame. Read them from
`core/scope_sras.py`; earlier revisions of this table drifted from the code.
---
## Acquisition Paths
Two acquisition strategies write **byte-identical** files; the choice is a
runtime flag (`ScanEngine(burst_mode=…)`, exposed as a checkbox in the app) and
is not recorded in the file.
| | Per-row (default) | Burst |
|---|---|---|
| FastFrame acquisitions | one per row | one per `floor(max_frames / n_frames)` rows |
| Curve transfers | one per channel per row | one per channel per burst |
| Stage X trigger out | armed for the whole scan | armed per acquiring pass, dropped for the flyback |
Both paths take the same per-angle background: the scope returns to the
single-record edge trigger for the capture and back to the logic-AND trigger
before the angle's rows, so the two paths still produce byte-identical files.
Burst mode runs a single acquisition across several rows, so the return move
must not trigger: the trigger output is dropped before each flyback and
re-armed for each acquiring pass. Row boundaries inside the burst come from
`ACQuire:NUMFRAMESACQuired?` sampled after each pass — the burst itself carries
no row markers. See `core/scope_burst.py`.
### Row packing
The format has no per-row length field, so a row that over- or under-triggers
cannot be written as it arrived — that would shift every later row. Two
policies are selectable (`ScanEngine(strict_rows=…)`, a checkbox in the app),
and the choice is not recorded in the file:
| | Pad (default) | Strict |
|---|---|---|
| Short row | zero-padded to `n_frames`, warned | scan stops |
| Long row | trailing frames dropped, warned | scan stops |
Pad keeps a scan running through an occasional mis-trigger, at the cost that
the affected row is indistinguishable from a good one afterwards — nothing in
the file records that it was padded. Strict is for data runs where that
ambiguity is worse than a failed scan: it aborts before writing the row, so
the file always ends on a whole-row boundary.
---
## SAW Quality Check (v11)
A full multi-angle scan takes hours, and a rig whose angles disagree produces
all of them before anyone finds out. The SAW quality check acquires **one row
per angle — the row-wise middle of the ROI** — and writes it as a v11 file.
The cost is one row-time per angle instead of `n_rows` of them.
Nothing about the byte layout changes. A v11 file is a v7 file in which every
angle's Per-Angle Geometry Table entry declares `n_rows = 1`, and its Row Table
holds that angle's single middle Y position. Every v7 reader that works from
the geometry table (rather than assuming a uniform shape) reads a v11 file
unchanged. Each angle still carries its own background, so a check costs the
same two operator prompts per angle a scan does.
The version byte earns its keep because the two are otherwise
indistinguishable: **a v7 scan aborted after its first row is not a check**,
even though both hold one row per angle. A reader that guessed from the row
count would treat a failed scan as a deliberate measurement.
Why the middle row in particular: `core/scan_geometry.py` centres every
angle's rotated bounding box on the same nominal ROI centre, so each angle's
middle row crosses that one point on the sample. All the angles therefore
measure the same material, and a spread in their SAW frequencies is a property
of the rig — which is what makes it an alignment check. `saw_check_viewer.py`
plots every angle's frequency on one graph for exactly that comparison.
Writers must honour the one-row rule; `core.sras_format.create_scan_file`
refuses a v11 write for any plan that breaks it. Producing the plan is
`core.saw_check.middle_row_plan(plan)`, and `n_rows // 2` is the middle-row
rule (the upper of the two central rows when the count is even).
---
## Legacy layout (v6/v10)
A v6 or v10 file differs in one place: the background block sits **once**,
between the preamble blocks and the data block, and the data block is
waveform data alone.
```
[Preamble Blocks]
[Background Block — uint32 n_bg_samples + n_bg_samples × int8 bytes]
[Waveform Data (ragged) — per angle: rows, as above, with no background between]
```
Everything else — header, tables, row order, spatial mapping — is identical,
which is why `core.sras_format.SrasFile` reads both: it hands the one legacy
background to every angle, so a reader that asks for angle *a*'s background
never has to know which layout it is looking at. Nothing writes v6/v10 any
more, and a resume refuses them, because a re-acquired angle writes a
background block the layout has no room for.
---
@@ -208,4 +355,7 @@ using that angle's `x_start` from the Per-Angle Geometry Table (not
| 4 | Added background waveform block (CH1, Helios ON / Genesis OFF) after the preamble blocks; stored as `uint32` sample count followed by raw `int8` ADC bytes. |
| 5 | (skipped) |
| 6 | Each angle now scans only the bounding box of the nominal ROI rotated by that angle instead of the AABB-expanded worst case across all angles. Header no longer carries a single global `x_start`/`x_delta`/`n_rows` — replaced with `*_nominal` reference fields plus a new Per-Angle Geometry Table (`x_start`, `x_delta`, `n_frames`, `n_rows` per angle) and a ragged Row Table / Waveform Data block sized per angle. **Not compatible with v4 readers** (e.g. `sras_viewer.py`, which has not yet been updated for v6). |
| 7 | Background moved into the data block, one per angle: the block now reads `[background][scan][background][scan] …`. Each angle is preceded by its own `uint32` + `int8[]` background, captured (Genesis off, Helios on) just before that angle is scanned, so the reference is contemporary with the data and the angles are comparable to each other. Per-angle offsets therefore come from a walk of the data block rather than arithmetic over the geometry table. **v6 files still read; v6 files cannot be resumed into.** |
| 8–9 | (skipped) |
| 10 | Middle-row SAW quality check on the v6 layout. Byte layout identical to v6, with every angle declaring exactly one row — the row-wise middle of the ROI. Superseded by v11; still read. |
| 11 | Middle-row SAW quality check on the v7 layout: identical to v7 with every angle declaring exactly one row, per-angle backgrounds included. The version byte exists so a check is not confused with a scan aborted after its first row. Written by the main app's *SAW Quality Check*, read by `saw_check_viewer.py`. |
-3
View File
@@ -1,3 +0,0 @@
"""Scan planning and modeling modules"""
from .sc3_scan_model import SC3ScanModel
from .stage_scan_plan_generator import *
-638
View File
@@ -1,638 +0,0 @@
"""
Scan Model Class.
Holds the scan configuration, and computes the
required start and end points for various specified angles.
Python implementation of SC3ScanModel.cs
"""
import math
from decimal import Decimal, InvalidOperation
from typing import List, Optional
import csv
class SC3ScanModel:
"""
Scan Model for generating scan paths and rotated scans.
Manages scan configuration and computes scan coordinates at various angles.
"""
def __init__(self):
# Private variables
self._x_origin: Decimal = Decimal('0.0')
self._y_origin: Decimal = Decimal('0.0')
self._x_delta: Decimal = Decimal('0.0')
self._y_delta: Decimal = Decimal('0.0')
self._row_spacing: Decimal = Decimal('0.0')
self._laser_frequency: Decimal = Decimal('2000.0') # Default: 2000 Hz
self._scan_velocity: Decimal = Decimal('100.0') # Default: 100 mm/s
self._scan_acceleration: Decimal = Decimal('0.0')
self._scan_angles: int = 0
self._points_required: int = 0
self._rows_required: int = 0
self._points_per_line: int = 0
# Optical axis centerline in stage coordinates
# Stage: MLS203-1
self._optical_x_origin: Decimal = Decimal('55.0')
self._optical_y_origin: Decimal = Decimal('37.5')
# Data storage
self._scan_coordinates: List[List[Decimal]] = []
self._scan_velocities: List[List[Decimal]] = []
self._scan_accelerations: List[List[Decimal]] = []
self._rotated_coordinates: List[List[List[Decimal]]] = []
# Constants
self._deg2rad: float = math.pi / 180.0
# Properties
@property
def x_origin(self) -> Decimal:
"""X coordinate of scan origin (mm)"""
return self._x_origin
@x_origin.setter
def x_origin(self, value: Decimal):
try:
self._x_origin = Decimal(str(value))
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid x_origin value: {value}") from e
@property
def y_origin(self) -> Decimal:
"""Y coordinate of scan origin (mm)"""
return self._y_origin
@y_origin.setter
def y_origin(self, value: Decimal):
try:
self._y_origin = Decimal(str(value))
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid y_origin value: {value}") from e
@property
def x_delta(self) -> Decimal:
"""Total X distance to scan (mm)"""
return self._x_delta
@x_delta.setter
def x_delta(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("x_delta must be non-negative")
self._x_delta = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid x_delta value: {value}") from e
@property
def y_delta(self) -> Decimal:
"""Total Y distance to scan (mm)"""
return self._y_delta
@y_delta.setter
def y_delta(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("y_delta must be non-negative")
self._y_delta = val
self.calculate_rows_required()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid y_delta value: {value}") from e
@property
def row_spacing(self) -> Decimal:
"""Spacing between scan rows (mm)"""
return self._row_spacing
@row_spacing.setter
def row_spacing(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("row_spacing must be non-negative")
self._row_spacing = val
self.calculate_rows_required()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid row_spacing value: {value}") from e
@property
def laser_frequency(self) -> Decimal:
"""Laser pulse frequency (Hz)"""
return self._laser_frequency
@laser_frequency.setter
def laser_frequency(self, value: Decimal):
try:
val = Decimal(str(value))
if val <= 0:
raise ValueError("laser_frequency must be positive")
self._laser_frequency = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid laser_frequency value: {value}") from e
@property
def scan_velocity(self) -> Decimal:
"""Scan velocity (mm/s)"""
return self._scan_velocity
@scan_velocity.setter
def scan_velocity(self, value: Decimal):
try:
val = Decimal(str(value))
if val <= 0:
raise ValueError("scan_velocity must be positive")
self._scan_velocity = val
self.calculate_points_per_line()
self.calculate_points_required()
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid scan_velocity value: {value}") from e
@property
def scan_acceleration(self) -> Decimal:
"""Scan acceleration (mm/s²)"""
return self._scan_acceleration
@scan_acceleration.setter
def scan_acceleration(self, value: Decimal):
try:
val = Decimal(str(value))
if val < 0:
raise ValueError("scan_acceleration must be non-negative")
self._scan_acceleration = val
except (ValueError, InvalidOperation) as e:
raise ValueError(f"Invalid scan_acceleration value: {value}") from e
@property
def scan_angles(self) -> int:
"""Number of scan angles to compute"""
return self._scan_angles
@scan_angles.setter
def scan_angles(self, value: int):
if value < 0:
raise ValueError("scan_angles must be non-negative")
self._scan_angles = value
# Only compute rotated scans if we have base scan coordinates
if self._scan_coordinates:
self.compute_rotated_scans()
@property
def points_required(self) -> int:
"""Total number of points required for a single angle scan (computed)"""
return self._points_required
@property
def rows_required(self) -> int:
"""Number of rows required for the scan (computed)"""
return self._rows_required
@property
def points_per_line(self) -> int:
"""Number of points per scan line (computed)"""
return self._points_per_line
@property
def scan_coordinates(self) -> List[List[Decimal]]:
"""List of scan coordinates [x_start, y_start, x_end, y_end] in mm"""
return self._scan_coordinates
@property
def scan_velocities(self) -> List[List[Decimal]]:
"""List of velocity vectors [vx, vy] in mm/s for each angle"""
return self._scan_velocities
@property
def scan_accelerations(self) -> List[List[Decimal]]:
"""List of acceleration vectors [ax, ay] in mm/s² for each angle"""
return self._scan_accelerations
@property
def rotated_coordinates(self) -> List[List[List[Decimal]]]:
"""List of rotated scan coordinates for each angle in mm"""
return self._rotated_coordinates
@property
def optical_x_origin(self) -> Decimal:
"""X coordinate of optical axis origin in mm (read-only, MLS203-1 stage)"""
return self._optical_x_origin
@property
def optical_y_origin(self) -> Decimal:
"""Y coordinate of optical axis origin in mm (read-only, MLS203-1 stage)"""
return self._optical_y_origin
# Calculation Methods
def calculate_points_per_line(self):
"""
Calculates the number of data points per scan line based on
x_delta, scan_velocity, and laser_frequency.
Formula: points = (distance / velocity) * frequency
"""
if self._scan_velocity != 0:
self._points_per_line = int(
(self._x_delta / self._scan_velocity) * self._laser_frequency
)
def calculate_points_required(self):
"""
Calculates the total number of data points for a complete single-angle scan.
Also triggers computation of the zero-angle scan coordinates.
Formula: total_points = points_per_line * rows_required
"""
if self._rows_required != 0:
self._points_required = self._points_per_line * self._rows_required
self.compute_zero_scan()
def calculate_rows_required(self):
"""
Calculates the number of scan rows needed based on y_delta and row_spacing.
Formula: rows = ceil(y_delta / row_spacing)
"""
if self._row_spacing == 0:
self._rows_required = 0
else:
self._rows_required = int(math.ceil(self._y_delta / self._row_spacing))
def compute_zero_scan(self):
"""
Computes the zero-angle (reference) scan coordinates.
Each coordinate is [x_start, y_start, x_end, y_end].
"""
# Ignore the zero-row case
if self._rows_required == 0:
return
y_offset = Decimal('0.0')
self._scan_coordinates.clear()
for row in range(self._rows_required + 1):
# Calculate x/y origin/delta for each needed row
y_offset = Decimal(row) * self._row_spacing
coords = [
self._x_origin, # x_start
self._y_origin + y_offset, # y_start
self._x_origin + self._x_delta, # x_end
self._y_origin + y_offset # y_end (same as y_start for horizontal scan)
]
self._scan_coordinates.append(coords)
def _rotate_point(self, x: Decimal, y: Decimal, cosine: Decimal, sine: Decimal) -> tuple:
"""
Rotate a point around the optical axis origin.
Args:
x, y: Point coordinates to rotate
cosine, sine: Precomputed cos and sin of rotation angle
Returns:
Tuple of (rotated_x, rotated_y)
"""
# Rotation transformation:
# Xr = (X - Xo)*cos(a) + (Y - Yo)*sin(a) + Xo
# Yr = -(X - Xo)*sin(a) + (Y - Yo)*cos(a) + Yo
x_rot = ((x - self._optical_x_origin) * cosine +
(y - self._optical_y_origin) * sine +
self._optical_x_origin)
y_rot = (-(x - self._optical_x_origin) * sine +
(y - self._optical_y_origin) * cosine +
self._optical_y_origin)
return (x_rot, y_rot)
def _compute_rotated_aoi_bbox(self, angle_rad: float) -> tuple:
"""
Compute the bounding box of the rotated area of interest.
Rotates the four corners of the AoI rectangle and finds the
min/max extents to create a bounding box.
Args:
angle_rad: Rotation angle in radians
Returns:
Tuple of (min_x, min_y, max_x, max_y) as Decimals
"""
cosine = Decimal(str(math.cos(angle_rad)))
sine = Decimal(str(math.sin(angle_rad)))
# Define the four corners of the AoI rectangle
corners = [
(self._x_origin, self._y_origin),
(self._x_origin + self._x_delta, self._y_origin),
(self._x_origin + self._x_delta, self._y_origin + self._y_delta),
(self._x_origin, self._y_origin + self._y_delta)
]
# Rotate all corners
rotated_corners = []
for x, y in corners:
x_rot, y_rot = self._rotate_point(x, y, cosine, sine)
rotated_corners.append((x_rot, y_rot))
# Find bounding box extents
x_coords = [corner[0] for corner in rotated_corners]
y_coords = [corner[1] for corner in rotated_corners]
return (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
def compute_rotated_scans(self):
"""
Computes rotated scan coordinates by rotating the area of interest (AoI)
and generating horizontal (+x direction) scans through the bounding box
of the rotated AoI.
The rotation covers 0 to 180 degrees with spacing determined by scan_angles.
For each angle:
1. Rotate the AoI rectangle around the optical axis origin
2. Compute the bounding box of the rotated rectangle
3. Generate horizontal scan lines through the bounding box
"""
if self._rows_required == 0:
return
# Compute spacing between scans
if self._scan_angles == 0:
angle_spacing = 180
else:
angle_spacing = 180 / self._scan_angles
self._rotated_coordinates.clear()
# Iterate through each required angle from 0 to 180 degrees
i = 0
while i < 180:
current_angle_radians = i * (math.pi / 180.0)
# Get bounding box of rotated AoI
min_x, min_y, max_x, max_y = self._compute_rotated_aoi_bbox(current_angle_radians)
# Calculate the y extent of the bounding box
y_extent = max_y - min_y
# Determine number of rows needed for this bounding box
if self._row_spacing == 0:
num_rows = 0
else:
num_rows = int(math.ceil(y_extent / self._row_spacing))
temp_list = []
# Generate horizontal scan lines through the bounding box
for row in range(num_rows + 1):
y_offset = Decimal(row) * self._row_spacing
y_pos = min_y + y_offset
# Create horizontal scan line at this y position
scan_line = [
min_x, # x_start
y_pos, # y_start
max_x, # x_end
y_pos # y_end (same as y_start for horizontal scan)
]
temp_list.append(scan_line)
self._rotated_coordinates.append(temp_list)
i += int(round(angle_spacing))
def compute_kinematics(self, offset: int = 0):
"""
Computes velocity and acceleration component vectors for each scan angle.
For each angle, decomposes the scalar velocity and acceleration into
X and Y components based on the scan direction angle.
Args:
offset: Angle offset in degrees (default: 0)
"""
if self._scan_angles == 0:
scan_increment = 180
else:
scan_increment = 180 // self._scan_angles
self._scan_velocities.clear()
self._scan_accelerations.clear()
for i in range(self._scan_angles):
deg_angle = scan_increment * i + offset
angle_rad = deg_angle * self._deg2rad
# Compute velocity components: V = V_mag * [cos(θ), sin(θ)]
velocities = [
Decimal(str(math.cos(angle_rad))) * self._scan_velocity,
Decimal(str(math.sin(angle_rad))) * self._scan_velocity
]
# Compute acceleration components: A = A_mag * [cos(θ), sin(θ)]
accels = [
Decimal(str(math.cos(angle_rad))) * self._scan_acceleration,
Decimal(str(math.sin(angle_rad))) * self._scan_acceleration
]
self._scan_velocities.append(velocities)
self._scan_accelerations.append(accels)
# Export Methods
def export_zero_scan_csv(self, filename: Optional[str] = None) -> str:
"""
Export the zero-angle scan coordinates to a CSV file.
Args:
filename: Output filename. If None, generates from row count.
Returns:
The filename that was written
Raises:
ValueError: If no scan coordinates have been computed
IOError: If file cannot be written
"""
if not self._scan_coordinates:
raise ValueError("No scan coordinates available. Configure scan parameters first.")
if filename is None:
filename = f"scantest-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._scan_coordinates:
writer.writerow([str(c) for c in coords])
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
def export_rotated_scan_csv(self, angle_index: int, filename: Optional[str] = None) -> str:
"""
Export a specific rotated scan to CSV.
Args:
angle_index: Index of the angle to export (0-based)
filename: Output filename. If None, generates from angle and row count.
Returns:
The filename that was written
Raises:
ValueError: If angle_index is invalid or no rotated coordinates exist
IOError: If file cannot be written
"""
if not self._rotated_coordinates:
raise ValueError("No rotated coordinates available. Set scan_angles first.")
if angle_index < 0 or angle_index >= len(self._rotated_coordinates):
raise ValueError(
f"Invalid angle_index {angle_index}. Must be 0-{len(self._rotated_coordinates)-1}"
)
if filename is None:
angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180)
filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._rotated_coordinates[angle_index]:
writer.writerow([str(c) for c in coords])
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
def export_all_rotated_scans_csv(self, output_dir: str = ".") -> List[str]:
"""
Export all rotated scans to separate CSV files.
Args:
output_dir: Directory to write files to (default: current directory)
Returns:
List of filenames that were written
Raises:
ValueError: If no rotated coordinates exist
IOError: If files cannot be written
"""
if not self._rotated_coordinates:
raise ValueError("No rotated coordinates available. Set scan_angles first.")
import os
filenames = []
for angle_index in range(len(self._rotated_coordinates)):
angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180)
filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv"
filepath = os.path.join(output_dir, filename)
try:
with open(filepath, 'w', newline='') as f:
writer = csv.writer(f)
for coords in self._rotated_coordinates[angle_index]:
writer.writerow([str(c) for c in coords])
filenames.append(filepath)
except IOError as e:
raise IOError(f"Failed to write file {filepath}: {e}") from e
return filenames
def export_kinematics_csv(self, filename: Optional[str] = None) -> str:
"""
Export velocity and acceleration data to CSV.
Format: vx, vy, ax, ay for each angle.
Args:
filename: Output filename. If None, generates from row count.
Returns:
The filename that was written
Raises:
ValueError: If no kinematics data has been computed
IOError: If file cannot be written
"""
if not self._scan_velocities or not self._scan_accelerations:
raise ValueError("No kinematics data available. Call compute_kinematics() first.")
if filename is None:
filename = f"kinematics-{self._rows_required}rows.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
# Optional: write header
writer.writerow(['vx', 'vy', 'ax', 'ay'])
for i in range(len(self._scan_velocities)):
row = [
str(self._scan_velocities[i][0]),
str(self._scan_velocities[i][1]),
str(self._scan_accelerations[i][0]),
str(self._scan_accelerations[i][1])
]
writer.writerow(row)
except IOError as e:
raise IOError(f"Failed to write file {filename}: {e}") from e
return filename
# Utility Methods
def get_angle_list(self) -> List[int]:
"""
Get the list of scan angles in degrees.
Returns:
List of angles in degrees for the configured scan_angles
"""
if self._scan_angles == 0:
return []
scan_increment = 180 // self._scan_angles
return [scan_increment * i for i in range(self._scan_angles)]
def get_scan_info(self) -> dict:
"""
Get a dictionary with current scan configuration and computed values.
Returns:
Dictionary containing scan parameters and computed values
"""
return {
'x_origin': float(self._x_origin),
'y_origin': float(self._y_origin),
'x_delta': float(self._x_delta),
'y_delta': float(self._y_delta),
'row_spacing': float(self._row_spacing),
'laser_frequency': float(self._laser_frequency),
'scan_velocity': float(self._scan_velocity),
'scan_acceleration': float(self._scan_acceleration),
'scan_angles': self._scan_angles,
'points_per_line': self._points_per_line,
'rows_required': self._rows_required,
'points_required': self._points_required,
'optical_x_origin': float(self._optical_x_origin),
'optical_y_origin': float(self._optical_y_origin),
'num_scan_coordinates': len(self._scan_coordinates),
'num_rotated_angles': len(self._rotated_coordinates),
'angle_list': self.get_angle_list(),
}
def __repr__(self) -> str:
"""String representation of the scan model."""
return (
f"SC3ScanModel("
f"origin=({self._x_origin},{self._y_origin}), "
f"delta=({self._x_delta},{self._y_delta}), "
f"rows={self._rows_required}, "
f"angles={self._scan_angles})"
)
-117
View File
@@ -1,117 +0,0 @@
"""
This module contains a StageScanPlanGenerator class that generates scanning plans
for microscope stages. The scans are generated in a single direction based on provided
start and end coordinates, as well as spacing between scan lines.
"""
import numpy as np
from typing import List, Tuple, Optional
class StageScanPlanGenerator:
"""
A class to generate scanning plans for microscope stages.
Attributes:
start_coords (Tuple[float, float]): Starting X and Y coordinates.
end_coords (Tuple[float, float]): Ending X and Y coordinates.
spacing (float): Spacing between scan lines in the perpendicular direction.
"""
def __init__(self, start_x: float, start_y: float,
end_x: float, end_y: float, spacing: float):
"""
Initialize the StageScanPlanGenerator with scan parameters.
Args:
start_x (float): Starting X coordinate.
start_y (float): Starting Y coordinate.
end_x (float): Ending X coordinate.
end_y (float): Ending Y coordinate.
spacing (float): Spacing between scan lines in the perpendicular direction.
"""
self.start_coords = (start_x, start_y)
self.end_coords = (end_x, end_y)
self.spacing = spacing
def _calculate_scan_direction(self) -> Tuple[float, float]:
"""
Calculate the direction vector of the scan.
Returns:
Tuple[float, float]: Normalized direction vector (dx, dy).
"""
dx = self.end_coords[0] - self.start_coords[0]
dy = self.end_coords[1] - self.start_coords[1]
length = np.sqrt(dx**2 + dy**2)
if length == 0:
raise ValueError("Start and end coordinates cannot be the same")
return dx / length, dy / length
def _calculate_perpendicular_direction(self) -> Tuple[float, float]:
"""
Calculate a perpendicular direction vector to the scan direction.
Returns:
Tuple[float, float]: Perpendicular vector (px, py).
"""
dx, dy = self._calculate_scan_direction()
# Rotate (dx, dy) by 90 degrees to get perpendicular vector
px = -dy
py = dx
return px, py
def generate_scan_plan(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
"""
Generate a scan plan with waypoints for the microscope stage.
Returns:
List[Tuple[Tuple[float, float], Tuple[float, float]]]:
A list of (start_point, end_point) tuples for each scan line.
"""
dx, dy = self._calculate_scan_direction()
px, py = self._calculate_perpendicular_direction()
# Calculate the total length in the perpendicular direction
start_x, start_y = self.start_coords
end_x, end_y = self.end_coords
min_coord_perp = min(start_x * px + start_y * py, end_x * px + end_y * py)
max_coord_perp = max(start_x * px + start_y * py, end_x * px + end_y * py)
# Generate scan lines
waypoints = []
current_pos_perp = min_coord_perp
while current_pos_perp <= max_coord_perp:
# Calculate start and end points for this scan line
perp_offset = current_pos_perp - (start_x * px + start_y * py)
line_start_x = start_x + perp_offset * dx
line_start_y = start_y + perp_offset * dy
line_end_x = line_start_x + dx * abs(self.end_coords[0] - self.start_coords[0])
line_end_y = line_start_y + dy * abs(self.end_coords[1] - self.start_coords[1])
waypoints.append(((line_start_x, line_start_y), (line_end_x, line_end_y)))
current_pos_perp += self.spacing
return waypoints
# Example usage:
if __name__ == "__main__":
# Create a scan plan generator
generator = StageScanPlanGenerator(
start_x=0.0, start_y=0.0,
end_x=10.0, end_y=10.0,
spacing=2.5
)
# Generate the scan plan
scan_plan = generator.generate_scan_plan()
# Print the scan plan
print("Scan Plan:")
for i, (start, end) in enumerate(scan_plan):
print(f"Line {i+1}: Start at {start}, End at {end}")
+71 -109
View File
@@ -1,33 +1,36 @@
#!/opt/srasenv/bin/python3
"""
SRAS Scan Manager
Command-line / interactive TUI for inspecting v6 .sras files.
Command-line / interactive TUI for inspecting .sras files.
A .sras file (see scan_format.md) holds one acquisition run across several
GR rotation angles, each with its own geometry (x_start, x_delta, n_frames,
n_rows) and waveform data block. This tool lists those per-angle sub-scans
and lets you export a subset to a new .sras file, or delete a subset from
the file in place — both operations rewrite the angle/geometry/row tables
and stream-copy only the selected angles' waveform data, producing a file
that is itself a valid v6 .sras readable by sras_viewer.py-style tools
(once updated for v6) or sc3_aui_app.py.
n_rows), background waveform, and waveform data block. This tool lists those
per-angle sub-scans and lets you export a subset to a new .sras file, or
delete a subset from the file in place — both operations rewrite the
angle/geometry/row tables, carry each kept angle's background across, and
stream-copy only the selected angles' waveform data, producing a file that is
itself a valid .sras readable by sras_viewer.py or sc3_aui_app.py.
Only format version 6 is supported.
Format versions 7 (full scan) and 11 (middle-row SAW check) are supported,
as are their pre-per-angle-background predecessors 6 and 10. A subset keeps
the version — and therefore the background layout — of the file it came
from: a v11 check exports as a v11 check, since dropping angles from one
leaves it one row per angle.
"""
import argparse
import struct
import sys
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
BLOB_MAGIC = b"SRAS"
BLOB_VERSION = 6
HDR_FMT = ">4sBHfffffffIdBB"
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
GEOM_FMT = ">ffIH"
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 bytes
sys.path.insert(0, str(Path(__file__).resolve().parent))
from core.sras_format import (
BG_LEN_FMT, GEOM_FMT, HDR_FMT, MAGIC, SrasFile,
)
@dataclass
@@ -40,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
@@ -51,101 +55,50 @@ class AngleEntry:
class SrasScanFile:
"""Parsed view of a v6 .sras file's header/tables plus per-angle data offsets."""
"""Parsed view of a .sras file's header/tables plus per-angle data offsets."""
def __init__(self, path: Path):
self.path = Path(path)
self._parse()
def _parse(self):
file_size = self.path.stat().st_size
with open(self.path, "rb") as f:
raw = f.read(HDR_SIZE)
if len(raw) < HDR_SIZE:
raise ValueError(f"{self.path.name}: file too short for a valid header")
(magic, version, n_angles, x_start_nom, y_start_nom, x_delta_nom,
y_delta_nom, row_spacing, velocity, laser_freq, samples_per_frame,
sample_rate, bytes_per_sample, n_channels) = struct.unpack(HDR_FMT, raw)
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
self.y_delta_nominal = h.y_delta_nominal
self.row_spacing_mm = h.row_spacing
self.velocity_mm_s = h.velocity
self.laser_freq_hz = h.laser_freq
self.samples_per_frame = h.samples_per_frame
self.sample_rate_hz = h.sample_rate
self.bytes_per_sample = h.bytes_per_sample
self.n_channels = h.n_channels
self.preambles_raw = sras.preambles_raw
self.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
if magic != BLOB_MAGIC:
raise ValueError(f"{self.path.name}: bad magic {magic!r}, not a .sras file")
if version != BLOB_VERSION:
raise ValueError(
f"{self.path.name}: unsupported format version {version} "
f"(this tool only supports v{BLOB_VERSION})")
self.x_start_nominal = x_start_nom
self.y_start_nominal = y_start_nom
self.x_delta_nominal = x_delta_nom
self.y_delta_nominal = y_delta_nom
self.row_spacing_mm = row_spacing
self.velocity_mm_s = velocity
self.laser_freq_hz = laser_freq
self.samples_per_frame = samples_per_frame
self.sample_rate_hz = sample_rate
self.bytes_per_sample = bytes_per_sample
self.n_channels = n_channels
angles = list(struct.unpack(f">{n_angles}f", f.read(4 * n_angles)))
geoms = []
for _ in range(n_angles):
x_start, x_delta, n_frames, n_rows = struct.unpack(GEOM_FMT, f.read(GEOM_SIZE))
geoms.append((x_start, x_delta, n_frames, n_rows))
row_tables = []
for (_, _, _, n_rows) in geoms:
row_tables.append(list(struct.unpack(f">{n_rows}f", f.read(4 * n_rows))))
preambles_raw = []
for _ in range(n_channels):
(plen,) = struct.unpack(">H", f.read(2))
preambles_raw.append(f.read(plen))
self.preambles_raw = preambles_raw
(n_bg,) = struct.unpack(">I", f.read(4))
self.background_raw = f.read(n_bg)
data_start_offset = f.tell()
# Build angle entries and compute what's actually present on disk,
# in case the file was closed early (aborted scan) — see scan_format.md's
# "Incomplete files" note. Waveform data is angle-major/row-minor with a
# fixed per-row byte count within an angle, so we walk cumulative offsets.
self.angles = []
cursor = data_start_offset
truncated_seen = False
for i, (angle, (x_start, x_delta, n_frames, n_rows)) in enumerate(zip(angles, geoms)):
row_bytes = n_channels * n_frames * samples_per_frame * bytes_per_sample
entry = AngleEntry(
index=i, angle_deg=angle, x_start=x_start, x_delta=x_delta,
n_frames=n_frames, n_rows_declared=n_rows,
y_positions=row_tables[i], row_bytes=row_bytes,
data_offset=cursor,
self.angles = [
AngleEntry(
index=st.index, angle_deg=st.angle_deg,
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,
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,
)
if truncated_seen:
entry.n_rows_available = 0
entry.data_size_available = 0
entry.complete = False
else:
declared_bytes = row_bytes * n_rows
if row_bytes > 0 and cursor + declared_bytes <= file_size:
entry.n_rows_available = n_rows
entry.data_size_available = declared_bytes
entry.complete = True
cursor += declared_bytes
else:
remaining = max(0, file_size - cursor)
n_complete = remaining // row_bytes if row_bytes > 0 else 0
entry.n_rows_available = n_complete
entry.data_size_available = n_complete * row_bytes
entry.complete = (n_complete == n_rows)
cursor += entry.data_size_available
truncated_seen = True
self.angles.append(entry)
self.data_start_offset = data_start_offset
self.file_size = file_size
for pa, st, bg in zip(sras.per_angle, sras.angle_status(),
sras.backgrounds, strict=True)
]
def get(self, index: int) -> AngleEntry:
return self.angles[index]
@@ -156,7 +109,7 @@ class SrasScanFile:
# ---------------------------------------------------------------------------
def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
"""Write a new v6 .sras file containing only the given angle indices
"""Write a new .sras file containing only the given angle indices
(in the given order). Returns a list of warning strings (e.g. for
angles that were truncated on disk and thus exported with fewer rows
than declared).
@@ -165,7 +118,7 @@ def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
selected = [sf.get(i) for i in indices]
header = struct.pack(
HDR_FMT, BLOB_MAGIC, BLOB_VERSION, len(selected),
HDR_FMT, MAGIC, sf.version, len(selected),
sf.x_start_nominal, sf.y_start_nominal,
sf.x_delta_nominal, sf.y_delta_nominal,
sf.row_spacing_mm, sf.velocity_mm_s, sf.laser_freq_hz,
@@ -194,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
@@ -281,7 +242,8 @@ def parse_index_spec(spec: str, max_index: int) -> list:
def print_summary(sf: SrasScanFile, selected: set):
print()
print(f"File: {sf.path} (v{BLOB_VERSION}, {_human_size(sf.file_size)})")
kind = " SAW check" if sf.is_saw_check else ""
print(f"File: {sf.path} (v{sf.version}{kind}, {_human_size(sf.file_size)})")
print(f"Nominal ROI: x_start={sf.x_start_nominal:.4f} x_delta={sf.x_delta_nominal:.4f} "
f"y_start={sf.y_start_nominal:.4f} y_delta={sf.y_delta_nominal:.4f} mm "
f"row_spacing={sf.row_spacing_mm:.4f} mm")
@@ -402,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'")
+525 -938
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
PyQt6==6.10.2
numpy==2.4.1
matplotlib==3.10.8
scipy==1.16.3
+14 -21
View File
@@ -19,7 +19,7 @@ Usage::
from __future__ import annotations
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFrame, QGridLayout,
@@ -27,9 +27,10 @@ from PyQt6.QtWidgets import (
QSpinBox, QSplitter, QVBoxLayout, QWidget,
)
from hardware.t3r_driver import T3RDriver
from gui.qt_t3r import QtT3RAdapter
from hardware.serial_util import scored_ports
from hardware.t3r_driver import T3RDriver # class constants (gear train, channel names)
import hardware.t3r_protocol as proto
import serial.tools.list_ports
# ── Utilities ─────────────────────────────────────────────────────────────────
@@ -62,7 +63,7 @@ def _spin(lo: int, hi: int, val: int) -> QSpinBox:
class ChannelPanel(QGroupBox):
"""Controls and live readouts for one T3R axis."""
def __init__(self, ch: int, driver: T3RDriver):
def __init__(self, ch: int, driver: QtT3RAdapter):
label = f"Axis {ch} — {T3RDriver.CHANNEL_NAMES[ch]}"
super().__init__(label)
self.ch = ch
@@ -278,7 +279,7 @@ class ChannelPanel(QGroupBox):
class GroupPanel(QGroupBox):
"""Ganged motion — selected axes step in lockstep."""
def __init__(self, driver: T3RDriver, log_fn):
def __init__(self, driver: QtT3RAdapter, log_fn):
super().__init__("Ganged / synchronised motion — selected axes move in lockstep")
self._driver = driver
self._log = log_fn
@@ -389,7 +390,7 @@ class RotationPanel(QGroupBox):
Ratio = 125/10 = 12.5
"""
def __init__(self, driver: T3RDriver):
def __init__(self, driver: QtT3RAdapter):
super().__init__(
f"Stage Rotation (GR-axis ch{T3RDriver.GR_AXIS_CH}) — "
f"gear: {T3RDriver.GEAR_TEETH_MOTOR}T motor → 30T idler → "
@@ -481,12 +482,12 @@ class RotationPanel(QGroupBox):
class T3RControlPanel(QDialog):
"""User-hidable T3R control window.
Pass a T3RDriver instance. The panel connects to its signals and forwards
Pass a QtT3RAdapter instance. The panel connects to its signals and forwards
commands via its API. Connection management (port open/close) is handled
inside the panel itself.
"""
def __init__(self, driver: T3RDriver, parent=None):
def __init__(self, driver: QtT3RAdapter, parent=None):
super().__init__(parent)
self.setWindowTitle("T3R Stepper Controller")
self.setWindowFlags(
@@ -613,16 +614,8 @@ class T3RControlPanel(QDialog):
def _refresh_ports(self):
current = self.port_combo.currentText()
self.port_combo.clear()
ports = list(serial.tools.list_ports.comports())
def score(p):
text = f"{p.description} {p.manufacturer or ''} {p.product or ''}".lower()
hints = ("esp32", "jtag", "espressif", "usb serial", "cp210", "ch340", "cdc")
return -sum(h in text for h in hints)
ports.sort(key=score)
for p in ports:
self.port_combo.addItem(f"{p.device} — {p.description or p.device}", p.device)
for device, label in scored_ports():
self.port_combo.addItem(label, device)
if self.port_combo.count() == 0:
self.port_combo.addItem("(no serial ports found)", None)
elif current:
@@ -632,14 +625,14 @@ class T3RControlPanel(QDialog):
def _toggle_connect(self):
if self._driver.is_open:
self._driver.disconnect()
self._driver.close()
return
port = self.port_combo.currentData()
if not port:
self._log("No serial port selected", "err")
return
try:
self._driver.connect(port)
self._driver.open(port)
except Exception as exc:
self._log(f"Connect failed: {exc}", "err")
self.conn_lbl.setText("connect failed")
@@ -650,7 +643,7 @@ class T3RControlPanel(QDialog):
self.conn_lbl.setText("opening…")
self.connect_btn.setText("Disconnect")
self.port_combo.setEnabled(False)
self._log(f"Port opened, sending PING…", "evt")
self._log("Port opened, sending PING…", "evt")
def _on_handshake_ok(self, proto_ver: int, fw_ver: int, num_ch: int):
self.conn_lbl.setText("connected")
+41
View File
@@ -0,0 +1,41 @@
"""Shared test setup: repo-root imports, headless Qt, pyueye stub.
The IDS uEye SDK (pyueye + libueye) only exists on the Linux rig. On any
other machine we stub the module before hardware.uc480_camera is imported;
everything in uc480_camera references `ueye.*` at call time, not import
time, so an attribute-permissive dummy is sufficient for constructing
windows and importing modules.
"""
import os
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
try:
import pyueye # noqa: F401
except ImportError:
class _UeyeStub:
"""Permissive attribute sink standing in for pyueye.ueye."""
IS_SUCCESS = 0
def __getattr__(self, name):
return _UeyeStub()
def __call__(self, *args, **kwargs):
return _UeyeStub()
def __or__(self, other):
return 0
def __ror__(self, other):
return 0
_pyueye = types.ModuleType("pyueye")
_pyueye.ueye = _UeyeStub()
sys.modules["pyueye"] = _pyueye
+388
View File
@@ -0,0 +1,388 @@
"""Recording fake hardware for headless ScanEngine tests.
Each fake records an ordered call trace, so a test can assert the exact
command sequence the engine issues — the property that matters when the
real rig isn't available.
The stage and scope are wired together the way the rig is: an X move at scan
velocity with the trigger gate armed feeds frames into a running acquisition,
at the real 20 kHz / 100 mm/s rate. Per-row and burst acquisition therefore
get their frame counts from the same model, which is what makes a
byte-identity comparison between the two paths meaningful — and it means a
gate the engine forgets to drop shows up as extra frames instead of passing
silently.
"""
from __future__ import annotations
from core.scan_engine import (
AXIS_X, LASER_FREQ_HZ, SCAN_RAMP_BUFFER_MM, SCAN_RAMP_MM,
SCAN_VELOCITY_MM_S,
)
RAMP_TOTAL_MM = SCAN_RAMP_MM + SCAN_RAMP_BUFFER_MM
class Trace:
"""Ordered record of hardware calls, shared by all fakes in one test."""
def __init__(self):
self.calls: list[tuple] = []
def record(self, *entry):
self.calls.append(entry)
def names(self) -> list[str]:
return [c[0] for c in self.calls]
def of(self, name: str) -> list[tuple]:
return [c for c in self.calls if c[0] == name]
def count(self, name: str) -> int:
return len(self.of(name))
class FakeStage:
"""Stands in for ThorlabsServoDriver."""
def __init__(self, trace: Trace, homed=(True, True), enabled=(True, True),
scope=None):
self._t = trace
self.am_homed = list(homed)
self.am_enabled = list(enabled)
self.positions = [0.0, 0.0]
self._scope = scope
self.gate_armed = False
def attach_scope(self, scope):
"""Route gated motion into `scope`, as the TRIGOUT pin does on the rig."""
self._scope = scope
def enable_axis(self, axis):
self._t.record("enable_axis", axis)
self.am_enabled[0 if axis == AXIS_X else 1] = True
def home_axis(self, axis, timeout=0.0):
self._t.record("home_axis", axis)
self.am_homed[0 if axis == AXIS_X else 1] = True
def set_velocity_params(self, axis, max_velocity=None, acceleration=None):
self._t.record("set_velocity_params", axis, max_velocity, acceleration)
def set_trigger_trigout_maxv(self, axis):
self._t.record("set_trigger_trigout_maxv", axis)
if axis == AXIS_X:
self.gate_armed = True
def set_trigger_gate_off(self, axis):
self._t.record("set_trigger_gate_off", axis)
if axis == AXIS_X:
self.gate_armed = False
def arm_scan_gate(self, axis, armed, verify=True):
self._t.record("arm_scan_gate", axis, bool(armed))
if axis == AXIS_X:
self.gate_armed = bool(armed)
def move_axis_absolute(self, axis, pos, timeout=0.0):
idx = 0 if axis == AXIS_X else 1
prev = self.positions[idx]
self._t.record("move_axis_absolute", axis, round(pos, 6))
self.positions[idx] = pos
# The gate is high only at max velocity, i.e. over the move minus its
# two ramps — direction-agnostic, so a flyback the engine failed to
# gate off produces frames instead of quietly producing none.
if axis == AXIS_X and self.gate_armed and self._scope is not None:
at_speed_mm = abs(pos - prev) - 2 * RAMP_TOTAL_MM
if at_speed_mm > 0:
self._scope.acquire_frames(
round(at_speed_mm * LASER_FREQ_HZ / SCAN_VELOCITY_MM_S))
def background_record(n: int, samples_per_frame: int) -> bytes:
"""The waveform FakeScope returns from its n-th background capture.
Every angle captures its own, so the tests need to tell one from the
next: a background that landed under the wrong angle would otherwise
look exactly like the right one.
"""
return bytes((n * 17 + s) % 256 for s in range(samples_per_frame))
class FakeScope:
"""Stands in for TektronixOscilloscopeBase.
Returns deterministic frame bytes so the written file can be compared
against an expected byte pattern.
"""
def __init__(self, trace: Trace, samples_per_frame=8, max_frames=4096):
self._t = trace
self.samples_per_frame = samples_per_frame
self.max_frames = max_frames
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.
self._next_frame: dict[int, int] = {}
# -- driven by FakeStage ------------------------------------------------
def acquire_frames(self, n):
if self._running:
self._acquired += n
# -- writes / queries ---------------------------------------------------
def write(self, cmd):
self._t.record("write", cmd)
if cmd == "ACQuire:STATE RUN":
self._running = True
self._acquired = 0
elif cmd == "ACQuire:STATE STOP":
self._running = False
def query(self, cmd):
self._t.record("query", cmd)
if cmd == "ACQuire:STATE?":
self._acq_polls += 1
# STOPAfter SEQuence self-stops when the sequence completes, so
# reporting "stopped" and staying armed would be inconsistent.
self._running = False
return "0" # background average finished
if cmd == "ACQuire:NUMFRAMESACQuired?":
return str(self._acquired)
return ""
# -- typed setters used by core.scope_sras ------------------------------
def set_trigger_source(self, ch):
self._t.record("set_trigger_source", ch)
def set_trigger_slope(self, slope):
self._t.record("set_trigger_slope", slope)
def set_trigger_level(self, ch, level):
self._t.record("set_trigger_level", ch, level)
def set_trigger_mode(self, mode):
self._t.record("set_trigger_mode", mode)
def set_acquire_mode(self, mode):
self._t.record("set_acquire_mode", mode)
def set_fastframe_state(self, on):
self._t.record("set_fastframe_state", on)
def set_fastframe_count(self, n):
self._t.record("set_fastframe_count", n)
def get_fastframe_state(self):
return 1
def get_fastframe_max_frames(self):
self._t.record("get_fastframe_max_frames")
return self.max_frames
def set_sample_rate(self, sr):
self._t.record("set_sample_rate", sr)
def get_record_length(self):
return self.samples_per_frame
def set_data_source(self, ch):
self._t.record("set_data_source", ch)
self._source = ch
def set_data_encoding(self, encoding):
self._t.record("set_data_encoding", encoding)
def set_data_width(self, width):
self._t.record("set_data_width", width)
def query_wfmoutpre(self):
return f"WFMOUTPRE:CH{self._source};YMULT 1.5625E-3;YOFF -87.04;YZERO 0.0"
def transfer_curve(self):
self._t.record("transfer_curve")
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
start = self._next_frame.get(ch, 0)
self._next_frame[ch] = start + count
return [bytes((ch * 31 + g + s) % 256 for s in range(spf))
for g in range(start, start + count)]
def transfer_fastframe(self, parse=True, byte_count=1, signed=True,
byte_order='MSB'):
self._t.record("transfer_fastframe", self._source)
return self._frames(self._source, self._acquired)
def transfer_fastframe_bulk(self, frame_count, samples_per_frame,
bytes_per_sample=1):
self._t.record("transfer_fastframe_bulk", self._source, frame_count)
return bytearray(b"".join(self._frames(self._source, frame_count)))
# channel config (only used by configure_channels)
def set_channel_label_name(self, ch, name):
self._t.record("set_channel_label_name", ch, name)
def set_channel_scale(self, ch, v):
self._t.record("set_channel_scale", ch, v)
def set_channel_position(self, ch, v):
self._t.record("set_channel_position", ch, v)
def set_channel_termination(self, ch, v):
self._t.record("set_channel_termination", ch, v)
def set_channel_coupling(self, ch, v):
self._t.record("set_channel_coupling", ch, v)
def set_channel_bandwidth(self, ch, v):
self._t.record("set_channel_bandwidth", ch, v)
class FakeT3R:
"""Stands in for the (Qt-free) T3RDriver, for RotationAxis."""
GR_AXIS_CH = 3
MOTOR_FULL_STEPS_PER_REV = 200
GEAR_TEETH_MOTOR = 10
GEAR_TEETH_STAGE = 125
def __init__(self, trace: Trace, is_open=True, motion_completes=True):
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)
def set_current(self, ch, run_ma, hold_ma, ihold):
self._t.record("t3r_set_current", ch, run_ma, hold_ma, ihold)
def enable(self, ch):
self._t.record("t3r_enable", ch)
def steps_for_angle(self, angle_deg, microsteps):
ratio = self.GEAR_TEETH_STAGE / self.GEAR_TEETH_MOTOR
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
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+170
View File
@@ -0,0 +1,170 @@
{
"header": {
"version": 6,
"n_angles": 2,
"x_start_nominal": 1.0,
"y_start_nominal": 1.0,
"x_delta_nominal": 0.019999999552965164,
"y_delta_nominal": 0.019999999552965164,
"row_spacing": 0.009999999776482582,
"velocity": 100.0,
"laser_freq": 20000.0,
"samples_per_frame": 8,
"sample_rate": 6250000000.0,
"bytes_per_sample": 1,
"n_channels": 3,
"angles": [
0.0,
-180.0
],
"per_angle": [
{
"angle": 0.0,
"x_start": 1.0,
"x_delta": 0.019999999552965164,
"n_frames": 4,
"n_rows": 3,
"y_positions": [
1.0,
1.0099999904632568,
1.0199999809265137
]
},
{
"angle": -180.0,
"x_start": 1.0,
"x_delta": 0.019999999552965164,
"n_frames": 4,
"n_rows": 3,
"y_positions": [
1.0,
1.0099999904632568,
1.0199999809265137
]
}
],
"data_start_offset": 265
},
"statuses": {
"complete.sras": [
{
"index": 0,
"angle_deg": 0.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 265,
"n_rows_available": 3,
"status": "OK"
},
{
"index": 1,
"angle_deg": -180.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 553,
"n_rows_available": 3,
"status": "OK"
}
],
"trunc_midrow_a1.sras": [
{
"index": 0,
"angle_deg": 0.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 265,
"n_rows_available": 3,
"status": "OK"
},
{
"index": 1,
"angle_deg": -180.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 553,
"n_rows_available": 1,
"status": "TRUNCATED"
}
],
"trunc_rowboundary_a1.sras": [
{
"index": 0,
"angle_deg": 0.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 265,
"n_rows_available": 3,
"status": "OK"
},
{
"index": 1,
"angle_deg": -180.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 553,
"n_rows_available": 2,
"status": "TRUNCATED"
}
],
"trunc_angleboundary.sras": [
{
"index": 0,
"angle_deg": 0.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 265,
"n_rows_available": 3,
"status": "OK"
},
{
"index": 1,
"angle_deg": -180.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 553,
"n_rows_available": 0,
"status": "MISSING"
}
],
"trunc_midrow_a0.sras": [
{
"index": 0,
"angle_deg": 0.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 265,
"n_rows_available": 1,
"status": "TRUNCATED"
},
{
"index": 1,
"angle_deg": -180.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 553,
"n_rows_available": 0,
"status": "MISSING"
}
],
"header_only.sras": [
{
"index": 0,
"angle_deg": 0.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 265,
"n_rows_available": 0,
"status": "MISSING"
},
{
"index": 1,
"angle_deg": -180.0,
"n_rows": 3,
"row_bytes": 96,
"data_offset": 553,
"n_rows_available": 0,
"status": "MISSING"
}
]
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
"""Shared constants and writers for the .sras test fixtures.
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]
PREAMBLES = [f"WFMOUTPRE:CH{ch};SYNTHETIC;PT_FMT Y;XINCR 1.6E-10" for ch in CHANNELS]
BACKGROUND = bytes(range(SPF))
# build_plan inputs for the fixture geometry: 2 angles × 3 rows × 4 frames
TINY_PLAN_ARGS = dict(x_start=1.0, y_start=1.0, x_delta=0.02, y_delta=0.02,
num_angles=2, row_spacing=0.01)
LASER_FREQ_HZ = 20000.0
VELOCITY_MM_S = 100.0
def synthetic_frame(ai, ri, ci, fi):
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
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
+292
View File
@@ -0,0 +1,292 @@
"""Pre-scan angle inspection, driven entirely by fake hardware.
The feature's defining constraint is that it reads nothing back from the
scope — the operator looks at the instrument. These tests pin that, the scope
state the app is responsible for putting the instrument into, and the motion
sequence across angles.
"""
import random
import pytest
from core.angle_inspect import AngleInspector, InspectCallbacks
from core.rotation import RotationAxis, RotationSettings
from core.scan_engine import AXIS_X, AXIS_Y
from core.scan_geometry import build_plan
from core.scope_inspect import (
BIAS_CHANNELS, BIAS_POSITION_DIV, BIAS_SCALE_V_DIV, BIAS_WINDOW_V,
INSPECT_TRIG_LEVEL_V, inspect_channel_profiles,
)
from core.scope_sras import SRAS_CHANNELS
from fakes import FakeScope, FakeStage, FakeT3R, Trace
SPF = 8
def make_plan(num_angles=3):
return build_plan(40.0, 30.0, 2.0, 1.0, num_angles, 0.25,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
def build(num_angles=3, seed=1234, callbacks=None, rotator_open=True):
trace = Trace()
scope = FakeScope(trace, samples_per_frame=SPF)
stage = FakeStage(trace, scope=scope)
t3r = FakeT3R(trace, is_open=rotator_open)
rotator = RotationAxis(t3r, RotationSettings())
plan = make_plan(num_angles)
insp = AngleInspector(stage, scope, rotator, plan,
callbacks=callbacks or InspectCallbacks(),
rng=random.Random(seed))
return insp, trace, plan
def writes(trace):
return [c[1] for c in trace.of("write")]
# ── The defining constraint ──────────────────────────────────────────────────
def test_inspection_never_reads_a_waveform_back():
"""The operator reads the scope; the app must not pull data off it.
If this fails, someone has added a transfer path to a feature whose whole
premise is that there isn't one.
"""
insp, trace, plan = build()
insp.start()
for i in range(plan.n_angles):
insp.goto_angle(i)
insp.new_point()
insp.stop()
forbidden = {"transfer_fastframe", "transfer_fastframe_bulk",
"transfer_curve", "set_data_source", "query_wfmoutpre"}
assert forbidden.isdisjoint(set(trace.names()))
assert "CURVe?" not in writes(trace)
# ── Scope configuration ──────────────────────────────────────────────────────
def test_start_sets_an_edge_trigger_on_ch2_above_the_scan_level():
insp, trace, _ = build()
insp.start()
assert "TRIGger:A:TYPe EDGE" in writes(trace)
assert trace.of("set_trigger_source")[-1][1] == 2
assert trace.of("set_trigger_slope")[-1][1] == "RISE"
ch, level = trace.of("set_trigger_level")[-1][1:3]
assert (ch, level) == (2, INSPECT_TRIG_LEVEL_V)
assert INSPECT_TRIG_LEVEL_V >= 2.0
def test_start_disables_fastframe_averaging_and_the_logic_trigger():
"""Everything the scan needs and inspection must not inherit."""
insp, trace, _ = build()
insp.start()
assert trace.of("set_fastframe_state")[-1][1] is False
assert trace.of("set_acquire_mode")[-1][1] == "SAMPLE"
w = writes(trace)
assert not any("LOGIc" in cmd or "LOGICPattern" in cmd for cmd in w)
def test_start_leaves_the_acquisition_free_running():
"""The display has to keep updating while the operator looks at it."""
insp, trace, _ = build()
insp.start()
w = writes(trace)
assert "ACQuire:STOPAfter RUNSTop" in w
assert w.index("ACQuire:STOPAfter RUNSTop") < w.index("ACQuire:STATE RUN")
assert "ACQuire:STATE STOP" not in w
def test_bias_channels_are_directly_comparable():
"""CH3/CH4 must share scale and position or the eye comparison is a lie."""
profiles = inspect_channel_profiles()
a, b = (profiles[ch] for ch in BIAS_CHANNELS)
assert a.scale_v_div == b.scale_v_div
assert a.position_div == b.position_div
# Same front end as the scan records — only the display changes.
for ch in BIAS_CHANNELS:
assert profiles[ch].termination_ohm == SRAS_CHANNELS[ch].termination_ohm
assert profiles[ch].coupling == SRAS_CHANNELS[ch].coupling
assert profiles[ch].bandwidth_hz == SRAS_CHANNELS[ch].bandwidth_hz
@pytest.mark.parametrize("n_divisions", [8, 10])
def test_bias_window_shows_zero_to_700mv_with_headroom(n_divisions):
"""0–700 mV must fit on screen, above ground, on either graticule size.
Ground sits BIAS_POSITION_DIV divisions below centre, so the visible
window runs from (-N/2 - pos)*scale to (+N/2 - pos)*scale.
"""
half = n_divisions / 2
bottom = (-half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV
top = (half - BIAS_POSITION_DIV) * BIAS_SCALE_V_DIV
assert bottom < 0.0, "no room below ground for undershoot"
assert top > BIAS_WINDOW_V, "700 mV is clipped or sitting on the top edge"
# The point of moving the trace down: most of the screen is above ground.
assert abs(bottom) < top
def test_ch1_keeps_the_acquisition_front_end():
"""What you see at a point is what a scan would record there."""
assert inspect_channel_profiles()[1] == SRAS_CHANNELS[1]
# ── Stage and rotation ───────────────────────────────────────────────────────
def test_start_parks_on_the_first_angle():
insp, _, plan = build()
point = insp.start()
assert point.angle_idx == 0
assert point.angle_deg == plan.per_angle[0].angle_deg
assert insp.current_point == point
def test_the_gate_is_off_for_the_whole_inspection():
"""Nothing here is gated, and an armed output keeps driving the line."""
insp, trace, _ = build()
insp.start()
insp.goto_angle(2)
insp.new_point()
assert trace.count("set_trigger_gate_off") >= 1
assert trace.count("set_trigger_trigout_maxv") == 0
assert [c[2] for c in trace.of("arm_scan_gate") if c[2]] == []
def test_points_land_on_the_scan_grid():
"""A point the scan would never sample tells you nothing about the scan."""
insp, _, plan = build()
insp.start()
for i in range(plan.n_angles):
pa = plan.per_angle[i]
for _ in range(5):
pt = insp.new_point() if insp.angle_idx == i else insp.goto_angle(i)
assert pt.angle_idx == i
assert pt.y_mm in pa.y_positions
assert pa.x_start <= pt.x_mm <= pa.x_start + pa.x_delta
def test_goto_angle_rotates_then_moves():
insp, trace, plan = build()
insp.start()
trace.calls.clear()
insp.goto_angle(2)
# t3r_rotate carries the delta, so assert the resulting absolute angle.
assert trace.count("t3r_rotate") == 1, "expected exactly one rotation"
assert insp._rotator.current_deg == pytest.approx(plan.per_angle[2].angle_deg)
moves = trace.of("move_axis_absolute")
assert [m[1] for m in moves] == [AXIS_Y, AXIS_X], "Y then X, as the scan does"
def test_new_point_re_rolls_without_rotating():
"""Distinguishing a bad spot from a bad angle depends on not rotating."""
insp, trace, _ = build()
insp.start()
insp.goto_angle(1)
trace.calls.clear()
first = insp.current_point
second = insp.new_point()
assert second.angle_idx == first.angle_idx == 1
assert (second.x_mm, second.y_mm) != (first.x_mm, first.y_mm)
assert trace.count("t3r_rotate") == 0, "new_point must not rotate"
assert [m[1] for m in trace.of("move_axis_absolute")] == [AXIS_Y, AXIS_X]
def test_next_and_prev_wrap_around():
insp, _, plan = build(num_angles=3)
insp.start()
assert insp.next_angle().angle_idx == 1
assert insp.next_angle().angle_idx == 2
assert insp.next_angle().angle_idx == 0, "should wrap forward"
assert insp.prev_angle().angle_idx == plan.n_angles - 1, "should wrap back"
def test_angle_labels_cover_every_angle():
insp, _, plan = build(num_angles=9)
labels = insp.angle_labels()
assert len(labels) == 9
assert labels[0].startswith("Angle 1/9")
# ── Guards ───────────────────────────────────────────────────────────────────
def test_multi_angle_inspection_requires_the_rotator():
insp, _, _ = build(num_angles=3, rotator_open=False)
with pytest.raises(RuntimeError, match="T3R rotation stage"):
insp.start()
def test_single_angle_inspection_works_without_the_rotator():
insp, _, _ = build(num_angles=1, rotator_open=False)
point = insp.start()
assert point.angle_idx == 0
def test_navigation_before_start_is_rejected():
insp, _, _ = build()
with pytest.raises(RuntimeError, match="not been started"):
insp.goto_angle(1)
with pytest.raises(RuntimeError, match="not been started"):
insp.new_point()
def test_out_of_range_angle_is_rejected():
insp, _, _ = build(num_angles=3)
insp.start()
with pytest.raises(IndexError):
insp.goto_angle(3)
def test_stop_halts_the_sweep_and_sends_the_rotator_home():
insp, trace, _ = build()
insp.start()
insp.goto_angle(2)
trace.calls.clear()
insp.stop()
assert "ACQuire:STATE STOP" in writes(trace)
assert trace.count("t3r_rotate") == 1, "GR not sent home"
assert insp._rotator.current_deg == pytest.approx(0.0)
def test_stop_is_idempotent():
insp, trace, _ = build()
insp.start()
insp.stop()
trace.calls.clear()
insp.stop() # must not re-issue anything or raise
assert trace.calls == []
def test_busy_callback_brackets_every_move():
"""The window disables its controls on this, so it has to pair up."""
events = []
insp, _, _ = build(callbacks=InspectCallbacks(on_busy=events.append))
insp.start()
insp.goto_angle(1)
insp.new_point()
insp.stop()
assert events, "no busy events emitted"
assert events[0] is True and events[-1] is False
depth = 0
for e in events:
depth += 1 if e else -1
assert depth in (0, 1), f"unbalanced busy events: {events}"
assert depth == 0
+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
+50
View File
@@ -0,0 +1,50 @@
"""core.config: round-trip, tolerance, and the helios_port regression.
The old dict-based writer rebuilt the JSON from only the main window's
fields, silently discarding helios_port every time a port was edited.
ScanDefaults.save() always writes every field.
"""
import json
from core.config import ScanDefaults
def test_roundtrip(tmp_path):
p = tmp_path / "defaults.json"
d = ScanDefaults(t3r_port="/dev/ttyACM3", helios_port="/dev/ttyUSB9")
d.save(p)
loaded = ScanDefaults.load(p)
assert loaded == d
def test_missing_file_creates_defaults(tmp_path):
p = tmp_path / "defaults.json"
d = ScanDefaults.load(p)
assert d == ScanDefaults()
assert p.exists()
def test_corrupt_file_falls_back(tmp_path):
p = tmp_path / "defaults.json"
p.write_text("{not json")
assert ScanDefaults.load(p) == ScanDefaults()
def test_unknown_keys_ignored(tmp_path):
p = tmp_path / "defaults.json"
p.write_text(json.dumps({"t3r_port": "/dev/ttyACM7", "laser_freq_hz": 20000.0}))
d = ScanDefaults.load(p)
assert d.t3r_port == "/dev/ttyACM7"
assert d.helios_port == ScanDefaults().helios_port
def test_helios_port_survives_partial_update(tmp_path):
"""Regression: editing main-window ports must not clobber helios_port."""
p = tmp_path / "defaults.json"
ScanDefaults(helios_port="/dev/ttyUSB7").save(p)
d = ScanDefaults.load(p)
d.t3r_port = "/dev/ttyACM1" # what _persist_defaults does
d.save(p)
assert ScanDefaults.load(p).helios_port == "/dev/ttyUSB7"
+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() == "---.---"
+172
View File
@@ -0,0 +1,172 @@
"""gui.qt_workers: the shared queue/poll worker base."""
import threading
import time
import pytest
from PyQt6.QtCore import QThread
from PyQt6.QtWidgets import QApplication
from gui.qt_workers import PollingQueueWorker, QueueWorker
@pytest.fixture(scope="module")
def qapp():
yield QApplication.instance() or QApplication([])
class _Recorder(QueueWorker):
def __init__(self):
super().__init__()
self.seen = []
self.stopped = threading.Event()
self._handlers.update({
"note": self._note,
"boom": self._boom,
})
def _note(self, value):
self.seen.append(value)
def _boom(self):
raise RuntimeError("handler failed")
def _on_stop(self):
self.stopped.set()
def _run_until(worker, predicate, timeout=5.0):
"""Run the worker loop on a plain thread until predicate() is true.
Pumps the Qt event loop while waiting: signals emitted from the worker
thread are delivered as queued events on this (main) thread.
"""
app = QApplication.instance()
t = threading.Thread(target=worker.run, daemon=True)
t.start()
deadline = time.monotonic() + timeout
while not predicate() and time.monotonic() < deadline:
app.processEvents()
time.sleep(0.01)
app.processEvents()
return t
def test_commands_dispatch_in_order(qapp):
w = _Recorder()
for i in range(5):
w._enqueue("note", value=i)
t = _run_until(w, lambda: len(w.seen) == 5)
w.stop_worker()
t.join(timeout=5)
assert w.seen == [0, 1, 2, 3, 4]
assert w.stopped.is_set()
def test_handler_exception_is_reported_not_fatal(qapp):
w = _Recorder()
errors = []
w.error_occurred.connect(errors.append)
w._enqueue("boom")
w._enqueue("note", value="after")
t = _run_until(w, lambda: w.seen == ["after"])
w.stop_worker()
t.join(timeout=5)
assert w.seen == ["after"], "loop died on a failing handler"
assert errors and "handler failed" in errors[0]
def test_unknown_command_reported(qapp):
w = _Recorder()
errors = []
w.error_occurred.connect(errors.append)
w._enqueue("nope")
t = _run_until(w, lambda: bool(errors))
w.stop_worker()
t.join(timeout=5)
assert errors and "Unknown command" in errors[0]
def test_idle_worker_does_not_spin(qapp):
"""The loop must block on the queue, not poll it on a timeout."""
w = _Recorder()
t = threading.Thread(target=w.run, daemon=True)
t.start()
time.sleep(0.3) # idle
cpu_before = time.process_time()
time.sleep(0.5) # still idle
cpu_used = time.process_time() - cpu_before
w.stop_worker()
t.join(timeout=5)
# A 10–20 Hz timeout-poll loop burns measurable CPU here; blocking uses ~0.
assert cpu_used < 0.05, f"idle worker used {cpu_used:.3f}s CPU"
class _Poller(PollingQueueWorker):
def __init__(self):
super().__init__(poll_interval_s=0.02)
self.polls = 0
self.in_flight = 0
self.overlaps = 0
self.is_connected = True
def _poll_once(self):
self.in_flight += 1
if self.in_flight > 1:
self.overlaps += 1
time.sleep(0.05) # deliberately slower than the poll interval
self.polls += 1
self.in_flight -= 1
def test_polling_never_overlaps_or_backs_up(qapp):
"""A device slower than the interval must not accumulate stale polls."""
w = _Poller()
t = threading.Thread(target=w.run, daemon=True)
t.start()
w.start_polling()
time.sleep(0.6)
w.stop_polling()
time.sleep(0.15)
queued = w._cmd_q.qsize()
w.stop_worker()
t.join(timeout=5)
assert w.polls >= 3, "polling did not run"
assert w.overlaps == 0, "polls overlapped"
# Self-rescheduling means at most one poll is ever pending.
assert queued <= 1, f"{queued} stale polls queued up"
def test_stop_polling_halts_the_cycle(qapp):
w = _Poller()
t = threading.Thread(target=w.run, daemon=True)
t.start()
w.start_polling()
time.sleep(0.2)
w.stop_polling()
time.sleep(0.2)
settled = w.polls
time.sleep(0.2)
w.stop_worker()
t.join(timeout=5)
assert w.polls == settled, "polling continued after stop_polling()"
def test_worker_runs_on_its_qthread(qapp):
"""Sanity check the intended usage: run() executes on the QThread."""
w = _Recorder()
thread = QThread()
w.moveToThread(thread)
thread.started.connect(w.run)
ids = []
w._handlers["note"] = lambda value: ids.append(threading.get_ident())
thread.start()
w._enqueue("note", value=None)
deadline = time.monotonic() + 5
while not ids and time.monotonic() < deadline:
qapp.processEvents()
time.sleep(0.01)
w.stop_worker()
thread.quit()
assert thread.wait(5000)
assert ids and ids[0] != threading.get_ident()
+364
View File
@@ -0,0 +1,364 @@
"""Middle-row SAW quality check: plan reduction, the v11 file, and the read-out.
The acquisition half runs on the same fake rig as the scan tests; the
analysis half runs on a synthetic v11 file whose CH1 is a pure sine at a
known FFT bin, so the frequency a trace reports is a number the test knows
in advance rather than one it copies from the implementation.
"""
import math
import numpy as np
import pytest
from core.rotation import RotationAxis, RotationSettings
from core.saw_check import (
SPREAD_GOOD_PCT, alignment_summary, frequency_traces, middle_row_index,
middle_row_plan,
)
from core.scan_engine import ScanCallbacks, ScanEngine
from core.scan_geometry import ScanGeometryError, build_plan
from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, create_scan_file,
write_background_block,
)
from fakes import FakeScope, FakeStage, FakeT3R, Trace
SAMPLE_RATE = 6.25e9
SPF = 256
LASER_FREQ_HZ = 20000.0
VELOCITY_MM_S = 100.0
PREAMBLES = [f"WFMOUTPRE:CH{ch};YMULT 1.5625E-3;YOFF -87.04;YZERO 0.0"
for ch in SCAN_CHANNELS]
# adc_to_mv with those constants maps 0 → +136 mV and -120 → -51 mV, so a
# frame of zeros passes a 50 mV CH4 gate and a frame of -120 does not.
DC_THRESHOLD_MV = 50.0
CH4_PASS = bytes(SPF)
CH4_FAIL = bytes([256 - 120]) * SPF
def full_plan(num_angles=3, y_delta=0.05):
"""A small ROI, well inside the stage limits, with several rows per angle."""
return build_plan(40.0, 30.0, 0.02, y_delta, num_angles, 0.01,
laser_freq_hz=LASER_FREQ_HZ, velocity_mm_s=VELOCITY_MM_S)
def bin_mhz(k: int) -> float:
return k * SAMPLE_RATE / SPF / 1e6
def sine_frame(k: int) -> bytes:
"""One frame holding a pure sine at FFT bin `k`."""
n = np.arange(SPF)
return np.round(100 * np.sin(2 * math.pi * k * n / SPF)).astype(np.int8).tobytes()
def write_check(path, bins, n_masked_frames=0, plan=None, backgrounds=None):
"""A synthetic v11 file: angle `i`'s CH1 is a sine at FFT bin `bins[i]`.
``backgrounds`` supplies each angle's own background block; the default
is a flat zero one per angle, which subtracts to nothing.
"""
plan = plan if plan is not None else middle_row_plan(full_plan(len(bins)))
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES,
version=VERSION_SAW_CHECK)
try:
for ai, pa in enumerate(plan.per_angle):
write_background_block(
f, bytes(SPF) if backgrounds is None else backgrounds[ai])
wave = sine_frame(bins[ai])
for ch in SCAN_CHANNELS:
for fi in range(pa.n_frames):
if ch == 1:
f.write(wave)
elif ch == 3:
f.write(bytes(SPF))
else:
f.write(CH4_FAIL if fi < n_masked_frames else CH4_PASS)
finally:
f.close()
return plan
# ── Plan reduction ───────────────────────────────────────────────────────────
def test_middle_row_plan_keeps_one_middle_row_per_angle():
plan = full_plan(num_angles=3)
check = middle_row_plan(plan)
assert check.n_angles == plan.n_angles
assert [pa.n_rows for pa in check.per_angle] == [1] * plan.n_angles
for original, reduced in zip(plan.per_angle, check.per_angle, strict=True):
mid = original.n_rows // 2
assert reduced.y_positions == [original.y_positions[mid]]
# The row is scanned exactly as the full scan would have scanned it.
assert reduced.angle_deg == original.angle_deg
assert reduced.x_start == original.x_start
assert reduced.x_delta == original.x_delta
assert reduced.n_frames == original.n_frames
def test_middle_row_plan_does_not_mutate_its_input():
plan = full_plan(num_angles=3)
before = [(pa.n_rows, list(pa.y_positions)) for pa in plan.per_angle]
middle_row_plan(plan)
assert [(pa.n_rows, pa.y_positions) for pa in plan.per_angle] == before
def test_every_angles_middle_row_crosses_the_roi_centre():
"""The premise the whole comparison rests on: one shared point on the sample."""
plan = full_plan(num_angles=5)
check = middle_row_plan(plan)
cx = plan.x_start_nominal + plan.x_delta_nominal / 2
cy = plan.y_start_nominal + plan.y_delta_nominal / 2
for pa in check.per_angle:
assert pa.x_start + pa.x_delta / 2 == pytest.approx(cx, abs=1e-6)
# Within one row spacing — the middle row is a grid point, not exact.
assert abs(pa.y_positions[0] - cy) <= plan.row_spacing
def test_middle_row_index_rule():
assert [middle_row_index(n) for n in (1, 2, 3, 4, 6)] == [0, 1, 1, 2, 3]
def test_middle_row_plan_rejects_an_empty_plan():
plan = full_plan(num_angles=1)
plan.per_angle = []
with pytest.raises(ScanGeometryError, match="no angles"):
middle_row_plan(plan)
def test_middle_row_plan_rejects_an_angle_with_no_rows():
plan = full_plan(num_angles=1)
plan.per_angle[0].y_positions = []
with pytest.raises(ScanGeometryError, match="no middle row"):
middle_row_plan(plan)
# ── The v11 file ─────────────────────────────────────────────────────────────
def test_saw_check_write_read_roundtrip(tmp_path):
out = tmp_path / "check.sras"
plan = write_check(out, bins=(8, 8, 8))
sras = SrasFile(out)
assert sras.version == VERSION_SAW_CHECK
assert sras.is_saw_check
assert [s.status for s in sras.angle_status()] == ["OK"] * plan.n_angles
assert [pa.n_rows for pa in sras.per_angle] == [1] * plan.n_angles
sras.close()
def test_saw_check_rejects_a_multi_row_plan(tmp_path):
plan = full_plan(num_angles=2)
assert any(pa.n_rows > 1 for pa in plan.per_angle)
with pytest.raises(ValueError, match="exactly one row per angle"):
create_scan_file(tmp_path / "bad.sras", plan, SPF, SAMPLE_RATE,
PREAMBLES, version=VERSION_SAW_CHECK)
assert not (tmp_path / "bad.sras").exists()
def test_unknown_version_rejected_at_write(tmp_path):
with pytest.raises(ValueError, match="version 99"):
create_scan_file(tmp_path / "bad.sras", middle_row_plan(full_plan(1)),
SPF, SAMPLE_RATE, PREAMBLES, version=99)
def test_a_scan_is_not_a_saw_check():
"""Legacy and current scans alike: only the check versions say check."""
assert not SrasFile("tests/golden/complete.sras").is_saw_check
assert VERSION not in (10, VERSION_SAW_CHECK)
# ── Acquisition through the engine ───────────────────────────────────────────
def run_engine(tmp_path, num_angles=3):
trace = Trace()
scope = FakeScope(trace, samples_per_frame=SPF)
stage = FakeStage(trace, scope=scope)
rotator = RotationAxis(FakeT3R(trace), RotationSettings())
plan = full_plan(num_angles)
check = middle_row_plan(plan)
engine = ScanEngine(stage, scope, rotator, check, tmp_path / "check.sras",
callbacks=ScanCallbacks(),
file_version=VERSION_SAW_CHECK)
return engine.run(), plan, check, trace
def test_engine_writes_a_complete_saw_check(tmp_path):
result, plan, check, _ = run_engine(tmp_path)
assert not result.aborted
assert result.rows_written == check.n_angles # exactly one row per angle
assert result.angles_acquired == list(range(check.n_angles))
sras = SrasFile(result.path)
assert sras.is_saw_check
assert [s.status for s in sras.angle_status()] == ["OK"] * check.n_angles
assert [pa.y_positions for pa in sras.per_angle] == [
[pytest.approx(original.y_positions[original.n_rows // 2], abs=1e-4)]
for original in plan.per_angle
]
sras.close()
def test_engine_visits_each_middle_row_once(tmp_path):
_, _, check, trace = run_engine(tmp_path)
y_moves = [round(c[2], 4) for c in trace.of("move_axis_absolute")
if c[1] == 0x22]
assert y_moves == [round(pa.y_positions[0], 4) for pa in check.per_angle]
def test_engine_still_writes_a_full_scan_by_default(tmp_path):
trace = Trace()
scope = FakeScope(trace, samples_per_frame=SPF)
stage = FakeStage(trace, scope=scope)
rotator = RotationAxis(FakeT3R(trace), RotationSettings())
engine = ScanEngine(stage, scope, rotator, full_plan(1),
tmp_path / "scan.sras", callbacks=ScanCallbacks())
result = engine.run()
assert SrasFile(result.path).version == VERSION
# ── Analysis ─────────────────────────────────────────────────────────────────
def test_traces_report_the_injected_frequency(tmp_path):
out = tmp_path / "check.sras"
bins = (8, 9, 10)
write_check(out, bins=bins)
with SrasFile(out) as sras:
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
assert len(traces) == len(bins)
for trace, k in zip(traces, bins, strict=True):
assert np.allclose(trace.freq_mhz, bin_mhz(k))
assert trace.median_mhz == pytest.approx(bin_mhz(k))
assert trace.valid_fraction == 1.0
assert trace.drift_mhz_per_mm == pytest.approx(0.0, abs=1e-6)
def test_background_subtraction_uses_each_angles_own(tmp_path):
"""Every angle is referenced against its own background, not angle 1's.
Each angle's background here is a copy of that angle's own CH1 wave, so
subtracting the right one leaves nothing to read at any angle — where
reusing angle 1's everywhere would leave angles 2 and 3 reporting their
sine unchanged.
"""
out = tmp_path / "check.sras"
bins = (8, 9, 10)
backgrounds = [sine_frame(k) for k in bins]
write_check(out, bins=bins, backgrounds=backgrounds)
with SrasFile(out) as sras:
assert sras.backgrounds == backgrounds
plain = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
subtracted = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV,
subtract_background=True)
assert [t.median_mhz for t in plain] == [pytest.approx(bin_mhz(k)) for k in bins]
for trace in subtracted:
assert np.isnan(trace.freq_mhz).all()
def test_masked_pixels_become_nan_not_zero(tmp_path):
out = tmp_path / "check.sras"
write_check(out, bins=(8, 8, 8), n_masked_frames=2)
with SrasFile(out) as sras:
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
for trace in traces:
assert np.isnan(trace.freq_mhz[:2]).all()
assert np.isfinite(trace.freq_mhz[2:]).all()
# A masked pixel must not drag the median toward 0 MHz.
assert trace.median_mhz == pytest.approx(bin_mhz(8))
assert trace.valid_fraction < 1.0
def test_traces_are_centred_on_a_common_offset(tmp_path):
out = tmp_path / "check.sras"
write_check(out, bins=(8, 9, 10))
with SrasFile(out) as sras:
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
# Absolute X differs per angle (different bounding boxes); the offset the
# viewer plots against does not, which is what puts the curves together.
assert len({round(t.x_mm[0], 6) for t in traces}) > 1
for trace in traces:
assert trace.offset_mm[0] == pytest.approx(-trace.offset_mm[-1])
def test_angles_with_no_data_are_skipped(tmp_path):
out = tmp_path / "check.sras"
write_check(out, bins=(8, 8, 8))
full = out.read_bytes()
with SrasFile(out) as sras:
last_offset = sras.angle_data_offset(2)
out.write_bytes(full[:last_offset]) # angle 3 never acquired
with SrasFile(out) as sras:
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
assert [t.angle_idx for t in traces] == [0, 1]
def test_summary_flags_agreeing_angles_as_good(tmp_path):
out = tmp_path / "check.sras"
write_check(out, bins=(8, 8, 8))
with SrasFile(out) as sras:
summary = alignment_summary(
frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV))
assert summary.n_angles == 3
assert summary.median_mhz == pytest.approx(bin_mhz(8))
assert summary.spread_mhz == pytest.approx(0.0)
assert summary.spread_pct <= SPREAD_GOOD_PCT
assert summary.level == "good"
def test_summary_flags_disagreeing_angles(tmp_path):
out = tmp_path / "check.sras"
write_check(out, bins=(8, 9, 10))
with SrasFile(out) as sras:
traces = frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV)
summary = alignment_summary(traces)
assert summary.spread_mhz == pytest.approx(bin_mhz(10) - bin_mhz(8))
assert summary.level == "poor"
assert summary.worst_angle_deg == traces[0].angle_deg # lowest median
assert summary.best_angle_deg == traces[2].angle_deg # highest median
assert f"{summary.spread_mhz:.3f} MHz" in summary.describe()
def test_summary_calls_out_a_mostly_masked_row(tmp_path):
out = tmp_path / "check.sras"
plan = middle_row_plan(full_plan(3))
# Mask nearly every frame of every angle: the spread is meaningless then.
write_check(out, bins=(8, 8, 8), plan=plan,
n_masked_frames=max(pa.n_frames for pa in plan.per_angle) - 1)
with SrasFile(out) as sras:
summary = alignment_summary(
frequency_traces(sras, dc_threshold_mv=DC_THRESHOLD_MV))
assert summary.level == "poor"
assert "DC threshold" in summary.describe()
def test_summary_of_nothing_is_not_a_crash():
summary = alignment_summary([])
assert summary.n_angles == 0 and summary.level == "poor"
assert "No angle" in summary.describe()
def test_middle_row_of_a_full_v6_scan_is_readable():
"""The check's read-out applied to a finished scan, after the fact."""
with SrasFile("tests/golden/complete.sras") as sras:
traces = frequency_traces(sras, dc_threshold_mv=-1e6)
assert len(traces) == sras.header.n_angles
for trace, pa in zip(traces, sras.per_angle, strict=True):
assert trace.row_idx == pa.n_rows // 2
assert len(trace.freq_mhz) == pa.n_frames
+549
View File
@@ -0,0 +1,549 @@
"""Headless ScanEngine tests driven entirely by fake hardware.
These cover what can't be checked without the rig: the command sequence,
the written file layout, and abort/pause behaviour.
"""
import threading
import time
import pytest
from core.rotation import RotationAxis, RotationSettings
from core.scan_engine import (
AXIS_X, AXIS_Y, ScanAborted, ScanCallbacks, ScanEngine, ResumeState,
ResumeTarget,
)
from core.scan_geometry import ScanGeometryError, build_plan
from core.sras_format import BG_LEN_SIZE, SCAN_CHANNELS, SrasFile
from fakes import FakeScope, FakeStage, FakeT3R, Trace, background_record
SPF = 8
def make_plan(num_angles=1, y_delta=0.005):
# Small ROI well inside the stage limits: few rows, few frames per angle.
return build_plan(40.0, 30.0, 0.02, y_delta, num_angles, 0.01,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
def build(tmp_path, num_angles=1, callbacks=None, resume=None, plan=None,
burst_mode=False, max_frames=4096, out_name="out.sras",
strict_rows=False, **kw):
trace = Trace()
scope = FakeScope(trace, samples_per_frame=SPF, max_frames=max_frames)
stage = FakeStage(trace, scope=scope)
t3r = FakeT3R(trace, **kw)
rotator = RotationAxis(t3r, RotationSettings())
plan = plan if plan is not None else make_plan(num_angles)
engine = ScanEngine(stage, scope, rotator, plan, tmp_path / out_name,
resume=resume,
callbacks=callbacks or ScanCallbacks(),
burst_mode=burst_mode, strict_rows=strict_rows)
return engine, trace, plan
def test_single_angle_scan_writes_readable_file(tmp_path):
engine, trace, plan = build(tmp_path)
result = engine.run()
assert not result.aborted
assert result.rows_written == plan.per_angle[0].n_rows
assert result.angles_acquired == [0]
sras = SrasFile(result.path)
assert sras.header.n_angles == 1
assert sras.header.samples_per_frame == SPF
assert sras.header.n_channels == len(SCAN_CHANNELS)
# 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.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):
engine, trace, plan = build(tmp_path)
engine.run()
names = trace.names()
def first(name):
return names.index(name)
# Stage prepared, then scope configured, then rows executed
assert first("set_trigger_trigout_maxv") < first("set_sample_rate")
assert first("set_sample_rate") < first("transfer_fastframe")
# Velocity set for both axes before any scan move
assert trace.count("set_velocity_params") == 2
# Per row: Y positioned, then X pre-ramp, then X run
moves = trace.of("move_axis_absolute")
assert moves[0][1] == AXIS_Y
assert moves[1][1] == AXIS_X and moves[2][1] == AXIS_X
assert moves[1][2] < moves[2][2] # pre-ramp start < run-off end
# Data channels transferred (CH3 is synthesized, not read)
assert [c[1] for c in trace.of("transfer_fastframe")] == [1, 4]
def test_multi_angle_rotates_and_returns_home(tmp_path):
engine, trace, plan = build(tmp_path, num_angles=3)
engine.run()
rotations = [c[1] for c in trace.of("t3r_rotate")]
# Three angles at 0/-90/-180 → two moves out, then one back to 0
assert rotations == [-90.0, -90.0, 180.0]
# Every move waits for completion instead of sleeping a guess
assert trace.count("t3r_wait_motion_done") == len(rotations)
# GR configured once, before any rotation
assert trace.names().index("t3r_set_microstep") < trace.names().index("t3r_rotate")
sras = SrasFile(tmp_path / "out.sras")
assert [s.status for s in sras.angle_status()] == ["OK"] * 3
def test_fastframe_count_rearmed_per_angle(tmp_path):
engine, trace, plan = build(tmp_path, num_angles=3)
engine.run()
counts = [c[1] for c in trace.of("set_fastframe_count")]
assert counts == [pa.n_frames for pa in plan.per_angle]
def test_abort_before_start_raises_and_stops_early(tmp_path):
engine, trace, _ = build(tmp_path)
engine.abort()
with pytest.raises(ScanAborted):
engine.run()
assert trace.count("transfer_fastframe") == 0
def test_abort_during_prompt_unblocks(tmp_path):
"""A prompt that never returns must not deadlock an aborting scan."""
released = threading.Event()
def prompt(title, msg):
# Simulates the GUI bridge: waits until abort flips the flag.
while not engine.aborted:
if released.wait(0.01):
return
engine, trace, _ = build(tmp_path, callbacks=ScanCallbacks(prompt=prompt))
errors = []
def run():
try:
engine.run()
except ScanAborted:
errors.append("aborted")
t = threading.Thread(target=run, daemon=True)
t.start()
time.sleep(0.2) # let it reach the first prompt
engine.abort()
t.join(timeout=5)
assert not t.is_alive(), "engine deadlocked on a prompt during abort"
assert errors == ["aborted"]
def test_pause_and_resume_at_row_boundary(tmp_path):
states = []
engine, trace, plan = build(
tmp_path, num_angles=1,
callbacks=ScanCallbacks(on_paused_changed=states.append))
engine.pause()
done = threading.Event()
def run():
try:
engine.run()
except ScanAborted:
pass # only reachable via the failure escape hatch below
finally:
done.set()
t = threading.Thread(target=run, daemon=True)
t.start()
try:
# The engine's instrument-settling sleeps run before the first row,
# so poll for the pause rather than assuming a fixed delay.
deadline = time.monotonic() + 10.0
while not states and time.monotonic() < deadline:
time.sleep(0.05)
paused = bool(states)
assert paused and states[0] is True, "engine did not report the pause"
finally:
# Always release the scan thread; if the pause never arrived, abort
# too, so a failed assertion can't leave it parked forever.
if not states:
engine.abort()
engine.resume()
t.join(timeout=10)
assert done.is_set()
assert states[-1] is False
def test_dc_bias_callback_reports_per_frame_means(tmp_path):
rows = []
engine, trace, plan = build(
tmp_path, callbacks=ScanCallbacks(on_dc_bias=lambda r, m: rows.append((r, m))))
engine.run()
assert len(rows) == plan.per_angle[0].n_rows
row_idx, means = rows[0]
assert row_idx == 1
assert len(means) == plan.per_angle[0].n_frames
assert all(isinstance(v, float) for v in means)
def test_offstage_plan_rejected_before_touching_hardware(tmp_path):
trace = Trace()
scope = FakeScope(trace, samples_per_frame=SPF)
stage = FakeStage(trace, scope=scope)
# X range that runs off the 110 mm stage once ramps are added
plan = build_plan(80.0, 30.0, 40.0, 5.0, 1, 0.25,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
engine = ScanEngine(stage, scope, None, plan, tmp_path / "bad.sras")
with pytest.raises(ScanGeometryError):
engine.run()
assert trace.calls == [], "hardware touched despite invalid geometry"
def test_multi_angle_without_rotator_raises(tmp_path):
trace = Trace()
engine = ScanEngine(FakeStage(trace), FakeScope(trace, samples_per_frame=SPF),
None, make_plan(3), tmp_path / "x.sras")
with pytest.raises(RuntimeError, match="T3R rotation stage"):
engine.run()
def test_missing_hardware_raises(tmp_path):
trace = Trace()
with pytest.raises(RuntimeError, match="BBD202"):
ScanEngine(None, FakeScope(trace), None, make_plan(),
tmp_path / "x.sras").run()
with pytest.raises(RuntimeError, match="Oscilloscope"):
ScanEngine(FakeStage(trace), None, None, make_plan(),
tmp_path / "x.sras").run()
def test_resume_seeks_to_angle_offset_and_skips_others(tmp_path):
# First produce a complete 3-angle file
engine, trace, plan = build(tmp_path, num_angles=3)
engine.run()
path = tmp_path / "out.sras"
original = path.read_bytes()
sras = SrasFile(path)
statuses = sras.angle_status()
target = statuses[1]
resume = ResumeState(
path=path,
targets=[ResumeTarget(target.index, target.bg_offset,
target.data_offset, target.n_rows,
target.angle_deg)],
samples_per_frame=SPF,
)
engine2, trace2, _ = build(tmp_path, num_angles=3, resume=resume)
result = engine2.run()
assert result.angles_acquired == [1]
# Only the middle angle's rows were re-acquired
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 — 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, 0, 1, 0.0)],
samples_per_frame=SPF + 1) # scope changed
engine2, _, _ = build(tmp_path, resume=resume)
with pytest.raises(RuntimeError, match="record length"):
engine2.run()
# ── Burst acquisition ────────────────────────────────────────────────────────
# 6 rows × 4 frames/row; max_frames=14 gives 14//4 = 3 rows per burst, so the
# angle needs two bursts and the second is not a whole burst wide.
BURST_PLAN = dict(y_delta=0.05)
BURST_MAX_FRAMES = 14
def test_burst_and_serial_produce_identical_files(tmp_path):
"""The whole point: burst mode must be a pure acquisition optimisation."""
plan = make_plan(**BURST_PLAN)
assert plan.per_angle[0].n_rows == 6 and plan.per_angle[0].n_frames == 4
serial, _, _ = build(tmp_path, plan=plan, out_name="serial.sras")
serial.run()
burst, _, _ = build(tmp_path, plan=plan, out_name="burst.sras",
burst_mode=True, max_frames=BURST_MAX_FRAMES)
burst.run()
assert (tmp_path / "burst.sras").read_bytes() == \
(tmp_path / "serial.sras").read_bytes()
def test_burst_multi_angle_file_is_complete(tmp_path):
plan = make_plan(num_angles=3, **BURST_PLAN)
engine, trace, _ = build(tmp_path, plan=plan, burst_mode=True,
max_frames=BURST_MAX_FRAMES)
result = engine.run()
assert result.rows_written == plan.total_rows
assert result.angles_acquired == [0, 1, 2]
sras = SrasFile(tmp_path / "out.sras")
assert [s.status for s in sras.angle_status()] == ["OK"] * 3
def test_burst_gates_the_flyback_and_runs_once_per_burst(tmp_path):
plan = make_plan(**BURST_PLAN)
engine, trace, _ = build(tmp_path, plan=plan, burst_mode=True,
max_frames=BURST_MAX_FRAMES)
engine.run()
# Two bursts (3 + 3 rows), two preflight acquisitions, one background.
runs = [c for c in trace.of("write") if c[1] == "ACQuire:STATE RUN"]
assert len(runs) == 5
# Every acquiring pass is bracketed by an arm/disarm, so the gate is low
# for each flyback. 6 rows + 1 preflight pass = 7 arms.
gate = [c[2] for c in trace.of("arm_scan_gate")]
assert gate.count(True) == 7
# No two arms without a disarm between them — that is what would let a
# flyback into the acquisition. (A repeated disarm is just defensive.)
for a, b in zip(gate, gate[1:], strict=False):
assert not (a and b), f"acquiring pass with no disarm before it: {gate}"
assert gate[-1] is False, "scan left the gate armed"
# One bulk transfer per data channel per burst, none per row.
assert [(c[1], c[2]) for c in trace.of("transfer_fastframe_bulk")] == [
(1, 12), (4, 12), (1, 12), (4, 12)]
assert trace.count("transfer_fastframe") == 0
def test_burst_preflight_rejects_a_leaky_gate(tmp_path):
plan = make_plan(**BURST_PLAN)
engine, trace, _ = build(tmp_path, plan=plan, burst_mode=True,
max_frames=BURST_MAX_FRAMES)
stage = engine._stage
# A gate that ignores the disable request — the failure mode the preflight
# exists to catch (TRIGOUT_GATE_OFF set to the wrong mode value).
def stuck_gate(axis, armed, verify=True):
trace.record("arm_scan_gate", axis, bool(armed))
stage.gate_armed = True
stage.arm_scan_gate = stuck_gate
with pytest.raises(RuntimeError, match="not idling low"):
engine.run()
def test_burst_preflight_rejects_a_dark_laser(tmp_path):
"""A gate that never fires would let a leak check pass vacuously."""
plan = make_plan(**BURST_PLAN)
engine, trace, _ = build(tmp_path, plan=plan, burst_mode=True,
max_frames=BURST_MAX_FRAMES)
engine._stage.attach_scope(None) # no pulses ever reach the scope
with pytest.raises(RuntimeError, match="no frames acquired"):
engine.run()
def _clip_one_row(engine, which_pass=2, lost=1):
"""Make one acquiring pass come up `lost` frames short.
`which_pass` counts acquiring passes from 1, so the default clips the
second data row (row 2) — far enough in that a mishandled short row shows
up as a shift in the rows after it.
"""
scope = engine._scope
real_acquire = scope.acquire_frames
passes = {"n": 0}
def clipped(n):
if scope._running:
passes["n"] += 1
if passes["n"] == which_pass:
n -= lost
real_acquire(n)
scope.acquire_frames = clipped
@pytest.mark.parametrize("burst_mode", [False, True])
def test_short_row_is_padded_to_declared_frame_count(tmp_path, burst_mode):
"""A clipped row must not shift every later row in the file.
v6 declares n_frames per row up front and has no per-row length, so an
under-triggered row has to be squared up. In burst mode this also proves
the splitter advances by what actually arrived, not by n_frames.
"""
warnings = []
plan = make_plan(**BURST_PLAN)
engine, trace, _ = build(
tmp_path, plan=plan, burst_mode=burst_mode,
max_frames=BURST_MAX_FRAMES,
callbacks=ScanCallbacks(on_status=warnings.append))
# The preflight is covered by its own tests; skipping it keeps the
# acquiring-pass count below identical in both modes.
engine._preflight_done = True
_clip_one_row(engine)
result = engine.run()
assert result.rows_written == 6
assert any("Row 2: 3 frames acquired, 4 expected" in w for w in warnings)
assert any("zero-padded" in w for w in warnings)
sras = SrasFile(tmp_path / "out.sras")
assert [s.status for s in sras.angle_status()] == ["OK"]
# The padding lands at the end of the short row, not in the next one.
assert bytes(sras.load_row(0, 1, 0)[-1]) == bytes(SPF)
assert bytes(sras.load_row(0, 2, 0)[0]) != bytes(SPF)
# ── Strict row packing ───────────────────────────────────────────────────────
@pytest.mark.parametrize("burst_mode", [False, True])
def test_strict_row_packing_aborts_on_a_short_row(tmp_path, burst_mode):
"""Strict mode fails the scan instead of silently squaring a row up.
The default padding keeps the file readable but makes a mis-triggered row
indistinguishable from a good one after the fact, since v6 records no
per-row frame count. Strict mode trades the salvaged rows for knowing.
"""
plan = make_plan(**BURST_PLAN)
engine, _, _ = build(tmp_path, plan=plan, burst_mode=burst_mode,
max_frames=BURST_MAX_FRAMES, strict_rows=True)
engine._preflight_done = True
_clip_one_row(engine)
with pytest.raises(RuntimeError, match="Row 2: 3 frames acquired, 4 expected"):
engine.run()
@pytest.mark.parametrize("burst_mode", [False, True])
def test_strict_row_packing_does_not_disturb_a_clean_scan(tmp_path, burst_mode):
"""Strict mode is inert when every row acquires what it declared."""
plan = make_plan(**BURST_PLAN)
engine, _, _ = build(tmp_path, plan=plan, burst_mode=burst_mode,
max_frames=BURST_MAX_FRAMES, strict_rows=True)
engine._preflight_done = True
result = engine.run()
assert result.rows_written == plan.total_rows
sras = SrasFile(tmp_path / "out.sras")
assert [s.status for s in sras.angle_status()] == ["OK"]
def test_strict_row_packing_writes_nothing_for_the_failed_row(tmp_path):
"""The abort must not leave a half-written row behind.
CH1 leads SCAN_CHANNELS, so the frame count is known before any of the
row's channels are written — the file should end on a whole-row boundary.
"""
plan = make_plan(**BURST_PLAN)
engine, _, _ = build(tmp_path, plan=plan, strict_rows=True)
engine._preflight_done = True
_clip_one_row(engine)
with pytest.raises(RuntimeError, match="Strict row packing"):
engine.run()
# Row 1 was written in full; row 2 aborted before writing anything, so
# the file ends exactly on a row boundary.
sras = SrasFile(tmp_path / "out.sras")
written = (tmp_path / "out.sras").stat().st_size - sras.angle_data_offset(0)
assert written == sras.row_bytes(0)
def test_engine_imports_without_qt():
"""The engine must be usable from a non-Qt front end."""
import subprocess
import sys
code = (
"import sys;"
"sys.modules['PyQt6'] = None;"
"import core.scan_engine, core.rotation, core.scope_sras,"
" core.scan_resume, core.sras_format, core.scan_geometry;"
"print('ok')"
)
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, cwd=str(__import__('pathlib').Path(__file__).parent.parent))
assert out.returncode == 0, out.stderr
assert "ok" in out.stdout
+151
View File
@@ -0,0 +1,151 @@
"""core.scan_geometry vs the pre-refactor golden geometry fixtures,
plus structural invariants and travel-limit validation."""
import json
import math
from pathlib import Path
import pytest
from core.scan_geometry import (
EtaEstimator, ScanGeometryError, StageLimits, build_plan, format_eta,
validate_plan,
)
GOLDEN = Path(__file__).parent / "golden"
@pytest.fixture(scope="module")
def geometry():
with open(GOLDEN / "geometry.json") as f:
return json.load(f)
def _plan_from_case(case, consts):
i = case["inputs"]
return build_plan(
float(i["XS"]), float(i["YS"]), float(i["XD"]), float(i["YD"]),
int(i["num_angles"]), float(i["row_spacing"]),
laser_freq_hz=consts["LASER_FREQ_HZ"],
velocity_mm_s=consts["SCAN_VELOCITY_MM_S"],
rotation_sign=consts["GR_ROTATION_SIGN"],
)
def test_all_golden_cases_match(geometry):
consts = geometry["constants"]
for label, case in geometry["cases"].items():
plan = _plan_from_case(case, consts)
exp = case["params"]
assert plan.x_start_nominal == exp["x_start_nominal"], label
assert plan.y_start_nominal == exp["y_start_nominal"], label
assert plan.x_delta_nominal == exp["x_delta_nominal"], label
assert plan.y_delta_nominal == exp["y_delta_nominal"], label
assert plan.row_spacing == exp["row_spacing"], label
assert plan.n_angles == exp["num_angles"], label
assert len(plan.per_angle) == len(exp["per_angle"]), label
for pa, e in zip(plan.per_angle, exp["per_angle"], strict=True):
assert pa.angle_deg == e["angle"], label
assert pa.x_start == e["x_start"], label
assert pa.x_delta == e["x_delta"], label
assert pa.n_frames == e["n_frames"], label
assert pa.n_rows == e["n_rows"], label
assert pa.y_positions == e["y_positions"], label
def test_golden_error_cases_raise(geometry):
consts = geometry["constants"]
for case in geometry["error_cases"].values():
with pytest.raises(ValueError):
_plan_from_case(case, consts)
def test_zero_degree_bbox_equals_nominal_roi():
plan = build_plan(10.0, 5.0, 20.0, 8.0, 1, 0.5,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
pa = plan.per_angle[0]
assert pa.angle_deg == 0.0
assert math.isclose(pa.x_start, 10.0)
assert math.isclose(pa.x_delta, 20.0)
assert math.isclose(pa.y_positions[0], 5.0)
def test_rotated_bbox_contains_all_roi_corners():
plan = build_plan(30.0, 20.0, 24.0, 10.0, 7, 0.1,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
cy = 20.0 + 5.0
corners = [(-12.0, -5.0), (12.0, -5.0), (-12.0, 5.0), (12.0, 5.0)]
for pa in plan.per_angle:
r = math.radians(pa.angle_deg)
half_w = pa.x_delta / 2.0
y_lo, y_hi = min(pa.y_positions), max(pa.y_positions)
for dx, dy in corners:
# ROI corner in the rotated frame
rx = dx * math.cos(r) - dy * math.sin(r)
ry = dx * math.sin(r) + dy * math.cos(r)
assert abs(rx) <= half_w + 1e-9, pa.angle_deg
# Row grid covers within one row-spacing at the edges
assert y_lo - 0.1 - 1e-9 <= cy + ry <= y_hi + 0.1 + 1e-9, pa.angle_deg
def test_plus_minus_theta_symmetry():
a = build_plan(10, 10, 20, 10, 5, 0.25, laser_freq_hz=20000.0,
velocity_mm_s=100.0, rotation_sign=1)
b = build_plan(10, 10, 20, 10, 5, 0.25, laser_freq_hz=20000.0,
velocity_mm_s=100.0, rotation_sign=-1)
for pa, pb in zip(a.per_angle, b.per_angle, strict=True):
assert pa.angle_deg == -pb.angle_deg
assert math.isclose(pa.x_delta, pb.x_delta)
assert pa.n_rows == pb.n_rows
assert pa.n_frames == pb.n_frames
def test_validate_plan_limit_violations():
limits = StageLimits()
ramp, buf = 100.0**2 / (2 * 1500.0), 1.0
ok = build_plan(20.0, 10.0, 40.0, 30.0, 3, 0.25,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
validate_plan(ok, ramp, buf, limits) # must not raise
too_left = build_plan(2.0, 10.0, 40.0, 30.0, 1, 0.25,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
with pytest.raises(ScanGeometryError, match="pre-ramp start"):
validate_plan(too_left, ramp, buf, limits)
too_right = build_plan(80.0, 10.0, 40.0, 30.0, 1, 0.25,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
with pytest.raises(ScanGeometryError, match="run-off end"):
validate_plan(too_right, ramp, buf, limits)
too_low = build_plan(30.0, -5.0, 40.0, 30.0, 1, 0.25,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
with pytest.raises(ScanGeometryError, match="Y axis minimum"):
validate_plan(too_low, ramp, buf, limits)
too_high = build_plan(30.0, 60.0, 40.0, 30.0, 1, 0.25,
laser_freq_hz=20000.0, velocity_mm_s=100.0)
with pytest.raises(ScanGeometryError, match="Y axis maximum"):
validate_plan(too_high, ramp, buf, limits)
def test_format_eta():
assert format_eta(-3) == "0s"
assert format_eta(42) == "42s"
assert format_eta(90) == "1m 30s"
assert format_eta(3720) == "1h 02m"
def test_eta_estimator_rolls_and_resets_on_angle_change():
eta = EtaEstimator(window=3)
assert eta.eta_secs(5) is None
t = 100.0
for dur in (2.0, 4.0, 6.0, 8.0):
eta.row_started(now=t)
eta.row_finished(angle_idx=1, now=t + dur)
t += dur
# window=3 keeps [4, 6, 8] → avg 6
assert eta.eta_secs(2) == pytest.approx(12.0)
# angle change wipes history
eta.row_started(now=t)
eta.row_finished(angle_idx=2, now=t + 10.0)
assert eta.eta_secs(3) == pytest.approx(30.0)
+94
View File
@@ -0,0 +1,94 @@
"""core.scan_resume: the frontier contiguity rule, over real fixture files."""
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"
def _statuses(name):
return SrasFile(GOLDEN / name).angle_status()
def test_complete_file_frontier_is_past_the_end():
st = _statuses("complete.sras")
plan = plan_resume(st, selected={0})
assert plan.frontier_idx == len(st)
assert [t.angle_idx for t in plan.targets] == [0]
assert plan.auto_added == []
def test_selecting_past_frontier_backfills_the_gap():
# angle 0 complete, angle 1 truncated → frontier = 1
st = _statuses("trunc_rowboundary_a1.sras")
assert st[0].complete and not st[1].complete
plan = plan_resume(st, selected={1})
assert plan.frontier_idx == 1
assert [t.angle_idx for t in plan.targets] == [1]
assert plan.auto_added == []
def test_selection_before_frontier_is_untouched():
st = _statuses("trunc_rowboundary_a1.sras")
plan = plan_resume(st, selected={0})
assert [t.angle_idx for t in plan.targets] == [0]
assert plan.auto_added == []
def test_selecting_only_a_later_angle_pulls_in_the_frontier():
"""Data is one contiguous stream, so angle 1 can't be skipped to reach 2."""
st = _statuses("header_only.sras") # nothing written: frontier = 0
assert plan_resume(st, selected={0}).frontier_idx == 0
plan = plan_resume(st, selected={1})
assert [t.angle_idx for t in plan.targets] == [0, 1]
assert plan.auto_added == [0]
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)
def test_to_state_carries_samples_per_frame():
sras = SrasFile(GOLDEN / "complete.sras")
state = plan_resume(sras.angle_status(), selected={0}).to_state(sras)
assert state.path == sras.path
assert state.samples_per_frame == sras.header.samples_per_frame
assert state.target_indices == {0}
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)
assert not is_compatible(sras, **{**ok, "velocity": h.velocity + 1})
assert not is_compatible(sras, **{**ok, "laser_freq": h.laser_freq * 2})
assert not is_compatible(sras, **{**ok, "sample_rate": h.sample_rate * 2})
assert not is_compatible(sras, **{**ok, "n_channels": h.n_channels + 1})
+103
View File
@@ -0,0 +1,103 @@
"""Burst sizing and row-splitting, exercised without any instrument."""
import pytest
from core.scope_burst import (
frame_means_block, normalize_row, rows_per_burst, split_row_counts,
)
SPF = 8
# ── rows_per_burst ───────────────────────────────────────────────────────────
def test_rows_per_burst_rounds_down():
# 9.7 rows' worth of capacity is 9 rows: a partial row is unusable.
assert rows_per_burst(97, 10, SPF, rows_remaining=100) == 9
assert rows_per_burst(100, 10, SPF, rows_remaining=100) == 10
def test_rows_per_burst_clamped_by_rows_remaining():
assert rows_per_burst(1000, 10, SPF, rows_remaining=3) == 3
def test_rows_per_burst_clamped_by_memory_budget():
# Budget holds 4 rows of 10 frames × 8 samples; the scope would hold 100.
assert rows_per_burst(1000, 10, SPF, rows_remaining=100,
memory_budget=4 * 10 * SPF) == 4
def test_rows_per_burst_headroom_reserves_slack_per_row():
assert rows_per_burst(100, 10, SPF, rows_remaining=100, headroom=0) == 10
assert rows_per_burst(100, 10, SPF, rows_remaining=100, headroom=2) == 8
def test_rows_per_burst_never_returns_zero():
"""A row too big for any budget still goes, or the scan cannot progress."""
assert rows_per_burst(5, 10, SPF, rows_remaining=100) == 1
assert rows_per_burst(1000, 10, SPF, rows_remaining=100,
memory_budget=1) == 1
def test_rows_per_burst_rejects_degenerate_geometry():
with pytest.raises(ValueError):
rows_per_burst(100, 0, SPF, rows_remaining=1)
# ── split_row_counts ─────────────────────────────────────────────────────────
def test_split_row_counts_differences_the_cumulative_counter():
assert split_row_counts([4, 8, 12]) == [4, 4, 4]
assert split_row_counts([4, 7, 12]) == [4, 3, 5]
assert split_row_counts([]) == []
def test_split_row_counts_rejects_a_counter_that_went_backwards():
# Only happens if the acquisition restarted mid-burst, which would
# misattribute every later row.
with pytest.raises(RuntimeError, match="backwards"):
split_row_counts([8, 4])
# ── normalize_row ────────────────────────────────────────────────────────────
def test_normalize_row_passes_an_exact_row_through():
buf = bytes(range(4 * SPF))
assert bytes(normalize_row(buf, 0, 4, 4, SPF)) == buf
def test_normalize_row_pads_a_short_row():
buf = bytes(range(3 * SPF))
out = bytes(normalize_row(buf, 0, 3, 4, SPF))
assert len(out) == 4 * SPF
assert out[:3 * SPF] == buf
assert out[3 * SPF:] == bytes(SPF)
def test_normalize_row_truncates_a_long_row():
buf = bytes(range(6 * SPF))
out = bytes(normalize_row(buf, 0, 6, 4, SPF))
assert out == buf[:4 * SPF]
def test_normalize_row_reads_at_an_offset():
buf = bytes(range(8 * SPF))
out = bytes(normalize_row(buf, 2 * SPF, 4, 4, SPF))
assert out == buf[2 * SPF:6 * SPF]
def test_normalize_row_pads_a_buffer_that_ends_early():
"""Defensive: a truncated transfer must not shorten the row on disk."""
out = bytes(normalize_row(bytes(2 * SPF), 0, 4, 4, SPF))
assert len(out) == 4 * SPF
# ── frame_means_block ────────────────────────────────────────────────────────
def test_frame_means_block_is_per_frame():
buf = bytes([1] * SPF + [3] * SPF)
assert frame_means_block(buf, 0, 2, SPF) == [1.0, 3.0]
def test_frame_means_block_reads_signed_samples_at_an_offset():
buf = bytes([0] * SPF) + bytes([0xFF] * SPF) # 0xFF == -1 as int8
assert frame_means_block(buf, SPF, 1, SPF) == [-1.0]
+155
View File
@@ -0,0 +1,155 @@
"""Offscreen smoke tests: every GUI app must construct without hardware.
These don't exercise behavior — they catch import errors, missing .ui
widgets, and constructor regressions during the refactor.
"""
import pytest
from PyQt6.QtWidgets import QApplication
@pytest.fixture(scope="session")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def _pump(qapp):
qapp.processEvents()
def test_sc3_aui_main_window(qapp):
import sc3_aui_app
win = sc3_aui_app.MainWindow()
_pump(qapp)
try:
assert win.x_start_edit.text()
assert win.windowTitle() == "Scanengine-3 AUI"
finally:
for worker, thread in (
(win._bbd_worker, win._bbd_thread),
(win._oscope_worker, win._oscope_thread),
(win._helios_worker, win._helios_thread),
):
worker.stop_worker()
thread.quit()
assert thread.wait(2000)
win._camera_win.deleteLater()
win.deleteLater()
_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()
_pump(qapp)
try:
assert win.windowTitle()
finally:
win.deleteLater()
_pump(qapp)
def test_saw_check_viewer_window(qapp):
import saw_check_viewer
win = saw_check_viewer.SawCheckWindow()
_pump(qapp)
try:
assert win.windowTitle()
finally:
win.deleteLater()
_pump(qapp)
def test_helios_test_app(qapp):
import helios_test_app
win = helios_test_app.HeliosTestApp()
_pump(qapp)
try:
assert win.windowTitle()
finally:
win.deleteLater()
_pump(qapp)
def test_bbd202_test_app(qapp):
import bbd202_test_app
win = bbd202_test_app.BBD202TestApp()
_pump(qapp)
try:
assert win.windowTitle()
finally:
win.close()
_pump(qapp)
def test_camera_test_app(qapp):
import camera_test_app
win = camera_test_app.CameraTestWindow()
_pump(qapp)
try:
assert win.windowTitle()
finally:
win.deleteLater()
_pump(qapp)
def test_t3r_control_panel(qapp):
from hardware.t3r_driver import T3RDriver
from t3r_control_panel import T3RControlPanel
driver = T3RDriver()
panel = T3RControlPanel(driver)
_pump(qapp)
try:
assert panel.windowTitle()
finally:
panel.deleteLater()
_pump(qapp)
def test_sras_scan_manager_importable():
import sras_scan_manager # noqa: F401
+144
View File
@@ -0,0 +1,144 @@
"""core.sras_analysis + the viewer's LoadedScan/compute path, run headlessly
over the golden fixtures."""
from pathlib import Path
import numpy as np
import pytest
from core.sras_analysis import (
CH1_IDX, CH4_IDX, ChannelCalibration, FALLBACK_YMULT_MV,
SawPipeline, compute_dc_image, compute_rf_image, power_spectrum,
)
GOLDEN = Path(__file__).parent / "golden"
def _loaded_scan(name="complete.sras"):
# Imported lazily: sras_viewer pulls in PyQt6/matplotlib.
from sras_viewer import LoadedScan
return LoadedScan(str(GOLDEN / name))
def test_loaded_scan_basics():
scan = _loaded_scan()
assert scan.rows_available == [3, 3]
# 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)
scan.close()
def test_loaded_scan_truncated_rows():
scan = _loaded_scan("trunc_rowboundary_a1.sras")
assert scan.rows_available == [3, 2]
assert scan.angle_view(1).shape[0] == 2
scan = _loaded_scan("header_only.sras")
assert scan.rows_available == [0, 0]
assert scan.angle_view(0).shape[0] == 0
def test_compute_dc_image_matches_manual():
scan = _loaded_scan()
view = scan.angle_view(0)
img = compute_dc_image(view, CH4_IDX)
manual = view[:, CH4_IDX].astype(np.float64).mean(axis=-1)
assert img.shape == (3, 4)
assert img.dtype == np.float32
np.testing.assert_allclose(img, manual, rtol=1e-6)
def test_compute_rf_image_mask_and_values():
scan = _loaded_scan()
view = scan.angle_view(0)
sras = scan.sras
f_mhz = sras.freq_axis_mhz(sras.header.samples_per_frame)
# Threshold below everything: all pixels valid, values from the freq axis
img_all = compute_rf_image(view, scan.calib, f_mhz, dc_threshold_mv=-1e9)
assert img_all.shape == (3, 4)
assert set(np.unique(img_all)).issubset(set(f_mhz))
# Threshold above everything: fully masked, zero image, no FFT work
img_none = compute_rf_image(view, scan.calib, f_mhz, dc_threshold_mv=1e9)
assert not img_none.any()
def test_compute_rf_image_gate_zeroes_samples():
scan = _loaded_scan()
view = scan.angle_view(0)
sras = scan.sras
f_mhz = sras.freq_axis_mhz(sras.header.samples_per_frame)
t_ns = sras.time_axis_ns()
img = compute_rf_image(view, scan.calib, f_mhz, dc_threshold_mv=-1e9,
gate_start_ns=float(t_ns[2]), gate_end_ns=float(t_ns[5]),
time_axis_ns=t_ns)
assert img.shape == (3, 4)
def test_calibration_roundtrip_and_fallback():
cal = ChannelCalibration.from_preambles(["", "", ""])
assert cal.ymult_mv == [FALLBACK_YMULT_MV] * 3
assert cal.mv_to_adc(cal.adc_to_mv(42.0, 0), 0) == pytest.approx(42.0)
cal2 = ChannelCalibration.from_preambles(
["YMULT 1.0E-3;YOFF -10.0;YZERO 2.0E-3"])
assert cal2.ymult_mv[0] == pytest.approx(1.0)
assert cal2.yoff_adc[0] == pytest.approx(-10.0)
assert cal2.yzero_mv[0] == pytest.approx(2.0)
def test_power_spectrum_dc_suppressed():
x = np.ones(64, dtype=np.float32) * 5.0
p = power_spectrum(x)
assert p[0] == 0.0
assert not p[1:].any()
def test_saw_pipeline_finds_injected_packet():
sr = 6.25e9
n = 1250
t = np.arange(n) / sr
rng = np.random.default_rng(42)
def make_shot(delay_ns=200.0):
sig = rng.normal(0, 0.05, n).astype(np.float32)
packet = np.exp(-((t * 1e9 - delay_ns) / 25.0) ** 2) \
* np.sin(2 * np.pi * 140e6 * t)
return sig + 3.0 * packet.astype(np.float32)
pipe = SawPipeline(sr, emi_gate_ns=50.0, bp_lo_mhz=85.0, bp_hi_mhz=200.0,
saw_window_ns=(80.0, 350.0))
pipe.build_template(np.stack([make_shot() for _ in range(10)]))
assert pipe.template is not None
metrics = pipe.process_shot_metrics(make_shot())
assert metrics["peak_time_ns"] == pytest.approx(200.0, abs=15.0)
assert metrics["snr"] > 3.0
# process_shot returns the same metrics plus the stage arrays
full = pipe.process_shot(make_shot())
for key in ("raw", "gated", "filtered", "mf_output", "envelope"):
assert isinstance(full[key], np.ndarray)
def test_compute_saw_image_scalars_only():
# Synthetic angle view: golden frames (8 samples) are too short for the
# 6th-order zero-phase bandpass; real records are >1000 samples.
from core.sras_analysis import compute_saw_image
rng = np.random.default_rng(1)
view = rng.integers(-40, 40, size=(3, 3, 4, 512), dtype=np.int8)
view[:, CH4_IDX] = 100 # every pixel passes the DC mask
calib = ChannelCalibration.from_preambles(["", "", ""])
pipe = SawPipeline(6.25e9, saw_window_ns=(10.0, 70.0))
pipe.build_template(view[0, CH1_IDX, :4].astype(np.float32))
img = compute_saw_image(view, calib, -1e9, pipe, "amplitude")
assert img.shape == (3, 4)
assert img.dtype == np.float32
assert (img > 0).all()
tof = compute_saw_image(view, calib, -1e9, pipe, "tof")
assert tof.shape == (3, 4)
+250
View File
@@ -0,0 +1,250 @@
"""core.sras_format: the current writer against the spec, and the parser
against the pre-refactor golden fixtures.
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.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, PREAMBLES, SAMPLE_RATE, SPF, angle_background,
synthetic_frame, tiny_plan, write_v7,
)
GOLDEN = Path(__file__).parent / "golden"
@pytest.fixture(scope="module")
def expected():
with open(GOLDEN / "sras_expected.json") as f:
return json.load(f)
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 test_writer_matches_the_spec_byte_for_byte(tmp_path):
out = tmp_path / "v7.sras"
plan = write_v7(out)
assert out.read_bytes() == spec_bytes(plan)
def test_writer_refuses_the_legacy_versions(tmp_path):
for version in (6, 10):
with pytest.raises(ValueError, match=f"version {version}"):
create_scan_file(tmp_path / "bad.sras", tiny_plan(), SPF,
SAMPLE_RATE, PREAMBLES, version=version)
assert not (tmp_path / "bad.sras").exists()
def test_header_matches_golden(expected):
sras = SrasFile(GOLDEN / "complete.sras")
h = expected["header"]
assert asdict(sras.header) == {
"n_angles": h["n_angles"],
"x_start_nominal": h["x_start_nominal"], "y_start_nominal": h["y_start_nominal"],
"x_delta_nominal": h["x_delta_nominal"], "y_delta_nominal": h["y_delta_nominal"],
"row_spacing": h["row_spacing"], "velocity": h["velocity"],
"laser_freq": h["laser_freq"],
"samples_per_frame": h["samples_per_frame"], "sample_rate": h["sample_rate"],
"bytes_per_sample": h["bytes_per_sample"], "n_channels": h["n_channels"],
}
assert sras.data_start_offset == h["data_start_offset"]
assert [pa.angle_deg for pa in sras.per_angle] == h["angles"]
for pa, exp in zip(sras.per_angle, h["per_angle"], strict=True):
assert pa.angle_deg == exp["angle"]
assert pa.x_start == exp["x_start"]
assert pa.x_delta == exp["x_delta"]
assert pa.n_frames == exp["n_frames"]
assert pa.n_rows == exp["n_rows"]
assert pa.y_positions == exp["y_positions"]
def test_frontier_all_truncation_variants(expected):
"""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 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_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.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():
with SrasFile(GOLDEN / "complete.sras") as sras:
raw = (GOLDEN / "complete.sras").read_bytes()
for ai, pa in enumerate(sras.per_angle):
view = sras.load_angle(ai)
h = sras.header
assert view.shape == (pa.n_rows, h.n_channels, pa.n_frames, h.samples_per_frame)
start = sras.angle_data_offset(ai)
eager = np.frombuffer(
raw, dtype=np.int8, offset=start, count=view.size
).reshape(view.shape)
assert np.array_equal(view, eager)
assert not view.flags.writeable
def test_load_row_matches_synthetic_pattern():
with SrasFile(GOLDEN / "complete.sras") as sras:
for ai in range(2):
for ri in range(3):
for ci in range(3):
row = sras.load_row(ai, ri, ci)
expected_bytes = b"".join(
synthetic_frame(ai, ri, ci, fi)
for fi in range(sras.per_angle[ai].n_frames)
)
assert row.tobytes() == expected_bytes
def test_truncated_load_angle_partial_rows():
sras = SrasFile(GOLDEN / "trunc_rowboundary_a1.sras")
st = sras.angle_status()[1]
assert st.status == "TRUNCATED" and st.n_rows_available == 2
view = sras.load_angle(1, n_rows=st.n_rows_available)
assert view.shape[0] == 2
sras.close()
def test_bad_magic_and_version_rejected(tmp_path):
bad = tmp_path / "bad.sras"
bad.write_bytes(b"XXXX" + bytes(60))
with pytest.raises(ValueError, match="bad magic"):
SrasFile(bad)
data = bytearray((GOLDEN / "complete.sras").read_bytes())
data[4] = 5 # version byte
v5 = tmp_path / "v5.sras"
v5.write_bytes(bytes(data))
with pytest.raises(ValueError, match="version 5"):
SrasFile(v5)
+3 -10
View File
@@ -31,26 +31,19 @@ Date: 2026-01-24
"""
import sys
import time
import struct
from datetime import datetime
from typing import Optional, List, Tuple
from enum import IntEnum
import serial
from serial.tools import list_ports
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTabWidget, QLabel, QSlider, QPushButton, QSpinBox, QCheckBox,
QComboBox, QTextEdit, QLineEdit, QGroupBox, QGridLayout,
QMessageBox, QStatusBar, QProgressBar
QMessageBox, QStatusBar
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSettings
from PyQt6.QtGui import QFont, QPalette, QColor
from PyQt6.QtCore import Qt, QTimer, QSettings
from PyQt6.QtGui import QFont
# Import core hardware control classes
from hardware.genesis_core import (
I2CAddress, PCA9555Register, ControlBitmask,
SerialComm, I2CProtocol, I2CDevices, LaserControl
)
-1
View File
@@ -39,7 +39,6 @@ from PyQt6.QtWidgets import (
QLineEdit, QTextEdit, QCheckBox, QMessageBox, QGroupBox, QGridLayout
)
from PyQt6.QtCore import QObject, pyqtSignal, QTimer, Qt
from PyQt6.QtGui import QPalette, QColor
# ============================================================================
# CONSTANTS
-1321
View File
File diff suppressed because it is too large Load Diff