Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 278df9411e | |||
| 709dc529df | |||
| 44febe34b8 | |||
| afe33249d1 | |||
| d2734c45d6 | |||
| dff9f69d78 | |||
| 67aabde4b6 | |||
| 5148f0bca2 | |||
| 1d4e65f8ac | |||
| d185676130 | |||
| 7bcdff9756 | |||
| 2041fc8439 |
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,47 @@
|
||||
"""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"
|
||||
|
||||
@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,393 @@
|
||||
"""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_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):
|
||||
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()
|
||||
|
||||
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()
|
||||
# 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
|
||||
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)
|
||||
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
|
||||
ctrl = self._stage
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
||||
result.rows_written += 1
|
||||
self._cb.on_row_done(ri + 1, pa.n_rows, ai + 1, n_angles)
|
||||
|
||||
result.angles_acquired.append(ai)
|
||||
|
||||
def _write_row(self, scan_file, samples_per_frame: int, row_idx: 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
|
||||
for ch in SCAN_CHANNELS:
|
||||
if ch == 3:
|
||||
self._cb.on_status("Writing zeroed CH3 frames …")
|
||||
zero_frame = bytes(samples_per_frame)
|
||||
for _ in range(scope_sras.frames_acquired(scope)):
|
||||
scan_file.write(zero_frame)
|
||||
continue
|
||||
|
||||
self._cb.on_status(f"Fetching CH{ch} data …")
|
||||
waveforms = scope_sras.transfer_channel(scope, ch)
|
||||
if ch == 4 and waveforms:
|
||||
self._cb.on_dc_bias(row_idx + 1, scope_sras.frame_means(waveforms))
|
||||
for w in waveforms:
|
||||
scan_file.write(w)
|
||||
@@ -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,167 @@
|
||||
"""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
|
||||
|
||||
import numpy as np
|
||||
|
||||
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)
|
||||
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 frames_acquired(scope) -> int:
|
||||
return int(scope.query("ACQuire:NUMFRAMESACQuired?"))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def frame_means(waveforms: list[bytes]) -> list[float]:
|
||||
"""Per-frame DC mean of a raw int8 FastFrame block.
|
||||
|
||||
numpy over the joined buffer: the per-frame struct.unpack this replaces
|
||||
allocated a tuple of Python ints per frame (~16k frames per row).
|
||||
"""
|
||||
if not waveforms:
|
||||
return []
|
||||
n = len(waveforms[0])
|
||||
if n == 0 or any(len(w) != n for w in waveforms):
|
||||
# Ragged block (shouldn't happen) — fall back to per-frame means.
|
||||
return [float(np.frombuffer(w, dtype=np.int8).mean()) if len(w) else 0.0
|
||||
for w in waveforms]
|
||||
block = np.frombuffer(b"".join(waveforms), dtype=np.int8).reshape(len(waveforms), n)
|
||||
return block.mean(axis=1, dtype=np.float32).tolist()
|
||||
@@ -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,96 @@
|
||||
"""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):
|
||||
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)
|
||||
|
||||
# ── 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
+71
-208
@@ -3,12 +3,13 @@ Helios Laser System Driver
|
||||
Basic implementation for controlling the Helios pulsed laser.
|
||||
"""
|
||||
|
||||
import serial
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from hardware.serial_util import open_8n1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -28,13 +29,7 @@ class HeliosLaser:
|
||||
"""
|
||||
|
||||
def __init__(self, port: str = None, timeout: float = 1.0):
|
||||
"""
|
||||
Initialize Helios laser driver.
|
||||
|
||||
Args:
|
||||
port: Serial port (e.g., '/dev/ttyUSB0' or 'COM5')
|
||||
timeout: Serial timeout in seconds
|
||||
"""
|
||||
"""Initialize Helios laser driver."""
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.serial = None
|
||||
@@ -42,21 +37,12 @@ class HeliosLaser:
|
||||
|
||||
@staticmethod
|
||||
def list_available_ports() -> List[str]:
|
||||
"""List available serial ports"""
|
||||
import serial.tools.list_ports
|
||||
ports = serial.tools.list_ports.comports()
|
||||
return [port.device for port in ports]
|
||||
"""List available serial ports, likeliest devices first."""
|
||||
from hardware.serial_util import list_port_devices
|
||||
return list_port_devices()
|
||||
|
||||
def connect(self, port: str = None) -> bool:
|
||||
"""
|
||||
Connect to the Helios laser.
|
||||
|
||||
Args:
|
||||
port: Serial port (uses stored port if None)
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
"""
|
||||
"""Connect to the Helios laser."""
|
||||
if port:
|
||||
self.port = port
|
||||
|
||||
@@ -65,14 +51,7 @@ class HeliosLaser:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.serial = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=9600,
|
||||
bytesize=serial.EIGHTBITS,
|
||||
parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE,
|
||||
timeout=self.timeout
|
||||
)
|
||||
self.serial = open_8n1(self.port, baudrate=9600, timeout=self.timeout)
|
||||
time.sleep(0.1) # Allow time for connection to stabilize
|
||||
self.is_connected = True
|
||||
logger.info(f"Connected to Helios laser on {self.port}")
|
||||
@@ -98,15 +77,7 @@ class HeliosLaser:
|
||||
self.serial = None
|
||||
|
||||
def _send_command(self, command: str) -> bool:
|
||||
"""
|
||||
Send a command to the laser.
|
||||
|
||||
Args:
|
||||
command: ASCII command string (without CR)
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
"""Send a command to the laser."""
|
||||
if not self.is_connected or not self.serial:
|
||||
logger.error("Not connected to laser")
|
||||
return False
|
||||
@@ -122,37 +93,36 @@ class HeliosLaser:
|
||||
return False
|
||||
|
||||
def _query(self, command: str) -> Optional[str]:
|
||||
"""
|
||||
Send a query and read response.
|
||||
"""Send a query and return the value from its response.
|
||||
|
||||
Args:
|
||||
command: ASCII query command (without CR or ?)
|
||||
|
||||
Returns:
|
||||
Response value string or None if error
|
||||
Reads until the CR terminator rather than sleeping a fixed interval:
|
||||
the device usually answers in a few ms, so the old unconditional
|
||||
0.05 + 0.2 s cost ~250 ms per query and made an 8-query status poll
|
||||
take ~2 s — longer than the 1 s interval that scheduled it.
|
||||
"""
|
||||
try:
|
||||
# Clear any pending data in the buffer
|
||||
# Clear any stale bytes so a previous timed-out reply can't be
|
||||
# mistaken for this command's response.
|
||||
self.serial.reset_input_buffer()
|
||||
time.sleep(0.05)
|
||||
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
|
||||
time.sleep(0.2) # Give device time to respond
|
||||
|
||||
response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip()
|
||||
if not response:
|
||||
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
|
||||
return None
|
||||
logger.debug(f"Query '{command}' response: {response}")
|
||||
|
||||
# Helios format: "COMMAND = VALUE UNIT"
|
||||
# Extract just the value part
|
||||
# Helios format: "COMMAND = VALUE UNIT" — take just the value
|
||||
if '=' in response:
|
||||
parts = response.split('=')
|
||||
if len(parts) >= 2:
|
||||
value_part = parts[1].strip()
|
||||
# Remove unit suffix if present (e.g., "ns", "mA", "mW")
|
||||
value = value_part.split()[0]
|
||||
return value
|
||||
# Strip the unit suffix if present (e.g. "ns", "mA", "mW")
|
||||
fields = value_part.split()
|
||||
if fields:
|
||||
return fields[0]
|
||||
|
||||
return response
|
||||
|
||||
@@ -160,16 +130,19 @@ class HeliosLaser:
|
||||
logger.error(f"Failed to read response for '{command}': {e}")
|
||||
return None
|
||||
|
||||
def _query_int(self, command: str) -> Optional[int]:
|
||||
"""Query a value that should parse as an int; None if absent/unparseable."""
|
||||
raw = self._query(command)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.error(f"Query '{command}' returned non-integer {raw!r}")
|
||||
return None
|
||||
|
||||
def set_frequency_hz(self, frequency: int) -> bool:
|
||||
"""
|
||||
Set laser pulse frequency in Hz.
|
||||
|
||||
Args:
|
||||
frequency: Frequency in Hz (16700 - 125000)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
"""Set laser pulse frequency in Hz."""
|
||||
if not (16700 <= frequency <= 125000):
|
||||
logger.error(f"Frequency {frequency} Hz out of range (16700-125000)")
|
||||
return False
|
||||
@@ -186,15 +159,7 @@ class HeliosLaser:
|
||||
return self._send_command(command)
|
||||
|
||||
def set_current_ma(self, current: int) -> bool:
|
||||
"""
|
||||
Set pump diode current in mA.
|
||||
|
||||
Args:
|
||||
current: Current in mA (0 - 2000 for this model)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
"""Set pump diode current in mA."""
|
||||
if not (0 <= current <= 2000):
|
||||
logger.error(f"Current {current} mA out of range (0-2000)")
|
||||
return False
|
||||
@@ -203,28 +168,12 @@ class HeliosLaser:
|
||||
return self._send_command(command)
|
||||
|
||||
def set_pulse_mode(self, mode: PulseMode) -> bool:
|
||||
"""
|
||||
Set pulse mode.
|
||||
|
||||
Args:
|
||||
mode: PulseMode enumeration value
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
"""Set pulse mode."""
|
||||
command = f"LDG {mode.value}"
|
||||
return self._send_command(command)
|
||||
|
||||
def set_laser_enable(self, enable: bool) -> bool:
|
||||
"""
|
||||
Enable or disable laser emission.
|
||||
|
||||
Args:
|
||||
enable: True to enable, False to disable
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
"""Enable or disable laser emission."""
|
||||
command = f"LDO {1 if enable else 0}"
|
||||
success = self._send_command(command)
|
||||
|
||||
@@ -235,60 +184,24 @@ class HeliosLaser:
|
||||
return success
|
||||
|
||||
def is_laser_enabled(self) -> bool:
|
||||
"""
|
||||
Check if laser is currently enabled.
|
||||
|
||||
Returns:
|
||||
True if laser is enabled
|
||||
"""
|
||||
response = self._query("LDO")
|
||||
if response:
|
||||
try:
|
||||
return int(response) == 1
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for LDO: {response}")
|
||||
return False
|
||||
"""True if laser emission is currently enabled."""
|
||||
return self._query_int("LDO") == 1
|
||||
|
||||
def get_frequency_hz(self) -> Optional[int]:
|
||||
"""
|
||||
Get current laser frequency in Hz.
|
||||
|
||||
Returns:
|
||||
Frequency in Hz or None if error
|
||||
"""
|
||||
response = self._query("LDF")
|
||||
if response:
|
||||
try:
|
||||
period_ns = int(response)
|
||||
return int(1e9 / period_ns)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
logger.error(f"Invalid response for LDF: {response}")
|
||||
return None
|
||||
"""Current laser frequency in Hz, or None on error."""
|
||||
period_ns = self._query_int("LDF")
|
||||
if not period_ns:
|
||||
return None
|
||||
return int(1e9 / period_ns)
|
||||
|
||||
def get_current_ma(self) -> Optional[int]:
|
||||
"""
|
||||
Get current pump diode current in mA.
|
||||
|
||||
Returns:
|
||||
Current in mA or None if error
|
||||
"""
|
||||
response = self._query("LDS")
|
||||
if response:
|
||||
try:
|
||||
return int(response)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for LDS: {response}")
|
||||
return None
|
||||
"""Current pump diode current in mA, or None on error."""
|
||||
return self._query_int("LDS")
|
||||
|
||||
def _query_millicelsius(self, command: str) -> Optional[float]:
|
||||
"""Query a temperature register (returns milli-°C) and convert to °C."""
|
||||
response = self._query(command)
|
||||
if response:
|
||||
try:
|
||||
return int(response) / 1000.0
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for {command}: {response}")
|
||||
return None
|
||||
"""Query a temperature register (milli-°C) and convert to °C."""
|
||||
value = self._query_int(command)
|
||||
return None if value is None else value / 1000.0
|
||||
|
||||
def get_diode_temp_c(self) -> Optional[float]:
|
||||
"""Diode temperature in °C (LTA, 5000–50000 milli-°C)."""
|
||||
@@ -303,46 +216,22 @@ class HeliosLaser:
|
||||
return self._query_millicelsius("EOA")
|
||||
|
||||
def get_controller_serial(self) -> Optional[str]:
|
||||
"""
|
||||
Get controller serial number.
|
||||
|
||||
Returns:
|
||||
Serial number string or None if error
|
||||
"""
|
||||
"""Controller serial number, or None on error."""
|
||||
return self._query("CSR")
|
||||
|
||||
def get_head_serial(self) -> Optional[str]:
|
||||
"""
|
||||
Get laser head serial number.
|
||||
|
||||
Returns:
|
||||
Serial number string or None if error
|
||||
"""
|
||||
"""Laser head serial number, or None on error."""
|
||||
return self._query("HSR")
|
||||
|
||||
def get_status_registers(self) -> tuple:
|
||||
"""Query the LER, LCE and CCE status registers.
|
||||
|
||||
Each is a bitmask (sum of flags); non-zero means active faults,
|
||||
cleared with reset_faults(). Returns (ler, lce, cce), any of which
|
||||
is None if that register could not be read.
|
||||
"""
|
||||
Query LER, LCE, and CCE status registers.
|
||||
|
||||
Each register is a bitmask (sum of flags). Non-zero values indicate
|
||||
active faults. Reset with reset_faults().
|
||||
|
||||
Returns:
|
||||
Tuple of (ler, lce, cce) as ints, or None for each on error.
|
||||
"""
|
||||
def _read_reg(cmd):
|
||||
resp = self._query(cmd)
|
||||
if resp is not None:
|
||||
try:
|
||||
return int(resp)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for {cmd}: {resp}")
|
||||
return None
|
||||
|
||||
ler = _read_reg("LER")
|
||||
lce = _read_reg("LCE")
|
||||
cce = _read_reg("CCE")
|
||||
return (ler, lce, cce)
|
||||
return (self._query_int("LER"), self._query_int("LCE"),
|
||||
self._query_int("CCE"))
|
||||
|
||||
def reset_faults(self) -> bool:
|
||||
"""
|
||||
@@ -364,38 +253,19 @@ class HeliosLaser:
|
||||
return ok
|
||||
|
||||
def get_remote_enable(self) -> Optional[bool]:
|
||||
"""
|
||||
Query the remote enable state (LRE - activates utility connector pin 8).
|
||||
|
||||
Returns:
|
||||
True if remote enable is active, False if not, None on error
|
||||
"""
|
||||
response = self._query("LRE")
|
||||
if response is not None:
|
||||
try:
|
||||
return int(response) == 1
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for LRE: {response}")
|
||||
return None
|
||||
"""Remote enable state (LRE — utility connector pin 8); None on error."""
|
||||
value = self._query_int("LRE")
|
||||
return None if value is None else value == 1
|
||||
|
||||
def send_raw_command(self, command: str) -> Optional[str]:
|
||||
"""
|
||||
Send a raw command string and return the raw response.
|
||||
|
||||
Useful for diagnostics. Sends *command* + CR, waits briefly,
|
||||
then reads whatever the device returns (up to the first CR or timeout).
|
||||
|
||||
Returns:
|
||||
Raw response string (decoded, stripped) or None on error.
|
||||
"""
|
||||
"""Send a raw command and return the unparsed response (diagnostics)."""
|
||||
if not self.is_connected or not self.serial:
|
||||
logger.error("Not connected to laser")
|
||||
return None
|
||||
try:
|
||||
self.serial.reset_input_buffer()
|
||||
time.sleep(0.05)
|
||||
self.serial.write((command + '\r').encode('ascii'))
|
||||
time.sleep(0.3)
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
raw = self.serial.read_until(b'\r')
|
||||
if not raw:
|
||||
raw = self.serial.read(self.serial.in_waiting)
|
||||
@@ -405,18 +275,11 @@ class HeliosLaser:
|
||||
return None
|
||||
|
||||
def set_remote_enable(self, enable: bool) -> bool:
|
||||
"""
|
||||
Set the remote enable state (LRE - utility connector pin 8).
|
||||
|
||||
Args:
|
||||
enable: True to activate remote enable, False to deactivate
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
"""Set the remote enable state (LRE - utility connector pin 8)."""
|
||||
command = f"LRE {1 if enable else 0}"
|
||||
return self._send_command(command)
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor - ensure cleanup"""
|
||||
self.disconnect()
|
||||
# No __del__: it used to call disconnect(), which disables the laser and
|
||||
# writes to the serial port from the garbage collector at an
|
||||
# unpredictable time (including interpreter shutdown, when the port may
|
||||
# already be torn down). Callers close the driver explicitly.
|
||||
|
||||
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
-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
|
||||
|
||||
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
+48
-91
@@ -12,12 +12,18 @@ 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,6 @@ 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)
|
||||
|
||||
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()]
|
||||
+119
-63
@@ -1,8 +1,12 @@
|
||||
"""T3R Stepper Controller driver for ScanEngine-3.
|
||||
|
||||
Qt-based driver that owns the serial connection and an internal reader QThread.
|
||||
All events arrive as Qt signals; all commands are fire-and-forget writes.
|
||||
Create in the main (GUI) thread; no additional thread management required.
|
||||
Owns the serial connection and an internal reader thread. Events are
|
||||
delivered as plain-Python callbacks (see ``Signal``); commands are
|
||||
fire-and-forget writes. No Qt — GUIs wrap this with gui.qt_t3r.QtT3RAdapter,
|
||||
which re-emits every event as a queued Qt signal on the GUI thread.
|
||||
|
||||
Callbacks run on the reader thread. Keep them short, and never touch Qt
|
||||
widgets from one directly.
|
||||
|
||||
Gear train (stage rotation via GR-axis, ch3):
|
||||
Motor → 10T pinion → 30T idler → 125T index gear (stage)
|
||||
@@ -11,23 +15,53 @@ Gear train (stage rotation via GR-axis, ch3):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import serial
|
||||
from PyQt6.QtCore import QObject, QThread, QTimer, pyqtSignal
|
||||
|
||||
from . import t3r_protocol as proto
|
||||
from .serial_util import open_8n1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _T3RReader(QThread):
|
||||
"""Blocking read loop — runs on its own QThread."""
|
||||
class Signal:
|
||||
"""Minimal observer slot: ``connect(fn)`` then ``emit(*args)``.
|
||||
|
||||
frame = pyqtSignal(int, bytes) # (cmd, payload) for each valid frame
|
||||
finished_reason = pyqtSignal(str) # "" = clean stop, else I/O error string
|
||||
Mirrors the pyqtSignal API used by the existing panels so the same call
|
||||
sites work against either this driver or a Qt adapter over it. A raising
|
||||
subscriber is logged and skipped so one bad listener cannot kill the
|
||||
reader thread.
|
||||
"""
|
||||
|
||||
def __init__(self, ser: serial.Serial):
|
||||
super().__init__()
|
||||
__slots__ = ("_subs", "_name")
|
||||
|
||||
def __init__(self, name: str = ""):
|
||||
self._subs: list = []
|
||||
self._name = name
|
||||
|
||||
def connect(self, fn) -> None:
|
||||
self._subs.append(fn)
|
||||
|
||||
def disconnect(self, fn) -> None:
|
||||
if fn in self._subs:
|
||||
self._subs.remove(fn)
|
||||
|
||||
def emit(self, *args) -> None:
|
||||
for fn in list(self._subs):
|
||||
try:
|
||||
fn(*args)
|
||||
except Exception:
|
||||
logger.exception("T3R %s subscriber failed", self._name)
|
||||
|
||||
|
||||
class _Reader(threading.Thread):
|
||||
"""Blocking read loop — decodes frames and hands them to `on_frame`."""
|
||||
|
||||
def __init__(self, ser, on_frame, on_finished):
|
||||
super().__init__(daemon=True, name="T3RReader")
|
||||
self._ser = ser
|
||||
self._on_frame = on_frame
|
||||
self._on_finished = on_finished
|
||||
self._running = True
|
||||
self._parser = proto.FrameParser()
|
||||
|
||||
@@ -44,20 +78,20 @@ class _T3RReader(QThread):
|
||||
break
|
||||
if data:
|
||||
for cmd, payload in self._parser.feed(data):
|
||||
self.frame.emit(cmd, payload)
|
||||
self.finished_reason.emit(reason)
|
||||
self._on_frame(cmd, payload)
|
||||
self._on_finished(reason)
|
||||
|
||||
|
||||
class T3RDriver(QObject):
|
||||
"""Qt-based driver for the T3R four-channel stepper controller.
|
||||
class T3RDriver:
|
||||
"""Driver for the T3R four-channel stepper controller.
|
||||
|
||||
Usage::
|
||||
|
||||
driver = T3RDriver()
|
||||
driver.handshake_ok.connect(lambda pv, fw, nc: print("connected"))
|
||||
driver.info_updated.connect(on_info)
|
||||
driver.connect("/dev/ttyUSB0")
|
||||
driver.handshake_ok.connect(lambda pv, fw, nc: ...)
|
||||
driver.open("/dev/ttyUSB0")
|
||||
driver.move(0, steps=3200, velocity=8000, accel=4000)
|
||||
driver.wait_motion_done(0, timeout=30.0)
|
||||
"""
|
||||
|
||||
CHANNEL_NAMES = ["T-axis (focus)", "Axis 1", "Axis 2", "GR-axis"]
|
||||
@@ -68,32 +102,33 @@ class T3RDriver(QObject):
|
||||
GEAR_TEETH_MOTOR = 10
|
||||
GEAR_TEETH_STAGE = 125 # idler is 30T but does not change ratio
|
||||
|
||||
# ── Signals ───────────────────────────────────────────────────────────────
|
||||
POLL_INTERVAL_S = 0.25
|
||||
|
||||
port_opened = pyqtSignal() # serial port open; PING sent
|
||||
handshake_ok = pyqtSignal(int, int, int) # proto_ver, fw_ver, num_channels
|
||||
disconnected = pyqtSignal(str) # reason ("" = user-initiated)
|
||||
|
||||
info_updated = pyqtSignal(int, object) # ch, proto.Info
|
||||
drv_status_updated = pyqtSignal(int, object) # ch, proto.DrvStatus
|
||||
position_updated = pyqtSignal(int, int) # ch, position (microsteps)
|
||||
motion_done = pyqtSignal(int, int) # ch, final_position
|
||||
stopped = pyqtSignal(int, int) # ch, final_position
|
||||
fault_occurred = pyqtSignal(int, int) # ch, fault_mask
|
||||
ack_received = pyqtSignal(int, int) # req_cmd, status (0=OK)
|
||||
frame_received = pyqtSignal(int, bytes) # raw (cmd, payload) for log
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._ser: serial.Serial | None = None
|
||||
self._reader: _T3RReader | None = None
|
||||
def __init__(self):
|
||||
self._ser = None
|
||||
self._reader: _Reader | None = None
|
||||
self._write_lock = threading.Lock()
|
||||
self._tearing_down = False
|
||||
self._is_open = False
|
||||
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.setInterval(250)
|
||||
self._poll_timer.timeout.connect(self._poll)
|
||||
self._poll_stop = threading.Event()
|
||||
self._poll_thread: threading.Thread | None = None
|
||||
|
||||
# Per-channel motion-completion events, so a caller can block on a
|
||||
# move finishing instead of guessing its duration.
|
||||
self._motion_events = [threading.Event() for _ in range(proto.NUM_CHANNELS)]
|
||||
|
||||
self.port_opened = Signal("port_opened") # ()
|
||||
self.handshake_ok = Signal("handshake_ok") # proto_ver, fw_ver, n_ch
|
||||
self.disconnected = Signal("disconnected") # reason ("" = user)
|
||||
self.info_updated = Signal("info_updated") # ch, proto.Info
|
||||
self.drv_status_updated = Signal("drv_status_updated") # ch, proto.DrvStatus
|
||||
self.position_updated = Signal("position_updated") # ch, position
|
||||
self.motion_done = Signal("motion_done") # ch, final_position
|
||||
self.stopped = Signal("stopped") # ch, final_position
|
||||
self.fault_occurred = Signal("fault_occurred") # ch, fault_mask
|
||||
self.ack_received = Signal("ack_received") # req_cmd, status
|
||||
self.frame_received = Signal("frame_received") # raw cmd, payload
|
||||
|
||||
# ── Connection ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,26 +136,24 @@ class T3RDriver(QObject):
|
||||
def is_open(self) -> bool:
|
||||
return self._is_open
|
||||
|
||||
def connect(self, port: str, baud: int = 115200) -> None:
|
||||
"""Open the serial port and start the reader. Emits port_opened on success."""
|
||||
def open(self, port: str, baud: int = 115200) -> None:
|
||||
"""Open the serial port and start the reader; emits port_opened."""
|
||||
if self._is_open:
|
||||
self.disconnect()
|
||||
self.close()
|
||||
try:
|
||||
self._ser = serial.Serial(port, baudrate=baud, timeout=0.05)
|
||||
self._ser = open_8n1(port, baudrate=baud, timeout=0.05)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Cannot open {port}: {exc}") from exc
|
||||
|
||||
self._tearing_down = False
|
||||
self._is_open = True
|
||||
self._reader = _T3RReader(self._ser)
|
||||
self._reader.frame.connect(self._on_frame)
|
||||
self._reader.finished_reason.connect(self._on_reader_finished)
|
||||
self._reader = _Reader(self._ser, self._on_frame, self._on_reader_finished)
|
||||
self._reader.start()
|
||||
self.port_opened.emit()
|
||||
self.send_frame(proto.ping()) # handshake; polling starts on PONG
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close port and stop polling."""
|
||||
def close(self) -> None:
|
||||
"""Close the port and stop polling."""
|
||||
self._teardown("")
|
||||
|
||||
def _on_reader_finished(self, reason: str):
|
||||
@@ -131,16 +164,21 @@ class T3RDriver(QObject):
|
||||
if self._tearing_down or not self._is_open:
|
||||
return
|
||||
self._tearing_down = True
|
||||
self._poll_timer.stop()
|
||||
self.stop_polling()
|
||||
self._is_open = False
|
||||
|
||||
# Release anyone blocked in wait_motion_done so a disconnect during a
|
||||
# move raises there instead of hanging until the timeout.
|
||||
for ev in self._motion_events:
|
||||
ev.set()
|
||||
|
||||
reader, self._reader = self._reader, None
|
||||
ser, self._ser = self._ser, None
|
||||
|
||||
if reader is not None:
|
||||
reader.stop()
|
||||
if QThread.currentThread() is not reader:
|
||||
reader.wait(1000)
|
||||
if threading.current_thread() is not reader:
|
||||
reader.join(1.0)
|
||||
if ser is not None:
|
||||
try:
|
||||
ser.close()
|
||||
@@ -186,6 +224,7 @@ class T3RDriver(QObject):
|
||||
self.send_frame(proto.set_current(ch, run_ma, hold_ma, ihold_delay))
|
||||
|
||||
def move(self, ch: int, steps: int, velocity: int, accel: int):
|
||||
self._motion_events[ch].clear()
|
||||
self.send_frame(proto.move(ch, steps, velocity, accel))
|
||||
|
||||
def jog(self, ch: int, velocity: int, accel: int):
|
||||
@@ -215,30 +254,44 @@ class T3RDriver(QObject):
|
||||
# ── Rotation helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def steps_for_angle(self, angle_deg: float, microsteps: int) -> int:
|
||||
"""Compute GR-axis microsteps needed to rotate the stage by angle_deg."""
|
||||
"""GR-axis microsteps needed to rotate the stage by angle_deg."""
|
||||
gear_ratio = self.GEAR_TEETH_STAGE / self.GEAR_TEETH_MOTOR
|
||||
steps_per_stage_rev = self.MOTOR_FULL_STEPS_PER_REV * microsteps * gear_ratio
|
||||
return round(steps_per_stage_rev * angle_deg / 360.0)
|
||||
|
||||
def rotate_stage(self, angle_deg: float, microsteps: int,
|
||||
velocity: int = 8000, accel: int = 4000):
|
||||
"""Move GR-axis by the number of steps that rotate the stage by angle_deg."""
|
||||
steps = self.steps_for_angle(angle_deg, microsteps)
|
||||
self.move(self.GR_AXIS_CH, steps, velocity, accel)
|
||||
"""Move GR-axis by the steps that rotate the stage by angle_deg."""
|
||||
self.move(self.GR_AXIS_CH, self.steps_for_angle(angle_deg, microsteps),
|
||||
velocity, accel)
|
||||
|
||||
def wait_motion_done(self, ch: int, timeout: float) -> bool:
|
||||
"""Block until the channel reports MOTION_DONE. False on timeout.
|
||||
|
||||
Cleared by ``move()``, set by the MOTION_DONE event and by teardown,
|
||||
so a disconnect mid-move unblocks immediately.
|
||||
"""
|
||||
return self._motion_events[ch].wait(timeout)
|
||||
|
||||
# ── Polling ───────────────────────────────────────────────────────────────
|
||||
|
||||
def start_polling(self):
|
||||
self._poll_timer.start()
|
||||
if self._poll_thread is not None and self._poll_thread.is_alive():
|
||||
return
|
||||
self._poll_stop.clear()
|
||||
self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True,
|
||||
name="T3RPoll")
|
||||
self._poll_thread.start()
|
||||
|
||||
def stop_polling(self):
|
||||
self._poll_timer.stop()
|
||||
self._poll_stop.set()
|
||||
|
||||
def _poll(self):
|
||||
if not self._is_open:
|
||||
return
|
||||
for ch in range(proto.NUM_CHANNELS):
|
||||
self.send_frame(proto.get_info(ch))
|
||||
def _poll_loop(self):
|
||||
while not self._poll_stop.wait(self.POLL_INTERVAL_S):
|
||||
if not self._is_open:
|
||||
break
|
||||
for ch in range(proto.NUM_CHANNELS):
|
||||
self.send_frame(proto.get_info(ch))
|
||||
|
||||
# ── Frame dispatcher ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -274,14 +327,17 @@ class T3RDriver(QObject):
|
||||
elif cmd == proto.EVT_MOTION_DONE:
|
||||
ev = proto.decode_event_position(payload)
|
||||
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
|
||||
self._motion_events[ev.ch].set()
|
||||
self.motion_done.emit(ev.ch, ev.position)
|
||||
|
||||
elif cmd == proto.EVT_STOPPED:
|
||||
ev = proto.decode_event_position(payload)
|
||||
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
|
||||
self._motion_events[ev.ch].set()
|
||||
self.stopped.emit(ev.ch, ev.position)
|
||||
|
||||
elif cmd == proto.EVT_FAULT:
|
||||
ev = proto.decode_fault(payload)
|
||||
if ev and 0 <= ev.ch < proto.NUM_CHANNELS:
|
||||
self._motion_events[ev.ch].set()
|
||||
self.fault_occurred.emit(ev.ch, ev.position)
|
||||
|
||||
@@ -193,14 +193,6 @@ def set_position(ch: int, position: int) -> bytes:
|
||||
return build_frame(CMD_SET_POSITION, struct.pack("<Bi", ch, position))
|
||||
|
||||
|
||||
def read_reg(ch: int, reg: int) -> bytes:
|
||||
return build_frame(CMD_READ_REG, struct.pack("<BB", ch, reg))
|
||||
|
||||
|
||||
def write_reg(ch: int, reg: int, value: int) -> bytes:
|
||||
return build_frame(CMD_WRITE_REG, struct.pack("<BBI", ch, reg, value))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response / event decoders. Each returns a dataclass (or None on bad length).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -261,13 +253,6 @@ class Position:
|
||||
position: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Reg:
|
||||
ch: int
|
||||
reg: int
|
||||
value: int
|
||||
|
||||
|
||||
def decode_pong(p: bytes):
|
||||
if len(p) < 4:
|
||||
return None
|
||||
@@ -304,13 +289,6 @@ def decode_position(p: bytes):
|
||||
return Position(ch, pos)
|
||||
|
||||
|
||||
def decode_reg(p: bytes):
|
||||
if len(p) < 6:
|
||||
return None
|
||||
ch, reg, value = struct.unpack_from("<BBI", p, 0)
|
||||
return Reg(ch, reg, value)
|
||||
|
||||
|
||||
def decode_event_position(p: bytes):
|
||||
"""MOTION_DONE / STOPPED share the (ch, position) layout."""
|
||||
return decode_position(p)
|
||||
|
||||
Regular → Executable
+35
-1112
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
+98
-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>
|
||||
|
||||
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
+481
-1078
File diff suppressed because it is too large
Load Diff
Regular → Executable
+86
-88
@@ -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)]
|
||||
[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]
|
||||
[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 (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,81 +156,37 @@ 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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,
|
||||
}
|
||||
```
|
||||
using that angle's `x_start` from the Per-Angle Geometry Table (not
|
||||
`x_start_nominal`).
|
||||
|
||||
---
|
||||
|
||||
## Acquisition Settings (fixed by sc3_aui_app.py)
|
||||
|
||||
| Parameter | Value |
|
||||
|----------------------|------------------------------|
|
||||
|-----------------------|------------------------------|
|
||||
| Oscilloscope trigger | CH2, rising edge, 1.24 V |
|
||||
| Trigger offset | 0 % (trigger at left edge) |
|
||||
| Sample rate | 6.25 GS/s (160 ps/sample) |
|
||||
@@ -211,3 +206,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
+513
-938
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
|
||||
|
||||
+14
-21
@@ -19,7 +19,7 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFrame, QGridLayout,
|
||||
@@ -27,9 +27,10 @@ from PyQt6.QtWidgets import (
|
||||
QSpinBox, QSplitter, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from hardware.t3r_driver import T3RDriver
|
||||
from gui.qt_t3r import QtT3RAdapter
|
||||
from hardware.serial_util import scored_ports
|
||||
from hardware.t3r_driver import T3RDriver # class constants (gear train, channel names)
|
||||
import hardware.t3r_protocol as proto
|
||||
import serial.tools.list_ports
|
||||
|
||||
|
||||
# ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
@@ -62,7 +63,7 @@ def _spin(lo: int, hi: int, val: int) -> QSpinBox:
|
||||
class ChannelPanel(QGroupBox):
|
||||
"""Controls and live readouts for one T3R axis."""
|
||||
|
||||
def __init__(self, ch: int, driver: T3RDriver):
|
||||
def __init__(self, ch: int, driver: QtT3RAdapter):
|
||||
label = f"Axis {ch} — {T3RDriver.CHANNEL_NAMES[ch]}"
|
||||
super().__init__(label)
|
||||
self.ch = ch
|
||||
@@ -278,7 +279,7 @@ class ChannelPanel(QGroupBox):
|
||||
class GroupPanel(QGroupBox):
|
||||
"""Ganged motion — selected axes step in lockstep."""
|
||||
|
||||
def __init__(self, driver: T3RDriver, log_fn):
|
||||
def __init__(self, driver: QtT3RAdapter, log_fn):
|
||||
super().__init__("Ganged / synchronised motion — selected axes move in lockstep")
|
||||
self._driver = driver
|
||||
self._log = log_fn
|
||||
@@ -389,7 +390,7 @@ class RotationPanel(QGroupBox):
|
||||
Ratio = 125/10 = 12.5
|
||||
"""
|
||||
|
||||
def __init__(self, driver: T3RDriver):
|
||||
def __init__(self, driver: QtT3RAdapter):
|
||||
super().__init__(
|
||||
f"Stage Rotation (GR-axis ch{T3RDriver.GR_AXIS_CH}) — "
|
||||
f"gear: {T3RDriver.GEAR_TEETH_MOTOR}T motor → 30T idler → "
|
||||
@@ -481,12 +482,12 @@ class RotationPanel(QGroupBox):
|
||||
class T3RControlPanel(QDialog):
|
||||
"""User-hidable T3R control window.
|
||||
|
||||
Pass a T3RDriver instance. The panel connects to its signals and forwards
|
||||
Pass a QtT3RAdapter instance. The panel connects to its signals and forwards
|
||||
commands via its API. Connection management (port open/close) is handled
|
||||
inside the panel itself.
|
||||
"""
|
||||
|
||||
def __init__(self, driver: T3RDriver, parent=None):
|
||||
def __init__(self, driver: QtT3RAdapter, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("T3R Stepper Controller")
|
||||
self.setWindowFlags(
|
||||
@@ -613,16 +614,8 @@ class T3RControlPanel(QDialog):
|
||||
def _refresh_ports(self):
|
||||
current = self.port_combo.currentText()
|
||||
self.port_combo.clear()
|
||||
ports = list(serial.tools.list_ports.comports())
|
||||
|
||||
def score(p):
|
||||
text = f"{p.description} {p.manufacturer or ''} {p.product or ''}".lower()
|
||||
hints = ("esp32", "jtag", "espressif", "usb serial", "cp210", "ch340", "cdc")
|
||||
return -sum(h in text for h in hints)
|
||||
|
||||
ports.sort(key=score)
|
||||
for p in ports:
|
||||
self.port_combo.addItem(f"{p.device} — {p.description or p.device}", p.device)
|
||||
for device, label in scored_ports():
|
||||
self.port_combo.addItem(label, device)
|
||||
if self.port_combo.count() == 0:
|
||||
self.port_combo.addItem("(no serial ports found)", None)
|
||||
elif current:
|
||||
@@ -632,14 +625,14 @@ class T3RControlPanel(QDialog):
|
||||
|
||||
def _toggle_connect(self):
|
||||
if self._driver.is_open:
|
||||
self._driver.disconnect()
|
||||
self._driver.close()
|
||||
return
|
||||
port = self.port_combo.currentData()
|
||||
if not port:
|
||||
self._log("No serial port selected", "err")
|
||||
return
|
||||
try:
|
||||
self._driver.connect(port)
|
||||
self._driver.open(port)
|
||||
except Exception as exc:
|
||||
self._log(f"Connect failed: {exc}", "err")
|
||||
self.conn_lbl.setText("connect failed")
|
||||
@@ -650,7 +643,7 @@ class T3RControlPanel(QDialog):
|
||||
self.conn_lbl.setText("opening…")
|
||||
self.connect_btn.setText("Disconnect")
|
||||
self.port_combo.setEnabled(False)
|
||||
self._log(f"Port opened, sending PING…", "evt")
|
||||
self._log("Port opened, sending PING…", "evt")
|
||||
|
||||
def _on_handshake_ok(self, proto_ver: int, fw_ver: int, num_ch: int):
|
||||
self.conn_lbl.setText("connected")
|
||||
|
||||
@@ -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
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
"""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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
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)):
|
||||
self._t = trace
|
||||
self.am_homed = list(homed)
|
||||
self.am_enabled = list(enabled)
|
||||
self.positions = [0.0, 0.0]
|
||||
|
||||
def enable_axis(self, axis):
|
||||
self._t.record("enable_axis", axis)
|
||||
self.am_enabled[0 if axis == 0x21 else 1] = True
|
||||
|
||||
def home_axis(self, axis, timeout=0.0):
|
||||
self._t.record("home_axis", axis)
|
||||
self.am_homed[0 if axis == 0x21 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)
|
||||
|
||||
def move_axis_absolute(self, axis, pos, timeout=0.0):
|
||||
self._t.record("move_axis_absolute", axis, round(pos, 6))
|
||||
self.positions[0 if axis == 0x21 else 1] = pos
|
||||
|
||||
|
||||
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, n_frames=4):
|
||||
self._t = trace
|
||||
self.samples_per_frame = samples_per_frame
|
||||
self._n_frames = n_frames
|
||||
self._acq_polls = 0
|
||||
self.frame_seq = 0
|
||||
|
||||
# -- writes / queries ---------------------------------------------------
|
||||
def write(self, cmd):
|
||||
self._t.record("write", cmd)
|
||||
|
||||
def query(self, cmd):
|
||||
self._t.record("query", cmd)
|
||||
if cmd == "ACQuire:STATE?":
|
||||
self._acq_polls += 1
|
||||
return "0" # background average finished
|
||||
if cmd == "ACQuire:NUMFRAMESACQuired?":
|
||||
return str(self._n_frames)
|
||||
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)
|
||||
self._n_frames = n
|
||||
|
||||
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 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 transfer_fastframe(self, parse=True):
|
||||
self._t.record("transfer_fastframe", self._source)
|
||||
frames = []
|
||||
for i in range(self._n_frames):
|
||||
frames.append(bytes((self.frame_seq + i + s) % 256
|
||||
for s in range(self.samples_per_frame)))
|
||||
self.frame_seq += 1
|
||||
return frames
|
||||
|
||||
# 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,282 @@
|
||||
"""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):
|
||||
# Small ROI well inside the stage limits: 1 row, few frames per angle.
|
||||
return build_plan(40.0, 30.0, 0.02, 0.005, 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, **kw):
|
||||
trace = Trace()
|
||||
stage = FakeStage(trace)
|
||||
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||
t3r = FakeT3R(trace, **kw)
|
||||
rotator = RotationAxis(t3r, RotationSettings())
|
||||
plan = make_plan(num_angles)
|
||||
engine = ScanEngine(stage, scope, rotator, plan, tmp_path / "out.sras",
|
||||
resume=resume,
|
||||
callbacks=callbacks or ScanCallbacks())
|
||||
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()
|
||||
stage = FakeStage(trace)
|
||||
scope = FakeScope(trace, samples_per_frame=SPF)
|
||||
# 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()
|
||||
|
||||
|
||||
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,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