Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52fdcdd9f3 | |||
| ab5f3166f4 | |||
| ef8c0feb91 | |||
| 116c9c07c7 | |||
| d6a56266b7 | |||
| 278df9411e | |||
| 709dc529df | |||
| 44febe34b8 | |||
| afe33249d1 | |||
| d2734c45d6 | |||
| dff9f69d78 | |||
| 67aabde4b6 | |||
| 5148f0bca2 | |||
| 1d4e65f8ac | |||
| d185676130 | |||
| 7bcdff9756 | |||
| 2041fc8439 | |||
| 1014533626 |
Regular → Executable
+3
@@ -177,3 +177,6 @@ cython_debug/
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
Regular → Executable
@@ -0,0 +1,50 @@
|
||||
# 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.
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
- **Real-time Monitoring**: Live status updates and progress tracking
|
||||
@@ -30,11 +30,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 +37,69 @@ 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
|
||||
│ ├── 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/ # 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
|
||||
│ ├── qt_t3r.py # Qt adapter over the T3R driver
|
||||
│ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases
|
||||
│ └── 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
|
||||
├── 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/ # 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 +121,32 @@ 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
|
||||
|
||||
# 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 +156,137 @@ 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
|
||||
### 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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -1,545 +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 motion_worker import MotionWorker
|
||||
from scanning.stage_scan_plan_generator import StageScanPlanGenerator
|
||||
from genesis_worker import GenesisWorker, GenesisCommand
|
||||
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):
|
||||
super().__init__()
|
||||
self.scan_params = scan_params
|
||||
self.motion_worker = motion_worker
|
||||
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()
|
||||
# TODO: implement scan execution logic
|
||||
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
|
||||
|
||||
self._connect_signals()
|
||||
self._init_genesis_worker()
|
||||
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):
|
||||
pass # TODO
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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"],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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.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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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):
|
||||
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()
|
||||
Regular → Executable
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"t3r_port": "/dev/ttyACM0",
|
||||
"bbd_port": "/dev/ttyAPT",
|
||||
"bbd_port": "/dev/ttyUSB0",
|
||||
"oscope_ip": "192.168.100.105",
|
||||
"laser_freq_hz": 20000.0,
|
||||
"save_dir": "/opt/scanengine-3/scans",
|
||||
"helios_port": "/dev/ttyUSB0"
|
||||
"save_dir": "/data/SRAS",
|
||||
"helios_port": "/dev/ttyUSB1"
|
||||
}
|
||||
Regular → Executable
+2
-2
@@ -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
|
||||
|
||||
Regular → Executable
+2
-2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Headless scan-engine core: importable without PyQt6 or any vendor SDK."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,637 @@
|
||||
"""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 SCAN_CHANNELS, create_scan_file
|
||||
|
||||
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."""
|
||||
angle_idx: 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):
|
||||
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
|
||||
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)
|
||||
self._prompt(
|
||||
"Background Capture",
|
||||
"Please ensure the Helios laser is ON and the Genesis laser is OFF,\n"
|
||||
"then click OK to capture the background waveform."
|
||||
)
|
||||
self._background = scope_sras.capture_background(
|
||||
self._scope, should_abort=self._abort.is_set,
|
||||
on_status=self._cb.on_status)
|
||||
self._prompt(
|
||||
"Begin Scanning",
|
||||
"Background captured successfully.\n\n"
|
||||
"Please ensure the Genesis laser is back ON,\n"
|
||||
"then click OK to begin scanning."
|
||||
)
|
||||
else:
|
||||
# Resuming: the file's existing background waveform and channel
|
||||
# preambles are reused as-is (the format has no way to replace
|
||||
# them without rewriting the whole file), so background capture is
|
||||
# skipped. Sanity-check that this scope still produces the same
|
||||
# record length the file was started with — a mismatch would
|
||||
# silently corrupt the ragged per-row byte layout on append.
|
||||
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"
|
||||
"Please ensure the Genesis laser is ON,\n"
|
||||
"then click OK to continue scanning."
|
||||
)
|
||||
|
||||
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, self._background,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
if targets_by_ai is not None:
|
||||
# Interior angles may already have valid data on either side,
|
||||
# so seek to this angle's fixed offset rather than relying on
|
||||
# the file's current position.
|
||||
scan_file.seek(targets_by_ai[ai].data_offset)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# ── 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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
"""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 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, 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."""
|
||||
h = sras.header
|
||||
return (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)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Burst-mode FastFrame acquisition policy.
|
||||
|
||||
Per-row acquisition pays a full arm/stop/transfer round trip for every row,
|
||||
and the transfer alone is one IEEE-488.2 block read per frame (~16k frames a
|
||||
row). A burst instead runs one FastFrame acquisition across as many complete
|
||||
rows as the scope's frame memory holds, then pulls the whole thing in a single
|
||||
transaction — amortising the round trip over `rows_per_burst` rows.
|
||||
|
||||
The scope reports its capacity with ``HORizontal:FASTframe:MAXFRames?`` once
|
||||
the horizontal settings are fixed; ``rows_per_burst`` turns that into a row
|
||||
count. Everything here that computes rather than talks to hardware is a free
|
||||
function, so the row-splitting logic is testable without a rig.
|
||||
|
||||
The catch is that the burst contains no row markers: the scope hands back one
|
||||
flat run of frames. Boundaries come from polling ``ACQuire:NUMFRAMESACQuired?``
|
||||
after each row's acquiring pass, while the stage gate is already low — see
|
||||
``split_row_counts``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Peak transfer buffer, per channel. The writer holds one channel at a time
|
||||
# (see ScanEngine._scan_rows_burst), so this is the real high-water mark.
|
||||
BURST_MEMORY_BUDGET_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# Extra frames budgeted per row on top of n_frames. 0 gives the plain
|
||||
# floor(max_frames / n_frames) row count; raise it if the acquiring pass
|
||||
# routinely over-triggers (watch the pad/truncate warnings).
|
||||
BURST_FRAME_HEADROOM = 0
|
||||
|
||||
BURST_ARM_SETTLE_S = 0.05 # after ACQuire:STATE RUN, before the first move
|
||||
BURST_ROW_SETTLE_S = 0.05 # after the gate drops, before reading the counter
|
||||
|
||||
|
||||
# ── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def rows_per_burst(max_frames: int, n_frames: int, samples_per_frame: int,
|
||||
rows_remaining: int,
|
||||
memory_budget: int = BURST_MEMORY_BUDGET_BYTES,
|
||||
headroom: int = BURST_FRAME_HEADROOM) -> int:
|
||||
"""How many complete rows fit in one acquisition.
|
||||
|
||||
Rounds down — a partial row is worthless, since a row must be transferred
|
||||
whole to be written. Clamped by the transfer buffer budget and by the rows
|
||||
actually left in the angle, and never below 1 (a single row always goes,
|
||||
even if it exceeds the budget, so the scan can still make progress).
|
||||
"""
|
||||
if n_frames < 1 or samples_per_frame < 1:
|
||||
raise ValueError(f"n_frames={n_frames} samples_per_frame={samples_per_frame}")
|
||||
|
||||
by_scope = max_frames // (n_frames + headroom)
|
||||
by_memory = memory_budget // (n_frames * samples_per_frame)
|
||||
return max(1, min(by_scope, by_memory, rows_remaining))
|
||||
|
||||
|
||||
def split_row_counts(cumulative: list[int]) -> list[int]:
|
||||
"""Per-row frame counts from the cumulative counter sampled after each row.
|
||||
|
||||
``cumulative`` is ``ACQuire:NUMFRAMESACQuired?`` read once per row, already
|
||||
rebased on the value at burst start.
|
||||
"""
|
||||
counts = []
|
||||
prev = 0
|
||||
for i, c in enumerate(cumulative):
|
||||
if c < prev:
|
||||
raise RuntimeError(
|
||||
f"FastFrame counter went backwards at row {i} ({prev} → {c}) — "
|
||||
"the acquisition was restarted mid-burst"
|
||||
)
|
||||
counts.append(c - prev)
|
||||
prev = c
|
||||
return counts
|
||||
|
||||
|
||||
def normalize_row(buf, offset: int, count: int, n_frames: int,
|
||||
samples_per_frame: int):
|
||||
"""Coerce one row's frames to exactly ``n_frames``.
|
||||
|
||||
The v6 format commits to n_frames per row in the header and has no per-row
|
||||
length field, so a row that over- or under-triggers must be squared up or
|
||||
every later row in the file shifts. Short rows are zero-padded, long rows
|
||||
lose their trailing frames. Returns something writable directly.
|
||||
"""
|
||||
want = n_frames * samples_per_frame
|
||||
end = min(offset + count * samples_per_frame, offset + want, len(buf))
|
||||
chunk = memoryview(buf)[offset:end]
|
||||
if len(chunk) == want:
|
||||
return chunk
|
||||
return bytes(chunk) + bytes(want - len(chunk))
|
||||
|
||||
|
||||
def frame_means_block(buf, offset: int, n_frames: int,
|
||||
samples_per_frame: int) -> list[float]:
|
||||
"""Per-frame DC mean over one row's slice of a burst buffer."""
|
||||
n = n_frames * samples_per_frame
|
||||
block = np.frombuffer(buf, dtype=np.int8, count=n, offset=offset)
|
||||
return block.reshape(n_frames, samples_per_frame).mean(
|
||||
axis=1, dtype=np.float32).tolist()
|
||||
|
||||
|
||||
# ── Instrument control ───────────────────────────────────────────────────────
|
||||
|
||||
def max_frames(scope) -> int:
|
||||
"""Frames the scope can hold under the current horizontal settings."""
|
||||
try:
|
||||
m = scope.get_fastframe_max_frames()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Scope did not answer HORizontal:FASTframe:MAXFRames? — burst mode "
|
||||
"cannot size a burst without it. Use per-row acquisition on this "
|
||||
f"firmware. ({exc})"
|
||||
) from exc
|
||||
if m < 1:
|
||||
raise RuntimeError(f"Scope reports a FastFrame capacity of {m} frames")
|
||||
return m
|
||||
|
||||
|
||||
def start_burst(scope, frame_count: int) -> int:
|
||||
"""Arm one burst; returns the counter baseline to subtract from later reads.
|
||||
|
||||
Reading the baseline back beats assuming the counter resets to 0 on RUN —
|
||||
any residual is simply subtracted out instead of being misattributed to the
|
||||
first row.
|
||||
"""
|
||||
scope.set_fastframe_count(frame_count)
|
||||
scope.write("ACQuire:STATE RUN")
|
||||
time.sleep(BURST_ARM_SETTLE_S)
|
||||
return frames_acquired(scope)
|
||||
|
||||
|
||||
def stop_burst(scope) -> None:
|
||||
time.sleep(BURST_ROW_SETTLE_S)
|
||||
scope.write("ACQuire:STATE STOP")
|
||||
|
||||
|
||||
def frames_acquired(scope) -> int:
|
||||
return int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
||||
|
||||
|
||||
def transfer_burst(scope, ch: int, frame_count: int, samples_per_frame: int):
|
||||
"""Pull a whole burst for one channel in a single CURVe? transaction."""
|
||||
scope.set_data_source(ch)
|
||||
return scope.transfer_fastframe_bulk(frame_count, samples_per_frame)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""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_acquisition(scope) -> int:
|
||||
"""Program the edge trigger and timebase; returns samples per frame.
|
||||
|
||||
Edge trigger on the rising edge of CH2 (laser pulse). FastFrame stays
|
||||
off here so the background capture runs as a single record.
|
||||
"""
|
||||
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)
|
||||
# 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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"""SRAS v6 binary scan-file format — 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)
|
||||
background block >I length + raw int8 CH1 average
|
||||
waveform data angle-major, row-minor, channel-inner:
|
||||
for each angle, for each row, for each channel,
|
||||
n_frames × samples_per_frame × bytes_per_sample
|
||||
|
||||
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``).
|
||||
"""
|
||||
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 = 6
|
||||
HDR_FMT = ">4sBHfffffffIdBB"
|
||||
HDR_SIZE = struct.calcsize(HDR_FMT) # 49 bytes
|
||||
GEOM_FMT = ">ffIH"
|
||||
GEOM_SIZE = struct.calcsize(GEOM_FMT) # 14 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
|
||||
data_offset: int
|
||||
n_rows_available: int
|
||||
status: str # STATUS_OK / STATUS_TRUNCATED / STATUS_MISSING
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
return self.status == STATUS_OK
|
||||
|
||||
|
||||
def create_scan_file(path: Path, plan: ScanPlan, samples_per_frame: int,
|
||||
sample_rate: float, preambles: list[str],
|
||||
background_waveform: bytes) -> BinaryIO:
|
||||
"""Create a new .sras file and write the v6 header + tables.
|
||||
|
||||
Returns an open binary file positioned at the start of the data block;
|
||||
the caller appends waveform rows and must close it (try/finally).
|
||||
"""
|
||||
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)
|
||||
f.write(struct.pack(">I", len(background_waveform)))
|
||||
f.write(background_waveform)
|
||||
return f
|
||||
|
||||
|
||||
@dataclass
|
||||
class SrasFile:
|
||||
"""Parsed v6 .sras file: header, tables, and lazy (memmap) data access.
|
||||
|
||||
Parsing reads only the header/tables — never the waveform block — so
|
||||
opening a multi-GB file is cheap. ``load_angle``/``load_row`` return
|
||||
read-only numpy views backed by a shared mmap; no data is copied until
|
||||
the caller computes on it.
|
||||
"""
|
||||
path: Path
|
||||
header: ScanHeader = field(init=False)
|
||||
per_angle: list[AngleGeometry] = field(init=False)
|
||||
preambles: list[str] = field(init=False)
|
||||
preambles_raw: list[bytes] = field(init=False)
|
||||
background: bytes = field(init=False)
|
||||
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 != VERSION:
|
||||
raise ValueError(
|
||||
f"{self.path.name}: unsupported SRAS format version {version} "
|
||||
f"(only version {VERSION} is supported)"
|
||||
)
|
||||
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]
|
||||
|
||||
(n_bg,) = struct.unpack(">I", f.read(4))
|
||||
self.background = f.read(n_bg)
|
||||
|
||||
self.data_start_offset = f.tell()
|
||||
|
||||
# ── 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.
|
||||
"""
|
||||
statuses = []
|
||||
cursor = self.data_start_offset
|
||||
frontier_seen = False
|
||||
for ai, pa in enumerate(self.per_angle):
|
||||
row_bytes = self.row_bytes(ai)
|
||||
data_offset = cursor
|
||||
if frontier_seen:
|
||||
n_rows_available = 0
|
||||
status = STATUS_MISSING
|
||||
else:
|
||||
declared_bytes = row_bytes * pa.n_rows
|
||||
if row_bytes > 0 and cursor + declared_bytes <= self.file_size:
|
||||
n_rows_available = pa.n_rows
|
||||
status = STATUS_OK
|
||||
cursor += declared_bytes
|
||||
else:
|
||||
remaining = max(0, self.file_size - cursor)
|
||||
n_rows_available = remaining // row_bytes if row_bytes > 0 else 0
|
||||
status = STATUS_MISSING if n_rows_available == 0 else STATUS_TRUNCATED
|
||||
frontier_seen = True
|
||||
statuses.append(AngleStatus(
|
||||
index=ai, angle_deg=pa.angle_deg, n_rows=pa.n_rows,
|
||||
row_bytes=row_bytes, data_offset=data_offset,
|
||||
n_rows_available=n_rows_available, status=status,
|
||||
))
|
||||
return statuses
|
||||
|
||||
def angle_data_offset(self, angle_idx: int) -> int:
|
||||
offset = self.data_start_offset
|
||||
for ai in range(angle_idx):
|
||||
offset += self.row_bytes(ai) * self.per_angle[ai].n_rows
|
||||
return offset
|
||||
|
||||
# ── 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 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),
|
||||
)
|
||||
@@ -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`.
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared PyQt6 layer: adapters and widgets used by more than one app."""
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
# ── 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)
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
"""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 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)
|
||||
font = QFont("Menlo")
|
||||
font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
font.setPointSize(11)
|
||||
self.view.setFont(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 "")
|
||||
Regular → Executable
+6
-6
@@ -1,6 +1,6 @@
|
||||
"""Hardware driver modules for ScanEngine-3"""
|
||||
from .pybbd202 import ThorlabsServoDriver, TriggerBitsServo, AXIS_X, AXIS_Y, CONTROLLER
|
||||
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).
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
Regular → Executable
+10
-1
@@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Regular → Executable
+70
-207
@@ -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}")
|
||||
"""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.
|
||||
|
||||
Regular → Executable
-2
@@ -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
|
||||
|
||||
Regular → Executable
+15
-10
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Regular → Executable
+1
-3
@@ -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
|
||||
|
||||
|
||||
Regular → Executable
+88
-92
@@ -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"
|
||||
)
|
||||
|
||||
Regular → Executable
@@ -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()]
|
||||
Executable
+343
@@ -0,0 +1,343 @@
|
||||
"""T3R Stepper Controller driver for ScanEngine-3.
|
||||
|
||||
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)
|
||||
Ratio motor:stage = 125/10 = 12.5
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from . import t3r_protocol as proto
|
||||
from .serial_util import open_8n1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Signal:
|
||||
"""Minimal observer slot: ``connect(fn)`` then ``emit(*args)``.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
__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()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
def run(self):
|
||||
reason = ""
|
||||
while self._running:
|
||||
try:
|
||||
data = self._ser.read(256)
|
||||
except Exception as exc:
|
||||
reason = str(exc) or exc.__class__.__name__
|
||||
break
|
||||
if data:
|
||||
for cmd, payload in self._parser.feed(data):
|
||||
self._on_frame(cmd, payload)
|
||||
self._on_finished(reason)
|
||||
|
||||
|
||||
class T3RDriver:
|
||||
"""Driver for the T3R four-channel stepper controller.
|
||||
|
||||
Usage::
|
||||
|
||||
driver = T3RDriver()
|
||||
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"]
|
||||
GR_AXIS_CH = 3
|
||||
|
||||
# Gear train constants
|
||||
MOTOR_FULL_STEPS_PER_REV = 200
|
||||
GEAR_TEETH_MOTOR = 10
|
||||
GEAR_TEETH_STAGE = 125 # idler is 30T but does not change ratio
|
||||
|
||||
POLL_INTERVAL_S = 0.25
|
||||
|
||||
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_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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._is_open
|
||||
|
||||
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.close()
|
||||
try:
|
||||
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 = _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 close(self) -> None:
|
||||
"""Close the port and stop polling."""
|
||||
self._teardown("")
|
||||
|
||||
def _on_reader_finished(self, reason: str):
|
||||
if reason:
|
||||
self._teardown(reason)
|
||||
|
||||
def _teardown(self, reason: str):
|
||||
if self._tearing_down or not self._is_open:
|
||||
return
|
||||
self._tearing_down = True
|
||||
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 threading.current_thread() is not reader:
|
||||
reader.join(1.0)
|
||||
if ser is not None:
|
||||
try:
|
||||
ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.disconnected.emit(reason)
|
||||
|
||||
# ── Sending ───────────────────────────────────────────────────────────────
|
||||
|
||||
def send_frame(self, frame: bytes) -> bool:
|
||||
if not self._is_open or self._ser is None:
|
||||
return False
|
||||
try:
|
||||
with self._write_lock:
|
||||
self._ser.write(frame)
|
||||
return True
|
||||
except Exception as exc:
|
||||
self._teardown(str(exc) or exc.__class__.__name__)
|
||||
return False
|
||||
|
||||
# ── Command API ───────────────────────────────────────────────────────────
|
||||
|
||||
def ping(self):
|
||||
self.send_frame(proto.ping())
|
||||
|
||||
def stop_all(self):
|
||||
self.send_frame(proto.stop_all())
|
||||
|
||||
def enable(self, ch: int):
|
||||
self.send_frame(proto.enable(ch))
|
||||
|
||||
def disable(self, ch: int):
|
||||
self.send_frame(proto.disable(ch))
|
||||
|
||||
def enable_mask(self, mask: int):
|
||||
self.send_frame(proto.enable_mask(mask))
|
||||
|
||||
def set_microstep(self, ch: int, microsteps: int):
|
||||
self.send_frame(proto.set_microstep(ch, microsteps))
|
||||
|
||||
def set_current(self, ch: int, run_ma: int, hold_ma: int, ihold_delay: int):
|
||||
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):
|
||||
self.send_frame(proto.jog(ch, velocity, accel))
|
||||
|
||||
def stop(self, ch: int, hard: bool = False):
|
||||
self.send_frame(proto.stop(ch, hard))
|
||||
|
||||
def move_group(self, mask: int, steps: int, velocity: int, accel: int):
|
||||
self.send_frame(proto.move_group(mask, steps, velocity, accel))
|
||||
|
||||
def jog_group(self, mask: int, velocity: int, accel: int):
|
||||
self.send_frame(proto.jog_group(mask, velocity, accel))
|
||||
|
||||
def get_info(self, ch: int):
|
||||
self.send_frame(proto.get_info(ch))
|
||||
|
||||
def get_drv_status(self, ch: int):
|
||||
self.send_frame(proto.get_drv_status(ch))
|
||||
|
||||
def get_position(self, ch: int):
|
||||
self.send_frame(proto.get_position(ch))
|
||||
|
||||
def set_position(self, ch: int, position: int):
|
||||
self.send_frame(proto.set_position(ch, position))
|
||||
|
||||
# ── Rotation helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def steps_for_angle(self, angle_deg: float, microsteps: int) -> int:
|
||||
"""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 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):
|
||||
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_stop.set()
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
def _on_frame(self, cmd: int, payload: bytes):
|
||||
self.frame_received.emit(cmd, payload)
|
||||
|
||||
if cmd == proto.RSP_PONG:
|
||||
p = proto.decode_pong(payload)
|
||||
if p:
|
||||
self.handshake_ok.emit(p.proto_ver, p.fw_version, p.num_channels)
|
||||
self.start_polling()
|
||||
|
||||
elif cmd == proto.RSP_ACK:
|
||||
a = proto.decode_ack(payload)
|
||||
if a:
|
||||
self.ack_received.emit(a.req_cmd, a.status)
|
||||
|
||||
elif cmd == proto.RSP_INFO:
|
||||
info = proto.decode_info(payload)
|
||||
if info and 0 <= info.ch < proto.NUM_CHANNELS:
|
||||
self.info_updated.emit(info.ch, info)
|
||||
|
||||
elif cmd == proto.RSP_DRV_STATUS:
|
||||
st = proto.decode_drv_status(payload)
|
||||
if st and 0 <= st.ch < proto.NUM_CHANNELS:
|
||||
self.drv_status_updated.emit(st.ch, st)
|
||||
|
||||
elif cmd == proto.RSP_POSITION:
|
||||
pos = proto.decode_position(payload)
|
||||
if pos and 0 <= pos.ch < proto.NUM_CHANNELS:
|
||||
self.position_updated.emit(pos.ch, pos.position)
|
||||
|
||||
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)
|
||||
Executable
+352
@@ -0,0 +1,352 @@
|
||||
"""T3R binary serial protocol — host-side codec.
|
||||
|
||||
A faithful Python port of the wire protocol implemented in ``main/protocol.c``.
|
||||
Pure standard library (no PyQt / pyserial), so it can be unit-tested on its own.
|
||||
|
||||
Frame layout (all multi-byte fields little-endian):
|
||||
|
||||
+------+------+------+----------------+------+
|
||||
| SOF | CMD | LEN | PAYLOAD[LEN] | CRC8 |
|
||||
+------+------+------+----------------+------+
|
||||
0xA5 1 B 1 B LEN bytes 1 B
|
||||
|
||||
CRC8 is CRC-8/SMBUS (poly 0x07, init 0x00) over CMD, LEN and PAYLOAD.
|
||||
See PROTOCOL.md for the full catalogue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
SOF = 0xA5
|
||||
PROTO_VERSION = 1
|
||||
|
||||
# ---- Requests (host -> device) --------------------------------------------
|
||||
CMD_PING = 0x01
|
||||
CMD_SET_MICROSTEP = 0x10
|
||||
CMD_SET_CURRENT = 0x11
|
||||
CMD_ENABLE = 0x12
|
||||
CMD_DISABLE = 0x13
|
||||
CMD_ENABLE_MASK = 0x14
|
||||
CMD_MOVE = 0x20
|
||||
CMD_JOG = 0x21
|
||||
CMD_STOP = 0x22
|
||||
CMD_STOP_ALL = 0x23
|
||||
CMD_MOVE_GROUP = 0x24
|
||||
CMD_JOG_GROUP = 0x25
|
||||
CMD_GET_INFO = 0x30
|
||||
CMD_GET_DRV_STATUS = 0x31
|
||||
CMD_GET_POSITION = 0x32
|
||||
CMD_SET_POSITION = 0x33
|
||||
CMD_READ_REG = 0x40
|
||||
CMD_WRITE_REG = 0x41
|
||||
|
||||
# ---- Responses (device -> host) -------------------------------------------
|
||||
RSP_ACK = 0x81
|
||||
RSP_PONG = 0x82
|
||||
RSP_INFO = 0x83
|
||||
RSP_DRV_STATUS = 0x84
|
||||
RSP_POSITION = 0x85
|
||||
RSP_REG = 0x86
|
||||
|
||||
# ---- Asynchronous events (device -> host) ---------------------------------
|
||||
EVT_MOTION_DONE = 0xE0
|
||||
EVT_STOPPED = 0xE1
|
||||
EVT_FAULT = 0xE2
|
||||
|
||||
# Human-readable names, used by the log/console.
|
||||
CMD_NAMES = {
|
||||
CMD_PING: "PING", CMD_SET_MICROSTEP: "SET_MICROSTEP",
|
||||
CMD_SET_CURRENT: "SET_CURRENT", CMD_ENABLE: "ENABLE", CMD_DISABLE: "DISABLE",
|
||||
CMD_ENABLE_MASK: "ENABLE_MASK",
|
||||
CMD_MOVE: "MOVE", CMD_JOG: "JOG", CMD_STOP: "STOP", CMD_STOP_ALL: "STOP_ALL",
|
||||
CMD_MOVE_GROUP: "MOVE_GROUP", CMD_JOG_GROUP: "JOG_GROUP",
|
||||
CMD_GET_INFO: "GET_INFO", CMD_GET_DRV_STATUS: "GET_DRV_STATUS",
|
||||
CMD_GET_POSITION: "GET_POSITION", CMD_SET_POSITION: "SET_POSITION",
|
||||
CMD_READ_REG: "READ_REG", CMD_WRITE_REG: "WRITE_REG",
|
||||
RSP_ACK: "ACK", RSP_PONG: "PONG", RSP_INFO: "INFO",
|
||||
RSP_DRV_STATUS: "DRV_STATUS", RSP_POSITION: "POSITION", RSP_REG: "REG",
|
||||
EVT_MOTION_DONE: "MOTION_DONE", EVT_STOPPED: "STOPPED", EVT_FAULT: "FAULT",
|
||||
}
|
||||
|
||||
# ---- ACK status codes ------------------------------------------------------
|
||||
STATUS_NAMES = {
|
||||
0x00: "OK", 0x02: "bad length", 0x03: "bad channel", 0x04: "bad parameter",
|
||||
0x05: "busy", 0x06: "SPI comms fault", 0x07: "unknown command",
|
||||
}
|
||||
|
||||
# ---- INFO.state ------------------------------------------------------------
|
||||
STATE_NAMES = {0: "IDLE", 1: "MOVING", 2: "JOGGING", 3: "STOPPING"}
|
||||
|
||||
# ---- Fault mask bits -------------------------------------------------------
|
||||
FAULT_BITS = [
|
||||
(1 << 0, "SHORT_GND_A"),
|
||||
(1 << 1, "SHORT_GND_B"),
|
||||
(1 << 2, "OVERTEMP"),
|
||||
(1 << 3, "OVERTEMP_WARN"),
|
||||
(1 << 4, "OPEN_LOAD_A"),
|
||||
(1 << 5, "OPEN_LOAD_B"),
|
||||
]
|
||||
|
||||
MICROSTEPS = [1, 2, 4, 8, 16, 32, 64, 128, 256]
|
||||
MAX_VELOCITY = 200_000 # steps/s (MOTOR_MAX_VELOCITY)
|
||||
MAX_ACCEL = 2_000_000 # steps/s2 (MOTOR_MAX_ACCEL)
|
||||
NUM_CHANNELS = 4
|
||||
|
||||
|
||||
def fault_names(mask: int) -> str:
|
||||
"""Render a fault mask as a comma-separated list, or 'none'."""
|
||||
names = [name for bit, name in FAULT_BITS if mask & bit]
|
||||
return ", ".join(names) if names else "none"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRC-8 (poly 0x07, init 0x00) — matches the firmware reference.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def crc8(data: bytes) -> int:
|
||||
crc = 0
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
|
||||
return crc
|
||||
|
||||
|
||||
def build_frame(cmd: int, payload: bytes = b"") -> bytes:
|
||||
body = bytes([cmd, len(payload)]) + payload
|
||||
return bytes([SOF]) + body + bytes([crc8(body)])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request encoders — each returns a complete frame ready to write.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ping() -> bytes:
|
||||
return build_frame(CMD_PING)
|
||||
|
||||
|
||||
def set_microstep(ch: int, microsteps: int) -> bytes:
|
||||
return build_frame(CMD_SET_MICROSTEP, struct.pack("<BH", ch, microsteps))
|
||||
|
||||
|
||||
def set_current(ch: int, run_ma: int, hold_ma: int, ihold_delay: int) -> bytes:
|
||||
return build_frame(CMD_SET_CURRENT,
|
||||
struct.pack("<BHHB", ch, run_ma, hold_ma, ihold_delay))
|
||||
|
||||
|
||||
def enable(ch: int) -> bytes:
|
||||
return build_frame(CMD_ENABLE, bytes([ch]))
|
||||
|
||||
|
||||
def disable(ch: int) -> bytes:
|
||||
return build_frame(CMD_DISABLE, bytes([ch]))
|
||||
|
||||
|
||||
def enable_mask(mask: int) -> bytes:
|
||||
"""Energise exactly the channels in `mask` (bit i = channel i); 0 = all off."""
|
||||
return build_frame(CMD_ENABLE_MASK, bytes([mask & 0x0F]))
|
||||
|
||||
|
||||
def move(ch: int, steps: int, velocity: int, accel: int) -> bytes:
|
||||
return build_frame(CMD_MOVE, struct.pack("<BiII", ch, steps, velocity, accel))
|
||||
|
||||
|
||||
def jog(ch: int, velocity: int, accel: int) -> bytes:
|
||||
return build_frame(CMD_JOG, struct.pack("<BiI", ch, velocity, accel))
|
||||
|
||||
|
||||
def move_group(mask: int, steps: int, velocity: int, accel: int) -> bytes:
|
||||
"""Ganged move: every channel in `mask` steps together in lockstep."""
|
||||
return build_frame(CMD_MOVE_GROUP,
|
||||
struct.pack("<BiII", mask & 0x0F, steps, velocity, accel))
|
||||
|
||||
|
||||
def jog_group(mask: int, velocity: int, accel: int) -> bytes:
|
||||
"""Ganged jog: every channel in `mask` runs together (velocity 0 = stop)."""
|
||||
return build_frame(CMD_JOG_GROUP,
|
||||
struct.pack("<BiI", mask & 0x0F, velocity, accel))
|
||||
|
||||
|
||||
def stop(ch: int, hard: bool) -> bytes:
|
||||
return build_frame(CMD_STOP, struct.pack("<BB", ch, 1 if hard else 0))
|
||||
|
||||
|
||||
def stop_all() -> bytes:
|
||||
return build_frame(CMD_STOP_ALL)
|
||||
|
||||
|
||||
def get_info(ch: int) -> bytes:
|
||||
return build_frame(CMD_GET_INFO, bytes([ch]))
|
||||
|
||||
|
||||
def get_drv_status(ch: int) -> bytes:
|
||||
return build_frame(CMD_GET_DRV_STATUS, bytes([ch]))
|
||||
|
||||
|
||||
def get_position(ch: int) -> bytes:
|
||||
return build_frame(CMD_GET_POSITION, bytes([ch]))
|
||||
|
||||
|
||||
def set_position(ch: int, position: int) -> bytes:
|
||||
return build_frame(CMD_SET_POSITION, struct.pack("<Bi", ch, position))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response / event decoders. Each returns a dataclass (or None on bad length).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Pong:
|
||||
proto_ver: int
|
||||
fw_version: int
|
||||
num_channels: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ack:
|
||||
req_cmd: int
|
||||
status: int
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.status == 0x00
|
||||
|
||||
|
||||
@dataclass
|
||||
class Info:
|
||||
ch: int
|
||||
state: int
|
||||
position: int
|
||||
velocity: int
|
||||
microsteps: int
|
||||
run_ma: int
|
||||
hold_ma: int
|
||||
enabled: bool
|
||||
comms_ok: bool
|
||||
fault_mask: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrvStatus:
|
||||
ch: int
|
||||
raw: int
|
||||
fault_mask: int
|
||||
|
||||
@property
|
||||
def standstill(self) -> bool:
|
||||
return bool(self.raw & (1 << 31))
|
||||
|
||||
@property
|
||||
def cs_actual(self) -> int:
|
||||
return (self.raw >> 16) & 0x1F
|
||||
|
||||
@property
|
||||
def sg_result(self) -> int:
|
||||
return self.raw & 0x3FF
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
ch: int
|
||||
position: int
|
||||
|
||||
|
||||
def decode_pong(p: bytes):
|
||||
if len(p) < 4:
|
||||
return None
|
||||
proto, fw, nch = struct.unpack_from("<BHB", p, 0)
|
||||
return Pong(proto, fw, nch)
|
||||
|
||||
|
||||
def decode_ack(p: bytes):
|
||||
if len(p) < 2:
|
||||
return None
|
||||
return Ack(p[0], p[1])
|
||||
|
||||
|
||||
def decode_info(p: bytes):
|
||||
if len(p) < 18:
|
||||
return None
|
||||
(ch, state, pos, vel, micro, run_ma, hold_ma, flags, fault) = \
|
||||
struct.unpack_from("<BBiIHHHBB", p, 0)
|
||||
return Info(ch, state, pos, vel, micro, run_ma, hold_ma,
|
||||
bool(flags & 0x01), bool(flags & 0x02), fault)
|
||||
|
||||
|
||||
def decode_drv_status(p: bytes):
|
||||
if len(p) < 6:
|
||||
return None
|
||||
ch, raw, fault = struct.unpack_from("<BIB", p, 0)
|
||||
return DrvStatus(ch, raw, fault)
|
||||
|
||||
|
||||
def decode_position(p: bytes):
|
||||
if len(p) < 5:
|
||||
return None
|
||||
ch, pos = struct.unpack_from("<Bi", p, 0)
|
||||
return Position(ch, pos)
|
||||
|
||||
|
||||
def decode_event_position(p: bytes):
|
||||
"""MOTION_DONE / STOPPED share the (ch, position) layout."""
|
||||
return decode_position(p)
|
||||
|
||||
|
||||
def decode_fault(p: bytes):
|
||||
if len(p) < 2:
|
||||
return None
|
||||
return Position(p[0], p[1]) # reuse (ch, value); value is the fault mask
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Incremental frame parser — mirrors the firmware byte-wise state machine.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FrameParser:
|
||||
"""Feed raw bytes, get back a list of (cmd, payload) for each valid frame.
|
||||
|
||||
Bad-CRC and oversized frames are dropped silently; the parser resyncs on
|
||||
the next SOF, exactly like the device-side parser.
|
||||
"""
|
||||
|
||||
MAX_PAYLOAD = 64
|
||||
|
||||
_SOF, _CMD, _LEN, _PAYLOAD, _CRC = range(5)
|
||||
|
||||
def __init__(self):
|
||||
self._state = self._SOF
|
||||
self._cmd = 0
|
||||
self._len = 0
|
||||
self._payload = bytearray()
|
||||
|
||||
def feed(self, data: bytes):
|
||||
out = []
|
||||
for b in data:
|
||||
if self._state == self._SOF:
|
||||
if b == SOF:
|
||||
self._state = self._CMD
|
||||
elif self._state == self._CMD:
|
||||
self._cmd = b
|
||||
self._state = self._LEN
|
||||
elif self._state == self._LEN:
|
||||
self._len = b
|
||||
self._payload.clear()
|
||||
if b > self.MAX_PAYLOAD:
|
||||
self._state = self._SOF # too big: resync
|
||||
elif b == 0:
|
||||
self._state = self._CRC
|
||||
else:
|
||||
self._state = self._PAYLOAD
|
||||
elif self._state == self._PAYLOAD:
|
||||
self._payload.append(b)
|
||||
if len(self._payload) >= self._len:
|
||||
self._state = self._CRC
|
||||
elif self._state == self._CRC:
|
||||
body = bytes([self._cmd, self._len]) + bytes(self._payload)
|
||||
if crc8(body) == b:
|
||||
out.append((self._cmd, bytes(self._payload)))
|
||||
# else bad CRC: drop, resync
|
||||
self._state = self._SOF
|
||||
return out
|
||||
Regular → Executable
+117
-1125
File diff suppressed because it is too large
Load Diff
Regular → Executable
+159
-137
@@ -4,6 +4,9 @@ Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library.
|
||||
Provides camera control, live streaming, and image capture capabilities.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import numpy as np
|
||||
from pyueye import ueye
|
||||
@@ -11,11 +14,71 @@ from PyQt6.QtCore import QThread, pyqtSignal, QObject
|
||||
from PyQt6.QtGui import QImage
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Tuple
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IDS_USB_VENDOR_ID = "1409"
|
||||
|
||||
|
||||
def find_camera_bus_conflicts() -> List[Tuple[str, str, str]]:
|
||||
"""Return (tty_name, bus, product) for serial adapters sharing a USB host
|
||||
controller with the IDS camera.
|
||||
|
||||
A uC480 USB2 camera at high pixel clock needs nearly the whole 480 Mbit/s
|
||||
of its host controller. When a full-speed serial adapter (ESP32 CDC,
|
||||
FTDI, …) on the same controller has its port *open*, the kernel's
|
||||
periodic split transactions starve the camera's bulk stream — measured
|
||||
on this rig: 22 fps with all ports closed, <1.5 fps with either the T3R
|
||||
(ttyACM0) or BBD202 (ttyUSB1) port open, full recovery on close. The
|
||||
only real fix is plugging the camera into a port on a different
|
||||
controller (e.g. a USB-3/xHCI port); this check exists so the UI can
|
||||
say that instead of silently dropping frames.
|
||||
"""
|
||||
cam_buses = set()
|
||||
for vid_path in glob.glob("/sys/bus/usb/devices/*/idVendor"):
|
||||
try:
|
||||
with open(vid_path) as f:
|
||||
if f.read().strip() != IDS_USB_VENDOR_ID:
|
||||
continue
|
||||
with open(os.path.join(os.path.dirname(vid_path), "busnum")) as f:
|
||||
cam_buses.add(f.read().strip())
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
conflicts: List[Tuple[str, str, str]] = []
|
||||
if not cam_buses:
|
||||
return conflicts
|
||||
|
||||
tty_paths = glob.glob("/sys/class/tty/ttyUSB*") + glob.glob("/sys/class/tty/ttyACM*")
|
||||
for tty_path in sorted(tty_paths):
|
||||
real = os.path.realpath(os.path.join(tty_path, "device"))
|
||||
m = re.search(r"/usb(\d+)/", real)
|
||||
if not m or m.group(1) not in cam_buses:
|
||||
continue
|
||||
# Walk up from the interface dir to the USB device dir for its name
|
||||
product = ""
|
||||
d = real
|
||||
for _ in range(6):
|
||||
d = os.path.dirname(d)
|
||||
if os.path.exists(os.path.join(d, "busnum")):
|
||||
try:
|
||||
with open(os.path.join(d, "product")) as f:
|
||||
product = f.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
break
|
||||
conflicts.append((os.path.basename(tty_path), m.group(1), product))
|
||||
|
||||
if conflicts:
|
||||
devs = ", ".join(f"/dev/{n} ({p})" if p else f"/dev/{n}" for n, _, p in conflicts)
|
||||
logger.warning(
|
||||
f"Camera shares USB bus {sorted(cam_buses)} with serial adapters: {devs}. "
|
||||
f"Opening any of these ports will collapse the camera frame rate — "
|
||||
f"move the camera to a port on another USB controller."
|
||||
)
|
||||
return conflicts
|
||||
|
||||
|
||||
class UC480Camera(QObject):
|
||||
"""
|
||||
@@ -28,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
|
||||
@@ -60,18 +118,15 @@ class UC480Camera(QObject):
|
||||
self.height = 0
|
||||
self.bits_per_pixel = 24 # Default to 24-bit color
|
||||
self.bytes_per_pixel = 3
|
||||
self.color_mode = ueye.IS_CM_BGR8_PACKED
|
||||
# RGB (not BGR) so get_frame() can hand the buffer straight to QImage
|
||||
# without a per-frame channel-reversal copy.
|
||||
self.color_mode = ueye.IS_CM_RGB8_PACKED
|
||||
|
||||
# Lock to serialize parameter changes that require stopping live video
|
||||
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
|
||||
@@ -159,10 +214,19 @@ class UC480Camera(QObject):
|
||||
self.is_initialized = True
|
||||
logger.info(f"Camera initialized: {self.width}x{self.height}, {self.bits_per_pixel}bpp")
|
||||
|
||||
# Set default settings
|
||||
# Set default settings. Pixel clock caps the max sensor readout
|
||||
# rate, which in turn caps achievable fps regardless of the
|
||||
# requested framerate below — use the sensor's max rather than
|
||||
# a hardcoded guess, since a too-low clock silently forces
|
||||
# is_SetFrameRate to negotiate down to a much lower actual fps.
|
||||
self.set_exposure(10.0) # 10ms default exposure
|
||||
self.set_pixel_clock(30) # 30MHz default pixel clock
|
||||
self.set_framerate(30.0) # 30fps default
|
||||
clock_range = self.get_pixel_clock_range()
|
||||
if clock_range is not None:
|
||||
min_clock, max_clock, _ = clock_range
|
||||
self.set_pixel_clock(max_clock)
|
||||
else:
|
||||
self.set_pixel_clock(30) # fallback if range query fails
|
||||
self.set_framerate(30.0) # 30fps default (actual may be lower)
|
||||
|
||||
return True
|
||||
|
||||
@@ -190,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
|
||||
@@ -210,20 +269,38 @@ class UC480Camera(QObject):
|
||||
self.error_occurred.emit(f"Failed to start capture: {ret}")
|
||||
return False
|
||||
|
||||
ret = ueye.is_EnableEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME)
|
||||
if ret != ueye.IS_SUCCESS:
|
||||
logger.error(f"Failed to enable frame event: {ret}")
|
||||
|
||||
self.is_capturing = True
|
||||
logger.info("Video capture started")
|
||||
return True
|
||||
|
||||
def stop_capture(self) -> bool:
|
||||
def wait_for_frame(self, timeout_ms: int = 200) -> bool:
|
||||
"""
|
||||
Stop continuous video capture.
|
||||
Block until the camera signals that a new frame has landed in
|
||||
image memory (or until timeout_ms elapses).
|
||||
|
||||
Without this, a caller polling get_frame() in a tight loop just
|
||||
re-reads the same still-unfinished/unchanged buffer as fast as the
|
||||
GIL allows — burning CPU without raising the delivered frame rate,
|
||||
and occasionally reading a frame mid-write (tearing).
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
True if a new frame arrived, False on timeout/error.
|
||||
"""
|
||||
if not self.is_capturing:
|
||||
return False
|
||||
ret = ueye.is_WaitEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME, timeout_ms)
|
||||
return ret == ueye.IS_SUCCESS
|
||||
|
||||
def stop_capture(self) -> bool:
|
||||
"""Stop continuous video capture."""
|
||||
if not self.is_capturing:
|
||||
return True
|
||||
|
||||
ueye.is_DisableEvent(self.h_cam, ueye.IS_SET_EVENT_FRAME)
|
||||
ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
|
||||
self.is_capturing = False # Always reset, even if the call fails
|
||||
if ret != ueye.IS_SUCCESS:
|
||||
@@ -233,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
|
||||
@@ -278,25 +327,23 @@ class UC480Camera(QObject):
|
||||
copy=True
|
||||
)
|
||||
|
||||
# Reshape to image dimensions
|
||||
# Reshape to image dimensions (view, no copy — array already owns
|
||||
# its memory since get_data() was called with copy=True above)
|
||||
frame = np.reshape(array, (self.height, self.width, self.bytes_per_pixel))
|
||||
|
||||
# Convert to QImage (BGR to RGB)
|
||||
height, width, channel = frame.shape
|
||||
bytes_per_line = self.bytes_per_pixel * width
|
||||
|
||||
# Convert BGR to RGB
|
||||
rgb_frame = frame[:, :, ::-1].copy()
|
||||
|
||||
q_image = QImage(
|
||||
rgb_frame.data,
|
||||
frame.data,
|
||||
width,
|
||||
height,
|
||||
bytes_per_line,
|
||||
QImage.Format.Format_RGB888
|
||||
)
|
||||
|
||||
# Make a copy since the numpy array will be deleted
|
||||
# Must copy: this QImage crosses threads via a queued signal,
|
||||
# which hands the slot a new Python wrapper around the same
|
||||
# frame that gets delivered after `frame` may already be GC'd —
|
||||
# confirmed by testing that skipping this copy corrupts pixels.
|
||||
return q_image.copy()
|
||||
|
||||
except Exception as e:
|
||||
@@ -305,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
|
||||
|
||||
@@ -333,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
|
||||
|
||||
@@ -355,16 +389,27 @@ class UC480Camera(QObject):
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_pixel_clock_range(self) -> Optional[Tuple[int, int, int]]:
|
||||
"""Query the sensor's supported pixel clock range."""
|
||||
if not self.is_initialized:
|
||||
return None
|
||||
|
||||
clock_range = (ueye.c_uint * 3)()
|
||||
ret = ueye.is_PixelClock(
|
||||
self.h_cam,
|
||||
ueye.IS_PIXELCLOCK_CMD_GET_RANGE,
|
||||
clock_range,
|
||||
ueye.sizeof(clock_range)
|
||||
)
|
||||
|
||||
if ret == ueye.IS_SUCCESS:
|
||||
return clock_range[0].value, clock_range[1].value, clock_range[2].value
|
||||
else:
|
||||
logger.error(f"Failed to get pixel clock range: {ret}")
|
||||
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
|
||||
|
||||
@@ -376,7 +421,7 @@ class UC480Camera(QObject):
|
||||
)
|
||||
|
||||
if ret == ueye.IS_SUCCESS:
|
||||
logger.debug(f"Pixel clock set to {pixel_clock_mhz}MHz")
|
||||
logger.info(f"Pixel clock set to {pixel_clock_mhz}MHz")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to set pixel clock: {ret}")
|
||||
@@ -384,7 +429,10 @@ class UC480Camera(QObject):
|
||||
|
||||
def set_framerate(self, fps: float) -> bool:
|
||||
"""
|
||||
Set camera framerate.
|
||||
Set camera framerate. The SDK negotiates the requested value against
|
||||
the current pixel clock/exposure/AOI and may return a lower actual
|
||||
rate — that negotiated value is what's logged and returned, not the
|
||||
request, since silently trusting the request hides the real cap.
|
||||
|
||||
Args:
|
||||
fps: Frames per second
|
||||
@@ -400,40 +448,20 @@ class UC480Camera(QObject):
|
||||
ret = ueye.is_SetFrameRate(self.h_cam, new_fps, actual_fps)
|
||||
|
||||
if ret == ueye.IS_SUCCESS:
|
||||
logger.debug(f"Framerate set to {fps}fps")
|
||||
if actual_fps.value < fps * 0.9:
|
||||
logger.warning(
|
||||
f"Requested {fps}fps but camera negotiated only "
|
||||
f"{actual_fps.value:.1f}fps (pixel clock/exposure/AOI-limited)"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Framerate set to {actual_fps.value:.1f}fps")
|
||||
return True
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -454,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:
|
||||
@@ -464,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 {}
|
||||
|
||||
@@ -495,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
|
||||
@@ -514,12 +532,16 @@ class CameraStreamThread(QThread):
|
||||
return
|
||||
|
||||
while self.running:
|
||||
# Block until the camera actually has a new frame ready, instead
|
||||
# of re-reading (and re-copying/re-emitting) the same buffer as
|
||||
# fast as possible. The short timeout just bounds how quickly a
|
||||
# stop() request is noticed.
|
||||
if not self.camera.wait_for_frame(200):
|
||||
continue
|
||||
|
||||
frame = self.camera.get_frame()
|
||||
if frame is not None:
|
||||
self.frame_ready.emit(frame)
|
||||
else:
|
||||
# Small delay on error to prevent CPU spinning
|
||||
self.msleep(10)
|
||||
|
||||
self.camera.stop_capture()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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'))
|
||||
Regular → Executable
@@ -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"]
|
||||
Regular → Executable
Regular → Executable
Regular → Executable
+118
-8
@@ -662,6 +662,12 @@
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="0" column="4">
|
||||
<widget class="QPushButton" name="bbd_enable_all_btn">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>36</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Toggle Axes Enable</string>
|
||||
</property>
|
||||
@@ -699,9 +705,15 @@
|
||||
</item>
|
||||
<item row="4" column="4" alignment="Qt::AlignmentFlag::AlignLeft">
|
||||
<widget class="QLabel" name="bbd_current_y_position_indicator">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>170</width>
|
||||
<height>44</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>220</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
@@ -734,6 +746,12 @@
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="bbd_home_all_btn">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>36</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Home All Axes</string>
|
||||
</property>
|
||||
@@ -741,12 +759,23 @@
|
||||
</item>
|
||||
<item row="1" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||
<widget class="QPushButton" name="bbd_jog_y_pos_btn">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>44</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<width>190</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>14</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Y+</string>
|
||||
</property>
|
||||
@@ -756,10 +785,15 @@
|
||||
<widget class="QLabel" name="label_21">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>130</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>12</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>X Position:</string>
|
||||
</property>
|
||||
@@ -770,9 +804,15 @@
|
||||
</item>
|
||||
<item row="4" column="1" alignment="Qt::AlignmentFlag::AlignLeft">
|
||||
<widget class="QLabel" name="bbd_current_x_position_indicator">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>170</width>
|
||||
<height>44</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>220</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
@@ -792,12 +832,23 @@
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="bbd_jog_x_neg_btn">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>44</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<width>190</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>14</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>X-</string>
|
||||
</property>
|
||||
@@ -818,12 +869,23 @@
|
||||
</item>
|
||||
<item row="3" column="2" alignment="Qt::AlignmentFlag::AlignHCenter">
|
||||
<widget class="QPushButton" name="bbd_jog_y_neg_btn">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>44</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<width>190</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>14</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Y-</string>
|
||||
</property>
|
||||
@@ -831,12 +893,23 @@
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QPushButton" name="bbd_jog_x_pos_btn">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>44</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>150</width>
|
||||
<width>190</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>14</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>X+</string>
|
||||
</property>
|
||||
@@ -859,10 +932,15 @@
|
||||
<widget class="QLabel" name="label_23">
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>130</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>12</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Y Position:</string>
|
||||
</property>
|
||||
@@ -873,6 +951,12 @@
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QPushButton" name="bbd_set_current_start_btn">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>36</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Set Current Coords as Start Coords</string>
|
||||
</property>
|
||||
@@ -880,6 +964,12 @@
|
||||
</item>
|
||||
<item row="5" column="4">
|
||||
<widget class="QPushButton" name="bbd_set_delta_current_btn">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>36</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Calculate Delta (Current - Start)</string>
|
||||
</property>
|
||||
@@ -907,6 +997,26 @@
|
||||
</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="start_scan_btn">
|
||||
<property name="text">
|
||||
|
||||
Regular → Executable
+16
@@ -118,6 +118,22 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pause_btn">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Noto Sans Condensed ExtraBold</family>
|
||||
<pointsize>24</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>PAUSE</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="abort_btn">
|
||||
<property name="font">
|
||||
|
||||
-2635
File diff suppressed because it is too large
Load Diff
+477
-1066
File diff suppressed because it is too large
Load Diff
Regular → Executable
+133
-91
@@ -1,19 +1,27 @@
|
||||
# SRAS Scan Binary Format — Version 4
|
||||
# SRAS Scan Binary Format — Version 6
|
||||
|
||||
Each `.sras` file contains **one complete scan**: all GR rotation angles and all
|
||||
Y rows. Files are named `{prefix}.sras`.
|
||||
|
||||
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
|
||||
`n_rows` all vary per angle. A 0°/180° scan of a wide, short ROI needs far
|
||||
fewer rows than a 45° scan of the same ROI, and the file format reflects that
|
||||
instead of forcing every angle to the largest bounding box.
|
||||
|
||||
---
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
[Global Header — 43 bytes]
|
||||
[Angle Table — n_angles × 4 bytes (float32 per angle)]
|
||||
[Row Table — n_rows × 4 bytes (float32 per row)]
|
||||
[Global Header — 49 bytes]
|
||||
[Angle Table — n_angles × 4 bytes (float32 per angle, degrees)]
|
||||
[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 — n_angles × n_rows × n_channels × n_frames × samples_per_frame × bps bytes]
|
||||
[Waveform Data (ragged) — per angle: n_rows[a] × n_channels × n_frames[a] × samples_per_frame × bps bytes]
|
||||
```
|
||||
|
||||
All multi-byte integers and floats use **big-endian** byte order
|
||||
@@ -21,33 +29,40 @@ All multi-byte integers and floats use **big-endian** byte order
|
||||
|
||||
---
|
||||
|
||||
## Global Header (42 bytes)
|
||||
## Global Header (49 bytes)
|
||||
|
||||
| Offset | Size | Type | Field | Description |
|
||||
|--------|------|-----------|--------------------|--------------------------------------------------|
|
||||
| 0 | 4 | `4s` | `magic` | Always `SRAS` (0x53 0x52 0x41 0x53) |
|
||||
| 4 | 1 | `uint8` | `version` | Format version — `4` |
|
||||
| 4 | 1 | `uint8` | `version` | Format version — `6` |
|
||||
| 5 | 2 | `uint16` | `n_angles` | Number of GR rotation angles |
|
||||
| 7 | 2 | `uint16` | `n_rows` | Number of Y rows per angle |
|
||||
| 9 | 4 | `float32` | `x_start_mm` | X scan start position in mm |
|
||||
| 13 | 4 | `float32` | `x_delta_mm` | X scan width in mm |
|
||||
| 17 | 4 | `float32` | `velocity_mm_s` | Stage scan velocity in mm/s |
|
||||
| 21 | 4 | `float32` | `laser_freq_hz` | Laser repetition rate in Hz |
|
||||
| 25 | 4 | `uint32` | `n_frames` | A-scans per row (= FastFrame count per channel) |
|
||||
| 29 | 4 | `uint32` | `samples_per_frame`| Time samples per waveform |
|
||||
| 33 | 8 | `float64` | `sample_rate_hz` | Oscilloscope sample rate in Hz (e.g. 6.25e9) |
|
||||
| 41 | 1 | `uint8` | `bytes_per_sample` | Bytes per ADC sample: `1` = int8, `2` = int16 |
|
||||
| 42 | 1 | `uint8` | `n_channels` | Number of channels recorded (currently `3`) |
|
||||
| 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 |
|
||||
| 15 | 4 | `float32` | `x_delta_nominal` | Nominal (pre-rotation) X scan width, mm |
|
||||
| 19 | 4 | `float32` | `y_delta_nominal` | Nominal (pre-rotation) Y scan height, mm |
|
||||
| 23 | 4 | `float32` | `row_spacing_mm` | Y spacing between rows, mm |
|
||||
| 27 | 4 | `float32` | `velocity_mm_s` | Stage scan velocity in mm/s |
|
||||
| 31 | 4 | `float32` | `laser_freq_hz` | Laser repetition rate in Hz |
|
||||
| 35 | 4 | `uint32` | `samples_per_frame`| Time samples per waveform |
|
||||
| 39 | 8 | `float64` | `sample_rate_hz` | Oscilloscope sample rate in Hz (e.g. 6.25e9) |
|
||||
| 47 | 1 | `uint8` | `bytes_per_sample` | Bytes per ADC sample: `1` = int8, `2` = int16 |
|
||||
| 48 | 1 | `uint8` | `n_channels` | Number of channels recorded (currently `3`) |
|
||||
|
||||
**Total header size:** 43 bytes — verified:
|
||||
`struct.calcsize(">4sBHHffffIIdBB") == 43`.
|
||||
**Total header size:** 49 bytes — verified:
|
||||
`struct.calcsize(">4sBHfffffffIdBB") == 49`.
|
||||
|
||||
The `*_nominal` fields describe the ROI as originally entered on the New Scan
|
||||
page (XS/YS/XD/YD), **before** per-angle bounding-box expansion. They are for
|
||||
reference/reconstruction only — the actual per-angle scan geometry used for
|
||||
acquisition is in the Per-Angle Geometry Table below.
|
||||
|
||||
---
|
||||
|
||||
## Angle Table
|
||||
|
||||
Immediately after the header: **n_angles** big-endian float32 values, one per
|
||||
GR angle (degrees, 0–180).
|
||||
GR angle (degrees, signed; magnitude 0–180, sign gives physical rotation
|
||||
direction — negative for the current CW-rotating GR stage).
|
||||
|
||||
```
|
||||
angle[0], angle[1], …, angle[n_angles - 1]
|
||||
@@ -55,15 +70,36 @@ angle[0], angle[1], …, angle[n_angles - 1]
|
||||
|
||||
---
|
||||
|
||||
## Row Table
|
||||
## Per-Angle Geometry Table
|
||||
|
||||
Immediately after the angle table: **n_rows** big-endian float32 values, one
|
||||
per Y row (mm).
|
||||
Immediately after the angle table: **n_angles** fixed-size records, one per
|
||||
angle (same order as the angle table), each 14 bytes:
|
||||
|
||||
| Size | Type | Field | Description |
|
||||
|------|-----------|------------|-------------------------------------------------------|
|
||||
| 4 | `float32` | `x_start` | X scan start for this angle's bounding box, mm |
|
||||
| 4 | `float32` | `x_delta` | X scan width for this angle's bounding box, mm |
|
||||
| 4 | `uint32` | `n_frames` | A-scans per row for this angle (FastFrame count) |
|
||||
| 2 | `uint16` | `n_rows` | Number of Y rows scanned for this angle |
|
||||
|
||||
Format string per record: `">ffIH"`.
|
||||
|
||||
---
|
||||
|
||||
## Row Table (ragged)
|
||||
|
||||
Immediately after the per-angle geometry table: for each angle in order,
|
||||
that angle's `n_rows` big-endian float32 Y positions (mm), concatenated with
|
||||
no padding between angles.
|
||||
|
||||
```
|
||||
y_mm[0], y_mm[1], …, y_mm[n_rows - 1]
|
||||
# angle 0's rows, then angle 1's rows, …
|
||||
y_mm[0][0], …, y_mm[0][n_rows[0]-1], y_mm[1][0], …, y_mm[n_angles-1][n_rows[-1]-1]
|
||||
```
|
||||
|
||||
Row-table boundaries for angle *a* are derived from the per-angle geometry
|
||||
table: `sum(n_rows[0:a])` gives the starting index into the flattened array.
|
||||
|
||||
---
|
||||
|
||||
## Preamble Blocks
|
||||
@@ -97,17 +133,20 @@ int8[] bg_data — raw ADC samples (same encoding as waveform data)
|
||||
|
||||
---
|
||||
|
||||
## Waveform Data
|
||||
## Waveform Data (ragged)
|
||||
|
||||
Immediately after the background block. Data is stored in **angle-major, row-minor**
|
||||
order. Within each row, channels are interleaved in ascending channel-index
|
||||
Immediately after the background block. Data is stored in **angle-major,
|
||||
row-minor** order, but unlike earlier versions each angle contributes a
|
||||
different number of rows (`n_rows[a]`) and a different number of frames per
|
||||
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 in 0 … n_angles-1:
|
||||
for row in 0 … n_rows-1:
|
||||
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-1:
|
||||
for frame in 0 … n_frames[a]-1:
|
||||
samples[0 … samples_per_frame-1] # bps bytes each
|
||||
```
|
||||
|
||||
@@ -117,90 +156,90 @@ int16**.
|
||||
|
||||
Total data size:
|
||||
```
|
||||
n_angles × n_rows × 3 × n_frames × samples_per_frame × bytes_per_sample
|
||||
sum over angles a of: 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 check
|
||||
> `file_size >= header + angle_table + row_table + data` before reshaping.
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
## Spatial Mapping
|
||||
|
||||
The *k*-th waveform (frame) in a row corresponds to the *k*-th laser pulse that
|
||||
hit the sample. The physical X position of that pulse is:
|
||||
hit the sample. For a row belonging to angle *a*, the physical X position of
|
||||
that pulse is:
|
||||
|
||||
```
|
||||
x_k = x_start_mm + k * (velocity_mm_s / laser_freq_hz)
|
||||
x_k = x_start[a] + k * (velocity_mm_s / laser_freq_hz)
|
||||
```
|
||||
|
||||
using that angle's `x_start` from the Per-Angle Geometry Table (not
|
||||
`x_start_nominal`).
|
||||
|
||||
---
|
||||
|
||||
## Python Read Example
|
||||
|
||||
```python
|
||||
import struct, numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
HDR_FMT = ">4sBHHffffIIdBB"
|
||||
HDR_SIZE = struct.calcsize(HDR_FMT) # 43 bytes
|
||||
|
||||
def read_sras(path):
|
||||
with open(path, "rb") as f:
|
||||
hdr = struct.unpack(HDR_FMT, f.read(HDR_SIZE))
|
||||
magic, ver, n_angles, n_rows, xs, xd, vel, freq, nf, spf, sr, bps, n_ch = hdr
|
||||
assert magic == b"SRAS" and ver == 4, "Not a v4 SRAS file"
|
||||
|
||||
angles = np.frombuffer(f.read(n_angles * 4), dtype=">f4")
|
||||
y_positions = np.frombuffer(f.read(n_rows * 4), dtype=">f4")
|
||||
|
||||
# Preamble blocks (one per channel)
|
||||
preambles = []
|
||||
for _ in range(n_ch):
|
||||
(plen,) = struct.unpack(">H", f.read(2))
|
||||
preambles.append(f.read(plen).decode("utf-8"))
|
||||
|
||||
# Background waveform block (v4+)
|
||||
(n_bg,) = struct.unpack(">I", f.read(4))
|
||||
background = np.frombuffer(f.read(n_bg), dtype=np.int8)
|
||||
|
||||
dtype = np.int8 if bps == 1 else ">i2"
|
||||
data = np.frombuffer(f.read(), dtype=dtype).reshape(
|
||||
n_angles, n_rows, n_ch, nf, spf
|
||||
)
|
||||
|
||||
return {
|
||||
"angles_deg": angles,
|
||||
"y_positions_mm": y_positions,
|
||||
"x_start_mm": xs,
|
||||
"x_delta_mm": xd,
|
||||
"velocity_mm_s": vel,
|
||||
"laser_freq_hz": freq,
|
||||
"sample_rate_hz": sr,
|
||||
"n_channels": n_ch, # 3: CH1, CH3, CH4 (see Acquisition Settings)
|
||||
"preambles": preambles, # WFMOutpre strings, same order as n_channels
|
||||
"background": background,# shape: (n_bg_samples,) — CH1 noise reference
|
||||
# shape: (n_angles, n_rows, n_channels, n_frames, samples_per_frame)
|
||||
"data": data,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|-----------------------|------------------------------------------|
|
||||
| Setup trigger | CH2, rising edge, 0.500 V (`TRIG_LEVEL_V`) |
|
||||
| Scan trigger | Logic AND, CH2 HIGH ∧ CH3 HIGH, 0.500 V |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
@@ -211,3 +250,6 @@ def read_sras(path):
|
||||
| 2 | One file per scan; global header with `n_angles`/`n_rows`; separate angle and row tables; three channels (CH1, CH3, CH4) per row. |
|
||||
| 3 | Added preamble blocks (WFMOutpre strings) after the row table, one length-prefixed UTF-8 block per channel. |
|
||||
| 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). |
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Scan planning and modeling modules"""
|
||||
from .sc3_scan_model import SC3ScanModel
|
||||
from .stage_scan_plan_generator import *
|
||||
@@ -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})"
|
||||
)
|
||||
@@ -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}")
|
||||
Executable
+411
@@ -0,0 +1,411 @@
|
||||
#!/opt/srasenv/bin/python3
|
||||
"""
|
||||
SRAS Scan Manager
|
||||
Command-line / interactive TUI for inspecting v6 .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.
|
||||
|
||||
Only format version 6 is supported.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from core.sras_format import GEOM_FMT, HDR_FMT, MAGIC, VERSION as BLOB_VERSION, SrasFile
|
||||
|
||||
|
||||
@dataclass
|
||||
class AngleEntry:
|
||||
index: int
|
||||
angle_deg: float
|
||||
x_start: float
|
||||
x_delta: float
|
||||
n_frames: int
|
||||
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
|
||||
n_rows_available: int = 0
|
||||
data_size_available: int = 0
|
||||
complete: bool = True
|
||||
|
||||
@property
|
||||
def data_size_declared(self) -> int:
|
||||
return self.row_bytes * self.n_rows_declared
|
||||
|
||||
|
||||
class SrasScanFile:
|
||||
"""Parsed view of a v6 .sras file's header/tables plus per-angle data offsets."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self._parse()
|
||||
|
||||
def _parse(self):
|
||||
sras = SrasFile(self.path)
|
||||
h = sras.header
|
||||
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.background_raw = sras.background
|
||||
self.data_start_offset = sras.data_start_offset
|
||||
self.file_size = sras.file_size
|
||||
|
||||
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,
|
||||
data_offset=st.data_offset,
|
||||
n_rows_available=st.n_rows_available,
|
||||
data_size_available=st.n_rows_available * st.row_bytes,
|
||||
complete=st.complete,
|
||||
)
|
||||
for pa, st in zip(sras.per_angle, sras.angle_status(), strict=True)
|
||||
]
|
||||
|
||||
def get(self, index: int) -> AngleEntry:
|
||||
return self.angles[index]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export / delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_subset(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
||||
"""Write a new v6 .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).
|
||||
"""
|
||||
warnings = []
|
||||
selected = [sf.get(i) for i in indices]
|
||||
|
||||
header = struct.pack(
|
||||
HDR_FMT, MAGIC, BLOB_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,
|
||||
sf.samples_per_frame, sf.sample_rate_hz,
|
||||
sf.bytes_per_sample, sf.n_channels,
|
||||
)
|
||||
|
||||
with open(sf.path, "rb") as src, open(dst_path, "wb") as dst:
|
||||
dst.write(header)
|
||||
dst.write(struct.pack(f">{len(selected)}f", *[e.angle_deg for e in selected]))
|
||||
|
||||
for e in selected:
|
||||
if e.n_rows_available != e.n_rows_declared:
|
||||
warnings.append(
|
||||
f"angle[{e.index}] ({e.angle_deg:.2f} deg): declared "
|
||||
f"{e.n_rows_declared} rows but only {e.n_rows_available} "
|
||||
f"present on disk — exporting truncated")
|
||||
dst.write(struct.pack(GEOM_FMT, e.x_start, e.x_delta, e.n_frames,
|
||||
e.n_rows_available))
|
||||
|
||||
for e in selected:
|
||||
ys = e.y_positions[:e.n_rows_available]
|
||||
dst.write(struct.pack(f">{len(ys)}f", *ys))
|
||||
|
||||
for praw in sf.preambles_raw:
|
||||
dst.write(struct.pack(">H", len(praw)))
|
||||
dst.write(praw)
|
||||
|
||||
dst.write(struct.pack(">I", len(sf.background_raw)))
|
||||
dst.write(sf.background_raw)
|
||||
|
||||
for e in selected:
|
||||
src.seek(e.data_offset)
|
||||
remaining = e.data_size_available
|
||||
chunk_size = 1 << 20
|
||||
while remaining > 0:
|
||||
chunk = src.read(min(chunk_size, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
dst.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def export_angles(sf: SrasScanFile, indices: list, dst_path: Path) -> list:
|
||||
if not indices:
|
||||
raise ValueError("no angles selected to export")
|
||||
return _write_subset(sf, indices, dst_path)
|
||||
|
||||
|
||||
def delete_angles(sf: SrasScanFile, indices_to_delete: list, backup: bool = True) -> Path | None:
|
||||
"""Rewrite sf.path in place, keeping every angle NOT in indices_to_delete.
|
||||
|
||||
Returns the backup file path if one was made, else None.
|
||||
"""
|
||||
keep = [e.index for e in sf.angles if e.index not in set(indices_to_delete)]
|
||||
if not keep:
|
||||
raise ValueError("refusing to delete every angle — a .sras file needs at least one")
|
||||
|
||||
tmp_path = sf.path.with_suffix(sf.path.suffix + ".tmp")
|
||||
_write_subset(sf, keep, tmp_path)
|
||||
|
||||
backup_path = None
|
||||
if backup:
|
||||
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
backup_path = sf.path.with_name(f"{sf.path.stem}.bak-{stamp}{sf.path.suffix}")
|
||||
sf.path.rename(backup_path)
|
||||
|
||||
tmp_path.replace(sf.path)
|
||||
return backup_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _human_size(n: int) -> str:
|
||||
size = float(n)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} PB"
|
||||
|
||||
|
||||
def parse_index_spec(spec: str, max_index: int) -> list:
|
||||
"""Parse '0,2,4-6' or 'all' into a sorted list of unique in-range indices."""
|
||||
spec = spec.strip().lower()
|
||||
if spec in ("all", "*"):
|
||||
return list(range(max_index + 1))
|
||||
if not spec:
|
||||
return []
|
||||
out = set()
|
||||
for part in spec.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
lo, hi = part.split("-", 1)
|
||||
lo, hi = int(lo), int(hi)
|
||||
if lo > hi:
|
||||
lo, hi = hi, lo
|
||||
for i in range(lo, hi + 1):
|
||||
out.add(i)
|
||||
else:
|
||||
out.add(int(part))
|
||||
bad = [i for i in out if i < 0 or i > max_index]
|
||||
if bad:
|
||||
raise ValueError(f"index out of range (0-{max_index}): {sorted(bad)}")
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def print_summary(sf: SrasScanFile, selected: set):
|
||||
print()
|
||||
print(f"File: {sf.path} (v{BLOB_VERSION}, {_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")
|
||||
print(f"Velocity={sf.velocity_mm_s:.2f} mm/s Laser={sf.laser_freq_hz:.1f} Hz "
|
||||
f"Samples/frame={sf.samples_per_frame} Sample rate={sf.sample_rate_hz:.3e} Hz "
|
||||
f"Channels={sf.n_channels} Bytes/sample={sf.bytes_per_sample}")
|
||||
print()
|
||||
hdr = f"{'':>2} {'#':>3} {'Angle(deg)':>10} {'Rows':>7} {'Frames/row':>10} {'x_start':>9} {'x_delta':>9} {'Data size':>11} Status"
|
||||
print(hdr)
|
||||
print("-" * len(hdr))
|
||||
for e in sf.angles:
|
||||
mark = "*" if e.index in selected else " "
|
||||
if e.complete:
|
||||
status = "OK"
|
||||
elif e.n_rows_available == 0:
|
||||
status = "MISSING (no data on disk)"
|
||||
else:
|
||||
status = f"TRUNCATED ({e.n_rows_available}/{e.n_rows_declared} rows on disk)"
|
||||
print(f"{mark:>2} {e.index:>3} {e.angle_deg:>10.2f} {e.n_rows_declared:>7} "
|
||||
f"{e.n_frames:>10} {e.x_start:>9.3f} {e.x_delta:>9.3f} "
|
||||
f"{_human_size(e.data_size_available):>11} {status}")
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive TUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def interactive_loop(path: Path):
|
||||
sf = SrasScanFile(path)
|
||||
selected: set = set()
|
||||
|
||||
help_text = (
|
||||
" [l] list show the angle table again\n"
|
||||
" [s] select <spec> set selection, e.g. '0,2,4-6' or 'all' or 'none'\n"
|
||||
" [e] export <path> write selected angles to a new .sras file\n"
|
||||
" [d] delete remove selected angles from this file in place\n"
|
||||
" [r] reload re-read the file from disk (after external changes)\n"
|
||||
" [h] help show this help\n"
|
||||
" [q] quit"
|
||||
)
|
||||
|
||||
print_summary(sf, selected)
|
||||
print(help_text)
|
||||
|
||||
while True:
|
||||
try:
|
||||
cmd_line = input("\nsras> ").strip()
|
||||
except EOFError:
|
||||
print()
|
||||
break
|
||||
if not cmd_line:
|
||||
continue
|
||||
parts = cmd_line.split(None, 1)
|
||||
cmd = parts[0].lower()
|
||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
try:
|
||||
if cmd in ("q", "quit", "exit"):
|
||||
break
|
||||
elif cmd in ("h", "help", "?"):
|
||||
print(help_text)
|
||||
elif cmd in ("l", "list"):
|
||||
print_summary(sf, selected)
|
||||
elif cmd in ("s", "select"):
|
||||
if not arg:
|
||||
arg = input("Angles to select (e.g. 0,2,4-6 / all / none): ").strip()
|
||||
if arg.lower() == "none":
|
||||
selected = set()
|
||||
else:
|
||||
selected = set(parse_index_spec(arg, len(sf.angles) - 1))
|
||||
print(f"Selected {len(selected)} angle(s): {sorted(selected)}")
|
||||
elif cmd in ("e", "export"):
|
||||
if not selected:
|
||||
print("Nothing selected — use 's' first.")
|
||||
continue
|
||||
dst = arg or input("Output path: ").strip()
|
||||
if not dst:
|
||||
print("Export cancelled — no path given.")
|
||||
continue
|
||||
dst_path = Path(dst)
|
||||
if dst_path.exists():
|
||||
ans = input(f"{dst_path} exists — overwrite? [y/N] ").strip().lower()
|
||||
if ans != "y":
|
||||
print("Export cancelled.")
|
||||
continue
|
||||
warnings = export_angles(sf, sorted(selected), dst_path)
|
||||
print(f"Exported {len(selected)} angle(s) -> {dst_path}")
|
||||
for w in warnings:
|
||||
print(f" warning: {w}")
|
||||
elif cmd in ("d", "delete"):
|
||||
if not selected:
|
||||
print("Nothing selected — use 's' first.")
|
||||
continue
|
||||
print(f"About to delete {len(selected)} angle(s) from {sf.path}: {sorted(selected)}")
|
||||
ans = input("Type 'yes' to confirm (a timestamped .bak copy will be kept): ").strip()
|
||||
if ans != "yes":
|
||||
print("Delete cancelled.")
|
||||
continue
|
||||
backup_path = delete_angles(sf, sorted(selected), backup=True)
|
||||
print(f"Deleted. Backup saved to {backup_path}")
|
||||
sf = SrasScanFile(sf.path)
|
||||
selected = set()
|
||||
print_summary(sf, selected)
|
||||
elif cmd in ("r", "reload"):
|
||||
sf = SrasScanFile(sf.path)
|
||||
selected = set()
|
||||
print_summary(sf, selected)
|
||||
else:
|
||||
print(f"Unknown command: {cmd!r} (type 'h' for help)")
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Inspect, export, or delete per-angle sub-scans in a v6 .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'")
|
||||
ap.add_argument("--output", metavar="PATH", type=Path, help="destination path for --export")
|
||||
ap.add_argument("--delete", metavar="SPEC", help="angle index spec to delete in place, e.g. '1,3'")
|
||||
ap.add_argument("--no-backup", action="store_true", help="skip the .bak copy when using --delete")
|
||||
ap.add_argument("--yes", action="store_true", help="don't prompt for confirmation on --delete")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.file.exists():
|
||||
print(f"error: {args.file} does not exist", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
sf = SrasScanFile(args.file)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
non_interactive = args.list or args.export or args.delete
|
||||
|
||||
if args.list:
|
||||
print_summary(sf, set())
|
||||
|
||||
try:
|
||||
if args.export:
|
||||
indices = parse_index_spec(args.export, len(sf.angles) - 1)
|
||||
if not args.output:
|
||||
print("error: --export requires --output", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.output.exists() and not args.yes:
|
||||
ans = input(f"{args.output} exists — overwrite? [y/N] ").strip().lower()
|
||||
if ans != "y":
|
||||
print("Export cancelled.")
|
||||
sys.exit(1)
|
||||
warnings = export_angles(sf, indices, args.output)
|
||||
print(f"Exported {len(indices)} angle(s) -> {args.output}")
|
||||
for w in warnings:
|
||||
print(f" warning: {w}")
|
||||
|
||||
if args.delete:
|
||||
indices = parse_index_spec(args.delete, len(sf.angles) - 1)
|
||||
if not indices:
|
||||
print("error: --delete requires a non-empty angle spec", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not args.yes:
|
||||
print(f"About to delete {len(indices)} angle(s) from {sf.path}: {indices}")
|
||||
ans = input("Type 'yes' to confirm: ").strip()
|
||||
if ans != "yes":
|
||||
print("Delete cancelled.")
|
||||
sys.exit(1)
|
||||
backup_path = delete_angles(sf, indices, backup=not args.no_backup)
|
||||
if backup_path:
|
||||
print(f"Deleted. Backup saved to {backup_path}")
|
||||
else:
|
||||
print("Deleted (no backup kept).")
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not non_interactive:
|
||||
interactive_loop(args.file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Regular → Executable
+457
-882
File diff suppressed because it is too large
Load Diff
Regular → Executable
+1
@@ -1,3 +1,4 @@
|
||||
PyQt6==6.10.2
|
||||
numpy==2.4.1
|
||||
matplotlib==3.10.8
|
||||
scipy==1.16.3
|
||||
|
||||
Executable
+715
@@ -0,0 +1,715 @@
|
||||
"""T3R Focusing & Rotation Control Panel.
|
||||
|
||||
A user-hidable QDialog that provides full control over the T3R four-channel
|
||||
stepper controller. Mirrors the functionality of /opt/t3r-firmware/tester.py
|
||||
and adds a Stage Rotation section that computes the correct GR-axis step count
|
||||
from the physical gear train.
|
||||
|
||||
Gear train (ch3 = GR-axis):
|
||||
Motor → 10T pinion → 30T idler → 125T index gear (stage)
|
||||
Motor:stage ratio = 125/10 = 12.5
|
||||
|
||||
Usage::
|
||||
|
||||
from t3r_control_panel import T3RControlPanel
|
||||
panel = T3RControlPanel(driver)
|
||||
panel.show()
|
||||
# toggle with panel.setVisible(not panel.isVisible())
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFrame, QGridLayout,
|
||||
QGroupBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QScrollArea,
|
||||
QSpinBox, QSplitter, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _hline() -> QFrame:
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
return line
|
||||
|
||||
|
||||
def _mono_font(size: int = 11) -> QFont:
|
||||
f = QFont("Menlo")
|
||||
f.setStyleHint(QFont.StyleHint.Monospace)
|
||||
f.setPointSize(size)
|
||||
return f
|
||||
|
||||
|
||||
def _spin(lo: int, hi: int, val: int) -> QSpinBox:
|
||||
s = QSpinBox()
|
||||
s.setRange(lo, hi)
|
||||
s.setValue(val)
|
||||
s.setGroupSeparatorShown(True)
|
||||
s.setMaximumWidth(130)
|
||||
return s
|
||||
|
||||
|
||||
# ── Per-channel panel ─────────────────────────────────────────────────────────
|
||||
|
||||
class ChannelPanel(QGroupBox):
|
||||
"""Controls and live readouts for one T3R axis."""
|
||||
|
||||
def __init__(self, ch: int, driver: QtT3RAdapter):
|
||||
label = f"Axis {ch} — {T3RDriver.CHANNEL_NAMES[ch]}"
|
||||
super().__init__(label)
|
||||
self.ch = ch
|
||||
self._driver = driver
|
||||
self._build()
|
||||
|
||||
driver.info_updated.connect(self._on_info)
|
||||
driver.drv_status_updated.connect(self._on_drv_status)
|
||||
driver.motion_done.connect(self._on_event_pos)
|
||||
driver.stopped.connect(self._on_event_pos)
|
||||
driver.fault_occurred.connect(self._on_fault_event)
|
||||
|
||||
def _build(self):
|
||||
grid = QGridLayout(self)
|
||||
grid.setVerticalSpacing(4)
|
||||
grid.setHorizontalSpacing(8)
|
||||
row = 0
|
||||
|
||||
# Enable + state + position
|
||||
self.enable_chk = QCheckBox("Enabled")
|
||||
self.enable_chk.toggled.connect(self._on_enable_toggled)
|
||||
grid.addWidget(self.enable_chk, row, 0)
|
||||
|
||||
self.state_lbl = QLabel("—")
|
||||
self.state_lbl.setMinimumWidth(72)
|
||||
grid.addWidget(self.state_lbl, row, 1)
|
||||
|
||||
lbl = QLabel("pos:")
|
||||
lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
grid.addWidget(lbl, row, 2)
|
||||
self.pos_lbl = QLabel("0")
|
||||
self.pos_lbl.setFont(_mono_font(13))
|
||||
grid.addWidget(self.pos_lbl, row, 3, 1, 2)
|
||||
row += 1
|
||||
|
||||
self.fault_lbl = QLabel("faults: none")
|
||||
self.fault_lbl.setStyleSheet("color: #2e7d32;")
|
||||
grid.addWidget(self.fault_lbl, row, 0, 1, 3)
|
||||
self.comms_lbl = QLabel("comms: —")
|
||||
grid.addWidget(self.comms_lbl, row, 3, 1, 2)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_hline(), row, 0, 1, 5); row += 1
|
||||
|
||||
# Configuration
|
||||
grid.addWidget(QLabel("Microsteps"), row, 0)
|
||||
self.micro_combo = QComboBox()
|
||||
for m in proto.MICROSTEPS:
|
||||
self.micro_combo.addItem(str(m), m)
|
||||
self.micro_combo.setCurrentText("16")
|
||||
grid.addWidget(self.micro_combo, row, 1)
|
||||
|
||||
grid.addWidget(QLabel("Run mA"), row, 2)
|
||||
self.run_spin = _spin(0, 3000, 800)
|
||||
grid.addWidget(self.run_spin, row, 3)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(QLabel("ihold delay"), row, 0)
|
||||
self.ihold_spin = _spin(0, 15, 6)
|
||||
grid.addWidget(self.ihold_spin, row, 1)
|
||||
|
||||
grid.addWidget(QLabel("Hold mA"), row, 2)
|
||||
self.hold_spin = _spin(0, 3000, 400)
|
||||
grid.addWidget(self.hold_spin, row, 3)
|
||||
|
||||
apply_btn = QPushButton("Apply config")
|
||||
apply_btn.setToolTip("Send SET_MICROSTEP + SET_CURRENT (channel must be idle)")
|
||||
apply_btn.clicked.connect(self._on_apply_config)
|
||||
grid.addWidget(apply_btn, row, 4)
|
||||
row += 1
|
||||
|
||||
self.achieved_lbl = QLabel("achieved: run —/hold — mA, — µsteps")
|
||||
self.achieved_lbl.setStyleSheet("color: gray;")
|
||||
grid.addWidget(self.achieved_lbl, row, 0, 1, 5)
|
||||
row += 1
|
||||
|
||||
self.drv_lbl = QLabel("driver status: —")
|
||||
self.drv_lbl.setStyleSheet("color: gray;")
|
||||
grid.addWidget(self.drv_lbl, row, 0, 1, 5)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_hline(), row, 0, 1, 5); row += 1
|
||||
|
||||
# Motion parameters
|
||||
grid.addWidget(QLabel("Max vel (steps/s)"), row, 0)
|
||||
self.vel_spin = _spin(1, proto.MAX_VELOCITY, 8000)
|
||||
grid.addWidget(self.vel_spin, row, 1)
|
||||
|
||||
grid.addWidget(QLabel("Accel (steps/s²)"), row, 2)
|
||||
self.accel_spin = _spin(0, proto.MAX_ACCEL, 4000)
|
||||
grid.addWidget(self.accel_spin, row, 3)
|
||||
row += 1
|
||||
|
||||
# Move
|
||||
grid.addWidget(QLabel("Steps (signed)"), row, 0)
|
||||
self.steps_spin = _spin(-100_000_000, 100_000_000, 3200)
|
||||
grid.addWidget(self.steps_spin, row, 1)
|
||||
move_btn = QPushButton("Move")
|
||||
move_btn.clicked.connect(self._on_move)
|
||||
grid.addWidget(move_btn, row, 2)
|
||||
neg_btn = QPushButton("Move −")
|
||||
neg_btn.clicked.connect(lambda: self._on_move(-1))
|
||||
grid.addWidget(neg_btn, row, 3)
|
||||
pos_btn = QPushButton("Move +")
|
||||
pos_btn.clicked.connect(lambda: self._on_move(+1))
|
||||
grid.addWidget(pos_btn, row, 4)
|
||||
row += 1
|
||||
|
||||
# Jog / stop
|
||||
jogneg = QPushButton("◀ Jog −")
|
||||
jogneg.clicked.connect(lambda: self._on_jog(-1))
|
||||
grid.addWidget(jogneg, row, 0)
|
||||
jogpos = QPushButton("Jog + ▶")
|
||||
jogpos.clicked.connect(lambda: self._on_jog(+1))
|
||||
grid.addWidget(jogpos, row, 1)
|
||||
stop_btn = QPushButton("Stop")
|
||||
stop_btn.clicked.connect(self._on_stop)
|
||||
grid.addWidget(stop_btn, row, 2)
|
||||
self.hard_chk = QCheckBox("hard stop")
|
||||
grid.addWidget(self.hard_chk, row, 3, 1, 2)
|
||||
row += 1
|
||||
|
||||
grid.addWidget(_hline(), row, 0, 1, 5); row += 1
|
||||
|
||||
# Position utilities
|
||||
grid.addWidget(QLabel("Set pos"), row, 0)
|
||||
self.setpos_spin = _spin(-100_000_000, 100_000_000, 0)
|
||||
grid.addWidget(self.setpos_spin, row, 1)
|
||||
setpos_btn = QPushButton("Set")
|
||||
setpos_btn.clicked.connect(self._on_set_position)
|
||||
grid.addWidget(setpos_btn, row, 2)
|
||||
zero_btn = QPushButton("Zero")
|
||||
zero_btn.clicked.connect(lambda: self._driver.set_position(self.ch, 0))
|
||||
grid.addWidget(zero_btn, row, 3)
|
||||
drvst_btn = QPushButton("Driver status")
|
||||
drvst_btn.setToolTip("GET_DRV_STATUS — SPI read, only works while idle")
|
||||
drvst_btn.clicked.connect(lambda: self._driver.get_drv_status(self.ch))
|
||||
grid.addWidget(drvst_btn, row, 4)
|
||||
|
||||
grid.setColumnStretch(4, 1)
|
||||
|
||||
# ── Commands ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_enable_toggled(self, checked: bool):
|
||||
if checked:
|
||||
self._driver.enable(self.ch)
|
||||
else:
|
||||
self._driver.disable(self.ch)
|
||||
|
||||
def _on_apply_config(self):
|
||||
micro = self.micro_combo.currentData()
|
||||
self._driver.set_microstep(self.ch, micro)
|
||||
self._driver.set_current(self.ch, self.run_spin.value(),
|
||||
self.hold_spin.value(), self.ihold_spin.value())
|
||||
|
||||
def _on_move(self, force_sign: int = 0):
|
||||
steps = self.steps_spin.value()
|
||||
if force_sign:
|
||||
steps = force_sign * abs(steps)
|
||||
self._driver.move(self.ch, steps, self.vel_spin.value(), self.accel_spin.value())
|
||||
|
||||
def _on_jog(self, direction: int):
|
||||
self._driver.jog(self.ch, direction * self.vel_spin.value(), self.accel_spin.value())
|
||||
|
||||
def _on_stop(self):
|
||||
self._driver.stop(self.ch, self.hard_chk.isChecked())
|
||||
|
||||
def _on_set_position(self):
|
||||
self._driver.set_position(self.ch, self.setpos_spin.value())
|
||||
|
||||
# ── Incoming updates ──────────────────────────────────────────────────────
|
||||
|
||||
def _on_info(self, ch: int, info):
|
||||
if ch != self.ch:
|
||||
return
|
||||
self.state_lbl.setText(proto.STATE_NAMES.get(info.state, "?"))
|
||||
self.pos_lbl.setText(f"{info.position:,}")
|
||||
self.achieved_lbl.setText(
|
||||
f"achieved: run {info.run_ma}/hold {info.hold_ma} mA, {info.microsteps} µsteps")
|
||||
self.comms_lbl.setText("comms: OK" if info.comms_ok else "comms: FAIL")
|
||||
self.comms_lbl.setStyleSheet("" if info.comms_ok else "color: #c0392b;")
|
||||
self._set_fault(info.fault_mask)
|
||||
if self.enable_chk.isChecked() != info.enabled:
|
||||
self.enable_chk.blockSignals(True)
|
||||
self.enable_chk.setChecked(info.enabled)
|
||||
self.enable_chk.blockSignals(False)
|
||||
|
||||
def _on_drv_status(self, ch: int, st):
|
||||
if ch != self.ch:
|
||||
return
|
||||
self._set_fault(st.fault_mask)
|
||||
self.drv_lbl.setText(
|
||||
f"driver status: CS_ACTUAL={st.cs_actual} SG_RESULT={st.sg_result} "
|
||||
f"{'standstill' if st.standstill else 'moving'} raw=0x{st.raw:08X}")
|
||||
|
||||
def _on_event_pos(self, ch: int, position: int):
|
||||
if ch == self.ch:
|
||||
self.pos_lbl.setText(f"{position:,}")
|
||||
|
||||
def _on_fault_event(self, ch: int, mask: int):
|
||||
if ch == self.ch:
|
||||
self._set_fault(mask)
|
||||
|
||||
def _set_fault(self, mask: int):
|
||||
names = proto.fault_names(mask)
|
||||
self.fault_lbl.setText(f"faults: {names}")
|
||||
self.fault_lbl.setStyleSheet(
|
||||
"color: #c0392b; font-weight: bold;" if mask else "color: #2e7d32;")
|
||||
|
||||
|
||||
# ── Ganged motion panel ───────────────────────────────────────────────────────
|
||||
|
||||
class GroupPanel(QGroupBox):
|
||||
"""Ganged motion — selected axes step in lockstep."""
|
||||
|
||||
def __init__(self, driver: QtT3RAdapter, log_fn):
|
||||
super().__init__("Ganged / synchronised motion — selected axes move in lockstep")
|
||||
self._driver = driver
|
||||
self._log = log_fn
|
||||
self._axis_chks: list[QCheckBox] = []
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
grid = QGridLayout(self)
|
||||
grid.setVerticalSpacing(4)
|
||||
grid.setHorizontalSpacing(8)
|
||||
|
||||
grid.addWidget(QLabel("Axes:"), 0, 0)
|
||||
axis_box = QHBoxLayout()
|
||||
for i in range(proto.NUM_CHANNELS):
|
||||
chk = QCheckBox(str(i))
|
||||
self._axis_chks.append(chk)
|
||||
axis_box.addWidget(chk)
|
||||
axis_box.addStretch(1)
|
||||
holder = QWidget()
|
||||
holder.setLayout(axis_box)
|
||||
grid.addWidget(holder, 0, 1, 1, 3)
|
||||
|
||||
hold_btn = QPushButton("Energise selected")
|
||||
hold_btn.clicked.connect(self._on_energise)
|
||||
grid.addWidget(hold_btn, 0, 4)
|
||||
rel_btn = QPushButton("Release all")
|
||||
rel_btn.clicked.connect(lambda: self._driver.enable_mask(0))
|
||||
grid.addWidget(rel_btn, 0, 5)
|
||||
|
||||
grid.addWidget(QLabel("Max vel (steps/s)"), 1, 0)
|
||||
self.vel_spin = _spin(1, proto.MAX_VELOCITY, 8000)
|
||||
grid.addWidget(self.vel_spin, 1, 1)
|
||||
grid.addWidget(QLabel("Accel (steps/s²)"), 1, 2)
|
||||
self.accel_spin = _spin(0, proto.MAX_ACCEL, 4000)
|
||||
grid.addWidget(self.accel_spin, 1, 3)
|
||||
grid.addWidget(QLabel("Steps (signed)"), 1, 4)
|
||||
self.steps_spin = _spin(-100_000_000, 100_000_000, 3200)
|
||||
grid.addWidget(self.steps_spin, 1, 5)
|
||||
|
||||
move_btn = QPushButton("Move group")
|
||||
move_btn.clicked.connect(lambda: self._on_move())
|
||||
grid.addWidget(move_btn, 2, 0)
|
||||
neg_btn = QPushButton("Move −")
|
||||
neg_btn.clicked.connect(lambda: self._on_move(-1))
|
||||
grid.addWidget(neg_btn, 2, 1)
|
||||
pos_btn = QPushButton("Move +")
|
||||
pos_btn.clicked.connect(lambda: self._on_move(+1))
|
||||
grid.addWidget(pos_btn, 2, 2)
|
||||
jogneg = QPushButton("◀ Jog −")
|
||||
jogneg.clicked.connect(lambda: self._on_jog(-1))
|
||||
grid.addWidget(jogneg, 2, 3)
|
||||
jogpos = QPushButton("Jog + ▶")
|
||||
jogpos.clicked.connect(lambda: self._on_jog(+1))
|
||||
grid.addWidget(jogpos, 2, 4)
|
||||
stop_btn = QPushButton("Stop group")
|
||||
stop_btn.clicked.connect(self._on_stop)
|
||||
grid.addWidget(stop_btn, 2, 5)
|
||||
|
||||
self.hard_chk = QCheckBox("hard stop")
|
||||
grid.addWidget(self.hard_chk, 3, 5)
|
||||
grid.setColumnStretch(3, 1)
|
||||
|
||||
def _mask(self) -> int:
|
||||
return sum((1 << i) for i, chk in enumerate(self._axis_chks) if chk.isChecked())
|
||||
|
||||
def _require_mask(self) -> int | None:
|
||||
mask = self._mask()
|
||||
if not mask:
|
||||
self._log("No axes selected for ganged motion", "err")
|
||||
return None
|
||||
return mask
|
||||
|
||||
def _on_energise(self):
|
||||
mask = self._require_mask()
|
||||
if mask is not None:
|
||||
self._driver.enable_mask(mask)
|
||||
|
||||
def _on_move(self, force_sign: int = 0):
|
||||
mask = self._require_mask()
|
||||
if mask is None:
|
||||
return
|
||||
steps = self.steps_spin.value()
|
||||
if force_sign:
|
||||
steps = force_sign * abs(steps)
|
||||
self._driver.move_group(mask, steps, self.vel_spin.value(), self.accel_spin.value())
|
||||
|
||||
def _on_jog(self, direction: int):
|
||||
mask = self._require_mask()
|
||||
if mask is None:
|
||||
return
|
||||
self._driver.jog_group(mask, direction * self.vel_spin.value(), self.accel_spin.value())
|
||||
|
||||
def _on_stop(self):
|
||||
mask = self._require_mask()
|
||||
if mask is None:
|
||||
return
|
||||
ch = (mask & -mask).bit_length() - 1
|
||||
self._driver.stop(ch, self.hard_chk.isChecked())
|
||||
|
||||
|
||||
# ── Rotation panel ────────────────────────────────────────────────────────────
|
||||
|
||||
class RotationPanel(QGroupBox):
|
||||
"""Stage rotation via GR-axis (ch3).
|
||||
|
||||
Computes the GR-axis step count from the physical gear train:
|
||||
Motor → 10T pinion → 30T idler → 125T index gear (stage)
|
||||
Ratio = 125/10 = 12.5
|
||||
"""
|
||||
|
||||
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 → "
|
||||
f"{T3RDriver.GEAR_TEETH_STAGE}T stage "
|
||||
f"= {T3RDriver.GEAR_TEETH_STAGE}/{T3RDriver.GEAR_TEETH_MOTOR} ratio"
|
||||
)
|
||||
self._driver = driver
|
||||
self._gr_microsteps = 16 # updated from info_updated signal
|
||||
self._build()
|
||||
driver.info_updated.connect(self._on_info)
|
||||
|
||||
def _build(self):
|
||||
grid = QGridLayout(self)
|
||||
grid.setVerticalSpacing(4)
|
||||
grid.setHorizontalSpacing(8)
|
||||
|
||||
# Microsteps (read-only, tracked from driver)
|
||||
grid.addWidget(QLabel("GR-axis µsteps:"), 0, 0)
|
||||
self.microstep_lbl = QLabel("16 (live from device)")
|
||||
self.microstep_lbl.setStyleSheet("color: gray;")
|
||||
grid.addWidget(self.microstep_lbl, 0, 1)
|
||||
|
||||
# Angle input
|
||||
grid.addWidget(QLabel("Rotate by (°):"), 0, 2)
|
||||
self.angle_spin = QDoubleSpinBox()
|
||||
self.angle_spin.setRange(-360.0, 360.0)
|
||||
self.angle_spin.setSingleStep(1.0)
|
||||
self.angle_spin.setDecimals(3)
|
||||
self.angle_spin.setValue(90.0)
|
||||
self.angle_spin.valueChanged.connect(self._update_steps_display)
|
||||
grid.addWidget(self.angle_spin, 0, 3)
|
||||
|
||||
self.steps_lbl = QLabel("= — steps")
|
||||
self.steps_lbl.setFont(_mono_font(11))
|
||||
grid.addWidget(self.steps_lbl, 0, 4)
|
||||
|
||||
# Velocity / accel
|
||||
grid.addWidget(QLabel("Velocity (steps/s):"), 1, 0)
|
||||
self.vel_spin = _spin(1, proto.MAX_VELOCITY, 8000)
|
||||
grid.addWidget(self.vel_spin, 1, 1)
|
||||
|
||||
grid.addWidget(QLabel("Accel (steps/s²):"), 1, 2)
|
||||
self.accel_spin = _spin(0, proto.MAX_ACCEL, 4000)
|
||||
grid.addWidget(self.accel_spin, 1, 3)
|
||||
|
||||
# Preset angles for N-angle scans
|
||||
grid.addWidget(QLabel("Quick presets:"), 2, 0)
|
||||
presets = QHBoxLayout()
|
||||
for n in (2, 4, 6, 8, 12):
|
||||
btn = QPushButton(f"360/{n}")
|
||||
btn.setToolTip(f"{360/n:.3f}° — rotate for {n}-angle scan")
|
||||
btn.clicked.connect(lambda _, ang=360.0/n: self.angle_spin.setValue(ang))
|
||||
presets.addWidget(btn)
|
||||
presets.addStretch(1)
|
||||
presets_w = QWidget()
|
||||
presets_w.setLayout(presets)
|
||||
grid.addWidget(presets_w, 2, 1, 1, 3)
|
||||
|
||||
# Execute
|
||||
rotate_btn = QPushButton("Rotate Stage")
|
||||
rotate_btn.setStyleSheet(
|
||||
"QPushButton { background: #1565c0; color: white; font-weight: bold; padding: 4px 14px; }"
|
||||
"QPushButton:disabled { background: #90a4ae; }")
|
||||
rotate_btn.clicked.connect(self._on_rotate)
|
||||
grid.addWidget(rotate_btn, 2, 4)
|
||||
|
||||
grid.setColumnStretch(4, 1)
|
||||
self._update_steps_display()
|
||||
|
||||
def _on_info(self, ch: int, info):
|
||||
if ch != T3RDriver.GR_AXIS_CH:
|
||||
return
|
||||
self._gr_microsteps = info.microsteps
|
||||
self.microstep_lbl.setText(f"{info.microsteps} µsteps (from device)")
|
||||
self._update_steps_display()
|
||||
|
||||
def _update_steps_display(self):
|
||||
steps = self._driver.steps_for_angle(self.angle_spin.value(), self._gr_microsteps)
|
||||
self.steps_lbl.setText(f"= {steps:,} steps")
|
||||
|
||||
def _on_rotate(self):
|
||||
self._driver.rotate_stage(
|
||||
self.angle_spin.value(), self._gr_microsteps,
|
||||
self.vel_spin.value(), self.accel_spin.value())
|
||||
|
||||
|
||||
# ── Main dialog ───────────────────────────────────────────────────────────────
|
||||
|
||||
class T3RControlPanel(QDialog):
|
||||
"""User-hidable T3R control window.
|
||||
|
||||
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: QtT3RAdapter, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("T3R Stepper Controller")
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Window
|
||||
| Qt.WindowType.WindowCloseButtonHint
|
||||
| Qt.WindowType.WindowMinimizeButtonHint
|
||||
)
|
||||
self._driver = driver
|
||||
self._build()
|
||||
self._connect_driver_signals()
|
||||
self._set_controls_enabled(False)
|
||||
|
||||
# ── Construction ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build(self):
|
||||
outer = QVBoxLayout(self)
|
||||
|
||||
outer.addLayout(self._build_connection_bar())
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
|
||||
panels_host = QWidget()
|
||||
vbox = QVBoxLayout(panels_host)
|
||||
self.group_panel = GroupPanel(self._driver, self._log)
|
||||
vbox.addWidget(self.group_panel)
|
||||
|
||||
grid_holder = QWidget()
|
||||
grid = QGridLayout(grid_holder)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
self.channel_panels: list[ChannelPanel] = []
|
||||
for ch in range(proto.NUM_CHANNELS):
|
||||
p = ChannelPanel(ch, self._driver)
|
||||
self.channel_panels.append(p)
|
||||
grid.addWidget(p, ch // 2, ch % 2)
|
||||
vbox.addWidget(grid_holder)
|
||||
|
||||
self.rotation_panel = RotationPanel(self._driver)
|
||||
vbox.addWidget(self.rotation_panel)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setWidget(panels_host)
|
||||
splitter.addWidget(scroll)
|
||||
|
||||
splitter.addWidget(self._build_log())
|
||||
splitter.setStretchFactor(0, 3)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
outer.addWidget(splitter, 1)
|
||||
|
||||
self.resize(1100, 950)
|
||||
|
||||
def _build_connection_bar(self) -> QHBoxLayout:
|
||||
bar = QHBoxLayout()
|
||||
bar.addWidget(QLabel("Port:"))
|
||||
self.port_combo = QComboBox()
|
||||
self.port_combo.setMinimumWidth(280)
|
||||
bar.addWidget(self.port_combo)
|
||||
|
||||
refresh_btn = QPushButton("⟳")
|
||||
refresh_btn.setMaximumWidth(36)
|
||||
refresh_btn.setToolTip("Rescan serial ports")
|
||||
refresh_btn.clicked.connect(self._refresh_ports)
|
||||
bar.addWidget(refresh_btn)
|
||||
|
||||
self.connect_btn = QPushButton("Connect")
|
||||
self.connect_btn.clicked.connect(self._toggle_connect)
|
||||
bar.addWidget(self.connect_btn)
|
||||
|
||||
self.conn_lbl = QLabel("disconnected")
|
||||
bar.addWidget(self.conn_lbl)
|
||||
bar.addStretch(1)
|
||||
|
||||
self.ping_btn = QPushButton("Ping")
|
||||
self.ping_btn.setEnabled(False)
|
||||
self.ping_btn.clicked.connect(self._driver.ping)
|
||||
bar.addWidget(self.ping_btn)
|
||||
|
||||
self.fw_lbl = QLabel("")
|
||||
bar.addWidget(self.fw_lbl)
|
||||
|
||||
self.stopall_btn = QPushButton("STOP ALL")
|
||||
self.stopall_btn.setEnabled(False)
|
||||
self.stopall_btn.setStyleSheet(
|
||||
"QPushButton { background:#c0392b; color:white; font-weight:bold; padding:4px 14px; }"
|
||||
"QPushButton:disabled { background:#e8a39b; }")
|
||||
self.stopall_btn.clicked.connect(self._driver.stop_all)
|
||||
bar.addWidget(self.stopall_btn)
|
||||
return bar
|
||||
|
||||
def _build_log(self) -> QWidget:
|
||||
box = QWidget()
|
||||
v = QVBoxLayout(box)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
head = QHBoxLayout()
|
||||
head.addWidget(QLabel("Log"))
|
||||
head.addStretch(1)
|
||||
self.rawlog_chk = QCheckBox("Log raw frames")
|
||||
head.addWidget(self.rawlog_chk)
|
||||
clear_btn = QPushButton("Clear")
|
||||
clear_btn.clicked.connect(lambda: self.log_view.clear())
|
||||
head.addWidget(clear_btn)
|
||||
v.addLayout(head)
|
||||
self.log_view = QPlainTextEdit()
|
||||
self.log_view.setReadOnly(True)
|
||||
self.log_view.setMaximumBlockCount(2000)
|
||||
self.log_view.setFont(_mono_font(11))
|
||||
v.addWidget(self.log_view)
|
||||
return box
|
||||
|
||||
# ── Driver signal wiring ──────────────────────────────────────────────────
|
||||
|
||||
def _connect_driver_signals(self):
|
||||
self._driver.port_opened.connect(self._on_port_opened)
|
||||
self._driver.handshake_ok.connect(self._on_handshake_ok)
|
||||
self._driver.disconnected.connect(self._on_disconnected)
|
||||
self._driver.ack_received.connect(self._on_ack)
|
||||
self._driver.motion_done.connect(self._on_motion_done)
|
||||
self._driver.stopped.connect(self._on_stopped)
|
||||
self._driver.fault_occurred.connect(self._on_fault)
|
||||
self._driver.frame_received.connect(self._on_raw_frame)
|
||||
|
||||
# ── Connection control ────────────────────────────────────────────────────
|
||||
|
||||
def _refresh_ports(self):
|
||||
current = self.port_combo.currentText()
|
||||
self.port_combo.clear()
|
||||
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:
|
||||
idx = self.port_combo.findText(current, Qt.MatchFlag.MatchStartsWith)
|
||||
if idx >= 0:
|
||||
self.port_combo.setCurrentIndex(idx)
|
||||
|
||||
def _toggle_connect(self):
|
||||
if self._driver.is_open:
|
||||
self._driver.close()
|
||||
return
|
||||
port = self.port_combo.currentData()
|
||||
if not port:
|
||||
self._log("No serial port selected", "err")
|
||||
return
|
||||
try:
|
||||
self._driver.open(port)
|
||||
except Exception as exc:
|
||||
self._log(f"Connect failed: {exc}", "err")
|
||||
self.conn_lbl.setText("connect failed")
|
||||
|
||||
# ── Driver event handlers ─────────────────────────────────────────────────
|
||||
|
||||
def _on_port_opened(self):
|
||||
self.conn_lbl.setText("opening…")
|
||||
self.connect_btn.setText("Disconnect")
|
||||
self.port_combo.setEnabled(False)
|
||||
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")
|
||||
self.fw_lbl.setText(
|
||||
f"proto v{proto_ver}, fw {fw_ver >> 8}.{fw_ver & 0xFF}, {num_ch} ch")
|
||||
self._log(f"PONG: proto v{proto_ver}, fw 0x{fw_ver:04X}, {num_ch} ch", "rx")
|
||||
self._set_controls_enabled(True)
|
||||
|
||||
def _on_disconnected(self, reason: str):
|
||||
self.conn_lbl.setText(f"disconnected ({reason})" if reason else "disconnected")
|
||||
self.connect_btn.setText("Connect")
|
||||
self.port_combo.setEnabled(True)
|
||||
self.fw_lbl.setText("")
|
||||
self._set_controls_enabled(False)
|
||||
if reason:
|
||||
self._log(f"Disconnected: {reason}", "err")
|
||||
else:
|
||||
self._log("Disconnected", "evt")
|
||||
|
||||
def _on_ack(self, req_cmd: int, status: int):
|
||||
name = proto.CMD_NAMES.get(req_cmd, f"0x{req_cmd:02X}")
|
||||
status_name = proto.STATUS_NAMES.get(status, f"0x{status:02X}")
|
||||
if status != 0:
|
||||
self._log(f"ACK {name} → {status_name}", "rx")
|
||||
elif self.rawlog_chk.isChecked():
|
||||
self._log(f"ACK {name} → OK", "rx")
|
||||
|
||||
def _on_motion_done(self, ch: int, position: int):
|
||||
name = T3RDriver.CHANNEL_NAMES[ch] if ch < len(T3RDriver.CHANNEL_NAMES) else f"ch{ch}"
|
||||
self._log(f"MOTION_DONE {name} @ {position:,}", "evt")
|
||||
|
||||
def _on_stopped(self, ch: int, position: int):
|
||||
name = T3RDriver.CHANNEL_NAMES[ch] if ch < len(T3RDriver.CHANNEL_NAMES) else f"ch{ch}"
|
||||
self._log(f"STOPPED {name} @ {position:,}", "evt")
|
||||
|
||||
def _on_fault(self, ch: int, mask: int):
|
||||
name = T3RDriver.CHANNEL_NAMES[ch] if ch < len(T3RDriver.CHANNEL_NAMES) else f"ch{ch}"
|
||||
self._log(f"FAULT {name}: {proto.fault_names(mask)}", "err")
|
||||
|
||||
def _on_raw_frame(self, cmd: int, payload: bytes):
|
||||
if self.rawlog_chk.isChecked():
|
||||
frame = proto.build_frame(cmd, payload)
|
||||
self._log(frame.hex(" "), "rx")
|
||||
|
||||
def _set_controls_enabled(self, on: bool):
|
||||
self.ping_btn.setEnabled(on)
|
||||
self.stopall_btn.setEnabled(on)
|
||||
self.group_panel.setEnabled(on)
|
||||
self.rotation_panel.setEnabled(on)
|
||||
for p in self.channel_panels:
|
||||
p.setEnabled(on)
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _log(self, msg: str, kind: str = ""):
|
||||
prefix = {"tx": "→ ", "rx": "← ", "evt": "● ", "err": "! "}.get(kind, " ")
|
||||
self.log_view.appendPlainText(prefix + msg)
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
if self.port_combo.count() == 0:
|
||||
self._refresh_ports()
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Hide instead of destroy so the window can be re-shown.
|
||||
event.ignore()
|
||||
self.hide()
|
||||
@@ -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
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
"""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))
|
||||
|
||||
|
||||
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
|
||||
# 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")
|
||||
return bytes(range(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
|
||||
|
||||
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 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
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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": 265,
|
||||
"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": 265,
|
||||
"n_rows_available": 0,
|
||||
"status": "MISSING"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
"""Shared constants for the golden .sras fixtures.
|
||||
|
||||
These mirror the values tests/gen_goldens.py used when the fixtures were
|
||||
generated against the pre-refactor code (commit d185676); they must never
|
||||
change, or the byte-identical comparisons stop meaning anything.
|
||||
"""
|
||||
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))
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,482 @@
|
||||
"""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 SCAN_CHANNELS, SrasFile
|
||||
from fakes import FakeScope, FakeStage, FakeT3R, Trace
|
||||
|
||||
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.background == bytes(range(SPF))
|
||||
|
||||
|
||||
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.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 (fresh frame data)
|
||||
a1_start, a1_end = target.data_offset, 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:]
|
||||
|
||||
|
||||
def test_resume_record_length_mismatch_rejected(tmp_path):
|
||||
engine, trace, plan = build(tmp_path)
|
||||
engine.run()
|
||||
path = tmp_path / "out.sras"
|
||||
resume = ResumeState(path=path,
|
||||
targets=[ResumeTarget(0, 0, 1, 0.0)],
|
||||
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.data_start_offset
|
||||
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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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
|
||||
|
||||
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():
|
||||
st = _statuses("complete.sras")
|
||||
plan = plan_resume(st, selected={0, 1})
|
||||
for target, status in zip(plan.targets, st, strict=True):
|
||||
assert target.data_offset == status.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_checks_acquisition_settings():
|
||||
sras = SrasFile(GOLDEN / "complete.sras")
|
||||
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})
|
||||
@@ -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]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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_sras_viewer_window(qapp):
|
||||
import sras_viewer
|
||||
win = sras_viewer.SrasViewerWindow()
|
||||
_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
|
||||
@@ -0,0 +1,142 @@
|
||||
"""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]
|
||||
assert scan.background is not None and len(scan.background) == 8
|
||||
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)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""core.sras_format vs the pre-refactor golden fixtures.
|
||||
|
||||
The goldens were produced by the original sc3_aui_app implementation; the
|
||||
extracted module must reproduce them byte-for-byte (writer) and
|
||||
field-for-field (parser + frontier walk).
|
||||
"""
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from core.scan_geometry import build_plan
|
||||
from core.sras_format import SrasFile, create_scan_file
|
||||
from golden_util import (
|
||||
BACKGROUND, CHANNELS, LASER_FREQ_HZ, PREAMBLES, SAMPLE_RATE, SPF,
|
||||
TINY_PLAN_ARGS, VELOCITY_MM_S, synthetic_frame,
|
||||
)
|
||||
|
||||
GOLDEN = Path(__file__).parent / "golden"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def expected():
|
||||
with open(GOLDEN / "sras_expected.json") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _tiny_plan():
|
||||
return build_plan(**TINY_PLAN_ARGS, laser_freq_hz=LASER_FREQ_HZ,
|
||||
velocity_mm_s=VELOCITY_MM_S)
|
||||
|
||||
|
||||
def _write_complete(path):
|
||||
plan = _tiny_plan()
|
||||
f = create_scan_file(path, plan, SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND)
|
||||
try:
|
||||
for ai, pa in enumerate(plan.per_angle):
|
||||
for ri in range(pa.n_rows):
|
||||
for ci in range(len(CHANNELS)):
|
||||
for fi in range(pa.n_frames):
|
||||
f.write(synthetic_frame(ai, ri, ci, fi))
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
def test_writer_byte_identical_to_golden(tmp_path):
|
||||
out = tmp_path / "rewrite.sras"
|
||||
_write_complete(out)
|
||||
assert out.read_bytes() == (GOLDEN / "complete.sras").read_bytes()
|
||||
|
||||
|
||||
def test_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):
|
||||
for name, exp_statuses in expected["statuses"].items():
|
||||
statuses = SrasFile(GOLDEN / name).angle_status()
|
||||
assert [asdict(s) for s in statuses] == exp_statuses, f"mismatch for {name}"
|
||||
|
||||
|
||||
def test_preambles_and_background_roundtrip():
|
||||
sras = SrasFile(GOLDEN / "complete.sras")
|
||||
assert sras.preambles == PREAMBLES
|
||||
assert sras.background == BACKGROUND
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user