diff --git a/.gitignore b/.gitignore index b66b92e..b2ac7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ tests/sdr/ # Sphinx documentation docs/build/ +docs/_build/ # Jupyter Notebook .ipynb_checkpoints diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fec3c2..1bc13c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.1.0] - 2026-02-20 + +### Added +- **Dual-Threshold Detection:** Logic to capture the start and end of signals, not just the peak. +- **Signal Smoothing & Noise Filters:** Prevents detections from breaking into fragments and ignores short interference spikes. +- **Auto-Frequency Calculation:** Automatically adjusts bounding boxes to fit signal frequency ranges tightly. + +### Changed +- **Signal Power Detection:** Switched from raw signal strength to power for improved accuracy. +- **CLI Workflow:** `Clear` and `Remove` commands now modify files directly (in-place) to avoid redundant copies. +- **Metadata Logic:** Updated labels to show detection percentages and overhauled internal metadata cleaning. +- **Viewer UI:** Moved legend outside the plot, added a black background, and adjusted transparency for better spectrogram visibility. + +### Fixed +- Prevented redundant `_annotated` suffixes in file naming patterns. +- Simplified internal math to increase processing speed and precision. All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/docs/_build/html/_sources/intro/getting_started.rst.txt b/docs/_build/html/_sources/intro/getting_started.rst.txt new file mode 100644 index 0000000..c2386ee --- /dev/null +++ b/docs/_build/html/_sources/intro/getting_started.rst.txt @@ -0,0 +1,1083 @@ +############### +Getting Started +############### + +This is a practical reference for the ``ria`` CLI from ``ria-toolkit-oss``. + +**Scope of this guide:** + +* Installation and setup +* End-to-end CLI workflows +* Full command reference for CLI features +* Brief scripting section + +**Official resources:** + +* `Project README `_ +* `Documentation `_ +* `PyPI package `_ +* `RIA Hub Conda package `_ + +.. contents:: Contents + :local: + :depth: 2 + :backlinks: none + + +1) Installation and Setup +========================== + +1.1 Installation with Conda +---------------------------- + +RIA Toolkit OSS is available as a Conda package on RIA Hub. This is typically the easiest +path when using SDR tooling that depends on native/system libraries. + +.. code-block:: bash + + conda update --force conda + conda config --add channels https://riahub.ai/api/packages/qoherent/conda + conda activate base + conda install ria-toolkit-oss + +Verify: + +.. code-block:: bash + + conda list | grep ria-toolkit-oss + + +1.2 Installation with pip +-------------------------- + +Use pip unless you specifically need to edit toolkit source. + +.. code-block:: bash + + python3 -m venv .venv + source .venv/bin/activate + pip install --upgrade pip + pip install ria-toolkit-oss + +Verify CLI entrypoint: + +.. code-block:: bash + + ria --help + +``pyproject.toml`` defines two script entry points: + +* ``ria`` +* ``ria-tools`` + +Both point to the same CLI module (``ria_toolkit_oss_cli.cli:cli``). + + +1.3 Optional install from source +---------------------------------- + +Use this for local development or testing unreleased changes. + +.. code-block:: bash + + git clone https://riahub.ai/qoherent/ria-toolkit-oss.git + cd ria-toolkit-oss + python3 -m venv .venv + source .venv/bin/activate + pip install -e . + + +1.4 SDR driver prerequisites +----------------------------- + +Toolkit package install does not install all system SDR drivers. Install vendor/runtime +dependencies for the hardware you use. + +Examples (depends on device and OS): + +* USRP: UHD drivers +* Pluto: libiio / IIO utilities +* BladeRF: libbladeRF +* HackRF: libhackrf +* RTL-SDR: librtlsdr + +See repo docs under ``docs/source/sdr_guides/*`` and your OS package instructions. + + +2) CLI Structure +================= + +Top-level CLI follows this model: + +.. code-block:: bash + + ria [GLOBAL_OPTS] [ARGS] [OPTIONS] + +**Global:** + +* ``-v, --verbose`` (defined on root click group) + +**Top-level commands:** + +* ``discover`` +* ``init`` +* ``capture`` +* ``view`` +* ``annotate`` (group) +* ``convert`` +* ``split`` +* ``combine`` +* ``generate`` (group) +* ``transform`` (group) +* ``transmit`` +* ``synth`` (alias of ``generate`` in command bindings) + + +3) Quick End-to-End Workflow +============================= + +3.1 Discover radios +-------------------- + +Run this first in any new environment to verify drivers and detect hardware before +attempting RX/TX commands. + +.. code-block:: bash + + ria discover + ria discover -v + ria discover --json-output + + +3.2 Initialize local metadata defaults +--------------------------------------- + +Set reusable metadata once so generated/captured files automatically include consistent +provenance fields. + +.. code-block:: bash + + ria init + # or non-interactive + ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" + # show config + ria init --show + + +3.3 Capture IQ +--------------- + +Capture baseband data from a connected SDR into a reusable file format. + +.. code-block:: bash + + ria capture -d pluto -f 2.44G -s 2e6 -n 500000 -o capture.sigmf-data + + +3.4 Visualize and inspect +-------------------------- + +Render quick diagnostic plots to validate signal presence, quality, and rough structure. + +.. code-block:: bash + + ria view capture.sigmf-data --type simple + ria view capture.sigmf-data --type full --show --no-save + + +3.5 Auto-annotate and inspect annotations +------------------------------------------ + +Create initial labels automatically, then inspect annotation objects before downstream use. + +.. code-block:: bash + + ria annotate energy capture.sigmf-data --label signal --threshold 1.2 + ria annotate list capture.sigmf-data --verbose + + +3.6 Convert and split +---------------------- + +Normalize file format and split large captures into manageable chunks for processing or +training. + +.. code-block:: bash + + ria convert capture.sigmf-data capture.npy + ria split capture.sigmf-data --split-every 100000 --output-dir chunks + + +3.7 Apply transforms +--------------------- + +Augment or impair recordings to produce controlled variants. + +.. code-block:: bash + + ria transform augment channel_swap capture.npy + ria transform impair add_awgn_to_signal capture.npy --params snr=10 + + +3.8 Transmit (TX-capable radios only) +-------------------------------------- + +Replay recorded or synthesized IQ through a transmit-capable SDR. + +.. code-block:: bash + + ria transmit -d hackrf -f 2.44G -s 2e6 --input capture.sigmf-data + # or generated waveform + ria transmit -d hackrf --generate lfm --continuous + + +4) Command Reference +===================== + +4.1 ``discover`` +----------------- + +**Purpose:** + +* Probe available SDR drivers and enumerate attached hardware. +* Confirm whether runtime libraries/drivers are installed and discoverable before + capture/transmit. + +**Usage:** + +.. code-block:: bash + + ria discover [--verbose] [--json-output] + +**Options:** + +* ``-v, --verbose``: include per-driver probe details and import/init failures. +* ``--json-output``: emit JSON (useful for automation and inventory scripts). + +**Behavior notes:** + +* ``discover`` checks multiple backends (USB and network paths, depending on driver support). +* A device not appearing here usually means one of: missing system driver, permission issue, + USB/network connectivity issue. +* Use ``--verbose`` first when troubleshooting; it surfaces driver-level failures that are + hidden in default output. + + +4.2 ``init`` +------------- + +**Purpose:** + +* Create/manage user config file (defaults to ``~/.ria/config.yaml``, or + ``$XDG_CONFIG_HOME/ria/config.yaml``). + +**Usage:** + +.. code-block:: bash + + ria init [options] + +**Key options:** + +*Metadata defaults:* +``--author``, ``--organization``, ``--project``, ``--location``, ``--testbed``, +``--license``, ``--hw``, ``--dataset`` + +*Actions:* +``--show``, ``--reset`` + +*Control:* +``--config-path``, ``--interactive`` / ``--no-interactive``, ``-y`` / ``--yes`` + +**What each option category does:** + +* Metadata defaults (``--author``, ``--project``, etc.): stored once and reused for later + recordings so files have consistent provenance. +* SigMF-focused fields (``--license``, ``--hw``, ``--dataset``): populate metadata commonly + expected in shared datasets. +* ``--show``: read-only inspect of the current resolved config. +* ``--reset``: remove config and start clean. +* ``--config-path``: use a non-default config location (useful for isolated environments or + CI). +* ``--interactive`` / ``--no-interactive``: force prompts on or off regardless of terminal + auto-detection. +* ``--yes``: suppress confirmation prompts for scripted runs. + +.. note:: + Current command output includes a note that some config integration is still being + finalized. Config values are already consumed by multiple commands (capture, convert, + generate metadata, and YAML config loading paths). + + +4.3 ``capture`` +---------------- + +**Purpose:** + +* Record IQ samples from a supported SDR and save to ``sigmf``, ``npy``, ``wav``, or + ``blue``. + +**Usage:** + +.. code-block:: bash + + ria capture [options] + +Device selection (``--device``) is optional if only one device is detected. Exactly one of +``--num-samples`` or ``--duration`` is required. + +**Core options:** + +*Device/connection:* + +* ``-d, --device {pluto,hackrf,bladerf,usrp,rtlsdr,thinkrf}`` +* ``-i, --ident`` +* ``-c, --config `` + +*RF/capture:* + +* ``-s, --sample-rate`` +* ``-f, --center-frequency`` (supports values like ``915e6``, ``2.4G``) +* ``-g, --gain`` +* ``-b, --bandwidth`` +* ``-n, --num-samples`` +* ``-t, --duration`` + +*Output:* + +* ``-o, --output`` +* ``--output-dir`` +* ``--format {npy,sigmf,wav,blue}`` +* ``--save-image`` + +*Metadata/logging:* + +* ``-m, --metadata KEY=VALUE`` (repeatable) +* ``-v, --verbose``, ``-q, --quiet`` + +**How options work in practice:** + +* ``--device`` + ``--ident``: select both device class and target instance; ``--ident`` + takes serial/IP style selectors. +* ``--config``: load a YAML option set, then override specific fields on the CLI as needed. +* ``--num-samples`` vs ``--duration``: use exact sample count for deterministic datasets, + or time-based capture for quick acquisition. +* ``--format``: ``sigmf`` is best for metadata/annotation workflows. +* ``--save-image``: writes a quick visual summary alongside capture output. +* ``--metadata KEY=VALUE``: injects run-specific metadata (campaign ID, antenna, scenario + tag, etc.). + +**Output behavior:** + +* If ``--output`` is omitted, a timestamped filename is generated automatically. +* If ``--output-dir`` is omitted, captures default to ``recordings/``. +* Format is inferred from the ``--output`` extension when no explicit ``--format`` is given. + +**Examples:** + +.. code-block:: bash + + ria capture -d hackrf -s 2e6 -f 2.44G -n 1000000 -o rf.sigmf-data + ria capture -d pluto -f 915e6 -t 2 --format npy --output-dir recordings + ria capture -c capture_config.yaml + + +4.4 ``view`` +------------- + +**Purpose:** + +* Generate visualizations from IQ files. +* Quickly validate signal quality, occupancy, and annotation coverage without writing custom + plotting code. + +**Usage:** + +.. code-block:: bash + + ria view [options] + +```` accepts SigMF, NPY, WAV, and Blue files. + +**Mode** (``--type``): + +* ``simple``: fast-look plots for sanity checks and quick iteration. +* ``full``: multi-panel diagnostic figure (IQ, time, frequency, metadata views). +* ``annotations`` / ``annotation``: render annotation overlays. +* ``channels``: channelized/segmented visualization. +* ``annotate``: convenience path used in some annotation workflows. + +**Output/display options:** + +* ``--output``, ``--format {png,pdf,svg,jpg}`` +* ``--show``: open an interactive window (requires a GUI display environment). +* ``--no-save``: suppress file output; only meaningful with ``--show``. +* ``--overwrite`` + +**Style options:** + +* ``--dpi``, ``--figsize WxH``, ``--title`` +* ``--light``: switch to a light theme (useful for reports/slides). + +**Loading options:** + +* ``--legacy``: force legacy NPY loading path for older datasets. +* ``--config`` + +**Mode-specific options:** + +*simple:* ``--fast``, ``--compact``, ``--horizontal``, ``--constellation``, ``--labels``, +``--slice start:end[:step]`` + +*full:* ``--plot-length``, ``--no-spectrogram``, ``--no-iq``, ``--no-frequency``, +``--no-constellation``, ``--no-metadata``, ``--no-logo``, ``--spines`` + +*annotations/channels:* ``--channel`` + +**Examples:** + +.. code-block:: bash + + ria view capture.sigmf-data --type simple + ria view capture.npy --type full --title "Test Capture" --format pdf + ria view capture.npy --show --no-save + ria view old.npy --legacy --type simple + + +4.5 ``annotate`` group +----------------------- + +**Purpose:** + +* Manual annotation management and auto-detection/separation. +* Build or refine label metadata directly in recordings for downstream training, QA, and + filtering. + +**Command shape:** + +.. code-block:: bash + + ria annotate ... + +**Subcommands:** ``list``, ``add``, ``remove``, ``clear``, ``energy``, ``cusum``, +``threshold``, ``separate`` + +**General behavior:** + +* SigMF is the preferred format for durable annotation metadata. +* For non-SigMF input, many operations write a new output artifact unless overwrite behavior + is explicitly requested. +* ``--type {standalone,parallel,intersection}`` controls annotation relation semantics. + +``ria annotate list`` +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate list [--verbose] + +Prints all annotations for a recording in index order. ``--verbose`` includes additional +detail per record. + +``ria annotate add`` +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate add --start --count --label [options] + +Adds one explicit annotation with sample-domain boundaries. + +* ``--start``: first sample index of the annotated region. +* ``--count``: number of samples in the region. +* ``--freq-lower``, ``--freq-upper``: optional spectral bounds in Hz. +* ``--comment``, ``--type``, ``-o`` / ``--output``, ``--overwrite``, ``--quiet`` + +``ria annotate remove`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate remove [--output ...] [--overwrite] [--quiet] + +Removes exactly one annotation by list index. Run ``annotate list`` first to confirm the +index. + +``ria annotate clear`` +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate clear [--force] [--overwrite] [--quiet] + +Removes all annotations from the recording. ``--force`` bypasses the confirmation prompt. + +``ria annotate energy`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate energy [options] + +Detects energetic regions above the estimated noise floor and writes them as annotations. + +* ``--label`` +* ``--threshold``: noise-floor multiplier; higher values reduce false positives but can miss + weak signals. +* ``--segments``: number of segments used to estimate baseline noise. +* ``--window-size``: smoothing size; larger windows stabilize detections at the cost of + sharp transition precision. +* ``--min-distance``: minimum sample spacing between detections, preventing dense duplicate + regions. +* ``--freq-method {nbw,obw,full-detected,full-bandwidth}``: how frequency bounds are assigned + to annotations. +* ``--nfft``, ``--obw-power`` +* ``--type``, ``-o`` / ``--output``, ``--overwrite``, ``--quiet`` + +``ria annotate cusum`` +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate cusum [options] + +Uses change-point detection (CUSUM-style logic) to find regime changes and annotate +contiguous segments. + +* ``--label`` +* ``--min-duration`` (ms): prevents tiny over-segmented labels. +* ``--window-size`` +* ``--tolerance``: merges nearby boundaries when set above default. +* ``--type``, ``-o`` / ``--output``, ``--overwrite``, ``--quiet`` + +``ria annotate threshold`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate threshold --threshold <0.0..1.0> [options] + +Uses normalized magnitude thresholding to derive annotation spans. Use where a fixed +amplitude threshold is sufficient. + +* ``--label``, ``--window-size``, ``--type``, ``-o`` / ``--output``, ``--overwrite``, + ``--quiet`` + +``ria annotate separate`` +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate separate [options] + +Decomposes selected annotations into narrower spectral components and emits refined +annotations. + +* ``--indices "0,1,2"``: limit operation to specific annotations; omit to process all. +* ``--nfft``: larger FFT improves frequency resolution but increases compute time. +* ``--noise-threshold-db``: stabilizes detection across heterogeneous captures. +* ``--min-component-bw``: rejects narrow fragments likely to be noise artifacts. +* ``-o`` / ``--output``, ``--overwrite``, ``--quiet``, ``--verbose`` + +**Examples:** + +.. code-block:: bash + + ria annotate list capture.sigmf-data --verbose + ria annotate add capture.sigmf-data --start 10000 --count 5000 --label burst + ria annotate energy capture.sigmf-data --label signal --threshold 1.3 + ria annotate cusum capture.sigmf-data --min-duration 5 + ria annotate separate capture.sigmf-data --indices 0,1 --verbose + + +4.6 ``convert`` +---------------- + +**Purpose:** + +* Convert between ``sigmf``, ``npy``, ``wav``, and ``blue``. +* Normalize datasets into the format required by downstream tooling or collaboration targets. + +**Usage:** + +.. code-block:: bash + + ria convert [output] [options] + +If ``output`` is omitted, ``--format`` must be provided. If both are given, format is +inferred from the output file extension. + +**Options:** + +* ``--format {npy,sigmf,wav,blue}`` +* ``--output-dir`` +* ``--legacy``: use older NPY loader behavior for historical recordings. +* ``--wav-sample-rate``: target sample rate for WAV export. +* ``--wav-bits {16,32}``: output PCM depth; higher preserves more dynamic range. +* ``--blue-format {CI,CF,CD}``: Bluefile complex sample representation. +* ``--metadata KEY=VALUE`` (repeatable): add or override metadata during conversion; + especially useful when exporting to SigMF. +* ``--overwrite``, ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +**Examples:** + +.. code-block:: bash + + ria convert recording.sigmf-data output.npy + ria convert recording.npy --format sigmf + ria convert highrate.npy audio.wav --wav-sample-rate 48000 + ria convert old.npy --format sigmf --legacy --overwrite + + +4.7 ``split`` +-------------- + +**Purpose:** + +* Split, trim, or extract recordings. +* Create manageable dataset shards or extract windows of interest without custom scripts. + +**Usage:** + +.. code-block:: bash + + ria split [operation] [options] + +Choose exactly one operation per invocation: + +* ``--split-at ``: binary split at a specific sample index. +* ``--split-every ``: fixed-size chunking for ML pipelines. +* ``--split-duration ``: time-based chunking. +* ``--trim`` (with ``--start`` + ``--length`` or ``--end``): extract one sub-window. +* ``--extract-annotations``: write each annotated region as a standalone file. + +**Trim controls:** ``--start``, ``--length``, ``--end`` + +**Annotation extraction filters:** ``--annotation-label``, ``--annotation-index`` + +**Output controls:** +``--output-dir``, ``--output-prefix``, ``--output-format {npy,sigmf,wav,blue}``, +``--overwrite``, ``--legacy``, ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +**Examples:** + +.. code-block:: bash + + ria split recording.sigmf-data --split-at 500000 --output-dir out + ria split recording.sigmf-data --split-every 100000 --output-dir chunks + ria split recording.sigmf-data --split-duration 1.0 --output-dir chunks + ria split recording.npy --trim --start 1000 --length 5000 --output-dir trimmed + ria split annotated.sigmf-data --extract-annotations --annotation-label payload + + +4.8 ``combine`` +---------------- + +**Purpose:** + +* Merge multiple recordings by concatenation or sample-wise addition. +* Assemble multi-part captures or synthesize mixtures for testing and model training. + +**Usage:** + +.. code-block:: bash + + ria combine [input3 ...] [options] + +**Options:** + +* ``--mode {concat,add}`` +* ``--align-mode {error,truncate,pad,pad-start,pad-center,pad-end,repeat,repeat-spaced}`` +* ``--pad-start-sample``, ``--repeat-spacing`` +* ``--normalize``: rescale combined output to avoid clipping/saturation after addition. +* ``--output-format {sigmf,npy,wav,blue}`` +* ``--overwrite``, ``--metadata KEY=VALUE`` (repeatable) +* ``--legacy``, ``--verbose``, ``--quiet`` + +**Mode semantics:** + +* ``concat``: append inputs sequentially in time. +* ``add``: sample-wise summation — all inputs must be aligned to the same length. + +**Alignment options for** ``--mode add``: + +* ``error``: fail if lengths differ. +* ``truncate``: cut all to shortest length. +* ``pad``, ``pad-start``, ``pad-center``, ``pad-end``: zero-pad shorter streams. +* ``repeat``: tile shorter streams to match longest. +* ``repeat-spaced``: repeated placement with spacing via ``--repeat-spacing``. + +**Examples:** + +.. code-block:: bash + + ria combine a.npy b.npy c.npy merged.npy + ria combine signal.npy noise.npy noisy.npy --mode add + ria combine long.npy short.npy out.npy --mode add --align-mode pad-center + ria combine signal.npy pattern.npy out.npy --mode add --align-mode repeat-spaced --repeat-spacing 10000 + + +4.9 ``generate`` group (and ``synth`` alias) +--------------------------------------------- + +**Purpose:** + +* Generate synthetic IQ signals and save in ``npy``, ``sigmf``, ``wav``, or ``blue``. +* Create known-reference waveforms and synthetic datasets for validation, demos, and ML + data generation. + +``ria synth ...`` is an alias for ``ria generate ...``. + +**Shape:** + +.. code-block:: bash + + ria generate [subcommand options] [common options] + +**Available subcommands:** +``tone``, ``noise``, ``chirp``, ``square``, ``sawtooth``, ``qam``, ``apsk``, ``pam``, +``fsk``, ``ook``, ``oqpsk``, ``gmsk``, ``psk`` + +**Common options shared across all generators:** + +* ``-s, --sample-rate`` (required) +* ``-n, --num-samples`` or ``-t, --duration`` +* ``--frequency-shift``, ``-fc`` / ``--center-frequency`` +* ``--add-noise``, ``--noise-power``, ``--path-gain`` +* ``-o, --output`` (required), ``-F`` / ``--format {npy,sigmf,wav,blue}`` +* ``--multipath-paths``, ``--multipath-max-delay`` +* ``--iq-amp-imbalance``, ``--iq-phase-imbalance``, ``--iq-dc-offset`` +* ``--config `` +* ``-w`` / ``--overwrite``, ``-m`` / ``--metadata KEY=VALUE`` (repeatable) +* ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +``--frequency-shift`` and ``--center-frequency`` let you separate the baseband shape from +RF metadata context. ``--add-noise`` and ``--noise-power`` apply post-generation noise. +Multipath and IQ imbalance flags apply impairment-style post-processing during generation. + +``tone`` +~~~~~~~~~ + +Options: ``--frequency``, ``--amplitude``, ``--phase`` + +Clean sinusoidal calibration/reference source. + +``noise`` +~~~~~~~~~~ + +Options: ``--noise-type {gaussian,uniform}``, ``--power`` + +Baseline noise floor data or controlled additive-noise synthesis. + +``chirp`` +~~~~~~~~~~ + +Options: ``--bandwidth`` (required), ``--period`` (required), ``--type {up,down,up_down}`` + +Sweep-based radar/sonar-style signals and bandwidth occupancy tests. + +``square`` +~~~~~~~~~~~ + +Options: ``--frequency``, ``--amplitude``, ``--duty-cycle``, ``--phase`` + +``sawtooth`` +~~~~~~~~~~~~~ + +Options: ``--frequency``, ``--amplitude``, ``--phase`` + +Digital modulation families: ``qam``, ``apsk``, ``pam``, ``psk`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ``--symbols``, ``--order`` +* ``--symbol-rate`` +* ``--filter {rrc,rc,gaussian,none}``, ``--filter-span``, ``--filter-beta`` +* ``--message-source {random,file,string}``, ``--message-content`` + +Use ``--message-source random`` for synthetic datasets, ``file`` for deterministic replay, +or ``string`` for small human-readable payload testing. Pulse-shaping filter options +(``--filter``, ``--filter-span``, ``--filter-beta``) control spectral occupancy and ISI. + +``fsk`` +~~~~~~~~ + +Options: ``--symbols``, ``--order``, ``--symbol-rate``, ``--freq-spacing``, +``--modulation-index``, ``--message-source``, ``--message-content`` + +``--freq-spacing`` and ``--modulation-index`` drive tone separation and spectral profile. + +``ook``, ``oqpsk``, ``gmsk`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Options: ``--symbol-rate`` (required), ``--message-source {random,file,string}``, +``--message-content``; ``gmsk`` also accepts ``--bt`` + +``gmsk --bt`` sets the Gaussian filter bandwidth-time product (spectral compactness vs +symbol transition sharpness). + +**Examples:** + +.. code-block:: bash + + ria generate tone -s 2e6 -n 500000 --frequency 50e3 -o tone.sigmf-data + ria generate noise -s 2e6 -n 500000 --noise-type gaussian --power 0.05 -o noise.npy + ria generate chirp -s 5e6 -t 0.5 --bandwidth 2e6 --period 0.01 --type up -o chirp.sigmf-data + ria generate qam -s 2e6 -r 100e3 -M 16 -N 5000 --message-source random -o qam16.npy + ria synth psk -s 2e6 -r 100e3 -M 8 -N 8000 -o psk8.npy + + +4.10 ``transform`` group +------------------------- + +**Purpose:** + +* Apply algorithmic transforms to existing recordings. +* Run reusable augmentations/impairments for dataset diversity and robustness testing. + +**Shape:** + +.. code-block:: bash + + ria transform ... + +``augment`` +~~~~~~~~~~~~ + +.. code-block:: bash + + ria transform augment [augmentation] [input] [output] [options] + +Applies transforms from ``iq_augmentations`` (dataset-expansion style modifications). + +Options: ``--list``, ``--help-transform``, ``--params KEY=VALUE`` (repeatable), ``--view``, +``--overwrite``, ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +``impair`` +~~~~~~~~~~~ + +.. code-block:: bash + + ria transform impair [impairment] [input] [output] [options] + +Applies transforms from ``iq_impairments`` (noise, distortion, and channel degradation +effects). Same options as ``augment``. + +``custom`` +~~~~~~~~~~~ + +.. code-block:: bash + + ria transform custom [transform_name] [input] [output] --transform-dir [options] + +Dynamically loads public functions from Python files in ``--transform-dir`` and exposes them +as callable transforms. + +Options: ``--transform-dir`` (required), ``--list``, ``--help-transform``, +``--params KEY=VALUE`` (repeatable), ``--view``, ``--overwrite``, ``-v`` / ``--verbose``, +``-q`` / ``--quiet`` + +``--params`` values must be ``KEY=VALUE``; types are inferred as int, float, or string. +Use ``--list`` to enumerate available transform names, and ``--help-transform `` to +inspect parameter hints. ``--view`` writes a PNG preview alongside transform output. + +**Examples:** + +.. code-block:: bash + + ria transform augment --list + ria transform augment channel_swap in.npy out.npy + ria transform augment drop_samples in.npy --params max_section_size=5 --view + + ria transform impair --list + ria transform impair add_awgn_to_signal in.npy out.npy --params snr=10 + + ria transform custom --transform-dir ./my_transforms --list + ria transform custom my_filter in.npy out.npy --transform-dir ./my_transforms --params cutoff=0.2 + + +4.11 ``transmit`` +------------------ + +**Purpose:** + +* Transmit IQ via a TX-capable SDR (``pluto``, ``hackrf``, ``bladerf``, ``usrp``). +* Support playback of captured/generated waveforms for over-the-air or wired-loop test + scenarios. + +**Usage:** + +.. code-block:: bash + + ria transmit [options] + +**Input source (choose one):** + +* ``--input ``: transmit an existing recording. +* ``--generate {lfm,chirp,sine,pulse}``: synthesize a transmit signal on the fly. +* If neither is specified, the command defaults to a generated LFM waveform. + +**Core options:** + +*Device/radio:* ``-d`` / ``--device {pluto,hackrf,bladerf,usrp}``, ``-i`` / ``--ident``, +``-c`` / ``--config`` + +*RF:* ``-s`` / ``--sample-rate``, ``-f`` / ``--center-frequency``, ``-g`` / ``--gain``, +``-b`` / ``--bandwidth`` + +*Input/gen:* ``--input``, ``--legacy``, ``--generate {lfm,chirp,sine,pulse}`` + +*TX control:* + +* ``-r, --repeat`` +* ``--continuous``: transmit until interrupted (``Ctrl+C``). +* ``--tx-delay``: pause between repeats when ``--repeat`` is used. +* ``-y, --yes``: skip confirmation prompts; use carefully in scripted environments. + +*Logging:* ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +.. warning:: + ``--continuous`` transmits until manually interrupted. Validate gain settings, antenna + configuration, and regulatory compliance before use. + +**Examples:** + +.. code-block:: bash + + ria transmit -d pluto -f 915e6 -s 2e6 --input capture.sigmf-data + ria transmit -d hackrf --generate lfm -f 2.44G --continuous + ria transmit -d usrp --input msg.npy -r 3 --tx-delay 0.5 + + +5) YAML Config Patterns +======================== + +Several commands accept ``--config `` for parameter loading. CLI flags generally +override values loaded from ``--config``. + +Keep one stable baseline YAML per workflow (capture, generate, transmit), then override only +experiment-specific fields on the CLI. + +**Capture config example:** + +.. code-block:: yaml + + device: pluto + ident: 192.168.2.1 + sample_rate: 2000000 + center_frequency: 2.44G + gain: 20 + bandwidth: 2000000 + num_samples: 500000 + format: sigmf + output: run1.sigmf-data + metadata: + campaign: lab_eval + antenna: dipole + +.. code-block:: bash + + ria capture -c capture.yaml + +**Generate config example:** + +.. code-block:: yaml + + sample_rate: 2000000 + num_samples: 200000 + format: npy + output: synth.npy + noise_power: 0.02 + +.. code-block:: bash + + ria generate noise --config generate.yaml + + +6) Practical Tips and Safety +============================= + +* Use ``ria discover`` before capture/transmit sessions. +* Keep TX gain conservative first; validate with attenuators/dummy loads when needed. +* Prefer SigMF for interoperable metadata and annotations. +* For long workflows, keep outputs organized by campaign directories and consistent prefixes. +* Use ``--verbose`` when debugging device init or driver issues. + + +7) Version Notes +================= + +These notes are based on the current implementation and should be re-validated against future +releases. + +1. Some command docstrings and examples still mention ``utils`` or ``ria_toolkit_oss`` + command prefixes in text blocks. The operational command is ``ria ...``. +2. Command bindings currently import ``viewe`` instead of ``view`` in + ``src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py``. +3. Multiple non-CLI modules still import ``utils.*``, which can create runtime dependency + coupling when using only ``ria-toolkit-oss`` in isolation. + +If you observe unexpected import errors after install, check the package version and +changelog, then test ``ria --help`` in a clean virtual environment. + + +8) Brief Scripting (Python) Preview +===================================== + +For quick non-CLI use: + +.. code-block:: python + + from ria_toolkit_oss.datatypes import Recording + from ria_toolkit_oss.io import load_recording, to_sigmf + from ria_toolkit_oss.transforms import iq_augmentations, iq_impairments + + rec = load_recording("capture.sigmf-data") + aug = iq_augmentations.channel_swap(rec) + imp = iq_impairments.add_awgn_to_signal(aug, snr=10) + to_sigmf(imp, filename="capture_awgn", path=".") + +You can also call annotation algorithms and block-generator primitives from Python directly. + + +9) Cheat Sheet +=============== + +.. code-block:: bash + + # Install + pip install ria-toolkit-oss + + # Discover + ria discover -v + + # Init defaults + ria init --author "Jane" --project "rf1" --location "Lab-A" + + # Capture + ria capture -d pluto -f 2.44G -s 2e6 -n 1000000 -o cap.sigmf-data + + # View + ria view cap.sigmf-data --type simple + + # Annotate + ria annotate energy cap.sigmf-data --threshold 1.2 + ria annotate list cap.sigmf-data --verbose + + # Convert + ria convert cap.sigmf-data cap.npy + + # Split + ria split cap.sigmf-data --split-every 100000 --output-dir chunks + + # Combine + ria combine chunks/a.npy chunks/b.npy merged.npy + + # Generate + ria generate qam -s 2e6 -r 100e3 -M 16 -N 5000 -o qam16.npy + + # Transform + ria transform augment channel_swap cap.npy + ria transform impair add_awgn_to_signal cap.npy --params snr=10 + + # Transmit + ria transmit -d hackrf --input cap.sigmf-data -f 2.44G -s 2e6 diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css new file mode 100644 index 0000000..40b2823 --- /dev/null +++ b/docs/source/_static/custom.css @@ -0,0 +1,29 @@ +/* Change the hex values below to customize heading colours */ + +.rst-content h1 { color: #2c3e50; } +.rst-content h2, +.rst-content h2 a { color: #ffffff !important; font-size: 22px !important; } + +.rst-content h3, +.rst-content h3 a { color: #ffffff !important; font-size: 16px !important; } + +.rst-content h3 code { font-size: inherit !important; } + +.rst-content .admonition.warning { + background: #1a1a2e !important; + border-left: 4px solid #c0392b !important; +} + +.rst-content .admonition.warning .admonition-title { + background: #c0392b !important; + color: #ffffff !important; +} + +.rst-content .admonition.warning p { + color: #ffffff !important; +} +.rst-content h4 { color: #404040; } + +.highlight * { color: #ffffff !important; } + +.ria-cmd { color: #2980b9 !important; } diff --git a/docs/source/_static/custom.js b/docs/source/_static/custom.js new file mode 100644 index 0000000..f9caf03 --- /dev/null +++ b/docs/source/_static/custom.js @@ -0,0 +1,8 @@ +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.highlight pre').forEach(function (pre) { + pre.innerHTML = pre.innerHTML.replace( + /((?:^|\n|>))(ria)(?=[ \t]|<)/g, + '$1$2' + ); + }); +}); diff --git a/docs/source/conf.py b/docs/source/conf.py index 9db1d19..1a3b6d0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,7 +14,7 @@ sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) project = 'ria-toolkit-oss' copyright = '2025, Qoherent Inc' author = 'Qoherent Inc.' -release = '0.1.4' +release = '0.1.5' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -73,3 +73,6 @@ def setup(app): # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] +html_css_files = ['custom.css'] +html_js_files = ['custom.js'] diff --git a/docs/source/intro/getting_started.rst b/docs/source/intro/getting_started.rst index 928e753..c2386ee 100644 --- a/docs/source/intro/getting_started.rst +++ b/docs/source/intro/getting_started.rst @@ -1,7 +1,1083 @@ -Getting Started -=============== - -.. todo:: - - Getting started instructions are coming soon! In the meantime, feel free - to explore the project documentation. Many components include usage examples. +############### +Getting Started +############### + +This is a practical reference for the ``ria`` CLI from ``ria-toolkit-oss``. + +**Scope of this guide:** + +* Installation and setup +* End-to-end CLI workflows +* Full command reference for CLI features +* Brief scripting section + +**Official resources:** + +* `Project README `_ +* `Documentation `_ +* `PyPI package `_ +* `RIA Hub Conda package `_ + +.. contents:: Contents + :local: + :depth: 2 + :backlinks: none + + +1) Installation and Setup +========================== + +1.1 Installation with Conda +---------------------------- + +RIA Toolkit OSS is available as a Conda package on RIA Hub. This is typically the easiest +path when using SDR tooling that depends on native/system libraries. + +.. code-block:: bash + + conda update --force conda + conda config --add channels https://riahub.ai/api/packages/qoherent/conda + conda activate base + conda install ria-toolkit-oss + +Verify: + +.. code-block:: bash + + conda list | grep ria-toolkit-oss + + +1.2 Installation with pip +-------------------------- + +Use pip unless you specifically need to edit toolkit source. + +.. code-block:: bash + + python3 -m venv .venv + source .venv/bin/activate + pip install --upgrade pip + pip install ria-toolkit-oss + +Verify CLI entrypoint: + +.. code-block:: bash + + ria --help + +``pyproject.toml`` defines two script entry points: + +* ``ria`` +* ``ria-tools`` + +Both point to the same CLI module (``ria_toolkit_oss_cli.cli:cli``). + + +1.3 Optional install from source +---------------------------------- + +Use this for local development or testing unreleased changes. + +.. code-block:: bash + + git clone https://riahub.ai/qoherent/ria-toolkit-oss.git + cd ria-toolkit-oss + python3 -m venv .venv + source .venv/bin/activate + pip install -e . + + +1.4 SDR driver prerequisites +----------------------------- + +Toolkit package install does not install all system SDR drivers. Install vendor/runtime +dependencies for the hardware you use. + +Examples (depends on device and OS): + +* USRP: UHD drivers +* Pluto: libiio / IIO utilities +* BladeRF: libbladeRF +* HackRF: libhackrf +* RTL-SDR: librtlsdr + +See repo docs under ``docs/source/sdr_guides/*`` and your OS package instructions. + + +2) CLI Structure +================= + +Top-level CLI follows this model: + +.. code-block:: bash + + ria [GLOBAL_OPTS] [ARGS] [OPTIONS] + +**Global:** + +* ``-v, --verbose`` (defined on root click group) + +**Top-level commands:** + +* ``discover`` +* ``init`` +* ``capture`` +* ``view`` +* ``annotate`` (group) +* ``convert`` +* ``split`` +* ``combine`` +* ``generate`` (group) +* ``transform`` (group) +* ``transmit`` +* ``synth`` (alias of ``generate`` in command bindings) + + +3) Quick End-to-End Workflow +============================= + +3.1 Discover radios +-------------------- + +Run this first in any new environment to verify drivers and detect hardware before +attempting RX/TX commands. + +.. code-block:: bash + + ria discover + ria discover -v + ria discover --json-output + + +3.2 Initialize local metadata defaults +--------------------------------------- + +Set reusable metadata once so generated/captured files automatically include consistent +provenance fields. + +.. code-block:: bash + + ria init + # or non-interactive + ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" + # show config + ria init --show + + +3.3 Capture IQ +--------------- + +Capture baseband data from a connected SDR into a reusable file format. + +.. code-block:: bash + + ria capture -d pluto -f 2.44G -s 2e6 -n 500000 -o capture.sigmf-data + + +3.4 Visualize and inspect +-------------------------- + +Render quick diagnostic plots to validate signal presence, quality, and rough structure. + +.. code-block:: bash + + ria view capture.sigmf-data --type simple + ria view capture.sigmf-data --type full --show --no-save + + +3.5 Auto-annotate and inspect annotations +------------------------------------------ + +Create initial labels automatically, then inspect annotation objects before downstream use. + +.. code-block:: bash + + ria annotate energy capture.sigmf-data --label signal --threshold 1.2 + ria annotate list capture.sigmf-data --verbose + + +3.6 Convert and split +---------------------- + +Normalize file format and split large captures into manageable chunks for processing or +training. + +.. code-block:: bash + + ria convert capture.sigmf-data capture.npy + ria split capture.sigmf-data --split-every 100000 --output-dir chunks + + +3.7 Apply transforms +--------------------- + +Augment or impair recordings to produce controlled variants. + +.. code-block:: bash + + ria transform augment channel_swap capture.npy + ria transform impair add_awgn_to_signal capture.npy --params snr=10 + + +3.8 Transmit (TX-capable radios only) +-------------------------------------- + +Replay recorded or synthesized IQ through a transmit-capable SDR. + +.. code-block:: bash + + ria transmit -d hackrf -f 2.44G -s 2e6 --input capture.sigmf-data + # or generated waveform + ria transmit -d hackrf --generate lfm --continuous + + +4) Command Reference +===================== + +4.1 ``discover`` +----------------- + +**Purpose:** + +* Probe available SDR drivers and enumerate attached hardware. +* Confirm whether runtime libraries/drivers are installed and discoverable before + capture/transmit. + +**Usage:** + +.. code-block:: bash + + ria discover [--verbose] [--json-output] + +**Options:** + +* ``-v, --verbose``: include per-driver probe details and import/init failures. +* ``--json-output``: emit JSON (useful for automation and inventory scripts). + +**Behavior notes:** + +* ``discover`` checks multiple backends (USB and network paths, depending on driver support). +* A device not appearing here usually means one of: missing system driver, permission issue, + USB/network connectivity issue. +* Use ``--verbose`` first when troubleshooting; it surfaces driver-level failures that are + hidden in default output. + + +4.2 ``init`` +------------- + +**Purpose:** + +* Create/manage user config file (defaults to ``~/.ria/config.yaml``, or + ``$XDG_CONFIG_HOME/ria/config.yaml``). + +**Usage:** + +.. code-block:: bash + + ria init [options] + +**Key options:** + +*Metadata defaults:* +``--author``, ``--organization``, ``--project``, ``--location``, ``--testbed``, +``--license``, ``--hw``, ``--dataset`` + +*Actions:* +``--show``, ``--reset`` + +*Control:* +``--config-path``, ``--interactive`` / ``--no-interactive``, ``-y`` / ``--yes`` + +**What each option category does:** + +* Metadata defaults (``--author``, ``--project``, etc.): stored once and reused for later + recordings so files have consistent provenance. +* SigMF-focused fields (``--license``, ``--hw``, ``--dataset``): populate metadata commonly + expected in shared datasets. +* ``--show``: read-only inspect of the current resolved config. +* ``--reset``: remove config and start clean. +* ``--config-path``: use a non-default config location (useful for isolated environments or + CI). +* ``--interactive`` / ``--no-interactive``: force prompts on or off regardless of terminal + auto-detection. +* ``--yes``: suppress confirmation prompts for scripted runs. + +.. note:: + Current command output includes a note that some config integration is still being + finalized. Config values are already consumed by multiple commands (capture, convert, + generate metadata, and YAML config loading paths). + + +4.3 ``capture`` +---------------- + +**Purpose:** + +* Record IQ samples from a supported SDR and save to ``sigmf``, ``npy``, ``wav``, or + ``blue``. + +**Usage:** + +.. code-block:: bash + + ria capture [options] + +Device selection (``--device``) is optional if only one device is detected. Exactly one of +``--num-samples`` or ``--duration`` is required. + +**Core options:** + +*Device/connection:* + +* ``-d, --device {pluto,hackrf,bladerf,usrp,rtlsdr,thinkrf}`` +* ``-i, --ident`` +* ``-c, --config `` + +*RF/capture:* + +* ``-s, --sample-rate`` +* ``-f, --center-frequency`` (supports values like ``915e6``, ``2.4G``) +* ``-g, --gain`` +* ``-b, --bandwidth`` +* ``-n, --num-samples`` +* ``-t, --duration`` + +*Output:* + +* ``-o, --output`` +* ``--output-dir`` +* ``--format {npy,sigmf,wav,blue}`` +* ``--save-image`` + +*Metadata/logging:* + +* ``-m, --metadata KEY=VALUE`` (repeatable) +* ``-v, --verbose``, ``-q, --quiet`` + +**How options work in practice:** + +* ``--device`` + ``--ident``: select both device class and target instance; ``--ident`` + takes serial/IP style selectors. +* ``--config``: load a YAML option set, then override specific fields on the CLI as needed. +* ``--num-samples`` vs ``--duration``: use exact sample count for deterministic datasets, + or time-based capture for quick acquisition. +* ``--format``: ``sigmf`` is best for metadata/annotation workflows. +* ``--save-image``: writes a quick visual summary alongside capture output. +* ``--metadata KEY=VALUE``: injects run-specific metadata (campaign ID, antenna, scenario + tag, etc.). + +**Output behavior:** + +* If ``--output`` is omitted, a timestamped filename is generated automatically. +* If ``--output-dir`` is omitted, captures default to ``recordings/``. +* Format is inferred from the ``--output`` extension when no explicit ``--format`` is given. + +**Examples:** + +.. code-block:: bash + + ria capture -d hackrf -s 2e6 -f 2.44G -n 1000000 -o rf.sigmf-data + ria capture -d pluto -f 915e6 -t 2 --format npy --output-dir recordings + ria capture -c capture_config.yaml + + +4.4 ``view`` +------------- + +**Purpose:** + +* Generate visualizations from IQ files. +* Quickly validate signal quality, occupancy, and annotation coverage without writing custom + plotting code. + +**Usage:** + +.. code-block:: bash + + ria view [options] + +```` accepts SigMF, NPY, WAV, and Blue files. + +**Mode** (``--type``): + +* ``simple``: fast-look plots for sanity checks and quick iteration. +* ``full``: multi-panel diagnostic figure (IQ, time, frequency, metadata views). +* ``annotations`` / ``annotation``: render annotation overlays. +* ``channels``: channelized/segmented visualization. +* ``annotate``: convenience path used in some annotation workflows. + +**Output/display options:** + +* ``--output``, ``--format {png,pdf,svg,jpg}`` +* ``--show``: open an interactive window (requires a GUI display environment). +* ``--no-save``: suppress file output; only meaningful with ``--show``. +* ``--overwrite`` + +**Style options:** + +* ``--dpi``, ``--figsize WxH``, ``--title`` +* ``--light``: switch to a light theme (useful for reports/slides). + +**Loading options:** + +* ``--legacy``: force legacy NPY loading path for older datasets. +* ``--config`` + +**Mode-specific options:** + +*simple:* ``--fast``, ``--compact``, ``--horizontal``, ``--constellation``, ``--labels``, +``--slice start:end[:step]`` + +*full:* ``--plot-length``, ``--no-spectrogram``, ``--no-iq``, ``--no-frequency``, +``--no-constellation``, ``--no-metadata``, ``--no-logo``, ``--spines`` + +*annotations/channels:* ``--channel`` + +**Examples:** + +.. code-block:: bash + + ria view capture.sigmf-data --type simple + ria view capture.npy --type full --title "Test Capture" --format pdf + ria view capture.npy --show --no-save + ria view old.npy --legacy --type simple + + +4.5 ``annotate`` group +----------------------- + +**Purpose:** + +* Manual annotation management and auto-detection/separation. +* Build or refine label metadata directly in recordings for downstream training, QA, and + filtering. + +**Command shape:** + +.. code-block:: bash + + ria annotate ... + +**Subcommands:** ``list``, ``add``, ``remove``, ``clear``, ``energy``, ``cusum``, +``threshold``, ``separate`` + +**General behavior:** + +* SigMF is the preferred format for durable annotation metadata. +* For non-SigMF input, many operations write a new output artifact unless overwrite behavior + is explicitly requested. +* ``--type {standalone,parallel,intersection}`` controls annotation relation semantics. + +``ria annotate list`` +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate list [--verbose] + +Prints all annotations for a recording in index order. ``--verbose`` includes additional +detail per record. + +``ria annotate add`` +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate add --start --count --label [options] + +Adds one explicit annotation with sample-domain boundaries. + +* ``--start``: first sample index of the annotated region. +* ``--count``: number of samples in the region. +* ``--freq-lower``, ``--freq-upper``: optional spectral bounds in Hz. +* ``--comment``, ``--type``, ``-o`` / ``--output``, ``--overwrite``, ``--quiet`` + +``ria annotate remove`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate remove [--output ...] [--overwrite] [--quiet] + +Removes exactly one annotation by list index. Run ``annotate list`` first to confirm the +index. + +``ria annotate clear`` +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate clear [--force] [--overwrite] [--quiet] + +Removes all annotations from the recording. ``--force`` bypasses the confirmation prompt. + +``ria annotate energy`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate energy [options] + +Detects energetic regions above the estimated noise floor and writes them as annotations. + +* ``--label`` +* ``--threshold``: noise-floor multiplier; higher values reduce false positives but can miss + weak signals. +* ``--segments``: number of segments used to estimate baseline noise. +* ``--window-size``: smoothing size; larger windows stabilize detections at the cost of + sharp transition precision. +* ``--min-distance``: minimum sample spacing between detections, preventing dense duplicate + regions. +* ``--freq-method {nbw,obw,full-detected,full-bandwidth}``: how frequency bounds are assigned + to annotations. +* ``--nfft``, ``--obw-power`` +* ``--type``, ``-o`` / ``--output``, ``--overwrite``, ``--quiet`` + +``ria annotate cusum`` +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate cusum [options] + +Uses change-point detection (CUSUM-style logic) to find regime changes and annotate +contiguous segments. + +* ``--label`` +* ``--min-duration`` (ms): prevents tiny over-segmented labels. +* ``--window-size`` +* ``--tolerance``: merges nearby boundaries when set above default. +* ``--type``, ``-o`` / ``--output``, ``--overwrite``, ``--quiet`` + +``ria annotate threshold`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate threshold --threshold <0.0..1.0> [options] + +Uses normalized magnitude thresholding to derive annotation spans. Use where a fixed +amplitude threshold is sufficient. + +* ``--label``, ``--window-size``, ``--type``, ``-o`` / ``--output``, ``--overwrite``, + ``--quiet`` + +``ria annotate separate`` +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate separate [options] + +Decomposes selected annotations into narrower spectral components and emits refined +annotations. + +* ``--indices "0,1,2"``: limit operation to specific annotations; omit to process all. +* ``--nfft``: larger FFT improves frequency resolution but increases compute time. +* ``--noise-threshold-db``: stabilizes detection across heterogeneous captures. +* ``--min-component-bw``: rejects narrow fragments likely to be noise artifacts. +* ``-o`` / ``--output``, ``--overwrite``, ``--quiet``, ``--verbose`` + +**Examples:** + +.. code-block:: bash + + ria annotate list capture.sigmf-data --verbose + ria annotate add capture.sigmf-data --start 10000 --count 5000 --label burst + ria annotate energy capture.sigmf-data --label signal --threshold 1.3 + ria annotate cusum capture.sigmf-data --min-duration 5 + ria annotate separate capture.sigmf-data --indices 0,1 --verbose + + +4.6 ``convert`` +---------------- + +**Purpose:** + +* Convert between ``sigmf``, ``npy``, ``wav``, and ``blue``. +* Normalize datasets into the format required by downstream tooling or collaboration targets. + +**Usage:** + +.. code-block:: bash + + ria convert [output] [options] + +If ``output`` is omitted, ``--format`` must be provided. If both are given, format is +inferred from the output file extension. + +**Options:** + +* ``--format {npy,sigmf,wav,blue}`` +* ``--output-dir`` +* ``--legacy``: use older NPY loader behavior for historical recordings. +* ``--wav-sample-rate``: target sample rate for WAV export. +* ``--wav-bits {16,32}``: output PCM depth; higher preserves more dynamic range. +* ``--blue-format {CI,CF,CD}``: Bluefile complex sample representation. +* ``--metadata KEY=VALUE`` (repeatable): add or override metadata during conversion; + especially useful when exporting to SigMF. +* ``--overwrite``, ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +**Examples:** + +.. code-block:: bash + + ria convert recording.sigmf-data output.npy + ria convert recording.npy --format sigmf + ria convert highrate.npy audio.wav --wav-sample-rate 48000 + ria convert old.npy --format sigmf --legacy --overwrite + + +4.7 ``split`` +-------------- + +**Purpose:** + +* Split, trim, or extract recordings. +* Create manageable dataset shards or extract windows of interest without custom scripts. + +**Usage:** + +.. code-block:: bash + + ria split [operation] [options] + +Choose exactly one operation per invocation: + +* ``--split-at ``: binary split at a specific sample index. +* ``--split-every ``: fixed-size chunking for ML pipelines. +* ``--split-duration ``: time-based chunking. +* ``--trim`` (with ``--start`` + ``--length`` or ``--end``): extract one sub-window. +* ``--extract-annotations``: write each annotated region as a standalone file. + +**Trim controls:** ``--start``, ``--length``, ``--end`` + +**Annotation extraction filters:** ``--annotation-label``, ``--annotation-index`` + +**Output controls:** +``--output-dir``, ``--output-prefix``, ``--output-format {npy,sigmf,wav,blue}``, +``--overwrite``, ``--legacy``, ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +**Examples:** + +.. code-block:: bash + + ria split recording.sigmf-data --split-at 500000 --output-dir out + ria split recording.sigmf-data --split-every 100000 --output-dir chunks + ria split recording.sigmf-data --split-duration 1.0 --output-dir chunks + ria split recording.npy --trim --start 1000 --length 5000 --output-dir trimmed + ria split annotated.sigmf-data --extract-annotations --annotation-label payload + + +4.8 ``combine`` +---------------- + +**Purpose:** + +* Merge multiple recordings by concatenation or sample-wise addition. +* Assemble multi-part captures or synthesize mixtures for testing and model training. + +**Usage:** + +.. code-block:: bash + + ria combine [input3 ...] [options] + +**Options:** + +* ``--mode {concat,add}`` +* ``--align-mode {error,truncate,pad,pad-start,pad-center,pad-end,repeat,repeat-spaced}`` +* ``--pad-start-sample``, ``--repeat-spacing`` +* ``--normalize``: rescale combined output to avoid clipping/saturation after addition. +* ``--output-format {sigmf,npy,wav,blue}`` +* ``--overwrite``, ``--metadata KEY=VALUE`` (repeatable) +* ``--legacy``, ``--verbose``, ``--quiet`` + +**Mode semantics:** + +* ``concat``: append inputs sequentially in time. +* ``add``: sample-wise summation — all inputs must be aligned to the same length. + +**Alignment options for** ``--mode add``: + +* ``error``: fail if lengths differ. +* ``truncate``: cut all to shortest length. +* ``pad``, ``pad-start``, ``pad-center``, ``pad-end``: zero-pad shorter streams. +* ``repeat``: tile shorter streams to match longest. +* ``repeat-spaced``: repeated placement with spacing via ``--repeat-spacing``. + +**Examples:** + +.. code-block:: bash + + ria combine a.npy b.npy c.npy merged.npy + ria combine signal.npy noise.npy noisy.npy --mode add + ria combine long.npy short.npy out.npy --mode add --align-mode pad-center + ria combine signal.npy pattern.npy out.npy --mode add --align-mode repeat-spaced --repeat-spacing 10000 + + +4.9 ``generate`` group (and ``synth`` alias) +--------------------------------------------- + +**Purpose:** + +* Generate synthetic IQ signals and save in ``npy``, ``sigmf``, ``wav``, or ``blue``. +* Create known-reference waveforms and synthetic datasets for validation, demos, and ML + data generation. + +``ria synth ...`` is an alias for ``ria generate ...``. + +**Shape:** + +.. code-block:: bash + + ria generate [subcommand options] [common options] + +**Available subcommands:** +``tone``, ``noise``, ``chirp``, ``square``, ``sawtooth``, ``qam``, ``apsk``, ``pam``, +``fsk``, ``ook``, ``oqpsk``, ``gmsk``, ``psk`` + +**Common options shared across all generators:** + +* ``-s, --sample-rate`` (required) +* ``-n, --num-samples`` or ``-t, --duration`` +* ``--frequency-shift``, ``-fc`` / ``--center-frequency`` +* ``--add-noise``, ``--noise-power``, ``--path-gain`` +* ``-o, --output`` (required), ``-F`` / ``--format {npy,sigmf,wav,blue}`` +* ``--multipath-paths``, ``--multipath-max-delay`` +* ``--iq-amp-imbalance``, ``--iq-phase-imbalance``, ``--iq-dc-offset`` +* ``--config `` +* ``-w`` / ``--overwrite``, ``-m`` / ``--metadata KEY=VALUE`` (repeatable) +* ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +``--frequency-shift`` and ``--center-frequency`` let you separate the baseband shape from +RF metadata context. ``--add-noise`` and ``--noise-power`` apply post-generation noise. +Multipath and IQ imbalance flags apply impairment-style post-processing during generation. + +``tone`` +~~~~~~~~~ + +Options: ``--frequency``, ``--amplitude``, ``--phase`` + +Clean sinusoidal calibration/reference source. + +``noise`` +~~~~~~~~~~ + +Options: ``--noise-type {gaussian,uniform}``, ``--power`` + +Baseline noise floor data or controlled additive-noise synthesis. + +``chirp`` +~~~~~~~~~~ + +Options: ``--bandwidth`` (required), ``--period`` (required), ``--type {up,down,up_down}`` + +Sweep-based radar/sonar-style signals and bandwidth occupancy tests. + +``square`` +~~~~~~~~~~~ + +Options: ``--frequency``, ``--amplitude``, ``--duty-cycle``, ``--phase`` + +``sawtooth`` +~~~~~~~~~~~~~ + +Options: ``--frequency``, ``--amplitude``, ``--phase`` + +Digital modulation families: ``qam``, ``apsk``, ``pam``, ``psk`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ``--symbols``, ``--order`` +* ``--symbol-rate`` +* ``--filter {rrc,rc,gaussian,none}``, ``--filter-span``, ``--filter-beta`` +* ``--message-source {random,file,string}``, ``--message-content`` + +Use ``--message-source random`` for synthetic datasets, ``file`` for deterministic replay, +or ``string`` for small human-readable payload testing. Pulse-shaping filter options +(``--filter``, ``--filter-span``, ``--filter-beta``) control spectral occupancy and ISI. + +``fsk`` +~~~~~~~~ + +Options: ``--symbols``, ``--order``, ``--symbol-rate``, ``--freq-spacing``, +``--modulation-index``, ``--message-source``, ``--message-content`` + +``--freq-spacing`` and ``--modulation-index`` drive tone separation and spectral profile. + +``ook``, ``oqpsk``, ``gmsk`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Options: ``--symbol-rate`` (required), ``--message-source {random,file,string}``, +``--message-content``; ``gmsk`` also accepts ``--bt`` + +``gmsk --bt`` sets the Gaussian filter bandwidth-time product (spectral compactness vs +symbol transition sharpness). + +**Examples:** + +.. code-block:: bash + + ria generate tone -s 2e6 -n 500000 --frequency 50e3 -o tone.sigmf-data + ria generate noise -s 2e6 -n 500000 --noise-type gaussian --power 0.05 -o noise.npy + ria generate chirp -s 5e6 -t 0.5 --bandwidth 2e6 --period 0.01 --type up -o chirp.sigmf-data + ria generate qam -s 2e6 -r 100e3 -M 16 -N 5000 --message-source random -o qam16.npy + ria synth psk -s 2e6 -r 100e3 -M 8 -N 8000 -o psk8.npy + + +4.10 ``transform`` group +------------------------- + +**Purpose:** + +* Apply algorithmic transforms to existing recordings. +* Run reusable augmentations/impairments for dataset diversity and robustness testing. + +**Shape:** + +.. code-block:: bash + + ria transform ... + +``augment`` +~~~~~~~~~~~~ + +.. code-block:: bash + + ria transform augment [augmentation] [input] [output] [options] + +Applies transforms from ``iq_augmentations`` (dataset-expansion style modifications). + +Options: ``--list``, ``--help-transform``, ``--params KEY=VALUE`` (repeatable), ``--view``, +``--overwrite``, ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +``impair`` +~~~~~~~~~~~ + +.. code-block:: bash + + ria transform impair [impairment] [input] [output] [options] + +Applies transforms from ``iq_impairments`` (noise, distortion, and channel degradation +effects). Same options as ``augment``. + +``custom`` +~~~~~~~~~~~ + +.. code-block:: bash + + ria transform custom [transform_name] [input] [output] --transform-dir [options] + +Dynamically loads public functions from Python files in ``--transform-dir`` and exposes them +as callable transforms. + +Options: ``--transform-dir`` (required), ``--list``, ``--help-transform``, +``--params KEY=VALUE`` (repeatable), ``--view``, ``--overwrite``, ``-v`` / ``--verbose``, +``-q`` / ``--quiet`` + +``--params`` values must be ``KEY=VALUE``; types are inferred as int, float, or string. +Use ``--list`` to enumerate available transform names, and ``--help-transform `` to +inspect parameter hints. ``--view`` writes a PNG preview alongside transform output. + +**Examples:** + +.. code-block:: bash + + ria transform augment --list + ria transform augment channel_swap in.npy out.npy + ria transform augment drop_samples in.npy --params max_section_size=5 --view + + ria transform impair --list + ria transform impair add_awgn_to_signal in.npy out.npy --params snr=10 + + ria transform custom --transform-dir ./my_transforms --list + ria transform custom my_filter in.npy out.npy --transform-dir ./my_transforms --params cutoff=0.2 + + +4.11 ``transmit`` +------------------ + +**Purpose:** + +* Transmit IQ via a TX-capable SDR (``pluto``, ``hackrf``, ``bladerf``, ``usrp``). +* Support playback of captured/generated waveforms for over-the-air or wired-loop test + scenarios. + +**Usage:** + +.. code-block:: bash + + ria transmit [options] + +**Input source (choose one):** + +* ``--input ``: transmit an existing recording. +* ``--generate {lfm,chirp,sine,pulse}``: synthesize a transmit signal on the fly. +* If neither is specified, the command defaults to a generated LFM waveform. + +**Core options:** + +*Device/radio:* ``-d`` / ``--device {pluto,hackrf,bladerf,usrp}``, ``-i`` / ``--ident``, +``-c`` / ``--config`` + +*RF:* ``-s`` / ``--sample-rate``, ``-f`` / ``--center-frequency``, ``-g`` / ``--gain``, +``-b`` / ``--bandwidth`` + +*Input/gen:* ``--input``, ``--legacy``, ``--generate {lfm,chirp,sine,pulse}`` + +*TX control:* + +* ``-r, --repeat`` +* ``--continuous``: transmit until interrupted (``Ctrl+C``). +* ``--tx-delay``: pause between repeats when ``--repeat`` is used. +* ``-y, --yes``: skip confirmation prompts; use carefully in scripted environments. + +*Logging:* ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` + +.. warning:: + ``--continuous`` transmits until manually interrupted. Validate gain settings, antenna + configuration, and regulatory compliance before use. + +**Examples:** + +.. code-block:: bash + + ria transmit -d pluto -f 915e6 -s 2e6 --input capture.sigmf-data + ria transmit -d hackrf --generate lfm -f 2.44G --continuous + ria transmit -d usrp --input msg.npy -r 3 --tx-delay 0.5 + + +5) YAML Config Patterns +======================== + +Several commands accept ``--config `` for parameter loading. CLI flags generally +override values loaded from ``--config``. + +Keep one stable baseline YAML per workflow (capture, generate, transmit), then override only +experiment-specific fields on the CLI. + +**Capture config example:** + +.. code-block:: yaml + + device: pluto + ident: 192.168.2.1 + sample_rate: 2000000 + center_frequency: 2.44G + gain: 20 + bandwidth: 2000000 + num_samples: 500000 + format: sigmf + output: run1.sigmf-data + metadata: + campaign: lab_eval + antenna: dipole + +.. code-block:: bash + + ria capture -c capture.yaml + +**Generate config example:** + +.. code-block:: yaml + + sample_rate: 2000000 + num_samples: 200000 + format: npy + output: synth.npy + noise_power: 0.02 + +.. code-block:: bash + + ria generate noise --config generate.yaml + + +6) Practical Tips and Safety +============================= + +* Use ``ria discover`` before capture/transmit sessions. +* Keep TX gain conservative first; validate with attenuators/dummy loads when needed. +* Prefer SigMF for interoperable metadata and annotations. +* For long workflows, keep outputs organized by campaign directories and consistent prefixes. +* Use ``--verbose`` when debugging device init or driver issues. + + +7) Version Notes +================= + +These notes are based on the current implementation and should be re-validated against future +releases. + +1. Some command docstrings and examples still mention ``utils`` or ``ria_toolkit_oss`` + command prefixes in text blocks. The operational command is ``ria ...``. +2. Command bindings currently import ``viewe`` instead of ``view`` in + ``src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py``. +3. Multiple non-CLI modules still import ``utils.*``, which can create runtime dependency + coupling when using only ``ria-toolkit-oss`` in isolation. + +If you observe unexpected import errors after install, check the package version and +changelog, then test ``ria --help`` in a clean virtual environment. + + +8) Brief Scripting (Python) Preview +===================================== + +For quick non-CLI use: + +.. code-block:: python + + from ria_toolkit_oss.datatypes import Recording + from ria_toolkit_oss.io import load_recording, to_sigmf + from ria_toolkit_oss.transforms import iq_augmentations, iq_impairments + + rec = load_recording("capture.sigmf-data") + aug = iq_augmentations.channel_swap(rec) + imp = iq_impairments.add_awgn_to_signal(aug, snr=10) + to_sigmf(imp, filename="capture_awgn", path=".") + +You can also call annotation algorithms and block-generator primitives from Python directly. + + +9) Cheat Sheet +=============== + +.. code-block:: bash + + # Install + pip install ria-toolkit-oss + + # Discover + ria discover -v + + # Init defaults + ria init --author "Jane" --project "rf1" --location "Lab-A" + + # Capture + ria capture -d pluto -f 2.44G -s 2e6 -n 1000000 -o cap.sigmf-data + + # View + ria view cap.sigmf-data --type simple + + # Annotate + ria annotate energy cap.sigmf-data --threshold 1.2 + ria annotate list cap.sigmf-data --verbose + + # Convert + ria convert cap.sigmf-data cap.npy + + # Split + ria split cap.sigmf-data --split-every 100000 --output-dir chunks + + # Combine + ria combine chunks/a.npy chunks/b.npy merged.npy + + # Generate + ria generate qam -s 2e6 -r 100e3 -M 16 -N 5000 -o qam16.npy + + # Transform + ria transform augment channel_swap cap.npy + ria transform impair add_awgn_to_signal cap.npy --params snr=10 + + # Transmit + ria transmit -d hackrf --input cap.sigmf-data -f 2.44G -s 2e6 diff --git a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst b/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst index 149fbaf..95d47e2 100644 --- a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst +++ b/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst @@ -11,15 +11,15 @@ The Radio Dataset Framework provides a software interface to access and manipula the need for users to interface with the source files directly. Instead, users initialize and interact with a Python object, while the complexities of efficient data retrieval and source file manipulation are managed behind the scenes. -Utils includes an abstract class called :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`, which defines common properties and +Ria Toolkit OSS includes an abstract class called :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`, which defines common properties and behaviors for all radio datasets. :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset` can be considered a blueprint for all other radio dataset classes. This class is then subclassed to define more specific blueprints for different types of radio datasets. For example, :py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset`, which is tailored for machine learning tasks involving the processing of signals represented as IQ (In-phase and Quadrature) samples. -Then, in the various project backends, there are concrete dataset classes, which inherit from both Utils and the base +Then, in the various project backends, there are concrete dataset classes, which inherit from both Ria Toolkit OSS and the base dataset class from the respective backend. For example, the :py:obj:`TorchIQDataset` class extends both -:py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset` from Utils and :py:obj:`torch.ria_toolkit_oss.datatypes.IterableDataset` from +:py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset` from Ria Toolkit OSS and :py:obj:`torch.ria_toolkit_oss.datatypes.IterableDataset` from PyTorch, providing a concrete dataset class tailored for IQ datasets and optimized for the PyTorch backend. Dataset initialization @@ -130,7 +130,7 @@ Dataset processing and manipulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ All radio datasets support methods tailored specifically for radio processing. These methods are backend-independent, -inherited from the blueprints in Utils like :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`. +inherited from the blueprints in Ria Toolkit OSS like :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`. For example, we can trim down the length of the examples from 1,024 to 512 samples, and then augment the dataset: diff --git a/poetry.lock b/poetry.lock index cb7a9f0..51596df 100644 --- a/poetry.lock +++ b/poetry.lock @@ -98,6 +98,83 @@ files = [ [package.extras] dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] +[[package]] +name = "bcrypt" +version = "5.0.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83"}, + {file = "bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba"}, + {file = "bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e"}, + {file = "bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5"}, + {file = "bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493"}, + {file = "bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c"}, + {file = "bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d"}, + {file = "bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993"}, + {file = "bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75"}, + {file = "bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c"}, + {file = "bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb"}, + {file = "bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9"}, + {file = "bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980"}, + {file = "bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8"}, + {file = "bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10"}, + {file = "bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172"}, + {file = "bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2"}, + {file = "bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911"}, + {file = "bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4"}, + {file = "bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "black" version = "26.3.1" @@ -153,26 +230,26 @@ uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; [[package]] name = "cachetools" -version = "7.0.5" +version = "7.0.6" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114"}, - {file = "cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990"}, + {file = "cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b"}, + {file = "cachetools-7.0.6.tar.gz", hash = "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24"}, ] [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["agent", "docs", "test"] files = [ - {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, - {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, + {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, + {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, ] [[package]] @@ -182,7 +259,7 @@ description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "implementation_name == \"pypy\"" +markers = "implementation_name == \"pypy\" or platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -275,153 +352,153 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.6" +version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["agent", "docs"] files = [ - {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"}, - {file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"}, - {file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.3" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev", "docs", "server", "test"] files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, + {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, + {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, ] [package.dependencies] @@ -611,6 +688,72 @@ mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.17.0)", " test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] +[[package]] +name = "cryptography" +version = "47.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +files = [ + {file = "cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8"}, + {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318"}, + {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001"}, + {file = "cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203"}, + {file = "cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa"}, + {file = "cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7"}, + {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52"}, + {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd"}, + {file = "cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63"}, + {file = "cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b"}, + {file = "cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe"}, + {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31"}, + {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7"}, + {file = "cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310"}, + {file = "cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8"}, + {file = "cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + [[package]] name = "cycler" version = "0.12.1" @@ -627,6 +770,18 @@ files = [ docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] tests = ["pytest", "pytest-cov", "pytest-xdist"] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "dill" version = "0.4.1" @@ -688,14 +843,14 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi" -version = "0.135.3" +version = "0.136.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.10" groups = ["server", "test"] files = [ - {file = "fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98"}, - {file = "fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654"}, + {file = "fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f"}, + {file = "fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f"}, ] [package.dependencies] @@ -707,19 +862,19 @@ typing-inspection = ">=0.4.2" [package.extras] all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.0" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, - {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, + {file = "filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258"}, + {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, ] [[package]] @@ -746,6 +901,7 @@ description = "The FlatBuffers serialization format for Python" optional = false python-versions = "*" groups = ["server", "test"] +markers = "python_version >= \"3.11\"" files = [ {file = "flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4"}, ] @@ -998,18 +1154,18 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" -version = "3.11" +version = "3.13" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" groups = ["agent", "docs", "server", "test"] files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"}, + {file = "idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "imagesize" @@ -1049,6 +1205,18 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "invoke" +version = "3.0.3" +description = "Pythonic task execution" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053"}, + {file = "invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"}, +] + [[package]] name = "isort" version = "5.13.2" @@ -1347,67 +1515,67 @@ files = [ [[package]] name = "matplotlib" -version = "3.10.8" +version = "3.10.9" description = "Python plotting package" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7"}, - {file = "matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656"}, - {file = "matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df"}, - {file = "matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17"}, - {file = "matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933"}, - {file = "matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a"}, - {file = "matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160"}, - {file = "matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78"}, - {file = "matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4"}, - {file = "matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2"}, - {file = "matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6"}, - {file = "matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9"}, - {file = "matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2"}, - {file = "matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a"}, - {file = "matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58"}, - {file = "matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04"}, - {file = "matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f"}, - {file = "matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466"}, - {file = "matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf"}, - {file = "matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b"}, - {file = "matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6"}, - {file = "matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1"}, - {file = "matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486"}, - {file = "matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce"}, - {file = "matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6"}, - {file = "matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149"}, - {file = "matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645"}, - {file = "matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077"}, - {file = "matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22"}, - {file = "matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39"}, - {file = "matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565"}, - {file = "matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a"}, - {file = "matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958"}, - {file = "matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5"}, - {file = "matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f"}, - {file = "matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b"}, - {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d"}, - {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008"}, - {file = "matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c"}, - {file = "matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11"}, - {file = "matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8"}, - {file = "matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50"}, - {file = "matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908"}, - {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a"}, - {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1"}, - {file = "matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c"}, - {file = "matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b"}, - {file = "matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2"}, - {file = "matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3"}, + {file = "matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217"}, + {file = "matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b"}, + {file = "matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37"}, + {file = "matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294"}, + {file = "matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65"}, + {file = "matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda"}, + {file = "matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb"}, + {file = "matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb"}, + {file = "matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb"}, + {file = "matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9"}, + {file = "matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb"}, + {file = "matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f"}, + {file = "matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80"}, + {file = "matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1"}, + {file = "matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320"}, + {file = "matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285"}, + {file = "matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2"}, + {file = "matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf"}, + {file = "matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6"}, + {file = "matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42"}, + {file = "matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f"}, + {file = "matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e"}, + {file = "matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f"}, + {file = "matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838"}, + {file = "matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2"}, + {file = "matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921"}, + {file = "matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8"}, + {file = "matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9"}, + {file = "matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4"}, + {file = "matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc"}, + {file = "matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99"}, + {file = "matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d"}, + {file = "matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8"}, + {file = "matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38"}, + {file = "matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d"}, + {file = "matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f"}, + {file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b"}, + {file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2"}, + {file = "matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716"}, + {file = "matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f"}, + {file = "matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456"}, + {file = "matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe"}, + {file = "matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6"}, + {file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c"}, + {file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4"}, + {file = "matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf"}, + {file = "matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39"}, + {file = "matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4"}, + {file = "matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358"}, ] [package.dependencies] @@ -1422,7 +1590,7 @@ pyparsing = ">=3" python-dateutil = ">=2.7" [package.extras] -dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] +dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7,<10)"] [[package]] name = "mccabe" @@ -1436,24 +1604,6 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] -[[package]] -name = "mpmath" -version = "1.3.0" -description = "Python library for arbitrary-precision floating-point arithmetic" -optional = false -python-versions = "*" -groups = ["server", "test"] -files = [ - {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, - {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, -] - -[package.extras] -develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] -docs = ["sphinx"] -gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] -tests = ["pytest (>=4.6)"] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1468,14 +1618,14 @@ files = [ [[package]] name = "narwhals" -version = "2.18.1" +version = "2.20.0" description = "Extremely lightweight compatibility layer between dataframe libraries" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad"}, - {file = "narwhals-2.18.1.tar.gz", hash = "sha256:652a1fcc9d432bbf114846688884c215f17eb118aa640b7419295d2f910d2a8b"}, + {file = "narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d"}, + {file = "narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e"}, ] [package.extras] @@ -1537,82 +1687,41 @@ files = [ {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, ] +markers = {server = "python_version >= \"3.11\"", test = "python_version >= \"3.11\""} [[package]] name = "onnxruntime" -version = "1.24.3" -description = "ONNX Runtime is a runtime accelerator for Machine Learning models" -optional = false -python-versions = ">=3.10" -groups = ["server", "test"] -markers = "python_version == \"3.10\"" -files = [ - {file = "onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c"}, - {file = "onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b2ebc54c6d8281dccff78d4b06e47d4cf07535937584ab759448390a70f4978"}, - {file = "onnxruntime-1.24.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb56575d7794bf0781156955610c9e651c9504c64d42ec880784b6106244882d"}, - {file = "onnxruntime-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:c958222ef9eff54018332beecd32d5d94a3ab079d8821937b333811bf4da0d39"}, - {file = "onnxruntime-1.24.3-cp311-cp311-win_arm64.whl", hash = "sha256:a8f761857ebaf58a85b9e42422d03207f1d39e6bb8fecfdbf613bac5b9710723"}, - {file = "onnxruntime-1.24.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0d244227dc5e00a9ae15a7ac1eba4c4460d7876dfecafe73fb00db9f1d914d91"}, - {file = "onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a9847b870b6cb462652b547bc98c49e0efb67553410a082fde1918a38707452"}, - {file = "onnxruntime-1.24.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b354afce3333f2859c7e8706d84b6c552beac39233bcd3141ce7ab77b4cabb5d"}, - {file = "onnxruntime-1.24.3-cp312-cp312-win_amd64.whl", hash = "sha256:44ea708c34965439170d811267c51281d3897ecfc4aa0087fa25d4a4c3eb2e4a"}, - {file = "onnxruntime-1.24.3-cp312-cp312-win_arm64.whl", hash = "sha256:48d1092b44ca2ba6f9543892e7c422c15a568481403c10440945685faf27a8d8"}, - {file = "onnxruntime-1.24.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:34a0ea5ff191d8420d9c1332355644148b1bf1a0d10c411af890a63a9f662aa7"}, - {file = "onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fd2ec7bb0fabe42f55e8337cfc9b1969d0d14622711aac73d69b4bd5abb5ed7"}, - {file = "onnxruntime-1.24.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df8e70e732fe26346faaeec9147fa38bef35d232d2495d27e93dd221a2d473a9"}, - {file = "onnxruntime-1.24.3-cp313-cp313-win_amd64.whl", hash = "sha256:2d3706719be6ad41d38a2250998b1d87758a20f6ea4546962e21dc79f1f1fd2b"}, - {file = "onnxruntime-1.24.3-cp313-cp313-win_arm64.whl", hash = "sha256:b082f3ba9519f0a1a1e754556bc7e635c7526ef81b98b3f78da4455d25f0437b"}, - {file = "onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72f956634bc2e4bd2e8b006bef111849bd42c42dea37bd0a4c728404fdaf4d34"}, - {file = "onnxruntime-1.24.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d1f25eed4ab9959db70a626ed50ee24cf497e60774f59f1207ac8556399c4d"}, - {file = "onnxruntime-1.24.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a6b4bce87d96f78f0a9bf5cefab3303ae95d558c5bfea53d0bf7f9ea207880a8"}, - {file = "onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d48f36c87b25ab3b2b4c88826c96cf1399a5631e3c2c03cc27d6a1e5d6b18eb4"}, - {file = "onnxruntime-1.24.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e104d33a409bf6e3f30f0e8198ec2aaf8d445b8395490a80f6e6ad56da98e400"}, - {file = "onnxruntime-1.24.3-cp314-cp314-win_amd64.whl", hash = "sha256:e785d73fbd17421c2513b0bb09eb25d88fa22c8c10c3f5d6060589efa5537c5b"}, - {file = "onnxruntime-1.24.3-cp314-cp314-win_arm64.whl", hash = "sha256:951e897a275f897a05ffbcaa615d98777882decaeb80c9216c68cdc62f849f53"}, - {file = "onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d4e70ce578aa214c74c7a7a9226bc8e229814db4a5b2d097333b81279ecde36"}, - {file = "onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02aaf6ddfa784523b6873b4176a79d508e599efe12ab0ea1a3a6e7314408b7aa"}, -] - -[package.dependencies] -flatbuffers = "*" -numpy = ">=1.21.6" -packaging = "*" -protobuf = "*" -sympy = "*" - -[[package]] -name = "onnxruntime" -version = "1.24.4" +version = "1.25.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = ">=3.11" groups = ["server", "test"] markers = "python_version >= \"3.11\"" files = [ - {file = "onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2"}, - {file = "onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7"}, - {file = "onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330"}, - {file = "onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153"}, - {file = "onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b"}, - {file = "onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78"}, - {file = "onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5"}, - {file = "onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c"}, - {file = "onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb"}, - {file = "onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90"}, - {file = "onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0"}, - {file = "onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13"}, - {file = "onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f"}, - {file = "onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93"}, - {file = "onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19"}, - {file = "onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee"}, - {file = "onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36"}, - {file = "onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4"}, - {file = "onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1"}, - {file = "onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177"}, - {file = "onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858"}, - {file = "onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d"}, - {file = "onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661"}, - {file = "onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731"}, + {file = "onnxruntime-1.25.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a71baa8e0e2f3417106e3a8b2183fd5741875b998041f1a2422a1d0240f302cb"}, + {file = "onnxruntime-1.25.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15f220ed2ac4a549c97a31e57f21311add9f13d381f13ed1a52be5b25275038c"}, + {file = "onnxruntime-1.25.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a349feb15476092372eb045a340cd88e53f7965ce5b489ebf5fea20c0bd49add"}, + {file = "onnxruntime-1.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:d32eff37efac78c676f1a3b3102863de5e55fbabc18348ec2c1439c18f3e90fa"}, + {file = "onnxruntime-1.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:fcb074b3c62ffa315e222ad246e46125b046b9b531c719852deda7c89b72ebb7"}, + {file = "onnxruntime-1.25.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8ecd3362de3fb496fb3e2d055a95d5acab611cf759a27609c6d99704c9d8f184"}, + {file = "onnxruntime-1.25.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c99238d20bfa80ac68c7b03c2c936d389189ae40997f78a30d151570d7e18bf"}, + {file = "onnxruntime-1.25.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be93baa694ef8e5831fcb7b542da21f502b122918b5b9612d9f02972e043ee01"}, + {file = "onnxruntime-1.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:9596040c1f7d247bbfab5d4db1e7651c790235e48e460c7d445ec81687d5a182"}, + {file = "onnxruntime-1.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:463aed7f5e4a3ca5a476db7e9bba9164fa26921ef34c37e59b28c4c61e55f266"}, + {file = "onnxruntime-1.25.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:1b3d76cf770afba76859f270679c9ad0b017b9357eb5892e91926943e05ca82c"}, + {file = "onnxruntime-1.25.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cddb565dfd630550a8817b3d5493ffcfa0fec273b545b2816f2fce53384e1151"}, + {file = "onnxruntime-1.25.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ade74e651e28b39e6bfd6f576cb9b8a4edfa0916234145154dc891bd55331c22"}, + {file = "onnxruntime-1.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:9196c32c039c37ce8362cbee0aa3a704679be5f2b6fb3e849fea927c98fe1e5b"}, + {file = "onnxruntime-1.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:b3e52dc2208dec6f61ef118dff04610927e9a18d99e019a828799b23cc9cdea4"}, + {file = "onnxruntime-1.25.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de8548d8fe8fd58ca841178051d535d6f378efae14a4b4eb336617d80540fb41"}, + {file = "onnxruntime-1.25.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4edec672d09e34b9e83ad09c44454ce97627388f32858b1d59fe01d091ff54b5"}, + {file = "onnxruntime-1.25.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:38f27febd2ff034a600a8bdbea34b1f7c961a2dab6bcb5351e70548fea456161"}, + {file = "onnxruntime-1.25.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0ae389ed1647f11c1b501ba1cef1e2c7453002f626136ace214c9c46153ee4"}, + {file = "onnxruntime-1.25.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ca32d38173c0f58699ca9dc9e867de74d2c2ab7d1c2d969f862ee8633370b77"}, + {file = "onnxruntime-1.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:a2829e29621db7a4bcd457e6d0f3e4f541fb274c7127e7d2e1a5b46c70572672"}, + {file = "onnxruntime-1.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:2bed9b35568b3ecf8ab34dc832d37216e47947e86508a0fd6b75e4c19d7ba907"}, + {file = "onnxruntime-1.25.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00548a16e8f0d52cb1c67ef50177e5e2be848ccffc6db60010ee37faaccbbb6f"}, + {file = "onnxruntime-1.25.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a071a0740388e0ffad081c583761f37837b113bde3d03dc70790ed6cf4f4de0b"}, ] [package.dependencies] @@ -1620,19 +1729,23 @@ flatbuffers = "*" numpy = ">=1.21.6" packaging = "*" protobuf = "*" -sympy = "*" + +[package.extras] +quantization = ["ml_dtypes"] +symbolic = ["sympy"] [[package]] name = "packaging" -version = "26.0" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "dev", "docs", "server", "test"] files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] +markers = {server = "python_version >= \"3.11\""} [[package]] name = "pandas" @@ -1701,9 +1814,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.22.4", markers = "python_version < \"3.11\""}, {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -1734,23 +1847,43 @@ sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-d test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] xml = ["lxml (>=4.9.2)"] +[[package]] +name = "paramiko" +version = "4.0.0" +description = "SSH2 protocol library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9"}, + {file = "paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f"}, +] + +[package.dependencies] +bcrypt = ">=3.2" +cryptography = ">=3.3" +invoke = ">=2.0" +pynacl = ">=1.5" + +[package.extras] +gssapi = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8) ; platform_system == \"Windows\""] + [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, - {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, ] [package.extras] hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] -tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] [[package]] name = "pillow" @@ -1863,26 +1996,26 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.9.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev", "test"] files = [ - {file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"}, - {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, + {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, + {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, ] [[package]] name = "plotly" -version = "6.6.0" +version = "6.7.0" description = "An open-source interactive data visualization library for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0"}, - {file = "plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c"}, + {file = "plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0"}, + {file = "plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6"}, ] [package.dependencies] @@ -1890,11 +2023,11 @@ narwhals = ">=1.15.1" packaging = "*" [package.extras] -dev = ["plotly[dev-optional]"] -dev-build = ["build", "jupyter", "plotly[dev-core]"] +dev = ["anywidget", "build", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "jupyterlab", "kaleido (>=1.1.0)", "numpy (>=1.22)", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "polars[timezone]", "pyarrow", "pyshp", "pytest", "pytz", "requests", "ruff (==0.11.12)", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] +dev-build = ["build", "jupyterlab", "pytest", "requests", "ruff (==0.11.12)"] dev-core = ["pytest", "requests", "ruff (==0.11.12)"] -dev-optional = ["anywidget", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "numpy", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "plotly[dev-build]", "plotly[kaleido]", "polars[timezone]", "pyarrow", "pyshp", "pytz", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] -express = ["numpy"] +dev-optional = ["anywidget", "build", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "jupyterlab", "kaleido (>=1.1.0)", "numpy (>=1.22)", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "polars[timezone]", "pyarrow", "pyshp", "pytest", "pytz", "requests", "ruff (==0.11.12)", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] +express = ["numpy (>=1.22)"] kaleido = ["kaleido (>=1.1.0)"] [[package]] @@ -1920,6 +2053,7 @@ description = "" optional = false python-versions = ">=3.10" groups = ["server", "test"] +markers = "python_version >= \"3.11\"" files = [ {file = "protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7"}, {file = "protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b"}, @@ -1950,7 +2084,7 @@ description = "C parser in Python" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "implementation_name == \"pypy\"" +markers = "(platform_python_implementation != \"PyPy\" or implementation_name == \"pypy\") and implementation_name != \"PyPy\"" files = [ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, @@ -1958,19 +2092,19 @@ files = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["server", "test"] files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, + {file = "pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927"}, + {file = "pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.41.5" +pydantic-core = "2.46.3" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" @@ -1980,133 +2114,132 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.3" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["server", "test"] files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1"}, + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fa3eb7c2995aa443687a825bc30395c8521b7c6ec201966e55debfd1128bcceb"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d08782c4045f90724b44c95d35ebec0d67edb8a957a2ac81d5a8e4b8a200495"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:831eb19aa789a97356979e94c981e5667759301fb708d1c0d5adf1bc0098b873"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4335e87c7afa436a0dfa899e138d57a72f8aad542e2cf19c36fb428461caabd0"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99421e7684a60f7f3550a1d159ade5fdff1954baedb6bdd407cba6a307c9f27d"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd81f6907932ebac3abbe41378dac64b2380db1287e2aa64d8d88f78d170f51a"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f247596366f4221af52beddd65af1218797771d6989bc891a0b86ccaa019168"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:6dff8cc884679df229ebc6d8eb2321ea6f8e091bc7d4886d4dc2e0e71452843c"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68ef2f623dda6d5a9067ac014e406c020c780b2a358930a7e5c1b73702900720"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d56bdb4af1767cc15b0386b3c581fdfe659bb9ee4a4f776e92c1cd9d074000d6"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:91249bcb7c165c2fb2a2f852dbc5c91636e2e218e75d96dfdd517e4078e173dd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b068543bdb707f5d935dab765d99227aa2545ef2820935f2e5dd801795c7dbd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win32.whl", hash = "sha256:dcda6583921c05a40533f982321532f2d8db29326c7b95c4026941fa5074bd79"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win_amd64.whl", hash = "sha256:a35cc284c8dd7edae8a31533713b4d2467dfe7c4f1b5587dd4031f28f90d1d13"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff"}, + {file = "pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c"}, ] [package.dependencies] @@ -2155,9 +2288,9 @@ files = [ astroid = ">=3.3.8,<=3.4.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ - {version = ">=0.2", markers = "python_version < \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, {version = ">=0.3.6", markers = "python_version == \"3.11\""}, + {version = ">=0.2", markers = "python_version < \"3.11\""}, ] isort = ">=4.2.5,<5.13 || >5.13,<7" mccabe = ">=0.6,<0.8" @@ -2169,6 +2302,48 @@ tomlkit = ">=0.10.1" spelling = ["pyenchant (>=3.2,<4.0)"] testutils = ["gitpython (>3)"] +[[package]] +name = "pynacl" +version = "1.6.2" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, + {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} + +[package.extras] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] + [[package]] name = "pyparsing" version = "3.3.2" @@ -2245,14 +2420,14 @@ six = ">=1.5" [[package]] name = "python-discovery" -version = "1.2.1" +version = "1.2.2" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502"}, - {file = "python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e"}, + {file = "python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a"}, + {file = "python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb"}, ] [package.dependencies] @@ -2775,17 +2950,18 @@ test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis [[package]] name = "sigmf" -version = "1.7.2" +version = "1.9.0" description = "Easily interact with Signal Metadata Format (SigMF) recordings." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "sigmf-1.7.2-py3-none-any.whl", hash = "sha256:6599b95e8bd3ac2c568b8ec46c312a77b80868cbda79d729234f396d2194d3d8"}, - {file = "sigmf-1.7.2.tar.gz", hash = "sha256:5f80f7127539358c7528ccf26e0ac5b3c268ecaeb69a921542e8ff71d0c85346"}, + {file = "sigmf-1.9.0-py3-none-any.whl", hash = "sha256:902e694894e61f8cdb75b0d69ae8c407f82f35435c3c5e4c1b586b313f77b89b"}, + {file = "sigmf-1.9.0.tar.gz", hash = "sha256:95e4b28156b2182035ecca5f5852108fb3cdef5f20b0cd48919bb0fc5f293d0e"}, ] [package.dependencies] +defusedxml = "*" jsonschema = "*" numpy = "*" @@ -3029,24 +3205,6 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] -[[package]] -name = "sympy" -version = "1.14.0" -description = "Computer algebra system (CAS) in Python" -optional = false -python-versions = ">=3.9" -groups = ["server", "test"] -files = [ - {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, - {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, -] - -[package.dependencies] -mpmath = ">=1.1.0,<1.4" - -[package.extras] -dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] - [[package]] name = "tomli" version = "2.4.1" @@ -3131,14 +3289,14 @@ files = [ [[package]] name = "tox" -version = "4.52.0" +version = "4.53.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "tox-4.52.0-py3-none-any.whl", hash = "sha256:624d8ea4a8c6d5e8d168eedf0e318d736fb22e83ca83137d001ac65ffdec46fd"}, - {file = "tox-4.52.0.tar.gz", hash = "sha256:6054abf5c8b61d58776fbec991f9bf0d34bb883862beb93d2fe55601ef3977c9"}, + {file = "tox-4.53.0-py3-none-any.whl", hash = "sha256:cc4e716d18c4889aa179d785175c438fa60c35deef20ce689ec288d8fb656096"}, + {file = "tox-4.53.0.tar.gz", hash = "sha256:62c780e42f87d34ee60f2ea20342156253794fdcbd6885fd797d98ee05009f22"}, ] [package.dependencies] @@ -3149,7 +3307,7 @@ packaging = ">=26" platformdirs = ">=4.9.4" pluggy = ">=1.6" pyproject-api = ">=1.10" -python-discovery = ">=1.2.1" +python-discovery = ">=1.2.2" tomli = {version = ">=2.4", markers = "python_version < \"3.11\""} tomli-w = ">=1.2" typing-extensions = {version = ">=4.15", markers = "python_version < \"3.11\""} @@ -3169,7 +3327,7 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {main = "python_version <= \"3.12\"", dev = "python_version == \"3.10\"", docs = "python_version <= \"3.12\""} +markers = {main = "python_version < \"3.13\"", dev = "python_version == \"3.10\"", docs = "python_version < \"3.13\""} [[package]] name = "typing-inspection" @@ -3188,14 +3346,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2025.3" +version = "2026.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] files = [ - {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, - {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] [[package]] @@ -3218,14 +3376,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" -version = "0.42.0" +version = "0.46.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" groups = ["docs", "server", "test"] files = [ - {file = "uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359"}, - {file = "uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775"}, + {file = "uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048"}, + {file = "uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d"}, ] [package.dependencies] @@ -3310,21 +3468,21 @@ test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", [[package]] name = "virtualenv" -version = "21.2.0" +version = "21.2.4" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, - {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, + {file = "virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac"}, + {file = "virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" -python-discovery = ">=1" +python-discovery = ">=1.2.2" typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} [[package]] @@ -3548,4 +3706,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "7ddbf7d85e9ae7bd3a1b99ae481df20aaf6fd185d5f628b0fdf9b7bd278730ed" +content-hash = "ffde300b2fc93161d2279a6e2b899bc988d3b5eb3833135821830affc9a5fb62" diff --git a/pyproject.toml b/pyproject.toml index a0bd664..e648d0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ria-toolkit-oss" -version = "0.1.4" +version = "0.1.5" description = "An open-source version of the RIA Toolkit, including the fundamental tools to get started developing, testing, and deploying radio intelligence applications" license = { text = "AGPL-3.0-only" } readme = "README.md" @@ -49,7 +49,8 @@ dependencies = [ "pyzmq (>=27.1.0,<28.0.0)", "pyyaml (>=6.0.3,<7.0.0)", "click (>=8.1.0,<9.0.0)", - "matplotlib (>=3.8.0,<4.0.0)" + "matplotlib (>=3.8.0,<4.0.0)", + "paramiko (>=4.0.0)" ] # [project.optional-dependencies] Commented out to prevent Tox tests from failing @@ -87,7 +88,7 @@ pytest = "^8.0.0" tox = "^4.19.0" fastapi = ">=0.111,<1.0" uvicorn = {version = ">=0.29,<1.0", extras = ["standard"]} -onnxruntime = ">=1.17,<2.0" +onnxruntime = {version = ">=1.17,<2.0", python = ">=3.11"} httpx = ">=0.27,<1.0" [tool.poetry.group.docs.dependencies] @@ -118,11 +119,12 @@ ria = "ria_toolkit_oss_cli.cli:cli" ria-tools = "ria_toolkit_oss_cli.cli:cli" ria-server = "ria_toolkit_oss.server.cli:serve" ria-agent = "ria_toolkit_oss.agent.cli:main" +ria-app = "ria_toolkit_oss.app.cli:main" [tool.poetry.group.server.dependencies] fastapi = ">=0.111,<1.0" uvicorn = {version = ">=0.29,<1.0", extras = ["standard"]} -onnxruntime = ">=1.17,<2.0" +onnxruntime = {version = ">=1.17,<2.0", python = ">=3.11"} [tool.black] line-length = 119 diff --git a/scripts/pluto_tx_smoke.py b/scripts/pluto_tx_smoke.py new file mode 100755 index 0000000..97913ec --- /dev/null +++ b/scripts/pluto_tx_smoke.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Transmit a continuous tone through the agent's TX pipeline on a real Pluto. + +End-to-end smoke test for the Pluto + Streamer TX path. Drives the same +``Streamer`` the hub talks to, but in-process with a logging ``FakeWs`` so +the script is self-contained — no hub required. + +Default: 100 kHz baseband tone × 2 450 MHz LO → carrier at 2 450.1 MHz, +continuous until you Ctrl-C (or the ``--duration`` timer fires). A spectrum +analyzer tuned to 2 450.1 MHz should show a clean CW spike as long as +``tx_status: transmitting`` prints. + +Usage:: + + python3 scripts/pluto_tx_smoke.py # auto-discover Pluto + python3 scripts/pluto_tx_smoke.py --identifier 192.168.3.1 + python3 scripts/pluto_tx_smoke.py --frequency 2.4e9 --gain -20 --duration 60 + +Flags map 1:1 onto the agent's ``radio_config``: + + --identifier Pluto IP or hostname (omitted → ip:pluto.local). + --frequency TX LO in Hz. Default 2 450 MHz. + --gain Pluto TX gain in dB. Pluto range is ``[-89, 0]``; more negative + = more attenuation = less power. Default -30. + --sample-rate Baseband sample rate. Default 1 MHz. + --tone Baseband tone offset in Hz. Default 100 kHz; set 0 for DC + (unmodulated carrier at exactly --frequency, but Pluto's + LO leakage will dominate). + --buffer-size Complex samples per WS frame. Default 4096. + --duration Stop after this many seconds (0 = run until Ctrl-C). +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import signal +import sys + +import numpy as np + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer + + +class LoggingFakeWs: + """In-process stand-in for the hub's WebSocket. + + Prints every ``tx_status`` + ``error`` frame the Streamer emits so the + operator can watch the lifecycle (armed → transmitting → done) on stdout. + """ + + async def send_json(self, payload: dict) -> None: + t = payload.get("type") + if t == "tx_status": + state = payload.get("state") + msg = payload.get("message") + tail = f" — {msg}" if msg else "" + print(f"[tx_status] {state}{tail}") + elif t == "error": + print(f"[error] {payload.get('message')}") + + async def send_bytes(self, data: bytes) -> None: + # Agent side won't send RX bytes in this script (no RX session). + pass + + +def _make_iq_frame( + buffer_size: int, tone_hz: float, sample_rate: float, phase_offset: float = 0.0 +) -> tuple[bytes, float]: + """Return ``(interleaved_float32_bytes, next_phase)`` for a sine tone. + + Emitting one continuous phase-coherent tone requires threading the phase + across frames; the returned ``next_phase`` should be fed back as + ``phase_offset`` on the next call so the sinusoid doesn't glitch at frame + boundaries. Amplitude is 0.7 to leave some headroom below the [-1, 1] cap + that ``_verify_sample_format`` polices elsewhere in the toolkit. + """ + n = np.arange(buffer_size, dtype=np.float64) + phase = 2.0 * np.pi * tone_hz / sample_rate * n + phase_offset + amp = 0.7 + iq = amp * (np.cos(phase) + 1j * np.sin(phase)) + iq = iq.astype(np.complex64) + interleaved = np.empty(buffer_size * 2, dtype=np.float32) + interleaved[0::2] = iq.real + interleaved[1::2] = iq.imag + next_phase = (2.0 * np.pi * tone_hz / sample_rate * buffer_size + phase_offset) % (2.0 * np.pi) + return interleaved.tobytes(), next_phase + + +def _make_pluto_factory(identifier: str | None): + def factory(device: str, _ident: str | None): + if device != "pluto": + raise ValueError(f"this script only drives pluto; got device={device!r}") + from ria_toolkit_oss.sdr.pluto import Pluto + + return Pluto(identifier=identifier) + + return factory + + +async def _run(args: argparse.Namespace) -> int: + ws = LoggingFakeWs() + cfg = AgentConfig( + tx_enabled=True, + # Pluto's TX gain range is [-89, 0]. Cap at 0 so a fat-fingered + # --gain=+5 still gets rejected at the agent boundary rather than + # turned into mystery attenuation by Pluto's setter. + tx_max_gain_db=0.0, + tx_max_duration_s=float(args.duration) if args.duration > 0 else None, + ) + streamer = Streamer(ws=ws, sdr_factory=_make_pluto_factory(args.identifier), cfg=cfg) + + await streamer.on_message( + { + "type": "tx_start", + "app_id": "smoke", + "radio_config": { + "device": "pluto", + "identifier": args.identifier, + "tx_sample_rate": int(args.sample_rate), + "tx_center_frequency": int(args.frequency), + "tx_gain": int(args.gain), + "buffer_size": int(args.buffer_size), + # "repeat" keeps the last buffer on the air if we ever stall, + # so a continuous carrier stays up even when Python GC or + # asyncio scheduling briefly pauses the producer. + "underrun_policy": "repeat", + }, + } + ) + + # Abort if tx_start was rejected by an interlock (no session → nothing to do). + if streamer._tx is None: + print("tx_start rejected — see [tx_status] line above for the reason.", file=sys.stderr) + return 2 + + print( + f"Transmitting at {args.frequency/1e6:.3f} MHz with " + f"{args.tone/1e3:.1f} kHz baseband tone at gain {args.gain} dB. " + f"{'Running for ' + str(args.duration) + 's' if args.duration > 0 else 'Run until Ctrl-C'}." + ) + + # Arrange a clean shutdown on Ctrl-C. + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop.set) + except NotImplementedError: + # add_signal_handler is not available on Windows event loops. + pass + + # Produce buffers at the nominal sample-rate pace. We deliberately stay + # slightly ahead of the radio — queue is bounded at 8, so backpressure + # flows naturally. + phase = 0.0 + buffer_dt = args.buffer_size / args.sample_rate + # Aim for one buffer every ``buffer_dt * 0.5`` seconds so the queue stays + # topped up. The queue's own backpressure keeps us from spinning. + produce_interval = buffer_dt * 0.5 + try: + + async def producer(): + nonlocal phase + while not stop.is_set(): + frame, phase = _make_iq_frame(args.buffer_size, args.tone, args.sample_rate, phase) + await streamer.on_binary(frame) + await asyncio.sleep(produce_interval) + + producer_task = asyncio.create_task(producer()) + + if args.duration > 0: + try: + await asyncio.wait_for(stop.wait(), timeout=args.duration) + except asyncio.TimeoutError: + pass + else: + await stop.wait() + + stop.set() + producer_task.cancel() + try: + await producer_task + except (asyncio.CancelledError, Exception): + pass + finally: + await streamer.on_message({"type": "tx_stop", "app_id": "smoke"}) + + print("TX session closed.") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser( + description="End-to-end TX smoke test: agent → Pluto continuous tone.", + ) + p.add_argument("--identifier", default=None, help="Pluto IP/hostname (default: auto-discover pluto.local)") + p.add_argument("--frequency", type=float, default=3_410_000_000.0, help="TX LO in Hz (default 2.45 GHz)") + p.add_argument("--gain", type=float, default=-0.0, help="TX gain in dB; Pluto range [-89, 0] (default -30)") + p.add_argument("--sample-rate", type=float, default=1_000_000.0, help="Baseband sample rate (default 1 Msps)") + p.add_argument( + "--tone", type=float, default=100_000.0, help="Baseband tone offset in Hz; 0 = DC (default 100 kHz)" + ) + p.add_argument("--buffer-size", type=int, default=4096, help="Complex samples per frame (default 4096)") + p.add_argument( + "--duration", type=float, default=60.0, help="Seconds to transmit; 0 = run until Ctrl-C (default 30)" + ) + p.add_argument("--log-level", default="INFO") + args = p.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + try: + return asyncio.run(_run(args)) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pluto_tx_ws_smoke.py b/scripts/pluto_tx_ws_smoke.py new file mode 100755 index 0000000..f828e0c --- /dev/null +++ b/scripts/pluto_tx_ws_smoke.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Full-stack TX smoke test: localhost mock-hub → WS → agent → real Pluto. + +Same radio output as ``pluto_tx_smoke.py`` (continuous tone at 2 450.1 MHz), +but drives the agent through the *real* WebSocket path instead of calling +handlers in-process. Proves that the hub-driven path behaves identically: + + mock hub ── ws:// ──▶ WsClient.run() ──▶ Streamer.on_message + └▶ Streamer.on_binary + │ + ▼ + real Pluto + +This is the most rigorous check short of pointing the real ``ria-agent stream`` +at a live ria-hub. If a tone appears on the spectrum analyzer here but *not* +when ria-hub drives it, the fault is above the WS decoder (registration, +capability gate, TX operator, hub's binary-frame publisher); everything +downstream of ``ws.recv()`` is this script's code path. + +Usage:: + + python3 scripts/pluto_tx_ws_smoke.py # default 30s tone + python3 scripts/pluto_tx_ws_smoke.py --identifier 192.168.3.1 + python3 scripts/pluto_tx_ws_smoke.py --duration 0 # until Ctrl-C +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import signal +import sys + +import numpy as np +import websockets + +from ria_toolkit_oss.agent.config import AgentConfig +from ria_toolkit_oss.agent.streamer import Streamer +from ria_toolkit_oss.agent.ws_client import WsClient + + +def _make_iq_frame(buffer_size: int, tone_hz: float, sample_rate: float, phase_offset: float) -> tuple[bytes, float]: + n = np.arange(buffer_size, dtype=np.float64) + phase = 2.0 * np.pi * tone_hz / sample_rate * n + phase_offset + amp = 0.7 + iq = (amp * (np.cos(phase) + 1j * np.sin(phase))).astype(np.complex64) + interleaved = np.empty(buffer_size * 2, dtype=np.float32) + interleaved[0::2] = iq.real + interleaved[1::2] = iq.imag + next_phase = (2.0 * np.pi * tone_hz / sample_rate * buffer_size + phase_offset) % (2.0 * np.pi) + return interleaved.tobytes(), next_phase + + +def _make_pluto_factory(identifier: str | None): + def factory(device: str, _ident: str | None): + if device != "pluto": + raise ValueError(f"this script only drives pluto; got device={device!r}") + from ria_toolkit_oss.sdr.pluto import Pluto + + return Pluto(identifier=identifier) + + return factory + + +async def _mock_hub_handler(ws, args, stop: asyncio.Event): + """Server side of the WS. Sends tx_start, streams IQ, then tx_stop.""" + # Drain the first heartbeat so the log is clean; we don't need to gate on + # it for a localhost smoke test. + try: + first = await asyncio.wait_for(ws.recv(), timeout=2.0) + if isinstance(first, str): + payload = json.loads(first) + if payload.get("type") == "heartbeat": + caps = payload.get("capabilities") + print(f"[mock-hub] agent heartbeat: capabilities={caps} " f"tx_enabled={payload.get('tx_enabled')}") + except asyncio.TimeoutError: + print("[mock-hub] warning: no heartbeat received in first 2s") + + # Arm the agent's TX path. + await ws.send( + json.dumps( + { + "type": "tx_start", + "app_id": "ws-smoke", + "radio_config": { + "device": "pluto", + "identifier": args.identifier, + "tx_sample_rate": int(args.sample_rate), + "tx_center_frequency": int(args.frequency), + "tx_gain": int(args.gain), + "buffer_size": int(args.buffer_size), + "underrun_policy": "repeat", + }, + } + ) + ) + print(f"[mock-hub] sent tx_start at {args.frequency/1e6:.3f} MHz, " f"gain={args.gain} dB") + + # Producer: push IQ frames at a steady clip. Use a concurrent receiver so + # tx_status frames show up in real time rather than being queued behind + # the sends. + phase = 0.0 + buffer_dt = args.buffer_size / args.sample_rate + + async def receiver(): + try: + while True: + msg = await ws.recv() + if isinstance(msg, str): + print(f"[mock-hub] ← {msg}") + except (websockets.ConnectionClosed, asyncio.CancelledError): + pass + + recv_task = asyncio.create_task(receiver()) + try: + deadline = None if args.duration <= 0 else (asyncio.get_event_loop().time() + args.duration) + while not stop.is_set(): + if deadline is not None and asyncio.get_event_loop().time() >= deadline: + break + frame, phase = _make_iq_frame(args.buffer_size, args.tone, args.sample_rate, phase) + try: + await ws.send(frame) + except websockets.ConnectionClosed: + break + # Slightly ahead of real-time; WS backpressure handles the rest. + await asyncio.sleep(buffer_dt * 0.5) + finally: + try: + await ws.send(json.dumps({"type": "tx_stop", "app_id": "ws-smoke"})) + print("[mock-hub] sent tx_stop") + except websockets.ConnectionClosed: + pass + # Give the agent a moment to emit `tx_status: done` before we tear down. + await asyncio.sleep(0.3) + recv_task.cancel() + try: + await recv_task + except (asyncio.CancelledError, Exception): + pass + + +async def _run(args: argparse.Namespace) -> int: + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop.set) + except NotImplementedError: + pass + + # Start the mock hub on a local port. + async def handler(ws): + try: + await _mock_hub_handler(ws, args, stop) + finally: + stop.set() + + server = await websockets.serve(handler, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + print(f"[mock-hub] listening on ws://127.0.0.1:{port}") + + # Run the agent — exactly as ``ria-agent stream`` would, just with a + # different URL and an in-memory AgentConfig instead of one loaded from + # ``~/.ria/agent.json``. + client = WsClient( + f"ws://127.0.0.1:{port}", + token="", + heartbeat_interval=5.0, + reconnect_pause=0.5, + ) + streamer = Streamer( + ws=client, + sdr_factory=_make_pluto_factory(args.identifier), + cfg=AgentConfig(tx_enabled=True, tx_max_gain_db=0.0), + ) + client_task = asyncio.create_task( + client.run( + on_message=streamer.on_message, + heartbeat=streamer.build_heartbeat, + on_binary=streamer.on_binary, + ) + ) + + try: + await stop.wait() + finally: + client.stop() + client_task.cancel() + try: + await client_task + except (asyncio.CancelledError, Exception): + pass + server.close() + await server.wait_closed() + + print("Done.") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser( + description="Full-stack TX smoke: localhost mock-hub → WS → agent → Pluto.", + ) + p.add_argument("--identifier", default=None, help="Pluto IP/hostname (default: auto-discover pluto.local)") + p.add_argument("--frequency", type=float, default=2_450_000_000.0, help="TX LO in Hz (default 2.45 GHz)") + p.add_argument("--gain", type=float, default=0.0, help="TX gain in dB; Pluto range [-89, 0] (default 0)") + p.add_argument("--sample-rate", type=float, default=1_000_000.0, help="Baseband sample rate (default 1 Msps)") + p.add_argument("--tone", type=float, default=100_000.0, help="Baseband tone offset in Hz (default 100 kHz)") + p.add_argument("--buffer-size", type=int, default=4096, help="Complex samples per frame (default 4096)") + p.add_argument( + "--duration", type=float, default=30.0, help="Seconds to transmit; 0 = run until Ctrl-C (default 30)" + ) + p.add_argument("--log-level", default="INFO") + args = p.parse_args() + + logging.basicConfig( + level=getattr(logging, args.log_level.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + try: + return asyncio.run(_run(args)) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ria_toolkit_oss/agent/cli.py b/src/ria_toolkit_oss/agent/cli.py index 41be210..692ea6a 100644 --- a/src/ria_toolkit_oss/agent/cli.py +++ b/src/ria_toolkit_oss/agent/cli.py @@ -5,8 +5,11 @@ Subcommands: - ``ria-agent run [legacy args]`` — legacy long-poll NodeAgent (unchanged). - ``ria-agent stream`` — new WebSocket-based IQ streamer. - ``ria-agent detect`` — print SDR drivers whose modules import cleanly. -- ``ria-agent register --url URL --token TOKEN`` — save credentials to - ``~/.ria/agent.json``. +- ``ria-agent register --hub URL --api-key KEY`` — register with the hub + using a personal registration key (minted from **Settings → RIA Agents** + on the hub, shown once at mint time) and save credentials (and optional + TX interlocks) to ``~/.ria/agent.json``. The hub also accepts the legacy + shared ``[wac] API_KEY`` for back-compat, but that path is deprecated. Invoking ``ria-agent`` with no subcommand falls through to the legacy long-poll behavior for back-compatibility with existing deployments. @@ -23,10 +26,62 @@ import sys from . import config as _config from .hardware import available_devices from .legacy_executor import main as _legacy_main +from .namegen import generate_agent_name _LEGACY_ALIASES = {"--hub", "--key", "--name", "--device", "--insecure", "--log-level", "--config"} +REGISTRATION_REASON_MESSAGES = { + "invalid_key": ( + "Registration key not recognized. Generate a fresh key from " + "Settings → RIA Agents on the hub." + ), + "expired": ( + "This registration key has expired. Generate a new one from " + "Settings → RIA Agents on the hub." + ), + "revoked": ( + "This registration key was revoked. Generate a new one from " + "Settings → RIA Agents on the hub." + ), + "already_consumed": ( + "This single-use registration key has already been used. " + "Generate a new one, or mint a reusable key instead." + ), +} + + +def _explain_registration_failure(status: int, body: bytes) -> str: + """Return a human-readable explanation for a failed register call.""" + try: + parsed = json.loads(body) if body else None + except ValueError: + parsed = None + + if status == 429: + # 429 carries a plain string detail, never a reason code. + if isinstance(parsed, dict) and parsed.get("detail"): + detail = parsed["detail"] + else: + detail = body.decode("utf-8", "replace") or "rate limited" + return f"Registration rate-limited by the hub: {detail}" + + if not isinstance(parsed, dict): + text = body.decode("utf-8", "replace") + return f"HTTP {status}: {text or 'no body'}" + + detail = parsed.get("detail") + if isinstance(detail, dict): + reason = detail.get("reason") + if reason in REGISTRATION_REASON_MESSAGES: + return REGISTRATION_REASON_MESSAGES[reason] + if reason: + return f"Registration rejected ({reason})" + if isinstance(detail, str) and detail: + return f"Registration rejected: {detail}" + return f"HTTP {status}: {parsed}" + + def _cmd_detect(_args: argparse.Namespace) -> int: devices = available_devices() if not devices: @@ -38,11 +93,13 @@ def _cmd_detect(_args: argparse.Namespace) -> int: def _cmd_register(args: argparse.Namespace) -> int: + import urllib.error import urllib.request hub_url = args.hub.rstrip("/") url = f"{hub_url}/screens/agents/register" - body = json.dumps({"name": args.name or ""}).encode() + name = args.name or generate_agent_name() + body = json.dumps({"name": name}).encode() # Explicit User-Agent: Python's default `Python-urllib/` is blocked # by Cloudflare's Browser Integrity Check on `riahub.ai` (HTTP 403 code # 1010), so register() never reached the hub. Any non-default UA passes. @@ -58,6 +115,14 @@ def _cmd_register(args: argparse.Namespace) -> int: try: with urllib.request.urlopen(req) as resp: data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + try: + err_body = e.read() + except Exception: + err_body = b"" + msg = _explain_registration_failure(e.code, err_body) + print(f"error: registration failed: {msg}", file=sys.stderr) + return 1 except Exception as e: print(f"error: registration failed: {e}", file=sys.stderr) return 1 @@ -70,12 +135,29 @@ def _cmd_register(args: argparse.Namespace) -> int: cfg.agent_id = agent_id cfg.token = token cfg.api_key = args.api_key - if args.name: - cfg.name = args.name + cfg.name = name cfg.insecure = bool(args.insecure) + cfg.tx_enabled = bool(getattr(args, "allow_tx", False)) + if (v := getattr(args, "tx_max_gain_db", None)) is not None: + cfg.tx_max_gain_db = float(v) + if (v := getattr(args, "tx_max_duration_s", None)) is not None: + cfg.tx_max_duration_s = float(v) + freq_ranges = getattr(args, "tx_freq_range", None) or [] + if freq_ranges: + cfg.tx_allowed_freq_ranges = [[float(lo), float(hi)] for lo, hi in freq_ranges] path = _config.save(cfg) - print(f"Registered agent: {agent_id}") + print(f"Registered agent: ({name})") + if cfg.tx_enabled: + caps: list[str] = [] + if cfg.tx_max_gain_db is not None: + caps.append(f"gain<={cfg.tx_max_gain_db} dB") + if cfg.tx_max_duration_s is not None: + caps.append(f"duration<={cfg.tx_max_duration_s} s") + if cfg.tx_allowed_freq_ranges: + caps.append(f"freq in {cfg.tx_allowed_freq_ranges}") + tail = f" ({', '.join(caps)})" if caps else "" + print(f"TX enabled{tail}") print(f"Credentials saved to {path}") return 0 @@ -89,8 +171,10 @@ def _cmd_stream(args: argparse.Namespace) -> int: if not url: print("error: --url is required (or run `ria-agent register` first)", file=sys.stderr) return 2 + if getattr(args, "allow_tx", False): + cfg.tx_enabled = True try: - asyncio.run(run_streamer(url, token)) + asyncio.run(run_streamer(url, token, cfg=cfg)) except KeyboardInterrupt: pass return 0 @@ -101,9 +185,9 @@ def _derive_ws_url(hub_url: str, agent_id: str) -> str: return "" base = hub_url.rstrip("/") if base.startswith("https://"): - base = "wss://" + base[len("https://"):] + base = "wss://" + base[len("https://") :] elif base.startswith("http://"): - base = "ws://" + base[len("http://"):] + base = "ws://" + base[len("http://") :] suffix = f"/screens/agent/ws?agent_id={agent_id}" if agent_id else "/screens/agent/ws" return base + suffix @@ -124,14 +208,59 @@ def main() -> None: p_reg = sub.add_parser("register", help="Register agent with RIA Hub and save credentials") p_reg.add_argument("--hub", required=True, help="RIA Hub URL (e.g. http://whitehorse:3005)") - p_reg.add_argument("--api-key", dest="api_key", required=True, help="Hub API key") + p_reg.add_argument( + "--api-key", + dest="api_key", + required=True, + help=( + "Personal registration key from the RIA Agents page on the hub " + "(format: ria_reg_...). Shown once when generated; save it then. " + "The legacy shared API key is also accepted but deprecated." + ), + ) p_reg.add_argument("--name", default=None, help="Human-friendly agent name") p_reg.add_argument("--insecure", action="store_true", help="Skip TLS verification") + p_reg.add_argument( + "--allow-tx", + dest="allow_tx", + action="store_true", + help="Opt this agent in to TX (required for any transmission from the hub)", + ) + p_reg.add_argument( + "--tx-max-gain-db", + dest="tx_max_gain_db", + type=float, + default=None, + help="Reject tx_start frames whose tx_gain exceeds this cap (dB)", + ) + p_reg.add_argument( + "--tx-max-duration-s", + dest="tx_max_duration_s", + type=float, + default=None, + help="Auto-stop any TX session after this many seconds", + ) + p_reg.add_argument( + "--tx-freq-range", + dest="tx_freq_range", + type=float, + nargs=2, + action="append", + metavar=("LO", "HI"), + default=None, + help="Allowed TX center-frequency range in Hz (repeat for multiple bands)", + ) p_stream = sub.add_parser("stream", help="Run the WebSocket IQ streamer") p_stream.add_argument("--url", default=None, help="Override WebSocket URL") p_stream.add_argument("--token", default=None, help="Override bearer token") p_stream.add_argument("--log-level", default="INFO") + p_stream.add_argument( + "--allow-tx", + dest="allow_tx", + action="store_true", + help="Runtime override: enable TX for this process without writing config", + ) # Unknown extras are forwarded to the legacy CLI when command == "run". args, extras = parser.parse_known_args(argv) diff --git a/src/ria_toolkit_oss/agent/config.py b/src/ria_toolkit_oss/agent/config.py index d1f0e00..37d20c8 100644 --- a/src/ria_toolkit_oss/agent/config.py +++ b/src/ria_toolkit_oss/agent/config.py @@ -7,7 +7,11 @@ Schema:: "agent_id": "agent-abc123", "token": "rha_xxxx", "name": "lab-bench-1", - "insecure": false + "insecure": false, + "tx_enabled": false, + "tx_max_gain_db": null, + "tx_max_duration_s": null, + "tx_allowed_freq_ranges": null } """ @@ -18,7 +22,9 @@ import os from dataclasses import asdict, dataclass, field from pathlib import Path -_DEFAULT_PATH = Path(os.environ.get("RIA_AGENT_CONFIG", str(Path.home() / ".ria" / "agent.json"))) + +def _resolve_default_path() -> Path: + return Path(os.environ.get("RIA_AGENT_CONFIG", str(Path.home() / ".ria" / "agent.json"))) @dataclass @@ -29,15 +35,29 @@ class AgentConfig: name: str = "" insecure: bool = False api_key: str = "" + tx_enabled: bool = False + tx_max_gain_db: float | None = None + tx_max_duration_s: float | None = None + tx_allowed_freq_ranges: list[list[float]] | None = None extra: dict = field(default_factory=dict) def default_path() -> Path: - return _DEFAULT_PATH + return _resolve_default_path() + + +def _coerce_ranges(raw) -> list[list[float]] | None: + if raw is None: + return None + out: list[list[float]] = [] + for pair in raw: + lo, hi = pair + out.append([float(lo), float(hi)]) + return out def load(path: Path | None = None) -> AgentConfig: - p = path or _DEFAULT_PATH + p = path or _resolve_default_path() if not p.exists(): return AgentConfig() data = json.loads(p.read_text()) @@ -50,12 +70,16 @@ def load(path: Path | None = None) -> AgentConfig: name=data.get("name", ""), insecure=bool(data.get("insecure", False)), api_key=data.get("api_key", ""), + tx_enabled=bool(data.get("tx_enabled", False)), + tx_max_gain_db=(float(v) if (v := data.get("tx_max_gain_db")) is not None else None), + tx_max_duration_s=(float(v) if (v := data.get("tx_max_duration_s")) is not None else None), + tx_allowed_freq_ranges=_coerce_ranges(data.get("tx_allowed_freq_ranges")), extra=extra, ) def save(cfg: AgentConfig, path: Path | None = None) -> Path: - p = path or _DEFAULT_PATH + p = path or _resolve_default_path() p.parent.mkdir(parents=True, exist_ok=True) data = asdict(cfg) extra = data.pop("extra", {}) or {} diff --git a/src/ria_toolkit_oss/agent/hardware.py b/src/ria_toolkit_oss/agent/hardware.py index 417bf1c..98b4683 100644 --- a/src/ria_toolkit_oss/agent/hardware.py +++ b/src/ria_toolkit_oss/agent/hardware.py @@ -4,19 +4,51 @@ from __future__ import annotations from ria_toolkit_oss.sdr import detect_available +from .config import AgentConfig + def available_devices() -> list[str]: """Return a sorted list of device names whose driver modules import cleanly.""" return sorted(detect_available().keys()) -def heartbeat_payload(status: str = "idle", app_id: str | None = None) -> dict: - """Build the JSON body of a periodic heartbeat frame.""" +def heartbeat_payload( + status: str = "idle", + app_id: str | None = None, + *, + cfg: AgentConfig | None = None, + sessions: dict | None = None, +) -> dict: + """Build the JSON body of a periodic heartbeat frame. + + *cfg* drives the ``capabilities`` list and the ``tx_enabled`` flag. If not + supplied, the heartbeat advertises RX-only with ``tx_enabled=False`` — + matching the pre-TX shape. + """ + c = cfg or AgentConfig() + capabilities = ["rx"] + if c.tx_enabled: + capabilities.append("tx") + payload: dict = { "type": "heartbeat", "hardware": available_devices(), "status": status, + "capabilities": capabilities, + "tx_enabled": bool(c.tx_enabled), } + # Surface configured interlock values so the hub can pre-filter UI controls + # before sending a tx_start that would be rejected. Only included when TX + # is opted in AND the operator set a cap. + if c.tx_enabled: + if c.tx_max_gain_db is not None: + payload["tx_max_gain_db"] = float(c.tx_max_gain_db) + if c.tx_max_duration_s is not None: + payload["tx_max_duration_s"] = float(c.tx_max_duration_s) + if c.tx_allowed_freq_ranges: + payload["tx_allowed_freq_ranges"] = [[float(lo), float(hi)] for lo, hi in c.tx_allowed_freq_ranges] if app_id: payload["app_id"] = app_id + if sessions: + payload["sessions"] = sessions return payload diff --git a/src/ria_toolkit_oss/agent/legacy_executor.py b/src/ria_toolkit_oss/agent/legacy_executor.py index 274ff55..6e15eb1 100644 --- a/src/ria_toolkit_oss/agent/legacy_executor.py +++ b/src/ria_toolkit_oss/agent/legacy_executor.py @@ -20,7 +20,7 @@ Usage:: The agent: 1. Registers with RIA Hub and receives a ``node_id``. 2. Sends a heartbeat every 30 s so the hub knows it is online. - 3. Long-polls ``GET /orchestrator/nodes/{id}/commands`` (30 s timeout). + 3. Long-polls ``GET /composer/nodes/{id}/commands`` (30 s timeout). 4. Dispatches received commands: - ``run_campaign``: executes via CampaignExecutor, uploads recordings. - ``load_model``: loads an ONNX fingerprint or detector model. @@ -173,7 +173,7 @@ class NodeAgent: if self._ort_available: capabilities.append("inference") resp = self._post( - "/orchestrator/nodes/register", + "/composer/nodes/register", json={ "name": self.name, "sdr_device": self.sdr_device, @@ -190,7 +190,7 @@ class NodeAgent: if not self.node_id: return try: - self._delete(f"/orchestrator/nodes/{self.node_id}", timeout=10) + self._delete(f"/composer/nodes/{self.node_id}", timeout=10) logger.info("Deregistered %s", self.node_id) except Exception as exc: logger.debug("Deregister failed (ignored on shutdown): %s", exc) @@ -202,7 +202,7 @@ class NodeAgent: def _heartbeat_loop(self) -> None: while not self._stop.wait(_HEARTBEAT_INTERVAL): try: - resp = self._post(f"/orchestrator/nodes/{self.node_id}/heartbeat", timeout=10) + resp = self._post(f"/composer/nodes/{self.node_id}/heartbeat", timeout=10) if resp.status_code == 404: logger.warning("Heartbeat got 404 — hub lost registration, re-registering") self._register() @@ -217,7 +217,7 @@ class NodeAgent: while not self._stop.is_set(): try: resp = self._get( - f"/orchestrator/nodes/{self.node_id}/commands", + f"/composer/nodes/{self.node_id}/commands", timeout=_POLL_CLIENT_TIMEOUT, ) if resp.status_code == 204: @@ -540,7 +540,7 @@ class NodeAgent: logger.info("Inference loop exited") def _post_event(self, device_id: str | None, confidence: float, snr_db: float) -> None: - """POST a single detection event to ``POST /orchestrator/nodes/{id}/events``. + """POST a single detection event to ``POST /composer/nodes/{id}/events``. Failures are logged at DEBUG level and silently swallowed so that a transient network blip does not crash the inference loop. @@ -556,7 +556,7 @@ class NodeAgent: } try: resp = self._post( - f"/orchestrator/nodes/{self.node_id}/events", + f"/composer/nodes/{self.node_id}/events", json=payload, timeout=5, ) @@ -619,7 +619,7 @@ class NodeAgent: payload["error"] = error try: resp = self._post( - f"/orchestrator/nodes/{self.node_id}/campaign-status", + f"/composer/nodes/{self.node_id}/campaign-status", json=payload, timeout=15, ) diff --git a/src/ria_toolkit_oss/agent/namegen.py b/src/ria_toolkit_oss/agent/namegen.py new file mode 100644 index 0000000..9d3299a --- /dev/null +++ b/src/ria_toolkit_oss/agent/namegen.py @@ -0,0 +1,147 @@ +"""Generate random human-readable agent names. + +Produces names in the form ``adjective-colour-animal``, e.g. +``swift-teal-falcon`` or ``brave-coral-otter``. All words are chosen +to be friendly and inoffensive. +""" + +from __future__ import annotations + +import random + +ADJECTIVES: list[str] = [ + "brave", + "bright", + "calm", + "clever", + "cool", + "daring", + "eager", + "fair", + "fancy", + "fast", + "fierce", + "gentle", + "grand", + "happy", + "jolly", + "keen", + "kind", + "lively", + "lucky", + "mighty", + "noble", + "plucky", + "proud", + "quick", + "quiet", + "sharp", + "shiny", + "sleek", + "smart", + "steady", + "stellar", + "strong", + "sturdy", + "sunny", + "sure", + "swift", + "tall", + "vivid", + "warm", + "wise", +] + +COLOURS: list[str] = [ + "amber", + "aqua", + "azure", + "beige", + "blue", + "bronze", + "coral", + "copper", + "crimson", + "cyan", + "denim", + "gold", + "green", + "grey", + "indigo", + "ivory", + "jade", + "lemon", + "lilac", + "lime", + "maroon", + "mint", + "navy", + "olive", + "onyx", + "peach", + "pearl", + "plum", + "red", + "rose", + "ruby", + "rust", + "sage", + "sand", + "scarlet", + "silver", + "slate", + "steel", + "teal", + "violet", +] + +ANIMALS: list[str] = [ + "badger", + "bear", + "bison", + "crane", + "deer", + "dolphin", + "eagle", + "elk", + "falcon", + "finch", + "fox", + "gecko", + "hawk", + "heron", + "horse", + "ibis", + "jaguar", + "jay", + "kite", + "koala", + "lark", + "lion", + "lynx", + "marten", + "moose", + "newt", + "orca", + "osprey", + "otter", + "owl", + "panda", + "puma", + "raven", + "robin", + "salmon", + "seal", + "shark", + "stork", + "swift", + "wolf", +] + + +def generate_agent_name() -> str: + """Return a random ``adjective-colour-animal`` name.""" + adj = random.choice(ADJECTIVES) + col = random.choice(COLOURS) + ani = random.choice(ANIMALS) + return f"{adj}-{col}-{ani}" diff --git a/src/ria_toolkit_oss/agent/streamer.py b/src/ria_toolkit_oss/agent/streamer.py index 4d89743..6f727ff 100644 --- a/src/ria_toolkit_oss/agent/streamer.py +++ b/src/ria_toolkit_oss/agent/streamer.py @@ -1,20 +1,33 @@ -"""Thin IQ-streaming agent. +"""IQ-streaming agent. Listens for control messages from the RIA Hub over a persistent WebSocket. -When the server sends ``start``, opens the SDR described in ``radio_config``, -loops over ``sdr.rx(buffer_size)``, and sends each buffer as raw -interleaved float32 bytes. ``stop`` closes the SDR; ``configure`` applies -parameter updates at the next capture boundary. +Supports: + +- An **RX session** (hub sends ``start``/``stop``/``configure``; agent opens + the SDR, loops ``sdr.rx()`` and ships raw interleaved float32 IQ). +- A **TX session** (hub sends ``tx_start``/``tx_stop``/``tx_configure`` plus + binary IQ frames; agent feeds them into ``sdr._stream_tx``). Phase 3 wires + up the session plumbing and rejects TX when ``cfg.tx_enabled`` is False; + Phase 4 implements the full TX loop. + +Both sessions can run concurrently on the same physical SDR (FDD) — a +ref-counted SDR registry shares one driver instance when RX and TX name the +same ``(device, identifier)``. """ from __future__ import annotations import asyncio import logging +import queue +import threading +import time +from dataclasses import dataclass, field from typing import Any import numpy as np +from .config import AgentConfig from .hardware import heartbeat_payload from .ws_client import WsClient @@ -23,6 +36,98 @@ logger = logging.getLogger("ria_agent.streamer") _DEFAULT_BUFFER_SIZE = 1024 +# --------------------------------------------------------------------------- +# Session dataclasses + + +@dataclass +class RxSession: + app_id: str + sdr: Any + device_key: tuple[str, str | None] + buffer_size: int + task: asyncio.Task | None = None + pending_config: dict = field(default_factory=dict) + + +@dataclass +class TxSession: + app_id: str + sdr: Any + device_key: tuple[str, str | None] + buffer_size: int + task: Any = None # concurrent.futures.Future from run_in_executor + pending_config: dict = field(default_factory=dict) + underrun_policy: str = "pause" + last_buffer: np.ndarray | None = None + stop_event: threading.Event = field(default_factory=threading.Event) + started_at: float = 0.0 + max_duration_s: float | None = None + state: str = "armed" + # Thread-safe queue of inbound interleaved-float32 IQ frames. Bounded so + # hub-side over-production triggers WS backpressure rather than memory + # growth in the agent. + in_queue: "queue.Queue[bytes]" = field(default_factory=lambda: queue.Queue(maxsize=8)) + # Set by the TX callback when it hits an underrun while policy=="pause"; + # asyncio side flips the session state and emits tx_status. + underrun_flag: threading.Event = field(default_factory=threading.Event) + + +# --------------------------------------------------------------------------- +# SDR registry (ref-counted so one Pluto handle serves RX + TX simultaneously) + + +class _SdrRegistry: + def __init__(self, factory): + self._factory = factory + self._instances: dict[tuple[str, str | None], tuple[Any, int]] = {} + self._lock = threading.Lock() + + def acquire(self, device: str, identifier: str | None) -> tuple[Any, tuple[str, str | None]]: + key = (device, identifier) + with self._lock: + if key in self._instances: + sdr, rc = self._instances[key] + self._instances[key] = (sdr, rc + 1) + return sdr, key + # Build outside the lock: driver init can be slow and we don't want to + # block concurrent releases on unrelated devices. + sdr = self._factory(device, identifier) + with self._lock: + if key in self._instances: + # Raced another acquirer; discard our duplicate and share theirs. + other_sdr, rc = self._instances[key] + try: + sdr.close() + except Exception: + pass + self._instances[key] = (other_sdr, rc + 1) + return other_sdr, key + self._instances[key] = (sdr, 1) + return sdr, key + + def release(self, key: tuple[str, str | None]) -> bool: + """Decrement refcount. Returns True if the caller owns the last reference + and should close the SDR.""" + with self._lock: + sdr, rc = self._instances.get(key, (None, 0)) + if sdr is None: + return False + if rc <= 1: + del self._instances[key] + return True + self._instances[key] = (sdr, rc - 1) + return False + + def refcount(self, key: tuple[str, str | None]) -> int: + with self._lock: + return self._instances.get(key, (None, 0))[1] + + +# --------------------------------------------------------------------------- +# Streamer + + class Streamer: """Main streamer loop. @@ -31,103 +136,188 @@ class Streamer: ws: Connected :class:`WsClient`. sdr_factory: - Callable ``(device, identifier) -> SDR``. Defaults to - :func:`ria_toolkit_oss.sdr.get_sdr_device`. Injectable for tests. + Callable ``(device, identifier) -> SDR``. Defaults to the helper in + :mod:`ria_toolkit_oss.sdr`. Injectable for tests. + cfg: + :class:`AgentConfig` for interlocks (``tx_enabled`` and caps) and + heartbeat capabilities. Defaults to an empty ``AgentConfig()`` which + leaves TX disabled. """ - def __init__(self, ws: WsClient, sdr_factory=None) -> None: + def __init__( + self, + ws, + sdr_factory=None, + cfg: AgentConfig | None = None, + ) -> None: self.ws = ws - self._sdr_factory = sdr_factory - self._app_id: str | None = None - self._sdr: Any = None - self._pending_config: dict = {} - self._capture_task: asyncio.Task | None = None - self._status = "idle" + self._cfg = cfg or AgentConfig() + self._registry = _SdrRegistry(sdr_factory or _default_sdr_factory) + self._rx: RxSession | None = None + self._tx: TxSession | None = None + # Pending radio_config accepted via ``configure`` before ``start``. + self._standalone_pending_config: dict = {} + # Cached asyncio event loop, set the first time a handler runs. Used + # to schedule async callbacks from the TX executor thread. + self._loop: asyncio.AbstractEventLoop | None = None + + # ------------------------------------------------------------------ + # Back-compat read-only shims for callers that check ``._sdr`` etc. + # Writes to these attributes are not supported — use the session objects. + + @property + def _sdr(self): + return self._rx.sdr if self._rx is not None else None + + @property + def _pending_config(self) -> dict: + return self._rx.pending_config if self._rx is not None else self._standalone_pending_config # ------------------------------------------------------------------ # WsClient wiring def build_heartbeat(self) -> dict: - return heartbeat_payload(status=self._status, app_id=self._app_id) + status = "streaming" if (self._rx is not None or self._tx is not None) else "idle" + app_id: str | None = None + if self._rx is not None: + app_id = self._rx.app_id + elif self._tx is not None: + app_id = self._tx.app_id + + sessions: dict[str, dict] = {} + if self._rx is not None: + sessions["rx"] = {"app_id": self._rx.app_id, "state": "streaming"} + if self._tx is not None: + sessions["tx"] = {"app_id": self._tx.app_id, "state": self._tx.state} + + return heartbeat_payload( + status=status, + app_id=app_id, + cfg=self._cfg, + sessions=sessions or None, + ) + + # Advisory / keepalive message types we accept and ignore without warning. + _IGNORED_MESSAGE_TYPES = frozenset({"tx_data_available"}) async def on_message(self, msg: dict) -> None: t = msg.get("type") - if t == "start": - await self._handle_start(msg) - elif t == "stop": - await self._handle_stop(msg) - elif t == "configure": - self._pending_config.update(msg.get("radio_config") or {}) - logger.debug("Queued configure: %s", self._pending_config) - else: + if t in self._IGNORED_MESSAGE_TYPES: + logger.debug("Ignoring advisory message: %r", t) + return + handler = { + "start": self._handle_rx_start, + "stop": self._handle_rx_stop, + "configure": self._handle_rx_configure, + "tx_start": self._handle_tx_start, + "tx_stop": self._handle_tx_stop, + "tx_configure": self._handle_tx_configure, + }.get(t) + if handler is None: logger.warning("Unknown server message type: %r", t) + return + await handler(msg) - # ------------------------------------------------------------------ - async def _handle_start(self, msg: dict) -> None: - if self._capture_task is not None and not self._capture_task.done(): + async def on_binary(self, data: bytes) -> None: + tx = self._tx + if tx is None: + logger.debug("Dropping %d-byte binary frame: no TX session", len(data)) + return + # Backpressure: if the TX queue is full, await briefly so the hub's + # ``await ws.send`` throttles naturally via TCP. We don't block + # indefinitely — a 2s stall means something else is wrong. + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor(None, lambda: tx.in_queue.put(data, timeout=2.0)) + except queue.Full: + logger.warning("TX queue stalled; dropping frame") + + # ================================================================== + # RX + + async def _handle_rx_start(self, msg: dict) -> None: + if self._rx is not None: logger.warning("start received while already streaming — ignoring") return - self._app_id = msg.get("app_id") + app_id = msg.get("app_id") or "" radio_config = dict(msg.get("radio_config") or {}) device = radio_config.pop("device", None) identifier = radio_config.pop("identifier", None) buffer_size = int(radio_config.pop("buffer_size", _DEFAULT_BUFFER_SIZE)) if not device: - await self._send_error("start missing radio_config.device") + await self._send_error(app_id, "start missing radio_config.device") return try: - factory = self._sdr_factory or _default_sdr_factory - self._sdr = factory(device, identifier) - _apply_sdr_config(self._sdr, radio_config) + sdr, device_key = self._registry.acquire(device, identifier) + _apply_sdr_config(sdr, radio_config) except Exception as exc: logger.exception("Failed to open SDR %r", device) - await self._send_error(f"SDR init failed: {exc}") + await self._send_error(app_id, f"SDR init failed: {exc}") return - self._status = "streaming" - await self._send_status("streaming") - self._capture_task = asyncio.create_task( - self._capture_loop(buffer_size), name="ria-streamer-capture" - ) + # Inherit any pending config that was queued before start. + pending = dict(self._standalone_pending_config) + self._standalone_pending_config = {} - async def _handle_stop(self, msg: dict) -> None: - if self._capture_task is not None: - self._capture_task.cancel() + session = RxSession( + app_id=app_id, + sdr=sdr, + device_key=device_key, + buffer_size=buffer_size, + pending_config=pending, + ) + self._rx = session + await self._send_status("streaming", app_id) + session.task = asyncio.create_task(self._capture_loop(session), name="ria-streamer-capture") + + async def _handle_rx_stop(self, msg: dict) -> None: + session = self._rx + if session is None: + return + if session.task is not None: + session.task.cancel() try: - await self._capture_task + await session.task except (asyncio.CancelledError, Exception): pass - self._capture_task = None - self._close_sdr() - self._app_id = None - self._status = "idle" - await self._send_status("idle") + self._close_session_sdr(session) + app_id = session.app_id + self._rx = None + await self._send_status("idle", app_id) - async def _capture_loop(self, buffer_size: int) -> None: + async def _handle_rx_configure(self, msg: dict) -> None: + cfg = dict(msg.get("radio_config") or {}) + if self._rx is not None: + self._rx.pending_config.update(cfg) + else: + self._standalone_pending_config.update(cfg) + logger.debug("Queued configure: %s", cfg) + + async def _capture_loop(self, session: RxSession) -> None: loop = asyncio.get_running_loop() try: while True: - if self._pending_config: - cfg = self._pending_config - self._pending_config = {} + if session.pending_config: + cfg = session.pending_config + session.pending_config = {} try: - _apply_sdr_config(self._sdr, cfg) + _apply_sdr_config(session.sdr, cfg) except Exception as exc: logger.warning("Applying configure failed: %s", exc) try: - samples = await loop.run_in_executor(None, self._sdr.rx, buffer_size) + samples = await loop.run_in_executor(None, session.sdr.rx, session.buffer_size) except Exception as exc: from ria_toolkit_oss.sdr import SdrDisconnectedError if isinstance(exc, SdrDisconnectedError): logger.warning("SDR disconnected: %s", exc) - await self._send_error(f"SDR disconnected: {exc}") + await self._send_error(session.app_id, f"SDR disconnected: {exc}") else: logger.exception("SDR rx error") - await self._send_error(f"SDR capture failed: {exc}") + await self._send_error(session.app_id, f"SDR capture failed: {exc}") break payload = _samples_to_interleaved_float32(samples) @@ -139,29 +329,320 @@ class Streamer: except asyncio.CancelledError: raise finally: - self._close_sdr() + self._close_session_sdr(session) + # If the loop died on its own (e.g. SDR disconnect), clear the + # session handle so future ``start`` messages can proceed. + if self._rx is session: + self._rx = None - def _close_sdr(self) -> None: - if self._sdr is None: + # ================================================================== + # TX + + async def _handle_tx_start(self, msg: dict) -> None: # noqa: C901 + app_id = msg.get("app_id") or "" + radio_config = dict(msg.get("radio_config") or {}) + + # --- interlocks (agent-enforced; never trust the hub alone) --- + if not self._cfg.tx_enabled: + await self._send_tx_status(app_id, "error", "tx disabled on this agent") return + tx_gain = radio_config.get("tx_gain") + if ( + self._cfg.tx_max_gain_db is not None + and tx_gain is not None + and float(tx_gain) > float(self._cfg.tx_max_gain_db) + ): + await self._send_tx_status( + app_id, + "error", + f"tx_gain {tx_gain} exceeds cap {self._cfg.tx_max_gain_db}", + ) + return + tx_freq = radio_config.get("tx_center_frequency") + if self._cfg.tx_allowed_freq_ranges and tx_freq is not None: + f = float(tx_freq) + if not any(float(lo) <= f <= float(hi) for lo, hi in self._cfg.tx_allowed_freq_ranges): + await self._send_tx_status( + app_id, + "error", + f"tx_center_frequency {tx_freq} outside allowed ranges", + ) + return + + if self._tx is not None: + await self._send_tx_status(app_id, "error", "tx already active on this agent") + return + + # --- device --- + device = radio_config.pop("device", None) + identifier = radio_config.pop("identifier", None) + buffer_size = int(radio_config.pop("buffer_size", _DEFAULT_BUFFER_SIZE)) + underrun_policy = str(radio_config.pop("underrun_policy", "pause")) + if underrun_policy not in ("pause", "zero", "repeat"): + await self._send_tx_status(app_id, "error", f"invalid underrun_policy {underrun_policy!r}") + return + if not device: + await self._send_tx_status(app_id, "error", "tx_start missing radio_config.device") + return + + device_key: tuple[str, str | None] | None = None + sdr: Any = None try: - self._sdr.close() + sdr, device_key = self._registry.acquire(device, identifier) + _apply_sdr_config(sdr, radio_config) + # init_tx is mandatory for any driver that exposes it: drivers + # that gate _stream_tx on _tx_initialized (Pluto, HackRF, USRP, + # …) crash with a confusing "TX was not initialized" error 2 s + # later in the executor thread if we skip it. Treat the three + # required keys as a hard contract — a missing one is a hub-side + # manifest bug and we want it surfaced immediately, not papered + # over with stale radio state. + if hasattr(sdr, "init_tx"): + init_args = {k: radio_config.get(f"tx_{k}") for k in ("sample_rate", "center_frequency", "gain")} + missing = [f"tx_{k}" for k, v in init_args.items() if v is None] + if missing: + raise ValueError(f"tx_start missing required radio_config keys: {missing}") + sdr.init_tx( + sample_rate=init_args["sample_rate"], + center_frequency=init_args["center_frequency"], + gain=init_args["gain"], + channel=radio_config.get("tx_channel", 0), + gain_mode=radio_config.get("tx_gain_mode", "manual"), + ) + except Exception as exc: + if device_key is not None: + if self._registry.release(device_key): + try: + sdr.close() + except Exception: + pass + logger.exception("Failed to init TX on %r", device) + await self._send_tx_status(app_id, "error", f"tx init failed: {exc}") + return + + self._loop = asyncio.get_running_loop() + session = TxSession( + app_id=app_id, + sdr=sdr, + device_key=device_key, + buffer_size=buffer_size, + underrun_policy=underrun_policy, + started_at=time.monotonic(), + max_duration_s=self._cfg.tx_max_duration_s, + ) + self._tx = session + await self._send_tx_status(app_id, "armed") + session.task = self._loop.run_in_executor(None, self._tx_executor_body, session) + # Spawn a small watchdog that transitions armed → transmitting when + # the first buffer has been consumed, and surfaces underrun / max- + # duration terminations back to the hub. + asyncio.create_task(self._tx_watchdog(session)) + + async def _handle_tx_stop(self, msg: dict) -> None: + session = self._tx + if session is None: + return + app_id = session.app_id + session.stop_event.set() + try: + session.sdr.pause_tx() + except Exception: + logger.debug("pause_tx raised during stop", exc_info=True) + # Wake the executor thread if it's blocked on ``queue.get``. + self._drain_tx_queue(session) + if session.task is not None: + try: + await asyncio.wait_for(asyncio.wrap_future(session.task), timeout=1.5) + except asyncio.TimeoutError: + logger.warning("TX executor did not exit within 1.5s after stop") + except Exception: + logger.debug("TX executor raised on shutdown", exc_info=True) + self._close_session_sdr(session) + self._tx = None + await self._send_tx_status(app_id, "done") + + async def _handle_tx_configure(self, msg: dict) -> None: + if self._tx is None: + return + self._tx.pending_config.update(msg.get("radio_config") or {}) + + # ------------------------------------------------------------------ + # TX executor & watchdog + + def _tx_executor_body(self, session: TxSession) -> None: + try: + session.sdr._stream_tx(lambda n: self._tx_callback(session, n)) + except Exception as exc: + logger.exception("TX stream crashed") + # Schedule both the error frame and session teardown on the loop + # so ``self._tx`` clears, subsequent binary frames are rejected, + # and the SDR handle is released. + self._schedule(self._tx_crash_teardown(session, str(exc))) + + def _tx_callback(self, session: TxSession, num_samples) -> np.ndarray: + n = int(num_samples) + # Honor stop requests: return silence one last time and let the driver + # exit its loop on the next iteration (pause_tx flips _enable_tx). + if session.stop_event.is_set(): + return _silence(n) + + # Max-duration watchdog. + if session.max_duration_s is not None and (time.monotonic() - session.started_at) >= float( + session.max_duration_s + ): + session.stop_event.set() + try: + session.sdr.pause_tx() + except Exception: + pass + self._schedule(self._send_tx_status(session.app_id, "done", "max duration reached")) + return _silence(n) + + # Apply queued configure at buffer boundary. + if session.pending_config: + cfg = session.pending_config + session.pending_config = {} + try: + _apply_sdr_config(session.sdr, cfg) + except Exception as exc: + logger.debug("tx_configure apply failed: %s", exc) + + try: + raw = session.in_queue.get(timeout=0.1) + except queue.Empty: + return self._underrun_fill(session, n) + + arr = np.frombuffer(raw, dtype=np.float32) + if arr.size < 2 or arr.size % 2 != 0: + logger.warning("Malformed TX frame: %d floats (must be non-zero even count)", arr.size) + return self._underrun_fill(session, n) + samples = arr[0::2].astype(np.complex64) + 1j * arr[1::2].astype(np.complex64) + if samples.size < n: + out = np.zeros(n, dtype=np.complex64) + out[: samples.size] = samples + session.last_buffer = out + return out + if samples.size > n: + samples = samples[:n] + session.last_buffer = samples + if session.state == "armed": + session.state = "transmitting" + self._schedule(self._send_tx_status(session.app_id, "transmitting")) + return samples + + def _underrun_fill(self, session: TxSession, n: int) -> np.ndarray: + policy = session.underrun_policy + if policy == "zero": + return _silence(n) + if policy == "repeat" and session.last_buffer is not None: + buf = session.last_buffer + if buf.size == n: + return buf + if buf.size > n: + return buf[:n].copy() + out = np.zeros(n, dtype=np.complex64) + out[: buf.size] = buf + return out + # "pause" policy (default) or "repeat" before any buffer arrived. + if not session.underrun_flag.is_set(): + session.underrun_flag.set() + session.stop_event.set() + try: + session.sdr.pause_tx() except Exception: pass - self._sdr = None + return _silence(n) - async def _send_status(self, status: str) -> None: + async def _tx_watchdog(self, session: TxSession) -> None: + # Poll the underrun flag so we can emit status + tear down cleanly + # when the callback flips the flag from the executor thread. Check + # underrun_flag before stop_event, since the "pause" path sets both. + while session is self._tx: + if session.underrun_flag.is_set(): + await self._send_tx_status(session.app_id, "underrun") + await self._teardown_tx_after_underrun(session) + return + if session.stop_event.is_set(): + return + await asyncio.sleep(0.05) + + async def _tx_crash_teardown(self, session: TxSession, message: str) -> None: + # Called from the executor thread via _schedule when _stream_tx raises. + # Emit the error, mark stopped, drain the queue, release the SDR. + await self._send_tx_status(session.app_id, "error", f"tx stream crashed: {message}") + if self._tx is not session: + return + session.stop_event.set() + self._drain_tx_queue(session) + self._close_session_sdr(session) + if self._tx is session: + self._tx = None + + async def _teardown_tx_after_underrun(self, session: TxSession) -> None: + if self._tx is not session: + return + self._drain_tx_queue(session) + if session.task is not None: + try: + await asyncio.wait_for(asyncio.wrap_future(session.task), timeout=1.0) + except asyncio.TimeoutError: + logger.warning("TX executor did not exit within 1s after underrun") + except Exception: + logger.debug("TX executor raised during underrun teardown", exc_info=True) + self._close_session_sdr(session) + if self._tx is session: + self._tx = None + + def _drain_tx_queue(self, session: TxSession) -> None: try: - await self.ws.send_json({"type": "status", "status": status, "app_id": self._app_id}) + while True: + session.in_queue.get_nowait() + except queue.Empty: + pass + + def _schedule(self, coro) -> None: + loop = self._loop + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(coro, loop) + except Exception: + logger.debug("_schedule failed", exc_info=True) + + # ================================================================== + # Helpers + + def _close_session_sdr(self, session) -> None: + if session.sdr is None: + return + should_close = self._registry.release(session.device_key) + if should_close: + try: + session.sdr.close() + except Exception: + logger.debug("SDR close raised", exc_info=True) + + async def _send_status(self, status: str, app_id: str) -> None: + try: + await self.ws.send_json({"type": "status", "status": status, "app_id": app_id}) except Exception as exc: logger.debug("Status send failed: %s", exc) - async def _send_error(self, message: str) -> None: + async def _send_error(self, app_id: str, message: str) -> None: try: - await self.ws.send_json({"type": "error", "app_id": self._app_id, "message": message}) + await self.ws.send_json({"type": "error", "app_id": app_id, "message": message}) except Exception as exc: logger.debug("Error-frame send failed: %s", exc) + async def _send_tx_status(self, app_id: str, state: str, message: str | None = None) -> None: + payload: dict = {"type": "tx_status", "app_id": app_id, "state": state} + if message is not None: + payload["message"] = message + try: + await self.ws.send_json(payload) + except Exception as exc: + logger.debug("tx_status send failed: %s", exc) + # --------------------------------------------------------------------------- # Helpers @@ -172,16 +653,51 @@ _CONFIG_ATTR_MAP = { "center_freq": ("center_freq", "rx_center_frequency"), "gain": ("gain", "rx_gain"), "bandwidth": ("bandwidth", "rx_bandwidth"), + "tx_sample_rate": ("tx_sample_rate",), + "tx_center_frequency": ("tx_center_frequency", "tx_lo"), + "tx_gain": ("tx_gain",), + "tx_bandwidth": ("tx_bandwidth",), } +def _is_stub_setter(method: Any) -> bool: + """True when *method* is an unimplemented base-class stub. + + The ``SDR`` abstract base defines ``set_rx_sample_rate`` / ``set_tx_gain`` + etc. as zero-argument ``NotImplementedError`` stubs. A driver (Pluto) that + actually transmits overrides them with a real ``(value, ...)`` signature. + Comparing ``__qualname__`` against ``SDR.`` lets us skip the stubs cheaply. + """ + return getattr(method, "__qualname__", "").startswith("SDR.") + + def _apply_sdr_config(sdr: Any, cfg: dict) -> None: - """Apply a radio_config dict to an SDR, trying multiple attribute aliases.""" + """Apply a radio_config dict to an SDR. + + Prefers ``sdr.set_(value)`` when the driver implements it — Pluto's + setters take ``_param_lock``, so routing through them keeps concurrent + RX + TX reconfigures from racing on shared native attributes. Falls back + to ``setattr`` for drivers (MockSDR, tests) that don't override the + base-class stubs. + """ for key, value in cfg.items(): if value is None: continue attrs = _CONFIG_ATTR_MAP.get(key, (key,)) applied = False + for attr in attrs: + setter = getattr(sdr, f"set_{attr}", None) + if callable(setter) and not _is_stub_setter(setter): + try: + setter(value) + applied = True + break + except Exception as exc: + logger.debug("set_%s(%r) failed: %s", attr, value, exc) + # Fall through to setattr; some drivers may partially + # implement setters. + if applied: + continue for attr in attrs: if hasattr(sdr, attr): try: @@ -194,6 +710,11 @@ def _apply_sdr_config(sdr: Any, cfg: dict) -> None: logger.debug("radio_config key %r ignored (no matching attr)", key) +def _silence(num_samples: int) -> np.ndarray: + """Return a ``num_samples``-length zero-filled complex64 buffer.""" + return np.zeros(int(num_samples), dtype=np.complex64) + + def _samples_to_interleaved_float32(samples: Any) -> bytes: """Convert complex IQ samples (any numeric dtype) to interleaved float32 bytes.""" arr = np.asarray(samples) @@ -214,8 +735,13 @@ def _default_sdr_factory(device: str, identifier: str | None): # --------------------------------------------------------------------------- # Top-level entry -async def run_streamer(ws_url: str, token: str) -> None: + +async def run_streamer(ws_url: str, token: str, *, cfg: AgentConfig | None = None) -> None: """Connect to *ws_url* and run the streamer loop until cancelled.""" ws = WsClient(ws_url, token) - streamer = Streamer(ws) - await ws.run(streamer.on_message, streamer.build_heartbeat) + streamer = Streamer(ws, cfg=cfg) + await ws.run( + streamer.on_message, + streamer.build_heartbeat, + on_binary=streamer.on_binary, + ) diff --git a/src/ria_toolkit_oss/agent/ws_client.py b/src/ria_toolkit_oss/agent/ws_client.py index 1bc66f6..a33991d 100644 --- a/src/ria_toolkit_oss/agent/ws_client.py +++ b/src/ria_toolkit_oss/agent/ws_client.py @@ -15,6 +15,7 @@ logger = logging.getLogger("ria_agent.ws") MessageHandler = Callable[[dict], Awaitable[None]] HeartbeatBuilder = Callable[[], dict] +BinaryHandler = Callable[[bytes], Awaitable[None]] class WsClient: @@ -65,7 +66,12 @@ class WsClient: self._stop.set() # ------------------------------------------------------------------ - async def run(self, on_message: MessageHandler, heartbeat: HeartbeatBuilder) -> None: + async def run( + self, + on_message: MessageHandler, + heartbeat: HeartbeatBuilder, + on_binary: BinaryHandler | None = None, + ) -> None: """Main loop: connect, heartbeat, dispatch messages, reconnect on drop.""" while not self._stop.is_set(): try: @@ -75,8 +81,13 @@ class WsClient: try: async for raw in self._ws: if isinstance(raw, bytes): - # Server shouldn't send binary to the agent; log and drop. - logger.debug("Discarding unexpected %d-byte binary frame", len(raw)) + if on_binary is None: + logger.debug("Discarding unexpected %d-byte binary frame", len(raw)) + continue + try: + await on_binary(raw) + except Exception: + logger.exception("on_binary handler raised; dropping frame") continue try: msg = json.loads(raw) diff --git a/src/ria_toolkit_oss/annotations/__init__.py b/src/ria_toolkit_oss/annotations/__init__.py new file mode 100644 index 0000000..1542dcf --- /dev/null +++ b/src/ria_toolkit_oss/annotations/__init__.py @@ -0,0 +1,54 @@ +""" +The annotations package contains tools and utilities for creating, managing, and processing annotations. + +Provides automatic annotation generation using various signal detection algorithms: +- Energy-based detection (detect_signals_energy) +- CUSUM-based segmentation (annotate_with_cusum) +- Threshold-based qualification (threshold_qualifier) +- Signal isolation and extraction (isolate_signal) +- Occupied bandwidth analysis (calculate_occupied_bandwidth, calculate_nominal_bandwidth) + +All detection functions return Recording objects with added annotations. +""" + +__all__ = [ + # Energy-based detection + "detect_signals_energy", + "calculate_occupied_bandwidth", + "calculate_nominal_bandwidth", + "calculate_full_detected_bandwidth", + "annotate_with_obw", + # CUSUM detection + "annotate_with_cusum", + # Threshold detection + "threshold_qualifier", + # Parallel signal separation (Phase 2) + "find_spectral_components", + "split_annotation_by_components", + "split_recording_annotations", + # Signal isolation + "isolate_signal", + # Annotation transforms + "remove_contained_boxes", + "is_annotation_contained", + # Dataset creation + "qualify_slice_from_annotations", +] + +from .annotation_transforms import is_annotation_contained, remove_contained_boxes +from .cusum_annotator import annotate_with_cusum +from .energy_detector import ( + annotate_with_obw, + calculate_full_detected_bandwidth, + calculate_nominal_bandwidth, + calculate_occupied_bandwidth, + detect_signals_energy, +) +from .parallel_signal_separator import ( + find_spectral_components, + split_annotation_by_components, + split_recording_annotations, +) +from .qualify_slice import qualify_slice_from_annotations +from .signal_isolation import isolate_signal +from .threshold_qualifier import threshold_qualifier diff --git a/src/ria_toolkit_oss/annotations/annotation_transforms.py b/src/ria_toolkit_oss/annotations/annotation_transforms.py new file mode 100644 index 0000000..47300c1 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/annotation_transforms.py @@ -0,0 +1,55 @@ +from ria_toolkit_oss.datatypes.annotation import Annotation + +# TODO figure out how to transfer labels in the merge case + + +def remove_contained_boxes(annotations: list[Annotation]): + """ + Remove all annotations (bounding boxes) that are entirely contained within other boxes in the list. + + :param annotations: A list of Annotation objects. + :type annotations: list[Annotation] + + :returns: A new list of Annotation objects. + :rtype: list[Annotation]""" + + output_boxes = [] + + for i in range(len(annotations)): + contained = False + for j in range(len(annotations)): + if i != j and is_annotation_contained(annotations[i], annotations[j]): + contained = True + break + + if not contained: + output_boxes.append(annotations[i]) + + return output_boxes + + +def is_annotation_contained(inner: Annotation, outer: Annotation) -> bool: + """ + Check if an annotation box is entirely contained within another annotation bounding box. + + :param inner: The inner box. + :type inner: Annotation. + :param outer: The outer box. + :type outer: Annotation. + + :returns: True if inner is within outer, false otherwise. + :rtype: bool + """ + + inner_sample_stop = inner.sample_start + inner.sample_count + outer_sample_stop = outer.sample_start + outer.sample_count + + if inner.sample_start > outer.sample_start and inner_sample_stop < outer_sample_stop: + if inner.freq_lower_edge > outer.freq_lower_edge and inner.freq_upper_edge < outer.freq_upper_edge: + return True + + return False + + +def merge_annotations(annotations: list[Annotation], overlap_threshold) -> list[Annotation]: + raise NotImplementedError diff --git a/src/ria_toolkit_oss/annotations/cusum_annotator.py b/src/ria_toolkit_oss/annotations/cusum_annotator.py new file mode 100644 index 0000000..d37186c --- /dev/null +++ b/src/ria_toolkit_oss/annotations/cusum_annotator.py @@ -0,0 +1,203 @@ +import json +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def annotate_with_cusum( + recording: Recording, + label: Optional[str] = "segment", + window_size: Optional[int] = 1, + min_duration: Optional[float] = None, + tolerance: Optional[int] = None, + annotation_type: Optional[str] = "standalone", +): + """ + Add annotations that divide the recording into distinct time segments. + + This algorithm computes the cumulative sum of the sample magnitudes and + determines break points in the signal. + + This tool can be used to find points where a signal turns on or off, or + changes between a low and high amplitude. + + :param recording: A ``Recording`` object to annotate. + :type recording: ``ria_toolkit_oss.datatypes.Recording`` + :param label: Label for the detected segments. + :type label: str + :param window_size: The length (in samples) of the moving average window. + :type window_size: int + :param min_duration: The minimum duration (in ms) of a segment. + The algorithm will not produce annotations shorter than this length. + :type min_duration: float + :param tolerance: The minimum length (in samples) of a segment. + :type tolerance: int + :param annotation_type: Annotation type (standalone, parallel, intersection). + :type annotation_type: str + """ + + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # Create an object of the time segmenter + time_segmenter = TimeSegmenter(sample_rate, min_duration, window_size, tolerance) + + change_points = time_segmenter.apply(recording.data[0]) + + time_segments_indices = np.append(np.insert(change_points, 0, 0), len(recording.data[0])) + annotations = [] + for i in range(len(time_segments_indices) - 1): + # Build comment JSON with type metadata + comment_data = { + "type": annotation_type, + "generator": "cusum_annotator", + "params": { + "window_size": window_size, + "min_duration": min_duration, + "tolerance": tolerance, + }, + } + f_min, f_max = detect_frequency( + signal=recording.data[0], + start=time_segments_indices[i], + stop=time_segments_indices[i + 1], + sample_rate=sample_rate, + ) + + annotations.append( + Annotation( + sample_start=time_segments_indices[i], + sample_count=time_segments_indices[i + 1] - time_segments_indices[i], + freq_lower_edge=center_frequency + f_min, + freq_upper_edge=center_frequency + f_max, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "cusum_annotator"}, + ) + ) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) + + +def _compute_cusum(_signal, sample_rate: int, tolerance: int = None, min_duration: float = -1): + """ + This function efficiently computes the cumulative sum of a give list (_signal), with an optional tolerance. + + Args: + - _signal: array of iq samples. + - Tolerance: the least acceptable length of a block, Defaults to None. + + Returns: + - cusum (array): Array of the cumulative sum of the given list + - sample_rate (int): __description_ + - change_points (array): Array of the indices at which a change in the CUSUM direction happens. + - min_duration (float): The least acceptable time width of each segment (in ms). Defaults to -1. + """ + + # efficiently calculate the running sum of the signal + # cusum = list(itertools.accumulate((_signal - np.mean(_signal)))) + x = _signal - np.mean(_signal) + cusum = np.cumsum(x) + + # 'diff' computes the differences between the consecutive values, + # then 'sign' determines if it is +ve or -ve. + change_indicators = np.sign(np.diff(cusum)) + change_points = np.where(np.diff(change_indicators))[0] + 1 + + # Limit the change_points + # Reject those whose number of samples < minimum accepted #n of samples in (min duration) ms. + if min_duration is not None and min_duration > 0: + min_samples_wide = int(min_duration * sample_rate / 1000) + segments_lengths = np.diff(change_points) + segments_lengths = np.insert(segments_lengths, 0, change_points[0]) + change_points = change_points[np.where(segments_lengths > min_samples_wide)[0]] + return cusum, change_points + + +def detect_frequency(signal, start, stop, sample_rate): + signal_segment = signal[start:stop] + if len(signal_segment) > 0: + fft_data = np.abs(np.fft.fftshift(np.fft.fft(signal_segment))) + fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) + + # Use a spectral threshold to find the 'height' of the orange block + spectral_thresh = np.max(fft_data) * 0.15 + sig_indices = np.where(fft_data > spectral_thresh)[0] + + if len(sig_indices) > 4: + return fft_freqs[sig_indices[0]], fft_freqs[sig_indices[-1]] + else: + return -sample_rate / 4, sample_rate / 4 + else: + return -sample_rate / 4, sample_rate / 4 + + +class TimeSegmenter: + """Time Segmenter class, it creates a segmenter object with certain\ + characteristics to easily split an input signal to segments based on\ + the cumulative sum of deviations (of the signal mean) + """ + + def __init__( + self, sample_rate: int, min_duration: float = 1, moving_average_window: int = 3, tolerance: int = None + ): + """_summary_ + + Args: + sample_rate (int): _description_ + min_duration (float, optional): _description_. Defaults to 1. + moving_average_window (int, optional): _description_. Defaults to 3. + tolerance (int, optional): _description_. Defaults to None. + """ + self.sample_rate = sample_rate + self.min_duration = min_duration + self.moving_average_window = moving_average_window + self._moving_avg_filter = self._init_filter() + self.tolerance = tolerance + + def _init_filter(self): + """_summary_ + + Returns: + _type_: _description_ + """ + return np.ones(self.moving_average_window) / self.moving_average_window + + def _apply_filter(self, iqsignal: np.array): + """_summary_ + + Args: + iqsignal (np.array): _description_ + + Returns: + _type_: _description_ + """ + return np.convolve(abs(iqsignal), self._moving_avg_filter, mode="same") + + def _create_segments(self, iq_signal: np.array, change_points: np.array): + """_summary_ + + Args: + iq_signal (np.array): _description_ + change_points (np.array): _description_ + + Returns: + _type_: _description_ + """ + return np.split(iq_signal, change_points) + + def apply(self, iq_signal: np.array): + """_summary_ + + Args: + iq_signal (np.array): _description_ + + Returns: + _type_: _description_ + """ + smoothed_signal = self._apply_filter(iq_signal) + _, change_points = _compute_cusum(smoothed_signal, self.sample_rate, self.tolerance, self.min_duration) + # segments = self._create_segments(iq_signal, change_points) + return change_points diff --git a/src/ria_toolkit_oss/annotations/energy_detector.py b/src/ria_toolkit_oss/annotations/energy_detector.py new file mode 100644 index 0000000..109fe6e --- /dev/null +++ b/src/ria_toolkit_oss/annotations/energy_detector.py @@ -0,0 +1,438 @@ +""" +Energy-based signal detection and bandwidth analysis. + +Provides automatic annotation generation using energy-based signal detection +and occupied bandwidth calculation following ITU-R SM.328 standard. +""" + +import json +from typing import Tuple + +import numpy as np +from scipy.signal import filtfilt + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def detect_signals_energy( + recording: Recording, + k: int = 10, + threshold_factor: float = 1.2, + window_size: int = 200, + min_distance: int = 5000, + label: str = "signal", + annotation_type: str = "standalone", + freq_method: str = "nbw", + nfft: int = None, + obw_power: float = 0.99, +) -> Recording: + """ + Detect signal bursts using energy-based method with adaptive noise floor estimation. + + This algorithm smooths the signal with a moving average filter, estimates the noise + floor from k segments, applies a threshold to detect regions above noise, and merges + nearby detections. Detected time boundaries are then assigned frequency bounds based + on the selected frequency method. + + Time Detection Algorithm: + 1. Smooth signal using moving average (envelope detection) + 2. Divide smoothed signal into k segments + 3. Estimate noise floor as median of segment mean powers + 4. Detect regions where power exceeds threshold_factor * noise_floor + 5. Merge regions closer than min_distance samples + + Frequency Bounding (freq_method): + - 'nbw': Nominal bandwidth (OBW + center frequency) - DEFAULT + - 'obw': Occupied bandwidth (99.99% power, includes siedelobes) + - 'full-detected': Lowest to highest spectral component + - 'full-bandwidth': Entire Nyquist span (center_freq ± sample_rate/2) + + :param recording: Recording to analyze + :type recording: Recording + :param k: Number of segments for noise floor estimation (default: 10) + :type k: int + :param threshold_factor: Threshold multiplier above noise floor (typical: 1.2-2.0, default: 1.2) + :type threshold_factor: float + :param window_size: Moving average window size in samples (default: 200) + :type window_size: int + :param min_distance: Minimum distance between separate signals in samples (default: 5000) + :type min_distance: int + :param label: Label for detected annotations (default: "signal") + :type label: str + :param annotation_type: Annotation type (standalone, parallel, intersection, default: standalone) + :type annotation_type: str + :param freq_method: How to calculate frequency bounds (default: 'nbw') + :type freq_method: str + :param nfft: FFT size for frequency calculations (default: None) + :type nfft: int + :param obw_power: Power percentage for OBW (0.9999 = 99.99%, default: 0.99) + :type obw_power: float + + :returns: New Recording with added annotations + :rtype: Recording + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import detect_signals_energy + >>> recording = load_recording("capture.sigmf") + + >>> # Detect with NBW frequency bounds (default, best for real signals) + >>> annotated = detect_signals_energy(recording, label="burst") + + >>> # Detect with OBW (more conservative, includes siedelobes) + >>> annotated = detect_signals_energy( + ... recording, label="burst", freq_method="obw" + ... ) + + >>> # Detect with full detected range (captures all spectral components) + >>> annotated = detect_signals_energy( + ... recording, label="burst", freq_method="full-detected" + ... ) + """ + # Extract signal data (use first channel only) + signal = recording.data[0] + + # Calculate smoothed signal power + kernel = np.ones(window_size) / window_size + smoothed_power = filtfilt(kernel, [1], np.abs(signal) ** 2) + + # Estimate noise floor using segment-based median (robust to signal presence) + segments = np.array_split(smoothed_power, k) + noise_floor = np.median([np.mean(s) for s in segments]) + + # Detect signal boundaries (regions above threshold) + enter = noise_floor * threshold_factor + exit = enter * 0.8 + boundaries = [] + start = None + active = False + + for i, p in enumerate(smoothed_power): + if not active and p > enter: + start = i + active = True + elif active and p < exit: + boundaries.append((start, i - start)) + active = False + + if active: + boundaries.append((start, len(smoothed_power) - start)) + + # Merge boundaries that are closer than min_distance + merged_boundaries = [] + if boundaries: + start, length = boundaries[0] + for next_start, next_length in boundaries[1:]: + if next_start - (start + length) < min_distance: + # Merge with current boundary + length = next_start + next_length - start + else: + # Save current and start new boundary + merged_boundaries.append((start, length)) + start, length = next_start, next_length + # Add final boundary + merged_boundaries.append((start, length)) + + # Create annotations from detected boundaries + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + # Validate frequency method + valid_freq_methods = ["nbw", "obw", "full-detected", "full-bandwidth"] + if freq_method not in valid_freq_methods: + raise ValueError(f"Invalid freq_method '{freq_method}'. " f"Must be one of: {', '.join(valid_freq_methods)}") + + annotations = [] + for start_sample, sample_count in merged_boundaries: + # Calculate frequency bounds based on method + freq_lower, freq_upper = calculate_frequency_bounds( + freq_method, center_frequency, sample_rate, nfft, signal, start_sample, sample_count, obw_power + ) + # Build comment JSON with type metadata + comment_data = { + "type": annotation_type, + "generator": "energy_detector", + "freq_method": freq_method, + "params": { + "threshold_factor": threshold_factor, + "window_size": window_size, + "noise_floor": float(noise_floor), + "threshold": float(enter), + }, + } + + anno = Annotation( + sample_start=start_sample, + sample_count=sample_count, + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "energy_detector", "freq_method": freq_method}, + ) + annotations.append(anno) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) + + +def calculate_occupied_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + power_percentage: float = 0.99, +): + if nfft is None: + nfft = max(65536, 2 ** int(np.floor(np.log2(len(signal))))) + + window = np.blackman(len(signal)) + spec = np.fft.fftshift(np.fft.fft(signal * window, n=nfft)) + + psd = np.abs(spec) ** 2 + psd = psd / psd.sum() # normalize + + freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / sampling_rate)) + + cdf = np.cumsum(psd) + + tail = (1 - power_percentage) / 2 + + lower_idx = np.searchsorted(cdf, tail) + upper_idx = np.searchsorted(cdf, 1 - tail) + + return freqs[upper_idx] - freqs[lower_idx], freqs[lower_idx], freqs[upper_idx] + + +def calculate_nominal_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + power_percentage: float = 0.99, +) -> Tuple[float, float]: + """ + Calculate nominal bandwidth and center frequency. + + Nominal bandwidth (NBW) is the occupied bandwidth along with the center + frequency of the signal's spectral occupancy. Useful for characterizing + signals with unknown or drifting center frequencies. + + :param signal: Complex IQ signal samples + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size + :type nfft: int + :param power_percentage: Fraction of power to contain + :type power_percentage: float + + :returns: Tuple of (nominal_bandwidth_hz, center_frequency_hz) + :rtype: Tuple[float, float] + + **Example**:: + + >>> from ria_toolkit_oss.annotations import calculate_nominal_bandwidth + >>> nbw, center = calculate_nominal_bandwidth(signal, sampling_rate=10e6) + >>> print(f"NBW: {nbw/1e6:.3f} MHz, Center: {center/1e6:.3f} MHz") + """ + bw, lower_freq, upper_freq = calculate_occupied_bandwidth(signal, sampling_rate, nfft, power_percentage) + + # Center frequency is midpoint of occupied band + center_freq = (lower_freq + upper_freq) / 2 + + return lower_freq, upper_freq, center_freq + + +def calculate_full_detected_bandwidth( + signal: np.ndarray, + sampling_rate: float, + nfft: int = None, + start_offset: int = 1000, +) -> Tuple[float, float, float]: + """ + Calculate frequency range from lowest to highest spectral component. + + Unlike OBW/NBW which define a power-based bandwidth, this calculates + the absolute frequency span from the lowest non-zero spectral component + to the highest non-zero component. + + Useful for: + - Signals with spectral gaps + - Multiple parallel signals (captures all of them) + - Understanding total occupied spectrum vs. actual bandwidth + + :param signal: Complex IQ signal samples + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size + :type nfft: int + :param start_offset: Skip samples at start + :type start_offset: int + + :returns: Tuple of (bandwidth_hz, lower_freq_hz, upper_freq_hz) + :rtype: Tuple[float, float, float] + + **Example**:: + + >>> # Signal with two components at different frequencies + >>> bw, f_low, f_high = calculate_full_detected_bandwidth( + ... signal, sampling_rate=10e6, nfft=65536 + ... ) + >>> print(f"Full span: {f_low/1e6:.3f} to {f_high/1e6:.3f} MHz") + """ + # Validate input + if len(signal) < nfft + start_offset: + raise ValueError( + f"Signal too short: need {nfft + start_offset} samples, " + f"got {len(signal)}. Reduce nfft or start_offset." + ) + + # Extract segment + signal_segment = signal[start_offset : nfft + start_offset] + + # Compute FFT and power spectral density + freq_spectrum = np.fft.fft(signal_segment, n=nfft) + psd = np.abs(freq_spectrum) ** 2 + + # Shift to center DC + psd_shifted = np.fft.fftshift(psd) + freq_bins = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / sampling_rate)) + + # Find noise floor (mean of lowest 10% of bins) and all bins above noise floor + noise_floor = np.mean(np.sort(psd_shifted)[: int(len(psd_shifted) * 0.1)]) + above_noise = np.where(psd_shifted > noise_floor * 1.5)[0] + + if len(above_noise) == 0: + # No signal above noise, return zero bandwidth + return 0.0, 0.0, 0.0 + + # Get frequency range of signal components + lower_idx = above_noise[0] + upper_idx = above_noise[-1] + + lower_freq = freq_bins[lower_idx] + upper_freq = freq_bins[upper_idx] + + bandwidth = upper_freq - lower_freq + + return bandwidth, lower_freq, upper_freq + + +def annotate_with_obw( + recording: Recording, + label: str = "signal", + annotation_type: str = "standalone", + nfft: int = None, + power_percentage: float = 0.99, +) -> Recording: + """ + Create a single annotation spanning the occupied bandwidth of the entire recording. + + Analyzes the full recording to find its occupied bandwidth and creates an annotation + covering that frequency range for the entire time duration. + + :param recording: Recording to analyze + :type recording: Recording + :param label: Annotation label + :type label: str + :param annotation_type: Annotation type + :type annotation_type: str + :param nfft: FFT size + :type nfft: int + :param power_percentage: Power percentage for OBW calculation + :type power_percentage: float + + :returns: Recording with OBW annotation added + :rtype: Recording + + **Example**:: + + >>> from ria_toolkit_oss.annotations import annotate_with_obw + >>> annotated = annotate_with_obw(recording, label="signal_obw") + """ + signal = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_freq = recording.metadata.get("center_frequency", 0) + + # Calculate OBW + obw, lower_offset, upper_offset = calculate_occupied_bandwidth(signal, sample_rate, nfft, power_percentage) + + # Convert baseband offsets to absolute frequencies + freq_lower = center_freq + lower_offset + freq_upper = center_freq + upper_offset + + # Create comment JSON + comment_data = { + "type": annotation_type, + "generator": "obw_annotator", + "obw_hz": float(obw), + "power_percentage": power_percentage, + "params": {"nfft": nfft}, + } + + # Create annotation spanning entire recording + anno = Annotation( + sample_start=0, + sample_count=len(signal), + freq_lower_edge=freq_lower, + freq_upper_edge=freq_upper, + label=label, + comment=json.dumps(comment_data), + detail={"generator": "obw_annotator", "obw_hz": float(obw)}, + ) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + [anno]) + + +def calculate_frequency_bounds( + freq_method, center_frequency, sample_rate, nfft, signal, start_sample, sample_count, obw_power +): + if freq_method == "full-bandwidth": + # Full Nyquist span + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + else: + # Extract segment for frequency analysis + segment_start = start_sample + segment_end = min(start_sample + sample_count, len(signal)) + segment = signal[segment_start:segment_end] + + if nfft is None or len(segment) >= nfft: + if freq_method == "nbw": + # Nominal bandwidth (OBW + center frequency) + try: + lower_freq, upper_freq, _ = calculate_nominal_bandwidth(segment, sample_rate, nfft, obw_power) + freq_lower = center_frequency + lower_freq + freq_upper = center_frequency + upper_freq + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + elif freq_method == "obw": + # Occupied bandwidth + try: + _, f_lower, f_upper = calculate_occupied_bandwidth(segment, sample_rate, nfft, obw_power) + freq_lower = center_frequency + f_lower + freq_upper = center_frequency + f_upper + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + elif freq_method == "full-detected": + # Full detected range (lowest to highest component) + try: + _, f_lower, f_upper = calculate_full_detected_bandwidth(segment, sample_rate, nfft) + freq_lower = center_frequency + f_lower + freq_upper = center_frequency + f_upper + except (ValueError, IndexError): + # Fallback if calculation fails + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + else: + # Segment too short for FFT, use full bandwidth + freq_lower = center_frequency - (sample_rate / 2) + freq_upper = center_frequency + (sample_rate / 2) + + return freq_lower, freq_upper diff --git a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py new file mode 100644 index 0000000..957cf58 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py @@ -0,0 +1,435 @@ +""" +Parallel signal separation for multi-component frequency-offset signals. + +Provides methods to detect and separate overlapping frequency-domain signals +that occupy the same time window but different frequency bands. + +This module implements **spectral peak detection** to identify distinct frequency +components and split single time-domain annotations into frequency-specific +sub-annotations. + +**Key Design Decisions** (per Codex review): + +1. **Complex IQ Support**: Uses `scipy.signal.welch` with `return_onesided=False` + for proper complex signal handling. Window length automatically adapts to + signal length via `nperseg=min(nfft, len(signal))` to handle bursts >> from ria_toolkit_oss.annotations import find_spectral_components + >>> # Detect the two distinct channels (returns relative frequencies) + >>> components = find_spectral_components(signal, sampling_rate=20e6) + >>> print(f"Found {len(components)} components") + Found 2 components + +The module is designed to work with detected time-domain annotations, +allowing splitting of overlapping signals into separate training samples. +""" + +import json +from typing import List, Optional, Tuple + +import numpy as np +from scipy import ndimage +from scipy import signal as scipy_signal + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def find_spectral_components( + signal_data: np.ndarray, + sampling_rate: float, + nfft: int = 65536, + noise_threshold_db: Optional[float] = None, + min_component_bw: float = 50e3, + time_percentile: float = 70.0, +) -> List[Tuple[float, float, float]]: + """ + Find distinct frequency components using spectral peak detection. + + Identifies separate frequency components in a signal by analyzing the power + spectral density and finding peaks corresponding to distinct signals. This is + useful for separating parallel signals that occupy different frequency bands. + + **Frequency Representation**: Returns frequencies in **baseband/relative** Hz + (centered at 0). To get absolute RF frequencies, add center_frequency_hz from + recording metadata to all returned values. + + Algorithm: + 1. Compute power spectral density using Welch (properly handles complex IQ) + 2. Auto-estimate noise floor from data if not specified + 3. Smooth PSD to reduce spurious peaks + 4. Find local maxima above noise floor + 5. Estimate bandwidth per peak using -3dB (fallback: cumulative power) + 6. Filter components below minimum bandwidth threshold + + :param signal_data: Complex IQ signal samples (np.complex64/128) + :type signal_data: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param nfft: FFT size / window length for Welch. Automatically capped at + signal length to handle bursts (default: 65536) + :type nfft: int + :param noise_threshold_db: Minimum SNR threshold in dB. If None (default), + auto-estimates as np.percentile(psd_db, 10). + Adapt this across hardware (Pluto: ~-100, ThinkRF: ~-60). + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz) + :type min_component_bw: float + :param power_threshold: Cumulative power threshold for fallback bandwidth + estimation (default: 0.99 = 99% power, like OBW) + :type power_threshold: float + + :returns: List of (center_freq_hz, lower_freq_hz, upper_freq_hz) tuples. + **All frequencies are relative (baseband, 0-centered).** + Add recording metadata['center_frequency'] to get absolute RF frequencies. + :rtype: List[Tuple[float, float, float]] + + :raises ValueError: If signal has fewer than 256 samples + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import find_spectral_components + >>> recording = load_recording("capture.sigmf") + >>> segment = recording.data[0][start:end] + >>> # Components in relative (baseband) frequency + >>> components = find_spectral_components(segment, sampling_rate=20e6) + >>> for center_rel, lower_rel, upper_rel in components: + ... # Convert to absolute RF frequency + ... center_abs = recording.metadata['center_frequency'] + center_rel + ... print(f"Component @ {center_abs/1e9:.3f} GHz") + """ + # Validate input + min_samples = 256 + if len(signal_data) < min_samples: + raise ValueError(f"Signal too short: need at least {min_samples} samples, " f"got {len(signal_data)}.") + + # Compute PSD using Welch method for complex IQ signals + # CRITICAL: return_onesided=False for proper complex signal handling + nperseg = min(nfft, len(signal_data)) + noverlap = nperseg // 2 + + # --- STFT --- + freqs, times, Zxx = scipy_signal.stft( + signal_data, + fs=sampling_rate, + window="blackman", + nperseg=nperseg, + noverlap=noverlap, + return_onesided=False, + boundary=None, + ) + + # Shift zero freq to center + Zxx = np.fft.fftshift(Zxx, axes=0) + freqs = np.fft.fftshift(freqs) + + # Power spectrogram + power = np.abs(Zxx) ** 2 + power_db = 10 * np.log10(power + 1e-12) + + # --- Aggregate across time robustly --- + # Using percentile instead of mean prevents short signals from being diluted + freq_profile_db = np.percentile(power_db, time_percentile, axis=1) + + # --- Noise floor estimation --- + if noise_threshold_db is None: + noise_threshold_db = np.percentile(freq_profile_db, 20) + + threshold = noise_threshold_db + 3 # 3 dB above noise floor + + # --- Smooth lightly (avoid merging nearby signals) --- + freq_profile_db = ndimage.gaussian_filter1d(freq_profile_db, sigma=1.5) + + # --- Binary mask of significant frequencies --- + mask = freq_profile_db > threshold + + # --- Find contiguous frequency regions --- + labeled, num_features = ndimage.label(mask) + + components = [] + + for region_label in range(1, num_features + 1): + region_indices = np.where(labeled == region_label)[0] + + if len(region_indices) == 0: + continue + + lower_idx = region_indices[0] + upper_idx = region_indices[-1] + + lower_freq = freqs[lower_idx] + upper_freq = freqs[upper_idx] + bw = upper_freq - lower_freq + + if bw < min_component_bw: + continue + + center_freq = (lower_freq + upper_freq) / 2 + components.append((center_freq, lower_freq, upper_freq)) + + return components + + +def split_annotation_by_components( + annotation: Annotation, + signal: np.ndarray, + sampling_rate: float, + center_frequency_hz: float = 0.0, + nfft: int = 65536, + noise_threshold_db: Optional[float] = None, + min_component_bw: float = 50e3, +) -> List[Annotation]: + """ + Split an annotation into multiple annotations by detected frequency components. + + Takes an existing annotation spanning multiple frequency components and + analyzes the frequency content to create separate sub-annotations for + each distinct frequency component. + + **Use case**: Energy detection found a time window with 2-3 parallel WiFi + channels. This function splits it into separate annotations per channel. + + **Frequency Handling**: `find_spectral_components` returns relative (baseband) + frequencies. This function adds `center_frequency_hz` to convert to absolute + RF frequencies for SigMF annotation bounds. This ensures correct frequency + context across baseband and RF domains. + + :param annotation: Original annotation to split + :type annotation: Annotation + :param signal: Full signal array (complex IQ) + :type signal: np.ndarray + :param sampling_rate: Sample rate in Hz + :type sampling_rate: float + :param center_frequency_hz: RF center frequency to add to relative frequencies + from peak detection (default: 0.0 = baseband) + :type center_frequency_hz: float + :param nfft: FFT size for analysis (default: 65536, auto-capped at signal length) + :type nfft: int + :param noise_threshold_db: Noise floor threshold in dB. If None (default), + auto-estimates from data. + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz) + :type min_component_bw: float + + :returns: List of new annotations (one per detected component). + Returns empty list if no components found or segment too short. + :rtype: List[Annotation] + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import split_annotation_by_components + >>> recording = load_recording("capture.sigmf") + >>> # Original annotation spans multiple channels + >>> original = recording.annotations[0] + >>> # Split using RF center frequency from metadata + >>> components = split_annotation_by_components( + ... original, + ... recording.data[0], + ... recording.metadata['sample_rate'], + ... center_frequency_hz=recording.metadata.get('center_frequency', 0.0) + ... ) + >>> print(f"Split into {len(components)} components") + Split into 2 components + + **Algorithm**: + 1. Extract segment corresponding to annotation time bounds + 2. Find frequency components in that segment (returns relative frequencies) + 3. Add center_frequency_hz to get absolute RF frequencies + 4. Create new annotation for each component + 5. Preserve original metadata (label, type, etc.) + 6. Add component info to comment JSON + + **Notes**: + - Original annotation is not modified + - Returns empty list if segment too short (<256 samples) + - Segments Recording: + """ + Split multiple annotations in a recording by frequency components. + + Processes specified annotations (or all if indices=None), replacing each + with its frequency-separated components. Uses RF center_frequency from + recording metadata for proper absolute frequency conversion. + + :param recording: Recording to process + :type recording: Recording + :param indices: Annotation indices to split (None = all, default: None). + Use indices=[] to skip splitting (returns unchanged recording). + :type indices: Optional[List[int]] + :param nfft: FFT size for spectral analysis (default: 65536, + auto-capped at signal segment length) + :type nfft: int + :param noise_threshold_db: Noise floor threshold in dB. If None (default), + auto-estimates from each segment. + :type noise_threshold_db: Optional[float] + :param min_component_bw: Minimum component bandwidth in Hz (default: 50 kHz). + Components narrower than this are filtered out. + :type min_component_bw: float + + :returns: New Recording with split annotations + :rtype: Recording + + **Example**:: + + >>> from ria.io import load_recording + >>> from ria_toolkit_oss.annotations import split_recording_annotations + >>> recording = load_recording("capture.sigmf") + >>> # Split all annotations + >>> split_rec = split_recording_annotations(recording) + >>> print(f"Original: {len(recording.annotations)} annotations") + >>> print(f"Split: {len(split_rec.annotations)} annotations") + Original: 5 annotations + Split: 9 annotations + + **Algorithm**: + 1. For each annotation in indices (or all if None): + 2. Call split_annotation_by_components with RF center_frequency + 3. If components found, replace annotation with components + 4. If no components found, keep original annotation + 5. Annotations not in indices are kept unchanged + + **Notes**: + - Original recording is not modified + - Returns empty Recording.annotations if recording has no annotations + - RF center_frequency from metadata ensures correct absolute frequencies + - If an annotation can't be split (too short, wrong format), original kept + """ + if indices is None: + # Split all annotations + indices = list(range(len(recording.annotations))) + + if not recording.annotations: + # No annotations to split + return recording + + signal = recording.data[0] + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0.0) + + # Build new annotation list + new_annotations = [] + for i, anno in enumerate(recording.annotations): + if i in indices: + # Attempt to split this annotation + try: + components = split_annotation_by_components( + anno, + signal, + sample_rate, + center_frequency_hz=center_frequency, + nfft=nfft, + noise_threshold_db=noise_threshold_db, + min_component_bw=min_component_bw, + ) + if components: + # Split successful, use components + new_annotations.extend(components) + else: + # No components found, keep original + new_annotations.append(anno) + except Exception: + # Split failed for any reason, keep original + new_annotations.append(anno) + else: + # Not in split list, keep as-is + new_annotations.append(anno) + + return Recording(data=recording.data, metadata=recording.metadata, annotations=new_annotations) diff --git a/src/ria_toolkit_oss/annotations/qualify_slice.py b/src/ria_toolkit_oss/annotations/qualify_slice.py new file mode 100644 index 0000000..2336fe5 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/qualify_slice.py @@ -0,0 +1,35 @@ +import numpy as np + +from ria_toolkit_oss.datatypes import Recording + + +def qualify_slice_from_annotations(recording: Recording, slice_length: int): + """ + Slice a recording into many smaller recordings, + discarding any slices which do not have annotations that apply to those samples. + Used together with an annotation based qualifier. + + :param recording: The recording to slice. + :type recording: Recording + :param slice_length: The length in samples of a slice. + :type slice_length: int""" + + if len(recording.annotations) == 0: + print("Warning, no annotations.") + + annotation_mask = np.zeros(len(recording.data[0])) + + for annotation in recording.annotations: + annotation_mask[annotation.sample_start : annotation.sample_start + annotation.sample_count] = 1 + + output_recordings = [] + + for i in range((len(recording.data[0]) // slice_length) - 1): + start_index = slice_length * i + end_index = slice_length * (i + 1) + + if 1 in annotation_mask[start_index:end_index]: + sl = recording.data[:, start_index:end_index] + output_recordings.append(Recording(data=sl, metadata=recording.metadata)) + + return output_recordings diff --git a/src/ria_toolkit_oss/annotations/signal_isolation.py b/src/ria_toolkit_oss/annotations/signal_isolation.py new file mode 100644 index 0000000..47852ae --- /dev/null +++ b/src/ria_toolkit_oss/annotations/signal_isolation.py @@ -0,0 +1,97 @@ +import numpy as np +from scipy.signal import butter, lfilter + +from ria_toolkit_oss.datatypes.annotation import Annotation +from ria_toolkit_oss.datatypes.recording import Recording + + +def isolate_signal(recording: Recording, annotation: Annotation) -> Recording: + """ + Slice, filter and frequency shift the input recording according to the bounding box defined by the annotation. + + :param recording: The input Recording to be sliced. + :type recording: Recording + :param annotation: The Annotation object defining the area of the recording to isolate. + :type annotation: Annotation + :param decimate: Decimate the input signal after filtering to reduce the sample rate. + :type decimate: bool + + :returns: The subsection of the original recording defined by the annotation. + :rtype: Recording""" + + sample_start = max(0, annotation.sample_start) + sample_stop = min(len(recording), annotation.sample_start + annotation.sample_count) + + anno_base_center_freq = (annotation.freq_lower_edge + annotation.freq_upper_edge) / 2 - recording.metadata.get( + "center_frequency", 0 + ) + + anno_bw = annotation.freq_upper_edge - annotation.freq_lower_edge + + signal_slice = recording.data[0, sample_start:sample_stop] + + # normalize + signal_slice = signal_slice / np.max(np.abs(signal_slice)) + + isolation_bw = anno_bw + + # frequency shift the center of the box about zero + shifted_signal_slice = frequency_shift_iq_samples( + iq_samples=signal_slice, + sample_rate=recording.metadata["sample_rate"], + shift_frequency=-1 * anno_base_center_freq, + ) + + # filter + if isolation_bw < recording.metadata["sample_rate"] - 1: + filtered_signal = apply_complex_lowpass_filter( + signal=shifted_signal_slice, cutoff_frequency=isolation_bw, sample_rate=recording.metadata["sample_rate"] + ) + + else: + filtered_signal = shifted_signal_slice + + output = Recording(data=[filtered_signal], metadata=recording.metadata) + return output + + +def frequency_shift_iq_samples(iq_samples, sample_rate, shift_frequency): + # Number of samples + num_samples = len(iq_samples) + + # Create a time vector from 0 to the total duration in seconds + time_vector = np.arange(num_samples) / sample_rate + + # Generate the complex exponential for the frequency shift + complex_exponential = np.exp(1j * 2 * np.pi * shift_frequency * time_vector) + + # Apply the frequency shift to the IQ samples + shifted_samples = iq_samples * complex_exponential + + return shifted_samples + + +# Function to apply a lowpass Butterworth filter to a complex signal +def apply_complex_lowpass_filter(signal, cutoff_frequency, sample_rate, order=5): + # Design the lowpass filter + b, a = design_complex_lowpass_filter(cutoff_frequency, sample_rate, order) + + # Apply the lowpass filter + filtered_signal = lfilter(b, a, signal) + return filtered_signal + + +def design_complex_lowpass_filter(cutoff_frequency, sample_rate, order=5): + # Nyquist frequency for complex signals is the sample rate + nyquist = sample_rate + + # Ensure the cutoff frequency is positive and within the Nyquist limit + if cutoff_frequency <= 0 or cutoff_frequency > nyquist: + raise ValueError("Cutoff frequency must be between 0 and the Nyquist frequency.") + + # Normalize the cutoff frequency to the Nyquist frequency + cutoff_normalized = cutoff_frequency / nyquist + + # Create a Butterworth lowpass filter + b, a = butter(order, cutoff_normalized, btype="low") + return b, a diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py new file mode 100644 index 0000000..804c5e1 --- /dev/null +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -0,0 +1,359 @@ +""" +Temporal signal detection and boundary refinement via Hysteresis Thresholding. + +Provides methods to detect signal bursts in the time domain by triggering on +smoothed power peaks and expanding boundaries to capture the full energy envelope. + +This module implements a **dual-threshold trigger** to solve the 'chatter' +problem in noisy environments, ensuring that signal annotations encapsulate +the entire rise and fall of a burst rather than just the peak. + +**Key Design Decisions**: + +1. **Hysteresis Logic (Dual-Threshold)**: + - **Trigger**: High threshold (`threshold * max_power`) ensures high confidence + in signal presence. + - **Boundary**: Low threshold (`0.5 * trigger`) allows the annotation to + "crawl" outward, capturing the lower-energy start and end of the burst + often missed by simple single-threshold detectors. + +2. **Temporal Smoothing**: Uses a moving average window (`window_size`) prior + - to thresholding. This prevents high-frequency noise spikes from causing + fragmented annotations and provides a more stable estimate of the + signal's power envelope. + +3. **Spectral Profiling**: Once a temporal segment is isolated, the module + - performs an automated FFT analysis. It identifies the **90% spectral + occupancy** to define the frequency boundaries (`f_min`, `f_max`), + allowing the detector to work on narrowband and wideband signals without + manual frequency tuning. + +4. **Baseband/RF Mapping**: Automatically handles the conversion from + - relative FFT bin frequencies to absolute RF frequencies by referencing + `recording.metadata["center_frequency"]`. + +5. **False Positive Mitigation**: Implements a hard minimum duration check + - (10ms) to ignore transient hardware spikes or noise floor fluctuations + that do not constitute a valid signal burst. + +The module is designed to be the primary "first-pass" detector for pulsed +waveforms (like ADS-B, Lora, or bursty FSK) before passing them to +classification or demodulation stages. +""" + +import json +from typing import Optional + +import numpy as np + +from ria_toolkit_oss.datatypes import Annotation, Recording + + +def _find_ranges(indices, max_gap): + """ + Groups individual indices into continuous temporal ranges. + + Args: + indices: Array of indices where the signal exceeded a threshold. + max_gap: Maximum gap allowed between indices to consider them part + of the same range. + + Returns: + A list of (start, stop) tuples representing detected signal segments. + """ + + if len(indices) == 0: + return [] + + start = indices[0] + prev = indices[0] + ranges = [] + + for i in range(1, len(indices)): + if indices[i] - prev > max_gap: + ranges.append((start, prev)) + start = indices[i] + prev = indices[i] + + ranges.append((start, prev)) + + return ranges + + +def _expand_and_filter_ranges( + smoothed_power: np.ndarray, + initial_ranges: list[tuple[int, int]], + boundary_val: float, + min_duration_samples: int, +) -> list[tuple[int, int]]: + """Apply hysteresis expansion and minimum-duration filtering.""" + out: list[tuple[int, int]] = [] + n = len(smoothed_power) + for start, stop in initial_ranges: + if (stop - start) < min_duration_samples: + continue + + true_start = start + while true_start > 0 and smoothed_power[true_start] > boundary_val: + true_start -= 1 + + true_stop = stop + while true_stop < n - 1 and smoothed_power[true_stop] > boundary_val: + true_stop += 1 + + if (true_stop - true_start) >= min_duration_samples: + out.append((true_start, true_stop)) + return out + + +def _merge_ranges(ranges: list[tuple[int, int]], max_gap: int) -> list[tuple[int, int]]: + """Merge overlapping or near-adjacent ranges.""" + if not ranges: + return [] + ranges = sorted(ranges, key=lambda r: r[0]) + merged = [ranges[0]] + for s, e in ranges[1:]: + last_s, last_e = merged[-1] + if s <= last_e + max_gap: + merged[-1] = (last_s, max(last_e, e)) + else: + merged.append((s, e)) + return merged + + +def _estimate_noise_floor(power: np.ndarray, quantile: float = 20.0) -> float: + """Estimate baseline from the quieter portion of the envelope.""" + return float(np.percentile(power, quantile)) + + +def _estimate_group_gap(sample_rate: float) -> int: + """Use a fixed temporal grouping gap instead of reusing the smoothing window.""" + return max(1, int(0.001 * sample_rate)) + + +def _estimate_spectral_bounds(signal_segment: np.ndarray, sample_rate: float) -> tuple[float, float]: + """Estimate occupied bandwidth from a smoothed magnitude spectrum.""" + if len(signal_segment) == 0: + return -sample_rate / 4, sample_rate / 4 + + window = np.hanning(len(signal_segment)) + windowed = signal_segment * window + + fft_data = np.abs(np.fft.fftshift(np.fft.fft(windowed))) + fft_freqs = np.fft.fftshift(np.fft.fftfreq(len(signal_segment), 1 / sample_rate)) + + # Smooth the spectrum so noise-like wideband bursts form a contiguous mask + # instead of thousands of tiny isolated runs. + spectral_smooth_bins = max(5, min(257, (len(signal_segment) // 512) | 1)) + spectral_kernel = np.ones(spectral_smooth_bins, dtype=np.float64) / spectral_smooth_bins + smoothed_fft = np.convolve(fft_data, spectral_kernel, mode="same") + + spectral_floor = float(np.percentile(smoothed_fft, 20)) + spectral_peak = float(np.max(smoothed_fft)) + spectral_ratio = spectral_peak / max(spectral_floor, 1e-12) + + if spectral_ratio < 1.2: + return -sample_rate / 4, sample_rate / 4 + + spectral_thresh = spectral_floor + 0.1 * (spectral_peak - spectral_floor) + sig_indices = np.where(smoothed_fft > spectral_thresh)[0] + + if len(sig_indices) == 0: + peak_idx = int(np.argmax(smoothed_fft)) + bin_hz = sample_rate / len(signal_segment) + half_bins = max(1, int(np.ceil(10_000.0 / bin_hz))) + lo_idx = max(0, peak_idx - half_bins) + hi_idx = min(len(smoothed_fft) - 1, peak_idx + half_bins) + else: + runs = _find_ranges(sig_indices, max_gap=max(1, spectral_smooth_bins // 2)) + peak_idx = int(np.argmax(smoothed_fft)) + lo_idx, hi_idx = min( + runs, + key=lambda run: 0 if run[0] <= peak_idx <= run[1] else min(abs(run[0] - peak_idx), abs(run[1] - peak_idx)), + ) + + # Prevent extremely narrow tone boxes from collapsing to just a few bins. + min_total_bw_hz = 20_000.0 + min_half_bins = max(1, int(np.ceil((min_total_bw_hz / 2) / (sample_rate / len(signal_segment))))) + center_idx = int(round((lo_idx + hi_idx) / 2)) + lo_idx = max(0, min(lo_idx, center_idx - min_half_bins)) + hi_idx = min(len(smoothed_fft) - 1, max(hi_idx, center_idx + min_half_bins)) + + return float(fft_freqs[lo_idx]), float(fft_freqs[hi_idx]) + + +def threshold_qualifier( + recording: Recording, + threshold: float, + window_size: Optional[int] = None, + label: Optional[str] = None, + annotation_type: Optional[str] = "standalone", + channel: int = 0, +) -> Recording: + """ + Annotate a recording with bounding boxes for regions above a threshold. + Threshold is defined as a fraction of the maximum sample magnitude. + This algorithm searches for samples above the threshold and combines them into ranges if they + are within window_size of each other. + Detects and annotates signals using energy thresholding and spectral analysis. + + The algorithm follows these steps: + 1. Smooths power data using a moving average. + 2. Identifies 'peak' regions exceeding a high trigger threshold. + 3. Uses hysteresis to expand boundaries until power drops below a lower threshold. + 4. Performs an FFT on each segment to determine frequency occupancy. + + Args: + recording: The Recording object containing IQ or real signal data. + threshold: Sensitivity multiplier (0.0 to 1.0) applied to max power. + window_size: Size of the smoothing filter in samples. Defaults to 1ms worth of samples. + label: Custom string label for annotations. + annotation_type: Metadata string for the 'type' field in the annotation. + channel: Index of the channel to annotate. Defaults to 0. + + Returns: + A new Recording object populated with detected Annotations. + """ + # Extract signal and metadata + sample_data = recording.data[channel] + sample_rate = recording.metadata["sample_rate"] + center_frequency = recording.metadata.get("center_frequency", 0) + + if window_size is None: + window_size = max(64, int(sample_rate * 0.001)) + + # --- 1. SIGNAL CONDITIONING --- + # Convert to power (Magnitude squared) + power_data = np.abs(sample_data) ** 2 + smoothing_window = np.ones(window_size) / window_size + smoothed_power = np.convolve(power_data, smoothing_window, mode="same") + group_gap_samples = _estimate_group_gap(sample_rate) + + # Define thresholds using peak relative to baseline. + max_power = np.max(smoothed_power) + noise_floor = _estimate_noise_floor(smoothed_power) + dynamic_range_ratio = max_power / max(noise_floor, 1e-12) + + # Soft early exit: keep a guard for low-contrast noise, but compute it from + # the quieter tail of the envelope so burst-heavy captures are not rejected. + if dynamic_range_ratio < 1.5: + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations) + + trigger_val = noise_floor + threshold * (max_power - noise_floor) + boundary_val = noise_floor + 0.5 * threshold * (max_power - noise_floor) + + # --- 2. INITIAL DETECTION --- + # Enforce an explicit minimum duration in seconds; this is stable across + # varying capture lengths and avoids over-fitting to recording length. + min_duration_samples = max(1, int(0.005 * sample_rate)) + annotations = [] + + # Pass 1: Detect stronger bursts. + indices = np.where(smoothed_power > trigger_val)[0] + pass1_initial = _find_ranges(indices=indices, max_gap=group_gap_samples) + pass1_ranges = _expand_and_filter_ranges( + smoothed_power=smoothed_power, + initial_ranges=pass1_initial, + boundary_val=boundary_val, + min_duration_samples=min_duration_samples, + ) + + # Pass 2: Recover weaker bursts on residual power not already covered. + # This improves recall in mixed-amplitude captures. + # Expand each Pass-1 range by the smoothing window on both sides so the + # smoothing skirts of a strong burst are not re-detected as a weak burst + # immediately adjacent to it (mirrors the guard used in Pass 3). + mask = np.ones_like(smoothed_power, dtype=np.float32) + pass2_mask_expand = window_size + for s, e in pass1_ranges: + mask[max(0, s - pass2_mask_expand) : min(len(mask), e + pass2_mask_expand)] = 0.0 + residual_power = smoothed_power * mask + + residual_max = float(np.max(residual_power)) + residual_ratio = residual_max / max(noise_floor, 1e-12) + + pass2_ranges: list[tuple[int, int]] = [] + if residual_ratio >= 2.0: + weak_threshold = max(0.3, threshold * 0.7) + weak_trigger = noise_floor + weak_threshold * (residual_max - noise_floor) + weak_boundary = noise_floor + 0.5 * weak_threshold * (residual_max - noise_floor) + weak_indices = np.where(residual_power > weak_trigger)[0] + pass2_initial = _find_ranges(indices=weak_indices, max_gap=group_gap_samples) + pass2_ranges = _expand_and_filter_ranges( + smoothed_power=residual_power, + initial_ranges=pass2_initial, + boundary_val=weak_boundary, + min_duration_samples=min_duration_samples, + ) + + # Pass 3: Detect sustained faint bursts via macro-window averaging. + # Targets bursts whose peak power is near the trigger level but whose + # *average* power is consistently elevated above the noise floor — these + # are missed by peak-based detection because only a few short spikes exceed + # the trigger, all too brief to pass the minimum-duration filter. + # + # The mask is applied to power_data *before* convolving so that bright + # burst energy does not bleed through the long window into adjacent regions, + # which would inflate macro_residual_max and push the trigger above the + # faint burst's average power. + macro_window_size = max(window_size * 16, int(sample_rate * 0.02)) + macro_kernel = np.ones(macro_window_size, dtype=np.float64) / macro_window_size + # Expand each annotated range by half the macro window on both sides so that + # the long convolution cannot "see" the leading/trailing edges of already- + # annotated bursts, which would produce spurious short fragments in Pass 3. + macro_expand = macro_window_size * 2 + masked_power_for_macro = power_data.copy() + n = len(masked_power_for_macro) + for s, e in pass1_ranges + pass2_ranges: + masked_power_for_macro[max(0, s - macro_expand) : min(n, e + macro_expand)] = 0.0 + macro_residual = np.convolve(masked_power_for_macro, macro_kernel, mode="same") + + macro_residual_max = float(np.max(macro_residual)) + + pass3_ranges: list[tuple[int, int]] = [] + if macro_residual_max / max(noise_floor, 1e-12) >= 1.3: + macro_trigger = noise_floor + threshold * (macro_residual_max - noise_floor) + macro_boundary = noise_floor + 0.5 * threshold * (macro_residual_max - noise_floor) + macro_indices = np.where(macro_residual > macro_trigger)[0] + macro_initial = _find_ranges(indices=macro_indices, max_gap=group_gap_samples) + pass3_ranges = _expand_and_filter_ranges( + smoothed_power=macro_residual, + initial_ranges=macro_initial, + boundary_val=macro_boundary, + min_duration_samples=min_duration_samples, + ) + + all_ranges = _merge_ranges(pass1_ranges + pass2_ranges + pass3_ranges, max_gap=group_gap_samples) + + for true_start, true_stop in all_ranges: + + # --- 4. SPECTRAL ANALYSIS (Frequency Detection) --- + signal_segment = sample_data[true_start:true_stop] + f_min, f_max = _estimate_spectral_bounds(signal_segment, sample_rate) + + # --- 5. ANNOTATION GENERATION --- + ann_label = label if label is not None else f"{int(threshold*100)}%" + + # Pack metadata for the UI/Downstream processing + comment_data = { + "type": annotation_type, + "generator": "threshold_qualifier", + "params": { + "threshold": threshold, + "window_size": window_size, + }, + } + + anno = Annotation( + sample_start=true_start, + sample_count=true_stop - true_start, + freq_lower_edge=center_frequency + f_min, + freq_upper_edge=center_frequency + f_max, + label=ann_label, + comment=json.dumps(comment_data), + detail={"generator": "hysteresis_qualifier"}, + ) + annotations.append(anno) + + # Return a new Recording object including the new annotations + return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations + annotations) diff --git a/src/ria_toolkit_oss/app/__init__.py b/src/ria_toolkit_oss/app/__init__.py new file mode 100644 index 0000000..465659f --- /dev/null +++ b/src/ria_toolkit_oss/app/__init__.py @@ -0,0 +1 @@ +"""App runner: pull and run containerized RIA applications.""" diff --git a/src/ria_toolkit_oss/app/cli.py b/src/ria_toolkit_oss/app/cli.py new file mode 100644 index 0000000..7a2b7c7 --- /dev/null +++ b/src/ria_toolkit_oss/app/cli.py @@ -0,0 +1,278 @@ +"""Unified ``ria-app`` CLI. + +Subcommands: + +- ``ria-app pull [:tag]`` — pull a RIA app image from the configured registry. +- ``ria-app run [:tag]`` — pull (if needed) and run, auto-configuring + GPU/USB/network flags from image labels set by CI. +- ``ria-app list`` — list locally cached RIA app images. +- ``ria-app stop `` — stop a running app container. +- ``ria-app logs `` — tail logs of a running app container. +- ``ria-app configure`` — set default registry/namespace. + +Image references resolve as:: + + my-classifier -> {registry}/{namespace}/my-classifier:latest + group/my-classifier -> {registry}/group/my-classifier:latest + host/group/app:tag -> host/group/app:tag (fully-qualified passthrough) +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys + +from . import config as _config + +_LABEL_PROFILE = "ria.profile" +_LABEL_HARDWARE = "ria.hardware" +_LABEL_APP = "ria.app" + + +def _engine(cfg: _config.AppConfig, sudo_override: bool = False) -> list[str]: + for exe in ("docker", "podman"): + if shutil.which(exe): + use_sudo = sudo_override or cfg.sudo + return ["sudo", exe] if use_sudo else [exe] + print("error: neither 'docker' nor 'podman' found on PATH", file=sys.stderr) + sys.exit(2) + + +def _resolve_ref(app: str, cfg: _config.AppConfig) -> str: + ref = app if ":" in app.split("/")[-1] else f"{app}:latest" + slashes = ref.count("/") + if slashes >= 2: + return ref + if slashes == 1: + return f"{cfg.registry}/{ref}" if cfg.registry else ref + if not cfg.registry or not cfg.namespace: + print( + "error: app is not fully qualified and no default registry/namespace configured. " + "Run `ria-app configure` or pass a full image reference (registry/namespace/app:tag).", + file=sys.stderr, + ) + sys.exit(2) + return f"{cfg.registry}/{cfg.namespace}/{ref}" + + +def _container_name(ref: str) -> str: + name = ref.rsplit("/", 1)[-1].split(":", 1)[0] + return f"ria-app-{name}" + + +def _inspect_labels(engine: list[str], ref: str) -> dict: + try: + out = subprocess.check_output( + [*engine, "image", "inspect", "--format", "{{json .Config.Labels}}", ref], + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return {} + try: + return json.loads(out.decode().strip()) or {} + except json.JSONDecodeError: + return {} + + +def _gpu_available() -> bool: + if os.path.exists("/dev/nvidia0"): + return True + return shutil.which("nvidia-smi") is not None + + +def _hardware_flags(labels: dict, no_gpu: bool, no_usb: bool, no_host_net: bool) -> tuple[list[str], list[str]]: + flags: list[str] = [] + notes: list[str] = [] + profile = (labels.get(_LABEL_PROFILE) or "").lower() + hardware = (labels.get(_LABEL_HARDWARE) or "").lower() + hw_items = {h.strip() for h in hardware.split(",") if h.strip()} + + wants_gpu = any(k in profile for k in ("nvidia", "holoscan", "cuda")) + if wants_gpu and not no_gpu: + if _gpu_available(): + flags += ["--gpus", "all"] + else: + notes.append( + "image wants GPU but no NVIDIA runtime detected — skipping --gpus (use --force-gpu to override)" + ) + + if hw_items & {"pluto", "rtlsdr", "hackrf", "bladerf"} and not no_usb: + flags += ["--device", "/dev/bus/usb"] + + if hw_items & {"usrp", "thinkrf", "pluto"} and not no_host_net: + flags += ["--net", "host"] + + return flags, notes + + +def _cmd_configure(args: argparse.Namespace) -> int: + cfg = _config.load() + if args.registry: + cfg.registry = args.registry + if args.namespace: + cfg.namespace = args.namespace + if args.sudo is not None: + cfg.sudo = args.sudo + path = _config.save(cfg) + print(f"Saved app config to {path}") + print(f" registry: {cfg.registry or '(unset)'}") + print(f" namespace: {cfg.namespace or '(unset)'}") + print(f" sudo: {cfg.sudo}") + return 0 + + +def _cmd_pull(args: argparse.Namespace) -> int: + cfg = _config.load() + engine = _engine(cfg, args.sudo) + ref = _resolve_ref(args.app, cfg) + print(f"Pulling {ref}") + return subprocess.call([*engine, "pull", ref]) + + +def _cmd_run(args: argparse.Namespace) -> int: + cfg = _config.load() + engine = _engine(cfg, args.sudo) + ref = _resolve_ref(args.app, cfg) + + if not _inspect_labels(engine, ref): + rc = subprocess.call([*engine, "pull", ref]) + if rc != 0: + return rc + + labels = _inspect_labels(engine, ref) + no_gpu = args.no_gpu and not args.force_gpu + hw_flags, notes = _hardware_flags(labels, no_gpu=no_gpu, no_usb=args.no_usb, no_host_net=args.no_host_net) + if args.force_gpu and "--gpus" not in hw_flags: + hw_flags = ["--gpus", "all", *hw_flags] + + cmd = [*engine, "run", "--rm"] + if not args.foreground: + cmd += ["-d"] + cmd += ["--name", args.name or _container_name(ref)] + cmd += hw_flags + + if args.config: + cmd += ["-v", f"{args.config}:/config/config.yaml:ro", "-e", "RIA_CONFIG=/config/config.yaml"] + + for env in args.env or []: + cmd += ["-e", env] + for vol in args.volume or []: + cmd += ["-v", vol] + for port in args.publish or []: + cmd += ["-p", port] + + cmd += list(args.docker_args or []) + cmd += [ref] + cmd += list(args.app_args or []) + + if args.dry_run: + print(" ".join(cmd)) + return 0 + + label_str = ", ".join(f"{k}={v}" for k, v in labels.items() if k.startswith("ria.")) or "(no ria.* labels)" + print(f"Running {ref} [{label_str}]") + if hw_flags: + print(f" auto flags: {' '.join(hw_flags)}") + for note in notes: + print(f" note: {note}") + return subprocess.call(cmd) + + +def _cmd_list(args: argparse.Namespace) -> int: + cfg = _config.load() + engine = _engine(cfg, args.sudo) + return subprocess.call( + [ + *engine, + "images", + "--filter", + f"label={_LABEL_APP}", + "--format", + "table {{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Size}}", + ] + ) + + +def _cmd_stop(args: argparse.Namespace) -> int: + cfg = _config.load() + engine = _engine(cfg, args.sudo) + name = args.name or _container_name(_resolve_ref(args.app, cfg)) + return subprocess.call([*engine, "stop", name]) + + +def _cmd_logs(args: argparse.Namespace) -> int: + cfg = _config.load() + engine = _engine(cfg, args.sudo) + name = args.name or _container_name(_resolve_ref(args.app, cfg)) + cmd = [*engine, "logs"] + if args.follow: + cmd += ["-f"] + cmd += [name] + return subprocess.call(cmd) + + +def main() -> None: + parser = argparse.ArgumentParser(prog="ria-app") + parser.add_argument("--sudo", action="store_true", default=False, help="Run docker/podman via sudo") + sub = parser.add_subparsers(dest="command", required=True) + + p_cfg = sub.add_parser("configure", help="Set default registry/namespace") + p_cfg.add_argument("--registry", default=None, help="Default container registry (e.g. registry.riahub.ai)") + p_cfg.add_argument("--namespace", default=None, help="Default namespace (e.g. qoherent)") + p_cfg.add_argument( + "--sudo", + dest="sudo", + action=argparse.BooleanOptionalAction, + default=None, + help="Persist sudo default (--sudo / --no-sudo)", + ) + + p_pull = sub.add_parser("pull", help="Pull an app image") + p_pull.add_argument("app", help="App name or image reference") + + p_run = sub.add_parser("run", help="Run an app, auto-detecting hardware flags") + p_run.add_argument("app", help="App name or image reference") + p_run.add_argument("--name", default=None, help="Container name (default: ria-app-)") + p_run.add_argument("--config", default=None, help="Path to config.yaml to mount into the container") + p_run.add_argument("-e", "--env", action="append", help="Extra env var (KEY=VALUE)") + p_run.add_argument("-v", "--volume", action="append", help="Extra volume mount") + p_run.add_argument("-p", "--publish", action="append", help="Publish port") + p_run.add_argument("--foreground", "-F", action="store_true", help="Run in foreground (no -d)") + p_run.add_argument("--no-gpu", action="store_true", help="Skip --gpus flag even if image wants GPU") + p_run.add_argument("--force-gpu", action="store_true", help="Force --gpus all even if no NVIDIA runtime detected") + p_run.add_argument("--no-usb", action="store_true", help="Skip --device /dev/bus/usb") + p_run.add_argument("--no-host-net", action="store_true", help="Skip --net host") + p_run.add_argument("--dry-run", action="store_true", help="Print the container command and exit") + p_run.add_argument("--docker-args", nargs=argparse.REMAINDER, help="Pass remaining args to docker/podman run") + p_run.add_argument("--app-args", nargs=argparse.REMAINDER, help="Pass remaining args to the app entrypoint") + + sub.add_parser("list", help="List locally cached RIA app images") + + p_stop = sub.add_parser("stop", help="Stop a running app") + p_stop.add_argument("app", help="App name or image reference") + p_stop.add_argument("--name", default=None, help="Container name override") + + p_logs = sub.add_parser("logs", help="Tail logs of a running app") + p_logs.add_argument("app", help="App name or image reference") + p_logs.add_argument("--name", default=None, help="Container name override") + p_logs.add_argument("-f", "--follow", action="store_true", help="Follow log output") + + args = parser.parse_args() + + dispatch = { + "configure": _cmd_configure, + "pull": _cmd_pull, + "run": _cmd_run, + "list": _cmd_list, + "stop": _cmd_stop, + "logs": _cmd_logs, + } + sys.exit(dispatch[args.command](args)) + + +if __name__ == "__main__": + main() diff --git a/src/ria_toolkit_oss/app/config.py b/src/ria_toolkit_oss/app/config.py new file mode 100644 index 0000000..8bff807 --- /dev/null +++ b/src/ria_toolkit_oss/app/config.py @@ -0,0 +1,51 @@ +"""App runner configuration at ``~/.ria/toolkit.json``. + +Schema:: + + { + "registry": "registry.riahub.ai", + "namespace": "qoherent" + } +""" + +from __future__ import annotations + +import json +import os +from dataclasses import asdict, dataclass +from pathlib import Path + +_DEFAULT_PATH = Path(os.environ.get("RIA_TOOLKIT_CONFIG", str(Path.home() / ".ria" / "toolkit.json"))) + + +@dataclass +class AppConfig: + registry: str = "" + namespace: str = "" + sudo: bool = False + + +def default_path() -> Path: + return _DEFAULT_PATH + + +def load(path: Path | None = None) -> AppConfig: + p = path or _DEFAULT_PATH + if not p.exists(): + return AppConfig( + registry=os.environ.get("RIA_REGISTRY", ""), + namespace=os.environ.get("RIA_NAMESPACE", ""), + ) + data = json.loads(p.read_text()) + return AppConfig( + registry=data.get("registry", "") or os.environ.get("RIA_REGISTRY", ""), + namespace=data.get("namespace", "") or os.environ.get("RIA_NAMESPACE", ""), + sudo=bool(data.get("sudo", False)) or os.environ.get("RIA_DOCKER_SUDO", "") not in ("", "0", "false"), + ) + + +def save(cfg: AppConfig, path: Path | None = None) -> Path: + p = path or _DEFAULT_PATH + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(asdict(cfg), indent=2)) + return p diff --git a/src/ria_toolkit_oss/data/__init__.py b/src/ria_toolkit_oss/data/__init__.py new file mode 100644 index 0000000..b72f469 --- /dev/null +++ b/src/ria_toolkit_oss/data/__init__.py @@ -0,0 +1,8 @@ +""" +The Data package contains abstract data types tailored for radio machine learning, such as ``Recording``, as well +as the abstract interfaces for the radio dataset and radio dataset builder framework. +""" + +__all__ = ["Annotation", "Recording"] +from .annotation import Annotation +from .recording import Recording diff --git a/src/ria_toolkit_oss/data/annotation.py b/src/ria_toolkit_oss/data/annotation.py new file mode 100644 index 0000000..1182480 --- /dev/null +++ b/src/ria_toolkit_oss/data/annotation.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +from typing import Any, Optional + +from sigmf import SigMFFile + + +class Annotation: + """Signal annotations are labels or additional information associated with specific data points or segments within + a signal. These annotations could be used for tasks like supervised learning, where the goal is to train a model + to recognize patterns or characteristics in the signal associated with these annotations. + + Annotations can be used to label interesting points in your recording. + + :param sample_start: The index of the starting sample of the annotation. + :type sample_start: int + :param sample_count: The index of the ending sample of the annotation, inclusive. + :type sample_count: int + :param freq_lower_edge: The lower frequency of the annotation. + :type freq_lower_edge: float + :param freq_upper_edge: The upper frequency of the annotation. + :type freq_upper_edge: float + :param label: The label that will be displayed with the bounding box in compatible viewers including IQEngine. + Defaults to an emtpy string. + :type label: str, optional + :param comment: A human-readable comment. Defaults to an empty string. + :type comment: str, optional + :param detail: A dictionary of user defined annotation-specific metadata. Defaults to None. + :type detail: dict, optional + """ + + def __init__( + self, + sample_start: int, + sample_count: int, + freq_lower_edge: float, + freq_upper_edge: float, + label: Optional[str] = "", + comment: Optional[str] = "", + detail: Optional[dict] = None, + ): + """Initialize a new Annotation instance.""" + self.sample_start = int(sample_start) + self.sample_count = int(sample_count) + self.freq_lower_edge = float(freq_lower_edge) + self.freq_upper_edge = float(freq_upper_edge) + self.label = str(label) + self.comment = str(comment) + + if detail is None: + self.detail = {} + elif not _is_jsonable(detail): + raise ValueError(f"Detail object is not json serializable: {detail}") + else: + self.detail = detail + + def is_valid(self) -> bool: + """ + Check that the annotation sample count is > 0 and the freq_lower_edge 0 and self.freq_lower_edge < self.freq_upper_edge + + def overlap(self, other): + """ + Quantify how much the bounding box in this annotation overlaps with another annotation. + + :param other: The other annotation. + :type other: Annotation + + :returns: The area of the overlap in samples*frequency, or 0 if they do not overlap.""" + + sample_overlap_start = max(self.sample_start, other.sample_start) + sample_overlap_end = min(self.sample_start + self.sample_count, other.sample_start + other.sample_count) + + freq_overlap_start = max(self.freq_lower_edge, other.freq_lower_edge) + freq_overlap_end = min(self.freq_upper_edge, other.freq_upper_edge) + + if freq_overlap_start >= freq_overlap_end or sample_overlap_start >= sample_overlap_end: + return 0 + else: + return (sample_overlap_end - sample_overlap_start) * (freq_overlap_end - freq_overlap_start) + + def area(self): + """ + The 'area' of the bounding box, samples*frequency. + Useful to quantify annotation size. + + :returns: sample length multiplied by bandwidth.""" + + return self.sample_count * (self.freq_upper_edge - self.freq_lower_edge) + + def __eq__(self, other: Annotation) -> bool: + return self.__dict__ == other.__dict__ + + def to_sigmf_format(self): + """ + Returns a JSON dictionary representing this annotation formatted to be saved in a .sigmf-meta file. + """ + + annotation_dict = {SigMFFile.START_INDEX_KEY: self.sample_start, SigMFFile.LENGTH_INDEX_KEY: self.sample_count} + + annotation_dict["metadata"] = { + SigMFFile.LABEL_KEY: self.label, + SigMFFile.COMMENT_KEY: self.comment, + SigMFFile.FHI_KEY: self.freq_upper_edge, + SigMFFile.FLO_KEY: self.freq_lower_edge, + "ria:detail": self.detail, + } + + if _is_jsonable(annotation_dict): + return annotation_dict + else: + raise ValueError("Annotation dictionary was not json serializable.") + + +def _is_jsonable(x: Any) -> bool: + """ + :return: True if x is JSON serializable, False otherwise. + """ + try: + json.dumps(x) + return True + except (TypeError, OverflowError): + return False diff --git a/src/ria_toolkit_oss/data/recording.py b/src/ria_toolkit_oss/data/recording.py new file mode 100644 index 0000000..20939bd --- /dev/null +++ b/src/ria_toolkit_oss/data/recording.py @@ -0,0 +1,853 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import os +import re +import time +import warnings +from typing import Any, Iterator, Optional + +import numpy as np +from numpy.typing import ArrayLike + +from ria_toolkit_oss.datatypes.annotation import Annotation + +PROTECTED_KEYS = ["rec_id", "timestamp"] + + +class Recording: + """Tape of complex IQ (in-phase and quadrature) samples with associated metadata and annotations. + + Recording data is a complex array of shape C x N, where C is the number of channels + and N is the number of samples in each channel. + + Metadata is stored in a dictionary of key value pairs, + to include information such as sample_rate and center_frequency. + + Annotations are a list of :ref:`Annotation `, + defining bounding boxes in time and frequency with labels and metadata. + + Here, signal data is represented as a NumPy array. This class is then extended in the RIA Backends to provide + support for different data structures, such as Tensors. + + Recordings are long-form tapes can be obtained either from a software-defined radio (SDR) or generated + synthetically. Then, machine learning datasets are curated from collection of recordings by segmenting these + longer-form tapes into shorter units called slices. + + All recordings are assigned a unique 64-character recording ID, ``rec_id``. If this field is missing from the + provided metadata, a new ID will be generated upon object instantiation. + + :param data: Signal data as a tape IQ samples, either C x N complex, where C is the number of + channels and N is number of samples in the signal. If data is a one-dimensional array of complex samples with + length N, it will be reshaped to a two-dimensional array with dimensions 1 x N. + :type data: array_like + + :param metadata: Additional information associated with the recording. + :type metadata: dict, optional + :param annotations: A collection of ``Annotation`` objects defining bounding boxes. + :type annotations: list of Annotations, optional + + :param dtype: Explicitly specify the data-type of the complex samples. Must be a complex NumPy type, such as + ``np.complex64`` or ``np.complex128``. Default is None, in which case the type is determined implicitly. If + ``data`` is a NumPy array, the Recording will use the dtype of ``data`` directly without any conversion. + :type dtype: numpy dtype object, optional + :param timestamp: The timestamp when the recording data was generated. If provided, it should be a float or integer + representing the time in seconds since epoch (e.g., ``time.time()``). Only used if the `timestamp` field is not + present in the provided metadata. + :type dtype: float or int, optional + + :raises ValueError: If data is not complex 1xN or CxN. + :raises ValueError: If metadata is not a python dict. + :raises ValueError: If metadata is not json serializable. + :raises ValueError: If annotations is not a list of valid annotation objects. + + **Examples:** + + >>> import numpy + >>> from ria_toolkit_oss.datatypes import Recording, Annotation + + >>> # Create an array of complex samples, just 1s in this case. + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + + >>> # Create a dictionary of relevant metadata. + >>> sample_rate = 1e6 + >>> center_frequency = 2.44e9 + >>> metadata = { + ... "sample_rate": sample_rate, + ... "center_frequency": center_frequency, + ... "author": "me", + ... } + + >>> # Create an annotation for the annotations list. + >>> annotations = [ + ... Annotation( + ... sample_start=0, + ... sample_count=1000, + ... freq_lower_edge=center_frequency - (sample_rate / 2), + ... freq_upper_edge=center_frequency + (sample_rate / 2), + ... label="example", + ... ) + ... ] + + >>> # Store samples, metadata, and annotations together in a convenient object. + >>> recording = Recording(data=samples, metadata=metadata, annotations=annotations) + >>> print(recording.metadata) + {'sample_rate': 1000000.0, 'center_frequency': 2440000000.0, 'author': 'me'} + >>> print(recording.annotations[0].label) + 'example' + """ + + def __init__( # noqa C901 + self, + data: ArrayLike | list[list], + metadata: Optional[dict[str, any]] = None, + dtype: Optional[np.dtype] = None, + timestamp: Optional[float | int] = None, + annotations: Optional[list[Annotation]] = None, + ): + + data_arr = np.asarray(data) + + if np.iscomplexobj(data_arr): + # Expect C x N + if data_arr.ndim == 1: + self._data = np.expand_dims(data_arr, axis=0) # N -> 1 x N + elif data_arr.ndim == 2: + self._data = data_arr + else: + raise ValueError("Complex data must be C x N.") + + else: + raise ValueError("Input data must be complex.") + + if dtype is not None: + self._data = self._data.astype(dtype) + + assert np.iscomplexobj(self._data) + + if metadata is None: + self._metadata = {} + elif isinstance(metadata, dict): + self._metadata = metadata + else: + raise ValueError(f"Metadata must be a python dict, but was {type(metadata)}.") + + if not _is_jsonable(metadata): + raise ValueError("Value must be JSON serializable.") + + if "timestamp" not in self.metadata: + if timestamp is not None: + if not isinstance(timestamp, (int, float)): + raise ValueError(f"timestamp must be int or float, not {type(timestamp)}") + self._metadata["timestamp"] = timestamp + else: + self._metadata["timestamp"] = time.time() + else: + if not isinstance(self._metadata["timestamp"], (int, float)): + raise ValueError("timestamp must be int or float, not ", type(self._metadata["timestamp"])) + + if "rec_id" not in self.metadata: + self._metadata["rec_id"] = generate_recording_id(data=self.data, timestamp=self._metadata["timestamp"]) + + if annotations is None: + self._annotations = [] + elif isinstance(annotations, list): + self._annotations = annotations + else: + raise ValueError("Annotations must be a list or None.") + + if not all(isinstance(annotation, Annotation) for annotation in self._annotations): + raise ValueError("All elements in self._annotations must be of type Annotation.") + + self._index = 0 + + @property + def data(self) -> np.ndarray: + """ + :return: Recording data, as a complex array. + :type: np.ndarray + + .. note:: + + For recordings with more than 1,024 samples, this property returns a read-only view of the data. + + .. note:: + + To access specific samples, consider indexing the object directly with ``rec[c, n]``. + """ + if self._data.size > 1024: + # Returning a read-only view prevents mutation at a distance while maintaining performance. + v = self._data.view() + v.setflags(write=False) + return v + else: + return self._data.copy() + + @property + def metadata(self) -> dict: + """ + :return: Dictionary of recording metadata. + :type: dict + """ + return self._metadata.copy() + + @property + def annotations(self) -> list[Annotation]: + """ + :return: List of recording annotations + :type: list of Annotation objects + """ + return self._annotations.copy() + + @property + def shape(self) -> tuple[int]: + """ + :return: The shape of the data array. + :type: tuple of ints + """ + return np.shape(self.data) + + @property + def n_chan(self) -> int: + """ + :return: The number of channels in the recording. + :type: int + """ + return self.shape[0] + + @property + def rec_id(self) -> str: + """ + :return: Recording ID. + :type: str + """ + return self.metadata["rec_id"] + + @property + def dtype(self) -> str: + """ + :return: Data-type of the data array's elements. + :type: numpy dtype object + """ + return self.data.dtype + + @property + def timestamp(self) -> float | int: + """ + :return: Recording timestamp (time in seconds since epoch). + :type: float or int + """ + return self.metadata["timestamp"] + + @property + def sample_rate(self) -> float | None: + """ + :return: Sample rate of the recording, or None if 'sample_rate' is not in metadata. + :type: str + """ + return self.metadata.get("sample_rate") + + @sample_rate.setter + def sample_rate(self, sample_rate: float | int) -> None: + """Set the sample rate of the recording. + + :param sample_rate: The sample rate of the recording. + :type sample_rate: float or int + + :return: None + """ + self.add_to_metadata(key="sample_rate", value=sample_rate) + + def astype(self, dtype: np.dtype) -> Recording: + """Copy of the recording, data cast to a specified type. + + .. todo: This method is not yet implemented. + + :param dtype: Data-type to which the array is cast. Must be a complex scalar type, such as ``np.complex64`` or + ``np.complex128``. + :type dtype: NumPy data type, optional + + .. note: Casting to a data type with less precision can risk losing data by truncating or rounding values, + potentially resulting in a loss of accuracy and significant information. + + :return: A new recording with the same metadata and data, with dtype. + + TODO: Add example usage. + """ + # Rather than check for a valid datatype, let's cast and check the result. This makes it easier to provide + # cross-platform support where the types are aliased across platforms. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # Casting may generate user warnings. E.g., complex -> real + data = self.data.astype(dtype) + + if np.iscomplexobj(data): + return Recording(data=data, metadata=self.metadata, annotations=self.annotations) + else: + raise ValueError("dtype must be a complex number scalar type.") + + def add_to_metadata(self, key: str, value: Any) -> None: + """Add a new key-value pair to the recording metadata. + + :param key: New metadata key, must be snake_case. + :type key: str + :param value: Corresponding metadata value. + :type value: any + + :raises ValueError: If key is already in metadata or if key is not a valid metadata key. + :raises ValueError: If value is not JSON serializable. + + :return: None. + + **Examples:** + + Create a recording and add metadata: + + >>> import numpy + >>> from ria_toolkit_oss.datatypes import Recording + >>> + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + >>> + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'timestamp': 17369..., + 'rec_id': 'fda0f41...'} + >>> + >>> recording.add_to_metadata(key="author", value="me") + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'author': 'me', + 'timestamp': 17369..., + 'rec_id': 'fda0f41...'} + """ + if key in self.metadata: + raise ValueError( + f"Key {key} already in metadata. Use Recording.update_metadata() to modify existing fields." + ) + + if not _is_valid_metadata_key(key): + raise ValueError(f"Invalid metadata key: {key}.") + + if not _is_jsonable(value): + raise ValueError("Value must be JSON serializable.") + + self._metadata[key] = value + + def update_metadata(self, key: str, value: Any) -> None: + """Update the value of an existing metadata key, + or add the key value pair if it does not already exist. + + :param key: Existing metadata key. + :type key: str + :param value: New value to enter at key. + :type value: any + + :raises ValueError: If value is not JSON serializable + :raises ValueError: If key is protected. + + :return: None. + + **Examples:** + + Create a recording and update metadata: + + >>> import numpy + >>> from ria_toolkit_oss.datatypes import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> "author": "me" + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'author': "me", + 'timestamp': 17369... + 'rec_id': 'fda0f41...'} + + >>> recording.update_metadata(key="author", value=you") + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'author': "you", + 'timestamp': 17369... + 'rec_id': 'fda0f41...'} + """ + if key not in self.metadata: + self.add_to_metadata(key=key, value=value) + + if not _is_jsonable(value): + raise ValueError("Value must be JSON serializable.") + + if key in PROTECTED_KEYS: # Check protected keys. + raise ValueError(f"Key {key} is protected and cannot be modified or removed.") + + else: + self._metadata[key] = value + + def remove_from_metadata(self, key: str): + """ + Remove a key from the recording metadata. + Does not remove key if it is protected. + + :param key: The key to remove. + :type key: str + + :raises ValueError: If key is protected. + + :return: None. + + **Examples:** + + Create a recording and add metadata: + + >>> import numpy + >>> from ria_toolkit_oss.datatypes import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'timestamp': 17369..., # Example value + 'rec_id': 'fda0f41...'} # Example value + + >>> recording.add_to_metadata(key="author", value="me") + >>> print(recording.metadata) + {'sample_rate': 1000000.0, + 'center_frequency': 2440000000.0, + 'author': 'me', + 'timestamp': 17369..., # Example value + 'rec_id': 'fda0f41...'} # Example value + """ + if key not in PROTECTED_KEYS: + self._metadata.pop(key) + else: + raise ValueError(f"Key {key} is protected and cannot be modified or removed.") + + def view(self, output_path: Optional[str] = "images/signal.png", **kwargs) -> None: + """Create a plot of various signal visualizations as a PNG image. + + :param output_path: The output image path. Defaults to "images/signal.png". + :type output_path: str, optional + :param kwargs: Keyword arguments passed on to utils.view.view_sig. + :type: dict of keyword arguments + + **Examples:** + + Create a recording and view it as a plot in a .png image: + + >>> import numpy + >>> from ria_toolkit_oss.datatypes import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.view() + """ + from ria_toolkit_oss.view import view_sig + + view_sig(recording=self, output_path=output_path, **kwargs) + + def simple_view(self, **kwargs) -> None: + """Create a plot of various signal visualizations as a PNG or SVG image. + + :param kwargs: Keyword arguments passed on to ria_toolkit_oss.view.view_signal_simple.create_plots. + :type: dict of keyword arguments + + **Examples:** + + Create a recording and view it as a plot in a .png image: + + >>> import numpy + >>> from ria_toolkit_oss.datatypes import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.simple_view() + """ + from ria_toolkit_oss.view.view_signal_simple import view_simple_sig + + view_simple_sig(recording=self, **kwargs) + + def to_sigmf( + self, filename: Optional[str] = None, path: Optional[os.PathLike | str] = None, overwrite: bool = False + ) -> None: + """Write recording to a set of SigMF files. + + The SigMF io format is defined by the `SigMF Specification Project `_ + + :param recording: The recording to be written to file. + :type recording: utils.data.Recording + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: None + + **Examples:** + + Create a recording and view it as a plot in a `.png` image: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.view() + """ + from ria_toolkit_oss.io.recording import to_sigmf + + to_sigmf(filename=filename, path=path, recording=self, overwrite=overwrite) + + def to_npy( + self, filename: Optional[str] = None, path: Optional[os.PathLike | str] = None, overwrite: bool = False + ) -> str: + """Write recording to ``.npy`` binary file. + + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: Path where the file was saved. + :rtype: str + + **Examples:** + + Create a recording and save it to a .npy file: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.to_npy() + """ + from ria_toolkit_oss.io.recording import to_npy + + to_npy(recording=self, filename=filename, path=path, overwrite=overwrite) + + def to_wav( + self, + filename: Optional[str] = None, + path: Optional[os.PathLike | str] = None, + target_sample_rate: Optional[int] = 48000, + bits_per_sample: int = 32, + overwrite: bool = False, + ) -> str: + """Write recording to WAV file with embedded YAML metadata. + + WAV format uses stereo audio with I (in-phase) in left channel and Q (quadrature) in right channel. + Metadata is stored in standard LIST INFO chunks with RF-specific metadata encoded as YAML + in the ICMT (comment) field for human readability. + + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + :param target_sample_rate: Sample rate stored in the WAV header when no sample_rate metadata + is present. IQ samples are written without decimation or interpolation. Default is 48000 Hz. + :type target_sample_rate: int, optional + :param bits_per_sample: Bits per sample (32 for float32, 16 for int16). Default is 32. + :type bits_per_sample: int, optional + :param overwrite: Whether to overwrite existing files. Default is False. + :type overwrite: bool, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: Path where the file was saved. + :rtype: str + + **Examples:** + + Create a recording and save it to a .wav file: + + >>> import numpy + >>> from utils.data import Recording + >>> samples = numpy.exp(1j * 2 * numpy.pi * 0.1 * numpy.arange(10000)) + >>> metadata = {"sample_rate": 1e6, "center_frequency": 915e6} + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.to_wav() + """ + from ria_toolkit_oss.io.recording import to_wav + + return to_wav( + recording=self, + filename=filename, + path=path, + target_sample_rate=target_sample_rate, + bits_per_sample=bits_per_sample, + overwrite=overwrite, + ) + + def to_blue( + self, + filename: Optional[str] = None, + path: Optional[os.PathLike | str] = None, + data_format: str = "CI", + overwrite: bool = False, + ) -> str: + """Write recording to MIDAS Blue file format. + + MIDAS Blue is a legacy RF file format with a 512-byte binary header. + Commonly used with X-Midas and other RF/radar signal processing tools. + + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + :param data_format: Format code (default 'CI' = complex int16). + Common formats: 'CI' (complex int16), 'CF' (complex float32), 'CD' (complex float64). + Integer formats require the IQ samples to already be scaled within [-1, 1). + :type data_format: str, optional + :param overwrite: Whether to overwrite existing files. Default is False. + :type overwrite: bool, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: Path where the file was saved. + :rtype: str + + **Examples:** + + Create a recording and save it to a .blue file: + + >>> import numpy + >>> from utils.data import Recording + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = {"sample_rate": 1e6, "center_frequency": 2.44e9} + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.to_blue() + """ + from ria_toolkit_oss.io.recording import to_blue + + return to_blue(recording=self, filename=filename, path=path, data_format=data_format, overwrite=overwrite) + + def trim(self, num_samples: int, start_sample: Optional[int] = 0) -> Recording: + """Trim Recording samples to a desired length, shifting annotations to maintain alignment. + + :param start_sample: The start index of the desired trimmed recording. Defaults to 0. + :type start_sample: int, optional + :param num_samples: The number of samples that the output trimmed recording will have. + :type num_samples: int + :raises IndexError: If start_sample + num_samples is greater than the length of the recording. + :raises IndexError: If sample_start < 0 or num_samples < 0. + + :return: The trimmed Recording. + :rtype: Recording + + **Examples:** + + Create a recording and trim it: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(len(recording)) + 10000 + + >>> trimmed_recording = recording.trim(start_sample=1000, num_samples=1000) + >>> print(len(trimmed_recording)) + 1000 + """ + + if start_sample < 0: + raise IndexError("start_sample cannot be < 0.") + elif start_sample + num_samples > len(self): + raise IndexError( + f"start_sample {start_sample} + num_samples {num_samples} > recording length {len(self)}." + ) + + end_sample = start_sample + num_samples + + data = self.data[:, start_sample:end_sample] + + new_annotations = copy.deepcopy(self.annotations) + for annotation in new_annotations: + # trim annotation if it goes outside the trim boundaries + if annotation.sample_start < start_sample: + annotation.sample_count = annotation.sample_count - (start_sample - annotation.sample_start) + annotation.sample_start = start_sample + + if annotation.sample_start + annotation.sample_count > end_sample: + annotation.sample_count = end_sample - annotation.sample_start + + # shift annotation to align with the new start point + annotation.sample_start = annotation.sample_start - start_sample + + return Recording(data=data, metadata=self.metadata, annotations=new_annotations) + + def normalize(self) -> Recording: + """Scale the recording data, relative to its maximum value, so that the magnitude of the maximum sample is 1. + + :return: Recording where the maximum sample amplitude is 1. + :rtype: Recording + + **Examples:** + + Create a recording with maximum amplitude 0.5 and normalize to a maximum amplitude of 1: + + >>> import numpy + >>> from utils.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) * 0.5 + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(numpy.max(numpy.abs(recording.data))) + 0.5 + + >>> normalized_recording = recording.normalize() + >>> print(numpy.max(numpy.abs(normalized_recording.data))) + 1 + """ + scaled_data = self.data / np.max(abs(self.data)) + return Recording(data=scaled_data, metadata=self.metadata, annotations=self.annotations) + + def __len__(self) -> int: + """The length of a recording is defined by the number of complex samples in each channel of the recording.""" + return self.shape[1] + + def __eq__(self, other: Recording) -> bool: + """Two Recordings are equal if all data, metadata, and annotations are the same.""" + + # counter used to allow for differently ordered annotation lists + return ( + np.array_equal(self.data, other.data) + and self.metadata == other.metadata + and self.annotations == other.annotations + ) + + def __ne__(self, other: Recording) -> bool: + """Two Recordings are equal if all data, and metadata, and annotations are the same.""" + return not self.__eq__(other=other) + + def __iter__(self) -> Iterator: + self._index = 0 + return self + + def __next__(self) -> np.ndarray: + if self._index < self.n_chan: + to_ret = self.data[self._index] + self._index += 1 + return to_ret + else: + raise StopIteration + + def __getitem__(self, key: int | tuple[int] | slice) -> np.ndarray | np.complexfloating: + """If key is an integer, tuple of integers, or a slice, return the corresponding samples. + + For arrays with 1,024 or fewer samples, return a copy of the recording data. For larger arrays, return a + read-only view. This prevents mutation at a distance while maintaining performance. + """ + if isinstance(key, (int, tuple, slice)): + v = self._data[key] + if isinstance(v, np.complexfloating): + return v + elif v.size > 1024: + v.setflags(write=False) # Make view read-only. + return v + else: + return v.copy() + + else: + raise ValueError(f"Key must be an integer, tuple, or slice but was {type(key)}.") + + def __setitem__(self, *args, **kwargs) -> None: + """Raise an error if an attempt is made to assign to the recording.""" + raise ValueError("Assignment to Recording is not allowed.") + + +def generate_recording_id(data: np.ndarray, timestamp: Optional[float | int] = None) -> str: + """Generate unique 64-character recording ID. The recording ID is generated by hashing the recording data with + the datetime that the recording data was generated. If no datatime is provided, the current datatime is used. + + :param data: Tape of IQ samples, as a NumPy array. + :type data: np.ndarray + :param timestamp: Unix timestamp in seconds. Defaults to None. + :type timestamp: float or int, optional + + :return: 256-character hash, to be used as the recording ID. + :rtype: str + """ + if timestamp is None: + timestamp = time.time() + + byte_sequence = data.tobytes() + str(timestamp).encode("utf-8") + sha256_hash = hashlib.sha256(byte_sequence) + + return sha256_hash.hexdigest() + + +def _is_jsonable(x: Any) -> bool: + """ + :return: True if x is JSON serializable, False otherwise. + """ + try: + json.dumps(x) + return True + except (TypeError, OverflowError): + return False + + +def _is_valid_metadata_key(key: Any) -> bool: + """ + :return: True if key is a valid metadata key, False otherwise. + """ + if isinstance(key, str) and key.islower() and re.match(pattern=r"^[a-z_]+$", string=key) is not None: + return True + + else: + return False diff --git a/src/ria_toolkit_oss/io/recording.py b/src/ria_toolkit_oss/io/recording.py index ae38bc8..1a81a04 100644 --- a/src/ria_toolkit_oss/io/recording.py +++ b/src/ria_toolkit_oss/io/recording.py @@ -367,9 +367,7 @@ def to_sigmf( meta_dict = sigMF_metafile.ordered_metadata() meta_dict["ria"] = metadata - if overwrite and os.path.isfile(meta_file_path): - os.remove(meta_file_path) - sigMF_metafile.tofile(meta_file_path) + sigMF_metafile.tofile(meta_file_path, overwrite=overwrite) def from_sigmf(file: os.PathLike | str) -> Recording: diff --git a/src/ria_toolkit_oss/orchestration/campaign.py b/src/ria_toolkit_oss/orchestration/campaign.py index 9d96c96..027c33f 100644 --- a/src/ria_toolkit_oss/orchestration/campaign.py +++ b/src/ria_toolkit_oss/orchestration/campaign.py @@ -223,13 +223,16 @@ class TransmitterConfig: id: str type: str # "wifi", "bluetooth", "sdr", "external" - control_method: str # "external_script" | "sdr" + control_method: str # "external_script" | "sdr" | "sdr_remote" schedule: list[CaptureStep] # For external_script control script: Optional[str] = None # path to control script device: Optional[str] = None # e.g. "/dev/wlan0" + # For sdr_remote control — keys: host, ssh_user, ssh_key_path, device_type, device_id, zmq_port + sdr_remote: Optional[dict] = None + @classmethod def from_dict(cls, d: dict) -> "TransmitterConfig": schedule = [CaptureStep.from_dict(s) for s in d.get("schedule", [])] @@ -240,6 +243,7 @@ class TransmitterConfig: schedule=schedule, script=d.get("script"), device=d.get("device"), + sdr_remote=d.get("sdr_remote"), ) diff --git a/src/ria_toolkit_oss/orchestration/executor.py b/src/ria_toolkit_oss/orchestration/executor.py index 629c0d8..1bdd4d8 100644 --- a/src/ria_toolkit_oss/orchestration/executor.py +++ b/src/ria_toolkit_oss/orchestration/executor.py @@ -196,6 +196,7 @@ class CampaignExecutor: self.config = config self.progress_cb = progress_cb self._sdr = None + self._remote_tx_controllers: dict = {} if verbose: logging.basicConfig(level=logging.DEBUG) @@ -222,6 +223,7 @@ class CampaignExecutor: ) self._init_sdr() + self._init_remote_tx_controllers() try: total = self.config.total_steps() step_index = 0 @@ -248,6 +250,7 @@ class CampaignExecutor: ) finally: self._close_sdr() + self._close_remote_tx_controllers() result.end_time = time.time() logger.info( @@ -287,6 +290,41 @@ class CampaignExecutor: logger.warning(f"SDR close error: {e}") self._sdr = None + # ------------------------------------------------------------------ + # Remote Tx controller management + # ------------------------------------------------------------------ + + def _init_remote_tx_controllers(self) -> None: + """Open SSH+ZMQ connections for all sdr_remote transmitters.""" + from ria_toolkit_oss.remote_control import RemoteTransmitterController + + for tx in self.config.transmitters: + if tx.control_method != "sdr_remote": + continue + cfg = tx.sdr_remote + if not cfg: + raise RuntimeError(f"Transmitter '{tx.id}' uses sdr_remote but has no sdr_remote config") + logger.info(f"Connecting remote Tx controller for {tx.id} → {cfg['host']}") + ctrl = RemoteTransmitterController( + host=cfg["host"], + ssh_user=cfg["ssh_user"], + ssh_key_path=cfg["ssh_key_path"], + zmq_port=int(cfg.get("zmq_port", 5556)), + ) + ctrl.set_radio( + device_type=cfg["device_type"], + device_id=cfg.get("device_id", ""), + ) + self._remote_tx_controllers[tx.id] = ctrl + + def _close_remote_tx_controllers(self) -> None: + for tx_id, ctrl in list(self._remote_tx_controllers.items()): + try: + ctrl.close() + except Exception as exc: + logger.warning(f"Error closing remote Tx controller {tx_id}: {exc}") + self._remote_tx_controllers.clear() + def _record(self, duration_s: float) -> Recording: """Capture ``duration_s`` seconds of IQ samples.""" num_samples = int(duration_s * self.config.recorder.sample_rate) @@ -372,7 +410,8 @@ class CampaignExecutor: traffic, etc. The script is responsible for applying the configuration and returning promptly (i.e. not blocking for the capture duration). - For SDR transmitters this is a no-op placeholder (TX not yet implemented). + For ``sdr_remote`` the remote ZMQ controller calls ``init_tx`` then + starts a background transmit thread that runs for the step duration. """ if transmitter.control_method == "external_script": if not transmitter.script: @@ -384,6 +423,20 @@ class CampaignExecutor: elif transmitter.control_method == "sdr": logger.debug("SDR TX not yet implemented — skipping start") + elif transmitter.control_method == "sdr_remote": + ctrl = self._remote_tx_controllers.get(transmitter.id) + if ctrl is None: + raise RuntimeError(f"No remote Tx controller found for transmitter '{transmitter.id}'") + gain = step.power_dbm if step.power_dbm is not None else 0.0 + ctrl.init_tx( + center_frequency=self.config.recorder.center_freq, + sample_rate=self.config.recorder.sample_rate, + gain=gain, + channel=step.channel or 0, + ) + # Start transmission in background; _record() runs concurrently + ctrl.transmit_async(step.duration + 1.0) + else: logger.warning(f"Unknown control method '{transmitter.control_method}' — skipping") @@ -391,6 +444,7 @@ class CampaignExecutor: """Signal the transmitter to stop. Calls ``