aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.python-version1
-rw-r--r--PicoStream.spec102
-rw-r--r--justfile12
-rw-r--r--new_plan.md65
-rw-r--r--picostream/cli.py651
-rw-r--r--picostream/dfplot.py50
-rw-r--r--picostream/main.py888
-rw-r--r--pyproject.toml1
9 files changed, 1128 insertions, 644 deletions
diff --git a/.gitignore b/.gitignore
index a7c3d30..d66a8e1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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",
]