diff options
| author | Sam Scholten | 2025-10-13 19:17:27 +1000 |
|---|---|---|
| committer | Sam Scholten | 2025-10-13 19:51:43 +1000 |
| commit | 62d4a04e2c2d6bff1a3ec9a90bc1a61927ef909f (patch) | |
| tree | 743fb86c76c11b6cc7efcb70fc111218dea2b5a1 | |
| parent | 6482423740314963b114af4b1827b667edcdfd19 (diff) | |
| download | picostream-62d4a04e2c2d6bff1a3ec9a90bc1a61927ef909f.tar.gz picostream-62d4a04e2c2d6bff1a3ec9a90bc1a61927ef909f.zip | |
New gui + packaging
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | justfile | 12 | ||||
| -rw-r--r-- | new_plan.md | 65 | ||||
| -rw-r--r-- | picostream/cli.py | 647 | ||||
| -rw-r--r-- | picostream/dfplot.py | 38 | ||||
| -rw-r--r-- | picostream/main.py | 876 | ||||
| -rw-r--r-- | uv.lock | 268 |
7 files changed, 1264 insertions, 645 deletions
@@ -4,3 +4,6 @@ output.hdf5 complexipy.json
build/*
*egg-info*
+
+*.spec
+dist/
diff --git a/justfile b/justfile new file mode 100644 index 0000000..478f4f3 --- /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 --onefile --windowed --name PicoStream picostream/main.py + 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..fa72518 --- /dev/null +++ b/picostream/cli.py @@ -0,0 +1,647 @@ +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, + ) -> 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 + ) + + 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 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 + 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..89784ac 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] = ["|", "/", "-", "\\"] @@ -121,15 +117,26 @@ 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 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() @@ -583,6 +590,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 +631,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..b2f03b4 100644 --- a/picostream/main.py +++ b/picostream/main.py @@ -1,646 +1,264 @@ -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, - ) -> 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 typing import Any, Dict, Optional + +from PyQt5.QtCore import QObject, QSettings, QThread, pyqtSignal +from PyQt5.QtGui import QCloseEvent +from PyQt5.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) - 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)" - ) +from picostream.cli import Streamer, VOLTAGE_RANGE_MAP +from picostream.dfplot import HDF5LivePlotter - # --- 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, - ) +class StreamerWorker(QObject): + """Worker to run the data acquisition in a background thread.""" - 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, + finished = pyqtSignal() + error = pyqtSignal(str) + + 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)) + return # Do not emit finished signal on error + self.finished.emit() + + def stop(self) -> None: + """Signal the acquisition to stop.""" + if self.streamer: + self.streamer.shutdown() + + +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) + + 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": self.output_file_input.text(), + "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, + } + + self.thread = QThread() + self.worker = StreamerWorker(settings) + self.worker.moveToThread(self.thread) + + 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() + + def stop_acquisition(self) -> None: + """Stop the background data acquisition.""" + if self.worker: + self.worker.stop() + self.stop_button.setEnabled(False) + + def on_acquisition_finished(self) -> None: + """Handle successful acquisition completion.""" + print("Acquisition finished.") + 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.""" + print(f"Acquisition error: {err_msg}") + # In a real app, show a QMessageBox + self.start_button.setEnabled(True) + self.stop_button.setEnabled(False) + self.thread = None + self.worker = None + + 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 - 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.") +def main() -> None: + """GUI entry point.""" + app = QApplication(sys.argv) + main_win = PicoStreamMainWindow() + main_win.show() + sys.exit(app.exec_()) if __name__ == "__main__": @@ -0,0 +1,268 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h5py" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/57/dfb3c5c3f1bf5f5ef2e59a22dec4ff1f3d7408b55bfcefcfb0ea69ef21c6/h5py-3.14.0.tar.gz", hash = "sha256:2372116b2e0d5d3e5e705b7f663f7c8d96fa79a4052d250484ef91d24d6a08f4", size = 424323, upload-time = "2025-06-06T14:06:15.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/77/8f651053c1843391e38a189ccf50df7e261ef8cd8bfd8baba0cbe694f7c3/h5py-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0045115d83272090b0717c555a31398c2c089b87d212ceba800d3dc5d952e23", size = 3312740, upload-time = "2025-06-06T14:05:01.193Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/20436a6cf419b31124e59fefc78d74cb061ccb22213226a583928a65d715/h5py-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6da62509b7e1d71a7d110478aa25d245dd32c8d9a1daee9d2a42dba8717b047a", size = 2829207, upload-time = "2025-06-06T14:05:05.061Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/c8bfe8543bfdd7ccfafd46d8cfd96fce53d6c33e9c7921f375530ee1d39a/h5py-3.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554ef0ced3571366d4d383427c00c966c360e178b5fb5ee5bb31a435c424db0c", size = 4708455, upload-time = "2025-06-06T14:05:11.528Z" }, + { url = "https://files.pythonhosted.org/packages/86/f9/f00de11c82c88bfc1ef22633557bfba9e271e0cb3189ad704183fc4a2644/h5py-3.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cbd41f4e3761f150aa5b662df991868ca533872c95467216f2bec5fcad84882", size = 4929422, upload-time = "2025-06-06T14:05:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6d/6426d5d456f593c94b96fa942a9b3988ce4d65ebaf57d7273e452a7222e8/h5py-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bf4897d67e613ecf5bdfbdab39a1158a64df105827da70ea1d90243d796d367f", size = 2862845, upload-time = "2025-06-06T14:05:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/7efe82d09ca10afd77cd7c286e42342d520c049a8c43650194928bcc635c/h5py-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa4b7bbce683379b7bf80aaba68e17e23396100336a8d500206520052be2f812", size = 3289245, upload-time = "2025-06-06T14:05:28.24Z" }, + { url = "https://files.pythonhosted.org/packages/4f/31/f570fab1239b0d9441024b92b6ad03bb414ffa69101a985e4c83d37608bd/h5py-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9603a501a04fcd0ba28dd8f0995303d26a77a980a1f9474b3417543d4c6174", size = 2807335, upload-time = "2025-06-06T14:05:31.997Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ce/3a21d87896bc7e3e9255e0ad5583ae31ae9e6b4b00e0bcb2a67e2b6acdbc/h5py-3.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8cbaf6910fa3983c46172666b0b8da7b7bd90d764399ca983236f2400436eeb", size = 4700675, upload-time = "2025-06-06T14:05:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ec/86f59025306dcc6deee5fda54d980d077075b8d9889aac80f158bd585f1b/h5py-3.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d90e6445ab7c146d7f7981b11895d70bc1dd91278a4f9f9028bc0c95e4a53f13", size = 4921632, upload-time = "2025-06-06T14:05:43.464Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6d/0084ed0b78d4fd3e7530c32491f2884140d9b06365dac8a08de726421d4a/h5py-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:ae18e3de237a7a830adb76aaa68ad438d85fe6e19e0d99944a3ce46b772c69b3", size = 2852929, upload-time = "2025-06-06T14:05:47.659Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/8d/5baf1cef7f9c084fb35a8afbde88074f0d6a727bc63ef764fe0e7543ba40/llvmlite-0.45.1.tar.gz", hash = "sha256:09430bb9d0bb58fc45a45a57c7eae912850bedc095cd0810a57de109c69e1c32", size = 185600, upload-time = "2025-10-01T17:59:52.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/7c/82cbd5c656e8991bcc110c69d05913be2229302a92acb96109e166ae31fb/llvmlite-0.45.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:28e763aba92fe9c72296911e040231d486447c01d4f90027c8e893d89d49b20e", size = 43043524, upload-time = "2025-10-01T18:03:30.666Z" }, + { url = "https://files.pythonhosted.org/packages/9d/bc/5314005bb2c7ee9f33102c6456c18cc81745d7055155d1218f1624463774/llvmlite-0.45.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1a53f4b74ee9fd30cb3d27d904dadece67a7575198bd80e687ee76474620735f", size = 37253123, upload-time = "2025-10-01T18:04:18.177Z" }, + { url = "https://files.pythonhosted.org/packages/96/76/0f7154952f037cb320b83e1c952ec4a19d5d689cf7d27cb8a26887d7bbc1/llvmlite-0.45.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b3796b1b1e1c14dcae34285d2f4ea488402fbd2c400ccf7137603ca3800864f", size = 56288211, upload-time = "2025-10-01T18:01:24.079Z" }, + { url = "https://files.pythonhosted.org/packages/00/b1/0b581942be2683ceb6862d558979e87387e14ad65a1e4db0e7dd671fa315/llvmlite-0.45.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:779e2f2ceefef0f4368548685f0b4adde34e5f4b457e90391f570a10b348d433", size = 55140958, upload-time = "2025-10-01T18:02:30.482Z" }, + { url = "https://files.pythonhosted.org/packages/33/94/9ba4ebcf4d541a325fd8098ddc073b663af75cc8b065b6059848f7d4dce7/llvmlite-0.45.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e6c9949baf25d9aa9cd7cf0f6d011b9ca660dd17f5ba2b23bdbdb77cc86b116", size = 38132231, upload-time = "2025-10-01T18:05:03.664Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e2/c185bb7e88514d5025f93c6c4092f6120c6cea8fe938974ec9860fb03bbb/llvmlite-0.45.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:d9ea9e6f17569a4253515cc01dade70aba536476e3d750b2e18d81d7e670eb15", size = 43043524, upload-time = "2025-10-01T18:03:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/b5437b9ecb2064e89ccf67dccae0d02cd38911705112dd0dcbfa9cd9a9de/llvmlite-0.45.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c9f3cadee1630ce4ac18ea38adebf2a4f57a89bd2740ce83746876797f6e0bfb", size = 37253121, upload-time = "2025-10-01T18:04:30.557Z" }, + { url = "https://files.pythonhosted.org/packages/f7/97/ad1a907c0173a90dd4df7228f24a3ec61058bc1a9ff8a0caec20a0cc622e/llvmlite-0.45.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:57c48bf2e1083eedbc9406fb83c4e6483017879714916fe8be8a72a9672c995a", size = 56288210, upload-time = "2025-10-01T18:01:40.26Z" }, + { url = "https://files.pythonhosted.org/packages/32/d8/c99c8ac7a326e9735401ead3116f7685a7ec652691aeb2615aa732b1fc4a/llvmlite-0.45.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aa3dfceda4219ae39cf18806c60eeb518c1680ff834b8b311bd784160b9ce40", size = 55140957, upload-time = "2025-10-01T18:02:46.244Z" }, + { url = "https://files.pythonhosted.org/packages/09/56/ed35668130e32dbfad2eb37356793b0a95f23494ab5be7d9bf5cb75850ee/llvmlite-0.45.1-cp313-cp313-win_amd64.whl", hash = "sha256:080e6f8d0778a8239cd47686d402cb66eb165e421efa9391366a9b7e5810a38b", size = 38132232, upload-time = "2025-10-01T18:05:14.477Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "numba" +version = "0.62.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/20/33dbdbfe60e5fd8e3dbfde299d106279a33d9f8308346022316781368591/numba-0.62.1.tar.gz", hash = "sha256:7b774242aa890e34c21200a1fc62e5b5757d5286267e71103257f4e2af0d5161", size = 2749817, upload-time = "2025-09-29T10:46:31.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/fa/30fa6873e9f821c0ae755915a3ca444e6ff8d6a7b6860b669a3d33377ac7/numba-0.62.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:1b743b32f8fa5fff22e19c2e906db2f0a340782caf024477b97801b918cf0494", size = 2685346, upload-time = "2025-09-29T10:43:43.677Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d5/504ce8dc46e0dba2790c77e6b878ee65b60fe3e7d6d0006483ef6fde5a97/numba-0.62.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:90fa21b0142bcf08ad8e32a97d25d0b84b1e921bc9423f8dda07d3652860eef6", size = 2688139, upload-time = "2025-09-29T10:44:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/50/5f/6a802741176c93f2ebe97ad90751894c7b0c922b52ba99a4395e79492205/numba-0.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ef84d0ac19f1bf80431347b6f4ce3c39b7ec13f48f233a48c01e2ec06ecbc59", size = 3796453, upload-time = "2025-09-29T10:42:52.771Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/efd21527d25150c4544eccc9d0b7260a5dec4b7e98b5a581990e05a133c0/numba-0.62.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9315cc5e441300e0ca07c828a627d92a6802bcbf27c5487f31ae73783c58da53", size = 3496451, upload-time = "2025-09-29T10:43:19.279Z" }, + { url = "https://files.pythonhosted.org/packages/80/44/79bfdab12a02796bf4f1841630355c82b5a69933b1d50eb15c7fa37dabe8/numba-0.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:44e3aa6228039992f058f5ebfcfd372c83798e9464297bdad8cc79febcf7891e", size = 2745552, upload-time = "2025-09-29T10:44:26.399Z" }, + { url = "https://files.pythonhosted.org/packages/22/76/501ea2c07c089ef1386868f33dff2978f43f51b854e34397b20fc55e0a58/numba-0.62.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:b72489ba8411cc9fdcaa2458d8f7677751e94f0109eeb53e5becfdc818c64afb", size = 2685766, upload-time = "2025-09-29T10:43:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/444986ed95350c0611d5c7b46828411c222ce41a0c76707c36425d27ce29/numba-0.62.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:44a1412095534a26fb5da2717bc755b57da5f3053965128fe3dc286652cc6a92", size = 2688741, upload-time = "2025-09-29T10:44:10.07Z" }, + { url = "https://files.pythonhosted.org/packages/78/7e/bf2e3634993d57f95305c7cee4c9c6cb3c9c78404ee7b49569a0dfecfe33/numba-0.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c9460b9e936c5bd2f0570e20a0a5909ee6e8b694fd958b210e3bde3a6dba2d7", size = 3804576, upload-time = "2025-09-29T10:42:59.53Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b6/8a1723fff71f63bbb1354bdc60a1513a068acc0f5322f58da6f022d20247/numba-0.62.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:728f91a874192df22d74e3fd42c12900b7ce7190b1aad3574c6c61b08313e4c5", size = 3503367, upload-time = "2025-09-29T10:43:26.326Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ec/9d414e7a80d6d1dc4af0e07c6bfe293ce0b04ea4d0ed6c45dad9bd6e72eb/numba-0.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:bbf3f88b461514287df66bc8d0307e949b09f2b6f67da92265094e8fa1282dd8", size = 2745529, upload-time = "2025-09-29T10:44:31.738Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014, upload-time = "2025-09-09T15:56:29.966Z" }, + { url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220, upload-time = "2025-09-09T15:56:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918, upload-time = "2025-09-09T15:56:34.175Z" }, + { url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922, upload-time = "2025-09-09T15:56:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991, upload-time = "2025-09-09T15:56:40.548Z" }, + { url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643, upload-time = "2025-09-09T15:56:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787, upload-time = "2025-09-09T15:56:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598, upload-time = "2025-09-09T15:56:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800, upload-time = "2025-09-09T15:56:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615, upload-time = "2025-09-09T15:56:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936, upload-time = "2025-09-09T15:56:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" }, + { url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" }, + { url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" }, + { url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" }, + { url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" }, + { url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" }, + { url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" }, + { url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" }, + { url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527, upload-time = "2025-09-09T15:57:52.006Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159, upload-time = "2025-09-09T15:57:54.407Z" }, + { url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624, upload-time = "2025-09-09T15:57:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627, upload-time = "2025-09-09T15:57:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926, upload-time = "2025-09-09T15:58:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958, upload-time = "2025-09-09T15:58:02.738Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920, upload-time = "2025-09-09T15:58:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076, upload-time = "2025-09-09T15:58:07.745Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952, upload-time = "2025-09-09T15:58:10.096Z" }, + { url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322, upload-time = "2025-09-09T15:58:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630, upload-time = "2025-09-09T15:58:14.64Z" }, + { url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987, upload-time = "2025-09-09T15:58:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076, upload-time = "2025-09-09T15:58:20.343Z" }, + { url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491, upload-time = "2025-09-09T15:58:22.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913, upload-time = "2025-09-09T15:58:24.569Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811, upload-time = "2025-09-09T15:58:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689, upload-time = "2025-09-09T15:58:28.831Z" }, + { url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855, upload-time = "2025-09-09T15:58:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520, upload-time = "2025-09-09T15:58:33.762Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371, upload-time = "2025-09-09T15:58:36.04Z" }, + { url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576, upload-time = "2025-09-09T15:58:37.927Z" }, + { url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953, upload-time = "2025-09-09T15:58:40.576Z" }, +] + +[[package]] +name = "picosdk" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/73/6ac7ab8eea102673173026adcde758127f5623cbd0c40987bbed27485e25/picosdk-1.1.tar.gz", hash = "sha256:b6dc68f3a1ae6f044421bf728b8b6f38145403607006e9c014705559d914d6f1", size = 115676, upload-time = "2022-08-25T22:08:03.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/e3/318adb98bdb872f328e4911312764f97ee4e0746edcf0b77138f79d43a16/picosdk-1.1-py2.py3-none-any.whl", hash = "sha256:be0d51b3561f99b16f026990915f51c207b6e347bbb9ded1130466e40734a4db", size = 75775, upload-time = "2022-08-25T22:07:57.465Z" }, +] + +[[package]] +name = "picostream" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "h5py" }, + { name = "loguru" }, + { name = "numba" }, + { name = "numpy" }, + { name = "picosdk" }, + { name = "pyqt5" }, + { name = "pyqtgraph" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "h5py" }, + { name = "loguru" }, + { name = "numba" }, + { name = "numpy" }, + { name = "picosdk" }, + { name = "pyqt5" }, + { name = "pyqtgraph" }, +] + +[[package]] +name = "pyqt5" +version = "5.15.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyqt5-qt5" }, + { name = "pyqt5-sip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/07/c9ed0bd428df6f87183fca565a79fee19fa7c88c7f00a7f011ab4379e77a/PyQt5-5.15.11.tar.gz", hash = "sha256:fda45743ebb4a27b4b1a51c6d8ef455c4c1b5d610c90d2934c7802b5c1557c52", size = 3216775, upload-time = "2024-07-19T08:39:57.756Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/64/42ec1b0bd72d87f87bde6ceb6869f444d91a2d601f2e67cd05febc0346a1/PyQt5-5.15.11-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c8b03dd9380bb13c804f0bdb0f4956067f281785b5e12303d529f0462f9afdc2", size = 6579776, upload-time = "2024-07-19T08:39:19.775Z" }, + { url = "https://files.pythonhosted.org/packages/49/f5/3fb696f4683ea45d68b7e77302eff173493ac81e43d63adb60fa760b9f91/PyQt5-5.15.11-cp38-abi3-macosx_11_0_x86_64.whl", hash = "sha256:6cd75628f6e732b1ffcfe709ab833a0716c0445d7aec8046a48d5843352becb6", size = 7016415, upload-time = "2024-07-19T08:39:32.977Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8c/4065950f9d013c4b2e588fe33cf04e564c2322842d84dbcbce5ba1dc28b0/PyQt5-5.15.11-cp38-abi3-manylinux_2_17_x86_64.whl", hash = "sha256:cd672a6738d1ae33ef7d9efa8e6cb0a1525ecf53ec86da80a9e1b6ec38c8d0f1", size = 8188103, upload-time = "2024-07-19T08:39:40.561Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/ae5a5b4f9b826b29ea4be841b2f2d951bcf5ae1d802f3732b145b57c5355/PyQt5-5.15.11-cp38-abi3-win32.whl", hash = "sha256:76be0322ceda5deecd1708a8d628e698089a1cea80d1a49d242a6d579a40babd", size = 5433308, upload-time = "2024-07-19T08:39:46.932Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/68eb9f3d19ce65df01b6c7b7a577ad3bbc9ab3a5dd3491a4756e71838ec9/PyQt5-5.15.11-cp38-abi3-win_amd64.whl", hash = "sha256:bdde598a3bb95022131a5c9ea62e0a96bd6fb28932cc1619fd7ba211531b7517", size = 6865864, upload-time = "2024-07-19T08:39:53.572Z" }, +] + +[[package]] +name = "pyqt5-qt5" +version = "5.15.17" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f9/accb06e76e23fb23053d48cc24fd78dec6ed14cb4d5cbadb0fd4a0c1b02e/PyQt5_Qt5-5.15.17-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8b8094108e748b4bbd315737cfed81291d2d228de43278f0b8bd7d2b808d2b9", size = 39972275, upload-time = "2025-05-24T11:15:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/87/1a/e1601ad6934cc489b8f1e967494f23958465cf1943712f054c5a306e9029/PyQt5_Qt5-5.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b68628f9b8261156f91d2f72ebc8dfb28697c4b83549245d9a68195bd2d74f0c", size = 37135109, upload-time = "2025-05-24T11:15:59.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/13d25a9ff2ac236a264b4603abaa39fa8bb9a7aa430519bb5f545c5b008d/PyQt5_Qt5-5.15.17-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b018f75d1cc61146396fa5af14da1db77c5d6318030e5e366f09ffdf7bd358d8", size = 61112954, upload-time = "2025-05-24T11:16:26.036Z" }, +] + +[[package]] +name = "pyqt5-sip" +version = "12.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/08/88a20c862f40b5c178c517cdc7e93767967dec5ac1b994e226d517991c9b/pyqt5_sip-12.17.1.tar.gz", hash = "sha256:0eab72bcb628f1926bf5b9ac51259d4fa18e8b2a81d199071135458f7d087ea8", size = 104136, upload-time = "2025-10-08T09:04:19.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/15/291f83f336558300626bebb0c403084ec171bbc8a70683e3376234422eb6/pyqt5_sip-12.17.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c362606de782d2d46374a38523632786f145c517ee62de246a6069e5f2c5f336", size = 124521, upload-time = "2025-10-08T09:04:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/45/85/ea1ae099260fd1859d71b31f51760b4226abfa778d5796b76d92c8fe6dcd/pyqt5_sip-12.17.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:140cc582151456103ebb149fefc678f3cae803e7720733db51212af5219cd45c", size = 282182, upload-time = "2025-10-08T09:15:35.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b3/d5b50c721651a0f2ccbef6f8db3dabf3db296b9ec239ba007f5615f57dd7/pyqt5_sip-12.17.1-cp312-cp312-win32.whl", hash = "sha256:9dc1f1525d4d42c080f6cfdfc70d78239f8f67b0a48ea0745497251d8d848b1d", size = 49447, upload-time = "2025-10-08T09:11:24.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/b6/474d8b17763683ab45fb364f3a44f25fdc25d97b47b29ad8819b95a15ac8/pyqt5_sip-12.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:d5e2e9e175559017cd161d661e0ee0b551684f824bb90800c5a8c8a3bea9355e", size = 57946, upload-time = "2025-10-08T09:08:35.775Z" }, + { url = "https://files.pythonhosted.org/packages/1d/58/9ecb688050e79ffe7bbd9fc917aa13f63856a5081ac46bbce87bb11ab971/pyqt5_sip-12.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9ebbd7769ccdaaa6295e9c872553b6cde17f38e171056f17300d8af9a14d1fc8", size = 124485, upload-time = "2025-10-08T09:04:17.473Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9f/ae691360a9f18e3e06fd297e854d7ad175367e35ea184fd2fcf6c79b8c25/pyqt5_sip-12.17.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b023da906a70af2cf5e6fc1932f441ede07530f3e164dd52c6c2bb5ab7c6f424", size = 281923, upload-time = "2025-10-08T09:15:37.004Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/491c45423174a359a4b8a8d84a7b541c453f48497ae928cbe4006bcd3e01/pyqt5_sip-12.17.1-cp313-cp313-win32.whl", hash = "sha256:36dbef482bd638786b909f3bda65b7b3d5cbd6cbf16797496de38bae542da307", size = 49400, upload-time = "2025-10-08T09:11:25.769Z" }, + { url = "https://files.pythonhosted.org/packages/64/61/e28681dd5200094f7b2e6671e85c02a4d6693da36d23ad7d39ffbc70b15c/pyqt5_sip-12.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:d04e5551bbc3bcec98acc63b3b0618ddcbf31ff107349225b516fe7e7c0a7c8b", size = 57979, upload-time = "2025-10-08T09:08:37.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/06c09dc94474ffe3f518f80e47fc69d34abf8e4a971ae7e7c667d6ff30a7/pyqt5_sip-12.17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c49918287e1ad77956d1589f1d3d432a0be7630c646ea02cf652413a48e14458", size = 124400, upload-time = "2025-10-08T08:38:23.927Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/be6e338ea427deac5cd81a93f51ae3fb6505d99d6d5e5d5341bcc099327e/pyqt5_sip-12.17.1-cp314-cp314-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:944a4bf1e1ee18ad03a54964c1c6433fb6de582313a1f0b17673e7203e22fc83", size = 282291, upload-time = "2025-10-08T08:38:25.735Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a3/8b758518bd0dd5d1581f7a6d522c9b4d9b58d05087b1d0b4dfaad5376434/pyqt5_sip-12.17.1-cp314-cp314-win32.whl", hash = "sha256:99a2935fd662a67748625b1e6ffa0a2d1f2da068b9df6db04fa59a4a5d4ee613", size = 50578, upload-time = "2025-10-08T08:38:28.72Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/e96f9877548810b1e537f46fc21ba74552dd4e8c498658114a8353bdf659/pyqt5_sip-12.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:aaa33232cc80793d14fdb3b149b27eec0855612ed66aad480add5ac49b9cee63", size = 59763, upload-time = "2025-10-08T08:38:27.443Z" }, +] + +[[package]] +name = "pyqtgraph" +version = "0.13.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/d9/b62d5cddb3caa6e5145664bee5ed90223dee23ca887ed3ee479f2609e40a/pyqtgraph-0.13.7.tar.gz", hash = "sha256:64f84f1935c6996d0e09b1ee66fe478a7771e3ca6f3aaa05f00f6e068321d9e3", size = 2343380, upload-time = "2024-04-29T02:18:58.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/34/5702b3b7cafe99be1d94b42f100e8cc5e6957b761fcb1cf5f72d492851da/pyqtgraph-0.13.7-py3-none-any.whl", hash = "sha256:7754edbefb6c367fa0dfb176e2d0610da3ada20aa7a5318516c74af5fb72bf7a", size = 1925473, upload-time = "2024-04-29T02:18:56.206Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] |
