Compare commits

..

41 Commits

Author SHA1 Message Date
3295b285f4 Merge pull request 'release/v0.1.6' (#32) from release/v0.1.6 into main
Some checks failed
Build Sphinx Docs Set / Build Docs (push) Successful in 43s
Test with tox / Test with tox (3.11) (push) Successful in 4m59s
Build Project / Build Project (3.10) (push) Successful in 8m8s
Build Project / Build Project (3.12) (push) Successful in 9m53s
Build Project / Build Project (3.11) (push) Successful in 11m19s
Test with tox / Test with tox (3.10) (push) Failing after 16m23s
Test with tox / Test with tox (3.12) (push) Successful in 16m18s
Reviewed-on: #32
2026-05-12 15:16:28 -04:00
84400b53c4 Updated year to 2026
Some checks failed
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 24s
Test with tox / Test with tox (3.11) (pull_request) Successful in 14m50s
Build Project / Build Project (3.10) (pull_request) Successful in 17m40s
Test with tox / Test with tox (3.10) (pull_request) Failing after 17m42s
Build Project / Build Project (3.11) (pull_request) Successful in 18m12s
Build Project / Build Project (3.12) (pull_request) Successful in 18m10s
Test with tox / Test with tox (3.12) (pull_request) Successful in 18m49s
2026-05-12 14:44:24 -04:00
98037a0d16 Bump version to 0.1.6 2026-05-12 14:40:17 -04:00
c15b79b43f Merge pull request 'docs: improve getting_started and installation readability' (#30) from docs/sdr-guides-update into main
Some checks failed
Build Sphinx Docs Set / Build Docs (push) Successful in 36s
Build Project / Build Project (3.10) (push) Successful in 13m31s
Build Project / Build Project (3.12) (push) Successful in 13m29s
Build Project / Build Project (3.11) (push) Successful in 13m35s
Test with tox / Test with tox (3.11) (push) Successful in 14m39s
Test with tox / Test with tox (3.12) (push) Successful in 14m23s
Test with tox / Test with tox (3.10) (push) Failing after 14m56s
Reviewed-on: #30
Reviewed-by: madrigal <madrigal@qoherent.ai>
2026-05-12 14:05:24 -04:00
7ef3fe8fb1 Revert "Drop Python 3.10 support, minimum is now 3.11"
Some checks failed
Test with tox / Test with tox (3.11) (pull_request) Successful in 15m58s
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 18m3s
Build Project / Build Project (3.10) (pull_request) Successful in 21m51s
Test with tox / Test with tox (3.10) (pull_request) Failing after 21m59s
Build Project / Build Project (3.11) (pull_request) Successful in 22m37s
Build Project / Build Project (3.12) (pull_request) Successful in 22m35s
Test with tox / Test with tox (3.12) (pull_request) Successful in 6m53s
This reverts commit 70c790cadd.
2026-05-12 13:39:05 -04:00
57d1d6e55e Revert "Fix duplicate 3.11 key in tox gh-actions config"
This reverts commit 657dd0d499.
2026-05-12 13:39:05 -04:00
657dd0d499 Fix duplicate 3.11 key in tox gh-actions config
Some checks failed
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 32s
Test with tox / Test with tox (3.11) (pull_request) Failing after 39s
Test with tox / Test with tox (3.12) (pull_request) Failing after 20s
Build Project / Build Project (3.12) (pull_request) Successful in 1m16s
Build Project / Build Project (3.11) (pull_request) Successful in 1m18s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:32:52 -04:00
70c790cadd Drop Python 3.10 support, minimum is now 3.11
Some checks failed
Test with tox / Test with tox (3.11) (pull_request) Failing after 6m28s
Test with tox / Test with tox (3.12) (pull_request) Failing after 6m27s
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 10m49s
Build Project / Build Project (3.11) (pull_request) Successful in 11m27s
Build Project / Build Project (3.12) (pull_request) Successful in 11m29s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:22:08 -04:00
F fordg1
f23bac08a1 Fix flake8 E501 and C901 violations in annotate.py and annotation classes
Some checks failed
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 48s
Build Project / Build Project (3.12) (pull_request) Successful in 4m10s
Build Project / Build Project (3.10) (pull_request) Successful in 4m24s
Build Project / Build Project (3.11) (pull_request) Successful in 4m29s
Test with tox / Test with tox (3.11) (pull_request) Successful in 4m23s
Test with tox / Test with tox (3.10) (pull_request) Failing after 4m44s
Test with tox / Test with tox (3.12) (pull_request) Successful in 4m45s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:10:40 -04:00
F fordg1
3b8b55ae7a Fix flake8 E501 line too long in annotate.py separate() signature
Some checks failed
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 1m43s
Build Project / Build Project (3.10) (pull_request) Successful in 23m32s
Build Project / Build Project (3.12) (pull_request) Successful in 23m41s
Test with tox / Test with tox (3.10) (pull_request) Failing after 23m43s
Build Project / Build Project (3.11) (pull_request) Successful in 23m50s
Test with tox / Test with tox (3.11) (pull_request) Successful in 24m31s
Test with tox / Test with tox (3.12) (pull_request) Successful in 23m9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:19:00 -04:00
F fordg1
c2dc2e6d43 docs: add generated viewer images for getting_started examples
Some checks failed
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 23m0s
Test with tox / Test with tox (3.11) (pull_request) Successful in 29m19s
Build Project / Build Project (3.10) (pull_request) Successful in 30m13s
Build Project / Build Project (3.12) (pull_request) Successful in 30m17s
Build Project / Build Project (3.11) (pull_request) Successful in 30m24s
Test with tox / Test with tox (3.12) (pull_request) Successful in 8m30s
Test with tox / Test with tox (3.10) (pull_request) Failing after 5m13s
2026-05-05 14:35:41 -04:00
F fordg1
18666d95ee docs: expand getting_started with real command output, examples, and images
- Add example output for every section 4 command (discover, init, capture,
  view, annotate, convert, split, combine, generate, transform, transmit)
- Add examples for all annotate subcommands (list, add, remove, clear,
  energy, threshold, cusum, separate)
- Clarify separate workflow: requires existing annotations as input;
  show threshold → separate two-step example with before/after images
- Regenerate all viewer images using updated viewer (post e5a3d32 styling)
- Add images for energy, threshold, cusum, and separate annotation views,
  AWGN transform output, and qam64_35 simple/full views
- Reorder annotate subcommands: manual first, auto-detection second
- Simplify section 3 workflow to one command per step with links to section 4
- Remove all italic inline option-group labels and redundant sub-headers
- Rewrite generate subcommand options as a table; consolidate capture and
  transmit option lists
2026-05-05 14:31:42 -04:00
04099fdbac Merge branch 'docs/sdr-guides-update' of https://riahub.ai/qoherent/ria-toolkit-oss into docs/sdr-guides-update
Some checks failed
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 1m31s
Test with tox / Test with tox (3.11) (pull_request) Successful in 12m45s
Build Project / Build Project (3.12) (pull_request) Successful in 15m4s
Test with tox / Test with tox (3.10) (pull_request) Failing after 15m7s
Build Project / Build Project (3.10) (pull_request) Successful in 15m14s
Build Project / Build Project (3.11) (pull_request) Successful in 15m17s
Test with tox / Test with tox (3.12) (pull_request) Successful in 14m32s
2026-04-30 10:31:42 -04:00
4ce42fa71a Formatting, updated lock file
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 34s
Build Project / Build Project (3.10) (pull_request) Successful in 1m42s
Build Project / Build Project (3.11) (pull_request) Successful in 1m41s
Build Project / Build Project (3.12) (pull_request) Successful in 1m46s
Test with tox / Test with tox (3.11) (pull_request) Successful in 2m47s
Test with tox / Test with tox (3.12) (pull_request) Successful in 2m32s
Test with tox / Test with tox (3.10) (pull_request) Successful in 3m1s
2026-04-29 09:55:29 -04:00
0a1bef8453 fix: harden annotation pipeline and CLI robustness
- Replace bare metadata["sample_rate"] access with .get() + clear
  ValueError in threshold_qualifier, energy_detector, cusum_annotator,
  parallel_signal_separator, and signal_isolation
- Add --sample-rate option to energy, threshold, cusum, and separate
  CLI commands with a pre-flight error if sample rate is still absent
- Normalize namespaced metadata keys (e.g. BlockGenerator:Foo:sample_rate)
  to standard keys on legacy .npy load
- Cap threshold_qualifier smoothing window at 1% of signal length to
  prevent over-smoothing short recordings into a flat envelope
- Warn when threshold or energy detector returns 0 annotations due to
  constant-envelope signal; point to cusum as the right tool
- Enforce --overwrite before any work begins; error fires before load
  and detection, not after
- Fix qualify_slice off-by-one that silently dropped the last slice
- Surface split failures in parallel_signal_separator via warnings.warn
  instead of swallowing them silently
- Add threshold annotation example image to getting_started docs
2026-04-28 16:31:35 -04:00
e5a3d327e5 refactor: unify signal viewer styling and update docs screenshots
Some checks failed
Test with tox / Test with tox (3.11) (pull_request) Successful in 3m37s
Test with tox / Test with tox (3.12) (pull_request) Successful in 3m44s
Build Project / Build Project (3.10) (pull_request) Successful in 5m55s
Build Project / Build Project (3.11) (pull_request) Successful in 5m35s
Build Project / Build Project (3.12) (pull_request) Successful in 6m27s
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 6m22s
Test with tox / Test with tox (3.10) (pull_request) Failing after 5m33s
- Align view_simple and view_full on background colour (#161616), title
  size (25pt), subtitle size (15pt), base font/tick/label sizes, grid
  style (alpha=0.2), and legend fontsize (10pt)
- Spectrogram placed above IQ plot in view_simple; subplot renamed from
  "Time Series" to "IQ Sample Plot"
- Frequency and spectrogram Y-axes formatted in MHz across both viewers
- Added xlabel/ylabel, subtle grids, and IQ legend to view_full subplots
- Fixed spectrogram right-side clipping in view_simple by syncing xlim
  from specgram output rather than total signal duration
- Updated getting_started.rst to reference both simple and full viewer
  screenshots; replaced doc images with latest renders
2026-04-28 14:08:44 -04:00
4c94f6ae94 Changed datasets to data to match utils 2026-04-28 12:49:43 -04:00
F fordg1
9a304faa00 docs: enhance getting started guide with example output and image reference
Some checks failed
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 22s
Build Project / Build Project (3.10) (pull_request) Failing after 3m19s
Test with tox / Test with tox (3.11) (pull_request) Failing after 6m16s
Test with tox / Test with tox (3.10) (pull_request) Failing after 11m32s
Build Project / Build Project (3.11) (pull_request) Successful in 12m4s
Build Project / Build Project (3.12) (pull_request) Successful in 12m2s
Test with tox / Test with tox (3.12) (pull_request) Successful in 12m26s
2026-04-28 11:27:47 -04:00
a4cd158b2a Merge branch 'main' into docs/sdr-guides-update
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 6m36s
Build Project / Build Project (3.10) (pull_request) Successful in 3m50s
Build Project / Build Project (3.12) (pull_request) Successful in 3m38s
Build Project / Build Project (3.11) (pull_request) Successful in 4m0s
Test with tox / Test with tox (3.10) (pull_request) Successful in 5m16s
Test with tox / Test with tox (3.11) (pull_request) Successful in 5m33s
Test with tox / Test with tox (3.12) (pull_request) Successful in 5m24s
2026-04-24 14:36:53 -04:00
2c1fba75da docs: improve getting_started and installation readability
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 35s
Test with tox / Test with tox (3.11) (pull_request) Successful in 14m53s
Build Project / Build Project (3.12) (pull_request) Successful in 21m54s
Test with tox / Test with tox (3.10) (pull_request) Successful in 22m0s
Build Project / Build Project (3.11) (pull_request) Successful in 22m7s
Build Project / Build Project (3.10) (pull_request) Successful in 22m16s
Test with tox / Test with tox (3.12) (pull_request) Successful in 22m42s
- Consolidate installation steps into installation.rst (pip upgrade,
  ria --help verification, entrypoints note, editable install note,
  SDR driver table); replace getting_started §1 body with a link
- Reformat command and subcommand lists as tables with purpose
  descriptions and internal ref links for navigation
- Remove redundant §6 tips and §9 cheat sheet; trim duplicate
  descriptions in generate subcommand sections
- Fix inline code comments to sit beside the command they describe
- Add custom CSS for light body text, white headings, and table
  header colour to suit the dark background theme
2026-04-24 14:34:11 -04:00
2baae2f63e Merge pull request 'Update SDR guides, Getting Started Guide and fix Sphinx warnings for release' (#29) from docs/sdr-guides-update into main
All checks were successful
Build Sphinx Docs Set / Build Docs (push) Successful in 30s
Build Project / Build Project (3.10) (push) Successful in 11m37s
Build Project / Build Project (3.12) (push) Successful in 12m20s
Build Project / Build Project (3.11) (push) Successful in 13m40s
Test with tox / Test with tox (3.10) (push) Successful in 13m58s
Test with tox / Test with tox (3.11) (push) Successful in 14m24s
Test with tox / Test with tox (3.12) (push) Successful in 14m10s
Reviewed-on: #29
Reviewed-by: muq <muq@noreply.localhost>
2026-04-24 11:52:45 -04:00
4df5455af4 Merge branch 'main' into docs/sdr-guides-update
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 35s
Build Project / Build Project (3.10) (pull_request) Successful in 5m49s
Build Project / Build Project (3.11) (pull_request) Successful in 19m39s
Build Project / Build Project (3.12) (pull_request) Successful in 19m21s
Test with tox / Test with tox (3.11) (pull_request) Successful in 21m31s
Test with tox / Test with tox (3.12) (pull_request) Successful in 17m24s
Test with tox / Test with tox (3.10) (pull_request) Successful in 21m51s
2026-04-24 10:36:18 -04:00
2881aaf06e Merge pull request 'zfp-oss' (#27) from zfp-oss into main
Some checks failed
Build Sphinx Docs Set / Build Docs (push) Successful in 44m43s
Test with tox / Test with tox (3.10) (push) Successful in 1h4m45s
Build Project / Build Project (3.10) (push) Successful in 1h16m56s
Build Project / Build Project (3.12) (push) Successful in 1h16m52s
Test with tox / Test with tox (3.12) (push) Successful in 31m45s
Test with tox / Test with tox (3.11) (push) Successful in 47m45s
Build Project / Build Project (3.11) (push) Failing after 1h9m0s
Reviewed-on: #27
2026-04-23 11:10:43 -04:00
ben
50d04161b7 Merge remote-tracking branch 'origin/main' into zfp-oss
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 35s
Build Project / Build Project (3.10) (pull_request) Successful in 8m8s
Test with tox / Test with tox (3.11) (pull_request) Successful in 8m0s
Build Project / Build Project (3.11) (pull_request) Successful in 8m6s
Build Project / Build Project (3.12) (pull_request) Successful in 8m6s
Test with tox / Test with tox (3.12) (pull_request) Successful in 9m8s
Test with tox / Test with tox (3.10) (pull_request) Successful in 13m58s
2026-04-22 15:44:12 -04:00
ben
07c72294f5 removing orchestrator references
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 19s
Test with tox / Test with tox (3.12) (pull_request) Successful in 10m47s
Test with tox / Test with tox (3.11) (pull_request) Successful in 15m47s
Build Project / Build Project (3.12) (pull_request) Successful in 15m55s
Build Project / Build Project (3.11) (pull_request) Successful in 16m46s
Build Project / Build Project (3.10) (pull_request) Successful in 16m49s
Test with tox / Test with tox (3.10) (pull_request) Successful in 18m15s
2026-04-22 10:10:25 -04:00
ben
c9b19949ad timeout chunk improvements
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 19m57s
Build Project / Build Project (3.10) (pull_request) Successful in 19m59s
Test with tox / Test with tox (3.10) (pull_request) Successful in 19m46s
Build Project / Build Project (3.11) (pull_request) Successful in 20m19s
Build Project / Build Project (3.12) (pull_request) Successful in 20m21s
Test with tox / Test with tox (3.11) (pull_request) Successful in 18m48s
Test with tox / Test with tox (3.12) (pull_request) Successful in 1m25s
2026-04-21 17:11:16 -04:00
ben
53e8e5adb6 chunk timeout error
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 3m26s
Build Project / Build Project (3.10) (pull_request) Successful in 20m28s
Test with tox / Test with tox (3.10) (pull_request) Successful in 22m26s
Build Project / Build Project (3.11) (pull_request) Successful in 24m14s
Build Project / Build Project (3.12) (pull_request) Successful in 24m26s
Test with tox / Test with tox (3.11) (pull_request) Successful in 22m45s
Test with tox / Test with tox (3.12) (pull_request) Successful in 24m13s
2026-04-21 16:40:49 -04:00
a502dd97a9 Merge pull request 'Moved all contents of datatypes to data, refactored accordingly' (#28) from fix/unify_data_folders into main
All checks were successful
Build Project / Build Project (3.10) (push) Successful in 19m47s
Build Sphinx Docs Set / Build Docs (push) Successful in 23m31s
Build Project / Build Project (3.11) (push) Successful in 24m54s
Test with tox / Test with tox (3.11) (push) Successful in 18m11s
Build Project / Build Project (3.12) (push) Successful in 25m35s
Test with tox / Test with tox (3.10) (push) Successful in 26m50s
Test with tox / Test with tox (3.12) (push) Successful in 8m7s
Reviewed-on: #28
Reviewed-by: gillian <gillian@qoherent.ai>
2026-04-21 16:04:28 -04:00
ben
34b67c0c17 campaign loop support
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 13m32s
Build Project / Build Project (3.12) (pull_request) Successful in 13m49s
Build Project / Build Project (3.11) (pull_request) Successful in 15m28s
Build Project / Build Project (3.10) (pull_request) Successful in 15m37s
Test with tox / Test with tox (3.10) (pull_request) Successful in 6m40s
Test with tox / Test with tox (3.11) (pull_request) Successful in 4m27s
Test with tox / Test with tox (3.12) (pull_request) Successful in 7m57s
2026-04-21 15:56:04 -04:00
ben
39d5d74d6a large memory fix
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 20m54s
Build Project / Build Project (3.12) (pull_request) Successful in 5m13s
Build Project / Build Project (3.10) (pull_request) Successful in 25m24s
Build Project / Build Project (3.11) (pull_request) Successful in 25m31s
Test with tox / Test with tox (3.10) (pull_request) Successful in 6m18s
Test with tox / Test with tox (3.11) (pull_request) Successful in 15m2s
Test with tox / Test with tox (3.12) (pull_request) Successful in 19m57s
2026-04-21 15:03:57 -04:00
8a66860d33 Moved all contents of to , refactored accordingly
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 15m51s
Build Project / Build Project (3.10) (pull_request) Successful in 16m14s
Build Project / Build Project (3.11) (pull_request) Successful in 17m9s
Build Project / Build Project (3.12) (pull_request) Successful in 2m29s
Test with tox / Test with tox (3.12) (pull_request) Successful in 21m28s
Test with tox / Test with tox (3.10) (pull_request) Successful in 22m50s
Test with tox / Test with tox (3.11) (pull_request) Successful in 23m18s
2026-04-21 14:38:06 -04:00
ben
4d3aaf6ec8 json access issue
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 26s
Build Project / Build Project (3.12) (pull_request) Successful in 2m39s
Build Project / Build Project (3.10) (pull_request) Successful in 3m9s
Build Project / Build Project (3.11) (pull_request) Successful in 3m7s
Test with tox / Test with tox (3.10) (pull_request) Successful in 8m2s
Test with tox / Test with tox (3.11) (pull_request) Successful in 13m37s
Test with tox / Test with tox (3.12) (pull_request) Successful in 13m28s
2026-04-21 14:34:48 -04:00
ben
4aea2841be two-machine TX/RX
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 17m1s
Test with tox / Test with tox (3.10) (pull_request) Successful in 17m10s
Build Project / Build Project (3.10) (pull_request) Successful in 17m31s
Test with tox / Test with tox (3.11) (pull_request) Successful in 17m38s
Build Project / Build Project (3.11) (pull_request) Successful in 17m48s
Build Project / Build Project (3.12) (pull_request) Successful in 17m47s
Test with tox / Test with tox (3.12) (pull_request) Successful in 3m12s
2026-04-21 14:09:36 -04:00
ben
4c2c9c0288 rx and tx test
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 19s
Build Project / Build Project (3.11) (pull_request) Successful in 1m15s
Build Project / Build Project (3.10) (pull_request) Successful in 1m18s
Build Project / Build Project (3.12) (pull_request) Successful in 1m16s
Test with tox / Test with tox (3.11) (pull_request) Successful in 1m47s
Test with tox / Test with tox (3.12) (pull_request) Successful in 1m44s
Test with tox / Test with tox (3.10) (pull_request) Successful in 2m4s
2026-04-21 13:23:49 -04:00
Mmuq
a68a325cb4 Update SDR guides and fix Sphinx warnings for release
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 6m4s
Build Project / Build Project (3.10) (pull_request) Successful in 11m52s
Build Project / Build Project (3.11) (pull_request) Successful in 11m50s
Build Project / Build Project (3.12) (pull_request) Successful in 11m51s
Test with tox / Test with tox (3.11) (pull_request) Successful in 12m10s
Test with tox / Test with tox (3.12) (pull_request) Successful in 6m22s
Test with tox / Test with tox (3.10) (pull_request) Successful in 12m28s
Fix Sphinx build errors:
- Add missing blank lines in rtlsdr.rst code-block directives
- Rename duplicate label in examples/sdr/index.rst
- Fix field list indentation in usrp.py and hackrf.py docstrings

Update SDR setup guides (all guides now cover both pip/venv and Radioconda):
- rtlsdr: switch to rtl-sdr-blog fork (required for rtlsdr_set_dithering
  symbol), add pyrtlsdr==0.3.0 and setuptools==69.5.1 version pinning,
  preserve Radioconda blacklist and udev symlink paths alongside new steps
- pluto: simplify primary path to apt install libiio, add Avahi network
  discovery note, preserve Radioconda udev symlink as alternative
- hackrf: note out-of-box support, preserve Radioconda udev symlink
- blade: note no extra Python packages needed, preserve Radioconda udev symlinks
- usrp: add build-from-source path for pip/venv users with cmake flags,
  Python binding copy step, and version mismatch warning; keep conda install
  as primary option; preserve Radioconda udev symlink
- thinkrf: add lib2to3 install step, Python <=3.12 restriction, and full
  Python 3 patching command to replace internal script reference

Update copyright year to 2026 in conf.py
2026-04-21 12:29:18 -04:00
ben
c27a5944c7 formats
All checks were successful
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 8m30s
Build Project / Build Project (3.12) (pull_request) Successful in 4m15s
Build Project / Build Project (3.11) (pull_request) Successful in 4m17s
Build Project / Build Project (3.10) (pull_request) Successful in 4m19s
Test with tox / Test with tox (3.11) (pull_request) Successful in 14m59s
Test with tox / Test with tox (3.10) (pull_request) Successful in 20m7s
Test with tox / Test with tox (3.12) (pull_request) Successful in 18m9s
2026-04-20 16:49:52 -04:00
ben
062a0e766f Merge origin/main into zfp-oss; regenerate poetry.lock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 16:44:59 -04:00
ben
cdcc03327b Merge remote-tracking branch 'origin/main' into zfp-oss 2026-04-20 16:42:08 -04:00
ben
912fc54f25 Merge remote-tracking branch 'origin/qac-cli-commands' into zfp-oss
Some checks failed
Build Sphinx Docs Set / Build Docs (pull_request) Successful in 23s
Build Project / Build Project (3.10) (pull_request) Successful in 11m47s
Test with tox / Test with tox (3.10) (pull_request) Failing after 21m33s
Build Project / Build Project (3.12) (pull_request) Successful in 21m47s
Build Project / Build Project (3.11) (pull_request) Successful in 21m52s
Test with tox / Test with tox (3.12) (pull_request) Failing after 26m45s
Test with tox / Test with tox (3.11) (pull_request) Failing after 28m40s
2026-04-20 13:28:34 -04:00
ben
b884397f1f Merge remote-tracking branch 'origin/main' into zfp-oss 2026-04-20 13:28:12 -04:00
ben
dae9510981 transmission code 2026-04-20 12:33:14 -04:00
106 changed files with 4424 additions and 3504 deletions

3
.gitignore vendored
View File

@ -99,4 +99,5 @@ cython_debug/
*.sigmf-meta *.sigmf-meta
*.blue *.blue
*.wav *.wav
images/ /images/
!docs/source/images/**

View File

@ -159,7 +159,7 @@ Finally, RIA Toolkit OSS can be installed directly from the source code. This ap
Once the project is installed, you can import modules, functions, and classes from the Toolkit for use in your Python code. For example, you can use the following import statement to access the `Recording` object: Once the project is installed, you can import modules, functions, and classes from the Toolkit for use in your Python code. For example, you can use the following import statement to access the `Recording` object:
```python ```python
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
``` ```
Additional usage information is provided in the project documentation: [RIA Toolkit OSS Documentation](https://ria-toolkit-oss.readthedocs.io/). Additional usage information is provided in the project documentation: [RIA Toolkit OSS Documentation](https://ria-toolkit-oss.readthedocs.io/).
@ -185,7 +185,7 @@ RIA Toolkit OSS is developed and maintained by [Qoherent](https://qoherent.ai/),
If you are doing research with RIA Toolkit OSS, please cite the project: If you are doing research with RIA Toolkit OSS, please cite the project:
``` ```
[1] Qoherent Inc., "Radio Intelligence Apps Toolkit OSS," 2025. [Online]. Available: https://riahub.ai/qoherent/ria-toolkit-oss [1] Qoherent Inc., "Radio Intelligence Apps Toolkit OSS," 2026. [Online]. Available: https://riahub.ai/qoherent/ria-toolkit-oss
``` ```
If you like what we're doing, don't forget to give the project a star! ⭐ If you like what we're doing, don't forget to give the project a star! ⭐

View File

@ -6,10 +6,10 @@ This is a practical reference for the ``ria`` CLI from ``ria-toolkit-oss``.
**Scope of this guide:** **Scope of this guide:**
* Installation and setup * **Installation and SDR driver prerequisites** — how to install RIA Toolkit OSS and configure the system drivers your hardware requires
* End-to-end CLI workflows * **End-to-end CLI workflow** — a step-by-step walkthrough from hardware discovery through capture, annotation, and processing
* Full command reference for CLI features * **Full command reference** — options, flags, and examples for every ``ria`` command
* Brief scripting section * **Python scripting preview** — using the toolkit API directly without the CLI
**Official resources:** **Official resources:**
@ -18,76 +18,15 @@ This is a practical reference for the ``ria`` CLI from ``ria-toolkit-oss``.
* `PyPI package <https://pypi.org/project/ria-toolkit-oss/>`_ * `PyPI package <https://pypi.org/project/ria-toolkit-oss/>`_
* `RIA Hub Conda package <https://riahub.ai/qoherent/-/packages/conda/ria-toolkit-oss>`_ * `RIA Hub Conda package <https://riahub.ai/qoherent/-/packages/conda/ria-toolkit-oss>`_
.. contents:: Contents
:local:
:depth: 2
:backlinks: none
1) Installation and Setup 1) Installation and Setup
========================== ==========================
1.1 Installation with Conda Before using the ``ria`` CLI, follow the :doc:`Installation <installation>` guide to
---------------------------- install RIA Toolkit OSS and any SDR drivers required for your hardware.
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 1.1 SDR driver prerequisites
--------------------------
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 Toolkit package install does not install all system SDR drivers. Install vendor/runtime
@ -95,11 +34,22 @@ dependencies for the hardware you use.
Examples (depends on device and OS): Examples (depends on device and OS):
* USRP: UHD drivers .. list-table::
* Pluto: libiio / IIO utilities :widths: 25 75
* BladeRF: libbladeRF :header-rows: 1
* HackRF: libhackrf
* RTL-SDR: librtlsdr * - Device
- Driver Package
* - 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. See repo docs under ``docs/source/sdr_guides/*`` and your OS package instructions.
@ -119,18 +69,34 @@ Top-level CLI follows this model:
**Top-level commands:** **Top-level commands:**
* ``discover`` .. list-table::
* ``init`` :widths: 25 75
* ``capture`` :header-rows: 1
* ``view``
* ``annotate`` (group) * - Command
* ``convert`` - Purpose
* ``split`` * - :ref:`discover <cmd-discover>`
* ``combine`` - Probe SDR drivers and enumerate attached hardware
* ``generate`` (group) * - :ref:`init <cmd-init>`
* ``transform`` (group) - Create and manage user metadata defaults
* ``transmit`` * - :ref:`capture <cmd-capture>`
* ``synth`` (alias of ``generate`` in command bindings) - Record IQ samples from a connected SDR
* - :ref:`view <cmd-view>`
- Generate visualizations from IQ files
* - :ref:`annotate <cmd-annotate>`
- Label signal regions manually or with auto-detection (group)
* - :ref:`convert <cmd-convert>`
- Convert between IQ file formats
* - :ref:`split <cmd-split>`
- Split, trim, or extract recordings
* - :ref:`combine <cmd-combine>`
- Merge multiple recordings by concatenation or addition
* - :ref:`generate / synth <cmd-generate>`
- Generate synthetic IQ signals (group; ``synth`` is an alias)
* - :ref:`transform <cmd-transform>`
- Apply augmentations or impairments to recordings (group)
* - :ref:`transmit <cmd-transmit>`
- Transmit IQ through a TX-capable SDR
3) Quick End-to-End Workflow 3) Quick End-to-End Workflow
@ -158,10 +124,8 @@ provenance fields.
.. code-block:: bash .. code-block:: bash
ria init ria init
# or non-interactive ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" # non-interactive
ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" ria init --show # show config
# show config
ria init --show
3.3 Capture IQ 3.3 Capture IQ
@ -227,13 +191,14 @@ Replay recorded or synthesized IQ through a transmit-capable SDR.
.. code-block:: bash .. code-block:: bash
ria transmit -d hackrf -f 2.44G -s 2e6 --input capture.sigmf-data ria transmit -d hackrf -f 2.44G -s 2e6 --input capture.sigmf-data
# or generated waveform ria transmit -d hackrf --generate lfm --continuous # generated waveform
ria transmit -d hackrf --generate lfm --continuous
4) Command Reference 4) Command Reference
===================== =====================
.. _cmd-discover:
4.1 ``discover`` 4.1 ``discover``
----------------- -----------------
@ -263,6 +228,8 @@ Replay recorded or synthesized IQ through a transmit-capable SDR.
hidden in default output. hidden in default output.
.. _cmd-init:
4.2 ``init`` 4.2 ``init``
------------- -------------
@ -309,6 +276,8 @@ Replay recorded or synthesized IQ through a transmit-capable SDR.
generate metadata, and YAML config loading paths). generate metadata, and YAML config loading paths).
.. _cmd-capture:
4.3 ``capture`` 4.3 ``capture``
---------------- ----------------
@ -382,6 +351,8 @@ Device selection (``--device``) is optional if only one device is detected. Exac
ria capture -c capture_config.yaml ria capture -c capture_config.yaml
.. _cmd-view:
4.4 ``view`` 4.4 ``view``
------------- -------------
@ -442,7 +413,21 @@ Device selection (``--device``) is optional if only one device is detected. Exac
ria view capture.npy --type full --title "Test Capture" --format pdf ria view capture.npy --type full --title "Test Capture" --format pdf
ria view capture.npy --show --no-save ria view capture.npy --show --no-save
ria view old.npy --legacy --type simple ria view old.npy --legacy --type simple
ria view recordings\qam64_35.npy --type simple
ria view recordings\qam64_35.npy --type full
.. figure:: ../images/recordings/qam64_35.png
:alt: Example output of ria view recordings\qam64_35.npy --type simple
Output of ``ria view recordings\qam64_35.npy --type simple``
.. figure:: ../images/recordings/qam64_35-full.png
:alt: Example output of ria view recordings\qam64_35.npy --type full
Output of ``ria view recordings\qam64_35.npy --type full``
.. _cmd-annotate:
4.5 ``annotate`` group 4.5 ``annotate`` group
----------------------- -----------------------
@ -459,8 +444,30 @@ Device selection (``--device``) is optional if only one device is detected. Exac
ria annotate <subcommand> ... ria annotate <subcommand> ...
**Subcommands:** ``list``, ``add``, ``remove``, ``clear``, ``energy``, ``cusum``, **Subcommands:**
``threshold``, ``separate``
.. list-table::
:widths: 25 75
:header-rows: 1
* - Subcommand
- Purpose
* - ``list``
- Inspect all annotations on a recording
* - ``add``
- Add one annotation with explicit sample-domain bounds
* - ``remove``
- Remove one annotation by index
* - ``clear``
- Remove all annotations from a recording
* - ``energy``
- Auto-detect regions above the estimated noise floor
* - ``cusum``
- Auto-detect regime changes using change-point detection
* - ``threshold``
- Auto-detect regions using normalized magnitude thresholding
* - ``separate``
- Decompose annotations into narrower spectral components
**General behavior:** **General behavior:**
@ -587,8 +594,16 @@ annotations.
ria annotate add capture.sigmf-data --start 10000 --count 5000 --label burst 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 energy capture.sigmf-data --label signal --threshold 1.3
ria annotate cusum capture.sigmf-data --min-duration 5 ria annotate cusum capture.sigmf-data --min-duration 5
ria annotate threshold recordings/sample_recording3.npy --threshold 0.7 --label 70%
ria annotate separate capture.sigmf-data --indices 0,1 --verbose ria annotate separate capture.sigmf-data --indices 0,1 --verbose
.. figure:: ../images/recordings/sample_recording3_annotated.png
:alt: Example output of ria annotate threshold recordings/sample_recording3.npy --threshold 0.7 --label 70%
Output of ``ria annotate threshold recordings/sample_recording3.npy --threshold 0.7 --label 70%``
.. _cmd-convert:
4.6 ``convert`` 4.6 ``convert``
---------------- ----------------
@ -629,6 +644,8 @@ inferred from the output file extension.
ria convert old.npy --format sigmf --legacy --overwrite ria convert old.npy --format sigmf --legacy --overwrite
.. _cmd-split:
4.7 ``split`` 4.7 ``split``
-------------- --------------
@ -670,6 +687,8 @@ Choose exactly one operation per invocation:
ria split annotated.sigmf-data --extract-annotations --annotation-label payload ria split annotated.sigmf-data --extract-annotations --annotation-label payload
.. _cmd-combine:
4.8 ``combine`` 4.8 ``combine``
---------------- ----------------
@ -717,6 +736,8 @@ Choose exactly one operation per invocation:
ria combine signal.npy pattern.npy out.npy --mode add --align-mode repeat-spaced --repeat-spacing 10000 ria combine signal.npy pattern.npy out.npy --mode add --align-mode repeat-spaced --repeat-spacing 10000
.. _cmd-generate:
4.9 ``generate`` group (and ``synth`` alias) 4.9 ``generate`` group (and ``synth`` alias)
--------------------------------------------- ---------------------------------------------
@ -728,15 +749,34 @@ Choose exactly one operation per invocation:
``ria synth ...`` is an alias for ``ria generate ...``. ``ria synth ...`` is an alias for ``ria generate ...``.
**Shape:** **Usage:**
.. code-block:: bash .. code-block:: bash
ria generate <subcommand> [subcommand options] [common options] ria generate <subcommand> [subcommand options] [common options]
**Available subcommands:** **Available subcommands:**
``tone``, ``noise``, ``chirp``, ``square``, ``sawtooth``, ``qam``, ``apsk``, ``pam``,
``fsk``, ``ook``, ``oqpsk``, ``gmsk``, ``psk`` .. list-table::
:widths: 30 70
:header-rows: 1
* - Subcommand(s)
- Description
* - ``tone``
- Clean sinusoidal calibration/reference source
* - ``noise``
- Baseline noise floor data or controlled additive-noise synthesis
* - ``chirp``
- Sweep-based radar/sonar-style signals and bandwidth occupancy tests
* - ``square``, ``sawtooth``
- Periodic waveform primitives
* - ``qam``, ``apsk``, ``pam``, ``psk``
- Digital modulation families with pulse-shaping filter support
* - ``fsk``
- Frequency-shift keying with configurable tone spacing
* - ``ook``, ``oqpsk``, ``gmsk``
- On-off keying and continuous-phase modulation schemes
**Common options shared across all generators:** **Common options shared across all generators:**
@ -760,22 +800,16 @@ Multipath and IQ imbalance flags apply impairment-style post-processing during g
Options: ``--frequency``, ``--amplitude``, ``--phase`` Options: ``--frequency``, ``--amplitude``, ``--phase``
Clean sinusoidal calibration/reference source.
``noise`` ``noise``
~~~~~~~~~~ ~~~~~~~~~~
Options: ``--noise-type {gaussian,uniform}``, ``--power`` Options: ``--noise-type {gaussian,uniform}``, ``--power``
Baseline noise floor data or controlled additive-noise synthesis.
``chirp`` ``chirp``
~~~~~~~~~~ ~~~~~~~~~~
Options: ``--bandwidth`` (required), ``--period`` (required), ``--type {up,down,up_down}`` Options: ``--bandwidth`` (required), ``--period`` (required), ``--type {up,down,up_down}``
Sweep-based radar/sonar-style signals and bandwidth occupancy tests.
``square`` ``square``
~~~~~~~~~~~ ~~~~~~~~~~~
@ -826,6 +860,8 @@ symbol transition sharpness).
ria synth psk -s 2e6 -r 100e3 -M 8 -N 8000 -o psk8.npy ria synth psk -s 2e6 -r 100e3 -M 8 -N 8000 -o psk8.npy
.. _cmd-transform:
4.10 ``transform`` group 4.10 ``transform`` group
------------------------- -------------------------
@ -834,7 +870,7 @@ symbol transition sharpness).
* Apply algorithmic transforms to existing recordings. * Apply algorithmic transforms to existing recordings.
* Run reusable augmentations/impairments for dataset diversity and robustness testing. * Run reusable augmentations/impairments for dataset diversity and robustness testing.
**Shape:** **Usage:**
.. code-block:: bash .. code-block:: bash
@ -895,6 +931,8 @@ inspect parameter hints. ``--view`` writes a PNG preview alongside transform out
ria transform custom my_filter in.npy out.npy --transform-dir ./my_transforms --params cutoff=0.2 ria transform custom my_filter in.npy out.npy --transform-dir ./my_transforms --params cutoff=0.2
.. _cmd-transmit:
4.11 ``transmit`` 4.11 ``transmit``
------------------ ------------------
@ -993,17 +1031,7 @@ experiment-specific fields on the CLI.
ria generate noise --config generate.yaml ria generate noise --config generate.yaml
6) Practical Tips and Safety 6) Version Notes
=============================
* 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 These notes are based on the current implementation and should be re-validated against future
@ -1016,18 +1044,19 @@ releases.
3. Multiple non-CLI modules still import ``utils.*``, which can create runtime dependency 3. Multiple non-CLI modules still import ``utils.*``, which can create runtime dependency
coupling when using only ``ria-toolkit-oss`` in isolation. coupling when using only ``ria-toolkit-oss`` in isolation.
If you observe unexpected import errors after install, check the package version and .. tip::
changelog, then test ``ria --help`` in a clean virtual environment. 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 7) Brief Scripting (Python) Preview
===================================== =====================================
For quick non-CLI use: For quick non-CLI use:
.. code-block:: python .. code-block:: python
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
from ria_toolkit_oss.io import load_recording, to_sigmf from ria_toolkit_oss.io import load_recording, to_sigmf
from ria_toolkit_oss.transforms import iq_augmentations, iq_impairments from ria_toolkit_oss.transforms import iq_augmentations, iq_impairments
@ -1037,47 +1066,3 @@ For quick non-CLI use:
to_sigmf(imp, filename="capture_awgn", path=".") to_sigmf(imp, filename="capture_awgn", path=".")
You can also call annotation algorithms and block-generator primitives from Python directly. 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

View File

@ -1,6 +1,7 @@
/* Change the hex values below to customize heading colours */ /* Change the hex values below to customize heading colours */
.rst-content h1 { color: #2c3e50; } .rst-content { color: #e0e0e0; }
.rst-content h1 { color: #ffffff; }
.rst-content h2, .rst-content h2,
.rst-content h2 a { color: #ffffff !important; font-size: 22px !important; } .rst-content h2 a { color: #ffffff !important; font-size: 22px !important; }
@ -22,8 +23,20 @@
.rst-content .admonition.warning p { .rst-content .admonition.warning p {
color: #ffffff !important; color: #ffffff !important;
} }
.rst-content h4 { color: #404040; } .rst-content h4 { color: #cccccc; }
.highlight * { color: #ffffff !important; } .highlight * { color: #ffffff !important; }
.ria-cmd { color: #2980b9 !important; } .ria-cmd { color: #2980b9 !important; }
/* Table header text */
.rst-content table.docutils th {
color: #ffffff !important;
}
/* Remove alternating row background colors from tables */
.rst-content table.docutils td,
.rst-content table.docutils tr:nth-child(2n-1) td {
background-color: transparent !important;
}

View File

@ -12,9 +12,9 @@ sys.path.insert(0, os.path.abspath(os.path.join('..', '..')))
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'ria-toolkit-oss' project = 'ria-toolkit-oss'
copyright = '2025, Qoherent Inc' copyright = '2026, Qoherent Inc'
author = 'Qoherent Inc.' author = 'Qoherent Inc.'
release = '0.1.5' release = '0.1.6'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -1,4 +1,4 @@
.. _examples: .. _sdr_examples:
############ ############
SDR Examples SDR Examples

View File

@ -25,7 +25,7 @@ In this example, we initialize the `Blade` SDR, configure it to record a signal
import time import time
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.sdr.blade import Blade from ria_toolkit_oss.sdr.blade import Blade
my_radio = Blade() my_radio = Blade()

View File

@ -21,7 +21,7 @@ Code
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.sdr.blade import Blade from ria_toolkit_oss.sdr.blade import Blade
# Parameters # Parameters

BIN
docs/source/images/recordings/qam64_35-full.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/source/images/recordings/qam64_35.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/source/images/recordings/sample_recording3-full.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/source/images/recordings/sample_recording3.png (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
docs/source/images/recordings/sample_recording3_awgn.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/source/images/recordings/sample_recording3_cusum.png (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,26 @@ Installation
RIA Hub Toolkit OSS can be installed either as a Conda package or as a standard Python package. RIA Hub Toolkit OSS can be installed either as a Conda package or as a standard Python package.
Please note that SDR drivers must be installed separately. Refer to the relevant guide in the Please note that SDR drivers must be installed separately. Refer to the relevant guide in the
:ref:`SDR Guides <sdr_guides>` section of the documentation for addition setup instructions. :ref:`SDR Guides <sdr_guides>` section of the documentation for additional setup instructions.
Common driver packages by device (exact package names depend on your OS):
.. list-table::
:widths: 25 75
:header-rows: 1
* - Device
- Driver Package
* - USRP
- UHD drivers
* - Pluto
- libiio / IIO utilities
* - BladeRF
- libbladeRF
* - HackRF
- libhackrf
* - RTL-SDR
- librtlsdr
We want your experience with RIA Toolkit OSS to be as smooth and frictionless as possible. If you run into any We want your experience with RIA Toolkit OSS to be as smooth and frictionless as possible. If you run into any
issues during installation, please reach out to our support team: ``support@qoherent.ai``. issues during installation, please reach out to our support team: ``support@qoherent.ai``.
@ -84,12 +103,22 @@ Please follow the steps below to install RIA Toolkit OSS using pip:
python -m venv venv python -m venv venv
venv\Scripts\activate venv\Scripts\activate
2. Install RIA Toolkit OSS from PyPI with pip: 2. Upgrade pip and install RIA Toolkit OSS:
.. code-block:: bash .. code-block:: bash
pip install --upgrade pip
pip install ria-toolkit-oss pip install ria-toolkit-oss
3. Verify the CLI is available:
.. code-block:: bash
ria --help
A successful install prints the top-level help text. ``pyproject.toml`` registers two
entrypoints — ``ria`` and ``ria-tools`` — that both point to the same CLI module.
RIA Toolkit OSS can also be installed from RIA Hub. However, RIA Hub does not yet support a proxy or cache for public packages. RIA Toolkit OSS can also be installed from RIA Hub. However, RIA Hub does not yet support a proxy or cache for public packages.
We intend to add this missing functionality soon. In the meantime, please use the ``--no-deps`` option with pip to skip automatic We intend to add this missing functionality soon. In the meantime, please use the ``--no-deps`` option with pip to skip automatic
dependency installation, and then manually install each dependency afterward. dependency installation, and then manually install each dependency afterward.
@ -119,3 +148,6 @@ Follow the steps below to install RIA Toolkit OSS from source:
.. code-block:: bash .. code-block:: bash
pip install . pip install .
For local development, use ``pip install -e .`` instead to install in editable mode
so local changes take effect immediately without reinstalling.

View File

@ -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 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. object, while the complexities of efficient data retrieval and source file manipulation are managed behind the scenes.
Ria Toolkit OSS 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.data.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 behaviors for all radio datasets. :py:obj:`ria_toolkit_oss.data.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 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 of radio datasets. For example, :py:obj:`ria_toolkit_oss.data.datasets.IQDataset`, which is tailored for machine learning tasks
involving the processing of signals represented as IQ (In-phase and Quadrature) samples. 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 Ria Toolkit OSS 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 dataset class from the respective backend. For example, the :py:obj:`TorchIQDataset` class extends both
:py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset` from Ria Toolkit OSS and :py:obj:`torch.ria_toolkit_oss.datatypes.IterableDataset` from :py:obj:`ria_toolkit_oss.data.datasets.IQDataset` from Ria Toolkit OSS and :py:obj:`torch.ria_toolkit_oss.data.IterableDataset` from
PyTorch, providing a concrete dataset class tailored for IQ datasets and optimized for the PyTorch backend. PyTorch, providing a concrete dataset class tailored for IQ datasets and optimized for the PyTorch backend.
Dataset initialization 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, All radio datasets support methods tailored specifically for radio processing. These methods are backend-independent,
inherited from the blueprints in Ria Toolkit OSS like :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`. inherited from the blueprints in Ria Toolkit OSS like :py:obj:`ria_toolkit_oss.data.datasets.RadioDataset`.
For example, we can trim down the length of the examples from 1,024 to 512 samples, and then augment the dataset: For example, we can trim down the length of the examples from 1,024 to 512 samples, and then augment the dataset:

View File

@ -1,7 +1,7 @@
Dataset License SubModule Dataset License SubModule
========================= =========================
.. automodule:: ria_toolkit_oss.datatypes.datasets.license .. automodule:: ria_toolkit_oss.data.datasets.license
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:

View File

@ -0,0 +1,25 @@
Data Package (ria_toolkit_oss.data)
=======================================
.. |br| raw:: html
<br />
.. automodule:: ria_toolkit_oss.data
:members:
:undoc-members:
:show-inheritance:
Radio Dataset SubPackage
------------------------
.. automodule:: ria_toolkit_oss.data.datasets
:members:
:undoc-members:
:show-inheritance:
.. toctree::
:maxdepth: 2
Dataset License SubModule <ria_toolkit_oss.data.datasets.license>
Radio Datasets <radio_datasets>

View File

@ -1,25 +0,0 @@
Datatypes Package (ria_toolkit_oss.datatypes)
=============================================
.. |br| raw:: html
<br />
.. automodule:: ria_toolkit_oss.datatypes
:members:
:undoc-members:
:show-inheritance:
Radio Dataset SubPackage
------------------------
.. automodule:: ria_toolkit_oss.datatypes.datasets
:members:
:undoc-members:
:show-inheritance:
.. toctree::
:maxdepth: 2
Dataset License SubModule <ria_toolkit_oss.datatypes.datasets.license>
Radio Datasets <radio_datasets>

View File

@ -11,7 +11,7 @@ class and function signatures, and doctest examples where available.
:maxdepth: 2 :maxdepth: 2
:caption: Contents: :caption: Contents:
Datatypes Package <datatypes/ria_toolkit_oss.datatypes> Data Package <data/ria_toolkit_oss.data>
SDR Package <ria_toolkit_oss.sdr> SDR Package <ria_toolkit_oss.sdr>
IO Package <ria_toolkit_oss.io> IO Package <ria_toolkit_oss.io>
Transforms Package <ria_toolkit_oss.transforms> Transforms Package <ria_toolkit_oss.transforms>

View File

@ -40,34 +40,44 @@ Limitations
- USB 3.0 connectivity is required for optimal performance; using USB 2.0 will significantly limit data - USB 3.0 connectivity is required for optimal performance; using USB 2.0 will significantly limit data
transfer rates. transfer rates.
Set up instructions (Linux, Radioconda) Set up instructions (Linux)
--------------------------------------- ---------------------------
1. Activate your Radioconda environment. No additional Python packages are required for BladeRF beyond the base RIA Toolkit OSS installation.
1. Install the system library:
.. code-block:: bash .. code-block:: bash
conda activate <your-env-name> sudo apt install libbladerf-dev
2. Install the base dependencies and drivers (*Easy method*): For a more complete installation including CLI tools and FPGA images, use the Nuand PPA:
.. code-block:: bash .. code-block:: bash
sudo add-apt-repository ppa:nuandllc/bladerf sudo add-apt-repository ppa:nuandllc/bladerf
sudo apt-get update sudo apt-get update
sudo apt-get install bladerf sudo apt-get install bladerf libbladerf-dev
sudo apt-get install libbladerf-dev sudo apt-get install bladerf-fpga-hostedxa4 # Necessary for BladeRF 2.0 Micro xA4
sudo apt-get install bladerf-fpga-hostedxa4 # Necessary for installation of bladeRF 2.0 Micro A4.
3. Install a ``udev`` rule by creating a link into your Radioconda installation: 2. Install udev rules:
For most users:
.. code-block:: bash .. code-block:: bash
sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/88-nuand-bladerf1.rules /etc/udev/rules.d/88-radioconda-nuand-bladerf1.rules sudo udevadm control --reload
sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/88-nuand-bladerf2.rules /etc/udev/rules.d/88-radioconda-nuand-bladerf2.rules sudo udevadm trigger
sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/88-nuand-bootloader.rules /etc/udev/rules.d/88-radioconda-nuand-bootloader.rules
sudo udevadm control --reload For **Radioconda** users, create symlinks from your conda environment instead:
sudo udevadm trigger
.. code-block:: bash
sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/88-nuand-bladerf1.rules /etc/udev/rules.d/88-radioconda-nuand-bladerf1.rules
sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/88-nuand-bladerf2.rules /etc/udev/rules.d/88-radioconda-nuand-bladerf2.rules
sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/88-nuand-bootloader.rules /etc/udev/rules.d/88-radioconda-nuand-bootloader.rules
sudo udevadm control --reload
sudo udevadm trigger
Further Information Further Information
------------------- -------------------

View File

@ -39,39 +39,44 @@ Limitations
- Bandwidth is limited to 20 MHz. - Bandwidth is limited to 20 MHz.
- USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs. - USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs.
Set up instructions (Linux, Radioconda) Set up instructions (Linux)
--------------------------------------- ---------------------------
1. Activate your Radioconda environment: HackRF is supported out of the box after installing RIA Toolkit OSS.
1. Ensure ``libhackrf`` is installed at the system level. On most Ubuntu installations this is already
present. If not:
.. code-block:: bash .. code-block:: bash
conda activate <your-env-name> sudo apt install libhackrf-dev
2. Install the System Package (Ubuntu / Debian): 2. Install udev rules to allow non-root device access:
For most users:
.. code-block:: bash .. code-block:: bash
sudo apt-get update sudo udevadm control --reload
sudo apt-get install hackrf sudo udevadm trigger
3. Install a ``udev`` rule by creating a link into your Radioconda installation: For **Radioconda** users, create a symlink from your conda environment instead:
.. code-block:: bash .. code-block:: bash
sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/53-hackrf.rules /etc/udev/rules.d/53-radioconda-hackrf.rules sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/53-hackrf.rules /etc/udev/rules.d/53-radioconda-hackrf.rules
sudo udevadm control --reload sudo udevadm control --reload
sudo udevadm trigger sudo udevadm trigger
Make sure your user account belongs to the plugdev group in order to access your device: Make sure your user account belongs to the ``plugdev`` group in order to access your device:
.. code-block:: bash .. code-block:: bash
sudo usermod -a -G plugdev <user> sudo usermod -a -G plugdev <user>
.. note:: .. note::
You may have to restart your system for changes to take effect. You may have to restart your system for group membership changes to take effect.
Further information Further information
------------------- -------------------

View File

@ -43,34 +43,34 @@ Limitations
affect stability. affect stability.
- USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs. - USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs.
Set up instructions (Linux, Radioconda) Set up instructions (Linux)
--------------------------------------- ---------------------------
1. Activate your Radioconda environment: The PlutoSDR is supported out of the box after installing RIA Toolkit OSS. The required Python package
(``pyadi-iio``) is included in the toolkit's dependencies.
1. Ensure ``libiio`` is installed at the system level. On most Ubuntu installations this is already present.
If not:
.. code-block:: bash .. code-block:: bash
conda activate <your-env-name> sudo apt install libiio-dev libiio-utils libiio0
2. Install system dependencies: .. note::
PlutoSDR devices are discoverable over both USB and network (mDNS). Network discovery uses Avahi — if
``avahi-daemon`` is not running, network discovery will be skipped but USB discovery still works.
2. Install a ``udev`` rule to allow non-root device access:
For most users:
.. code-block:: bash .. code-block:: bash
sudo apt-get update sudo udevadm control --reload
sudo apt-get install -y \ sudo udevadm trigger
build-essential \
git \
libxml2-dev \
bison \
flex \
libcdk5-dev \
cmake \
libusb-1.0-0-dev \
libavahi-client-dev \
libavahi-common-dev \
libaio-dev
3. Install a ``udev`` rule by creating a link into your Radioconda installation: For **Radioconda** users, create a symlink from your conda environment instead:
.. code-block:: bash .. code-block:: bash
@ -78,11 +78,18 @@ Set up instructions (Linux, Radioconda)
sudo udevadm control --reload sudo udevadm control --reload
sudo udevadm trigger sudo udevadm trigger
Once you can talk to the hardware, you may want to perform the post-install steps detailed on the `PlutoSDR Documentation <https://wiki.analog.com/university/tools/pluto>`_. Once you can communicate with the hardware, you may want to perform the post-install steps detailed on
the `PlutoSDR Documentation <https://wiki.analog.com/university/tools/pluto>`_.
4. (Optional) Building ``libiio`` or ``libad9361-iio`` from source: 3. (Optional) Building ``libiio`` or ``libad9361-iio`` from source:
This step is only required if you want the latest version of these libraries not provided in Radioconda. This step is only required if you need a version not available via ``apt``. First install build
dependencies:
.. code-block:: bash
sudo apt-get install -y build-essential git libxml2-dev bison flex libcdk5-dev cmake \
libusb-1.0-0-dev libavahi-client-dev libavahi-common-dev libaio-dev
.. code-block:: bash .. code-block:: bash

View File

@ -30,18 +30,10 @@ Limitations
- Sensitivity and performance can vary depending on the specific model and components. - Sensitivity and performance can vary depending on the specific model and components.
- Requires external software for signal processing and analysis. - Requires external software for signal processing and analysis.
Set up instructions (Linux, Radioconda) Set up instructions (Linux)
--------------------------------------- ---------------------------
1. Activate your Radioconda environment: 1. If you previously had RTL-SDR drivers installed, purge them first:
.. code-block:: bash
conda activate <your-env-name>
2. Purge drivers:
If you already have other drivers installed, purge them from your system.
.. code-block:: bash .. code-block:: bash
@ -53,47 +45,95 @@ If you already have other drivers installed, purge them from your system.
sudo rm -rvf /usr/local/include/rtl_* sudo rm -rvf /usr/local/include/rtl_*
sudo rm -rvf /usr/local/bin/rtl_* sudo rm -rvf /usr/local/bin/rtl_*
3. Install RTL-SDR Blog drivers: 2. Install build dependencies:
.. code-block:: bash .. code-block:: bash
sudo apt-get install libusb-1.0-0-dev git cmake pkg-config build-essential sudo apt install libusb-1.0-0-dev git cmake pkg-config build-essential
git clone https://github.com/osmocom/rtl-sdr
cd rtl-sdr 3. Build ``librtlsdr`` from source:
mkdir build
cd build The standard ``librtlsdr`` package available via ``apt`` is missing symbols required by the Python
cmake ../ -DINSTALL_UDEV_RULES=ON bindings. Build from the **rtl-sdr-blog fork**:
.. code-block:: bash
git clone https://github.com/rtlsdrblog/rtl-sdr-blog.git
cd rtl-sdr-blog
mkdir build && cd build
cmake .. -DINSTALL_UDEV_RULES=ON
make make
sudo make install sudo make install
sudo cp ../rtl-sdr.rules /etc/udev/rules.d/ sudo cp ../rtl-sdr.rules /etc/udev/rules.d/
sudo ldconfig sudo ldconfig
4. Blacklist the DVB-T modules that would otherwise claim the device: .. important::
Do not use the osmocom ``rtl-sdr`` repository or the Ubuntu ``librtlsdr-dev`` apt package. Neither
provides the ``rtlsdr_set_dithering`` symbol that the Python bindings require.
4. Blacklist the kernel DVB driver:
The kernel DVB-T driver (``dvb_usb_rtl28xxu``) claims the RTL-SDR device and prevents ``librtlsdr``
from accessing it.
For most users:
.. code-block:: bash .. code-block:: bash
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtlsdr.conf
sudo modprobe -r dvb_usb_rtl28xxu
For **Radioconda** users, a blacklist configuration is already provided in your conda environment:
.. code-block:: bash
sudo ln -s $CONDA_PREFIX/etc/modprobe.d/rtl-sdr-blacklist.conf /etc/modprobe.d/radioconda-rtl-sdr-blacklist.conf sudo ln -s $CONDA_PREFIX/etc/modprobe.d/rtl-sdr-blacklist.conf /etc/modprobe.d/radioconda-rtl-sdr-blacklist.conf
sudo modprobe -r $(cat $CONDA_PREFIX/etc/modprobe.d/rtl-sdr-blacklist.conf | sed -n -e 's/^blacklist //p') sudo modprobe -r $(cat $CONDA_PREFIX/etc/modprobe.d/rtl-sdr-blacklist.conf | sed -n -e 's/^blacklist //p')
.. note:: If ``modprobe -r`` fails with "Module is in use", unplug the RTL-SDR dongle, run the command again,
then plug it back in. Alternatively, reboot — the blacklist takes effect on next boot.
In addition to the Radioconda blacklist file, some systems also require .. note::
manually blacklisting the following DVB-T modules to prevent them from
claiming the device:
- ``dvb_usb_rtl28xxu`` Some systems also require blacklisting additional DVB-T modules. Add these entries to your
- ``rtl2832`` blacklist configuration if needed:
- ``rtl2830``
Add these entries to ``rtlsdr.conf`` (or create the file at - ``rtl2832``
``/etc/modprobe.d/rtlsdr.conf``) if they are not already present. - ``rtl2830``
5. Install a udev rule by creating a link into your radioconda installation: 5. Reload udev rules:
For most users (rules are installed by the build step above):
.. code-block:: bash .. code-block:: bash
sudo udevadm control --reload
sudo udevadm trigger
For **Radioconda** users, create a symlink from your conda environment instead:
.. code-block:: bash
sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/rtl-sdr.rules /etc/udev/rules.d/radioconda-rtl-sdr.rules sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/rtl-sdr.rules /etc/udev/rules.d/radioconda-rtl-sdr.rules
sudo udevadm control --reload sudo udevadm control --reload
sudo udevadm trigger sudo udevadm trigger
6. Install Python packages:
.. code-block:: bash
pip install pyrtlsdr==0.3.0
pip install setuptools==69.5.1
.. note::
``pyrtlsdr`` 0.4.0 references a ``rtlsdr_set_dithering`` symbol not present in standard
``librtlsdr`` builds. Version 0.3.0 works correctly.
``pyrtlsdr`` 0.3.0 depends on ``pkg_resources``, which was removed in ``setuptools`` >= 82.
Pinning to 69.5.1 ensures ``pkg_resources`` is available.
Further Information Further Information
------------------- -------------------
- `RTL-SDR Official Website <https://www.rtl-sdr.com/>`_ - `RTL-SDR Official Website <https://www.rtl-sdr.com/>`_

View File

@ -39,18 +39,48 @@ Limitations
Set up instructions (Linux) Set up instructions (Linux)
--------------------------------- ---------------------------------
Install PyRF ThinkRF devices require the ``pyrf`` package, which is written in Python 2 syntax and must be patched
after installation to work with Python 3.
.. note::
``lib2to3`` was fully removed in Python 3.13. ThinkRF support is currently limited to
**Python 3.12 and below**.
1. Install ``lib2to3``:
On some distributions (including Ubuntu 24.04+), ``lib2to3`` is not included by default:
.. code-block:: bash .. code-block:: bash
pip install 'pyrf>=2.8.0' sudo apt install python3-lib2to3
Convert PyRF scripts to Python 3 2. Install ``pyrf``:
.. code-block:: bash .. code-block:: bash
cd ../scripts pip install pyrf
./convert_pyrf_to_python3.sh
3. Patch ``pyrf`` for Python 3:
The ``pyrf`` package contains Python 2 syntax throughout (e.g., ``dict.iteritems()``, ``print``
statements). Run the following to automatically convert the entire package to Python 3:
.. code-block:: bash
python -c "
from lib2to3.refactor import RefactoringTool, get_fixers_from_package
import pyrf, os
pyrf_path = os.path.dirname(pyrf.__file__)
fixers = get_fixers_from_package('lib2to3.fixes')
tool = RefactoringTool(fixers)
tool.refactor_dir(pyrf_path, write=True)
print('Done')
"
.. note::
This patches the entire ``pyrf`` package in place, which is required for the driver to fully load.
Further Information Further Information
------------------- -------------------

View File

@ -41,48 +41,111 @@ Limitations
- Compatibility with certain software tools may vary depending on the version of the UHD. - Compatibility with certain software tools may vary depending on the version of the UHD.
- Price range can be a consideration, especially for high-end models. - Price range can be a consideration, especially for high-end models.
Set up instructions (Linux, Radioconda) Set up instructions (Linux)
--------------------------------------- ---------------------------
1. Activate your Radioconda environment: USRP devices require the UHD (USRP Hardware Driver) library with Python bindings. There is no pip-installable
UHD package — it must either be installed via conda or built from source.
**Option A: Install via conda (recommended for conda environments)**
.. code-block:: bash .. code-block:: bash
conda activate <your-env-name> conda install conda-forge::uhd
2. Install UHD and Python bindings: **Option B: Build from source (required for pip/venv environments)**
.. code-block:: bash The Python bindings must target the same Python version used in your virtual environment.
conda install conda-forge::uhd 1. Install build dependencies:
3. Download UHD images: .. code-block:: bash
sudo apt install cmake build-essential libboost-all-dev libusb-1.0-0-dev \
python3-dev python3-numpy libncurses-dev
2. Install the Mako template library into your virtual environment (used by UHD's build system):
.. code-block:: bash
pip install mako
3. Clone and build UHD with your virtual environment activated:
.. code-block:: bash
git clone https://github.com/EttusResearch/uhd.git
cd uhd
git checkout v4.7.0.0
cd host
mkdir build && cd build
cmake -DENABLE_PYTHON_API=ON -DPYTHON_EXECUTABLE=$(which python3) ..
make -j$(nproc)
sudo make install
sudo ldconfig
.. important::
Run the ``cmake`` command with your virtual environment activated so ``$(which python3)`` points
to the correct interpreter. Before running ``make``, verify the cmake output includes::
-- * LibUHD - Python API → must say "Enabling"
-- Python interpreter: .../your-venv/bin/python3
If "LibUHD - Python API" is not listed under enabled components, the Python bindings will not be
built. The build typically takes 1030 minutes.
4. Copy the Python bindings into your virtual environment if ``import uhd`` fails after installation:
.. code-block:: bash
cp -r ~/uhd/host/build/python/uhd ~/.venv/lib/python3.XX/site-packages/
Replace ``python3.XX`` with your Python version (e.g., ``python3.12``).
.. note::
If you have a pre-existing UHD installation built against a different Python version, you will see
a circular import error. The bindings must match the Python version in your virtual environment exactly.
**After either installation method:**
1. Download UHD FPGA/firmware images:
.. code-block:: bash .. code-block:: bash
uhd_images_downloader uhd_images_downloader
4. Verify access to your device: 2. Verify device access:
.. code-block:: bash .. code-block:: bash
uhd_find_devices uhd_find_devices
For USB devices only (e.g. B series), install a ``udev`` rule by creating a link into your Radioconda installation. For USB devices (e.g. B-series), install a ``udev`` rule.
.. code-block:: bash For most users:
sudo ln -s $CONDA_PREFIX/lib/uhd/utils/uhd-usrp.rules /etc/udev/rules.d/radioconda-uhd-usrp.rules .. code-block:: bash
sudo udevadm control --reload
sudo udevadm trigger
5. (Optional) Update firmware/FPGA images: sudo udevadm control --reload
sudo udevadm trigger
.. code-block:: bash For **Radioconda** users, create a symlink from your conda environment instead:
uhd_usrp_probe .. code-block:: bash
This will ensure your device is running the latest firmware and FPGA versions. sudo ln -s $CONDA_PREFIX/lib/uhd/utils/uhd-usrp.rules /etc/udev/rules.d/radioconda-uhd-usrp.rules
sudo udevadm control --reload
sudo udevadm trigger
3. (Optional) Update firmware/FPGA images:
.. code-block:: bash
uhd_usrp_probe
This will ensure your device is running the latest firmware and FPGA versions.
Further information Further information
------------------- -------------------

387
poetry.lock generated
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. # This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
[[package]] [[package]]
name = "alabaster" name = "alabaster"
@ -230,26 +230,26 @@ uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ;
[[package]] [[package]]
name = "cachetools" name = "cachetools"
version = "7.0.5" version = "7.0.6"
description = "Extensible memoizing collections and decorators" description = "Extensible memoizing collections and decorators"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["test"] groups = ["test"]
files = [ files = [
{file = "cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114"}, {file = "cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b"},
{file = "cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990"}, {file = "cachetools-7.0.6.tar.gz", hash = "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24"},
] ]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.4.22"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["agent", "docs", "test"] groups = ["agent", "docs", "test"]
files = [ files = [
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"},
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"},
] ]
[[package]] [[package]]
@ -491,14 +491,14 @@ files = [
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.2" version = "8.3.3"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main", "dev", "docs", "server", "test"] groups = ["main", "dev", "docs", "server", "test"]
files = [ files = [
{file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"},
{file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"},
] ]
[package.dependencies] [package.dependencies]
@ -690,61 +690,61 @@ test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist"
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "46.0.7" version = "47.0.0"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8" python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, {file = "cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f"},
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8"},
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318"},
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001"},
{file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, {file = "cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203"},
{file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, {file = "cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa"},
{file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, {file = "cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"},
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7"},
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52"},
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd"},
{file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, {file = "cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63"},
{file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, {file = "cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b"},
{file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, {file = "cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76"},
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe"},
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31"},
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7"},
{file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, {file = "cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310"},
{file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, {file = "cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab"},
{file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, {file = "cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8"},
{file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, {file = "cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb"},
] ]
[package.dependencies] [package.dependencies]
@ -752,14 +752,7 @@ cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and pla
typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""}
[package.extras] [package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"] ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]] [[package]]
name = "cycler" name = "cycler"
@ -850,14 +843,14 @@ test = ["pytest (>=6)"]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.0" version = "0.136.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["server", "test"] groups = ["server", "test"]
files = [ files = [
{file = "fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4"}, {file = "fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f"},
{file = "fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e"}, {file = "fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f"},
] ]
[package.dependencies] [package.dependencies]
@ -1161,18 +1154,18 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.13"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["agent", "docs", "server", "test"] groups = ["agent", "docs", "server", "test"]
files = [ files = [
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"},
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, {file = "idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242"},
] ]
[package.extras] [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]] [[package]]
name = "imagesize" name = "imagesize"
@ -1522,67 +1515,67 @@ files = [
[[package]] [[package]]
name = "matplotlib" name = "matplotlib"
version = "3.10.8" version = "3.10.9"
description = "Python plotting package" description = "Python plotting package"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7"}, {file = "matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217"},
{file = "matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656"}, {file = "matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b"},
{file = "matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df"}, {file = "matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37"},
{file = "matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17"}, {file = "matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294"},
{file = "matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933"}, {file = "matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65"},
{file = "matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a"}, {file = "matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda"},
{file = "matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160"}, {file = "matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb"},
{file = "matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78"}, {file = "matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb"},
{file = "matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4"}, {file = "matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb"},
{file = "matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2"}, {file = "matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9"},
{file = "matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6"}, {file = "matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb"},
{file = "matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9"}, {file = "matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f"},
{file = "matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2"}, {file = "matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80"},
{file = "matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a"}, {file = "matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1"},
{file = "matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58"}, {file = "matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320"},
{file = "matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04"}, {file = "matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285"},
{file = "matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f"}, {file = "matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2"},
{file = "matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466"}, {file = "matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf"},
{file = "matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf"}, {file = "matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6"},
{file = "matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b"}, {file = "matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42"},
{file = "matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6"}, {file = "matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f"},
{file = "matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1"}, {file = "matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e"},
{file = "matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486"}, {file = "matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f"},
{file = "matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce"}, {file = "matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838"},
{file = "matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6"}, {file = "matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2"},
{file = "matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149"}, {file = "matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921"},
{file = "matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645"}, {file = "matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8"},
{file = "matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077"}, {file = "matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9"},
{file = "matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22"}, {file = "matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4"},
{file = "matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39"}, {file = "matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc"},
{file = "matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565"}, {file = "matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99"},
{file = "matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a"}, {file = "matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d"},
{file = "matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958"}, {file = "matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8"},
{file = "matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5"}, {file = "matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38"},
{file = "matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f"}, {file = "matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d"},
{file = "matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b"}, {file = "matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f"},
{file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d"}, {file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b"},
{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.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2"},
{file = "matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c"}, {file = "matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716"},
{file = "matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11"}, {file = "matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f"},
{file = "matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8"}, {file = "matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456"},
{file = "matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50"}, {file = "matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe"},
{file = "matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908"}, {file = "matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6"},
{file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a"}, {file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c"},
{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.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4"},
{file = "matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c"}, {file = "matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf"},
{file = "matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b"}, {file = "matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39"},
{file = "matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f"}, {file = "matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c"},
{file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8"}, {file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b"},
{file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7"}, {file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f"},
{file = "matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3"}, {file = "matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585"},
{file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1"}, {file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20"},
{file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a"}, {file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba"},
{file = "matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2"}, {file = "matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4"},
{file = "matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3"}, {file = "matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358"},
] ]
[package.dependencies] [package.dependencies]
@ -1597,7 +1590,7 @@ pyparsing = ">=3"
python-dateutil = ">=2.7" python-dateutil = ">=2.7"
[package.extras] [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]] [[package]]
name = "mccabe" name = "mccabe"
@ -1611,25 +1604,6 @@ files = [
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, {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"]
markers = "python_version >= \"3.11\""
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]] [[package]]
name = "mypy-extensions" name = "mypy-extensions"
version = "1.1.0" version = "1.1.0"
@ -1717,37 +1691,37 @@ markers = {server = "python_version >= \"3.11\"", test = "python_version >= \"3.
[[package]] [[package]]
name = "onnxruntime" name = "onnxruntime"
version = "1.24.4" version = "1.25.1"
description = "ONNX Runtime is a runtime accelerator for Machine Learning models" description = "ONNX Runtime is a runtime accelerator for Machine Learning models"
optional = false optional = false
python-versions = ">=3.11" python-versions = ">=3.11"
groups = ["server", "test"] groups = ["server", "test"]
markers = "python_version >= \"3.11\"" markers = "python_version >= \"3.11\""
files = [ files = [
{file = "onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2"}, {file = "onnxruntime-1.25.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:5cf58ec7601120bb4370f0b868f794d3e3626db7b1b1dba366c27874b224e9de"},
{file = "onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7"}, {file = "onnxruntime-1.25.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa7d4daa78a18b8f3b410e31e82dab8580363c85cac644179a853f2748618e89"},
{file = "onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330"}, {file = "onnxruntime-1.25.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79162f873cdfa38cfc8d53d59a8dc7a71a14074df3d565b2f8ce24289545ddc0"},
{file = "onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153"}, {file = "onnxruntime-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:451b9494056f7f96b1be76a32745ccc4582bd61b2a0e1bc52de3708446151d5d"},
{file = "onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b"}, {file = "onnxruntime-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:7e608f8950076da02c0aeceec2dd790d201eeb31dd73acb04ec989b2bf6199dc"},
{file = "onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78"}, {file = "onnxruntime-1.25.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:66e52f7a30d1f780a34aa84d68a0a04d382d9f5b141884ecbf45b7566b9fbde9"},
{file = "onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5"}, {file = "onnxruntime-1.25.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5f41779f044d1ff75593df5c10a4d311bc82563687796d5218e2685b8f9da25"},
{file = "onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c"}, {file = "onnxruntime-1.25.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:905409e9eb2ef87f8226e073f56e71faf731c3e480ebd34952cf953730e4a4ff"},
{file = "onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb"}, {file = "onnxruntime-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4097b75b77486bb45835a8ed25b9a67976040ec6c258aeabae6aadfbdd1201c"},
{file = "onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90"}, {file = "onnxruntime-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:b6c7aa5cae606d5c90a392679fac074b60f80025a2e83e1e90fdf882bd2a97f0"},
{file = "onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0"}, {file = "onnxruntime-1.25.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e9d9b3b1694196bc3c5bc66f760a237a5e27d7688aaa2e2c9c0f66abd0486699"},
{file = "onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13"}, {file = "onnxruntime-1.25.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:311d29b943e46a55ca72ca1ea48d7815c993122bfc359f68215fddeb9583fff4"},
{file = "onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f"}, {file = "onnxruntime-1.25.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98016a038b31160db23208706139fa3b99cd60bc1c5ffdade77aafd6a37a92ad"},
{file = "onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93"}, {file = "onnxruntime-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:08717d6eee2820807ba60b1b17032af207bd7aaca5b6c4abaee71f83feae877b"},
{file = "onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19"}, {file = "onnxruntime-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:84f8963d70e00167bae273ab7e80e9795bfc5eb94f6b23236a99c5c11af00844"},
{file = "onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee"}, {file = "onnxruntime-1.25.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03e800b3a4b48d9f3a2d23aacc4fa95486a3b406b14e51d1a9b8b6981d9adf9c"},
{file = "onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36"}, {file = "onnxruntime-1.25.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd83ef5c10cfc051a1cb465db692d57b996a1bc75a2a97b161398e29cdbc47ff"},
{file = "onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4"}, {file = "onnxruntime-1.25.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:395eb662c437fa2407f44266e4778b75bff261b17c2a6fef042421f9069f871d"},
{file = "onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1"}, {file = "onnxruntime-1.25.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ae85395f41b291ae3e61780ec5092640181d369ef6c268aa8141c478b509e69"},
{file = "onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177"}, {file = "onnxruntime-1.25.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:828e1b12710fbedb6dfab5e7bae6f11563617cddf3c2e7e8d84c64de566a4a3a"},
{file = "onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858"}, {file = "onnxruntime-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:2affc9d2fd9ab013b9c9637464e649a0cca870d57ae18bfef74180eee65c3369"},
{file = "onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d"}, {file = "onnxruntime-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:3387d75d1a815b4b2495b4e47a05ef1b3bcb64a817ddc68587e0bfcb9702bcf6"},
{file = "onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661"}, {file = "onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06280b06604660595037f783c6d24bc70cbe5c6093975f194cd1482e77d450de"},
{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.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212"},
] ]
[package.dependencies] [package.dependencies]
@ -1755,18 +1729,21 @@ flatbuffers = "*"
numpy = ">=1.21.6" numpy = ">=1.21.6"
packaging = "*" packaging = "*"
protobuf = "*" protobuf = "*"
sympy = "*"
[package.extras]
quantization = ["ml_dtypes"]
symbolic = ["sympy"]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.1" version = "26.2"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev", "docs", "server", "test"] groups = ["main", "dev", "docs", "server", "test"]
files = [ files = [
{file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
] ]
markers = {server = "python_version >= \"3.11\""} markers = {server = "python_version >= \"3.11\""}
@ -1893,21 +1870,20 @@ gssapi = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "pyasn1 (>=0.1.7)
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "1.0.4" version = "1.1.1"
description = "Utility library for gitignore style pattern matching of file paths." description = "Utility library for gitignore style pattern matching of file paths."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"},
{file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"},
] ]
[package.extras] [package.extras]
hyperscan = ["hyperscan (>=0.7)"] hyperscan = ["hyperscan (>=0.7)"]
optional = ["typing-extensions (>=4)"] optional = ["typing-extensions (>=4)"]
re2 = ["google-re2 (>=1.1)"] re2 = ["google-re2 (>=1.1)"]
tests = ["pytest (>=9)", "typing-extensions (>=4.15)"]
[[package]] [[package]]
name = "pillow" name = "pillow"
@ -2974,14 +2950,14 @@ test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis
[[package]] [[package]]
name = "sigmf" name = "sigmf"
version = "1.8.0" version = "1.9.0"
description = "Easily interact with Signal Metadata Format (SigMF) recordings." description = "Easily interact with Signal Metadata Format (SigMF) recordings."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "sigmf-1.8.0-py3-none-any.whl", hash = "sha256:f233ab04344fa3e42170926a646f7e53edd7edc65fcda42eb3d7efaf8a2e8263"}, {file = "sigmf-1.9.0-py3-none-any.whl", hash = "sha256:902e694894e61f8cdb75b0d69ae8c407f82f35435c3c5e4c1b586b313f77b89b"},
{file = "sigmf-1.8.0.tar.gz", hash = "sha256:91e10cb046499639e5f961d66a24c17a33ff76fc98df892eab0953cc9d659a50"}, {file = "sigmf-1.9.0.tar.gz", hash = "sha256:95e4b28156b2182035ecca5f5852108fb3cdef5f20b0cd48919bb0fc5f293d0e"},
] ]
[package.dependencies] [package.dependencies]
@ -3229,25 +3205,6 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""
[package.extras] [package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] 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"]
markers = "python_version >= \"3.11\""
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]] [[package]]
name = "tomli" name = "tomli"
version = "2.4.1" version = "2.4.1"
@ -3389,14 +3346,14 @@ typing-extensions = ">=4.12.0"
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.1" version = "2026.2"
description = "Provider of IANA time zone data" description = "Provider of IANA time zone data"
optional = false optional = false
python-versions = ">=2" python-versions = ">=2"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"},
{file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"},
] ]
[[package]] [[package]]
@ -3419,14 +3376,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.44.0" version = "0.46.0"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["docs", "server", "test"] groups = ["docs", "server", "test"]
files = [ files = [
{file = "uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89"}, {file = "uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048"},
{file = "uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e"}, {file = "uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d"},
] ]
[package.dependencies] [package.dependencies]
@ -3511,14 +3468,14 @@ test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil",
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "21.2.4" version = "21.3.0"
description = "Virtual Python Environment builder" description = "Virtual Python Environment builder"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["test"] groups = ["test"]
files = [ files = [
{file = "virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac"}, {file = "virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7"},
{file = "virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada"}, {file = "virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e"},
] ]
[package.dependencies] [package.dependencies]
@ -3749,4 +3706,4 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10" python-versions = ">=3.10"
content-hash = "ffde300b2fc93161d2279a6e2b899bc988d3b5eb3833135821830affc9a5fb62" content-hash = "66c9adf647316db90f963da05e8a83574378bfa4db2c69ce751446b5ee7c408c"

View File

@ -1,6 +1,6 @@
[project] [project]
name = "ria-toolkit-oss" name = "ria-toolkit-oss"
version = "0.1.5" version = "0.1.6"
description = "An open-source version of the RIA Toolkit, including the fundamental tools to get started developing, testing, and deploying radio intelligence applications" 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" } license = { text = "AGPL-3.0-only" }
readme = "README.md" readme = "README.md"
@ -50,7 +50,7 @@ dependencies = [
"pyyaml (>=6.0.3,<7.0.0)", "pyyaml (>=6.0.3,<7.0.0)",
"click (>=8.1.0,<9.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)" "paramiko (>=3.5.1)"
] ]
# [project.optional-dependencies] Commented out to prevent Tox tests from failing # [project.optional-dependencies] Commented out to prevent Tox tests from failing
@ -149,6 +149,11 @@ exclude = '''
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]
filterwarnings = [
# FastAPI emits this internally when handling 422 responses; the constant
# is not yet renamed in the installed starlette version, so we can't migrate.
"ignore:'HTTP_422_UNPROCESSABLE_ENTITY' is deprecated:DeprecationWarning",
]
[tool.isort] [tool.isort]
profile = "black" profile = "black"

View File

@ -68,7 +68,7 @@ _HEARTBEAT_INTERVAL = 30 # seconds between heartbeats
_POLL_TIMEOUT = 30 # server-side long-poll duration _POLL_TIMEOUT = 30 # server-side long-poll duration
_POLL_CLIENT_TIMEOUT = 40 # client read timeout — slightly longer than server _POLL_CLIENT_TIMEOUT = 40 # client read timeout — slightly longer than server
_RECONNECT_PAUSE = 5 # seconds to wait after a poll error before retrying _RECONNECT_PAUSE = 5 # seconds to wait after a poll error before retrying
_CHUNK_SIZE = 50 * 1024 * 1024 # 50 MB — well below Cloudflare's 100 MB limit _CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB per chunk — fast enough for git-LFS to process within timeout
_DIRECT_THRESHOLD = 90 * 1024 * 1024 # files above this use chunked upload _DIRECT_THRESHOLD = 90 * 1024 * 1024 # files above this use chunked upload
_CAPTURE_SAMPLES = 4096 # IQ samples per inference window _CAPTURE_SAMPLES = 4096 # IQ samples per inference window
_IDLE_LABELS = frozenset({"noise", "idle", "no_signal", "unknown_protocol", "background"}) _IDLE_LABELS = frozenset({"noise", "idle", "no_signal", "unknown_protocol", "background"})
@ -93,16 +93,24 @@ class NodeAgent:
name: str, name: str,
sdr_device: str = "unknown", sdr_device: str = "unknown",
insecure: bool = False, insecure: bool = False,
role: str = "general",
session_code: str | None = None,
) -> None: ) -> None:
self.hub_url = hub_url.rstrip("/") self.hub_url = hub_url.rstrip("/")
self.api_key = api_key self.api_key = api_key
self.name = name self.name = name
self.sdr_device = sdr_device self.sdr_device = sdr_device
self.insecure = insecure self.insecure = insecure
self.role = role
self.session_code = session_code
self.node_id: str | None = None self.node_id: str | None = None
self._stop = threading.Event() self._stop = threading.Event()
# ── TX state ────────────────────────────────────────────────────────
self._tx_stop = threading.Event()
self._tx_thread: threading.Thread | None = None
# ── Inference state ───────────────────────────────────────────────── # ── Inference state ─────────────────────────────────────────────────
# Protected by _inf_lock for cross-thread model swaps. # Protected by _inf_lock for cross-thread model swaps.
self._inf_lock = threading.Lock() self._inf_lock = threading.Lock()
@ -172,19 +180,27 @@ class NodeAgent:
capabilities = ["campaign"] capabilities = ["campaign"]
if self._ort_available: if self._ort_available:
capabilities.append("inference") capabilities.append("inference")
resp = self._post( if self.role == "tx":
"/composer/nodes/register", capabilities.append("transmit")
json={ payload: dict = {
"name": self.name, "name": self.name,
"sdr_device": self.sdr_device, "sdr_device": self.sdr_device,
"ria_toolkit_version": self._ria_version, "ria_toolkit_version": self._ria_version,
"capabilities": capabilities, "capabilities": capabilities,
}, "role": self.role,
timeout=15, }
) if self.session_code:
payload["session_code"] = self.session_code
resp = self._post("/composer/nodes/register", json=payload, timeout=15)
resp.raise_for_status() resp.raise_for_status()
self.node_id = resp.json()["node_id"] self.node_id = resp.json()["node_id"]
logger.info("Registered as %r (node_id=%s)", self.name, self.node_id) logger.info(
"Registered as %r (node_id=%s, role=%s%s)",
self.name,
self.node_id,
self.role,
f", session_code={self.session_code!r}" if self.session_code else "",
)
def _deregister(self) -> None: def _deregister(self) -> None:
if not self.node_id: if not self.node_id:
@ -245,9 +261,10 @@ class NodeAgent:
if command == "run_campaign": if command == "run_campaign":
campaign_id: str = cmd.get("campaign_id") or str(uuid.uuid4()) campaign_id: str = cmd.get("campaign_id") or str(uuid.uuid4())
config_dict: dict = cmd.get("payload") or {} config_dict: dict = cmd.get("payload") or {}
skip_local_tx: bool = bool(cmd.get("skip_local_tx", False))
threading.Thread( threading.Thread(
target=self._run_campaign, target=self._run_campaign,
args=(campaign_id, config_dict), args=(campaign_id, config_dict, skip_local_tx),
daemon=True, daemon=True,
name=f"campaign-{campaign_id[:8]}", name=f"campaign-{campaign_id[:8]}",
).start() ).start()
@ -269,6 +286,17 @@ class NodeAgent:
self._stop_inference() self._stop_inference()
elif command == "configure_inference": elif command == "configure_inference":
self._queue_sdr_config(cmd) self._queue_sdr_config(cmd)
elif command == "start_transmit":
threading.Thread(
target=self._start_transmit,
args=(cmd,),
daemon=True,
name="ria-start-tx",
).start()
elif command == "stop_transmit":
self._stop_transmit()
elif command == "configure_transmit":
logger.info("configure_transmit received — will apply on next step boundary")
else: else:
logger.warning("Unknown command %r — ignored", command) logger.warning("Unknown command %r — ignored", command)
@ -276,7 +304,7 @@ class NodeAgent:
# Campaign execution # Campaign execution
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _run_campaign(self, campaign_id: str, config_dict: dict) -> None: def _run_campaign(self, campaign_id: str, config_dict: dict, skip_local_tx: bool = False) -> None:
try: try:
from ria_toolkit_oss.orchestration.campaign import CampaignConfig from ria_toolkit_oss.orchestration.campaign import CampaignConfig
from ria_toolkit_oss.orchestration.executor import CampaignExecutor from ria_toolkit_oss.orchestration.executor import CampaignExecutor
@ -288,10 +316,10 @@ class NodeAgent:
) )
return return
logger.info("Campaign %s starting", campaign_id[:8]) logger.info("Campaign %s starting (skip_local_tx=%s)", campaign_id[:8], skip_local_tx)
try: try:
config = CampaignConfig.from_dict(config_dict) config = CampaignConfig.from_dict(config_dict)
executor = CampaignExecutor(config) executor = CampaignExecutor(config, skip_local_tx=skip_local_tx)
result = executor.run() result = executor.run()
logger.info("Campaign %s completed — uploading recordings", campaign_id[:8]) logger.info("Campaign %s completed — uploading recordings", campaign_id[:8])
self._upload_recordings(campaign_id, config, result) self._upload_recordings(campaign_id, config, result)
@ -301,6 +329,58 @@ class NodeAgent:
logger.error("Campaign %s failed: %s", campaign_id[:8], exc) logger.error("Campaign %s failed: %s", campaign_id[:8], exc)
self._report_campaign_status(campaign_id, "failed", error=str(exc)) self._report_campaign_status(campaign_id, "failed", error=str(exc))
# ------------------------------------------------------------------
# TX execution
# ------------------------------------------------------------------
def _start_transmit(self, cmd: dict) -> None:
"""Execute a synthetic transmit campaign using TxExecutor.
The command payload mirrors a TransmitterConfig dict with an optional
``schedule`` of steps. Each step synthesises a signal and transmits it
via the local SDR in TX mode.
"""
try:
from ria_toolkit_oss.orchestration.tx_executor import TxExecutor
except ImportError as exc:
logger.error("start_transmit: TxExecutor not available: %s", exc)
return
if self._tx_thread and self._tx_thread.is_alive():
logger.warning("start_transmit: TX already running — ignoring duplicate command")
return
self._tx_stop.clear()
campaign_id: str = cmd.get("campaign_id") or str(uuid.uuid4())
executor = TxExecutor(
config=cmd,
sdr_device=self.sdr_device,
stop_event=self._tx_stop,
)
self._tx_thread = threading.Thread(
target=self._run_tx_campaign,
args=(executor, campaign_id),
daemon=True,
name=f"tx-campaign-{campaign_id[:8]}",
)
self._tx_thread.start()
def _run_tx_campaign(self, executor: Any, campaign_id: str) -> None:
try:
executor.run()
logger.info("TX campaign %s completed", campaign_id[:8])
self._report_campaign_status(campaign_id, "completed")
except Exception as exc:
logger.error("TX campaign %s failed: %s", campaign_id[:8], exc)
self._report_campaign_status(campaign_id, "failed", error=str(exc))
def _stop_transmit(self) -> None:
"""Signal the TX loop to stop gracefully."""
self._tx_stop.set()
if self._tx_thread and self._tx_thread.is_alive():
self._tx_thread.join(timeout=5.0)
logger.info("TX stopped")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Inference — model loading # Inference — model loading
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -579,13 +659,18 @@ class NodeAgent:
base_url = f"{self.hub_url}/datasets/upload" base_url = f"{self.hub_url}/datasets/upload"
steps = (result.get("steps") if isinstance(result, dict) else getattr(result, "steps", None)) or [] steps = (result.get("steps") if isinstance(result, dict) else getattr(result, "steps", None)) or []
output_obj = getattr(config, "output", None)
folder = getattr(output_obj, "folder", None)
campaign_name: str = folder if folder is not None else (getattr(config, "name", None) or "")
for step in steps: for step in steps:
output_path: str | None = getattr(step, "output_path", None) output_path: str | None = getattr(step, "output_path", None)
if not output_path: if not output_path:
continue continue
device_id: str = getattr(step, "transmitter_id", "") or "" device_id: str = getattr(step, "transmitter_id", "") or ""
for fpath in _sigmf_files(output_path): for fpath in _sigmf_files(output_path):
filename = os.path.basename(fpath) basename = os.path.basename(fpath)
path_parts = [p for p in (campaign_name, device_id) if p]
filename = "/".join(path_parts + [basename])
metadata = { metadata = {
"filename": filename, "filename": filename,
"repo_owner": repo_owner, "repo_owner": repo_owner,
@ -671,7 +756,7 @@ class NodeAgent:
headers=headers, headers=headers,
files={"file": (filename, chunk, "application/octet-stream")}, files={"file": (filename, chunk, "application/octet-stream")},
data={**metadata, "upload_id": upload_id, "chunk_index": i, "total_chunks": total_chunks}, data={**metadata, "upload_id": upload_id, "chunk_index": i, "total_chunks": total_chunks},
timeout=120, timeout=(30, None), # 30s connect, no read timeout — server may take minutes on final chunk
verify=verify, verify=verify,
) )
if not resp.ok: if not resp.ok:
@ -848,6 +933,21 @@ def main() -> None:
choices=["DEBUG", "INFO", "WARNING", "ERROR"], choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Logging verbosity (default: INFO)", help="Logging verbosity (default: INFO)",
) )
parser.add_argument(
"--role",
default=None,
choices=["general", "rx", "tx"],
help=("Node role reported to the hub. " "'tx' enables synthetic transmission commands. " "Default: general"),
)
parser.add_argument(
"--session-code",
default=None,
metavar="CODE",
help=(
"3-word session code to pair this TX agent with a waiting campaign, "
"e.g. 'amber-peak-transmit'. Supplied by the campaign UI."
),
)
args = parser.parse_args() args = parser.parse_args()
@ -861,6 +961,8 @@ def main() -> None:
device = args.device or cfg.get("device", "unknown") device = args.device or cfg.get("device", "unknown")
insecure = args.insecure if args.insecure is not None else cfg.get("insecure", False) insecure = args.insecure if args.insecure is not None else cfg.get("insecure", False)
log_level = args.log_level or cfg.get("log_level", "INFO") log_level = args.log_level or cfg.get("log_level", "INFO")
role = args.role or cfg.get("role", "general")
session_code = args.session_code or cfg.get("session_code")
if not hub: if not hub:
parser.error("--hub is required (or set 'hub' in the config file)") parser.error("--hub is required (or set 'hub' in the config file)")
@ -888,6 +990,8 @@ def main() -> None:
name=name, name=name,
sdr_device=device, sdr_device=device,
insecure=insecure, insecure=insecure,
role=role,
session_code=session_code,
) )
agent.run() agent.run()

View File

@ -1,4 +1,4 @@
from ria_toolkit_oss.datatypes.annotation import Annotation from ria_toolkit_oss.data.annotation import Annotation
# TODO figure out how to transfer labels in the merge case # TODO figure out how to transfer labels in the merge case

View File

@ -3,7 +3,7 @@ from typing import Optional
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes import Annotation, Recording from ria_toolkit_oss.data import Annotation, Recording
def annotate_with_cusum( def annotate_with_cusum(
@ -24,7 +24,7 @@ def annotate_with_cusum(
changes between a low and high amplitude. changes between a low and high amplitude.
:param recording: A ``Recording`` object to annotate. :param recording: A ``Recording`` object to annotate.
:type recording: ``ria_toolkit_oss.datatypes.Recording`` :type recording: ``ria_toolkit_oss.data.Recording``
:param label: Label for the detected segments. :param label: Label for the detected segments.
:type label: str :type label: str
:param window_size: The length (in samples) of the moving average window. :param window_size: The length (in samples) of the moving average window.
@ -38,7 +38,13 @@ def annotate_with_cusum(
:type annotation_type: str :type annotation_type: str
""" """
sample_rate = recording.metadata["sample_rate"] sample_rate = recording.metadata.get("sample_rate")
if sample_rate is None:
raise ValueError(
"Recording metadata does not contain 'sample_rate'. "
"Supply it with --sample-rate when using the CLI, or set "
"recording.sample_rate before calling this function."
)
center_frequency = recording.metadata.get("center_frequency", 0) center_frequency = recording.metadata.get("center_frequency", 0)
# Create an object of the time segmenter # Create an object of the time segmenter

View File

@ -6,12 +6,13 @@ and occupied bandwidth calculation following ITU-R SM.328 standard.
""" """
import json import json
import warnings
from typing import Tuple from typing import Tuple
import numpy as np import numpy as np
from scipy.signal import filtfilt from scipy.signal import filtfilt
from ria_toolkit_oss.datatypes import Annotation, Recording from ria_toolkit_oss.data import Annotation, Recording
def detect_signals_energy( def detect_signals_energy(
@ -119,6 +120,17 @@ def detect_signals_energy(
if active: if active:
boundaries.append((start, len(smoothed_power) - start)) boundaries.append((start, len(smoothed_power) - start))
if not boundaries and noise_floor > 0:
peak = float(np.max(smoothed_power))
dynamic_range = peak / noise_floor
if dynamic_range < threshold_factor:
warnings.warn(
f"detect_signals_energy: no signal boundaries found — dynamic range {dynamic_range:.2f}x is below "
f"the threshold factor {threshold_factor}x. The signal may be constant-envelope (e.g. CW or chirp). "
"If the entire recording is signal, use 'ria annotate cusum' to segment it as a single region.",
stacklevel=2,
)
# Merge boundaries that are closer than min_distance # Merge boundaries that are closer than min_distance
merged_boundaries = [] merged_boundaries = []
if boundaries: if boundaries:
@ -135,7 +147,13 @@ def detect_signals_energy(
merged_boundaries.append((start, length)) merged_boundaries.append((start, length))
# Create annotations from detected boundaries # Create annotations from detected boundaries
sample_rate = recording.metadata["sample_rate"] sample_rate = recording.metadata.get("sample_rate")
if sample_rate is None:
raise ValueError(
"Recording metadata does not contain 'sample_rate'. "
"Supply it with --sample-rate when using the CLI, or set "
"recording.sample_rate before calling this function."
)
center_frequency = recording.metadata.get("center_frequency", 0) center_frequency = recording.metadata.get("center_frequency", 0)
# Validate frequency method # Validate frequency method
@ -351,7 +369,12 @@ def annotate_with_obw(
>>> annotated = annotate_with_obw(recording, label="signal_obw") >>> annotated = annotate_with_obw(recording, label="signal_obw")
""" """
signal = recording.data[0] signal = recording.data[0]
sample_rate = recording.metadata["sample_rate"] sample_rate = recording.metadata.get("sample_rate")
if sample_rate is None:
raise ValueError(
"Recording metadata does not contain 'sample_rate'. "
"Set recording.sample_rate before calling this function."
)
center_freq = recording.metadata.get("center_frequency", 0) center_freq = recording.metadata.get("center_frequency", 0)
# Calculate OBW # Calculate OBW

View File

@ -49,13 +49,14 @@ allowing splitting of overlapping signals into separate training samples.
""" """
import json import json
import warnings
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
import numpy as np import numpy as np
from scipy import ndimage from scipy import ndimage
from scipy import signal as scipy_signal from scipy import signal as scipy_signal
from ria_toolkit_oss.datatypes import Annotation, Recording from ria_toolkit_oss.data import Annotation, Recording
def find_spectral_components( def find_spectral_components(
@ -401,7 +402,13 @@ def split_recording_annotations(
return recording return recording
signal = recording.data[0] signal = recording.data[0]
sample_rate = recording.metadata["sample_rate"] sample_rate = recording.metadata.get("sample_rate")
if sample_rate is None:
raise ValueError(
"Recording metadata does not contain 'sample_rate'. "
"Supply it with --sample-rate when using the CLI, or set "
"recording.sample_rate before calling this function."
)
center_frequency = recording.metadata.get("center_frequency", 0.0) center_frequency = recording.metadata.get("center_frequency", 0.0)
# Build new annotation list # Build new annotation list
@ -425,8 +432,11 @@ def split_recording_annotations(
else: else:
# No components found, keep original # No components found, keep original
new_annotations.append(anno) new_annotations.append(anno)
except Exception: except Exception as e:
# Split failed for any reason, keep original warnings.warn(
f"split_recording_annotations: failed to split annotation at index {i} ({e}); keeping original.",
stacklevel=2,
)
new_annotations.append(anno) new_annotations.append(anno)
else: else:
# Not in split list, keep as-is # Not in split list, keep as-is

View File

@ -1,6 +1,6 @@
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
def qualify_slice_from_annotations(recording: Recording, slice_length: int): def qualify_slice_from_annotations(recording: Recording, slice_length: int):
@ -24,7 +24,7 @@ def qualify_slice_from_annotations(recording: Recording, slice_length: int):
output_recordings = [] output_recordings = []
for i in range((len(recording.data[0]) // slice_length) - 1): for i in range(len(recording.data[0]) // slice_length):
start_index = slice_length * i start_index = slice_length * i
end_index = slice_length * (i + 1) end_index = slice_length * (i + 1)

View File

@ -1,8 +1,8 @@
import numpy as np import numpy as np
from scipy.signal import butter, lfilter from scipy.signal import butter, lfilter
from ria_toolkit_oss.datatypes.annotation import Annotation from ria_toolkit_oss.data.annotation import Annotation
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
def isolate_signal(recording: Recording, annotation: Annotation) -> Recording: def isolate_signal(recording: Recording, annotation: Annotation) -> Recording:
@ -35,17 +35,24 @@ def isolate_signal(recording: Recording, annotation: Annotation) -> Recording:
isolation_bw = anno_bw isolation_bw = anno_bw
sample_rate = recording.metadata.get("sample_rate")
if sample_rate is None:
raise ValueError(
"Recording metadata does not contain 'sample_rate'. "
"Set recording.sample_rate before calling isolate_signal."
)
# frequency shift the center of the box about zero # frequency shift the center of the box about zero
shifted_signal_slice = frequency_shift_iq_samples( shifted_signal_slice = frequency_shift_iq_samples(
iq_samples=signal_slice, iq_samples=signal_slice,
sample_rate=recording.metadata["sample_rate"], sample_rate=sample_rate,
shift_frequency=-1 * anno_base_center_freq, shift_frequency=-1 * anno_base_center_freq,
) )
# filter # filter
if isolation_bw < recording.metadata["sample_rate"] - 1: if isolation_bw < sample_rate - 1:
filtered_signal = apply_complex_lowpass_filter( filtered_signal = apply_complex_lowpass_filter(
signal=shifted_signal_slice, cutoff_frequency=isolation_bw, sample_rate=recording.metadata["sample_rate"] signal=shifted_signal_slice, cutoff_frequency=isolation_bw, sample_rate=sample_rate
) )
else: else:

View File

@ -42,11 +42,12 @@ classification or demodulation stages.
""" """
import json import json
import warnings
from typing import Optional from typing import Optional
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes import Annotation, Recording from ria_toolkit_oss.data import Annotation, Recording
def _find_ranges(indices, max_gap): def _find_ranges(indices, max_gap):
@ -216,11 +217,22 @@ def threshold_qualifier(
""" """
# Extract signal and metadata # Extract signal and metadata
sample_data = recording.data[channel] sample_data = recording.data[channel]
sample_rate = recording.metadata["sample_rate"] sample_rate = recording.metadata.get("sample_rate")
if sample_rate is None:
raise ValueError(
"Recording metadata does not contain 'sample_rate'. "
"Supply it with --sample-rate when using the CLI, or set "
"recording.sample_rate before calling this function."
)
center_frequency = recording.metadata.get("center_frequency", 0) center_frequency = recording.metadata.get("center_frequency", 0)
n_samples = len(sample_data)
if window_size is None: if window_size is None:
window_size = max(64, int(sample_rate * 0.001)) window_size = max(64, int(sample_rate * 0.001))
# Cap at 1% of signal length so short recordings aren't over-smoothed into
# a flat envelope that collapses the dynamic range below the early-exit guard.
window_size = min(window_size, max(64, n_samples // 100))
# --- 1. SIGNAL CONDITIONING --- # --- 1. SIGNAL CONDITIONING ---
# Convert to power (Magnitude squared) # Convert to power (Magnitude squared)
@ -237,6 +249,12 @@ def threshold_qualifier(
# Soft early exit: keep a guard for low-contrast noise, but compute it from # 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. # the quieter tail of the envelope so burst-heavy captures are not rejected.
if dynamic_range_ratio < 1.5: if dynamic_range_ratio < 1.5:
warnings.warn(
f"threshold_qualifier: dynamic range ratio {dynamic_range_ratio:.2f} is below 1.5 — "
"the signal appears to be constant-envelope or pure noise, so no burst boundaries can be found. "
"If the entire recording is signal, use 'ria annotate cusum' to segment it as a single region.",
stacklevel=2,
)
return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations) return Recording(data=recording.data, metadata=recording.metadata, annotations=recording.annotations)
trigger_val = noise_floor + threshold * (max_power - noise_floor) trigger_val = noise_floor + threshold * (max_power - noise_floor)
@ -296,7 +314,7 @@ def threshold_qualifier(
# burst energy does not bleed through the long window into adjacent regions, # burst energy does not bleed through the long window into adjacent regions,
# which would inflate macro_residual_max and push the trigger above the # which would inflate macro_residual_max and push the trigger above the
# faint burst's average power. # faint burst's average power.
macro_window_size = max(window_size * 16, int(sample_rate * 0.02)) macro_window_size = min(max(window_size * 16, int(sample_rate * 0.02)), max(window_size * 2, n_samples // 4))
macro_kernel = np.ones(macro_window_size, dtype=np.float64) / macro_window_size 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 # 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- # the long convolution cannot "see" the leading/trailing edges of already-

View File

@ -57,7 +57,7 @@ class Annotation:
def is_valid(self) -> bool: def is_valid(self) -> bool:
""" """
Check that the annotation sample count is > 0 and the freq_lower_edge<freq_upper_edge. Verify ``sample_count > 0`` and the ``freq_lower_edge < freq_upper_edge``.
:returns: True if valid, False if not. :returns: True if valid, False if not.
""" """
@ -96,9 +96,9 @@ class Annotation:
def __eq__(self, other: Annotation) -> bool: def __eq__(self, other: Annotation) -> bool:
return self.__dict__ == other.__dict__ return self.__dict__ == other.__dict__
def to_sigmf_format(self): def to_sigmf_format(self) -> dict:
""" """
Returns a JSON dictionary representing this annotation formatted to be saved in a .sigmf-meta file. Returns a JSON dictionary representation, formatted for saving in a ``.sigmf-meta`` file.
""" """
annotation_dict = {SigMFFile.START_INDEX_KEY: self.sample_start, SigMFFile.LENGTH_INDEX_KEY: self.sample_count} annotation_dict = {SigMFFile.START_INDEX_KEY: self.sample_start, SigMFFile.LENGTH_INDEX_KEY: self.sample_count}
@ -119,7 +119,8 @@ class Annotation:
def _is_jsonable(x: Any) -> bool: def _is_jsonable(x: Any) -> bool:
""" """
:return: True if x is JSON serializable, False otherwise. :return: True if ``x`` is JSON serializable, False otherwise.
:rtype: bool
""" """
try: try:
json.dumps(x) json.dumps(x)

View File

@ -7,8 +7,8 @@ from typing import Any, Optional
from packaging.version import Version from packaging.version import Version
from ria_toolkit_oss.datatypes.datasets.license.dataset_license import DatasetLicense from ria_toolkit_oss.data.datasets.license.dataset_license import DatasetLicense
from ria_toolkit_oss.datatypes.datasets.radio_dataset import RadioDataset from ria_toolkit_oss.data.datasets.radio_dataset import RadioDataset
from ria_toolkit_oss.utils.abstract_attribute import abstract_attribute from ria_toolkit_oss.utils.abstract_attribute import abstract_attribute

View File

@ -7,11 +7,11 @@ from typing import Optional
import h5py import h5py
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes.datasets.h5helpers import ( from ria_toolkit_oss.data.datasets.h5helpers import (
append_entry_inplace, append_entry_inplace,
copy_dataset_entry_by_index, copy_dataset_entry_by_index,
) )
from ria_toolkit_oss.datatypes.datasets.radio_dataset import RadioDataset from ria_toolkit_oss.data.datasets.radio_dataset import RadioDataset
class IQDataset(RadioDataset, ABC): class IQDataset(RadioDataset, ABC):
@ -19,7 +19,7 @@ class IQDataset(RadioDataset, ABC):
radiofrequency (RF) signals represented as In-phase (I) and Quadrature (Q) samples. radiofrequency (RF) signals represented as In-phase (I) and Quadrature (Q) samples.
For machine learning tasks that involve processing spectrograms, please use For machine learning tasks that involve processing spectrograms, please use
ria_toolkit_oss.datatypes.datasets.SpectDataset instead. ria_toolkit_oss.data.datasets.SpectDataset instead.
This is an abstract interface defining common properties and behaviour of IQDatasets. Therefore, this class This is an abstract interface defining common properties and behaviour of IQDatasets. Therefore, this class
should not be instantiated directly. Instead, it is subclassed to define custom interfaces for specific machine should not be instantiated directly. Instead, it is subclassed to define custom interfaces for specific machine

View File

@ -12,7 +12,7 @@ import numpy as np
import pandas as pd import pandas as pd
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from ria_toolkit_oss.datatypes.datasets.h5helpers import ( from ria_toolkit_oss.data.datasets.h5helpers import (
append_entry_inplace, append_entry_inplace,
copy_file, copy_file,
copy_over_example, copy_over_example,
@ -29,7 +29,7 @@ class RadioDataset(ABC):
This is an abstract interface defining common properties and behavior of radio datasets. Therefore, this class This is an abstract interface defining common properties and behavior of radio datasets. Therefore, this class
should not be instantiated directly. Instead, it should be subclassed to define specific interfaces for different should not be instantiated directly. Instead, it should be subclassed to define specific interfaces for different
types of radio datasets. For example, see ria_toolkit_oss.datatypes.datasets.IQDataset, which is a radio dataset types of radio datasets. For example, see ria_toolkit_oss.data.datasets.IQDataset, which is a radio dataset
subclass tailored for tasks involving the processing of radio signals represented as IQ (In-phase and Quadrature) subclass tailored for tasks involving the processing of radio signals represented as IQ (In-phase and Quadrature)
samples. samples.

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import os import os
from abc import ABC from abc import ABC
from ria_toolkit_oss.datatypes.datasets.radio_dataset import RadioDataset from ria_toolkit_oss.data.datasets.radio_dataset import RadioDataset
class SpectDataset(RadioDataset, ABC): class SpectDataset(RadioDataset, ABC):
@ -13,7 +13,7 @@ class SpectDataset(RadioDataset, ABC):
radio signal spectrograms. radio signal spectrograms.
For machine learning tasks that involve processing on IQ samples, please use For machine learning tasks that involve processing on IQ samples, please use
ria_toolkit_oss.datatypes.datasets.IQDataset instead. ria_toolkit_oss.data.datasets.IQDataset instead.
This is an abstract interface defining common properties and behaviour of IQDatasets. Therefore, this class This is an abstract interface defining common properties and behaviour of IQDatasets. Therefore, this class
should not be instantiated directly. Instead, it is subclassed to define custom interfaces for specific machine should not be instantiated directly. Instead, it is subclassed to define custom interfaces for specific machine

View File

@ -6,11 +6,8 @@ from typing import Optional
import numpy as np import numpy as np
from numpy.random import Generator from numpy.random import Generator
from ria_toolkit_oss.datatypes.datasets import RadioDataset from ria_toolkit_oss.data.datasets import RadioDataset
from ria_toolkit_oss.datatypes.datasets.h5helpers import ( from ria_toolkit_oss.data.datasets.h5helpers import copy_over_example, make_empty_clone
copy_over_example,
make_empty_clone,
)
def split(dataset: RadioDataset, lengths: list[int | float]) -> list[RadioDataset]: def split(dataset: RadioDataset, lengths: list[int | float]) -> list[RadioDataset]:
@ -31,7 +28,7 @@ def split(dataset: RadioDataset, lengths: list[int | float]) -> list[RadioDatase
cases. cases.
This function is deterministic, meaning it will always produce the same split. For a random split, see This function is deterministic, meaning it will always produce the same split. For a random split, see
ria_toolkit_oss.datatypes.datasets.random_split. ria_toolkit_oss.data.datasets.random_split.
:param dataset: Dataset to be split. :param dataset: Dataset to be split.
:type dataset: RadioDataset :type dataset: RadioDataset
@ -50,7 +47,7 @@ def split(dataset: RadioDataset, lengths: list[int | float]) -> list[RadioDatase
>>> import string >>> import string
>>> import numpy as np >>> import numpy as np
>>> import pandas as pd >>> import pandas as pd
>>> from ria_toolkit_oss.datatypes.datasets import split >>> from ria_toolkit_oss.data.datasets import split
First, let's generate some random data: First, let's generate some random data:
@ -126,7 +123,7 @@ def random_split(
training and test datasets. training and test datasets.
This restriction makes it unlikely that a random split will produce datasets with the exact lengths specified. This restriction makes it unlikely that a random split will produce datasets with the exact lengths specified.
If it is important to ensure the closest possible split, consider using ria_toolkit_oss.datatypes.datasets.split If it is important to ensure the closest possible split, consider using ria_toolkit_oss.data.datasets.split
instead. instead.
:param dataset: Dataset to be split. :param dataset: Dataset to be split.
@ -144,7 +141,7 @@ def random_split(
:rtype: list of RadioDataset :rtype: list of RadioDataset
See Also: See Also:
ria_toolkit_oss.datatypes.datasets.split: Usage is the same as for ``random_split()``. ria_toolkit_oss.data.datasets.split: Usage is the same as for ``random_split()``.
""" """
if not isinstance(dataset, RadioDataset): if not isinstance(dataset, RadioDataset):
raise ValueError(f"'dataset' must be RadioDataset or one of its subclasses, got {type(dataset)}.") raise ValueError(f"'dataset' must be RadioDataset or one of its subclasses, got {type(dataset)}.")

View File

@ -12,7 +12,7 @@ from typing import Any, Iterator, Optional
import numpy as np import numpy as np
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from ria_toolkit_oss.datatypes.annotation import Annotation from ria_toolkit_oss.data.annotation import Annotation
PROTECTED_KEYS = ["rec_id", "timestamp"] PROTECTED_KEYS = ["rec_id", "timestamp"]
@ -26,7 +26,7 @@ class Recording:
Metadata is stored in a dictionary of key value pairs, Metadata is stored in a dictionary of key value pairs,
to include information such as sample_rate and center_frequency. to include information such as sample_rate and center_frequency.
Annotations are a list of :ref:`Annotation <utils.data.Annotation>`, Annotations are a list of :class:`~ria_toolkit_oss.data.Annotation`,
defining bounding boxes in time and frequency with labels and metadata. 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 Here, signal data is represented as a NumPy array. This class is then extended in the RIA Backends to provide
@ -46,7 +46,7 @@ class Recording:
:param metadata: Additional information associated with the recording. :param metadata: Additional information associated with the recording.
:type metadata: dict, optional :type metadata: dict, optional
:param annotations: A collection of ``Annotation`` objects defining bounding boxes. :param annotations: A collection of :class:`~ria_toolkit_oss.data.Annotation` objects defining bounding boxes.
:type annotations: list of Annotations, optional :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 :param dtype: Explicitly specify the data-type of the complex samples. Must be a complex NumPy type, such as
@ -66,7 +66,7 @@ class Recording:
**Examples:** **Examples:**
>>> import numpy >>> import numpy
>>> from ria_toolkit_oss.datatypes import Recording, Annotation >>> from ria_toolkit_oss.data import Recording, Annotation
>>> # Create an array of complex samples, just 1s in this case. >>> # Create an array of complex samples, just 1s in this case.
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
@ -146,7 +146,7 @@ class Recording:
self._metadata["timestamp"] = time.time() self._metadata["timestamp"] = time.time()
else: else:
if not isinstance(self._metadata["timestamp"], (int, float)): if not isinstance(self._metadata["timestamp"], (int, float)):
raise ValueError("timestamp must be int or float, not ", type(self._metadata["timestamp"])) raise ValueError(f"timestamp must be int or float, not {type(self._metadata['timestamp'])}")
if "rec_id" not in self.metadata: if "rec_id" not in self.metadata:
self._metadata["rec_id"] = generate_recording_id(data=self.data, timestamp=self._metadata["timestamp"]) self._metadata["rec_id"] = generate_recording_id(data=self.data, timestamp=self._metadata["timestamp"])
@ -274,7 +274,13 @@ class Recording:
:return: A new recording with the same metadata and data, with dtype. :return: A new recording with the same metadata and data, with dtype.
TODO: Add example usage.
**Examples:**
.. todo::
Usage examples coming soon!
""" """
# Rather than check for a valid datatype, let's cast and check the result. This makes it easier to provide # 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. # cross-platform support where the types are aliased across platforms.
@ -305,7 +311,7 @@ class Recording:
Create a recording and add metadata: Create a recording and add metadata:
>>> import numpy >>> import numpy
>>> from ria_toolkit_oss.datatypes import Recording >>> from ria_toolkit_oss.data import Recording
>>> >>>
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
>>> metadata = { >>> metadata = {
@ -360,7 +366,7 @@ class Recording:
Create a recording and update metadata: Create a recording and update metadata:
>>> import numpy >>> import numpy
>>> from ria_toolkit_oss.datatypes import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
>>> metadata = { >>> metadata = {
@ -387,6 +393,7 @@ class Recording:
""" """
if key not in self.metadata: if key not in self.metadata:
self.add_to_metadata(key=key, value=value) self.add_to_metadata(key=key, value=value)
return
if not _is_jsonable(value): if not _is_jsonable(value):
raise ValueError("Value must be JSON serializable.") raise ValueError("Value must be JSON serializable.")
@ -414,7 +421,7 @@ class Recording:
Create a recording and add metadata: Create a recording and add metadata:
>>> import numpy >>> import numpy
>>> from ria_toolkit_oss.datatypes import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
>>> metadata = { >>> metadata = {
@ -438,7 +445,7 @@ class Recording:
'rec_id': 'fda0f41...'} # Example value 'rec_id': 'fda0f41...'} # Example value
""" """
if key not in PROTECTED_KEYS: if key not in PROTECTED_KEYS:
self._metadata.pop(key) self._metadata.pop(key, None)
else: else:
raise ValueError(f"Key {key} is protected and cannot be modified or removed.") raise ValueError(f"Key {key} is protected and cannot be modified or removed.")
@ -447,7 +454,7 @@ class Recording:
:param output_path: The output image path. Defaults to "images/signal.png". :param output_path: The output image path. Defaults to "images/signal.png".
:type output_path: str, optional :type output_path: str, optional
:param kwargs: Keyword arguments passed on to utils.view.view_sig. :param kwargs: Keyword arguments passed on to ria_toolkit_oss.view.view_sig.
:type: dict of keyword arguments :type: dict of keyword arguments
**Examples:** **Examples:**
@ -455,7 +462,7 @@ class Recording:
Create a recording and view it as a plot in a .png image: Create a recording and view it as a plot in a .png image:
>>> import numpy >>> import numpy
>>> from ria_toolkit_oss.datatypes import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
>>> metadata = { >>> metadata = {
@ -466,14 +473,14 @@ class Recording:
>>> recording = Recording(data=samples, metadata=metadata) >>> recording = Recording(data=samples, metadata=metadata)
>>> recording.view() >>> recording.view()
""" """
from ria_toolkit_oss.view import view_sig from ria_toolkit_oss.view.view_signal import view_sig
view_sig(recording=self, output_path=output_path, **kwargs) view_sig(recording=self, output_path=output_path, **kwargs)
def simple_view(self, **kwargs) -> None: def simple_view(self, **kwargs) -> None:
"""Create a plot of various signal visualizations as a PNG or SVG image. """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. :param kwargs: Keyword arguments passed on to ria_toolkit_oss.view.view_signal_simple.view_simple_sig.
:type: dict of keyword arguments :type: dict of keyword arguments
**Examples:** **Examples:**
@ -481,7 +488,7 @@ class Recording:
Create a recording and view it as a plot in a .png image: Create a recording and view it as a plot in a .png image:
>>> import numpy >>> import numpy
>>> from ria_toolkit_oss.datatypes import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
>>> metadata = { >>> metadata = {
@ -504,7 +511,7 @@ class Recording:
The SigMF io format is defined by the `SigMF Specification Project <https://github.com/sigmf/SigMF>`_ The SigMF io format is defined by the `SigMF Specification Project <https://github.com/sigmf/SigMF>`_
:param recording: The recording to be written to file. :param recording: The recording to be written to file.
:type recording: utils.data.Recording :type recording: ria_toolkit_oss.data.Recording
:param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. :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 :type filename: os.PathLike or str, optional
:param path: The directory path to where the recording is to be saved. Defaults to recordings/. :param path: The directory path to where the recording is to be saved. Defaults to recordings/.
@ -513,22 +520,6 @@ class Recording:
:raises IOError: If there is an issue encountered during the file writing process. :raises IOError: If there is an issue encountered during the file writing process.
:return: None :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 from ria_toolkit_oss.io.recording import to_sigmf
@ -554,7 +545,7 @@ class Recording:
Create a recording and save it to a .npy file: Create a recording and save it to a .npy file:
>>> import numpy >>> import numpy
>>> from utils.data import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
>>> metadata = { >>> metadata = {
@ -605,7 +596,7 @@ class Recording:
Create a recording and save it to a .wav file: Create a recording and save it to a .wav file:
>>> import numpy >>> import numpy
>>> from utils.data import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.exp(1j * 2 * numpy.pi * 0.1 * numpy.arange(10000)) >>> samples = numpy.exp(1j * 2 * numpy.pi * 0.1 * numpy.arange(10000))
>>> metadata = {"sample_rate": 1e6, "center_frequency": 915e6} >>> metadata = {"sample_rate": 1e6, "center_frequency": 915e6}
>>> recording = Recording(data=samples, metadata=metadata) >>> recording = Recording(data=samples, metadata=metadata)
@ -655,7 +646,7 @@ class Recording:
Create a recording and save it to a .blue file: Create a recording and save it to a .blue file:
>>> import numpy >>> import numpy
>>> from utils.data import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
>>> metadata = {"sample_rate": 1e6, "center_frequency": 2.44e9} >>> metadata = {"sample_rate": 1e6, "center_frequency": 2.44e9}
>>> recording = Recording(data=samples, metadata=metadata) >>> recording = Recording(data=samples, metadata=metadata)
@ -668,36 +659,36 @@ class Recording:
def trim(self, num_samples: int, start_sample: Optional[int] = 0) -> Recording: def trim(self, num_samples: int, start_sample: Optional[int] = 0) -> Recording:
"""Trim Recording samples to a desired length, shifting annotations to maintain alignment. """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. :param start_sample: The start index of the desired trimmed recording. Defaults to 0.
:type start_sample: int, optional :type start_sample: int, optional
:param num_samples: The number of samples that the output trimmed recording will have. :param num_samples: The number of samples that the output trimmed recording will have.
:type num_samples: int :type num_samples: int
:raises IndexError: If start_sample + num_samples is greater than the length of the recording. :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. :raises IndexError: If sample_start < 0 or num_samples < 0.
:return: The trimmed Recording. :return: The trimmed Recording.
:rtype: Recording :rtype: Recording
**Examples:** **Examples:**
Create a recording and trim it: Create a recording and trim it:
>>> import numpy >>> import numpy
>>> from utils.data import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.ones(10000, dtype=numpy.complex64) >>> samples = numpy.ones(10000, dtype=numpy.complex64)
>>> metadata = { >>> metadata = {
... "sample_rate": 1e6, ... "sample_rate": 1e6,
... "center_frequency": 2.44e9, ... "center_frequency": 2.44e9,
... } ... }
>>> recording = Recording(data=samples, metadata=metadata) >>> recording = Recording(data=samples, metadata=metadata)
>>> print(len(recording)) >>> print(len(recording))
10000 10000
>>> trimmed_recording = recording.trim(start_sample=1000, num_samples=1000) >>> trimmed_recording = recording.trim(start_sample=1000, num_samples=1000)
>>> print(len(trimmed_recording)) >>> print(len(trimmed_recording))
1000 1000
""" """
if start_sample < 0: if start_sample < 0:
@ -712,7 +703,14 @@ class Recording:
data = self.data[:, start_sample:end_sample] data = self.data[:, start_sample:end_sample]
new_annotations = copy.deepcopy(self.annotations) new_annotations = copy.deepcopy(self.annotations)
trimmed_annotations = []
for annotation in new_annotations: for annotation in new_annotations:
# skip annotations entirely outside the trim window
if annotation.sample_start + annotation.sample_count <= start_sample:
continue
if annotation.sample_start >= end_sample:
continue
# trim annotation if it goes outside the trim boundaries # trim annotation if it goes outside the trim boundaries
if annotation.sample_start < start_sample: if annotation.sample_start < start_sample:
annotation.sample_count = annotation.sample_count - (start_sample - annotation.sample_start) annotation.sample_count = annotation.sample_count - (start_sample - annotation.sample_start)
@ -723,8 +721,9 @@ class Recording:
# shift annotation to align with the new start point # shift annotation to align with the new start point
annotation.sample_start = annotation.sample_start - start_sample annotation.sample_start = annotation.sample_start - start_sample
trimmed_annotations.append(annotation)
return Recording(data=data, metadata=self.metadata, annotations=new_annotations) return Recording(data=data, metadata=self.metadata, annotations=trimmed_annotations)
def normalize(self) -> Recording: def normalize(self) -> Recording:
"""Scale the recording data, relative to its maximum value, so that the magnitude of the maximum sample is 1. """Scale the recording data, relative to its maximum value, so that the magnitude of the maximum sample is 1.
@ -737,7 +736,7 @@ class Recording:
Create a recording with maximum amplitude 0.5 and normalize to a maximum amplitude of 1: Create a recording with maximum amplitude 0.5 and normalize to a maximum amplitude of 1:
>>> import numpy >>> import numpy
>>> from utils.data import Recording >>> from ria_toolkit_oss.data import Recording
>>> samples = numpy.ones(10000, dtype=numpy.complex64) * 0.5 >>> samples = numpy.ones(10000, dtype=numpy.complex64) * 0.5
>>> metadata = { >>> metadata = {
@ -753,7 +752,10 @@ class Recording:
>>> print(numpy.max(numpy.abs(normalized_recording.data))) >>> print(numpy.max(numpy.abs(normalized_recording.data)))
1 1
""" """
scaled_data = self.data / np.max(abs(self.data)) max_val = np.max(abs(self.data))
if max_val == 0:
raise ValueError("Cannot normalize a recording with all-zero data.")
scaled_data = self.data / max_val
return Recording(data=scaled_data, metadata=self.metadata, annotations=self.annotations) return Recording(data=scaled_data, metadata=self.metadata, annotations=self.annotations)
def __len__(self) -> int: def __len__(self) -> int:

View File

@ -1,8 +0,0 @@
"""
The datatypes package contains abstract data types tailored for radio machine learning.
"""
__all__ = ["Annotation", "Recording"]
from .annotation import Annotation
from .recording import Recording

View File

@ -1,129 +0,0 @@
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:
"""
Verify ``sample_count > 0`` and the ``freq_lower_edge < freq_upper_edge``.
:returns: True if valid, False if not.
"""
return self.sample_count > 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) -> dict:
"""
Returns a JSON dictionary representation, formatted for saving 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.
:rtype: bool
"""
try:
json.dumps(x)
return True
except (TypeError, OverflowError):
return False

View File

@ -1,855 +0,0 @@
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 :class:`~ria_toolkit_oss.datatypes.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 :class:`~ria_toolkit_oss.datatypes.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(f"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 is '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.
**Examples:**
.. todo::
Usage examples coming soon!
"""
# 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)
return
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, None)
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 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.view.view_signal 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 utils.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 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.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 <https://github.com/sigmf/SigMF>`_
:param recording: The recording to be written to file.
:type recording: ria_toolkit_oss.datatypes.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
"""
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 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.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 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(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)
trimmed_annotations = []
for annotation in new_annotations:
# skip annotations entirely outside the trim window
if annotation.sample_start + annotation.sample_count <= start_sample:
continue
if annotation.sample_start >= end_sample:
continue
# 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
trimmed_annotations.append(annotation)
return Recording(data=data, metadata=self.metadata, annotations=trimmed_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 ria_toolkit_oss.datatypes 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
"""
max_val = np.max(abs(self.data))
if max_val == 0:
raise ValueError("Cannot normalize a recording with all-zero data.")
scaled_data = self.data / max_val
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

View File

@ -1,5 +1,5 @@
""" """
Utilities for input/output operations on the ria_toolkit_oss.datatypes.Recording object. Utilities for input/output operations on the ria_toolkit_oss.data.Recording object.
""" """
import datetime import datetime
@ -19,8 +19,8 @@ from quantiphy import Quantity
from sigmf import SigMFFile, sigmffile from sigmf import SigMFFile, sigmffile
from sigmf.utils import get_data_type_str from sigmf.utils import get_data_type_str
from ria_toolkit_oss.datatypes import Annotation from ria_toolkit_oss.data import Annotation
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
_BLUE_META_PREFIX = "META_" _BLUE_META_PREFIX = "META_"
_BLUE_META_TAG_MAX_LEN = 60 _BLUE_META_TAG_MAX_LEN = 60
@ -64,7 +64,7 @@ def to_npy(
"""Write recording to ``.npy`` binary file. """Write recording to ``.npy`` binary file.
:param recording: The recording to be written to file. :param recording: The recording to be written to file.
:type recording: ria_toolkit_oss.datatypes.Recording :type recording: ria_toolkit_oss.data.Recording
:param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. :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 :type filename: os.PathLike or str, optional
:param path: The directory path to where the recording is to be saved. Defaults to recordings/. :param path: The directory path to where the recording is to be saved. Defaults to recordings/.
@ -135,7 +135,7 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording:
:raises IOError: If there is an issue encountered during the file reading process. :raises IOError: If there is an issue encountered during the file reading process.
:return: The recording, as initialized from the ``.npy`` file. :return: The recording, as initialized from the ``.npy`` file.
:rtype: ria_toolkit_oss.datatypes.Recording :rtype: ria_toolkit_oss.data.Recording
""" """
filename, extension = os.path.splitext(file) filename, extension = os.path.splitext(file)
@ -161,7 +161,7 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording:
try: try:
raw_ann = np.load(f, allow_pickle=False) raw_ann = np.load(f, allow_pickle=False)
ann_list = json.loads(raw_ann.tobytes().decode()) ann_list = json.loads(raw_ann.tobytes().decode())
from ria_toolkit_oss.datatypes.annotation import Annotation from ria_toolkit_oss.data.annotation import Annotation
annotations = [Annotation(**a) for a in ann_list] annotations = [Annotation(**a) for a in ann_list]
except EOFError: except EOFError:
@ -175,6 +175,15 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording:
) )
data = first # already loaded without pickle (numeric array) data = first # already loaded without pickle (numeric array)
metadata = np.load(f, allow_pickle=True).tolist() metadata = np.load(f, allow_pickle=True).tolist()
# Normalize namespaced keys (e.g. "BlockGenerator:Foo:sample_rate") to
# their bare equivalents so downstream code can find them reliably.
_STANDARD_KEYS = {"sample_rate", "center_frequency", "bandwidth"}
if isinstance(metadata, dict):
for k in list(metadata):
if ":" in k:
bare = k.rsplit(":", 1)[-1]
if bare in _STANDARD_KEYS and bare not in metadata:
metadata[bare] = metadata[k]
try: try:
annotations = list(np.load(f, allow_pickle=True)) annotations = list(np.load(f, allow_pickle=True))
except EOFError: except EOFError:
@ -198,7 +207,7 @@ def from_npy_legacy(file: os.PathLike | str) -> Recording:
:raises IOError: If there is an issue encountered during the file reading process. :raises IOError: If there is an issue encountered during the file reading process.
:return: The recording, as initialized from the legacy ``.npy`` file. :return: The recording, as initialized from the legacy ``.npy`` file.
:rtype: ria_toolkit_oss.datatypes.Recording :rtype: ria_toolkit_oss.data.Recording
**Examples:** **Examples:**
@ -270,7 +279,7 @@ def to_sigmf(
The SigMF io format is defined by the `SigMF Specification Project <https://github.com/sigmf/SigMF>`_ The SigMF io format is defined by the `SigMF Specification Project <https://github.com/sigmf/SigMF>`_
:param recording: The recording to be written to file. :param recording: The recording to be written to file.
:type recording: ria_toolkit_oss.datatypes.Recording :type recording: ria_toolkit_oss.data.Recording
:param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. :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 :type filename: os.PathLike or str, optional
:param path: The directory path to where the recording is to be saved. Defaults to recordings/. :param path: The directory path to where the recording is to be saved. Defaults to recordings/.
@ -381,7 +390,7 @@ def from_sigmf(file: os.PathLike | str) -> Recording:
:raises IOError: If there is an issue encountered during the file reading process. :raises IOError: If there is an issue encountered during the file reading process.
:return: The recording, as initialized from the SigMF files. :return: The recording, as initialized from the SigMF files.
:rtype: ria_toolkit_oss.datatypes.Recording :rtype: ria_toolkit_oss.data.Recording
""" """
file = str(file) file = str(file)
@ -443,7 +452,7 @@ def to_wav(
in the ICMT (comment) field for human readability. in the ICMT (comment) field for human readability.
:param recording: The recording to be written to file. :param recording: The recording to be written to file.
:type recording: ria_toolkit_oss.datatypes.Recording :type recording: ria_toolkit_oss.data.Recording
:param filename: The name of the file where the recording is to be saved. :param filename: The name of the file where the recording is to be saved.
Defaults to auto-generated filename. Defaults to auto-generated filename.
:type filename: str, optional :type filename: str, optional
@ -553,7 +562,7 @@ def from_wav(file: os.PathLike | str) -> Recording:
:raises ValueError: If file is not stereo or has unsupported format. :raises ValueError: If file is not stereo or has unsupported format.
:return: The recording, as initialized from the WAV file. :return: The recording, as initialized from the WAV file.
:rtype: ria_toolkit_oss.datatypes.Recording :rtype: ria_toolkit_oss.data.Recording
""" """
import wave import wave
@ -635,7 +644,7 @@ def to_blue(
Commonly used with X-Midas and other RF/radar signal processing tools. Commonly used with X-Midas and other RF/radar signal processing tools.
:param recording: The recording to be written to file. :param recording: The recording to be written to file.
:type recording: ria_toolkit_oss.datatypes.Recording :type recording: ria_toolkit_oss.data.Recording
:param filename: The name of the file where the recording is to be saved. :param filename: The name of the file where the recording is to be saved.
Defaults to auto-generated filename. Defaults to auto-generated filename.
:type filename: str, optional :type filename: str, optional
@ -792,7 +801,7 @@ def from_blue(file: os.PathLike | str) -> Recording:
:raises ValueError: If file format is not valid or unsupported. :raises ValueError: If file format is not valid or unsupported.
:return: The recording, as initialized from the Blue file. :return: The recording, as initialized from the Blue file.
:rtype: ria_toolkit_oss.datatypes.Recording :rtype: ria_toolkit_oss.data.Recording
""" """
filename = str(file) filename = str(file)
if not filename.endswith(".blue"): if not filename.endswith(".blue"):
@ -917,7 +926,7 @@ def load_recording(file: os.PathLike) -> Recording:
:raises ValueError: If the inferred file extension is not supported. :raises ValueError: If the inferred file extension is not supported.
:return: The recording, as initialized from file(s). :return: The recording, as initialized from file(s).
:rtype: ria_toolkit_oss.datatypes.Recording :rtype: ria_toolkit_oss.data.Recording
""" """
_, extension = os.path.splitext(file) _, extension = os.path.splitext(file)
extension = extension.lstrip(".") extension = extension.lstrip(".")

View File

@ -233,6 +233,9 @@ class TransmitterConfig:
# For sdr_remote control — keys: host, ssh_user, ssh_key_path, device_type, device_id, zmq_port # For sdr_remote control — keys: host, ssh_user, ssh_key_path, device_type, device_id, zmq_port
sdr_remote: Optional[dict] = None sdr_remote: Optional[dict] = None
# For sdr_agent control — keys: modulation, order, symbol_rate, center_frequency, filter, rolloff
sdr_agent: Optional[dict] = None
@classmethod @classmethod
def from_dict(cls, d: dict) -> "TransmitterConfig": def from_dict(cls, d: dict) -> "TransmitterConfig":
schedule = [CaptureStep.from_dict(s) for s in d.get("schedule", [])] schedule = [CaptureStep.from_dict(s) for s in d.get("schedule", [])]
@ -244,6 +247,7 @@ class TransmitterConfig:
script=d.get("script"), script=d.get("script"),
device=d.get("device"), device=d.get("device"),
sdr_remote=d.get("sdr_remote"), sdr_remote=d.get("sdr_remote"),
sdr_agent=d.get("sdr_agent"),
) )
@ -272,6 +276,7 @@ class OutputConfig:
path: str = "recordings" path: str = "recordings"
device_id: Optional[str] = None # for device-profile campaigns device_id: Optional[str] = None # for device-profile campaigns
repo: Optional[str] = None repo: Optional[str] = None
folder: Optional[str] = None # repo subfolder: None = use campaign name, "" = no subfolder, str = custom
@classmethod @classmethod
def from_dict(cls, d: dict) -> "OutputConfig": def from_dict(cls, d: dict) -> "OutputConfig":
@ -280,6 +285,7 @@ class OutputConfig:
path=str(d.get("path", "recordings")), path=str(d.get("path", "recordings")),
device_id=d.get("device_id"), device_id=d.get("device_id"),
repo=d.get("repo"), repo=d.get("repo"),
folder=d.get("folder"),
) )
@ -293,6 +299,7 @@ class CampaignConfig:
qa: QAConfig = field(default_factory=QAConfig) qa: QAConfig = field(default_factory=QAConfig)
output: OutputConfig = field(default_factory=OutputConfig) output: OutputConfig = field(default_factory=OutputConfig)
mode: str = "controlled_testbed" mode: str = "controlled_testbed"
loops: int = 1 # repeat full schedule this many times; labels get _run{N:02d} suffix
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Loaders # Loaders
@ -320,6 +327,7 @@ class CampaignConfig:
return cls( return cls(
name=safe_name, name=safe_name,
mode=str(campaign_meta.get("mode", "controlled_testbed")), mode=str(campaign_meta.get("mode", "controlled_testbed")),
loops=max(1, int(campaign_meta.get("loops", 1))),
recorder=RecorderConfig.from_dict(raw["recorder"]), recorder=RecorderConfig.from_dict(raw["recorder"]),
transmitters=transmitters, transmitters=transmitters,
qa=QAConfig.from_dict(raw.get("qa", {})), qa=QAConfig.from_dict(raw.get("qa", {})),
@ -384,6 +392,7 @@ class CampaignConfig:
return cls( return cls(
name=safe_name, name=safe_name,
mode=str(campaign_meta.get("mode", "controlled_testbed")), mode=str(campaign_meta.get("mode", "controlled_testbed")),
loops=max(1, int(campaign_meta.get("loops", 1))),
recorder=RecorderConfig.from_dict(raw["recorder"]), recorder=RecorderConfig.from_dict(raw["recorder"]),
transmitters=transmitters, transmitters=transmitters,
qa=QAConfig.from_dict(raw.get("qa", {})), qa=QAConfig.from_dict(raw.get("qa", {})),
@ -486,9 +495,9 @@ class CampaignConfig:
) )
def total_capture_time_s(self) -> float: def total_capture_time_s(self) -> float:
"""Sum of all step durations across all transmitters.""" """Sum of all step durations across all transmitters and loops."""
return sum(step.duration for tx in self.transmitters for step in tx.schedule) return sum(step.duration for tx in self.transmitters for step in tx.schedule) * self.loops
def total_steps(self) -> int: def total_steps(self) -> int:
"""Total number of capture steps across all transmitters.""" """Total number of capture steps across all transmitters and loops."""
return sum(len(tx.schedule) for tx in self.transmitters) return sum(len(tx.schedule) for tx in self.transmitters) * self.loops

View File

@ -5,17 +5,19 @@ from __future__ import annotations
import json import json
import logging import logging
import subprocess import subprocess
import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.io.recording import to_sigmf from ria_toolkit_oss.io.recording import to_sigmf
from .campaign import CampaignConfig, CaptureStep, TransmitterConfig from .campaign import CampaignConfig, CaptureStep, TransmitterConfig
from .labeler import build_output_filename, label_recording from .labeler import build_output_filename, label_recording
from .qa import QAResult, check_recording from .qa import QAResult, check_recording
from .tx_executor import TxExecutor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -169,6 +171,21 @@ def _run_script(script: str, *args: str, timeout: float = 15.0) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_tx_params(transmitter: TransmitterConfig) -> dict | None:
"""Build a tx_params dict from a transmitter's signal config for SigMF labeling.
For sdr_agent transmitters, returns the synthetic generation parameters
(modulation, order, symbol_rate, etc.) so recordings capture what was
transmitted. Returns None for control methods without signal-level params.
"""
sdr_agent_cfg = getattr(transmitter, "sdr_agent", None)
if not sdr_agent_cfg:
return None
# Extract known signal-level fields; ignore infra fields
_INFRA_KEYS = {"node_id", "session_code"}
return {k: v for k, v in sdr_agent_cfg.items() if k not in _INFRA_KEYS and v is not None}
class CampaignExecutor: class CampaignExecutor:
"""Executes a :class:`CampaignConfig` end-to-end. """Executes a :class:`CampaignConfig` end-to-end.
@ -192,11 +209,14 @@ class CampaignExecutor:
config: CampaignConfig, config: CampaignConfig,
progress_cb: Optional[Callable[[int, int, StepResult], None]] = None, progress_cb: Optional[Callable[[int, int, StepResult], None]] = None,
verbose: bool = False, verbose: bool = False,
skip_local_tx: bool = False,
): ):
self.config = config self.config = config
self.progress_cb = progress_cb self.progress_cb = progress_cb
self.skip_local_tx = skip_local_tx
self._sdr = None self._sdr = None
self._remote_tx_controllers: dict = {} self._remote_tx_controllers: dict = {}
self._tx_executors: dict[str, tuple] = {} # tx_id → (TxExecutor, stop_event, thread)
if verbose: if verbose:
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
@ -216,10 +236,12 @@ class CampaignExecutor:
""" """
result = CampaignResult(campaign_name=self.config.name) result = CampaignResult(campaign_name=self.config.name)
loops = self.config.loops
logger.info( logger.info(
f"Starting campaign '{self.config.name}': " f"Starting campaign '{self.config.name}': "
f"{self.config.total_steps()} steps, " f"{self.config.total_steps()} steps"
f"~{self.config.total_capture_time_s():.0f}s capture time" + (f" ({self.config.total_steps() // loops} × {loops} loops)" if loops > 1 else "")
+ f", ~{self.config.total_capture_time_s():.0f}s capture time"
) )
self._init_sdr() self._init_sdr()
@ -228,29 +250,36 @@ class CampaignExecutor:
total = self.config.total_steps() total = self.config.total_steps()
step_index = 0 step_index = 0
for transmitter in self.config.transmitters: for loop_idx in range(loops):
logger.info(f"Transmitter: {transmitter.id} ({len(transmitter.schedule)} steps)") if loops > 1:
for step in transmitter.schedule: logger.info(f"Loop {loop_idx + 1}/{loops}")
step_result = self._execute_step(transmitter, step) for transmitter in self.config.transmitters:
result.steps.append(step_result) logger.info(f"Transmitter: {transmitter.id} ({len(transmitter.schedule)} steps)")
step_index += 1 for step in transmitter.schedule:
looped_step = replace(step, label=f"{step.label}_run{loop_idx + 1:02d}") if loops > 1 else step
step_result = self._execute_step(transmitter, looped_step)
result.steps.append(step_result)
step_index += 1
if self.progress_cb: if self.progress_cb:
self.progress_cb(step_index, total, step_result) self.progress_cb(step_index, total, step_result)
if step_result.error: if step_result.error:
logger.warning(f"Step '{step.label}' error: {step_result.error}") logger.warning(f"Step '{looped_step.label}' error: {step_result.error}")
elif step_result.qa.flagged: elif step_result.qa.flagged:
logger.warning(f"Step '{step.label}' flagged for review: " + "; ".join(step_result.qa.issues)) logger.warning(
else: f"Step '{looped_step.label}' flagged for review: " + "; ".join(step_result.qa.issues)
logger.info( )
f"Step '{step.label}' OK " else:
f"(SNR {step_result.qa.snr_db:.1f} dB, " logger.info(
f"{step_result.qa.duration_s:.1f}s)" f"Step '{looped_step.label}' OK "
) f"(SNR {step_result.qa.snr_db:.1f} dB, "
f"{step_result.qa.duration_s:.1f}s)"
)
finally: finally:
self._close_sdr() self._close_sdr()
self._close_remote_tx_controllers() self._close_remote_tx_controllers()
self._close_tx_executors()
result.end_time = time.time() result.end_time = time.time()
logger.info( logger.info(
@ -325,6 +354,12 @@ class CampaignExecutor:
logger.warning(f"Error closing remote Tx controller {tx_id}: {exc}") logger.warning(f"Error closing remote Tx controller {tx_id}: {exc}")
self._remote_tx_controllers.clear() self._remote_tx_controllers.clear()
def _close_tx_executors(self) -> None:
for tx_id, (_, stop_event, t) in list(self._tx_executors.items()):
stop_event.set()
t.join(timeout=5.0)
self._tx_executors.clear()
def _record(self, duration_s: float) -> Recording: def _record(self, duration_s: float) -> Recording:
"""Capture ``duration_s`` seconds of IQ samples.""" """Capture ``duration_s`` seconds of IQ samples."""
num_samples = int(duration_s * self.config.recorder.sample_rate) num_samples = int(duration_s * self.config.recorder.sample_rate)
@ -369,6 +404,7 @@ class CampaignExecutor:
step=step, step=step,
capture_timestamp=capture_timestamp, capture_timestamp=capture_timestamp,
campaign_name=self.config.name, campaign_name=self.config.name,
tx_params=_extract_tx_params(transmitter),
) )
# QA # QA
@ -437,6 +473,30 @@ class CampaignExecutor:
# Start transmission in background; _record() runs concurrently # Start transmission in background; _record() runs concurrently
ctrl.transmit_async(step.duration + 1.0) ctrl.transmit_async(step.duration + 1.0)
elif transmitter.control_method == "sdr_agent":
if self.skip_local_tx:
logger.debug(f"skip_local_tx — TX for '{transmitter.id}' delegated to TX agent node")
return
if not transmitter.sdr_agent:
logger.warning(f"Transmitter '{transmitter.id}' has no sdr_agent config — skipping")
return
step_dict: dict = {"label": step.label, "duration": step.duration + 1.0}
if step.power_dbm is not None:
step_dict["power_dbm"] = step.power_dbm
tx_config = {
"id": transmitter.id,
"sdr_agent": transmitter.sdr_agent,
"schedule": [step_dict],
}
rec = self.config.recorder
tx_device = transmitter.device or rec.device
sdr_device = _DEVICE_ALIASES.get(tx_device.lower(), tx_device.lower())
stop_event = threading.Event()
executor = TxExecutor(tx_config, sdr_device=sdr_device, stop_event=stop_event)
t = threading.Thread(target=executor.run, daemon=True, name=f"tx-{transmitter.id}")
self._tx_executors[transmitter.id] = (executor, stop_event, t)
t.start()
else: else:
logger.warning(f"Unknown control method '{transmitter.control_method}' — skipping") logger.warning(f"Unknown control method '{transmitter.control_method}' — skipping")
@ -459,6 +519,13 @@ class CampaignExecutor:
if ctrl is not None: if ctrl is not None:
ctrl.wait_transmit(timeout=step.duration + 10.0) ctrl.wait_transmit(timeout=step.duration + 10.0)
elif transmitter.control_method == "sdr_agent":
entry = self._tx_executors.pop(transmitter.id, None)
if entry is not None:
_, stop_event, t = entry
stop_event.set()
t.join(timeout=step.duration + 10.0)
@staticmethod @staticmethod
def _step_params_json(transmitter: TransmitterConfig, step: CaptureStep) -> str: def _step_params_json(transmitter: TransmitterConfig, step: CaptureStep) -> str:
"""Serialise step parameters to a JSON string for the control script.""" """Serialise step parameters to a JSON string for the control script."""

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Optional from typing import Optional
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from .campaign import CaptureStep from .campaign import CaptureStep
@ -15,6 +15,7 @@ def label_recording(
step: CaptureStep, step: CaptureStep,
capture_timestamp: float, capture_timestamp: float,
campaign_name: Optional[str] = None, campaign_name: Optional[str] = None,
tx_params: Optional[dict] = None,
) -> Recording: ) -> Recording:
"""Apply device identity and capture configuration labels to a recording's metadata. """Apply device identity and capture configuration labels to a recording's metadata.
@ -27,6 +28,9 @@ def label_recording(
step: The capture step that was active during this recording. step: The capture step that was active during this recording.
capture_timestamp: Unix timestamp (float) of when capture started. capture_timestamp: Unix timestamp (float) of when capture started.
campaign_name: Optional campaign name for cross-recording reference. campaign_name: Optional campaign name for cross-recording reference.
tx_params: Optional dict of transmitter signal parameters (e.g. modulation,
order, symbol_rate) written as ``ria:tx_<key>`` fields so downstream
training pipelines know what was transmitted into the recording.
Returns: Returns:
The same recording with updated metadata. The same recording with updated metadata.
@ -57,6 +61,11 @@ def label_recording(
if step.power_dbm is not None: if step.power_dbm is not None:
recording.update_metadata("tx_power_dbm", step.power_dbm) recording.update_metadata("tx_power_dbm", step.power_dbm)
# Transmitter signal parameters (e.g. from sdr_agent synthetic generation)
if tx_params:
for key, value in tx_params.items():
recording.update_metadata(f"tx_{key}", value)
return recording return recording

View File

@ -6,7 +6,7 @@ from dataclasses import dataclass, field
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from .campaign import QAConfig from .campaign import QAConfig

View File

@ -0,0 +1,299 @@
"""TX campaign executor — synthesises and transmits signals via a local SDR.
The TxExecutor receives a transmitter config dict (matching the
``sdr_agent`` control method's schema) and a step schedule, then for each
step builds a signal chain with the block generator and transmits it via
the local SDR device.
Supported modulations (``modulation`` field in config):
BPSK, QPSK, 8PSK, 16QAM, 64QAM, 256QAM, FSK, OOK, GMSK, OQPSK
Example config dict (matches CampaignConfig transmitter with
``control_method: sdr_agent``)::
{
"id": "synthetic-tx",
"type": "sdr",
"control_method": "sdr_agent",
"sdr_agent": {
"modulation": "QPSK",
"order": 4,
"symbol_rate": 1000000,
"center_frequency": 0.0,
"filter": "rrc",
"rolloff": 0.35
},
"schedule": [
{"label": "step1", "duration": 10, "power_dbm": -10}
]
}
"""
from __future__ import annotations
import logging
import threading
from typing import Any
logger = logging.getLogger(__name__)
def _parse_hz(val: object) -> float:
"""Parse a frequency value that may be a float (Hz) or a string like '2.45GHz'."""
if isinstance(val, (int, float)):
return float(val)
s = str(val).strip()
for suffix, mult in (("GHz", 1e9), ("MHz", 1e6), ("kHz", 1e3), ("Hz", 1.0)):
if s.endswith(suffix):
return float(s[: -len(suffix)]) * mult
return float(s)
def _parse_seconds(val: object) -> float:
"""Parse a duration value that may be a float (seconds) or a string like '5s'."""
if isinstance(val, (int, float)):
return float(val)
s = str(val).strip()
return float(s[:-1]) if s.endswith("s") else float(s)
# Mapping from modulation name → (PSK/QAM order, generator_type)
# 'psk' uses PSKGenerator, 'qam' uses QAMGenerator
_MOD_TABLE: dict[str, tuple[int, str]] = {
"BPSK": (1, "psk"),
"QPSK": (2, "psk"),
"8PSK": (3, "psk"),
"16QAM": (4, "qam"),
"64QAM": (6, "qam"),
"256QAM": (8, "qam"),
}
_SPECIAL_MODS = {"FSK", "OOK", "GMSK", "OQPSK"}
# usrp-uhd-client's tx_recording() streams 2 000-sample chunks and loops the
# source buffer for the full tx_time, so only this many samples ever need to
# be in RAM regardless of step duration or sample rate.
# 50 000 complex64 samples ≈ 400 kB — enough spectral diversity for looping.
_SYNTH_BLOCK_SAMPLES = 50_000
class TxExecutor:
"""Synthesise and transmit a signal campaign via a local SDR.
Args:
config: Transmitter config dict (must have ``sdr_agent`` sub-dict with
modulation params, and ``schedule`` list of step dicts).
sdr_device: SDR device name to open in TX mode (e.g. "pluto", "usrp").
stop_event: External event that aborts the TX loop mid-step.
"""
def __init__(
self,
config: dict,
sdr_device: str = "unknown",
stop_event: threading.Event | None = None,
) -> None:
self.config = config
self.sdr_device = sdr_device
self.stop_event = stop_event or threading.Event()
self._sdr: Any = None
def run(self) -> None:
"""Execute all steps in the schedule, transmitting for each step duration."""
agent_cfg: dict = self.config.get("sdr_agent") or {}
schedule: list[dict] = self.config.get("schedule") or []
if not schedule:
logger.warning("TxExecutor: no schedule steps — nothing to transmit")
return
modulation: str = agent_cfg.get("modulation", "QPSK").upper()
symbol_rate: float = float(agent_cfg.get("symbol_rate", 1e6))
center_freq: float = _parse_hz(agent_cfg.get("center_frequency", 0.0))
filter_type: str = agent_cfg.get("filter", "rrc").lower()
rolloff: float = float(agent_cfg.get("rolloff", 0.35))
loops: int = max(1, int(self.config.get("loops", 1)))
# Upsampling factor: samples_per_symbol, fixed at 8 for SDR compatibility.
sps = 8
sample_rate = symbol_rate * sps
self._init_sdr(sample_rate, center_freq)
try:
for loop_idx in range(loops):
if self.stop_event.is_set():
break
if loops > 1:
logger.info("TX loop %d/%d", loop_idx + 1, loops)
for step in schedule:
if self.stop_event.is_set():
break
looped_step = (
{**step, "label": f"{step.get('label', 'step')}_run{loop_idx + 1:02d}"} if loops > 1 else step
)
self._execute_step(looped_step, modulation, sps, symbol_rate, filter_type, rolloff)
finally:
self._close_sdr()
def _execute_step(
self,
step: dict,
modulation: str,
sps: int,
symbol_rate: float,
filter_type: str,
rolloff: float,
) -> None:
duration: float = _parse_seconds(step.get("duration", 10.0))
label: str = step.get("label", "step")
gain: float = float(step.get("power_dbm") or 0.0)
sample_rate = symbol_rate * sps
logger.info(
"TX step '%s': %.0f s, %s @ %.3f MHz (sps=%d, filter=%s)",
label,
duration,
modulation,
symbol_rate / 1e6,
sps,
filter_type,
)
num_samples = int(duration * sample_rate)
# Synthesise a short representative block. tx_recording() loops this
# buffer for the full tx_time using a 2 000-sample streaming callback,
# so peak memory is O(_SYNTH_BLOCK_SAMPLES) regardless of duration.
block_size = min(num_samples, _SYNTH_BLOCK_SAMPLES)
signal = self._synthesise(modulation, sps, block_size, filter_type, rolloff)
if self._sdr is not None:
try:
# Apply gain update if SDR supports it
if hasattr(self._sdr, "set_tx_gain"):
self._sdr.set_tx_gain(gain)
self._sdr.tx_recording(signal, tx_time=duration)
except Exception as exc:
logger.error("TX step '%s' SDR error: %s", label, exc)
else:
# No SDR available — simulate by sleeping for the step duration.
logger.warning("TX step '%s': no SDR — simulating %.0f s delay", label, duration)
self.stop_event.wait(timeout=duration)
def _synthesise(
self,
modulation: str,
sps: int,
num_samples: int,
filter_type: str,
rolloff: float,
):
"""Build a block-generator chain and return IQ samples as a numpy array."""
try:
import numpy as np
from ria_toolkit_oss.signal.block_generator import (
BinarySource,
GMSKModulator,
Mapper,
OOKModulator,
OQPSKModulator,
RaisedCosineFilter,
RootRaisedCosineFilter,
Upsampling,
)
from ria_toolkit_oss.signal.block_generator.continuous_modulation.fsk_modulator import (
FSKModulator,
)
except ImportError as exc:
raise RuntimeError(f"ria_toolkit_oss block generator not available: {exc}") from exc
# ── Special modulations with their own source-connected modulator ──
if modulation in ("OOK", "GMSK", "OQPSK"):
src = BinarySource()
if modulation == "OOK":
mod = OOKModulator(src, samples_per_symbol=sps)
elif modulation == "GMSK":
mod = GMSKModulator(src, samples_per_symbol=sps)
else:
mod = OQPSKModulator(src, samples_per_symbol=sps)
recording = mod.record(num_samples)
flat = np.asarray(recording.data).flatten().astype(np.complex64)
if len(flat) < num_samples:
flat = np.tile(flat, num_samples // len(flat) + 1)
return flat[:num_samples]
if modulation == "FSK":
symbol_rate = num_samples / sps
bits_per_sym = 1 # 2-FSK
num_bits = max(num_samples // sps, 128) * bits_per_sym
bits = BinarySource()((1, num_bits))
mod = FSKModulator(
num_bits_per_symbol=bits_per_sym,
frequency_spacing=symbol_rate * 0.5,
symbol_duration=1.0 / max(symbol_rate, 1.0),
sampling_frequency=symbol_rate * sps,
)
flat = np.asarray(mod(bits)).flatten().astype(np.complex64)
if len(flat) < num_samples:
flat = np.tile(flat, num_samples // len(flat) + 1)
return flat[:num_samples]
# ── PSK / QAM via Mapper → Upsampling → pulse filter ──────────────
if modulation not in _MOD_TABLE:
logger.warning("Unknown modulation %r — defaulting to QPSK", modulation)
modulation = "QPSK"
bits_per_sym, gen_type = _MOD_TABLE[modulation]
mod_family = "QAM" if gen_type == "qam" else "PSK"
source = BinarySource()
mapper = Mapper(constellation_type=mod_family, num_bits_per_symbol=bits_per_sym)
upsampler = Upsampling(factor=sps)
mapper.connect_input([source])
upsampler.connect_input([mapper])
if filter_type in ("rrc",):
pulse_filter = RootRaisedCosineFilter(span_in_symbols=6, upsampling_factor=sps, beta=rolloff)
pulse_filter.connect_input([upsampler])
recording = pulse_filter.record(num_samples)
elif filter_type in ("rc",):
pulse_filter = RaisedCosineFilter(span_in_symbols=6, upsampling_factor=sps, beta=rolloff)
pulse_filter.connect_input([upsampler])
recording = pulse_filter.record(num_samples)
else:
# "none", "rect", "gaussian" — use upsampler output directly
recording = upsampler.record(num_samples)
flat = np.asarray(recording.data).flatten().astype(np.complex64)
if len(flat) < num_samples:
flat = np.tile(flat, num_samples // len(flat) + 1)
return flat[:num_samples]
def _init_sdr(self, sample_rate: float, center_freq: float) -> None:
try:
from ria_toolkit_oss.sdr import get_sdr_device
self._sdr = get_sdr_device(self.sdr_device)
self._sdr.init_tx(
sample_rate=sample_rate,
center_frequency=center_freq,
gain=0,
channel=0,
gain_mode="manual",
)
logger.info(
"TX SDR initialised: %s @ %.3f MHz, %.1f Msps", self.sdr_device, center_freq / 1e6, sample_rate / 1e6
)
except Exception as exc:
logger.warning("TX SDR init failed (%s) — will simulate: %s", self.sdr_device, exc)
self._sdr = None
def _close_sdr(self) -> None:
if self._sdr is not None:
try:
self._sdr.close()
except Exception as exc:
logger.debug("TX SDR close error: %s", exc)
self._sdr = None

View File

@ -5,7 +5,7 @@ from typing import Optional
import numpy as np import numpy as np
from bladerf import _bladerf from bladerf import _bladerf
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
from ria_toolkit_oss.sdr import SDR, SDRError, SDRParameterError from ria_toolkit_oss.sdr import SDR, SDRError, SDRParameterError

View File

@ -4,7 +4,7 @@ from typing import Optional
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.sdr._external.libhackrf import HackRF as hrf from ria_toolkit_oss.sdr._external.libhackrf import HackRF as hrf
from ria_toolkit_oss.sdr.sdr import SDR, SDRParameterError from ria_toolkit_oss.sdr.sdr import SDR, SDRParameterError
@ -58,7 +58,7 @@ class HackRF(SDR):
:param channel: The channel the HackRF is set to. (Not actually used) :param channel: The channel the HackRF is set to. (Not actually used)
:type channel: int :type channel: int
:param gain_mode: 'absolute' passes gain directly to the sdr, :param gain_mode: 'absolute' passes gain directly to the sdr,
'relative' means that gain should be a negative value, and it will be subtracted from the max gain (40). 'relative' means that gain should be a negative value, and it will be subtracted from the max gain (40).
:type gain_mode: str :type gain_mode: str
""" """
print("Initializing RX") print("Initializing RX")

View File

@ -7,7 +7,7 @@ from typing import Optional
import adi import adi
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.sdr.sdr import ( from ria_toolkit_oss.sdr.sdr import (
SDR, SDR,
SDRError, SDRError,

View File

@ -11,7 +11,7 @@ try:
except ImportError as exc: # pragma: no cover - dependency provided by end user except ImportError as exc: # pragma: no cover - dependency provided by end user
raise ImportError("pyrtlsdr is required to use the RTLSDR class") from exc raise ImportError("pyrtlsdr is required to use the RTLSDR class") from exc
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.sdr.sdr import SDR, SDRParameterError from ria_toolkit_oss.sdr.sdr import SDR, SDRParameterError

View File

@ -8,7 +8,7 @@ from typing import Optional
import numpy as np import numpy as np
import zmq import zmq
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
class SDR(ABC): class SDR(ABC):

View File

@ -6,7 +6,7 @@ from typing import Optional
import numpy as np import numpy as np
import uhd import uhd
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.sdr.sdr import SDR, SDRParameterError from ria_toolkit_oss.sdr.sdr import SDR, SDRParameterError
@ -54,7 +54,7 @@ class USRP(SDR):
:param channel: The channel the USRP is set to. :param channel: The channel the USRP is set to.
:type channel: int :type channel: int
:param gain_mode: 'absolute' passes gain directly to the sdr, :param gain_mode: 'absolute' passes gain directly to the sdr,
'relative' means that gain should be a negative value, and it will be subtracted from the max gain. 'relative' means that gain should be a negative value, and it will be subtracted from the max gain.
:type gain_mode: str :type gain_mode: str
:param rx_buffer_size: Internal buffer size for receiving samples. Defaults to 960000. :param rx_buffer_size: Internal buffer size for receiving samples. Defaults to 960000.
:type rx_buffer_size: int :type rx_buffer_size: int
@ -285,7 +285,7 @@ class USRP(SDR):
:param channel: The channel the USRP is set to. :param channel: The channel the USRP is set to.
:type channel: int :type channel: int
:param gain_mode: 'absolute' passes gain directly to the sdr, :param gain_mode: 'absolute' passes gain directly to the sdr,
'relative' means that gain should be a negative value, and it will be subtracted from the max gain. 'relative' means that gain should be a negative value, and it will be subtracted from the max gain.
:type gain_mode: str :type gain_mode: str
""" """

View File

@ -3,7 +3,7 @@
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from .auth import require_api_key from .auth import require_api_key
from .routers import inference, orchestrator from .routers import conductor, inference
def create_app(api_key: str = "") -> FastAPI: def create_app(api_key: str = "") -> FastAPI:
@ -28,9 +28,9 @@ def create_app(api_key: str = "") -> FastAPI:
app.state.api_key = api_key app.state.api_key = api_key
app.include_router( app.include_router(
orchestrator.router, conductor.router,
prefix="/orchestrator", prefix="/conductor",
tags=["Orchestrator"], tags=["Conductor"],
dependencies=[Depends(require_api_key)], dependencies=[Depends(require_api_key)],
) )
app.include_router( app.include_router(

View File

@ -7,7 +7,7 @@ from pathlib import Path
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Orchestrator # Conductor
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -1,4 +1,4 @@
"""Orchestrator routes: campaign deployment, status, and cancellation.""" """Conductor routes: campaign deployment, status, and cancellation."""
from __future__ import annotations from __future__ import annotations

View File

@ -11,7 +11,7 @@ from scipy.signal import butter
from scipy.signal import chirp as sci_chirp from scipy.signal import chirp as sci_chirp
from scipy.signal import hilbert, lfilter from scipy.signal import hilbert, lfilter
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
def sine( def sine(

View File

@ -1,4 +1,4 @@
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.signal.block_generator.generators.signal_generator import ( from ria_toolkit_oss.signal.block_generator.generators.signal_generator import (
SignalGenerator, SignalGenerator,
) )

View File

@ -1,4 +1,4 @@
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.signal.block_generator.generators.signal_generator import ( from ria_toolkit_oss.signal.block_generator.generators.signal_generator import (
SignalGenerator, SignalGenerator,
) )

View File

@ -1,4 +1,4 @@
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.signal.block_generator.generators.signal_generator import ( from ria_toolkit_oss.signal.block_generator.generators.signal_generator import (
SignalGenerator, SignalGenerator,
) )

View File

@ -1,4 +1,4 @@
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
from ria_toolkit_oss.signal import Recordable from ria_toolkit_oss.signal import Recordable
from ria_toolkit_oss.signal.block_generator.block import Block from ria_toolkit_oss.signal.block_generator.block import Block

View File

@ -4,7 +4,7 @@ from datetime import datetime
import click import click
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper from ria_toolkit_oss.signal.block_generator.mapping.mapper import Mapper
from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling from ria_toolkit_oss.signal.block_generator.multirate.upsampling import Upsampling
from ria_toolkit_oss.signal.block_generator.pulse_shaping.raised_cosine_filter import ( from ria_toolkit_oss.signal.block_generator.pulse_shaping.raised_cosine_filter import (

View File

@ -1,4 +1,4 @@
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
from ria_toolkit_oss.signal.block_generator.data_types import DataType from ria_toolkit_oss.signal.block_generator.data_types import DataType
from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock from ria_toolkit_oss.signal.block_generator.recordable_block import RecordableBlock
from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock

View File

@ -1,6 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
class Recordable(ABC): class Recordable(ABC):

View File

@ -11,7 +11,7 @@ from typing import Optional
import numpy as np import numpy as np
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.utils.array_conversion import convert_to_2xn from ria_toolkit_oss.utils.array_conversion import convert_to_2xn
# TODO: For round 2 of index generation, should j be at min 2 spots away from where it was to prevent adjacent patches. # TODO: For round 2 of index generation, should j be at min 2 spots away from where it was to prevent adjacent patches.
@ -29,7 +29,7 @@ def generate_awgn(signal: ArrayLike | Recording, snr: Optional[float] = 1) -> np
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param snr: The signal-to-noise ratio in dB. Default is 1. :param snr: The signal-to-noise ratio in dB. Default is 1.
:type snr: float, optional :type snr: float, optional
@ -37,7 +37,7 @@ def generate_awgn(signal: ArrayLike | Recording, snr: Optional[float] = 1) -> np
:return: A numpy array representing the generated noise which matches the SNR of `signal`. If `signal` is a :return: A numpy array representing the generated noise which matches the SNR of `signal`. If `signal` is a
Recording, returns a Recording object with its `data` attribute containing the generated noise array. Recording, returns a Recording object with its `data` attribute containing the generated noise array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[2 + 5j, 1 + 8j]]) >>> rec = Recording(data=[[2 + 5j, 1 + 8j]])
>>> new_rec = generate_awgn(rec) >>> new_rec = generate_awgn(rec)
@ -80,14 +80,14 @@ def time_reversal(signal: ArrayLike | Recording) -> np.ndarray | Recording:
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:raises ValueError: If `signal` is not CxN complex. :raises ValueError: If `signal` is not CxN complex.
:return: A numpy array containing the reversed I and Q data samples if `signal` is an array. :return: A numpy array containing the reversed I and Q data samples if `signal` is an array.
If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing the If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing the
reversed array. reversed array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[1+2j, 3+4j, 5+6j]]) >>> rec = Recording(data=[[1+2j, 3+4j, 5+6j]])
>>> new_rec = time_reversal(rec) >>> new_rec = time_reversal(rec)
@ -123,14 +123,14 @@ def spectral_inversion(signal: ArrayLike | Recording) -> np.ndarray | Recording:
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:raises ValueError: If `signal` is not CxN complex. :raises ValueError: If `signal` is not CxN complex.
:return: A numpy array containing the original I and negated Q data samples if `signal` is an array. :return: A numpy array containing the original I and negated Q data samples if `signal` is an array.
If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing the If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing the
inverted array. inverted array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[0+45j, 2-10j]]) >>> rec = Recording(data=[[0+45j, 2-10j]])
>>> new_rec = spectral_inversion(rec) >>> new_rec = spectral_inversion(rec)
@ -165,14 +165,14 @@ def channel_swap(signal: ArrayLike | Recording) -> np.ndarray | Recording:
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:raises ValueError: If `signal` is not CxN complex. :raises ValueError: If `signal` is not CxN complex.
:return: A numpy array containing the swapped I and Q data samples if `signal` is an array. :return: A numpy array containing the swapped I and Q data samples if `signal` is an array.
If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing the If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing the
swapped array. swapped array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[10+20j, 7+35j]]) >>> rec = Recording(data=[[10+20j, 7+35j]])
>>> new_rec = channel_swap(rec) >>> new_rec = channel_swap(rec)
@ -207,14 +207,14 @@ def amplitude_reversal(signal: ArrayLike | Recording) -> np.ndarray | Recording:
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:raises ValueError: If `signal` is not CxN complex. :raises ValueError: If `signal` is not CxN complex.
:return: A numpy array containing the negated I and Q data samples if `signal` is an array. :return: A numpy array containing the negated I and Q data samples if `signal` is an array.
If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing the If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing the
negated array. negated array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[4-3j, -5-2j, -9+1j]]) >>> rec = Recording(data=[[4-3j, -5-2j, -9+1j]])
>>> new_rec = amplitude_reversal(rec) >>> new_rec = amplitude_reversal(rec)
@ -253,7 +253,7 @@ def drop_samples( # noqa: C901 # TODO: Simplify function
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param max_section_size: Maximum allowable size of the section to be dropped and replaced. Default is 2. :param max_section_size: Maximum allowable size of the section to be dropped and replaced. Default is 2.
:type max_section_size: int, optional :type max_section_size: int, optional
:param fill_type: Fill option used to replace dropped section of data (back-fill, front-fill, mean, zeros). :param fill_type: Fill option used to replace dropped section of data (back-fill, front-fill, mean, zeros).
@ -275,7 +275,7 @@ def drop_samples( # noqa: C901 # TODO: Simplify function
:return: A numpy array containing the I and Q data samples with replaced subsections if :return: A numpy array containing the I and Q data samples with replaced subsections if
`signal` is an array. If `signal` is a `Recording`, returns a `Recording` object with its `data` `signal` is an array. If `signal` is a `Recording`, returns a `Recording` object with its `data`
attribute containing the array with dropped samples. attribute containing the array with dropped samples.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]]) >>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]])
>>> new_rec = drop_samples(rec) >>> new_rec = drop_samples(rec)
@ -346,7 +346,7 @@ def quantize_tape(
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param bin_number: The number of bins the signal should be divided into. Default is 4. :param bin_number: The number of bins the signal should be divided into. Default is 4.
:type bin_number: int, optional :type bin_number: int, optional
:param rounding_type: The type of rounding applied during processing. Default is "floor". :param rounding_type: The type of rounding applied during processing. Default is "floor".
@ -362,7 +362,7 @@ def quantize_tape(
:return: A numpy array containing the quantized I and Q data samples if `signal` is an array. :return: A numpy array containing the quantized I and Q data samples if `signal` is an array.
If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing
the quantized array. the quantized array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[1+1j, 4+4j, 1+2j, 1+4j]]) >>> rec = Recording(data=[[1+1j, 4+4j, 1+2j, 1+4j]])
>>> new_rec = quantize_tape(rec) >>> new_rec = quantize_tape(rec)
@ -421,7 +421,7 @@ def quantize_parts(
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param max_section_size: Maximum allowable size of the section to be quantized. Default is 2. :param max_section_size: Maximum allowable size of the section to be quantized. Default is 2.
:type max_section_size: int, optional :type max_section_size: int, optional
:param bin_number: The number of bins the signal should be divided into. Default is 4. :param bin_number: The number of bins the signal should be divided into. Default is 4.
@ -439,7 +439,7 @@ def quantize_parts(
:return: A numpy array containing the I and Q data samples with quantized subsections if `signal` :return: A numpy array containing the I and Q data samples with quantized subsections if `signal`
is an array. If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute is an array. If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute
containing the partially quantized array. containing the partially quantized array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]]) >>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]])
>>> new_rec = quantize_parts(rec) >>> new_rec = quantize_parts(rec)
@ -510,7 +510,7 @@ def magnitude_rescale(
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param starting_bounds: The bounds (inclusive) as indices in which the starting position of the rescaling occurs. :param starting_bounds: The bounds (inclusive) as indices in which the starting position of the rescaling occurs.
Default is None, but if user does not assign any bounds, the bounds become (random index, N-1). Default is None, but if user does not assign any bounds, the bounds become (random index, N-1).
:type starting_bounds: tuple, optional :type starting_bounds: tuple, optional
@ -522,7 +522,7 @@ def magnitude_rescale(
:return: A numpy array containing the I and Q data samples with the rescaled magnitude after the random :return: A numpy array containing the I and Q data samples with the rescaled magnitude after the random
starting point if `signal` is an array. If `signal` is a `Recording`, returns a `Recording` starting point if `signal` is an array. If `signal` is a `Recording`, returns a `Recording`
object with its `data` attribute containing the rescaled array. object with its `data` attribute containing the rescaled array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]]) >>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]])
>>> new_rec = magniute_rescale(rec) >>> new_rec = magniute_rescale(rec)
@ -571,7 +571,7 @@ def cut_out( # noqa: C901 # TODO: Simplify function
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param max_section_size: Maximum allowable size of the section to be quantized. Default is 3. :param max_section_size: Maximum allowable size of the section to be quantized. Default is 3.
:type max_section_size: int, optional :type max_section_size: int, optional
:param fill_type: Fill option used to replace cutout section of data (zeros, ones, low-snr, avg-snr-1, avg-snr-2). :param fill_type: Fill option used to replace cutout section of data (zeros, ones, low-snr, avg-snr-1, avg-snr-2).
@ -596,7 +596,7 @@ def cut_out( # noqa: C901 # TODO: Simplify function
:return: A numpy array containing the I and Q data samples with random sections cut out and replaced according to :return: A numpy array containing the I and Q data samples with random sections cut out and replaced according to
`fill_type` if `signal` is an array. If `signal` is a `Recording`, returns a `Recording` object `fill_type` if `signal` is an array. If `signal` is a `Recording`, returns a `Recording` object
with its `data` attribute containing the cut out and replaced array. with its `data` attribute containing the cut out and replaced array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]]) >>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]])
>>> new_rec = cut_out(rec) >>> new_rec = cut_out(rec)
@ -666,7 +666,7 @@ def patch_shuffle(signal: ArrayLike | Recording, max_patch_size: Optional[int] =
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param max_patch_size: Maximum allowable patch size of the data that can be shuffled. Default is 3. :param max_patch_size: Maximum allowable patch size of the data that can be shuffled. Default is 3.
:type max_patch_size: int, optional :type max_patch_size: int, optional
@ -676,7 +676,7 @@ def patch_shuffle(signal: ArrayLike | Recording, max_patch_size: Optional[int] =
:return: A numpy array containing the I and Q data samples with randomly shuffled regions if `signal` is :return: A numpy array containing the I and Q data samples with randomly shuffled regions if `signal` is
an array. If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing an array. If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing
the shuffled array. the shuffled array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]]) >>> rec = Recording(data=[[2+5j, 1+8j, 6+4j, 3+7j, 4+9j]])
>>> new_rec = patch_shuffle(rec) >>> new_rec = patch_shuffle(rec)

View File

@ -16,7 +16,7 @@ import numpy as np
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from scipy.signal import resample_poly from scipy.signal import resample_poly
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
from ria_toolkit_oss.transforms import iq_augmentations from ria_toolkit_oss.transforms import iq_augmentations
@ -31,7 +31,7 @@ def add_awgn_to_signal(signal: ArrayLike | Recording, snr: Optional[float] = 1)
:param signal: Input IQ data as a complex ``C x N`` array or `Recording`, where ``C`` is the number of channels :param signal: Input IQ data as a complex ``C x N`` array or `Recording`, where ``C`` is the number of channels
and ``N`` is the length of the IQ examples. and ``N`` is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param snr: The signal-to-noise ratio in dB. Default is 1. :param snr: The signal-to-noise ratio in dB. Default is 1.
:type snr: float, optional :type snr: float, optional
@ -39,7 +39,7 @@ def add_awgn_to_signal(signal: ArrayLike | Recording, snr: Optional[float] = 1)
:return: A numpy array which is the sum of the noise (which matches the SNR) and the original signal. If `signal` :return: A numpy array which is the sum of the noise (which matches the SNR) and the original signal. If `signal`
is a `Recording`, returns a `Recording object` with its `data` attribute containing the noisy signal array. is a `Recording`, returns a `Recording object` with its `data` attribute containing the noisy signal array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[1+1j, 2+2j]]) >>> rec = Recording(data=[[1+1j, 2+2j]])
>>> new_rec = add_awgn_to_signal(rec) >>> new_rec = add_awgn_to_signal(rec)
@ -71,7 +71,7 @@ def time_shift(signal: ArrayLike | Recording, shift: Optional[int] = 1) -> np.nd
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param shift: The number of indices to shift by. Default is 1. :param shift: The number of indices to shift by. Default is 1.
:type shift: int, optional :type shift: int, optional
@ -80,7 +80,7 @@ def time_shift(signal: ArrayLike | Recording, shift: Optional[int] = 1) -> np.nd
:return: A numpy array which represents the time-shifted signal. If `signal` is a `Recording`, :return: A numpy array which represents the time-shifted signal. If `signal` is a `Recording`,
returns a `Recording object` with its `data` attribute containing the time-shifted array. returns a `Recording object` with its `data` attribute containing the time-shifted array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[1+1j, 2+2j, 3+3j, 4+4j, 5+5j]]) >>> rec = Recording(data=[[1+1j, 2+2j, 3+3j, 4+4j, 5+5j]])
>>> new_rec = time_shift(rec, -2) >>> new_rec = time_shift(rec, -2)
@ -134,7 +134,7 @@ def frequency_shift(signal: ArrayLike | Recording, shift: Optional[float] = 0.5)
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param shift: The frequency shift relative to the sample rate. Must be in the range ``[-0.5, 0.5]``. :param shift: The frequency shift relative to the sample rate. Must be in the range ``[-0.5, 0.5]``.
Default is 0.5. Default is 0.5.
:type shift: float, optional :type shift: float, optional
@ -144,7 +144,7 @@ def frequency_shift(signal: ArrayLike | Recording, shift: Optional[float] = 0.5)
:return: A numpy array which represents the frequency-shifted signal. If `signal` is a `Recording`, :return: A numpy array which represents the frequency-shifted signal. If `signal` is a `Recording`,
returns a `Recording object` with its `data` attribute containing the frequency-shifted array. returns a `Recording object` with its `data` attribute containing the frequency-shifted array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[1+1j, 2+2j, 3+3j, 4+4j]]) >>> rec = Recording(data=[[1+1j, 2+2j, 3+3j, 4+4j]])
>>> new_rec = frequency_shift(rec, -0.4) >>> new_rec = frequency_shift(rec, -0.4)
@ -189,7 +189,7 @@ def phase_shift(signal: ArrayLike | Recording, phase: Optional[float] = np.pi) -
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param phase: The phase angle by which to rotate the IQ samples, in radians. Must be in the range ``[-π, π]``. :param phase: The phase angle by which to rotate the IQ samples, in radians. Must be in the range ``[-π, π]``.
Default is π. Default is π.
:type phase: float, optional :type phase: float, optional
@ -199,7 +199,7 @@ def phase_shift(signal: ArrayLike | Recording, phase: Optional[float] = np.pi) -
:return: A numpy array which represents the phase-shifted signal. If `signal` is a `Recording`, :return: A numpy array which represents the phase-shifted signal. If `signal` is a `Recording`,
returns a `Recording object` with its `data` attribute containing the phase-shifted array. returns a `Recording object` with its `data` attribute containing the phase-shifted array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[1+1j, 2+2j, 3+3j, 4+4j]]) >>> rec = Recording(data=[[1+1j, 2+2j, 3+3j, 4+4j]])
>>> new_rec = phase_shift(rec, np.pi/2) >>> new_rec = phase_shift(rec, np.pi/2)
@ -246,7 +246,7 @@ def iq_imbalance(
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param amplitude_imbalance: The IQ amplitude imbalance to apply, in dB. Default is 1.5. :param amplitude_imbalance: The IQ amplitude imbalance to apply, in dB. Default is 1.5.
:type amplitude_imbalance: float, optional :type amplitude_imbalance: float, optional
:param phase_imbalance: The IQ phase imbalance to apply, in radians. Default is π. :param phase_imbalance: The IQ phase imbalance to apply, in radians. Default is π.
@ -260,7 +260,7 @@ def iq_imbalance(
:return: A numpy array which is the original signal with an applied IQ imbalance. If `signal` is a `Recording`, :return: A numpy array which is the original signal with an applied IQ imbalance. If `signal` is a `Recording`,
returns a `Recording object` with its `data` attribute containing the IQ imbalanced signal array. returns a `Recording object` with its `data` attribute containing the IQ imbalanced signal array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[2+18j, -34+2j, 3+9j]]) >>> rec = Recording(data=[[2+18j, -34+2j, 3+9j]])
>>> new_rec = iq_imbalance(rec, 1, np.pi, 2) >>> new_rec = iq_imbalance(rec, 1, np.pi, 2)
@ -315,7 +315,7 @@ def resample(signal: ArrayLike | Recording, up: Optional[int] = 4, down: Optiona
:param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N :param signal: Input IQ data as a complex CxN array or `Recording`, where C is the number of channels and N
is the length of the IQ examples. is the length of the IQ examples.
:type signal: array_like or ria_toolkit_oss.datatypes.Recording :type signal: array_like or ria_toolkit_oss.data.Recording
:param up: The upsampling factor. Default is 4. :param up: The upsampling factor. Default is 4.
:type up: int, optional :type up: int, optional
:param down: The downsampling factor. Default is 2. :param down: The downsampling factor. Default is 2.
@ -325,7 +325,7 @@ def resample(signal: ArrayLike | Recording, up: Optional[int] = 4, down: Optiona
:return: A numpy array which represents the resampled signal If `signal` is a `Recording`, :return: A numpy array which represents the resampled signal If `signal` is a `Recording`,
returns a `Recording object` with its `data` attribute containing the resampled array. returns a `Recording object` with its `data` attribute containing the resampled array.
:rtype: np.ndarray or ria_toolkit_oss.datatypes.Recording :rtype: np.ndarray or ria_toolkit_oss.data.Recording
>>> rec = Recording(data=[[1+1j, 2+2j]]) >>> rec = Recording(data=[[1+1j, 2+2j]])
>>> new_rec = resample(rec, 2, 1) >>> new_rec = resample(rec, 2, 1)

View File

@ -4,14 +4,14 @@ import scipy.signal as signal
from plotly.graph_objs import Figure from plotly.graph_objs import Figure
from scipy.fft import fft, fftshift from scipy.fft import fft, fftshift
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
def spectrogram(rec: Recording, thumbnail: bool = False) -> Figure: def spectrogram(rec: Recording, thumbnail: bool = False) -> Figure:
"""Create a spectrogram for the recording. """Create a spectrogram for the recording.
:param rec: Signal to plot. :param rec: Signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:param thumbnail: Whether to return a small thumbnail version or full plot. :param thumbnail: Whether to return a small thumbnail version or full plot.
:type thumbnail: bool :type thumbnail: bool
@ -95,7 +95,7 @@ def iq_time_series(rec: Recording) -> Figure:
"""Create a time series plot of the real and imaginary parts of signal. """Create a time series plot of the real and imaginary parts of signal.
:param rec: Signal to plot. :param rec: Signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: Time series plot as a Plotly figure. :return: Time series plot as a Plotly figure.
""" """
@ -125,7 +125,7 @@ def frequency_spectrum(rec: Recording) -> Figure:
"""Create a frequency spectrum plot from the recording. """Create a frequency spectrum plot from the recording.
:param rec: Input signal to plot. :param rec: Input signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: Frequency spectrum as a Plotly figure. :return: Frequency spectrum as a Plotly figure.
""" """
@ -160,7 +160,7 @@ def constellation(rec: Recording) -> Figure:
"""Create a constellation plot from the recording. """Create a constellation plot from the recording.
:param rec: Input signal to plot. :param rec: Input signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: Constellation as a Plotly figure. :return: Constellation as a Plotly figure.
""" """

View File

@ -32,16 +32,15 @@ def extract_metadata_fields(metadata):
def set_path(output_path): def set_path(output_path):
split_path = output_path.split("/") path = pathlib.Path(output_path)
if len(split_path) == 1: # If only filename provided (no directory), use default 'images' folder
folder = "images" if len(path.parts) == 1:
file = split_path[0] folder = pathlib.Path("images")
elif len(split_path) > 2: file = path.name
file = split_path[-1]
folder = "/".join(split_path[:-1])
else: else:
folder, file = split_path folder = path.parent
file = path.name
split_file = file.split(".") split_file = file.split(".")
if len(split_file) == 2: if len(split_file) == 2:
@ -53,5 +52,5 @@ def set_path(output_path):
extension = "png" extension = "png"
file = file + ".png" file = file + ".png"
pathlib.Path(folder).mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
return "/".join([folder, file]), extension return str(folder / file), extension

View File

@ -3,16 +3,17 @@ import os
import textwrap import textwrap
from typing import Optional from typing import Optional
import matplotlib
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import numpy as np import numpy as np
from matplotlib import gridspec from matplotlib import gridspec, ticker
from matplotlib.patches import Patch from matplotlib.patches import Patch
from PIL import Image from PIL import Image, UnidentifiedImageError
from scipy.fft import fft, fftshift from scipy.fft import fft, fftshift
from scipy.signal import spectrogram from scipy.signal import spectrogram
from scipy.signal.windows import hann from scipy.signal.windows import hann
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.view.tools import ( from ria_toolkit_oss.view.tools import (
COLORS, COLORS,
decimate, decimate,
@ -185,7 +186,7 @@ def view_sig(
logo: Optional[bool] = True, logo: Optional[bool] = True,
dark: Optional[bool] = True, dark: Optional[bool] = True,
spines: Optional[bool] = False, spines: Optional[bool] = False,
title_fontsize: Optional[int] = 35, title_fontsize: Optional[int] = 25,
subtitle_fontsize: Optional[int] = 15, subtitle_fontsize: Optional[int] = 15,
) -> None: ) -> None:
""" """
@ -230,11 +231,26 @@ def view_sig(
complex_signal = recording.data[0] complex_signal = recording.data[0]
sample_rate, center_frequency, _ = extract_metadata_fields(recording.metadata) sample_rate, center_frequency, _ = extract_metadata_fields(recording.metadata)
subplot_height = 2 * (plot_spectrogram + iq + frequency) + 3 * (constellation or metadata or logo) subplot_height = 3 * (plot_spectrogram) + 2 * (iq + frequency) + 3 * (constellation or metadata or logo)
subplot_width = max((constellation + metadata or 1), logo * 3) subplot_width = max((constellation + metadata or 1), logo * 3)
if dark: if dark:
plt.style.use("dark_background") plt.style.use("dark_background")
matplotlib.rcParams.update(
{
"figure.facecolor": "#161616",
"axes.facecolor": "#161616",
"savefig.facecolor": "#161616",
"savefig.edgecolor": "#161616",
"font.size": 10,
"axes.titlesize": 15,
"axes.labelsize": 10,
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"legend.frameon": False,
"legend.facecolor": "none",
}
)
logo_path = os.path.dirname(__file__) + "/graphics/Qoherent-logo-white-transparent.png" logo_path = os.path.dirname(__file__) + "/graphics/Qoherent-logo-white-transparent.png"
else: else:
plt.style.use("default") plt.style.use("default")
@ -252,8 +268,8 @@ def view_sig(
plot_x_indx = 0 plot_x_indx = 0
if plot_spectrogram: if plot_spectrogram:
spec_ax = plt.subplot(gs[plot_y_indx : plot_y_indx + 2, :]) spec_ax = plt.subplot(gs[plot_y_indx : plot_y_indx + 3, :])
plot_y_indx = plot_y_indx + 2 plot_y_indx = plot_y_indx + 3
fft_size = get_fft_size(plot_length=plot_length) fft_size = get_fft_size(plot_length=plot_length)
_, t_spec, Sxx = spectrogram( _, t_spec, Sxx = spectrogram(
@ -280,7 +296,10 @@ def view_sig(
) )
set_spines(spec_ax, spines) set_spines(spec_ax, spines)
spec_ax.set_title("Spectrogram", loc="center", fontsize=subtitle_fontsize) spec_ax.set_title("Spectrogram", loc="left", fontsize=subtitle_fontsize)
spec_ax.set_xlabel("Time (s)")
spec_ax.set_ylabel("Frequency (MHz)")
spec_ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: f"{x / 1e6:.1f}"))
if iq: if iq:
iq_ax = plt.subplot(gs[plot_y_indx : plot_y_indx + 2, :]) iq_ax = plt.subplot(gs[plot_y_indx : plot_y_indx + 2, :])
@ -291,12 +310,13 @@ def view_sig(
iq_ax.plot(t, plot_iq.real, color=COLORS["purple"], linewidth=0.6, alpha=0.8, label="I") iq_ax.plot(t, plot_iq.real, color=COLORS["purple"], linewidth=0.6, alpha=0.8, label="I")
iq_ax.plot(t, plot_iq.imag, color=COLORS["magenta"], linewidth=0.6, alpha=0.8, label="Q") iq_ax.plot(t, plot_iq.imag, color=COLORS["magenta"], linewidth=0.6, alpha=0.8, label="Q")
iq_ax.grid(False) iq_ax.grid(True, alpha=0.2, linewidth=0.5)
iq_ax.set_ylabel("Amplitude") iq_ax.set_ylabel("Amplitude")
iq_ax.set_xlim([min(t), max(t)]) iq_ax.set_xlim([min(t), max(t)])
iq_ax.set_xlabel("Time (s)") iq_ax.set_xlabel("Time (s)")
iq_ax.set_title("IQ Sample Plot", fontsize=subtitle_fontsize) iq_ax.set_title("IQ Sample Plot", loc="left", fontsize=subtitle_fontsize)
iq_ax.legend(loc="upper right", fontsize=10)
set_spines(iq_ax, spines) set_spines(iq_ax, spines)
if frequency: if frequency:
@ -310,10 +330,14 @@ def view_sig(
# Convert to dB # Convert to dB
spectrum_db = 20 * np.log10(spectrum + 1e-12) # 20*log for magnitude spectrum_db = 20 * np.log10(spectrum + 1e-12) # 20*log for magnitude
freqs = np.linspace(-sample_rate / 2, sample_rate / 2, len(complex_signal[:plot_length])) + center_frequency freqs = (
np.linspace(-sample_rate / 2, sample_rate / 2, len(complex_signal[:plot_length])) + center_frequency
) / 1e6
freq_ax.plot(freqs, spectrum_db, color=COLORS["accent"], linewidth=0.8) freq_ax.plot(freqs, spectrum_db, color=COLORS["accent"], linewidth=0.8)
freq_ax.set_xlabel("Frequency (MHz)")
freq_ax.set_ylabel("Magnitude (dB)") freq_ax.set_ylabel("Magnitude (dB)")
freq_ax.set_title("Frequency Spectrum (Windowed FFT)", fontsize=subtitle_fontsize) freq_ax.grid(True, alpha=0.2, linewidth=0.5)
freq_ax.set_title("Frequency Spectrum (Windowed FFT)", loc="left", fontsize=subtitle_fontsize)
set_spines(freq_ax, spines) set_spines(freq_ax, spines)
if constellation: if constellation:
@ -326,7 +350,7 @@ def view_sig(
const_ax.set_ylim([-1 * dimension, dimension]) const_ax.set_ylim([-1 * dimension, dimension])
const_ax.set_xlabel("In-phase (I)") const_ax.set_xlabel("In-phase (I)")
const_ax.set_ylabel("Quadrature (Q)") const_ax.set_ylabel("Quadrature (Q)")
const_ax.set_title("Constellation", fontsize=subtitle_fontsize) const_ax.set_title("Constellation", loc="left", fontsize=subtitle_fontsize)
const_ax.set_aspect("equal") const_ax.set_aspect("equal")
if not spines: if not spines:
@ -375,8 +399,8 @@ def view_sig(
image = Image.open(logo_path) # Open the PNG image using PIL image = Image.open(logo_path) # Open the PNG image using PIL
logo_ax.imshow(image) logo_ax.imshow(image)
except FileNotFoundError: except (FileNotFoundError, UnidentifiedImageError, OSError) as exc:
print(f"Warning, {logo_path} not found.") print(f"Warning, could not load logo image: {logo_path}. Reason: {exc}")
fig.subplots_adjust( fig.subplots_adjust(
left=0.1, # Left margin left=0.1, # Left margin

View File

@ -12,7 +12,7 @@ import numpy as np
from scipy.fft import fft, fftshift from scipy.fft import fft, fftshift
from scipy.signal.windows import hann from scipy.signal.windows import hann
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.view.tools import ( from ria_toolkit_oss.view.tools import (
COLORS, COLORS,
decimate, decimate,
@ -119,24 +119,19 @@ def setup_style(*, labels_mode: bool = False, compact_mode: bool = False) -> Non
label_font = 14 label_font = 14
else: else:
base_font = 10 base_font = 10
title_font = 12 title_font = 15
label_font = 10 label_font = 10
matplotlib.rcParams.update( matplotlib.rcParams.update(
{ {
"figure.facecolor": "#0f172a", "figure.facecolor": "#161616",
"axes.facecolor": "#1e293b", "axes.facecolor": "#161616",
"axes.edgecolor": COLORS["muted"], "savefig.facecolor": "#161616",
"axes.labelcolor": COLORS["light"], "savefig.edgecolor": "#161616",
"text.color": COLORS["light"],
"xtick.color": COLORS["muted"],
"ytick.color": COLORS["muted"],
"grid.color": COLORS["muted"],
"grid.alpha": 0.3,
"font.size": base_font, "font.size": base_font,
"axes.titlesize": title_font, "axes.titlesize": title_font,
"axes.labelsize": label_font, "axes.labelsize": label_font,
"figure.titlesize": title_font + 2, "figure.titlesize": title_font + 4,
"legend.frameon": False, "legend.frameon": False,
"legend.facecolor": "none", "legend.facecolor": "none",
"xtick.labelsize": base_font, "xtick.labelsize": base_font,
@ -194,7 +189,7 @@ def view_simple_sig(
constellation_mode: Optional[bool] = False, constellation_mode: Optional[bool] = False,
labels_mode: Optional[bool] = False, labels_mode: Optional[bool] = False,
slice: Optional[tuple] = None, slice: Optional[tuple] = None,
title: Optional[str] = "Signal", title: Optional[str] = "Signal Plot",
): ):
""" """
Create a simple plot of various signal visualizations as a png or svg image. Create a simple plot of various signal visualizations as a png or svg image.
@ -237,7 +232,7 @@ def view_simple_sig(
spec_signal = signal spec_signal = signal
if compact_mode: if compact_mode:
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 6), gridspec_kw={"height_ratios": [1, 5]}) fig, (ax2, ax1) = plt.subplots(2, 1, figsize=(12, 6), gridspec_kw={"height_ratios": [5, 1]})
show_title = False show_title = False
show_labels = False show_labels = False
ax_constellation = ax_psd = None ax_constellation = ax_psd = None
@ -253,25 +248,24 @@ def view_simple_sig(
ax_psd = None ax_psd = None
else: else:
if constellation_mode: if constellation_mode:
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 12)) fig, ((ax2, ax1), (ax3, ax4)) = plt.subplots(2, 2, figsize=(16, 12))
ax_constellation, ax_psd = ax3, ax4 ax_constellation, ax_psd = ax3, ax4
else: else:
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10)) fig, (ax2, ax1) = plt.subplots(2, 1, figsize=(14, 10))
ax_constellation = ax_psd = None ax_constellation = ax_psd = None
show_title = True show_title = True
show_labels = labels_mode show_labels = labels_mode
if show_title: if show_title:
fig.suptitle(title, fontsize=16, color=COLORS["light"], y=0.96) fig.suptitle(title, fontsize=25)
fig.patch.set_facecolor("#0f172a") fig.patch.set_facecolor(matplotlib.rcParams["figure.facecolor"])
total_duration_s = len(signal) / sample_rate_hz if sample_rate_hz else 0.0 total_duration_s = len(signal) / sample_rate_hz if sample_rate_hz else 0.0
t_s = np.linspace(0, total_duration_s, len(display_signal)) if len(display_signal) else np.array([]) t_s = np.linspace(0, total_duration_s, len(display_signal)) if len(display_signal) else np.array([])
ax1.plot(t_s, display_signal.real, color=COLORS["purple"], linewidth=0.8, alpha=0.8, label="I") ax1.plot(t_s, display_signal.real, color=COLORS["purple"], linewidth=0.6, alpha=0.8, label="I")
ax1.plot(t_s, display_signal.imag, color=COLORS["magenta"], linewidth=0.8, alpha=0.8, label="Q") ax1.plot(t_s, display_signal.imag, color=COLORS["magenta"], linewidth=0.6, alpha=0.8, label="Q")
ax1.set_xlim(0, total_duration_s) ax1.grid(True, alpha=0.2, linewidth=0.5)
ax1.grid(True, alpha=0.3)
nfft, overlap = _get_nfft_size(signal=signal, fast_mode=fast_mode) nfft, overlap = _get_nfft_size(signal=signal, fast_mode=fast_mode)
@ -285,7 +279,7 @@ def view_simple_sig(
) )
ax2.set_ylim(center_freq_hz - sample_rate_hz / 2, center_freq_hz + sample_rate_hz / 2) ax2.set_ylim(center_freq_hz - sample_rate_hz / 2, center_freq_hz + sample_rate_hz / 2)
ax2.set_xlim(0, total_duration_s) ax1.set_xlim(ax2.get_xlim())
if show_labels: if show_labels:
if horizontal_mode: if horizontal_mode:
@ -294,20 +288,25 @@ def view_simple_sig(
ax2.set_xlabel("Time (s)") ax2.set_xlabel("Time (s)")
ax1.set_ylabel("Amplitude") ax1.set_ylabel("Amplitude")
ax1.set_title(f"Time Series - {sdr} SDR", loc="left", pad=10) ax1.set_title(f"IQ Sample Plot - {sdr} SDR", loc="left", pad=10, fontsize=15)
ax1.legend(loc="upper right") ax1.legend(loc="upper right", fontsize=10)
ax2.set_ylabel("Frequency (Hz)") ax2.set_ylabel("Frequency (MHz)")
ax2.set_title( ax2.set_title(
f"Spectrogram - {center_freq_hz / 1e6:.1f} MHz ± {sample_rate_hz / 2e6:.1f} MHz", loc="left", pad=10 f"Spectrogram - {center_freq_hz / 1e6:.1f} MHz ± {sample_rate_hz / 2e6:.1f} MHz",
loc="left",
pad=10,
fontsize=15,
) )
yticks = ax2.get_yticks() ax2.yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(lambda x, _: f"{x / 1e6:.1f}"))
ax2.set_yticklabels([f"{y / 1e6:.1f}" for y in yticks])
elif not compact_mode: elif not compact_mode:
ax1.set_title("Time Series", loc="left", pad=10) ax1.set_title("IQ Sample Plot", loc="left", pad=10, fontsize=15)
ax1.legend(loc="upper right", fontsize=8) ax1.legend(loc="upper right", fontsize=10)
ax2.set_title("Spectrogram", loc="left", pad=10) ax2.set_xlabel("Time (s)")
ax2.set_ylabel("Frequency (MHz)")
ax2.set_title("Spectrogram", loc="left", pad=10, fontsize=15)
ax2.yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(lambda x, _: f"{x / 1e6:.1f}"))
_add_annotations( _add_annotations(
annotations=annotations, annotations=annotations,
@ -339,8 +338,8 @@ def view_simple_sig(
) )
ax_constellation.set_xlabel("In-phase (I)") ax_constellation.set_xlabel("In-phase (I)")
ax_constellation.set_ylabel("Quadrature (Q)") ax_constellation.set_ylabel("Quadrature (Q)")
ax_constellation.set_title("Constellation") ax_constellation.set_title("Constellation", loc="left", fontsize=15)
ax_constellation.grid(True, alpha=0.3) ax_constellation.grid(True, alpha=0.2, linewidth=0.5)
ax_constellation.set_aspect("equal") ax_constellation.set_aspect("equal")
if ax_psd is not None: if ax_psd is not None:
@ -351,11 +350,11 @@ def view_simple_sig(
freqs = freqs + center_freq_hz freqs = freqs + center_freq_hz
spectrum_db = 10 * np.log10(spectrum + 1e-12) spectrum_db = 10 * np.log10(spectrum + 1e-12)
ax_psd.plot(freqs / 1e6, spectrum_db, color=COLORS["accent"], linewidth=1.0) ax_psd.plot(freqs / 1e6, spectrum_db, color=COLORS["accent"], linewidth=0.8)
ax_psd.set_xlabel("Frequency (MHz)") ax_psd.set_xlabel("Frequency (MHz)")
ax_psd.set_ylabel("Power (dB)") ax_psd.set_ylabel("Power (dB)")
ax_psd.set_title("Power Spectral Density") ax_psd.set_title("Power Spectral Density", loc="left", fontsize=15)
ax_psd.grid(True, alpha=0.3) ax_psd.grid(True, alpha=0.2, linewidth=0.5)
if compact_mode: if compact_mode:
ax1.set_xticks([]) ax1.set_xticks([])
@ -367,13 +366,20 @@ def view_simple_sig(
else: else:
plt.tight_layout() plt.tight_layout()
if show_title: if show_title:
plt.subplots_adjust(top=0.92) plt.subplots_adjust(top=0.9)
if saveplot: if saveplot:
output_path, extension = set_path(output_path=output_path) output_path, extension = set_path(output_path=output_path)
dpi_value = _set_dpi(fast_mode=fast_mode, labels_mode=labels_mode, extension=extension) dpi_value = _set_dpi(fast_mode=fast_mode, labels_mode=labels_mode, extension=extension)
plt.savefig(output_path, dpi=dpi_value, bbox_inches="tight", facecolor="#0f172a", edgecolor="none") plt.savefig(
output_path,
dpi=dpi_value,
bbox_inches="tight",
pad_inches=0.3,
facecolor=matplotlib.rcParams["savefig.facecolor"],
edgecolor=matplotlib.rcParams["savefig.edgecolor"],
)
print(f"Saved signal plot to {output_path}") print(f"Saved signal plot to {output_path}")
return output_path return output_path

View File

@ -4,14 +4,14 @@ import scipy.signal as signal
from plotly.graph_objs import Figure from plotly.graph_objs import Figure
from scipy.fft import fft, fftshift from scipy.fft import fft, fftshift
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
def spectrogram(rec: Recording, thumbnail: bool = False) -> Figure: def spectrogram(rec: Recording, thumbnail: bool = False) -> Figure:
"""Create a spectrogram for the recording. """Create a spectrogram for the recording.
:param rec: Signal to plot. :param rec: Signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:param thumbnail: Whether to return a small thumbnail version or full plot. :param thumbnail: Whether to return a small thumbnail version or full plot.
:type thumbnail: bool :type thumbnail: bool
@ -107,7 +107,7 @@ def iq_time_series(rec: Recording) -> Figure:
"""Create a time series plot of the real and imaginary parts of signal. """Create a time series plot of the real and imaginary parts of signal.
:param rec: Signal to plot. :param rec: Signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: Time series plot, as a Plotly Figure. :return: Time series plot, as a Plotly Figure.
""" """
@ -145,7 +145,7 @@ def frequency_spectrum(rec: Recording) -> Figure:
"""Create a frequency spectrum plot from the recording. """Create a frequency spectrum plot from the recording.
:param rec: Input signal to plot. :param rec: Input signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: Frequency spectrum, as a Plotly figure. :return: Frequency spectrum, as a Plotly figure.
""" """
@ -187,7 +187,7 @@ def constellation(rec: Recording) -> Figure:
"""Create a constellation plot from the recording. """Create a constellation plot from the recording.
:param rec: Input signal to plot. :param rec: Input signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: Constellation, as a Plotly Figure. :return: Constellation, as a Plotly Figure.
""" """
@ -222,7 +222,7 @@ def power_spectral_density(rec: Recording) -> Figure:
"""Create a Power Spectral Density (PSD) plot from the recording. """Create a Power Spectral Density (PSD) plot from the recording.
:param rec: Input signal to plot. :param rec: Input signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: PSD plot, as a Plotly Figure. :return: PSD plot, as a Plotly Figure.
""" """
@ -268,7 +268,7 @@ def fft_plot(rec: Recording) -> Figure:
"""Create an FFT magnitude plot from the recording. """Create an FFT magnitude plot from the recording.
:param rec: Input signal to plot. :param rec: Input signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: FFT plot, as a Plotly Figure. :return: FFT plot, as a Plotly Figure.
""" """
@ -312,7 +312,7 @@ def spectrogram_3d(rec: Recording) -> Figure:
"""Create a 3D spectrogram plot from the recording. """Create a 3D spectrogram plot from the recording.
:param rec: Input signal to plot. :param rec: Input signal to plot.
:type rec: ria_toolkit_oss.datatypes.Recording :type rec: ria_toolkit_oss.data.Recording
:return: 3D Spectrogram, as a Plotly Figure. :return: 3D Spectrogram, as a Plotly Figure.
""" """

View File

@ -11,8 +11,8 @@ from ria_toolkit_oss.annotations import (
split_recording_annotations, split_recording_annotations,
threshold_qualifier, threshold_qualifier,
) )
from ria_toolkit_oss.datatypes import Annotation from ria_toolkit_oss.data import Annotation
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.io import load_recording, to_blue, to_npy, to_sigmf, to_wav from ria_toolkit_oss.io import load_recording, to_blue, to_npy, to_sigmf, to_wav
from ria_toolkit_oss_cli.ria_toolkit_oss.common import ( from ria_toolkit_oss_cli.ria_toolkit_oss.common import (
format_frequency, format_frequency,
@ -51,7 +51,7 @@ def detect_input_format(filepath):
raise click.ClickException(f"Unknown format for '{filepath}'. Supported: .sigmf, .npy, .wav, .blue") raise click.ClickException(f"Unknown format for '{filepath}'. Supported: .sigmf, .npy, .wav, .blue")
def determine_output_path(input_path, output_path, fmt, quiet, overwrite): def determine_output_path(input_path, output_path, fmt, overwrite):
input_path = Path(input_path) input_path = Path(input_path)
input_is_annotated = input_path.stem.endswith("_annotated") input_is_annotated = input_path.stem.endswith("_annotated")
@ -63,24 +63,20 @@ def determine_output_path(input_path, output_path, fmt, quiet, overwrite):
else: else:
target = input_path.with_name(f"{input_path.stem}_annotated{input_path.suffix}") target = input_path.with_name(f"{input_path.stem}_annotated{input_path.suffix}")
if fmt == "sigmf": final_path = normalize_sigmf_path(target) if fmt == "sigmf" else target
final_path = normalize_sigmf_path(target)
if not quiet:
click.echo(f"Saving SigMF metadata to: {final_path}")
else:
final_path = target
if not quiet:
click.echo(f"Saving to: {final_path}")
# Always allow writing to _annotated files; guard against overwriting originals if final_path.exists() and not overwrite:
target_is_annotated = final_path.stem.endswith("_annotated") raise click.ClickException(f"{final_path} already exists. Use --overwrite to replace it.")
if final_path.exists() and not target_is_annotated and final_path != input_path:
click.echo(f"Error: {final_path} is not an annotated file and cannot be overwritten.", err=True)
return None
return final_path return final_path
def check_output_available(input_path, output_path, overwrite):
"""Raise ClickException before any work begins if the output file already exists."""
fmt = detect_input_format(Path(input_path))
determine_output_path(input_path=input_path, output_path=output_path, fmt=fmt, overwrite=overwrite)
def save_recording_auto(recording, output_path, input_path, quiet=False, overwrite=False): def save_recording_auto(recording, output_path, input_path, quiet=False, overwrite=False):
"""Save recording, auto-detecting format from extension. """Save recording, auto-detecting format from extension.
@ -90,11 +86,16 @@ def save_recording_auto(recording, output_path, input_path, quiet=False, overwri
input_path = Path(input_path) input_path = Path(input_path)
fmt = detect_input_format(input_path) fmt = detect_input_format(input_path)
# Determine output path
output_path = determine_output_path( output_path = determine_output_path(
input_path=input_path, output_path=output_path, fmt=fmt, quiet=quiet, overwrite=overwrite input_path=input_path, output_path=output_path, fmt=fmt, overwrite=overwrite
) )
if not quiet:
if fmt == "sigmf":
click.echo(f"Saving SigMF metadata to: {output_path}")
else:
click.echo(f"Saving to: {output_path}")
if fmt == "sigmf": if fmt == "sigmf":
# Normalize path for SigMF # Normalize path for SigMF
base_path = output_path base_path = output_path
@ -312,6 +313,8 @@ def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_
except Exception as e: except Exception as e:
raise click.ClickException(f"Failed to load recording: {e}") raise click.ClickException(f"Failed to load recording: {e}")
check_output_available(input, output, overwrite)
# Validate sample range # Validate sample range
n_samples = len(recording.data[0]) n_samples = len(recording.data[0])
if start < 0: if start < 0:
@ -363,12 +366,9 @@ def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_
if comment: if comment:
click.echo(f" Comment: {comment}") click.echo(f" Comment: {comment}")
try: save_recording_auto(recording, output, input, quiet, overwrite)
save_recording_auto(recording, output, input, quiet, overwrite) if not quiet:
if not quiet: click.echo(" ✓ Saved")
click.echo(" ✓ Saved")
except Exception as e:
raise click.ClickException(f"Failed to save: {e}")
# ============================================================================ # ============================================================================
@ -466,8 +466,6 @@ def clear(input, output, overwrite, force, quiet):
if not quiet: if not quiet:
click.echo(f"\nCleared {count_before} annotation(s)") click.echo(f"\nCleared {count_before} annotation(s)")
recording._annotations = []
try: try:
save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True) save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True)
if not quiet: if not quiet:
@ -503,6 +501,10 @@ def clear(input, output, overwrite, force, quiet):
default="standalone", default="standalone",
help="Annotation type", help="Annotation type",
) )
@click.option(
"--sample-rate", type=float, default=None,
help="Sample rate in Hz (overrides metadata; required if not in file)"
)
@click.option("--output", "-o", type=click.Path(), help="Output file path") @click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
@click.option("--quiet", is_flag=True, help="Quiet mode") @click.option("--quiet", is_flag=True, help="Quiet mode")
@ -517,6 +519,7 @@ def energy(
nfft, nfft,
obw_power, obw_power,
annotation_type, annotation_type,
sample_rate,
output, output,
overwrite, overwrite,
quiet, quiet,
@ -539,8 +542,11 @@ def energy(
ria annotate energy signal.npy --threshold 1.5 --min-distance 10000 ria annotate energy signal.npy --threshold 1.5 --min-distance 10000
ria annotate energy signal.sigmf-data --freq-method obw ria annotate energy signal.sigmf-data --freq-method obw
ria annotate energy signal.sigmf-data --freq-method full-detected ria annotate energy signal.sigmf-data --freq-method full-detected
ria annotate energy signal.npy --sample-rate 1e6
""" """
check_output_available(input, output, overwrite)
try: try:
recording = load_recording(input) recording = load_recording(input)
if not quiet: if not quiet:
@ -548,6 +554,15 @@ def energy(
except Exception as e: except Exception as e:
raise click.ClickException(f"Failed to load recording: {e}") raise click.ClickException(f"Failed to load recording: {e}")
if sample_rate is not None:
recording.sample_rate = sample_rate
if recording.sample_rate is None:
raise click.ClickException(
"Recording metadata does not contain a sample rate. "
"Provide it with --sample-rate (e.g. --sample-rate 1e6)."
)
if not quiet: if not quiet:
click.echo("\nDetecting signals using energy-based method...") click.echo("\nDetecting signals using energy-based method...")
click.echo(" Time detection:") click.echo(" Time detection:")
@ -575,13 +590,13 @@ def energy(
if not quiet: if not quiet:
click.echo(f" ✓ Added {added} annotation(s)") click.echo(f" ✓ Added {added} annotation(s)")
save_recording_auto(recording, output, input, quiet, overwrite)
if not quiet:
click.echo(" ✓ Saved")
except Exception as e: except Exception as e:
raise click.ClickException(f"Energy detection failed: {e}") raise click.ClickException(f"Energy detection failed: {e}")
save_recording_auto(recording, output, input, quiet, overwrite)
if not quiet:
click.echo(" ✓ Saved")
# ============================================================================ # ============================================================================
# CUSUM detection subcommand # CUSUM detection subcommand
@ -601,10 +616,14 @@ def energy(
default="standalone", default="standalone",
help="Annotation type", help="Annotation type",
) )
@click.option(
"--sample-rate", type=float, default=None,
help="Sample rate in Hz (overrides metadata; required if not in file)"
)
@click.option("--output", "-o", type=click.Path(), help="Output file path") @click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
@click.option("--quiet", is_flag=True, help="Quiet mode") @click.option("--quiet", is_flag=True, help="Quiet mode")
def cusum(input, label, min_duration, window_size, tolerance, annotation_type, output, overwrite, quiet): def cusum(input, label, min_duration, window_size, tolerance, annotation_type, sample_rate, output, overwrite, quiet):
"""Auto-detect segments using CUSUM method. """Auto-detect segments using CUSUM method.
Detects signal state changes (on/off, amplitude transitions). Best for Detects signal state changes (on/off, amplitude transitions). Best for
@ -616,7 +635,10 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o
Examples: Examples:
ria annotate cusum signal.sigmf-data --min-duration 5.0 ria annotate cusum signal.sigmf-data --min-duration 5.0
ria annotate cusum data.npy --min-duration 10.0 --label state ria annotate cusum data.npy --min-duration 10.0 --label state
ria annotate cusum data.npy --sample-rate 1e6
""" """
check_output_available(input, output, overwrite)
try: try:
recording = load_recording(input) recording = load_recording(input)
if not quiet: if not quiet:
@ -624,6 +646,15 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o
except Exception as e: except Exception as e:
raise click.ClickException(f"Failed to load recording: {e}") raise click.ClickException(f"Failed to load recording: {e}")
if sample_rate is not None:
recording.sample_rate = sample_rate
if recording.sample_rate is None:
raise click.ClickException(
"Recording metadata does not contain a sample rate. "
"Provide it with --sample-rate (e.g. --sample-rate 1e6)."
)
if not quiet: if not quiet:
click.echo("\nDetecting segments using CUSUM...") click.echo("\nDetecting segments using CUSUM...")
click.echo(f" Min duration: {min_duration} ms") click.echo(f" Min duration: {min_duration} ms")
@ -644,13 +675,13 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o
if not quiet: if not quiet:
click.echo(f" ✓ Added {added} annotation(s)") click.echo(f" ✓ Added {added} annotation(s)")
save_recording_auto(recording, output, input, quiet, overwrite)
if not quiet:
click.echo(" ✓ Saved")
except Exception as e: except Exception as e:
raise click.ClickException(f"CUSUM detection failed: {e}") raise click.ClickException(f"CUSUM detection failed: {e}")
save_recording_auto(recording, output, input, quiet, overwrite)
if not quiet:
click.echo(" ✓ Saved")
# ============================================================================ # ============================================================================
# Threshold detection subcommand # Threshold detection subcommand
@ -675,10 +706,14 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o
help="Annotation type", help="Annotation type",
) )
@click.option("--channel", type=int, default=0, help="Channel index to annotate (default: 0)") @click.option("--channel", type=int, default=0, help="Channel index to annotate (default: 0)")
@click.option(
"--sample-rate", type=float, default=None,
help="Sample rate in Hz (overrides metadata; required if not in file)"
)
@click.option("--output", "-o", type=click.Path(), help="Output file path") @click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
@click.option("--quiet", is_flag=True, help="Quiet mode") @click.option("--quiet", is_flag=True, help="Quiet mode")
def threshold(input, threshold, label, window_size, annotation_type, channel, output, overwrite, quiet): def threshold(input, threshold, label, window_size, annotation_type, channel, sample_rate, output, overwrite, quiet):
"""Auto-detect signals using threshold method. """Auto-detect signals using threshold method.
Detects samples above a percentage of maximum magnitude. Best for simple Detects samples above a percentage of maximum magnitude. Best for simple
@ -688,10 +723,13 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou
Examples: Examples:
ria annotate threshold signal.sigmf-data --threshold 0.7 --label wifi ria annotate threshold signal.sigmf-data --threshold 0.7 --label wifi
ria annotate threshold data.npy --threshold 0.5 --window-size 2048 ria annotate threshold data.npy --threshold 0.5 --window-size 2048
ria annotate threshold data.npy --threshold 0.4 --sample-rate 1e6
""" """
if not (0.0 <= threshold <= 1.0): if not (0.0 <= threshold <= 1.0):
raise click.ClickException(f"--threshold must be between 0.0 and 1.0, got {threshold}") raise click.ClickException(f"--threshold must be between 0.0 and 1.0, got {threshold}")
check_output_available(input, output, overwrite)
try: try:
recording = load_recording(input) recording = load_recording(input)
if not quiet: if not quiet:
@ -699,11 +737,21 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou
except Exception as e: except Exception as e:
raise click.ClickException(f"Failed to load recording: {e}") raise click.ClickException(f"Failed to load recording: {e}")
if sample_rate is not None:
recording.sample_rate = sample_rate
if recording.sample_rate is None:
raise click.ClickException(
"Recording metadata does not contain a sample rate. "
"Provide it with --sample-rate (e.g. --sample-rate 1e6)."
)
if not quiet: if not quiet:
click.echo("\nDetecting signals using threshold qualifier...") click.echo("\nDetecting signals using threshold qualifier...")
click.echo(f" Threshold: {threshold * 100:.1f}% of max magnitude") click.echo(f" Threshold: {threshold * 100:.1f}% of max magnitude")
click.echo(f" Window size: {'auto (1ms)' if window_size is None else f'{window_size} samples'}") click.echo(f" Window size: {'auto (1ms)' if window_size is None else f'{window_size} samples'}")
click.echo(f" Channel: {channel}") click.echo(f" Channel: {channel}")
click.echo(f" Sample rate: {recording.sample_rate:.0f} Hz")
try: try:
initial_count = len(recording.annotations) initial_count = len(recording.annotations)
@ -719,13 +767,13 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou
if not quiet: if not quiet:
click.echo(f" ✓ Added {added} annotation(s)") click.echo(f" ✓ Added {added} annotation(s)")
save_recording_auto(recording, output, input, quiet, overwrite)
if not quiet:
click.echo(" ✓ Saved")
except Exception as e: except Exception as e:
raise click.ClickException(f"Threshold detection failed: {e}") raise click.ClickException(f"Threshold detection failed: {e}")
save_recording_auto(recording, output, input, quiet, overwrite)
if not quiet:
click.echo(" ✓ Saved")
# ============================================================================ # ============================================================================
# Separate subcommand (Phase 2: Parallel signal separation) # Separate subcommand (Phase 2: Parallel signal separation)
@ -738,11 +786,30 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou
@click.option("--nfft", type=int, default=65536, help="FFT size for spectral analysis") @click.option("--nfft", type=int, default=65536, help="FFT size for spectral analysis")
@click.option("--noise-threshold-db", type=float, help="Noise floor threshold in dB (auto-estimated if not specified)") @click.option("--noise-threshold-db", type=float, help="Noise floor threshold in dB (auto-estimated if not specified)")
@click.option("--min-component-bw", type=float, default=50e3, help="Min component bandwidth in Hz") @click.option("--min-component-bw", type=float, default=50e3, help="Min component bandwidth in Hz")
@click.option(
"--sample-rate", type=float, default=None,
help="Sample rate in Hz (overrides metadata; required if not in file)"
)
@click.option("--output", "-o", type=click.Path(), help="Output file path") @click.option("--output", "-o", type=click.Path(), help="Output file path")
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
@click.option("--quiet", is_flag=True, help="Quiet mode") @click.option("--quiet", is_flag=True, help="Quiet mode")
@click.option("--verbose", is_flag=True, help="Verbose output (show detected components)") @click.option("--verbose", is_flag=True, help="Verbose output (show detected components)")
def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output, overwrite, quiet, verbose): def _log_separate_start(quiet, recording, indices_list, nfft, noise_threshold_db, min_component_bw):
if not quiet:
click.echo("\nSplitting annotations by frequency components...")
click.echo(f" Input annotations: {len(recording.annotations)}")
if indices_list:
click.echo(f" Splitting indices: {indices_list}")
click.echo(f" FFT size: {nfft}")
if noise_threshold_db is not None:
click.echo(f" Noise threshold: {noise_threshold_db} dB")
else:
click.echo(" Noise threshold: auto-estimated")
click.echo(f" Min component BW: {format_frequency(min_component_bw)}")
def separate(
input, indices, nfft, noise_threshold_db, min_component_bw, sample_rate, output, overwrite, quiet, verbose):
""" """
Auto-detect parallel frequency-offset signals and split into sub-bands. Auto-detect parallel frequency-offset signals and split into sub-bands.
@ -768,6 +835,8 @@ def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output,
ria annotate separate signal.npy --min-component-bw 100000 ria annotate separate signal.npy --min-component-bw 100000
""" """
check_output_available(input, output, overwrite)
try: try:
recording = load_recording(input) recording = load_recording(input)
if not quiet: if not quiet:
@ -775,6 +844,15 @@ def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output,
except Exception as e: except Exception as e:
raise click.ClickException(f"Failed to load recording: {e}") raise click.ClickException(f"Failed to load recording: {e}")
if sample_rate is not None:
recording.sample_rate = sample_rate
if recording.sample_rate is None:
raise click.ClickException(
"Recording metadata does not contain a sample rate. "
"Provide it with --sample-rate (e.g. --sample-rate 1e6)."
)
# Parse indices if specified # Parse indices if specified
indices_list = get_indices_list(indices=indices, recording=recording) indices_list = get_indices_list(indices=indices, recording=recording)
@ -783,17 +861,7 @@ def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output,
click.echo("No annotations to split") click.echo("No annotations to split")
return return
if not quiet: _log_separate_start(quiet, recording, indices_list, nfft, noise_threshold_db, min_component_bw)
click.echo("\nSplitting annotations by frequency components...")
click.echo(f" Input annotations: {len(recording.annotations)}")
if indices_list:
click.echo(f" Splitting indices: {indices_list}")
click.echo(f" FFT size: {nfft}")
if noise_threshold_db is not None:
click.echo(f" Noise threshold: {noise_threshold_db} dB")
else:
click.echo(" Noise threshold: auto-estimated")
click.echo(f" Min component BW: {format_frequency(min_component_bw)}")
try: try:
initial_count = len(recording.annotations) initial_count = len(recording.annotations)
@ -821,8 +889,9 @@ def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output,
f"{format_sample_count(ann.sample_start + ann.sample_count)}: {freq_range}" f"{format_sample_count(ann.sample_start + ann.sample_count)}: {freq_range}"
) )
save_recording_auto(recording, output, input, quiet, overwrite)
if not quiet:
click.echo(" ✓ Saved")
except Exception as e: except Exception as e:
raise click.ClickException(f"Spectral separation failed: {e}") raise click.ClickException(f"Spectral separation failed: {e}")
save_recording_auto(recording, output, input, quiet, overwrite)
if not quiet:
click.echo(" ✓ Saved")

View File

@ -7,7 +7,7 @@ from pathlib import Path
import click import click
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
from ria_toolkit_oss.io import from_npy_legacy, load_recording from ria_toolkit_oss.io import from_npy_legacy, load_recording
from ria_toolkit_oss_cli.ria_toolkit_oss.common import ( from ria_toolkit_oss_cli.ria_toolkit_oss.common import (
echo_progress, echo_progress,

View File

@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional
import click import click
import yaml import yaml
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.io.recording import to_blue, to_npy, to_sigmf, to_wav from ria_toolkit_oss.io.recording import to_blue, to_npy, to_sigmf, to_wav

View File

@ -8,7 +8,7 @@ import numpy as np
import yaml import yaml
import ria_toolkit_oss.signal.basic_signal_generator as basic_gen import ria_toolkit_oss.signal.basic_signal_generator as basic_gen
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
from ria_toolkit_oss.signal.block_generator.basic import FrequencyShift from ria_toolkit_oss.signal.block_generator.basic import FrequencyShift
from ria_toolkit_oss.signal.block_generator.continuous_modulation.fsk_modulator import ( from ria_toolkit_oss.signal.block_generator.continuous_modulation.fsk_modulator import (
FSKModulator, FSKModulator,

View File

@ -23,9 +23,9 @@ def serve(host: str, port: int, api_key: str, log_level: str):
\b \b
Endpoints: Endpoints:
POST /orchestrator/deploy POST /conductor/deploy
GET /orchestrator/status/{campaign_id} GET /conductor/status/{campaign_id}
POST /orchestrator/cancel/{campaign_id} POST /conductor/cancel/{campaign_id}
POST /inference/load POST /inference/load
POST /inference/start POST /inference/start
POST /inference/stop POST /inference/stop

View File

@ -8,7 +8,7 @@ from pathlib import Path
import click import click
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.io.recording import load_recording from ria_toolkit_oss.io.recording import load_recording
from ria_toolkit_oss.transforms import iq_augmentations, iq_impairments from ria_toolkit_oss.transforms import iq_augmentations, iq_impairments
from ria_toolkit_oss_cli.ria_toolkit_oss.common import ( from ria_toolkit_oss_cli.ria_toolkit_oss.common import (

View File

@ -6,7 +6,7 @@ import time
import click import click
from ria_toolkit_oss.datatypes import Recording from ria_toolkit_oss.data import Recording
from ria_toolkit_oss.io import from_npy_legacy, load_recording from ria_toolkit_oss.io import from_npy_legacy, load_recording
from .common import ( from .common import (

View File

@ -1,4 +1,4 @@
from ria_toolkit_oss.datatypes import Annotation from ria_toolkit_oss.data import Annotation
def test_annotation_creation(): def test_annotation_creation():

View File

@ -3,8 +3,8 @@ from typing import Iterable
import numpy as np import numpy as np
import pytest import pytest
from ria_toolkit_oss.datatypes import Annotation, Recording from ria_toolkit_oss.data import Annotation, Recording
from ria_toolkit_oss.datatypes.recording import generate_recording_id from ria_toolkit_oss.data.recording import generate_recording_id
COMPLEX_DATA_1 = [[0.5 + 0.5j, 0.1 + 0.1j, 0.3 + 0.3j, 0.4 + 0.4j, 0.5 + 0.5j]] COMPLEX_DATA_1 = [[0.5 + 0.5j, 0.1 + 0.1j, 0.3 + 0.3j, 0.4 + 0.4j, 0.5 + 0.5j]]

View File

@ -1,6 +1,6 @@
import numpy as np import numpy as np
from ria_toolkit_oss.datatypes import Annotation, Recording from ria_toolkit_oss.data import Annotation, Recording
from ria_toolkit_oss.io.recording import ( from ria_toolkit_oss.io.recording import (
from_npy, from_npy,
from_sigmf, from_sigmf,

View File

@ -0,0 +1,314 @@
"""Tests for orchestration executor — StepResult, CampaignResult, _run_script, _extract_tx_params."""
from __future__ import annotations
import json
import stat
from types import SimpleNamespace
import pytest
from ria_toolkit_oss.orchestration.executor import (
CampaignResult,
StepResult,
_extract_tx_params,
_run_script,
)
from ria_toolkit_oss.orchestration.qa import QAResult
def _ok_qa() -> QAResult:
return QAResult(passed=True, flagged=False, snr_db=20.0, duration_s=1.0)
def _flagged_qa() -> QAResult:
return QAResult(passed=True, flagged=True, snr_db=5.0, duration_s=1.0, issues=["low SNR"])
def _failed_qa() -> QAResult:
return QAResult(passed=False, flagged=True, snr_db=0.0, duration_s=0.0, issues=["no signal"])
# ---------------------------------------------------------------------------
# StepResult
# ---------------------------------------------------------------------------
class TestStepResult:
def test_ok_true_when_no_error_and_qa_passed(self):
r = StepResult(
transmitter_id="tx1",
step_label="step1",
output_path="/out/rec.sigmf-data",
qa=_ok_qa(),
capture_timestamp=0.0,
)
assert r.ok is True
def test_ok_false_when_error_set(self):
r = StepResult(
transmitter_id="tx1",
step_label="step1",
output_path=None,
qa=_ok_qa(),
capture_timestamp=0.0,
error="SDR failed",
)
assert r.ok is False
def test_ok_false_when_qa_not_passed(self):
r = StepResult(
transmitter_id="tx1",
step_label="step1",
output_path="/out",
qa=_failed_qa(),
capture_timestamp=0.0,
)
assert r.ok is False
def test_to_dict_contains_required_keys(self):
r = StepResult(
transmitter_id="tx1",
step_label="step1",
output_path="/out/rec.sigmf-data",
qa=_ok_qa(),
capture_timestamp=1234.5,
)
d = r.to_dict()
assert d["transmitter_id"] == "tx1"
assert d["step_label"] == "step1"
assert d["output_path"] == "/out/rec.sigmf-data"
assert d["capture_timestamp"] == pytest.approx(1234.5)
assert d["error"] is None
assert d["qa"]["passed"] is True
def test_to_dict_includes_error_when_set(self):
r = StepResult(
transmitter_id="tx1",
step_label="step1",
output_path=None,
qa=_failed_qa(),
capture_timestamp=0.0,
error="disk full",
)
assert r.to_dict()["error"] == "disk full"
# ---------------------------------------------------------------------------
# CampaignResult
# ---------------------------------------------------------------------------
class TestCampaignResult:
def _make(self, steps: list) -> CampaignResult:
r = CampaignResult(campaign_name="test_campaign")
r.steps = steps
r.end_time = r.start_time + 5.0
return r
def test_total_steps(self):
r = self._make(
[
StepResult("tx1", "s1", "/out", _ok_qa(), 0.0),
StepResult("tx1", "s2", "/out", _ok_qa(), 0.0),
]
)
assert r.total_steps == 2
def test_passed_count(self):
r = self._make(
[
StepResult("tx1", "s1", "/out", _ok_qa(), 0.0),
StepResult("tx1", "s2", "/out", _failed_qa(), 0.0),
]
)
assert r.passed == 1
def test_failed_count(self):
r = self._make(
[
StepResult("tx1", "s1", "/out", _ok_qa(), 0.0),
StepResult("tx1", "s2", "/out", _failed_qa(), 0.0),
]
)
assert r.failed == 1
def test_flagged_count(self):
r = self._make(
[
StepResult("tx1", "s1", "/out", _ok_qa(), 0.0),
StepResult("tx1", "s2", "/out", _flagged_qa(), 0.0),
]
)
assert r.flagged == 1
def test_error_step_counts_as_failed_not_passed(self):
r = self._make(
[
StepResult("tx1", "s1", None, _ok_qa(), 0.0, error="disk full"),
]
)
assert r.failed == 1
assert r.passed == 0
def test_duration_s_from_end_time(self):
r = CampaignResult(campaign_name="c")
r.start_time = 100.0
r.end_time = 115.0
assert r.duration_s == pytest.approx(15.0)
def test_to_dict_structure(self):
r = self._make([StepResult("tx1", "s1", "/out", _ok_qa(), 0.0)])
d = r.to_dict()
assert d["campaign_name"] == "test_campaign"
assert d["total_steps"] == 1
assert d["passed"] == 1
assert len(d["steps"]) == 1
def test_write_report(self, tmp_path):
r = self._make([StepResult("tx1", "s1", "/out", _ok_qa(), 0.0)])
out = tmp_path / "report.json"
r.write_report(str(out))
assert out.exists()
data = json.loads(out.read_text())
assert data["campaign_name"] == "test_campaign"
def test_write_report_creates_nested_dirs(self, tmp_path):
r = self._make([])
out = tmp_path / "nested" / "deep" / "report.json"
r.write_report(str(out))
assert out.exists()
# ---------------------------------------------------------------------------
# _run_script
# ---------------------------------------------------------------------------
class TestRunScript:
def _script(self, tmp_path, body: str) -> str:
s = tmp_path / "script.sh"
s.write_text("#!/bin/sh\n" + body)
s.chmod(s.stat().st_mode | stat.S_IEXEC)
return str(s)
def test_returns_stdout(self, tmp_path):
out = _run_script(self._script(tmp_path, 'echo "hello world"'))
assert out == "hello world"
def test_passes_args_to_script(self, tmp_path):
out = _run_script(self._script(tmp_path, 'echo "$1 $2"'), "configure", "arg2")
assert "configure" in out
def test_raises_on_nonzero_exit(self, tmp_path):
with pytest.raises(RuntimeError, match="exited 1"):
_run_script(self._script(tmp_path, "exit 1"))
def test_raises_on_relative_path(self):
with pytest.raises(RuntimeError, match="absolute"):
_run_script("relative/script.sh")
def test_raises_on_missing_file(self, tmp_path):
with pytest.raises(RuntimeError):
_run_script(str(tmp_path / "nonexistent.sh"))
def test_raises_on_timeout(self, tmp_path):
with pytest.raises(RuntimeError, match="timed out"):
_run_script(self._script(tmp_path, "sleep 60"), timeout=0.1)
def test_stderr_included_in_error_message(self, tmp_path):
with pytest.raises(RuntimeError) as exc_info:
_run_script(self._script(tmp_path, "echo 'bad thing' >&2; exit 1"))
assert "bad thing" in str(exc_info.value)
# ---------------------------------------------------------------------------
# _extract_tx_params
# ---------------------------------------------------------------------------
class TestExtractTxParams:
def test_returns_none_when_no_sdr_agent_attribute(self):
tx = SimpleNamespace()
assert _extract_tx_params(tx) is None
def test_returns_none_when_sdr_agent_is_none(self):
tx = SimpleNamespace(sdr_agent=None)
assert _extract_tx_params(tx) is None
def test_returns_none_when_sdr_agent_is_empty_dict(self):
tx = SimpleNamespace(sdr_agent={})
assert _extract_tx_params(tx) is None
def test_returns_signal_params(self):
tx = SimpleNamespace(
sdr_agent={
"modulation": "QPSK",
"symbol_rate": 1e6,
"center_frequency": 2.4e9,
}
)
result = _extract_tx_params(tx)
assert result == {"modulation": "QPSK", "symbol_rate": 1e6, "center_frequency": 2.4e9}
def test_strips_infra_key_node_id(self):
tx = SimpleNamespace(
sdr_agent={
"modulation": "BPSK",
"node_id": "node_abc123",
}
)
result = _extract_tx_params(tx)
assert "node_id" not in result
assert result == {"modulation": "BPSK"}
def test_strips_infra_key_session_code(self):
tx = SimpleNamespace(
sdr_agent={
"modulation": "FSK",
"session_code": "amber-peak-transmit",
}
)
result = _extract_tx_params(tx)
assert "session_code" not in result
def test_strips_none_values(self):
tx = SimpleNamespace(
sdr_agent={
"modulation": "QPSK",
"order": None,
"rolloff": 0.35,
}
)
result = _extract_tx_params(tx)
assert "order" not in result
assert result == {"modulation": "QPSK", "rolloff": 0.35}
def test_does_not_mutate_source_dict(self):
cfg = {"modulation": "QPSK", "node_id": "nid", "session_code": "code"}
tx = SimpleNamespace(sdr_agent=cfg)
_extract_tx_params(tx)
assert "node_id" in cfg
def test_full_sdr_agent_config(self):
tx = SimpleNamespace(
sdr_agent={
"modulation": "16QAM",
"order": 4,
"symbol_rate": 5e6,
"center_frequency": 915e6,
"filter": "rrc",
"rolloff": 0.35,
"node_id": "node_xyz",
"session_code": "some-code",
}
)
result = _extract_tx_params(tx)
assert result == {
"modulation": "16QAM",
"order": 4,
"symbol_rate": 5e6,
"center_frequency": 915e6,
"filter": "rrc",
"rolloff": 0.35,
}

View File

@ -5,7 +5,7 @@ import time
import numpy as np import numpy as np
import pytest import pytest
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.orchestration.campaign import CaptureStep from ria_toolkit_oss.orchestration.campaign import CaptureStep
from ria_toolkit_oss.orchestration.labeler import build_output_filename, label_recording from ria_toolkit_oss.orchestration.labeler import build_output_filename, label_recording
@ -109,6 +109,38 @@ class TestLabelRecording:
result = label_recording(rec, "iphone13_001", _wifi_step(), time.time()) result = label_recording(rec, "iphone13_001", _wifi_step(), time.time())
assert result is rec assert result is rec
def test_tx_params_none_by_default(self):
rec = label_recording(_simple_recording(), "iphone13_001", _wifi_step(), time.time())
tx_keys = [k for k in rec.metadata if k.startswith("tx_")]
assert tx_keys == []
def test_tx_params_written_as_tx_prefix_keys(self):
params = {"modulation": "QPSK", "symbol_rate": 1e6}
rec = label_recording(_simple_recording(), "dev", _wifi_step(), time.time(), tx_params=params)
assert rec.metadata["tx_modulation"] == "QPSK"
assert rec.metadata["tx_symbol_rate"] == pytest.approx(1e6)
def test_tx_params_multiple_fields(self):
params = {
"modulation": "16QAM",
"order": 4,
"symbol_rate": 5e6,
"center_frequency": 915e6,
"filter": "rrc",
"rolloff": 0.35,
}
rec = label_recording(_simple_recording(), "dev", _wifi_step(), time.time(), tx_params=params)
for k, v in params.items():
assert f"tx_{k}" in rec.metadata
assert (
rec.metadata[f"tx_{k}"] == pytest.approx(v) if isinstance(v, float) else rec.metadata[f"tx_{k}"] == v
)
def test_tx_params_empty_dict_writes_nothing(self):
rec = label_recording(_simple_recording(), "dev", _wifi_step(), time.time(), tx_params={})
tx_keys = [k for k in rec.metadata if k.startswith("tx_") and k != "tx_power_dbm"]
assert tx_keys == []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build_output_filename # build_output_filename

View File

@ -3,7 +3,7 @@
import numpy as np import numpy as np
import pytest import pytest
from ria_toolkit_oss.datatypes.recording import Recording from ria_toolkit_oss.data.recording import Recording
from ria_toolkit_oss.orchestration.campaign import QAConfig from ria_toolkit_oss.orchestration.campaign import QAConfig
from ria_toolkit_oss.orchestration.qa import QAResult, check_recording, estimate_snr_db from ria_toolkit_oss.orchestration.qa import QAResult, check_recording, estimate_snr_db

View File

@ -0,0 +1,153 @@
"""Tests for TxExecutor — signal synthesis and step execution."""
from __future__ import annotations
import threading
from unittest.mock import patch
import numpy as np
import pytest
from ria_toolkit_oss.orchestration.tx_executor import TxExecutor
def _cfg(modulation="QPSK", symbol_rate=100_000, steps=None):
return {
"id": "test-tx",
"type": "sdr",
"control_method": "sdr_agent",
"sdr_agent": {
"modulation": modulation,
"symbol_rate": symbol_rate,
"center_frequency": 0.0,
"filter": "rrc",
"rolloff": 0.35,
},
"schedule": steps or [{"label": "step1", "duration": 0.001, "power_dbm": -10}],
}
# ---------------------------------------------------------------------------
# Initialisation
# ---------------------------------------------------------------------------
class TestTxExecutorInit:
def test_stores_sdr_device(self):
ex = TxExecutor(_cfg(), sdr_device="pluto")
assert ex.sdr_device == "pluto"
def test_stop_event_created_when_not_supplied(self):
ex = TxExecutor(_cfg())
assert isinstance(ex.stop_event, threading.Event)
assert not ex.stop_event.is_set()
def test_accepts_external_stop_event(self):
ev = threading.Event()
ex = TxExecutor(_cfg(), stop_event=ev)
assert ex.stop_event is ev
# ---------------------------------------------------------------------------
# run() — schedule iteration
# ---------------------------------------------------------------------------
class TestTxExecutorRun:
def test_empty_schedule_returns_immediately(self):
cfg = _cfg(steps=[])
ex = TxExecutor(cfg)
ex.run() # must not raise or block
def test_pre_set_stop_event_skips_all_steps(self):
ev = threading.Event()
ev.set()
ex = TxExecutor(_cfg(), stop_event=ev)
# If stop was set, _execute_step should never be called.
# run() should return cleanly without attempting synthesis.
ex.run()
def test_no_sdr_falls_back_to_simulation(self, monkeypatch):
"""Without SDR hardware TxExecutor simulates by calling stop_event.wait."""
cfg = _cfg(steps=[{"label": "s", "duration": 0.001, "power_dbm": 0}])
waited = []
real_ev = threading.Event()
def _fake_wait(timeout=None):
waited.append(timeout)
return False
monkeypatch.setattr(real_ev, "wait", _fake_wait)
# Patch SDR init to always fail (forces simulation path)
with patch.object(TxExecutor, "_init_sdr", lambda self, *a, **kw: setattr(self, "_sdr", None)):
ex = TxExecutor(cfg, sdr_device="nonexistent_xyz", stop_event=real_ev)
ex.run()
assert len(waited) >= 1, "expected stop_event.wait to be called for simulation"
# ---------------------------------------------------------------------------
# _synthesise — all modulation types and filter types
# ---------------------------------------------------------------------------
class TestSynthesise:
@pytest.fixture(autouse=True)
def _ex(self):
self.ex = TxExecutor(_cfg())
def _synth(self, mod, num_samples=256):
return self.ex._synthesise(mod, sps=4, num_samples=num_samples, filter_type="rrc", rolloff=0.35)
@pytest.mark.parametrize("mod", ["BPSK", "QPSK", "8PSK", "16QAM", "64QAM", "256QAM"])
def test_psk_qam_returns_complex64_array(self, mod):
sig = self._synth(mod)
assert sig.dtype == np.complex64
assert len(sig) == 256
def test_fsk_returns_correct_length(self):
sig = self._synth("FSK")
assert len(sig) == 256
def test_ook_returns_correct_length(self):
sig = self._synth("OOK")
assert len(sig) == 256
def test_gmsk_returns_correct_length(self):
sig = self._synth("GMSK")
assert len(sig) == 256
def test_oqpsk_returns_correct_length(self):
sig = self._synth("OQPSK")
assert len(sig) == 256
@pytest.mark.parametrize("mod", ["BPSK", "QPSK", "16QAM", "FSK", "OOK", "GMSK"])
def test_samples_are_finite(self, mod):
sig = self._synth(mod)
assert np.all(np.isfinite(sig.real)), f"{mod}: non-finite real samples"
assert np.all(np.isfinite(sig.imag)), f"{mod}: non-finite imag samples"
def test_unknown_modulation_defaults_to_qpsk(self):
sig = self._synth("UNKNOWN_MOD_XYZ")
assert len(sig) == 256
assert sig.dtype == np.complex64
@pytest.mark.parametrize("filter_type", ["rrc", "rc", "gaussian", "rect", "none"])
def test_all_filter_types(self, filter_type):
sig = self.ex._synthesise("QPSK", sps=4, num_samples=128, filter_type=filter_type, rolloff=0.35)
assert len(sig) == 128
@pytest.mark.parametrize("n", [64, 128, 512, 1024])
def test_output_length_matches_requested_samples(self, n):
sig = self._synth("QPSK", num_samples=n)
assert len(sig) == n
def test_bpsk_output_is_complex_not_real(self):
sig = self._synth("BPSK")
# complex64 always has imag part; just check dtype
assert sig.dtype == np.complex64
def test_256qam_correct_length(self):
sig = self._synth("256QAM")
assert len(sig) == 256

View File

@ -7,7 +7,7 @@ import numpy as np
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from ria_toolkit_oss.datatypes import Annotation, Recording from ria_toolkit_oss.data import Annotation, Recording
from ria_toolkit_oss.io import load_recording, to_npy, to_sigmf from ria_toolkit_oss.io import load_recording, to_npy, to_sigmf
from ria_toolkit_oss_cli.cli import cli from ria_toolkit_oss_cli.cli import cli

Some files were not shown because too many files have changed in this diff Show More