diff --git a/README.md b/README.md index 7714eb5..0d90f95 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/SETUP.md b/SETUP.md index 4213ae8..bec875e 100755 --- a/SETUP.md +++ b/SETUP.md @@ -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 diff --git a/hardware/helios_laser.py b/hardware/helios_laser.py index de9dff4..7b85eb7 100755 --- a/hardware/helios_laser.py +++ b/hardware/helios_laser.py @@ -29,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 @@ -48,15 +42,7 @@ class HeliosLaser: 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 @@ -91,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 @@ -164,15 +142,7 @@ class HeliosLaser: 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 @@ -189,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 @@ -206,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) @@ -329,15 +275,7 @@ 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) diff --git a/hardware/pybbd202/apt_messages.py b/hardware/pybbd202/apt_messages.py index 2706140..056b321 100755 --- a/hardware/pybbd202/apt_messages.py +++ b/hardware/pybbd202/apt_messages.py @@ -280,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 diff --git a/hardware/pybbd202/bbd20x.py b/hardware/pybbd202/bbd20x.py index 71cc892..0e7b6a6 100755 --- a/hardware/pybbd202/bbd20x.py +++ b/hardware/pybbd202/bbd20x.py @@ -344,7 +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. ''' - ch = self._channel_for(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) diff --git a/hardware/tektronix_base.py b/hardware/tektronix_base.py index 6417813..dd3153b 100755 --- a/hardware/tektronix_base.py +++ b/hardware/tektronix_base.py @@ -93,13 +93,7 @@ class TektronixOscilloscopeBase: self._connected = False def connect(self): - """ - Establish TCP socket connection to the oscilloscope. - - Raises: - ValueError: If resource_name is not provided - ConnectionError: If connection fails - """ + """Establish TCP socket connection to the oscilloscope.""" if not self.resource_name: raise ValueError("resource_name (IP address/hostname) must be provided") @@ -114,7 +108,8 @@ class TektronixOscilloscopeBase: except socket.error as e: self.socket = None self._connected = False - raise ConnectionError(f"Failed to connect to {self.resource_name}:{self.port} - {e}") + raise ConnectionError( + f"Failed to connect to {self.resource_name}:{self.port} - {e}") from e def disconnect(self): """ @@ -130,18 +125,7 @@ class TektronixOscilloscopeBase: self._connected = False def _normalize_channel(self, channel): - """ - Normalize channel input to integer (1-4). - - Args: - channel: Channel as int (1-4) or string ('CH1'-'CH4') - - Returns: - int: Channel number (1-4) - - Raises: - ValueError: If channel is invalid - """ + """Normalize channel input to integer (1-4).""" if isinstance(channel, str): channel_upper = channel.upper() if not (channel_upper.startswith('CH') and len(channel_upper) == 3 and channel_upper[2].isdigit()): @@ -154,27 +138,13 @@ class TektronixOscilloscopeBase: return channel def set_acquire_mode(self, mode): - """ - Set the acquisition mode. - - Args: - mode: Acquisition mode to set (case-insensitive). Valid options: - 'SAMple' or 'SAM' or 'SAMPLE' - Sample mode (default) - 'PEAKdetect' or 'PEAK' or 'PEAKDETECT' - Peak detect mode - 'HIRes' or 'HIR' or 'HIRES' - High resolution mode - 'AVErage' or 'AVE' or 'AVERAGE' - Average mode - 'ENVelope' or 'ENV' or 'ENVELOPE' - Envelope mode - - Raises: - ValueError: If mode is not valid - RuntimeError: If not connected - """ + """Set the acquisition mode.""" # Normalize mode to uppercase for comparison mode_upper = mode.upper() # Check if mode is valid (any short or long form) valid = False - for long_form, variants in self.ACQUIRE_MODES.items(): + for variants in self.ACQUIRE_MODES.values(): if mode_upper in [v.upper() for v in variants]: valid = True break @@ -189,30 +159,12 @@ class TektronixOscilloscopeBase: self.write(f"ACQuire:MODe {mode}") def get_fastframe_state(self): - """ - Query the current FastFrame state. - - Returns: - int: 0 if FastFrame is off, 1 if active - - Raises: - RuntimeError: If not connected - """ + """Query the current FastFrame state.""" response = self.query("HORizontal:FASTframe:STATE?") return int(response) def set_fastframe_state(self, state): - """ - Enable or disable FastFrame mode. - - Args: - state: FastFrame state (0 = off, 1 = active) - Can be int (0/1) or bool (False/True) - - Raises: - ValueError: If state is not valid - RuntimeError: If not connected - """ + """Enable or disable FastFrame mode.""" # Convert boolean to int if needed if isinstance(state, bool): state = 1 if state else 0 @@ -224,16 +176,7 @@ class TektronixOscilloscopeBase: self.write(f"HORizontal:FASTframe:STATE {state}") def set_fastframe_count(self, count): - """ - Set the number of FastFrame frames. - - Args: - count: Number of frames to capture (must be positive integer) - - Raises: - ValueError: If count is not valid - RuntimeError: If not connected - """ + """Set the number of FastFrame frames.""" # Validate count if not isinstance(count, int) or count <= 0: raise ValueError(f"Invalid frame count: {count}. Must be a positive integer") @@ -241,29 +184,12 @@ class TektronixOscilloscopeBase: self.write(f"HORizontal:FASTframe:COUNt {count}") def get_record_length(self): - """ - Query the current horizontal record length. - - Returns: - int: Current record length in samples - - Raises: - RuntimeError: If not connected - """ + """Query the current horizontal record length.""" response = self.query("HORizontal:MODe:RECOrdlength?") return int(response) def set_sample_rate(self, rate): - """ - Set the horizontal sample rate. - - Args: - rate: Sample rate in samples per second (must be positive number) - - Raises: - ValueError: If rate is not valid - RuntimeError: If not connected - """ + """Set the horizontal sample rate.""" # Validate rate if not isinstance(rate, (int, float)) or rate <= 0: raise ValueError(f"Invalid sample rate: {rate}. Must be a positive number") @@ -271,24 +197,12 @@ class TektronixOscilloscopeBase: self.write(f"HORizontal:MODe:SAMPLERate {rate}") def set_trigger_slope(self, slope): - """ - Set the trigger slope. - - Args: - slope: Trigger slope (case-insensitive). Valid options: - 'RISe' or 'RISE' - Rising edge - 'FALL' - Falling edge - 'EITher' or 'EITHER' - Either edge - - Raises: - ValueError: If slope is not valid - RuntimeError: If not connected - """ + """Set the trigger slope.""" slope_upper = slope.upper() # Check if slope is valid valid = False - for long_form, variants in self.TRIGGER_SLOPE.items(): + for variants in self.TRIGGER_SLOPE.values(): if slope_upper in [v.upper() for v in variants]: valid = True break @@ -303,18 +217,7 @@ class TektronixOscilloscopeBase: self.write(f"TRIGger:A:EDGE:SLOpe {slope}") def set_trigger_source(self, source): - """ - Set the trigger source. - - Args: - source: Trigger source channel. Valid options: - 'CH1', 'CH2', 'CH3', 'CH4' (case-insensitive) - Can also pass as integer 1-4 - - Raises: - ValueError: If source is not valid - RuntimeError: If not connected - """ + """Set the trigger source.""" # Convert integer to channel string if needed if isinstance(source, int): if source < 1 or source > 4: @@ -333,17 +236,7 @@ class TektronixOscilloscopeBase: self.write(f"TRIGger:A:EDGE:SOUrce {source}") def set_trigger_level(self, channel, level): - """ - Set the trigger level for a specific channel. - - Args: - channel: Channel number (1-4) or channel string ('CH1'-'CH4') - level: Trigger level in volts - - Raises: - ValueError: If channel or level is not valid - RuntimeError: If not connected - """ + """Set the trigger level for a specific channel.""" # Convert to channel number if string if isinstance(channel, str): channel_upper = channel.upper() @@ -362,23 +255,12 @@ class TektronixOscilloscopeBase: self.write(f"TRIGger:A:LEVel:CH{channel} {level}") def set_trigger_mode(self, mode): - """ - Set the trigger mode. - - Args: - mode: Trigger mode (case-insensitive). Valid options: - 'AUTO' - Auto trigger mode - 'NORMal' or 'NORMAL' - Normal trigger mode - - Raises: - ValueError: If mode is not valid - RuntimeError: If not connected - """ + """Set the trigger mode.""" mode_upper = mode.upper() # Check if mode is valid valid = False - for long_form, variants in self.TRIGGER_MODE.items(): + for variants in self.TRIGGER_MODE.values(): if mode_upper in [v.upper() for v in variants]: valid = True break @@ -395,38 +277,18 @@ class TektronixOscilloscopeBase: # ========== Channel Control Methods ========== def set_channel_bandwidth(self, channel, bandwidth): - """ - Set the bandwidth for a specific channel. - - Args: - channel: Channel number (1-4) or channel string ('CH1'-'CH4') - bandwidth: Bandwidth setting (depends on scope model) - - Raises: - ValueError: If channel is not valid - RuntimeError: If not connected - """ + """Set the bandwidth for a specific channel.""" channel = self._normalize_channel(channel) self.write(f"CH{channel}:BANdwidth {bandwidth}") def set_channel_coupling(self, channel, coupling): - """ - Set the coupling mode for a specific channel. - - Args: - channel: Channel number (1-4) or channel string ('CH1'-'CH4') - coupling: Coupling mode (AC or DC, case-insensitive) - - Raises: - ValueError: If channel or coupling is not valid - RuntimeError: If not connected - """ + """Set the coupling mode for a specific channel.""" channel = self._normalize_channel(channel) coupling_upper = coupling.upper() # Validate coupling valid = False - for long_form, variants in self.CHANNEL_COUPLING.items(): + for variants in self.CHANNEL_COUPLING.items(): if coupling_upper in [v.upper() for v in variants]: valid = True break @@ -441,17 +303,7 @@ class TektronixOscilloscopeBase: self.write(f"CH{channel}:COUPling {coupling}") def set_channel_label_name(self, channel, name): - """ - Set the label name for a specific channel. - - Args: - channel: Channel number (1-4) or channel string ('CH1'-'CH4') - name: Label name as string - - Raises: - ValueError: If channel is not valid - RuntimeError: If not connected - """ + """Set the label name for a specific channel.""" channel = self._normalize_channel(channel) if not isinstance(name, str): @@ -461,17 +313,7 @@ class TektronixOscilloscopeBase: self.write(f'CH{channel}:LABel:NAMe "{name}"') def set_channel_position(self, channel, position): - """ - Set the vertical position for a specific channel. - - Args: - channel: Channel number (1-4) or channel string ('CH1'-'CH4') - position: Vertical position in divisions (can be negative or positive) - - Raises: - ValueError: If channel or position is not valid - RuntimeError: If not connected - """ + """Set the vertical position for a specific channel.""" channel = self._normalize_channel(channel) if not isinstance(position, (int, float)): @@ -480,17 +322,7 @@ class TektronixOscilloscopeBase: self.write(f"CH{channel}:POSition {position}") def set_channel_scale(self, channel, scale): - """ - Set the vertical scale for a specific channel. - - Args: - channel: Channel number (1-4) or channel string ('CH1'-'CH4') - scale: Vertical scale in volts per division (must be positive) - - Raises: - ValueError: If channel or scale is not valid - RuntimeError: If not connected - """ + """Set the vertical scale for a specific channel.""" channel = self._normalize_channel(channel) if not isinstance(scale, (int, float)) or scale <= 0: @@ -499,17 +331,7 @@ class TektronixOscilloscopeBase: self.write(f"CH{channel}:SCAle {scale}") def set_channel_termination(self, channel, termination): - """ - Set the termination for a specific channel. - - Args: - channel: Channel number (1-4) or channel string ('CH1'-'CH4') - termination: Termination in ohms (50 or 1000000) - - Raises: - ValueError: If channel or termination is not valid - RuntimeError: If not connected - """ + """Set the termination for a specific channel.""" channel = self._normalize_channel(channel) if termination not in self.CHANNEL_TERMINATION: @@ -521,18 +343,7 @@ class TektronixOscilloscopeBase: # ========== Waveform Transfer Methods ========== def set_data_source(self, source): - """ - Set the data source for waveform transfer. - - Args: - source: Data source channel. Valid options: - 'CH1', 'CH2', 'CH3', 'CH4' (case-insensitive) - Can also pass as integer 1-4 - - Raises: - ValueError: If source is not valid - RuntimeError: If not connected - """ + """Set the data source for waveform transfer.""" # Convert integer to channel string if needed if isinstance(source, int): if source < 1 or source > 4: @@ -551,15 +362,7 @@ class TektronixOscilloscopeBase: self.write(f"DATa:SOUrce {source}") def query_wfmoutpre(self): - """ - Query all waveform output preamble parameters. - - Returns: - str: Complete waveform preamble string with all parameters - - Raises: - RuntimeError: If not connected - """ + """Query all waveform output preamble parameters.""" return self.query("WFMOutpre?") def transfer_curve(self): @@ -627,7 +430,7 @@ class TektronixOscilloscopeBase: # fewer frames than configured — reading the configured count would block. acquired = int(self.query("ACQuire:NUMFRAMESACQuired?")) if acquired <= 0: - raise RuntimeError(f"Scope acquired 0 FastFrame frames — no data to read") + raise RuntimeError("Scope acquired 0 FastFrame frames — no data to read") frame_count = acquired # Send a single CURVe? query - scope will return all frames @@ -653,21 +456,7 @@ class TektronixOscilloscopeBase: return waveforms def parse_curve_data(self, curve_bytes, byte_count=1, signed=True, byte_order='MSB'): - """ - Parse raw curve data into integer array. - - Args: - curve_bytes: Raw binary curve data - byte_count: Number of bytes per sample (1 or 2) - signed: True for signed integer, False for unsigned - byte_order: 'MSB' or 'LSB' for byte order - - Returns: - list: List of integer values from the curve data - - Raises: - ValueError: If parameters are invalid - """ + """Parse raw curve data into integer array.""" if byte_count not in (1, 2): raise ValueError(f"Invalid byte count: {byte_count}. Must be 1 or 2") @@ -709,16 +498,7 @@ class TektronixOscilloscopeBase: return values def write(self, command): - """ - Send a raw SCPI command to the instrument. - - Args: - command: SCPI command string - - Raises: - RuntimeError: If not connected - socket.error: If communication fails - """ + """Send a raw SCPI command to the instrument.""" if not self._connected or not self.socket: raise RuntimeError("Not connected to instrument") @@ -728,19 +508,7 @@ class TektronixOscilloscopeBase: self.socket.sendall(command.encode('ascii')) def query(self, command): - """ - Send a SCPI query and return the response. - - Args: - command: SCPI query command string - - Returns: - str: Response from the instrument (stripped of trailing newline) - - Raises: - RuntimeError: If not connected - socket.error: If communication fails - """ + """Send a SCPI query and return the response.""" if not self._connected or not self.socket: raise RuntimeError("Not connected to instrument") diff --git a/hardware/uc480_camera.py b/hardware/uc480_camera.py index b935ece..eb8c741 100755 --- a/hardware/uc480_camera.py +++ b/hardware/uc480_camera.py @@ -91,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 @@ -131,12 +126,7 @@ class UC480Camera(QObject): self._settings_lock = threading.Lock() def initialize(self) -> bool: - """ - Initialize the camera and allocate memory. - - Returns: - True if successful, False otherwise - """ + """Initialize the camera and allocate memory.""" try: # Initialize camera. After is_ExitCamera the UI124x series # resets and re-enumerates on USB (firmware reload), so retry @@ -264,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 @@ -311,12 +296,7 @@ class UC480Camera(QObject): return ret == ueye.IS_SUCCESS def stop_capture(self) -> bool: - """ - Stop continuous video capture. - - Returns: - True if successful, False otherwise - """ + """Stop continuous video capture.""" if not self.is_capturing: return True @@ -331,12 +311,7 @@ class UC480Camera(QObject): return True 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 @@ -377,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 @@ -405,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 @@ -428,12 +390,7 @@ class UC480Camera(QObject): return None def get_pixel_clock_range(self) -> Optional[Tuple[int, int, int]]: - """ - Query the sensor's supported pixel clock range. - - Returns: - (min_mhz, max_mhz, increment_mhz), or None if the query failed - """ + """Query the sensor's supported pixel clock range.""" if not self.is_initialized: return None @@ -452,15 +409,7 @@ class UC480Camera(QObject): return None def set_pixel_clock(self, pixel_clock_mhz: int) -> bool: - """ - Set camera pixel clock. - - Args: - pixel_clock_mhz: Pixel clock in MHz - - Returns: - True if successful, False otherwise - """ + """Set camera pixel clock.""" if not self.is_initialized: return False @@ -512,15 +461,7 @@ class UC480Camera(QObject): return False 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 @@ -541,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: @@ -551,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 {} @@ -582,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 diff --git a/ruff.toml b/ruff.toml index 07efc9e..2d67558 100644 --- a/ruff.toml +++ b/ruff.toml @@ -9,11 +9,11 @@ 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] -# Wildcard re-exports are part of this package's (legacy) public surface -# until Phase 1 empties it. -"hardware/__init__.py" = ["F401", "F403"] -"hardware/pybbd202/__init__.py" = ["F401", "F403"] -"scanning/__init__.py" = ["F401", "F403"] +# Deliberate package re-exports +"hardware/pybbd202/__init__.py" = ["F401"] +# Quarantined pending hardware verification (docs/genesis_verification.md) +"tools/genesis_laser_gui.py" = ["B007"] diff --git a/sc3_aui_app.py b/sc3_aui_app.py index d7ca45f..4aed6ef 100755 --- a/sc3_aui_app.py +++ b/sc3_aui_app.py @@ -13,7 +13,7 @@ from threading import Thread import numpy as np from PyQt6 import uic -from PyQt6.QtCore import QObject, QThread, QTimer, Qt, pyqtSignal +from PyQt6.QtCore import QThread, QTimer, Qt, pyqtSignal from PyQt6.QtGui import QImage, QPixmap from PyQt6.QtWidgets import ( QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel, diff --git a/t3r_control_panel.py b/t3r_control_panel.py index 962feea..7898b38 100755 --- a/t3r_control_panel.py +++ b/t3r_control_panel.py @@ -643,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")