aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--justfile12
-rw-r--r--new_plan.md65
-rw-r--r--picostream/cli.py647
-rw-r--r--picostream/dfplot.py38
-rw-r--r--picostream/main.py876
-rw-r--r--uv.lock268
7 files changed, 1264 insertions, 645 deletions
diff --git a/.gitignore b/.gitignore
index a7c3d30..8d381ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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__":
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..8481d90
--- /dev/null
+++ b/uv.lock
@@ -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" },
+]