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>
scanengine-3
SRAS Scanning and Instrumentation Control Platform
Overview
scanengine-3 is a unified platform for scanning acoustic microscopy and precision instrumentation control. It integrates multiple hardware control modules into a single cohesive PyQt6-based application.
Key Features
- Stage Control: ThorLabs BBD202/BBD203 motor controller with 3-axis positioning
- 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
- Real-time Monitoring: Live status updates and progress tracking
Hardware Components
Motion Control
- ThorLabs BBD202/BBD203 Motor Controller
- 3-channel APT protocol driver
- Precision positioning with encoder feedback
- Programmable velocity and acceleration
- Trigger output support for synchronized data acquisition
Laser Systems
- Helios Laser System
- Frequency control (16.7-125 kHz)
- Current control (0-7000 mA)
- Multiple pulse modes
- Temperature and power monitoring
Data Acquisition
- Tektronix MSO/DPO Series Oscilloscopes
- Direct socket communication (no VISA overhead)
- Fast-frame acquisition for high-speed scanning
- Multi-channel waveform capture
- Configurable triggering
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/
├── 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
│ ├── rotation.py # GR rotation axis settings + moves
│ ├── sras_format.py # v6 .sras writer/reader (memory-mapped)
│ ├── sras_analysis.py # Image reducers + SAW matched filter
│ └── config.py # ScanDefaults ⇄ aui_defaults.json
│
├── 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)
│
├── gui/ # Shared PyQt6 layer
│ ├── scan_bridge.py # QtScanController over core.scan_engine
│ ├── qt_t3r.py # Qt adapter over the T3R driver
│ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases
│ └── widgets.py # ConnectionBar, LogConsole, PortSelector…
│
├── sc3_aui_app.py # Main acquisition application
├── sras_viewer.py # Scan data viewer
├── 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/ # pytest suite
│ ├── golden/ # v6 .sras + geometry fixtures
│ ├── fakes.py # Recording fake stage/scope/rotator
│ └── test_*.py
│
├── docs/
│ ├── hardware/ # Driver notes
│ ├── protocols/ # Vendor protocol PDFs
│ └── genesis_verification.md # Bench checklist (see KNOWN_ISSUES.md)
│
├── 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
Installation
# Clone or navigate to project directory
cd scanengine-3
# Create virtual environment (recommended)
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
Running the Application
# Main acquisition application
python sc3_aui_app.py
# Scan data viewer
python sras_viewer.py
# 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
Running the tests
The suite is hardware-free: fake drivers and committed fixtures stand in for the rig.
pip install pytest ruff
python -m pytest tests/ -q
Dependencies
- PyQt6 (>=6.4.0) - GUI framework
- pyserial (>=3.5) - Serial communication
- 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 and
docs/genesis_verification.md before
changing either file.
Usage Examples
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:
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
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}")
Reading a scan file
SrasFile memory-maps the data block, so opening a multi-gigabyte scan
costs only the pages actually touched:
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
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
from core.scope_sras import configure_acquisition, configure_channels
from hardware.tektronix_base import TektronixOscilloscopeBase
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
from hardware.helios_laser import HeliosLaser
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
from hardware.uc480_camera import UC480Camera, find_camera_bus_conflicts
find_camera_bus_conflicts() # warns about USB bus contention
camera = UC480Camera(camera_id=1)
camera.initialize()
camera.start_capture()
Configuration
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.
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
Adding New Hardware
- Create driver module in
hardware/directory - Implement connection, control, and status methods
- Add UI elements to main window or create new dialog
- Connect signals in
main_window.py
Testing Without Hardware
All hardware modules include stub implementations or simulation modes. The GUI can be developed and tested without physical devices connected.
Documentation
Detailed documentation available in project subdirectories:
- BBD202/203 Driver Guide
- BBD202/203 Connection Guide
- BBD202/203 Communications Protocol
- Helios Laser Guide
- Genesis Laser Guide
- Laser Control Implementation Guide
- Setup Instructions
License
Copyright (C) 2025 Thomas Ales Licensed under GNU General Public License v2.0
See LICENSE file for full license text.
Support
For issues, questions, or contributions, please refer to the project documentation or contact the development team.
Version
scanengine-3 v0.1.0 - Initial unified release