diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | .python-version | 1 | ||||
| -rw-r--r-- | PicoStream.spec | 102 | ||||
| -rw-r--r-- | justfile | 12 | ||||
| -rw-r--r-- | new_plan.md | 65 | ||||
| -rw-r--r-- | picostream/cli.py | 651 | ||||
| -rw-r--r-- | picostream/dfplot.py | 50 | ||||
| -rw-r--r-- | picostream/main.py | 888 | ||||
| -rw-r--r-- | pyproject.toml | 1 |
9 files changed, 1128 insertions, 644 deletions
@@ -4,3 +4,5 @@ output.hdf5 complexipy.json
build/*
*egg-info*
+uv.lock
+dist/
diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/PicoStream.spec b/PicoStream.spec new file mode 100644 index 0000000..fdadedf --- /dev/null +++ b/PicoStream.spec @@ -0,0 +1,102 @@ +# -*- mode: python ; coding: utf-8 -*- + +import glob +import os +import sys + + +def find_libffi_dll(): + """ + Find the libffi-*.dll file required for _ctypes on Windows. + Searches in common locations for standard, venv, and Conda Python. + """ + if sys.platform != "win32": + return [] + + print("--- PyInstaller Build Environment ---") + print(f" - Python Executable: {sys.executable}") + print(f" - sys.prefix: {sys.prefix}") + if hasattr(sys, "base_prefix"): + print(f" - sys.base_prefix: {sys.base_prefix}") + else: + print(" - sys.base_prefix: Not available") + conda_prefix = os.environ.get("CONDA_PREFIX") + print(f" - CONDA_PREFIX env var: {conda_prefix}") + + search_paths = [] + # Active environment's DLLs directory + search_paths.append(os.path.join(sys.prefix, "DLLs")) + + # Base Python installation's directories (if in a venv) + if hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix: + search_paths.append(os.path.join(sys.base_prefix, "DLLs")) + search_paths.append(os.path.join(sys.base_prefix, "Library", "bin")) + + # Conda environment's directory (if CONDA_PREFIX is set) + if conda_prefix: + search_paths.append(os.path.join(conda_prefix, "Library", "bin")) + + print("\n--- Potential Search Paths for libffi ---") + for p in search_paths: + print(f" - Path: {p}, Exists? {os.path.isdir(p)}") + + print("\n--- Searching for libffi DLL on Windows ---") + unique_paths = sorted(list(set(p for p in search_paths if os.path.isdir(p)))) + + for path in unique_paths: + print(f" - Checking: {path}") + dll_pattern = os.path.join(path, "libffi-*.dll") + found_dlls = glob.glob(dll_pattern) + if found_dlls: + dll_path = found_dlls[0] + print(f" - Found: {dll_path}") + return [(dll_path, ".")] # (source, destination_in_bundle) + + print("\nERROR: Could not find libffi-*.dll on Windows.") + sys.exit(1) + + +# --- PyInstaller Spec --- +app_name = 'PicoStream' +binaries = find_libffi_dll() if sys.platform == "win32" else [] + +a = Analysis( + ['picostream/main.py'], + pathex=[], + binaries=binaries, + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=None, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=None) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name=app_name, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # useful for debugging for now + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=None, +) +# NOTE: The absence of a COLLECT block is what defines a one-file build. diff --git a/justfile b/justfile new file mode 100644 index 0000000..237121f --- /dev/null +++ b/justfile @@ -0,0 +1,12 @@ +set windows-shell := ["C:\\Program Files\\Git\\bin\\sh.exe","-c"] + + +# Run the picostream GUI application +gui: + PYTHONPATH=. uv run picostream/main.py + +# Build the picostream GUI executable +build-gui: + uv pip install pyinstaller pyinstaller-hooks-contrib + uv run pyinstaller --clean PicoStream.spec --noconfirm + diff --git a/new_plan.md b/new_plan.md new file mode 100644 index 0000000..6c7186e --- /dev/null +++ b/new_plan.md @@ -0,0 +1,65 @@ + Design + +The application will be a single-window GUI built with PyQt5. It will be composed of three main components: a main +window, a refactored plotter widget, and a background worker for data acquisition. + + 1 PicoStreamMainWindow (QMainWindow): This will be the application's central component, serving as the main entry point. + • Layout: It will feature a two-panel layout. The left panel will contain all user-configurable settings for the + acquisition (e.g., sample rate, voltage range, output file). The right panel will contain the embedded live plot. + • Control: It will have "Start" and "Stop" buttons to manage the acquisition lifecycle. It will manage the + application's state (e.g., idle, acquiring, error). + • Persistence: It will use QSettings to automatically save user-entered settings on exit and load them on startup. + • Lifecycle: It will be responsible for creating and managing the background worker thread and ensuring a graceful + shutdown. + 2 HDF5LivePlotter (QWidget): The existing plotter will be refactored from a QMainWindow into a QWidget. + • Responsibility: Its sole responsibility will be to monitor the HDF5 file and display the live data. It will no + longer be a top-level window or control the application's lifecycle. + • Integration: An instance of this widget will be created and embedded directly into the right-hand panel of the + PicoStreamMainWindow. + 3 StreamerWorker (QObject): This class will manage the acquisition task in a background thread to keep the GUI + responsive. + • Execution: It will be moved to a QThread. Its primary method will instantiate the Streamer class with parameters + from the GUI and call the blocking Streamer.run() method. + • Communication: It will use Qt signals to report its status (e.g., finished, error) back to the PicoStreamMainWindow + in a thread-safe manner. The main window will connect to these signals to update the UI, for example, by + re-enabling the "Start" button upon completion. + + Phased Implementation Plan + +This plan breaks the work into five distinct, sequential phases. + +Phase 1: Project Restructuring and GUI Shell The goal is to set up the new file structure and a basic, non-functional GUI +window. + + 1 Rename picostream/main.py to picostream/cli.py. + 2 Create a new, empty picostream/main.py to serve as the GUI entry point. + 3 In the new main.py, create a PicoStreamMainWindow class with a simple layout containing placeholders for the settings + panel and the plot. + 4 Update the justfile with a new target to run the GUI application. + +Phase 2: Background Worker Implementation The goal is to run the data acquisition in a background thread, controlled by +the GUI. + + 1 In picostream/main.py, create the StreamerWorker class inheriting from QObject. + 2 Implement the QThread worker pattern in PicoStreamMainWindow to start the acquisition when a "Start" button is clicked + and to signal a stop using the existing shutdown_event. + 3 Connect the worker's finished and error signals to GUI methods that update the UI state (e.g., re-enable buttons). + +Phase 3: GUI Controls and Settings Persistence The goal is to make the acquisition configurable through the GUI and to +remember settings. + + 1 Populate the settings panel in PicoStreamMainWindow with input widgets for all acquisition parameters. + 2 Pass the values from these widgets to the StreamerWorker when starting an acquisition. + 3 Implement load_settings and save_settings methods using QSettings. + +Phase 4: Plotter Integration The goal is to embed the live plot directly into the main window. + + 1 In picostream/dfplot.py, refactor the HDF5LivePlotter class to inherit from QWidget instead of QMainWindow. Remove its + window-management logic. + 2 In PicoStreamMainWindow, replace the plot placeholder with an instance of the refactored HDF5LivePlotter widget. + +Phase 5: Packaging The goal is to create a standalone, distributable executable. + + 1 Add a new build target to the justfile that uses PyInstaller to bundle the application. + 2 Configure the build to handle dependencies, particularly creating a hook for Numba if necessary. + 3 Test the final executable. diff --git a/picostream/cli.py b/picostream/cli.py new file mode 100644 index 0000000..2006645 --- /dev/null +++ b/picostream/cli.py @@ -0,0 +1,651 @@ +from __future__ import annotations + +import queue +import signal +import sys +import threading +import time +from datetime import datetime +from typing import TYPE_CHECKING, List, Optional + +if TYPE_CHECKING: + from PyQt5.QtWidgets import QApplication + +import click +import h5py +import numpy as np +from loguru import logger + +from . import __version__ +from .consumer import Consumer +from .pico import PicoDevice + + +class Streamer: + """Orchestrates the Picoscope data acquisition process. + + This class initializes the Picoscope device (producer), the HDF5 writer + (consumer), and the live plotter. It manages the threads, queues, and + graceful shutdown of the entire application. + + Supports both standard acquisition mode (saves all data) and live-only mode + (limits buffer size using max_buffer_seconds parameter). + """ + + def __init__( + self, + sample_rate_msps: float = 62.5, + resolution_bits: int = 12, + channel_range_str: str = "PS5000A_20V", + enable_live_plot: bool = False, + output_file: str = "./output.hdf5", + debug: bool = False, + plot_window_s: float = 0.5, + plot_points: int = 4000, + hardware_downsample: int = 1, + downsample_mode: str = "average", + offset_v: float = 0.0, + max_buffer_seconds: Optional[float] = None, + is_gui_mode: bool = False, + ) -> None: + # --- Configuration --- + self.output_file = output_file + self.debug = debug + self.enable_live_plot = enable_live_plot + self.is_gui_mode = is_gui_mode + self.plot_window_s = plot_window_s + self.max_buffer_seconds = max_buffer_seconds + + ( + sample_rate_msps, + pico_downsample_ratio, + pico_ratio_mode, + offset_v, + ) = self._validate_config( + resolution_bits, + sample_rate_msps, + channel_range_str, + hardware_downsample, + downsample_mode, + offset_v, + ) + # Dynamically size buffers to hold a specific duration of data. This makes + # memory usage proportional to the data rate, providing a consistent + # time-based buffer to handle processing latencies. + effective_rate_sps = (sample_rate_msps * 1e6) / pico_downsample_ratio + + # Consumer buffers (for writing to HDF5) are sized to hold 1 second of data. + # This is a good balance, as larger buffers lead to more efficient disk writes + # but use more RAM. + consumer_buffer_duration_s = 1.0 + self.consumer_buffer_size = int( + effective_rate_sps * consumer_buffer_duration_s + ) + if downsample_mode == "aggregate": + self.consumer_buffer_size *= 2 + self.consumer_num_buffers = 5 # A pool of 5 buffers + + # The Picoscope driver buffer is sized to hold 0.5 seconds of data. This + # buffer receives data directly from the hardware. A smaller size ensures + # that the application receives data in timely chunks, reducing latency. + driver_buffer_duration_s = 0.5 + self.pico_driver_buffer_size = int( + effective_rate_sps * driver_buffer_duration_s + ) + self.pico_driver_num_buffers = ( + 1 # A single large buffer is efficient for the driver + ) + + logger.info( + f"Consumer buffer sized to {self.consumer_buffer_size:,} samples " + f"({consumer_buffer_duration_s}s at effective rate)" + ) + logger.info( + f"Pico driver buffer sized to {self.pico_driver_buffer_size:,} samples " + f"({driver_buffer_duration_s}s at effective rate)" + ) + + # --- Plotting Decimation --- + # Calculate the decimation factor needed to achieve the target number of plot points. + points_per_timestep = 2 if downsample_mode == "aggregate" else 1 + samples_in_window = effective_rate_sps * plot_window_s * points_per_timestep + self.decimation_factor = max(1, int(samples_in_window / plot_points)) + logger.info( + f"Plotting with target of {plot_points} points. " + f"Calculated decimation factor: {self.decimation_factor}" + ) + + # Picoscope hardware settings + self.pico_resolution = f"PS5000A_DR_{resolution_bits}BIT" + self.pico_channel_range = channel_range_str + self.pico_sample_interval_ns = int(1000 / sample_rate_msps) + self.pico_sample_unit = "PS5000A_NS" + + # Streaming settings + self.pico_auto_stop = 0 # Don't auto stop + self.pico_auto_stop_stream = False + # --- End Configuration --- + + # --- System Components --- + self.shutdown_event: threading.Event = threading.Event() + data_queue: queue.Queue[int] = queue.Queue() + empty_queue: queue.Queue[int] = queue.Queue() + data_buffers: List[np.ndarray] = [] + + # Pre-allocate a pool of numpy arrays for data transfer and populate the + # empty_queue with their indices. + for idx in range(self.consumer_num_buffers): + data_buffers.append(np.empty((self.consumer_buffer_size,), dtype="int16")) + empty_queue.put(idx) + + # --- Producer --- + self.pico_device: PicoDevice = PicoDevice( + 0, # handle + self.pico_resolution, + self.pico_driver_buffer_size, + self.pico_driver_num_buffers, + self.consumer_buffer_size, + data_queue, + empty_queue, + data_buffers, + self.shutdown_event, + downsample_mode=downsample_mode, + ) + + self.pico_device.set_channel( + "PS5000A_CHANNEL_A", 1, "PS5000A_DC", self.pico_channel_range, offset_v + ) + self.pico_device.set_channel( + "PS5000A_CHANNEL_B", 0, "PS5000A_DC", self.pico_channel_range, 0.0 + ) + self.pico_device.set_data_buffer("PS5000A_CHANNEL_A", 0, pico_ratio_mode) + self.pico_device.configure_streaming_var( + self.pico_sample_interval_ns, + self.pico_sample_unit, + 0, # pre-trigger samples + pico_downsample_ratio, + pico_ratio_mode, + self.pico_auto_stop, + self.pico_auto_stop_stream, + ) + + # Run streaming once to get the actual sample interval from the driver + self.pico_device.run_streaming() + + # --- Consumer --- + # Prepare metadata for the consumer + acquisition_start_time_utc = datetime.utcnow().isoformat() + "Z" + was_live_mode = self.max_buffer_seconds is not None + + # Get metadata from configured device and pass to consumer + metadata = self.pico_device.get_metadata( + acquisition_start_time_utc=acquisition_start_time_utc, + picostream_version=__version__, + acquisition_command="", # Will be set later in main() + was_live_mode=was_live_mode + ) + + # Calculate max samples for live-only mode + max_samples = None + if self.max_buffer_seconds: + max_samples = int(effective_rate_sps * self.max_buffer_seconds) + if downsample_mode == "aggregate": + max_samples *= 2 + logger.info(f"Live-only mode: limiting buffer to {self.max_buffer_seconds}s ({max_samples:,} samples)") + + self.consumer: Consumer = Consumer( + self.consumer_buffer_size, + data_queue, + empty_queue, + data_buffers, + output_file, + self.shutdown_event, + metadata=metadata, + max_samples=max_samples, + ) + + # --- Threads --- + self.consumer_thread: threading.Thread = threading.Thread( + target=self.consumer.consume + ) + self.pico_thread: threading.Thread = threading.Thread( + target=self.pico_device.run_capture + ) + + # --- Signal Handling --- + # Only set the signal handler if not in GUI mode. + # In GUI mode, the main window handles shutdown signals. + # In CLI plot mode, the main() function handles SIGINT to quit the Qt app. + if not self.is_gui_mode and not self.enable_live_plot: + signal.signal(signal.SIGINT, self.signal_handler) + + # --- Live Plotting (optional) --- + self.start_time: Optional[float] = None + + def update_acquisition_command(self, command: str) -> None: + """Update the acquisition command in the consumer's metadata.""" + self.consumer.metadata["acquisition_command"] = command + + def _validate_config( + self, + resolution_bits: int, + sample_rate_msps: float, + channel_range_str: str, + hardware_downsample: int, + downsample_mode: str, + offset_v: float, + ) -> tuple[float, int, str, float]: + """Validates user-provided settings and returns derived configuration.""" + if resolution_bits == 8: + max_rate_msps = 125.0 + elif resolution_bits in [12, 14, 15, 16]: + max_rate_msps = 62.5 + else: + raise ValueError( + f"Unsupported resolution: {resolution_bits} bits. Must be one of 8, 12, 14, 15, 16." + ) + + if sample_rate_msps <= 0: + sample_rate_msps = max_rate_msps + logger.info(f"Max sample rate requested. Setting to {max_rate_msps} MS/s.") + + if sample_rate_msps > max_rate_msps: + raise ValueError( + f"Sample rate {sample_rate_msps} MS/s exceeds maximum of {max_rate_msps} MS/s for {resolution_bits}-bit resolution." + ) + + # Check if sample rate is excessive for the analog bandwidth. + # Bandwidth is dependent on both resolution and voltage range. + # (Based on PicoScope 5000A/B Series datasheet) + inv_voltage_map = {v: k for k, v in VOLTAGE_RANGE_MAP.items()} + voltage_v = inv_voltage_map.get(channel_range_str, 0) + + if resolution_bits == 16: + bandwidth_mhz = 20 # 20 MHz for all ranges + elif resolution_bits == 15: + # Bandwidth is 70MHz for < ±5V, 60MHz for >= ±5V + bandwidth_mhz = 70 if voltage_v < 5.0 else 60 + else: # 8-14 bits + # Bandwidth is 100MHz for < ±5V, 60MHz for >= ±5V + bandwidth_mhz = 100 if voltage_v < 5.0 else 60 + + # Nyquist rate is 2x bandwidth. A common rule of thumb is 3-5x. + # Warn if sampling faster than 5x the analog bandwidth. + if sample_rate_msps > 5 * bandwidth_mhz: + logger.warning( + f"Sample rate ({sample_rate_msps} MS/s) may be unnecessarily high " + f"for the selected voltage range ({channel_range_str}), which has an " + f"analog bandwidth of {bandwidth_mhz} MHz." + ) + + if downsample_mode == "aggregate" and hardware_downsample <= 1: + raise ValueError( + "Hardware downsample ratio must be > 1 for 'aggregate' mode." + ) + + if hardware_downsample > 1: + pico_downsample_ratio = hardware_downsample + pico_ratio_mode = f"PS5000A_RATIO_MODE_{downsample_mode.upper()}" + logger.info( + f"Hardware down-sampling ({downsample_mode}) enabled " + + f"with ratio {pico_downsample_ratio}." + ) + else: + pico_downsample_ratio = 1 + pico_ratio_mode = "PS5000A_RATIO_MODE_NONE" + + # Validate analog offset + if offset_v != 0.0: + if voltage_v >= 5.0: + raise ValueError( + f"Analog offset is not supported for voltage ranges >= 5V (selected: {channel_range_str})." + ) + if abs(offset_v) > voltage_v: + raise ValueError( + f"Analog offset ({offset_v}V) exceeds the selected voltage range (±{voltage_v}V)." + ) + logger.info(f"Analog offset set to {offset_v:.3f}V.") + + return sample_rate_msps, pico_downsample_ratio, pico_ratio_mode, offset_v + + def signal_handler(self, _sig: int, frame: Optional[object]) -> None: + """Handles Ctrl+C interrupts to initiate a graceful shutdown.""" + logger.warning("Ctrl+C detected. Shutting down.") + self.shutdown() + + def shutdown(self) -> None: + """Performs a graceful shutdown of all components. + + This method calculates final statistics, stops all threads, closes the + plotter, and ensures the Picoscope device is properly closed. + """ + if self.shutdown_event.is_set(): + return + + self._log_acquisition_summary() + self.shutdown_event.set() + + logger.info("Stopping data acquisition and saving...") + + self.pico_device.close_device() + self._join_threads() + + logger.success("Shutdown complete.") + + def _log_acquisition_summary(self) -> None: + """Calculates and logs final acquisition statistics.""" + if not self.start_time: + return + + end_time = time.time() + duration = end_time - self.start_time + total_samples = self.consumer.values_written + effective_rate_msps = (total_samples / duration) / 1e6 if duration > 0 else 0 + configured_rate_msps = 1e3 / self.pico_device.sample_int.value + + logger.info("--- Acquisition Summary ---") + logger.info(f"Total acquisition time: {duration:.2f} s") + logger.info( + "Total samples written: " + + f"{self.consumer.format_sample_count(total_samples)}" + ) + logger.info(f"Configured sample rate: {configured_rate_msps:.2f} MS/s") + logger.info(f"Effective average rate: {effective_rate_msps:.2f} MS/s") + + rate_ratio = ( + effective_rate_msps / configured_rate_msps + if configured_rate_msps > 0 + else 0 + ) + if rate_ratio < 0.95: + logger.warning( + f"Effective rate was only {rate_ratio:.1%} " + "of the configured rate." + ) + else: + logger.success("Effective rate matches configured rate.") + logger.info("--------------------------") + + def _join_threads(self) -> None: + """Waits for the producer and consumer threads to terminate.""" + for thread_name in ["pico_thread", "consumer_thread"]: + thread = getattr(self, thread_name, None) + if thread and thread.is_alive(): + logger.info(f"Waiting for {thread_name} to terminate...") + thread.join(timeout=2.0) + if thread.is_alive(): + logger.critical(f"{thread_name} failed to terminate.") + + def run(self, app: Optional[QApplication] = None) -> None: + """Starts the acquisition threads and optionally the Qt event loop.""" + # Start acquisition threads + self.start_time = time.time() + self.consumer_thread.start() + self.pico_thread.start() + + # Handle Qt event loop if plotting is enabled + if self.enable_live_plot and app: + from .dfplot import HDF5LivePlotter + + plotter = HDF5LivePlotter( + hdf5_path=self.output_file, + display_window_seconds=self.plot_window_s, + decimation_factor=self.decimation_factor, + ) + plotter.show() + + # Run the Qt event loop. This will block until the plot window is closed. + app.exec_() + + # Once the window is closed, the shutdown event should have been set. + # We call shutdown() to ensure threads are joined and cleanup happens. + self.shutdown() + else: + # In GUI mode, run() returns immediately, allowing the worker's event + # loop to process signals. In CLI mode, we block until completion. + if not self.is_gui_mode: + self.consumer_thread.join() + self.pico_thread.join() + logger.success("Acquisition complete!") + + +# --- Argument Parsing --- +VOLTAGE_RANGE_MAP = { + 0.01: "PS5000A_10MV", + 0.02: "PS5000A_20MV", + 0.05: "PS5000A_50MV", + 0.1: "PS5000A_100MV", + 0.2: "PS5000A_200MV", + 0.5: "PS5000A_500MV", + 1: "PS5000A_1V", + 2: "PS5000A_2V", + 5: "PS5000A_5V", + 10: "PS5000A_10V", + 20: "PS5000A_20V", +} + + +def generate_unique_filename(base_path: str) -> str: + """Generate a unique filename by appending timestamp if file exists.""" + import os + from pathlib import Path + + if not os.path.exists(base_path): + return base_path + + # Split the path into parts + path = Path(base_path) + stem = path.stem + suffix = path.suffix + parent = path.parent + + # Generate timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # Create new filename with timestamp + new_filename = f"{stem}_{timestamp}{suffix}" + new_path = parent / new_filename + + return str(new_path) + + +@click.command() +@click.option( + "--sample-rate", + "-s", + type=float, + default=20, + help="Sample rate in MS/s (e.g., 62.5). Use 0 for max rate. [default: 20]", +) +@click.option( + "--resolution", + "-b", + type=click.Choice(["8", "12", "16"]), + default="12", + help="Resolution in bits. [default: 12]", +) +@click.option( + "--rangev", + "-r", + type=click.Choice([str(k) for k in sorted(VOLTAGE_RANGE_MAP.keys())]), + default="20", + help=f"Voltage range in Volts. [default: 20]", +) +@click.option( + "--plot/--no-plot", + "-p", + is_flag=True, + default=True, + help="Enable/disable live plotting. [default: --plot]", +) +@click.option( + "--output", + "-o", + type=click.Path(dir_okay=False, writable=True), + help="Output HDF5 file (default: auto-timestamped).", +) +@click.option( + "--plot-window", + "-w", + type=float, + default=0.5, + help="Live plot display window duration in seconds. [default: 0.5]", +) +@click.option( + "--verbose", "-v", is_flag=True, default=False, help="Enable debug logging." +) +@click.option( + "--plot-npts", + "-n", + type=int, + default=4000, + help="Target number of points for the plot window. [default: 4000]", +) +@click.option( + "--hardware-downsample", + "-h", + type=int, + default=1, + help="Hardware down-sampling ratio (power of 2 for 'average' mode). [default: 1]", +) +@click.option( + "--downsample-mode", + "-m", + type=click.Choice(["average", "aggregate"]), + default="average", + help="Hardware down-sampling mode. [default: average]", +) +@click.option( + "--offset", + type=float, + default=0.0, + help="Analog offset in Volts (only for ranges < 5V). [default: 0.0]", +) +@click.option( + "--max-buff-sec", + type=float, + help="Maximum buffer duration in seconds for live-only mode (limits file size).", +) +@click.option( + "--force", + "-f", + is_flag=True, + default=False, + help="Overwrite existing output file.", +) +def main( + sample_rate: float, + resolution: str, + rangev: str, + plot: bool, + output: Optional[str], + plot_window: float, + verbose: bool, + plot_npts: int, + hardware_downsample: int, + downsample_mode: str, + offset: float, + max_buff_sec: Optional[float], + force: bool, +) -> None: + """High-speed data acquisition tool for Picoscope 5000a series.""" + # --- Argument Validation and Processing --- + channel_range_str = VOLTAGE_RANGE_MAP[float(rangev)] + resolution_bits = int(resolution) + + app: Optional[QApplication] = None + if plot: + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + + # When plotting, SIGINT should gracefully close the Qt application. + # The main loop will then handle the shutdown. + def sigint_handler(_sig: int, _frame: Optional[object]) -> None: + logger.warning("Ctrl+C detected. Closing application.") + QApplication.quit() + + signal.signal(signal.SIGINT, sigint_handler) + + # Configure logging + logger.remove() + log_level = "DEBUG" if verbose else "INFO" + logger.add(sys.stderr, level=log_level) + logger.info(f"Logging configured at level: {log_level}") + + # Auto-generate filename if not specified + if not output: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output = f"./output_{timestamp}.hdf5" + else: + # Check if file exists and handle accordingly + if not force: + original_output = output + output = generate_unique_filename(output) + if output != original_output: + logger.info(f"File '{original_output}' exists. Using '{output}' instead.") + logger.info("Use --force/-f to overwrite existing files.") + + logger.info(f"Output file: {output}") + logger.info(f"Selected voltage range: {rangev}V -> {channel_range_str}") + + try: + # Create and run the streamer + streamer = Streamer( + sample_rate_msps=sample_rate, + resolution_bits=resolution_bits, + channel_range_str=channel_range_str, + enable_live_plot=plot, + output_file=output, + debug=verbose, + plot_window_s=plot_window, + plot_points=plot_npts, + hardware_downsample=hardware_downsample, + downsample_mode=downsample_mode, + offset_v=offset, + max_buffer_seconds=max_buff_sec, + ) + + # Update the acquisition command in metadata + acquisition_command = " ".join(sys.argv) + streamer.update_acquisition_command(acquisition_command) + + streamer.run(app) + except RuntimeError as e: + if "PICO_NOT_FOUND" in str(e): + logger.critical( + "Picoscope device not found. Please check connection and ensure no other software is using it." + ) + else: + logger.critical(f"Failed to initialize Picoscope: {e}") + sys.exit(1) + + # --- Verification Step --- + # Skip verification in live-only mode since file size is limited + if not streamer.max_buffer_seconds: + logger.info(f"Verifying output file: {output}") + try: + expected_samples = streamer.consumer.values_written + if expected_samples == 0: + logger.warning("Consumer processed no samples. Nothing to verify.") + else: + with h5py.File(output, "r") as f: + if "adc_counts" not in f: + raise ValueError("Dataset 'adc_counts' not found in HDF5 file.") + + actual_samples = len(f["adc_counts"]) + if actual_samples == expected_samples: + logger.success( + f"Verification PASSED: File contains {actual_samples} samples, as expected." + ) + else: + logger.error( + f"Verification FAILED: Expected {expected_samples} samples, but file has {actual_samples}." + ) + except Exception as e: + logger.error(f"HDF5 file verification failed: {e}") + else: + logger.info("Skipping verification in live-only mode.") + + +if __name__ == "__main__": + main() diff --git a/picostream/dfplot.py b/picostream/dfplot.py index 50c1cba..ebf6f03 100644 --- a/picostream/dfplot.py +++ b/picostream/dfplot.py @@ -16,7 +16,6 @@ from PyQt5.QtWidgets import ( QApplication, QHBoxLayout, QLabel, - QMainWindow, QVBoxLayout, QWidget, ) @@ -24,7 +23,7 @@ from PyQt5.QtWidgets import ( from .conversion_utils import adc_to_mV, min_max_decimate_numba -class HDF5LivePlotter(QMainWindow): +class HDF5LivePlotter(QWidget): """ Real-time oscilloscope-style plotter that reads from HDF5 files. Completely independent of acquisition system for zero-risk operation. @@ -39,7 +38,6 @@ class HDF5LivePlotter(QMainWindow): update_interval_ms: int = 50, display_window_seconds: float = 0.5, decimation_factor: int = 150, - shutdown_event: Optional[threading.Event] = None, ) -> None: """Initializes the HDF5LivePlotter window. @@ -48,7 +46,6 @@ class HDF5LivePlotter(QMainWindow): update_interval_ms: How often to check the file for updates (in ms). display_window_seconds: The time duration of data to display. decimation_factor: The factor by which to decimate data for plotting. - shutdown_event: An event to signal graceful shutdown to the main application. """ super().__init__() @@ -57,7 +54,6 @@ class HDF5LivePlotter(QMainWindow): self.update_interval_ms: int = update_interval_ms self.display_window_seconds: float = display_window_seconds self.decimation_factor: int = decimation_factor - self.shutdown_event: Optional[threading.Event] = shutdown_event # --- UI State --- self.heartbeat_chars: List[str] = ["|", "/", "-", "\\"] @@ -109,10 +105,9 @@ class HDF5LivePlotter(QMainWindow): # Setup UI self.setup_ui() - # Setup update timer + # Setup update timer, which is controlled externally self.timer = QTimer() self.timer.timeout.connect(self.update_from_file) - self.timer.start(self.update_interval_ms) logger.info( f"HDF5LivePlotter initialized: path={hdf5_path}, interval={update_interval_ms}ms" @@ -121,15 +116,34 @@ class HDF5LivePlotter(QMainWindow): # Initial file check self.check_file_exists() + def set_hdf5_path(self, hdf5_path: str) -> None: + """Sets the HDF5 file path to monitor and resets the plot.""" + self.hdf5_path = hdf5_path + logger.info(f"Plotter path updated to: {hdf5_path}") + # Reset state + self.display_data = np.array([]) + self.time_data = np.array([]) + self.data_start_sample = 0 + self.last_file_size = 0 + self.buffer_reset_count = 0 + self.total_samples_processed = 0 + self.rate_check_start_time = None + self.last_displayed_size = 0 + self.curve.setData([], []) # Clear plot + self.check_file_exists() + + def start_updates(self) -> None: + """Starts the plot update timer.""" + self.timer.start(self.update_interval_ms) + + def stop_updates(self) -> None: + """Stops the plot update timer.""" + self.timer.stop() + def setup_ui(self) -> None: """Sets up the main window, widgets, and plot layout.""" - self.setWindowTitle("PicoScope Live Plotter - HDF5 Reader") - self.setGeometry(100, 100, 1200, 800) - - # Central widget - central_widget = QWidget() - self.setCentralWidget(central_widget) - layout = QVBoxLayout(central_widget) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) # Status bar status_layout = QHBoxLayout() @@ -279,6 +293,7 @@ class HDF5LivePlotter(QMainWindow): # Reset rate calculation to avoid confusion self.rate_check_start_time = None self.rate_check_start_samples = 0 + self.last_displayed_size = 0 def _handle_stale_data(self) -> None: """Handle a file check where no new data is found.""" @@ -583,6 +598,8 @@ class HDF5LivePlotter(QMainWindow): def save_screenshot(self) -> None: """Save a screenshot of the current plot.""" try: + import pyqtgraph.exporters + timestamp = time.strftime("%Y%m%d_%H%M%S") filename = f"plot_screenshot_{timestamp}.png" @@ -622,10 +639,7 @@ class HDF5LivePlotter(QMainWindow): def keyPressEvent(self, event: QKeyEvent) -> None: """Handles key presses for application control (e.g., 'Q' to quit).""" - if event.key() == Qt.Key_Q: - logger.info("'Q' key pressed. Closing application.") - self.close() - elif event.key() in (Qt.Key_S, Qt.Key_Space, Qt.Key_F12): + if event.key() in (Qt.Key_S, Qt.Key_Space, Qt.Key_F12): logger.info("Screenshot key pressed. Saving screenshot.") self.save_screenshot() else: diff --git a/picostream/main.py b/picostream/main.py index fa72518..26a9b06 100644 --- a/picostream/main.py +++ b/picostream/main.py @@ -1,646 +1,284 @@ -from __future__ import annotations -import queue -import signal -import sys -import threading -import time -from datetime import datetime -from typing import TYPE_CHECKING, List, Optional -if TYPE_CHECKING: - from PyQt5.QtWidgets import QApplication +import os +import sys +from typing import Any, Dict, Optional -import click -import h5py -import numpy as np from loguru import logger +from PyQt5.QtCore import QObject, QSettings, QThread, pyqtSignal, pyqtSlot, Qt +from PyQt5.QtGui import QCloseEvent +from PyQt5.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) -from . import __version__ -from .consumer import Consumer -from .pico import PicoDevice - - -class Streamer: - """Orchestrates the Picoscope data acquisition process. - - This class initializes the Picoscope device (producer), the HDF5 writer - (consumer), and the live plotter. It manages the threads, queues, and - graceful shutdown of the entire application. - - Supports both standard acquisition mode (saves all data) and live-only mode - (limits buffer size using max_buffer_seconds parameter). - """ - - def __init__( - self, - sample_rate_msps: float = 62.5, - resolution_bits: int = 12, - channel_range_str: str = "PS5000A_20V", - enable_live_plot: bool = False, - output_file: str = "./output.hdf5", - debug: bool = False, - plot_window_s: float = 0.5, - plot_points: int = 4000, - hardware_downsample: int = 1, - downsample_mode: str = "average", - offset_v: float = 0.0, - max_buffer_seconds: Optional[float] = None, - ) -> None: - # --- Configuration --- - self.output_file = output_file - self.debug = debug - self.enable_live_plot = enable_live_plot - self.plot_window_s = plot_window_s - self.max_buffer_seconds = max_buffer_seconds - - ( - sample_rate_msps, - pico_downsample_ratio, - pico_ratio_mode, - offset_v, - ) = self._validate_config( - resolution_bits, - sample_rate_msps, - channel_range_str, - hardware_downsample, - downsample_mode, - offset_v, - ) - # Dynamically size buffers to hold a specific duration of data. This makes - # memory usage proportional to the data rate, providing a consistent - # time-based buffer to handle processing latencies. - effective_rate_sps = (sample_rate_msps * 1e6) / pico_downsample_ratio - - # Consumer buffers (for writing to HDF5) are sized to hold 1 second of data. - # This is a good balance, as larger buffers lead to more efficient disk writes - # but use more RAM. - consumer_buffer_duration_s = 1.0 - self.consumer_buffer_size = int( - effective_rate_sps * consumer_buffer_duration_s - ) - if downsample_mode == "aggregate": - self.consumer_buffer_size *= 2 - self.consumer_num_buffers = 5 # A pool of 5 buffers - - # The Picoscope driver buffer is sized to hold 0.5 seconds of data. This - # buffer receives data directly from the hardware. A smaller size ensures - # that the application receives data in timely chunks, reducing latency. - driver_buffer_duration_s = 0.5 - self.pico_driver_buffer_size = int( - effective_rate_sps * driver_buffer_duration_s - ) - self.pico_driver_num_buffers = ( - 1 # A single large buffer is efficient for the driver - ) +from picostream.cli import Streamer, VOLTAGE_RANGE_MAP +from picostream.dfplot import HDF5LivePlotter - logger.info( - f"Consumer buffer sized to {self.consumer_buffer_size:,} samples " - f"({consumer_buffer_duration_s}s at effective rate)" - ) - logger.info( - f"Pico driver buffer sized to {self.pico_driver_buffer_size:,} samples " - f"({driver_buffer_duration_s}s at effective rate)" - ) - # --- Plotting Decimation --- - # Calculate the decimation factor needed to achieve the target number of plot points. - points_per_timestep = 2 if downsample_mode == "aggregate" else 1 - samples_in_window = effective_rate_sps * plot_window_s * points_per_timestep - self.decimation_factor = max(1, int(samples_in_window / plot_points)) - logger.info( - f"Plotting with target of {plot_points} points. " - f"Calculated decimation factor: {self.decimation_factor}" - ) +class StreamerWorker(QObject): + """Worker to run the data acquisition in a background thread.""" - # Picoscope hardware settings - self.pico_resolution = f"PS5000A_DR_{resolution_bits}BIT" - self.pico_channel_range = channel_range_str - self.pico_sample_interval_ns = int(1000 / sample_rate_msps) - self.pico_sample_unit = "PS5000A_NS" - - # Streaming settings - self.pico_auto_stop = 0 # Don't auto stop - self.pico_auto_stop_stream = False - # --- End Configuration --- - - # --- System Components --- - self.shutdown_event: threading.Event = threading.Event() - data_queue: queue.Queue[int] = queue.Queue() - empty_queue: queue.Queue[int] = queue.Queue() - data_buffers: List[np.ndarray] = [] - - # Pre-allocate a pool of numpy arrays for data transfer and populate the - # empty_queue with their indices. - for idx in range(self.consumer_num_buffers): - data_buffers.append(np.empty((self.consumer_buffer_size,), dtype="int16")) - empty_queue.put(idx) - - # --- Producer --- - self.pico_device: PicoDevice = PicoDevice( - 0, # handle - self.pico_resolution, - self.pico_driver_buffer_size, - self.pico_driver_num_buffers, - self.consumer_buffer_size, - data_queue, - empty_queue, - data_buffers, - self.shutdown_event, - downsample_mode=downsample_mode, - ) + finished = pyqtSignal() + error = pyqtSignal(str) + stopRequested = pyqtSignal() - self.pico_device.set_channel( - "PS5000A_CHANNEL_A", 1, "PS5000A_DC", self.pico_channel_range, offset_v - ) - self.pico_device.set_channel( - "PS5000A_CHANNEL_B", 0, "PS5000A_DC", self.pico_channel_range, 0.0 - ) - self.pico_device.set_data_buffer("PS5000A_CHANNEL_A", 0, pico_ratio_mode) - self.pico_device.configure_streaming_var( - self.pico_sample_interval_ns, - self.pico_sample_unit, - 0, # pre-trigger samples - pico_downsample_ratio, - pico_ratio_mode, - self.pico_auto_stop, - self.pico_auto_stop_stream, + def __init__(self, settings: Dict[str, Any]) -> None: + """Initialise the worker.""" + super().__init__() + self.streamer: Optional[Streamer] = None + self.settings = settings + + def run(self) -> None: + """Run the data acquisition.""" + try: + self.streamer = Streamer(**self.settings) + self.streamer.run() + except Exception as e: + self.error.emit(str(e)) + self.finished.emit() + + @pyqtSlot() + def stop(self) -> None: + """Signal the acquisition to stop.""" + if self.streamer: + self.streamer.shutdown() + self.finished.emit() + + +class PicoStreamMainWindow(QMainWindow): + """The main window for the PicoStream GUI application.""" + + def __init__(self) -> None: + """Initialise the main window.""" + super().__init__() + self.setWindowTitle("PicoStream") + self.setGeometry(100, 100, 1200, 600) + + self.settings = QSettings("picostream", "PicoStream") + self.thread: Optional[QThread] = None + self.worker: Optional[StreamerWorker] = None + + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QHBoxLayout(central_widget) + + # Left panel for settings + settings_panel = QWidget() + settings_panel.setFixedWidth(350) + settings_layout = QVBoxLayout(settings_panel) + form_layout = QFormLayout() + + self.sample_rate_input = QDoubleSpinBox() + self.sample_rate_input.setRange(1, 125) + self.sample_rate_input.setValue(62.5) + self.sample_rate_input.setSuffix(" MS/s") + form_layout.addRow("Sample Rate:", self.sample_rate_input) + + self.resolution_input = QComboBox() + self.resolution_input.addItems(["12", "14", "15", "16", "8"]) + form_layout.addRow("Resolution (bits):", self.resolution_input) + + self.voltage_range_input = QComboBox() + self.voltage_range_input.addItems(VOLTAGE_RANGE_MAP.values()) + form_layout.addRow("Voltage Range:", self.voltage_range_input) + + self.output_file_input = QLineEdit() + self.output_file_input.setText("output.hdf5") + file_browse_button = QPushButton("Browse...") + file_browse_button.clicked.connect(self.select_output_file) + file_layout = QHBoxLayout() + file_layout.addWidget(self.output_file_input) + file_layout.addWidget(file_browse_button) + form_layout.addRow("Output File:", file_layout) + + self.hw_downsample_input = QSpinBox() + self.hw_downsample_input.setRange(1, 1000) + form_layout.addRow("HW Downsample:", self.hw_downsample_input) + + self.downsample_mode_input = QComboBox() + self.downsample_mode_input.addItems(["average", "aggregate"]) + form_layout.addRow("Downsample Mode:", self.downsample_mode_input) + + self.offset_v_input = QDoubleSpinBox() + self.offset_v_input.setRange(-1.0, 1.0) + self.offset_v_input.setSingleStep(0.01) + self.offset_v_input.setDecimals(3) + self.offset_v_input.setSuffix(" V") + form_layout.addRow("Offset:", self.offset_v_input) + + self.live_only_checkbox = QCheckBox("Live-only (overwrite buffer)") + self.max_buffer_input = QDoubleSpinBox() + self.max_buffer_input.setRange(0.1, 60.0) + self.max_buffer_input.setValue(1.0) + self.max_buffer_input.setSuffix(" s") + self.live_only_checkbox.stateChanged.connect( + lambda state: self.max_buffer_input.setEnabled(state > 0) ) + form_layout.addRow(self.live_only_checkbox, self.max_buffer_input) + + settings_layout.addLayout(form_layout) + + self.start_button = QPushButton("Start Acquisition") + self.stop_button = QPushButton("Stop Acquisition") + self.stop_button.setEnabled(False) + + settings_layout.addWidget(self.start_button) + settings_layout.addWidget(self.stop_button) + settings_layout.addStretch() + + # Right panel for the plot + self.plotter = HDF5LivePlotter(hdf5_path=self.output_file_input.text()) + + main_layout.addWidget(settings_panel) + main_layout.addWidget(self.plotter, 1) + + # Connect signals + self.start_button.clicked.connect(self.start_acquisition) + self.stop_button.clicked.connect(self.stop_acquisition) + self.output_file_input.textChanged.connect(self.plotter.set_hdf5_path) - # Run streaming once to get the actual sample interval from the driver - self.pico_device.run_streaming() - - # --- Consumer --- - # Prepare metadata for the consumer - acquisition_start_time_utc = datetime.utcnow().isoformat() + "Z" - was_live_mode = self.max_buffer_seconds is not None - - # Get metadata from configured device and pass to consumer - metadata = self.pico_device.get_metadata( - acquisition_start_time_utc=acquisition_start_time_utc, - picostream_version=__version__, - acquisition_command="", # Will be set later in main() - was_live_mode=was_live_mode + self.load_settings() + + + def select_output_file(self) -> None: + """Open a dialog to select the output HDF5 file.""" + file_name, _ = QFileDialog.getSaveFileName( + self, "Select Output File", self.output_file_input.text(), "HDF5 Files (*.hdf5)" ) - - # Calculate max samples for live-only mode - max_samples = None - if self.max_buffer_seconds: - max_samples = int(effective_rate_sps * self.max_buffer_seconds) - if downsample_mode == "aggregate": - max_samples *= 2 - logger.info(f"Live-only mode: limiting buffer to {self.max_buffer_seconds}s ({max_samples:,} samples)") - - self.consumer: Consumer = Consumer( - self.consumer_buffer_size, - data_queue, - empty_queue, - data_buffers, - output_file, - self.shutdown_event, - metadata=metadata, - max_samples=max_samples, + if file_name: + self.output_file_input.setText(file_name) + + def start_acquisition(self) -> None: + """Start the background data acquisition.""" + self.start_button.setEnabled(False) + self.stop_button.setEnabled(True) + + output_file = self.output_file_input.text() + + # Reset plotter state and ensure a clean file for the new acquisition. + self.plotter.set_hdf5_path(output_file) + if os.path.exists(output_file): + try: + os.remove(output_file) + except OSError as e: + self.on_acquisition_error(f"Failed to remove old file: {e}") + self.on_acquisition_finished() # Reset UI state + return + + settings = { + "sample_rate_msps": self.sample_rate_input.value(), + "resolution_bits": int(self.resolution_input.currentText()), + "channel_range_str": self.voltage_range_input.currentText(), + "output_file": output_file, + "hardware_downsample": self.hw_downsample_input.value(), + "downsample_mode": self.downsample_mode_input.currentText(), + "offset_v": self.offset_v_input.value(), + "max_buffer_seconds": self.max_buffer_input.value() + if self.live_only_checkbox.isChecked() + else None, + "enable_live_plot": False, + "is_gui_mode": True, + } + + self.thread = QThread() + self.worker = StreamerWorker(settings) + self.worker.moveToThread(self.thread) + self.worker.stopRequested.connect(self.worker.stop, Qt.QueuedConnection) + + self.thread.started.connect(self.worker.run) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + self.worker.finished.connect(self.on_acquisition_finished) + self.worker.error.connect(self.on_acquisition_error) + + self.thread.start() + self.plotter.start_updates() + + def stop_acquisition(self) -> None: + """Stop the background data acquisition.""" + self.plotter.stop_updates() + if self.worker: + self.worker.stopRequested.emit() + self.stop_button.setEnabled(False) + + def on_acquisition_finished(self) -> None: + """Handle acquisition completion (both success and failure).""" + logger.info("Acquisition finished.") + self.plotter.stop_updates() + self.start_button.setEnabled(True) + self.stop_button.setEnabled(False) + self.thread = None + self.worker = None + + def on_acquisition_error(self, err_msg: str) -> None: + """Handle acquisition error.""" + logger.error(f"Acquisition error: {err_msg}") + # In a real app, this would show a QMessageBox. + # The UI state is reset in on_acquisition_finished. + + def closeEvent(self, event: QCloseEvent) -> None: + """Handle window close event.""" + self.save_settings() + if self.thread and self.thread.isRunning(): + self.stop_acquisition() + self.thread.wait() # Wait for the thread to finish + event.accept() + + def save_settings(self) -> None: + """Save current settings.""" + self.settings.setValue("sample_rate", self.sample_rate_input.value()) + self.settings.setValue("resolution", self.resolution_input.currentText()) + self.settings.setValue("voltage_range", self.voltage_range_input.currentText()) + self.settings.setValue("output_file", self.output_file_input.text()) + self.settings.setValue("hw_downsample", self.hw_downsample_input.value()) + self.settings.setValue("downsample_mode", self.downsample_mode_input.currentText()) + self.settings.setValue("offset_v", self.offset_v_input.value()) + self.settings.setValue("live_only_mode", self.live_only_checkbox.isChecked()) + self.settings.setValue("max_buffer_seconds", self.max_buffer_input.value()) + + def load_settings(self) -> None: + """Load settings.""" + self.sample_rate_input.setValue(self.settings.value("sample_rate", 62.5, type=float)) + self.resolution_input.setCurrentText(self.settings.value("resolution", "12")) + self.voltage_range_input.setCurrentText( + self.settings.value("voltage_range", "PS5000A_20V") ) - - # --- Threads --- - self.consumer_thread: threading.Thread = threading.Thread( - target=self.consumer.consume + self.output_file_input.setText(self.settings.value("output_file", "output.hdf5")) + self.hw_downsample_input.setValue(self.settings.value("hw_downsample", 1, type=int)) + self.downsample_mode_input.setCurrentText( + self.settings.value("downsample_mode", "average") ) - self.pico_thread: threading.Thread = threading.Thread( - target=self.pico_device.run_capture + self.offset_v_input.setValue(self.settings.value("offset_v", 0.0, type=float)) + live_only = self.settings.value("live_only_mode", False, type=bool) + self.live_only_checkbox.setChecked(live_only) + self.max_buffer_input.setValue( + self.settings.value("max_buffer_seconds", 1.0, type=float) ) + self.max_buffer_input.setEnabled(live_only) - # --- Signal Handling --- - # Only set the signal handler if not in GUI mode. - # In GUI mode, the main function will handle signals to quit the Qt app. - if not self.enable_live_plot: - signal.signal(signal.SIGINT, self.signal_handler) - - # --- Live Plotting (optional) --- - self.start_time: Optional[float] = None - - def update_acquisition_command(self, command: str) -> None: - """Update the acquisition command in the consumer's metadata.""" - self.consumer.metadata["acquisition_command"] = command - - def _validate_config( - self, - resolution_bits: int, - sample_rate_msps: float, - channel_range_str: str, - hardware_downsample: int, - downsample_mode: str, - offset_v: float, - ) -> tuple[float, int, str, float]: - """Validates user-provided settings and returns derived configuration.""" - if resolution_bits == 8: - max_rate_msps = 125.0 - elif resolution_bits in [12, 14, 15, 16]: - max_rate_msps = 62.5 - else: - raise ValueError( - f"Unsupported resolution: {resolution_bits} bits. Must be one of 8, 12, 14, 15, 16." - ) - - if sample_rate_msps <= 0: - sample_rate_msps = max_rate_msps - logger.info(f"Max sample rate requested. Setting to {max_rate_msps} MS/s.") - - if sample_rate_msps > max_rate_msps: - raise ValueError( - f"Sample rate {sample_rate_msps} MS/s exceeds maximum of {max_rate_msps} MS/s for {resolution_bits}-bit resolution." - ) - - # Check if sample rate is excessive for the analog bandwidth. - # Bandwidth is dependent on both resolution and voltage range. - # (Based on PicoScope 5000A/B Series datasheet) - inv_voltage_map = {v: k for k, v in VOLTAGE_RANGE_MAP.items()} - voltage_v = inv_voltage_map.get(channel_range_str, 0) - - if resolution_bits == 16: - bandwidth_mhz = 20 # 20 MHz for all ranges - elif resolution_bits == 15: - # Bandwidth is 70MHz for < ±5V, 60MHz for >= ±5V - bandwidth_mhz = 70 if voltage_v < 5.0 else 60 - else: # 8-14 bits - # Bandwidth is 100MHz for < ±5V, 60MHz for >= ±5V - bandwidth_mhz = 100 if voltage_v < 5.0 else 60 - - # Nyquist rate is 2x bandwidth. A common rule of thumb is 3-5x. - # Warn if sampling faster than 5x the analog bandwidth. - if sample_rate_msps > 5 * bandwidth_mhz: - logger.warning( - f"Sample rate ({sample_rate_msps} MS/s) may be unnecessarily high " - f"for the selected voltage range ({channel_range_str}), which has an " - f"analog bandwidth of {bandwidth_mhz} MHz." - ) - - if downsample_mode == "aggregate" and hardware_downsample <= 1: - raise ValueError( - "Hardware downsample ratio must be > 1 for 'aggregate' mode." - ) - - if hardware_downsample > 1: - pico_downsample_ratio = hardware_downsample - pico_ratio_mode = f"PS5000A_RATIO_MODE_{downsample_mode.upper()}" - logger.info( - f"Hardware down-sampling ({downsample_mode}) enabled " - + f"with ratio {pico_downsample_ratio}." - ) - else: - pico_downsample_ratio = 1 - pico_ratio_mode = "PS5000A_RATIO_MODE_NONE" - - # Validate analog offset - if offset_v != 0.0: - if voltage_v >= 5.0: - raise ValueError( - f"Analog offset is not supported for voltage ranges >= 5V (selected: {channel_range_str})." - ) - if abs(offset_v) > voltage_v: - raise ValueError( - f"Analog offset ({offset_v}V) exceeds the selected voltage range (±{voltage_v}V)." - ) - logger.info(f"Analog offset set to {offset_v:.3f}V.") - - return sample_rate_msps, pico_downsample_ratio, pico_ratio_mode, offset_v - - def signal_handler(self, _sig: int, frame: Optional[object]) -> None: - """Handles Ctrl+C interrupts to initiate a graceful shutdown.""" - logger.warning("Ctrl+C detected. Shutting down.") - self.shutdown() - - def shutdown(self) -> None: - """Performs a graceful shutdown of all components. - - This method calculates final statistics, stops all threads, closes the - plotter, and ensures the Picoscope device is properly closed. - """ - if self.shutdown_event.is_set(): - return - - self._log_acquisition_summary() - self.shutdown_event.set() - - logger.info("Stopping data acquisition and saving...") - - self._join_threads() - self.pico_device.close_device() - - logger.success("Shutdown complete.") - - def _log_acquisition_summary(self) -> None: - """Calculates and logs final acquisition statistics.""" - if not self.start_time: - return - - end_time = time.time() - duration = end_time - self.start_time - total_samples = self.consumer.values_written - effective_rate_msps = (total_samples / duration) / 1e6 if duration > 0 else 0 - configured_rate_msps = 1e3 / self.pico_device.sample_int.value - - logger.info("--- Acquisition Summary ---") - logger.info(f"Total acquisition time: {duration:.2f} s") - logger.info( - "Total samples written: " - + f"{self.consumer.format_sample_count(total_samples)}" - ) - logger.info(f"Configured sample rate: {configured_rate_msps:.2f} MS/s") - logger.info(f"Effective average rate: {effective_rate_msps:.2f} MS/s") - rate_ratio = ( - effective_rate_msps / configured_rate_msps - if configured_rate_msps > 0 - else 0 - ) - if rate_ratio < 0.95: - logger.warning( - f"Effective rate was only {rate_ratio:.1%} " + "of the configured rate." - ) - else: - logger.success("Effective rate matches configured rate.") - logger.info("--------------------------") - - def _join_threads(self) -> None: - """Waits for the producer and consumer threads to terminate.""" - for thread_name in ["pico_thread", "consumer_thread"]: - thread = getattr(self, thread_name, None) - if thread and thread.is_alive(): - logger.info(f"Waiting for {thread_name} to terminate...") - thread.join(timeout=2.0) - if thread.is_alive(): - logger.critical(f"{thread_name} failed to terminate.") - - def run(self, app: Optional[QApplication] = None) -> None: - """Starts the acquisition threads and optionally the Qt event loop.""" - # Start acquisition threads - self.start_time = time.time() - self.consumer_thread.start() - self.pico_thread.start() - - # Handle Qt event loop if plotting is enabled - if self.enable_live_plot and app: - from .dfplot import HDF5LivePlotter - - plotter = HDF5LivePlotter( - hdf5_path=self.output_file, - display_window_seconds=self.plot_window_s, - decimation_factor=self.decimation_factor, - shutdown_event=self.shutdown_event, - ) - plotter.show() - - # Run the Qt event loop. This will block until the plot window is closed. - app.exec_() - - # Once the window is closed, the shutdown event should have been set. - # We call shutdown() to ensure threads are joined and cleanup happens. - self.shutdown() - else: - # Original non-GUI behavior - self.consumer_thread.join() - self.pico_thread.join() - logger.success("Acquisition complete!") - - -# --- Argument Parsing --- -VOLTAGE_RANGE_MAP = { - 0.01: "PS5000A_10MV", - 0.02: "PS5000A_20MV", - 0.05: "PS5000A_50MV", - 0.1: "PS5000A_100MV", - 0.2: "PS5000A_200MV", - 0.5: "PS5000A_500MV", - 1: "PS5000A_1V", - 2: "PS5000A_2V", - 5: "PS5000A_5V", - 10: "PS5000A_10V", - 20: "PS5000A_20V", -} - - -def generate_unique_filename(base_path: str) -> str: - """Generate a unique filename by appending timestamp if file exists.""" - import os - from pathlib import Path - - if not os.path.exists(base_path): - return base_path - - # Split the path into parts - path = Path(base_path) - stem = path.stem - suffix = path.suffix - parent = path.parent - - # Generate timestamp - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - # Create new filename with timestamp - new_filename = f"{stem}_{timestamp}{suffix}" - new_path = parent / new_filename - - return str(new_path) - - -@click.command() -@click.option( - "--sample-rate", - "-s", - type=float, - default=20, - help="Sample rate in MS/s (e.g., 62.5). Use 0 for max rate. [default: 20]", -) -@click.option( - "--resolution", - "-b", - type=click.Choice(["8", "12", "16"]), - default="12", - help="Resolution in bits. [default: 12]", -) -@click.option( - "--rangev", - "-r", - type=click.Choice([str(k) for k in sorted(VOLTAGE_RANGE_MAP.keys())]), - default="20", - help=f"Voltage range in Volts. [default: 20]", -) -@click.option( - "--plot/--no-plot", - "-p", - is_flag=True, - default=True, - help="Enable/disable live plotting. [default: --plot]", -) -@click.option( - "--output", - "-o", - type=click.Path(dir_okay=False, writable=True), - help="Output HDF5 file (default: auto-timestamped).", -) -@click.option( - "--plot-window", - "-w", - type=float, - default=0.5, - help="Live plot display window duration in seconds. [default: 0.5]", -) -@click.option( - "--verbose", "-v", is_flag=True, default=False, help="Enable debug logging." -) -@click.option( - "--plot-npts", - "-n", - type=int, - default=4000, - help="Target number of points for the plot window. [default: 4000]", -) -@click.option( - "--hardware-downsample", - "-h", - type=int, - default=1, - help="Hardware down-sampling ratio (power of 2 for 'average' mode). [default: 1]", -) -@click.option( - "--downsample-mode", - "-m", - type=click.Choice(["average", "aggregate"]), - default="average", - help="Hardware down-sampling mode. [default: average]", -) -@click.option( - "--offset", - type=float, - default=0.0, - help="Analog offset in Volts (only for ranges < 5V). [default: 0.0]", -) -@click.option( - "--max-buff-sec", - type=float, - help="Maximum buffer duration in seconds for live-only mode (limits file size).", -) -@click.option( - "--force", - "-f", - is_flag=True, - default=False, - help="Overwrite existing output file.", -) -def main( - sample_rate: float, - resolution: str, - rangev: str, - plot: bool, - output: Optional[str], - plot_window: float, - verbose: bool, - plot_npts: int, - hardware_downsample: int, - downsample_mode: str, - offset: float, - max_buff_sec: Optional[float], - force: bool, -) -> None: - """High-speed data acquisition tool for Picoscope 5000a series.""" - # --- Argument Validation and Processing --- - channel_range_str = VOLTAGE_RANGE_MAP[float(rangev)] - resolution_bits = int(resolution) - - app: Optional[QApplication] = None - if plot: - from PyQt5.QtWidgets import QApplication - - app = QApplication(sys.argv) - - # When plotting, SIGINT should gracefully close the Qt application. - # The main loop will then handle the shutdown. - def sigint_handler(_sig: int, _frame: Optional[object]) -> None: - logger.warning("Ctrl+C detected. Closing application.") - QApplication.quit() - - signal.signal(signal.SIGINT, sigint_handler) - - # Configure logging +def main() -> None: + """GUI entry point.""" logger.remove() - log_level = "DEBUG" if verbose else "INFO" - logger.add(sys.stderr, level=log_level) - logger.info(f"Logging configured at level: {log_level}") - - # Auto-generate filename if not specified - if not output: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output = f"./output_{timestamp}.hdf5" - else: - # Check if file exists and handle accordingly - if not force: - original_output = output - output = generate_unique_filename(output) - if output != original_output: - logger.info(f"File '{original_output}' exists. Using '{output}' instead.") - logger.info("Use --force/-f to overwrite existing files.") - - logger.info(f"Output file: {output}") - logger.info(f"Selected voltage range: {rangev}V -> {channel_range_str}") - - try: - # Create and run the streamer - streamer = Streamer( - sample_rate_msps=sample_rate, - resolution_bits=resolution_bits, - channel_range_str=channel_range_str, - enable_live_plot=plot, - output_file=output, - debug=verbose, - plot_window_s=plot_window, - plot_points=plot_npts, - hardware_downsample=hardware_downsample, - downsample_mode=downsample_mode, - offset_v=offset, - max_buffer_seconds=max_buff_sec, - ) - - # Update the acquisition command in metadata - acquisition_command = " ".join(sys.argv) - streamer.update_acquisition_command(acquisition_command) - - streamer.run(app) - except RuntimeError as e: - if "PICO_NOT_FOUND" in str(e): - logger.critical( - "Picoscope device not found. Please check connection and ensure no other software is using it." - ) - else: - logger.critical(f"Failed to initialize Picoscope: {e}") - sys.exit(1) - - # --- Verification Step --- - # Skip verification in live-only mode since file size is limited - if not streamer.max_buffer_seconds: - logger.info(f"Verifying output file: {output}") - try: - expected_samples = streamer.consumer.values_written - if expected_samples == 0: - logger.warning("Consumer processed no samples. Nothing to verify.") - else: - with h5py.File(output, "r") as f: - if "adc_counts" not in f: - raise ValueError("Dataset 'adc_counts' not found in HDF5 file.") - - actual_samples = len(f["adc_counts"]) - if actual_samples == expected_samples: - logger.success( - f"Verification PASSED: File contains {actual_samples} samples, as expected." - ) - else: - logger.error( - f"Verification FAILED: Expected {expected_samples} samples, but file has {actual_samples}." - ) - except Exception as e: - logger.error(f"HDF5 file verification failed: {e}") - else: - logger.info("Skipping verification in live-only mode.") + logger.add(sys.stderr, level="INFO") + app = QApplication(sys.argv) + main_win = PicoStreamMainWindow() + main_win.show() + sys.exit(app.exec_()) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index e3e8497..a835307 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "picosdk",
"h5py",
"pyqtgraph",
- "PyQt5",
"numba",
"click",
]
|
