From dae9510981883b8d013ac7c8cd2150545d627ea6 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 20 Apr 2026 12:33:14 -0400 Subject: [PATCH 01/28] transmission code --- poetry.lock | 616 +++++++++--------- pyproject.toml | 5 + src/ria_toolkit_oss/agent.py | 124 +++- src/ria_toolkit_oss/orchestration/executor.py | 16 + src/ria_toolkit_oss/orchestration/labeler.py | 9 + .../orchestration/tx_executor.py | 253 +++++++ tests/orchestration/test_executor.py | 295 +++++++++ tests/orchestration/test_labeler.py | 36 + tests/orchestration/test_tx_executor.py | 155 +++++ tests/ria_toolkit_oss_cli/test_generate.py | 4 +- tests/test_agent.py | 249 +++++++ 11 files changed, 1448 insertions(+), 314 deletions(-) create mode 100644 src/ria_toolkit_oss/orchestration/tx_executor.py create mode 100644 tests/orchestration/test_executor.py create mode 100644 tests/orchestration/test_tx_executor.py create mode 100644 tests/test_agent.py diff --git a/poetry.lock b/poetry.lock index d2ddd55..09e83f8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -275,153 +275,153 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.6" +version = "3.4.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["agent", "docs"] files = [ - {file = "charset_normalizer-3.4.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-win32.whl", hash = "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-win_amd64.whl", hash = "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6"}, - {file = "charset_normalizer-3.4.6-cp310-cp310-win_arm64.whl", hash = "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4"}, - {file = "charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb"}, - {file = "charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389"}, - {file = "charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f"}, - {file = "charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4"}, - {file = "charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-win32.whl", hash = "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae"}, - {file = "charset_normalizer-3.4.6-cp38-cp38-win_amd64.whl", hash = "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-win32.whl", hash = "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-win_amd64.whl", hash = "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8"}, - {file = "charset_normalizer-3.4.6-cp39-cp39-win_arm64.whl", hash = "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8"}, - {file = "charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69"}, - {file = "charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev", "docs", "server", "test"] files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, + {file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, + {file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, ] [package.dependencies] @@ -627,6 +627,18 @@ files = [ docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] tests = ["pytest", "pytest-cov", "pytest-xdist"] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "dill" version = "0.4.1" @@ -688,14 +700,14 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi" -version = "0.135.3" +version = "0.136.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.10" groups = ["server", "test"] files = [ - {file = "fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98"}, - {file = "fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654"}, + {file = "fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4"}, + {file = "fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e"}, ] [package.dependencies] @@ -707,19 +719,19 @@ typing-inspection = ">=0.4.2" [package.extras] all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" -version = "3.25.2" +version = "3.29.0" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, - {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, + {file = "filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258"}, + {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, ] [[package]] @@ -1468,14 +1480,14 @@ files = [ [[package]] name = "narwhals" -version = "2.18.1" +version = "2.20.0" description = "Extremely lightweight compatibility layer between dataframe libraries" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad"}, - {file = "narwhals-2.18.1.tar.gz", hash = "sha256:652a1fcc9d432bbf114846688884c215f17eb118aa640b7419295d2f910d2a8b"}, + {file = "narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d"}, + {file = "narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e"}, ] [package.extras] @@ -1624,14 +1636,14 @@ sympy = "*" [[package]] name = "packaging" -version = "26.0" +version = "26.1" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "dev", "docs", "server", "test"] files = [ - {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, - {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, + {file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, + {file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, ] [[package]] @@ -1863,26 +1875,26 @@ xmp = ["defusedxml"] [[package]] name = "platformdirs" -version = "4.9.4" +version = "4.9.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev", "test"] files = [ - {file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"}, - {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, + {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, + {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, ] [[package]] name = "plotly" -version = "6.6.0" +version = "6.7.0" description = "An open-source interactive data visualization library for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0"}, - {file = "plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c"}, + {file = "plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0"}, + {file = "plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6"}, ] [package.dependencies] @@ -1890,11 +1902,11 @@ narwhals = ">=1.15.1" packaging = "*" [package.extras] -dev = ["plotly[dev-optional]"] -dev-build = ["build", "jupyter", "plotly[dev-core]"] +dev = ["anywidget", "build", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "jupyterlab", "kaleido (>=1.1.0)", "numpy (>=1.22)", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "polars[timezone]", "pyarrow", "pyshp", "pytest", "pytz", "requests", "ruff (==0.11.12)", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] +dev-build = ["build", "jupyterlab", "pytest", "requests", "ruff (==0.11.12)"] dev-core = ["pytest", "requests", "ruff (==0.11.12)"] -dev-optional = ["anywidget", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "numpy", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "plotly[dev-build]", "plotly[kaleido]", "polars[timezone]", "pyarrow", "pyshp", "pytz", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] -express = ["numpy"] +dev-optional = ["anywidget", "build", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "jupyterlab", "kaleido (>=1.1.0)", "numpy (>=1.22)", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "polars[timezone]", "pyarrow", "pyshp", "pytest", "pytz", "requests", "ruff (==0.11.12)", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] +express = ["numpy (>=1.22)"] kaleido = ["kaleido (>=1.1.0)"] [[package]] @@ -1958,19 +1970,19 @@ files = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.13.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["server", "test"] files = [ - {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, - {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, + {file = "pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927"}, + {file = "pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.41.5" +pydantic-core = "2.46.3" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" @@ -1980,133 +1992,132 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.3" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["server", "test"] files = [ - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, - {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"}, - {file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"}, - {file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"}, - {file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"}, - {file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"}, - {file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"}, - {file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"}, - {file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"}, - {file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"}, - {file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"}, - {file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"}, - {file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"}, - {file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"}, - {file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, - {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, - {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, - {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, - {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, - {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"}, - {file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"}, - {file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"}, - {file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"}, - {file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"}, - {file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"}, - {file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"}, - {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, - {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1"}, + {file = "pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045"}, + {file = "pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4"}, + {file = "pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7"}, + {file = "pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b"}, + {file = "pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22"}, + {file = "pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c"}, + {file = "pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e"}, + {file = "pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8"}, + {file = "pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fa3eb7c2995aa443687a825bc30395c8521b7c6ec201966e55debfd1128bcceb"}, + {file = "pydantic_core-2.46.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d08782c4045f90724b44c95d35ebec0d67edb8a957a2ac81d5a8e4b8a200495"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:831eb19aa789a97356979e94c981e5667759301fb708d1c0d5adf1bc0098b873"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4335e87c7afa436a0dfa899e138d57a72f8aad542e2cf19c36fb428461caabd0"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99421e7684a60f7f3550a1d159ade5fdff1954baedb6bdd407cba6a307c9f27d"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd81f6907932ebac3abbe41378dac64b2380db1287e2aa64d8d88f78d170f51a"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f247596366f4221af52beddd65af1218797771d6989bc891a0b86ccaa019168"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:6dff8cc884679df229ebc6d8eb2321ea6f8e091bc7d4886d4dc2e0e71452843c"}, + {file = "pydantic_core-2.46.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68ef2f623dda6d5a9067ac014e406c020c780b2a358930a7e5c1b73702900720"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d56bdb4af1767cc15b0386b3c581fdfe659bb9ee4a4f776e92c1cd9d074000d6"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:91249bcb7c165c2fb2a2f852dbc5c91636e2e218e75d96dfdd517e4078e173dd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b068543bdb707f5d935dab765d99227aa2545ef2820935f2e5dd801795c7dbd"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win32.whl", hash = "sha256:dcda6583921c05a40533f982321532f2d8db29326c7b95c4026941fa5074bd79"}, + {file = "pydantic_core-2.46.3-cp39-cp39-win_amd64.whl", hash = "sha256:a35cc284c8dd7edae8a31533713b4d2467dfe7c4f1b5587dd4031f28f90d1d13"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76"}, + {file = "pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5"}, + {file = "pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff"}, + {file = "pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c"}, ] [package.dependencies] @@ -2245,14 +2256,14 @@ six = ">=1.5" [[package]] name = "python-discovery" -version = "1.2.1" +version = "1.2.2" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502"}, - {file = "python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e"}, + {file = "python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a"}, + {file = "python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb"}, ] [package.dependencies] @@ -2775,17 +2786,18 @@ test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis [[package]] name = "sigmf" -version = "1.7.2" +version = "1.8.0" description = "Easily interact with Signal Metadata Format (SigMF) recordings." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "sigmf-1.7.2-py3-none-any.whl", hash = "sha256:6599b95e8bd3ac2c568b8ec46c312a77b80868cbda79d729234f396d2194d3d8"}, - {file = "sigmf-1.7.2.tar.gz", hash = "sha256:5f80f7127539358c7528ccf26e0ac5b3c268ecaeb69a921542e8ff71d0c85346"}, + {file = "sigmf-1.8.0-py3-none-any.whl", hash = "sha256:f233ab04344fa3e42170926a646f7e53edd7edc65fcda42eb3d7efaf8a2e8263"}, + {file = "sigmf-1.8.0.tar.gz", hash = "sha256:91e10cb046499639e5f961d66a24c17a33ff76fc98df892eab0953cc9d659a50"}, ] [package.dependencies] +defusedxml = "*" jsonschema = "*" numpy = "*" @@ -3131,14 +3143,14 @@ files = [ [[package]] name = "tox" -version = "4.52.0" +version = "4.53.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "tox-4.52.0-py3-none-any.whl", hash = "sha256:624d8ea4a8c6d5e8d168eedf0e318d736fb22e83ca83137d001ac65ffdec46fd"}, - {file = "tox-4.52.0.tar.gz", hash = "sha256:6054abf5c8b61d58776fbec991f9bf0d34bb883862beb93d2fe55601ef3977c9"}, + {file = "tox-4.53.0-py3-none-any.whl", hash = "sha256:cc4e716d18c4889aa179d785175c438fa60c35deef20ce689ec288d8fb656096"}, + {file = "tox-4.53.0.tar.gz", hash = "sha256:62c780e42f87d34ee60f2ea20342156253794fdcbd6885fd797d98ee05009f22"}, ] [package.dependencies] @@ -3149,7 +3161,7 @@ packaging = ">=26" platformdirs = ">=4.9.4" pluggy = ">=1.6" pyproject-api = ">=1.10" -python-discovery = ">=1.2.1" +python-discovery = ">=1.2.2" tomli = {version = ">=2.4", markers = "python_version < \"3.11\""} tomli-w = ">=1.2" typing-extensions = {version = ">=4.15", markers = "python_version < \"3.11\""} @@ -3188,14 +3200,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2025.3" +version = "2026.1" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] files = [ - {file = "tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1"}, - {file = "tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7"}, + {file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, + {file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, ] [[package]] @@ -3218,14 +3230,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" -version = "0.42.0" +version = "0.44.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" groups = ["docs", "server", "test"] files = [ - {file = "uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359"}, - {file = "uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775"}, + {file = "uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89"}, + {file = "uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e"}, ] [package.dependencies] @@ -3310,21 +3322,21 @@ test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", [[package]] name = "virtualenv" -version = "21.2.0" +version = "21.2.4" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, - {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, + {file = "virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac"}, + {file = "virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" -python-discovery = ">=1" +python-discovery = ">=1.2.2" typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} [[package]] diff --git a/pyproject.toml b/pyproject.toml index 8db3469..982fcac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,11 @@ exclude = ''' [tool.pytest.ini_options] 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] profile = "black" diff --git a/src/ria_toolkit_oss/agent.py b/src/ria_toolkit_oss/agent.py index 6e15eb1..776238a 100644 --- a/src/ria_toolkit_oss/agent.py +++ b/src/ria_toolkit_oss/agent.py @@ -93,16 +93,24 @@ class NodeAgent: name: str, sdr_device: str = "unknown", insecure: bool = False, + role: str = "general", + session_code: str | None = None, ) -> None: self.hub_url = hub_url.rstrip("/") self.api_key = api_key self.name = name self.sdr_device = sdr_device self.insecure = insecure + self.role = role + self.session_code = session_code self.node_id: str | None = None self._stop = threading.Event() + # ── TX state ──────────────────────────────────────────────────────── + self._tx_stop = threading.Event() + self._tx_thread: threading.Thread | None = None + # ── Inference state ───────────────────────────────────────────────── # Protected by _inf_lock for cross-thread model swaps. self._inf_lock = threading.Lock() @@ -172,19 +180,27 @@ class NodeAgent: capabilities = ["campaign"] if self._ort_available: capabilities.append("inference") - resp = self._post( - "/composer/nodes/register", - json={ - "name": self.name, - "sdr_device": self.sdr_device, - "ria_toolkit_version": self._ria_version, - "capabilities": capabilities, - }, - timeout=15, - ) + if self.role == "tx": + capabilities.append("transmit") + payload: dict = { + "name": self.name, + "sdr_device": self.sdr_device, + "ria_toolkit_version": self._ria_version, + "capabilities": capabilities, + "role": self.role, + } + if self.session_code: + payload["session_code"] = self.session_code + resp = self._post("/composer/nodes/register", json=payload, timeout=15) resp.raise_for_status() 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: if not self.node_id: @@ -269,6 +285,17 @@ class NodeAgent: self._stop_inference() elif command == "configure_inference": 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: logger.warning("Unknown command %r — ignored", command) @@ -301,6 +328,58 @@ class NodeAgent: logger.error("Campaign %s failed: %s", campaign_id[:8], 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 # ------------------------------------------------------------------ @@ -848,6 +927,25 @@ def main() -> None: choices=["DEBUG", "INFO", "WARNING", "ERROR"], 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() @@ -861,6 +959,8 @@ def main() -> None: device = args.device or cfg.get("device", "unknown") 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") + role = args.role or cfg.get("role", "general") + session_code = args.session_code or cfg.get("session_code") if not hub: parser.error("--hub is required (or set 'hub' in the config file)") @@ -888,6 +988,8 @@ def main() -> None: name=name, sdr_device=device, insecure=insecure, + role=role, + session_code=session_code, ) agent.run() diff --git a/src/ria_toolkit_oss/orchestration/executor.py b/src/ria_toolkit_oss/orchestration/executor.py index 1bdd4d8..3467995 100644 --- a/src/ria_toolkit_oss/orchestration/executor.py +++ b/src/ria_toolkit_oss/orchestration/executor.py @@ -169,6 +169,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: """Executes a :class:`CampaignConfig` end-to-end. @@ -369,6 +384,7 @@ class CampaignExecutor: step=step, capture_timestamp=capture_timestamp, campaign_name=self.config.name, + tx_params=_extract_tx_params(transmitter), ) # QA diff --git a/src/ria_toolkit_oss/orchestration/labeler.py b/src/ria_toolkit_oss/orchestration/labeler.py index efcb97a..709be3c 100644 --- a/src/ria_toolkit_oss/orchestration/labeler.py +++ b/src/ria_toolkit_oss/orchestration/labeler.py @@ -15,6 +15,7 @@ def label_recording( step: CaptureStep, capture_timestamp: float, campaign_name: Optional[str] = None, + tx_params: Optional[dict] = None, ) -> Recording: """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. capture_timestamp: Unix timestamp (float) of when capture started. 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_`` fields so downstream + training pipelines know what was transmitted into the recording. Returns: The same recording with updated metadata. @@ -57,6 +61,11 @@ def label_recording( if step.power_dbm is not None: 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 diff --git a/src/ria_toolkit_oss/orchestration/tx_executor.py b/src/ria_toolkit_oss/orchestration/tx_executor.py new file mode 100644 index 0000000..e666a1a --- /dev/null +++ b/src/ria_toolkit_oss/orchestration/tx_executor.py @@ -0,0 +1,253 @@ +"""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 +import time +from typing import Any + +logger = logging.getLogger(__name__) + +# 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"} + + +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 = float(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)) + + # 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 step in schedule: + if self.stop_event.is_set(): + break + self._execute_step(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 = float(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) + signal = self._synthesise(modulation, sps, num_samples, 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 diff --git a/tests/orchestration/test_executor.py b/tests/orchestration/test_executor.py new file mode 100644 index 0000000..260c883 --- /dev/null +++ b/tests/orchestration/test_executor.py @@ -0,0 +1,295 @@ +"""Tests for orchestration executor — StepResult, CampaignResult, _run_script, _extract_tx_params.""" + +from __future__ import annotations + +import json +import stat +import threading +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, + } diff --git a/tests/orchestration/test_labeler.py b/tests/orchestration/test_labeler.py index 37b370d..670a9dc 100644 --- a/tests/orchestration/test_labeler.py +++ b/tests/orchestration/test_labeler.py @@ -109,6 +109,42 @@ class TestLabelRecording: result = label_recording(rec, "iphone13_001", _wifi_step(), time.time()) 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 diff --git a/tests/orchestration/test_tx_executor.py b/tests/orchestration/test_tx_executor.py new file mode 100644 index 0000000..9a03e6b --- /dev/null +++ b/tests/orchestration/test_tx_executor.py @@ -0,0 +1,155 @@ +"""Tests for TxExecutor — signal synthesis and step execution.""" + +from __future__ import annotations + +import threading +from unittest.mock import MagicMock, 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() + + orig_wait = real_ev.wait + + 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 diff --git a/tests/ria_toolkit_oss_cli/test_generate.py b/tests/ria_toolkit_oss_cli/test_generate.py index 68d252c..8915037 100644 --- a/tests/ria_toolkit_oss_cli/test_generate.py +++ b/tests/ria_toolkit_oss_cli/test_generate.py @@ -189,6 +189,8 @@ class TestNoiseCommand: "10000", "--noise-type", "gaussian", + "--power", + "0.01", "--output", output, "-q", @@ -234,7 +236,7 @@ class TestNoiseCommand: "--num-samples", "10000", "--power", - "0.5", + "0.01", "--output", output, "-q", diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..ce7ac9c --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,249 @@ +"""Tests for NodeAgent — TX role, session code, and TX command dispatch.""" + +from __future__ import annotations + +import threading +import time +from unittest.mock import MagicMock, patch + +import pytest + +from ria_toolkit_oss.agent import NodeAgent + + +def _agent(role="general", session_code=None, **kwargs): + return NodeAgent( + hub_url="http://hub.test", + api_key="test-key", + name="test-node", + sdr_device="mock", + role=role, + session_code=session_code, + **kwargs, + ) + + +def _mock_register(agent, node_id="node_abc123"): + """Patch _post so _register() returns a fake node_id response.""" + resp = MagicMock() + resp.json.return_value = {"node_id": node_id} + resp.raise_for_status.return_value = None + agent._post = MagicMock(return_value=resp) + return agent._post + + +# --------------------------------------------------------------------------- +# Initialisation +# --------------------------------------------------------------------------- + + +class TestNodeAgentInit: + def test_stores_role_general(self): + assert _agent(role="general").role == "general" + + def test_stores_role_tx(self): + assert _agent(role="tx").role == "tx" + + def test_stores_role_rx(self): + assert _agent(role="rx").role == "rx" + + def test_session_code_stored(self): + assert _agent(session_code="amber-peak-transmit").session_code == "amber-peak-transmit" + + def test_session_code_none_by_default(self): + assert _agent().session_code is None + + def test_tx_stop_event_created(self): + a = _agent() + assert isinstance(a._tx_stop, threading.Event) + + def test_tx_thread_none_initially(self): + assert _agent()._tx_thread is None + + def test_hub_url_trailing_slash_stripped(self): + a = NodeAgent(hub_url="http://hub.test/", api_key="k", name="n") + assert a.hub_url == "http://hub.test" + + +# --------------------------------------------------------------------------- +# _register payload +# --------------------------------------------------------------------------- + + +class TestNodeAgentRegisterPayload: + def _payload(self, agent): + post = _mock_register(agent) + agent._register() + _, kwargs = post.call_args + return kwargs["json"] + + def test_general_role_in_payload(self): + payload = self._payload(_agent(role="general")) + assert payload["role"] == "general" + + def test_tx_role_in_payload(self): + payload = self._payload(_agent(role="tx")) + assert payload["role"] == "tx" + + def test_tx_role_adds_transmit_capability(self): + payload = self._payload(_agent(role="tx")) + assert "transmit" in payload["capabilities"] + + def test_general_role_omits_transmit_capability(self): + payload = self._payload(_agent(role="general")) + assert "transmit" not in payload.get("capabilities", []) + + def test_session_code_included_when_set(self): + payload = self._payload(_agent(role="tx", session_code="amber-peak-transmit")) + assert payload["session_code"] == "amber-peak-transmit" + + def test_session_code_omitted_when_none(self): + payload = self._payload(_agent()) + assert "session_code" not in payload + + def test_register_stores_returned_node_id(self): + a = _agent() + _mock_register(a, node_id="node_xyz999") + a._register() + assert a.node_id == "node_xyz999" + + def test_name_in_payload(self): + a = NodeAgent(hub_url="http://h", api_key="k", name="my-bench") + _mock_register(a) + a._register() + _, kwargs = a._post.call_args + assert kwargs["json"]["name"] == "my-bench" + + def test_sdr_device_in_payload(self): + a = _agent() + post = _mock_register(a) + a._register() + _, kwargs = post.call_args + assert kwargs["json"]["sdr_device"] == "mock" + + def test_campaign_capability_always_present(self): + for role in ("general", "rx", "tx"): + a = _agent(role=role) + payload = self._payload(a) + assert "campaign" in payload["capabilities"] + + +# --------------------------------------------------------------------------- +# _dispatch — TX commands +# --------------------------------------------------------------------------- + + +class TestNodeAgentDispatch: + def _make_agent(self): + a = _agent(role="tx") + a.node_id = "node_abc" + a._report_campaign_status = MagicMock() + return a + + def test_start_transmit_spawns_thread(self): + a = self._make_agent() + done = threading.Event() + + class _FakeExecutor: + def run(self_): + done.wait(timeout=2) + + with patch("ria_toolkit_oss.orchestration.tx_executor.TxExecutor", return_value=_FakeExecutor()): + a._dispatch({"command": "start_transmit", "sdr_agent": {}, "schedule": []}) + time.sleep(0.05) + assert a._tx_thread is not None + done.set() + + def test_start_transmit_clears_stop_event(self): + a = self._make_agent() + a._tx_stop.set() # pre-set + + done = threading.Event() + + class _FakeExecutor: + def run(self_): + done.wait(timeout=2) + + with patch("ria_toolkit_oss.orchestration.tx_executor.TxExecutor", return_value=_FakeExecutor()): + a._dispatch({"command": "start_transmit", "sdr_agent": {}, "schedule": []}) + time.sleep(0.05) + assert not a._tx_stop.is_set() + done.set() + + def test_stop_transmit_sets_stop_event(self): + a = self._make_agent() + a._dispatch({"command": "stop_transmit"}) + assert a._tx_stop.is_set() + + def test_configure_transmit_does_not_raise(self): + a = self._make_agent() + a._dispatch({"command": "configure_transmit", "modulation": "BPSK"}) + + def test_unknown_command_is_ignored(self): + a = self._make_agent() + a._dispatch({"command": "frobnicate_xyz"}) + + def test_duplicate_start_transmit_ignored_while_running(self): + a = self._make_agent() + done = threading.Event() + run_calls = [] + + class _FakeExecutor: + def run(self_): + run_calls.append(1) + done.wait(timeout=2) + + with patch("ria_toolkit_oss.orchestration.tx_executor.TxExecutor", return_value=_FakeExecutor()): + a._dispatch({"command": "start_transmit"}) + time.sleep(0.05) + a._dispatch({"command": "start_transmit"}) # second while first alive + done.set() + time.sleep(0.05) + + assert len(run_calls) == 1 + + def test_run_campaign_dispatched_in_thread(self): + a = self._make_agent() + done = threading.Event() + + with patch("ria_toolkit_oss.agent.NodeAgent._run_campaign") as mock_run: + mock_run.side_effect = lambda *_: done.set() + a._dispatch({"command": "run_campaign", "campaign_id": "c1", "payload": {}}) + done.wait(timeout=2) + assert mock_run.called + + +# --------------------------------------------------------------------------- +# _stop_transmit +# --------------------------------------------------------------------------- + + +class TestStopTransmit: + def test_no_thread_noop(self): + a = _agent() + a._stop_transmit() # must not raise + + def test_sets_stop_event(self): + a = _agent() + a._stop_transmit() + assert a._tx_stop.is_set() + + def test_joins_live_thread(self): + a = _agent() + finished = threading.Event() + unblock = threading.Event() + + def _task(): + unblock.wait(timeout=2) + finished.set() + + t = threading.Thread(target=_task, daemon=True) + t.start() + a._tx_thread = t + + # Signal stop and trigger thread exit + a._tx_stop.set() + unblock.set() + a._stop_transmit() + + assert not t.is_alive() From c27a5944c7ee852794bbe6823610f356e6ca4535 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 20 Apr 2026 16:49:52 -0400 Subject: [PATCH 02/28] formats --- src/ria_toolkit_oss/agent/legacy_executor.py | 6 +- .../orchestration/tx_executor.py | 28 +++-- tests/orchestration/test_executor.py | 115 ++++++++++-------- tests/orchestration/test_labeler.py | 16 +-- tests/orchestration/test_tx_executor.py | 4 +- tests/test_agent.py | 2 - 6 files changed, 92 insertions(+), 79 deletions(-) diff --git a/src/ria_toolkit_oss/agent/legacy_executor.py b/src/ria_toolkit_oss/agent/legacy_executor.py index 776238a..d8a56d6 100644 --- a/src/ria_toolkit_oss/agent/legacy_executor.py +++ b/src/ria_toolkit_oss/agent/legacy_executor.py @@ -931,11 +931,7 @@ def main() -> None: "--role", default=None, choices=["general", "rx", "tx"], - help=( - "Node role reported to the hub. " - "'tx' enables synthetic transmission commands. " - "Default: general" - ), + help=("Node role reported to the hub. " "'tx' enables synthetic transmission commands. " "Default: general"), ) parser.add_argument( "--session-code", diff --git a/src/ria_toolkit_oss/orchestration/tx_executor.py b/src/ria_toolkit_oss/orchestration/tx_executor.py index e666a1a..6ae32b1 100644 --- a/src/ria_toolkit_oss/orchestration/tx_executor.py +++ b/src/ria_toolkit_oss/orchestration/tx_executor.py @@ -33,7 +33,6 @@ from __future__ import annotations import logging import threading -import time from typing import Any logger = logging.getLogger(__name__) @@ -41,11 +40,11 @@ logger = logging.getLogger(__name__) # 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"), + "BPSK": (1, "psk"), + "QPSK": (2, "psk"), + "8PSK": (3, "psk"), + "16QAM": (4, "qam"), + "64QAM": (6, "qam"), "256QAM": (8, "qam"), } @@ -117,7 +116,12 @@ class TxExecutor: logger.info( "TX step '%s': %.0f s, %s @ %.3f MHz (sps=%d, filter=%s)", - label, duration, modulation, symbol_rate / 1e6, sps, filter_type, + label, + duration, + modulation, + symbol_rate / 1e6, + sps, + filter_type, ) num_samples = int(duration * sample_rate) @@ -133,9 +137,7 @@ class TxExecutor: 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 - ) + logger.warning("TX step '%s': no SDR — simulating %.0f s delay", label, duration) self.stop_event.wait(timeout=duration) def _synthesise( @@ -149,6 +151,7 @@ class TxExecutor: """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, @@ -231,6 +234,7 @@ class TxExecutor: 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, @@ -239,7 +243,9 @@ class TxExecutor: channel=0, gain_mode="manual", ) - logger.info("TX SDR initialised: %s @ %.3f MHz, %.1f Msps", self.sdr_device, center_freq / 1e6, sample_rate / 1e6) + 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 diff --git a/tests/orchestration/test_executor.py b/tests/orchestration/test_executor.py index 260c883..7aba499 100644 --- a/tests/orchestration/test_executor.py +++ b/tests/orchestration/test_executor.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import stat -import threading from types import SimpleNamespace import pytest @@ -108,37 +107,47 @@ class TestCampaignResult: 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), - ]) + 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), - ]) + 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), - ]) + 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), - ]) + 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"), - ]) + r = self._make( + [ + StepResult("tx1", "s1", None, _ok_qa(), 0.0, error="disk full"), + ] + ) assert r.failed == 1 assert r.passed == 0 @@ -232,37 +241,45 @@ class TestExtractTxParams: 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, - }) + 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", - }) + 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", - }) + 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, - }) + 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} @@ -274,16 +291,18 @@ class TestExtractTxParams: 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", - }) + 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", diff --git a/tests/orchestration/test_labeler.py b/tests/orchestration/test_labeler.py index 670a9dc..2e47739 100644 --- a/tests/orchestration/test_labeler.py +++ b/tests/orchestration/test_labeler.py @@ -116,9 +116,7 @@ class TestLabelRecording: 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 - ) + 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) @@ -131,17 +129,15 @@ class TestLabelRecording: "filter": "rrc", "rolloff": 0.35, } - rec = label_recording( - _simple_recording(), "dev", _wifi_step(), time.time(), tx_params=params - ) + 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 + 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={} - ) + 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 == [] diff --git a/tests/orchestration/test_tx_executor.py b/tests/orchestration/test_tx_executor.py index 9a03e6b..9d66850 100644 --- a/tests/orchestration/test_tx_executor.py +++ b/tests/orchestration/test_tx_executor.py @@ -3,7 +3,7 @@ from __future__ import annotations import threading -from unittest.mock import MagicMock, patch +from unittest.mock import patch import numpy as np import pytest @@ -73,8 +73,6 @@ class TestTxExecutorRun: waited = [] real_ev = threading.Event() - orig_wait = real_ev.wait - def _fake_wait(timeout=None): waited.append(timeout) return False diff --git a/tests/test_agent.py b/tests/test_agent.py index ce7ac9c..67991f9 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -6,8 +6,6 @@ import threading import time from unittest.mock import MagicMock, patch -import pytest - from ria_toolkit_oss.agent import NodeAgent From a68a325cb4867dad147c9b5113cd3eca6f7f1d94 Mon Sep 17 00:00:00 2001 From: muq Date: Tue, 21 Apr 2026 12:29:18 -0400 Subject: [PATCH 03/28] Update SDR guides and fix Sphinx warnings for release 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 --- docs/source/conf.py | 2 +- docs/source/examples/sdr/index.rst | 2 +- docs/source/sdr_guides/blade.rst | 164 ++++++++++--------- docs/source/sdr_guides/hackrf.rst | 171 ++++++++++---------- docs/source/sdr_guides/pluto.rst | 239 ++++++++++++++-------------- docs/source/sdr_guides/rtlsdr.rst | 110 +++++++++---- docs/source/sdr_guides/thinkrf.rst | 40 ++++- docs/source/sdr_guides/usrp.rst | 247 ++++++++++++++++++----------- src/ria_toolkit_oss/sdr/hackrf.py | 2 +- src/ria_toolkit_oss/sdr/usrp.py | 4 +- 10 files changed, 568 insertions(+), 413 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 1a3b6d0..f1f67e3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,7 +12,7 @@ sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'ria-toolkit-oss' -copyright = '2025, Qoherent Inc' +copyright = '2026, Qoherent Inc' author = 'Qoherent Inc.' release = '0.1.5' diff --git a/docs/source/examples/sdr/index.rst b/docs/source/examples/sdr/index.rst index c563bee..c8cfda4 100644 --- a/docs/source/examples/sdr/index.rst +++ b/docs/source/examples/sdr/index.rst @@ -1,4 +1,4 @@ -.. _examples: +.. _sdr_examples: ############ SDR Examples diff --git a/docs/source/sdr_guides/blade.rst b/docs/source/sdr_guides/blade.rst index 41641b6..5a31456 100644 --- a/docs/source/sdr_guides/blade.rst +++ b/docs/source/sdr_guides/blade.rst @@ -1,77 +1,87 @@ -.. _blade: - -BladeRF -======= - -The BladeRF is a versatile software-defined radio (SDR) platform developed by Nuand. It is designed for a wide -range of applications, from wireless communication research to field deployments. BladeRF devices are known -for their high performance, flexibility, and extensive open-source support, making them suitable for both -hobbyists and professionals. The BladeRF is based on the Analog Devices AD9361 RF transceiver, which provides -wide frequency coverage and high bandwidth. - -Supported Models ----------------- - -- **BladeRF 2.0 Micro xA4:** A compact model with a 49 kLE FPGA, ideal for portable applications. -- **BladeRF 2.0 Micro xA9:** A higher-end version of the Micro with a 115 kLE FPGA, offering more processing power in a small form factor. - -Key Features ------------- - -- **Frequency Range:** Typically from 47 MHz to 6 GHz, covering a wide range of wireless communication bands. -- **Bandwidth:** Up to 56 MHz, allowing for wideband signal processing. -- **FPGA:** Integrated FPGA (varies by model) for real-time processing and custom logic development. -- **Connectivity:** USB 3.0 interface for high-speed data transfer, with options for GPIO, SPI, and other I/O. - -Hackability ------------ - -- **Expansion:** The BladeRF features GPIO, expansion headers, and add-on boards, allowing users to extend the - functionality of the device for specific applications, such as additional RF front ends. -- **Frequency and Bandwidth Modification:** Advanced users can modify the BladeRF's settings and firmware to - explore different frequency bands and optimize the bandwidth for their specific use cases. - -Limitations ------------ - -- The complexity of FPGA development may present a steep learning curve for users unfamiliar with hardware - description languages (HDL). -- Bandwidth is capped at 56 MHz, which might not be sufficient for ultra-wideband applications. -- USB 3.0 connectivity is required for optimal performance; using USB 2.0 will significantly limit data - transfer rates. - -Set up instructions (Linux, Radioconda) ---------------------------------------- - -1. Activate your Radioconda environment. - - .. code-block:: bash - - conda activate - -2. Install the base dependencies and drivers (*Easy method*): - - .. code-block:: bash - - sudo add-apt-repository ppa:nuandllc/bladerf - sudo apt-get update - sudo apt-get install bladerf - sudo apt-get install libbladerf-dev - 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: - - .. 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 -------------------- - -- `Official BladeRF Website `_ -- `BladeRF GitHub Repository `_ -- `BladeRF Setup with Radioconda `_ +.. _blade: + +BladeRF +======= + +The BladeRF is a versatile software-defined radio (SDR) platform developed by Nuand. It is designed for a wide +range of applications, from wireless communication research to field deployments. BladeRF devices are known +for their high performance, flexibility, and extensive open-source support, making them suitable for both +hobbyists and professionals. The BladeRF is based on the Analog Devices AD9361 RF transceiver, which provides +wide frequency coverage and high bandwidth. + +Supported Models +---------------- + +- **BladeRF 2.0 Micro xA4:** A compact model with a 49 kLE FPGA, ideal for portable applications. +- **BladeRF 2.0 Micro xA9:** A higher-end version of the Micro with a 115 kLE FPGA, offering more processing power in a small form factor. + +Key Features +------------ + +- **Frequency Range:** Typically from 47 MHz to 6 GHz, covering a wide range of wireless communication bands. +- **Bandwidth:** Up to 56 MHz, allowing for wideband signal processing. +- **FPGA:** Integrated FPGA (varies by model) for real-time processing and custom logic development. +- **Connectivity:** USB 3.0 interface for high-speed data transfer, with options for GPIO, SPI, and other I/O. + +Hackability +----------- + +- **Expansion:** The BladeRF features GPIO, expansion headers, and add-on boards, allowing users to extend the + functionality of the device for specific applications, such as additional RF front ends. +- **Frequency and Bandwidth Modification:** Advanced users can modify the BladeRF's settings and firmware to + explore different frequency bands and optimize the bandwidth for their specific use cases. + +Limitations +----------- + +- The complexity of FPGA development may present a steep learning curve for users unfamiliar with hardware + description languages (HDL). +- Bandwidth is capped at 56 MHz, which might not be sufficient for ultra-wideband applications. +- USB 3.0 connectivity is required for optimal performance; using USB 2.0 will significantly limit data + transfer rates. + +Set up instructions (Linux) +--------------------------- + +No additional Python packages are required for BladeRF beyond the base RIA Toolkit OSS installation. + +1. Install the system library: + + .. code-block:: bash + + sudo apt install libbladerf-dev + + For a more complete installation including CLI tools and FPGA images, use the Nuand PPA: + + .. code-block:: bash + + sudo add-apt-repository ppa:nuandllc/bladerf + sudo apt-get update + sudo apt-get install bladerf libbladerf-dev + sudo apt-get install bladerf-fpga-hostedxa4 # Necessary for BladeRF 2.0 Micro xA4 + +2. Install udev rules: + + For most users: + + .. code-block:: bash + + sudo udevadm control --reload + sudo udevadm trigger + + For **Radioconda** users, create symlinks from your conda environment instead: + + .. 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 +------------------- + +- `Official BladeRF Website `_ +- `BladeRF GitHub Repository `_ +- `BladeRF Setup with Radioconda `_ diff --git a/docs/source/sdr_guides/hackrf.rst b/docs/source/sdr_guides/hackrf.rst index 6f65a6d..934d6bf 100644 --- a/docs/source/sdr_guides/hackrf.rst +++ b/docs/source/sdr_guides/hackrf.rst @@ -1,83 +1,88 @@ -.. _hackrf: - -HackRF -====== - -The HackRF One is a portable and affordable software-defined radio developed by Great Scott Gadgets. It is an -open source hardware platform that is designed to enable test and development of modern and next generation -radio technologies. - -The HackRF is based on the Analog Devices MAX2839 transceiver chip, which supports both transmission and -reception of signals across a wide frequency range, combined with a MAX5864 RF front-end chip and a -RFFC5072 wideband synthesizer/VCO. - -Supported models ----------------- - -- **HackRF One:** The standard model with a frequency range of 1 MHz to 6 GHz and a bandwidth of up to 20 MHz. -- **Opera Cake for HackRF:** An antenna switching add-on board for HackRF One that is configured with command-line software. - -Key features ------------- - -- **Frequency Range:** 1 MHz to 6 GHz. -- **Bandwidth:** 2 MHz to 20 MHz. -- **Connectivity:** USB 2.0 interface with support for power, data, and firmware updates. -- **Software Support:** Compatible with GNU Radio, SDR#, and other SDR frameworks. -- **Onboard Processing:** ARM-based LPC4320 processor for digital signal processing and interfacing over USB. - -Hackability ------------ - -.. todo:: - - Add information regarding HackRF hackability - -Limitations ------------ - -- Bandwidth is limited to 20 MHz. -- USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs. - -Set up instructions (Linux, Radioconda) ---------------------------------------- - -1. Activate your Radioconda environment: - - .. code-block:: bash - - conda activate - -2. Install the System Package (Ubuntu / Debian): - - .. code-block:: bash - - sudo apt-get update - sudo apt-get install hackrf - -3. Install a ``udev`` rule by creating a link into your Radioconda installation: - - .. code-block:: bash - - 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 trigger - - Make sure your user account belongs to the plugdev group in order to access your device: - - .. code-block:: bash - - sudo usermod -a -G plugdev - -.. note:: - - You may have to restart your system for changes to take effect. - -Further information -------------------- - -- `Official HackRF Website `_ -- `HackRF Project Documentation `_ -- `HackRF Software Installation Guide `_ -- `HackRF GitHub Repository `_ -- `HackRF Setup with Radioconda `_ +.. _hackrf: + +HackRF +====== + +The HackRF One is a portable and affordable software-defined radio developed by Great Scott Gadgets. It is an +open source hardware platform that is designed to enable test and development of modern and next generation +radio technologies. + +The HackRF is based on the Analog Devices MAX2839 transceiver chip, which supports both transmission and +reception of signals across a wide frequency range, combined with a MAX5864 RF front-end chip and a +RFFC5072 wideband synthesizer/VCO. + +Supported models +---------------- + +- **HackRF One:** The standard model with a frequency range of 1 MHz to 6 GHz and a bandwidth of up to 20 MHz. +- **Opera Cake for HackRF:** An antenna switching add-on board for HackRF One that is configured with command-line software. + +Key features +------------ + +- **Frequency Range:** 1 MHz to 6 GHz. +- **Bandwidth:** 2 MHz to 20 MHz. +- **Connectivity:** USB 2.0 interface with support for power, data, and firmware updates. +- **Software Support:** Compatible with GNU Radio, SDR#, and other SDR frameworks. +- **Onboard Processing:** ARM-based LPC4320 processor for digital signal processing and interfacing over USB. + +Hackability +----------- + +.. todo:: + + Add information regarding HackRF hackability + +Limitations +----------- + +- Bandwidth is limited to 20 MHz. +- USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs. + +Set up instructions (Linux) +--------------------------- + +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 + + sudo apt install libhackrf-dev + +2. Install udev rules to allow non-root device access: + + For most users: + + .. 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/53-hackrf.rules /etc/udev/rules.d/53-radioconda-hackrf.rules + sudo udevadm control --reload + sudo udevadm trigger + + Make sure your user account belongs to the ``plugdev`` group in order to access your device: + + .. code-block:: bash + + sudo usermod -a -G plugdev + +.. note:: + + You may have to restart your system for group membership changes to take effect. + +Further information +------------------- + +- `Official HackRF Website `_ +- `HackRF Project Documentation `_ +- `HackRF Software Installation Guide `_ +- `HackRF GitHub Repository `_ +- `HackRF Setup with Radioconda `_ diff --git a/docs/source/sdr_guides/pluto.rst b/docs/source/sdr_guides/pluto.rst index 2eaa475..30aa348 100644 --- a/docs/source/sdr_guides/pluto.rst +++ b/docs/source/sdr_guides/pluto.rst @@ -1,116 +1,123 @@ -.. _pluto: - -PlutoSDR -======== - -The ADALM-PLUTO (PlutoSDR) is a portable and affordable software-defined radio developed by Analog Devices. -It is designed for learning, experimenting, and prototyping in the field of wireless communication. The PlutoSDR -is popular among students, educators, and hobbyists due to its versatility and ease of use. - -The PlutoSDR is based on the AD9363 transceiver chip, which supports both transmission and reception of signals -across a wide frequency range. The device is supported by a robust open-source ecosystem, making it ideal for -hands-on learning and rapid prototyping. - -Supported models ----------------- - -- **ADALM-PLUTO:** The standard model with a frequency range of 325 MHz to 3.8 GHz and a bandwidth of up to 20 MHz. -- **Modified ADALM-PLUTO:** Some users modify their PlutoSDR to extend the frequency range to approximately 70 MHz - to 6 GHz by applying firmware patches with unqualified RF performance. - -Key features ------------- - -- **Frequency Range:** 325 MHz to 3.8 GHz (standard), expandable with modifications. -- **Bandwidth:** Up to 20 MHz, can be increased to 56 MHz with firmware modifications. -- **Connectivity:** USB 2.0 interface with support for power, data, and firmware updates. -- **Software Support:** Compatible with GNU Radio, MATLAB, Simulink, and other SDR frameworks. -- **Onboard Processing:** Integrated ARM Cortex-A9 processor for custom applications and signal processing. - -Hackability ------------- - -- **Frequency Range and Bandwidth:** The default frequency range of 325 MHz to 3.8 GHz can be expanded to - approximately 70 MHz to 6 GHz, and the bandwidth can be increased from 20 MHz to 56 MHz by modifying - the device's firmware. -- **2x2 MIMO:** On Rev C models, users can unlock 2x2 MIMO (Multiple Input Multiple Output) functionality by - wiring UFL to SMA connectors to the device's PCB, effectively turning the device into a dual-channel SDR. - -Limitations ------------ - -- Bandwidth is limited to 20 MHz by default, but can be increased to 56 MHz with modifications, which may - affect stability. -- USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs. - -Set up instructions (Linux, Radioconda) ---------------------------------------- - -1. Activate your Radioconda environment: - - .. code-block:: bash - - conda activate - -2. Install system dependencies: - - .. code-block:: bash - - sudo apt-get update - 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 - -3. Install a ``udev`` rule by creating a link into your Radioconda installation: - - .. code-block:: bash - - sudo ln -s $CONDA_PREFIX/lib/udev/rules.d/90-libiio.rules /etc/udev/rules.d/90-radioconda-libiio.rules - sudo udevadm control --reload - sudo udevadm trigger - - Once you can talk to the hardware, you may want to perform the post-install steps detailed on the `PlutoSDR Documentation `_. - -4. (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. - - .. code-block:: bash - - # Build libiio from source - cd ~ - git clone --branch v0.23 https://github.com/analogdevicesinc/libiio.git - cd libiio - mkdir -p build - cd build - cmake -DPYTHON_BINDINGS=ON .. - make -j"$(nproc)" - sudo make install - sudo ldconfig - - .. code-block:: bash - - # Build libad9361-iio from source - cd ~ - git clone https://github.com/analogdevicesinc/libad9361-iio.git - cd libad9361-iio - mkdir -p build - cd build - cmake .. - make -j"$(nproc)" - sudo make install - -Further information -------------------- - -- `PlutoSDR Documentation `_ -- `PlutoSDR Setup with Radioconda `_ \ No newline at end of file +.. _pluto: + +PlutoSDR +======== + +The ADALM-PLUTO (PlutoSDR) is a portable and affordable software-defined radio developed by Analog Devices. +It is designed for learning, experimenting, and prototyping in the field of wireless communication. The PlutoSDR +is popular among students, educators, and hobbyists due to its versatility and ease of use. + +The PlutoSDR is based on the AD9363 transceiver chip, which supports both transmission and reception of signals +across a wide frequency range. The device is supported by a robust open-source ecosystem, making it ideal for +hands-on learning and rapid prototyping. + +Supported models +---------------- + +- **ADALM-PLUTO:** The standard model with a frequency range of 325 MHz to 3.8 GHz and a bandwidth of up to 20 MHz. +- **Modified ADALM-PLUTO:** Some users modify their PlutoSDR to extend the frequency range to approximately 70 MHz + to 6 GHz by applying firmware patches with unqualified RF performance. + +Key features +------------ + +- **Frequency Range:** 325 MHz to 3.8 GHz (standard), expandable with modifications. +- **Bandwidth:** Up to 20 MHz, can be increased to 56 MHz with firmware modifications. +- **Connectivity:** USB 2.0 interface with support for power, data, and firmware updates. +- **Software Support:** Compatible with GNU Radio, MATLAB, Simulink, and other SDR frameworks. +- **Onboard Processing:** Integrated ARM Cortex-A9 processor for custom applications and signal processing. + +Hackability +------------ + +- **Frequency Range and Bandwidth:** The default frequency range of 325 MHz to 3.8 GHz can be expanded to + approximately 70 MHz to 6 GHz, and the bandwidth can be increased from 20 MHz to 56 MHz by modifying + the device's firmware. +- **2x2 MIMO:** On Rev C models, users can unlock 2x2 MIMO (Multiple Input Multiple Output) functionality by + wiring UFL to SMA connectors to the device's PCB, effectively turning the device into a dual-channel SDR. + +Limitations +----------- + +- Bandwidth is limited to 20 MHz by default, but can be increased to 56 MHz with modifications, which may + affect stability. +- USB 2.0 connectivity might limit data transfer rates compared to USB 3.0 or Ethernet-based SDRs. + +Set up instructions (Linux) +--------------------------- + +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 + + sudo apt install libiio-dev libiio-utils libiio0 + +.. 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 + + 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/90-libiio.rules /etc/udev/rules.d/90-radioconda-libiio.rules + sudo udevadm control --reload + sudo udevadm trigger + + Once you can communicate with the hardware, you may want to perform the post-install steps detailed on + the `PlutoSDR Documentation `_. + +3. (Optional) Building ``libiio`` or ``libad9361-iio`` from source: + + 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 + + # Build libiio from source + cd ~ + git clone --branch v0.23 https://github.com/analogdevicesinc/libiio.git + cd libiio + mkdir -p build + cd build + cmake -DPYTHON_BINDINGS=ON .. + make -j"$(nproc)" + sudo make install + sudo ldconfig + + .. code-block:: bash + + # Build libad9361-iio from source + cd ~ + git clone https://github.com/analogdevicesinc/libad9361-iio.git + cd libad9361-iio + mkdir -p build + cd build + cmake .. + make -j"$(nproc)" + sudo make install + +Further information +------------------- + +- `PlutoSDR Documentation `_ +- `PlutoSDR Setup with Radioconda `_ diff --git a/docs/source/sdr_guides/rtlsdr.rst b/docs/source/sdr_guides/rtlsdr.rst index d93212e..23d69eb 100644 --- a/docs/source/sdr_guides/rtlsdr.rst +++ b/docs/source/sdr_guides/rtlsdr.rst @@ -30,71 +30,111 @@ Limitations - Sensitivity and performance can vary depending on the specific model and components. - Requires external software for signal processing and analysis. -Set up instructions (Linux, Radioconda) ---------------------------------------- +Set up instructions (Linux) +--------------------------- -1. Activate your Radioconda environment: - - .. code-block:: bash - - conda activate - -2. Purge drivers: - -If you already have other drivers installed, purge them from your system. +1. If you previously had RTL-SDR drivers installed, purge them first: .. code-block:: bash sudo apt purge ^librtlsdr - sudo rm -rvf /usr/lib/librtlsdr* - sudo rm -rvf /usr/include/rtl-sdr* - sudo rm -rvf /usr/local/lib/librtlsdr* - sudo rm -rvf /usr/local/include/rtl-sdr* - sudo rm -rvf /usr/local/include/rtl_* + sudo rm -rvf /usr/lib/librtlsdr* + sudo rm -rvf /usr/include/rtl-sdr* + sudo rm -rvf /usr/local/lib/librtlsdr* + sudo rm -rvf /usr/local/include/rtl-sdr* + sudo rm -rvf /usr/local/include/rtl_* sudo rm -rvf /usr/local/bin/rtl_* -3. Install RTL-SDR Blog drivers: +2. Install build dependencies: .. code-block:: bash - sudo apt-get install libusb-1.0-0-dev git cmake pkg-config build-essential - git clone https://github.com/osmocom/rtl-sdr - cd rtl-sdr - mkdir build - cd build - cmake ../ -DINSTALL_UDEV_RULES=ON + sudo apt install libusb-1.0-0-dev git cmake pkg-config build-essential + +3. Build ``librtlsdr`` from source: + + The standard ``librtlsdr`` package available via ``apt`` is missing symbols required by the Python + 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 sudo make install sudo cp ../rtl-sdr.rules /etc/udev/rules.d/ 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 + + 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 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 - manually blacklisting the following DVB-T modules to prevent them from - claiming the device: + .. note:: - - ``dvb_usb_rtl28xxu`` - - ``rtl2832`` - - ``rtl2830`` + Some systems also require blacklisting additional DVB-T modules. Add these entries to your + blacklist configuration if needed: - Add these entries to ``rtlsdr.conf`` (or create the file at - ``/etc/modprobe.d/rtlsdr.conf``) if they are not already present. + - ``rtl2832`` + - ``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 + + 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 udevadm control --reload 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 ------------------- - `RTL-SDR Official Website `_ - - `RTL-SDR Documentation `_ \ No newline at end of file + - `RTL-SDR Documentation `_ diff --git a/docs/source/sdr_guides/thinkrf.rst b/docs/source/sdr_guides/thinkrf.rst index 83e5779..962793c 100644 --- a/docs/source/sdr_guides/thinkrf.rst +++ b/docs/source/sdr_guides/thinkrf.rst @@ -39,18 +39,48 @@ Limitations 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 - pip install 'pyrf>=2.8.0' + sudo apt install python3-lib2to3 -Convert PyRF scripts to Python 3 +2. Install ``pyrf``: .. code-block:: bash - cd ../scripts - ./convert_pyrf_to_python3.sh + pip install pyrf + +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 ------------------- diff --git a/docs/source/sdr_guides/usrp.rst b/docs/source/sdr_guides/usrp.rst index e4aa614..aad4fd7 100644 --- a/docs/source/sdr_guides/usrp.rst +++ b/docs/source/sdr_guides/usrp.rst @@ -1,92 +1,155 @@ -.. _usrp: - -USRP -==== - -The USRP (Universal Software Radio Peripheral) product line is a series of software-defined radios (SDRs) -developed by Ettus Research. These devices are widely used in academia, industry, and research for various -wireless communication applications, ranging from simple experimentation to complex signal processing tasks. - -USRP devices offer a flexible platform that can be used with various software frameworks, including GNU Radio -and the USRP Hardware Driver (UHD). The product line includes both entry-level models for hobbyists and -advanced models for professional and research use. - -Supported models ----------------- - -- **USRP B200/B210:** Compact, single-board, full-duplex, with a wide frequency range. -- **USRP N200/N210:** High-performance models with increased bandwidth and connectivity options. -- **USRP X300/X310:** High-end models featuring large bandwidth, multiple MIMO channels, and support for GPSDO. -- **USRP E310/E320:** Embedded devices with onboard processing capabilities. -- **USRP B200mini:** Ultra-compact model for portable and embedded applications. - -Key features ------------- - -- **Frequency Range:** Typically covers from DC to 6 GHz, depending on the model and daughter boards used. -- **Bandwidth:** Varies by model, up to 160 MHz in some high-end versions. -- **Connectivity:** Includes USB 3.0, Ethernet, and PCIe interfaces depending on the model. -- **Software Support:** Compatible with UHD, GNU Radio, and other SDR frameworks. - -Hackability ------------ - -- The UHD library is fully open source and can be modified to meet user untention. -- Certain USRP models have "RFNoC" which streamlines the inclusion of custom FPGA processing in a USRP. - -Limitations ------------ - -- Some models may have limited bandwidth or processing capabilities. -- 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. - -Set up instructions (Linux, Radioconda) ---------------------------------------- - -1. Activate your Radioconda environment: - - .. code-block:: bash - - conda activate - -2. Install UHD and Python bindings: - - .. code-block:: bash - - conda install conda-forge::uhd - -3. Download UHD images: - - .. code-block:: bash - - uhd_images_downloader - -4. Verify access to your device: - - .. code-block:: bash - - uhd_find_devices - - For USB devices only (e.g. B series), install a ``udev`` rule by creating a link into your Radioconda installation. - - .. code-block:: bash - - 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 - -5. (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 -------------------- - -- `Official USRP Website `_ -- `USRP Documentation `_ -- `USRP Setup with Radioconda `_ +.. _usrp: + +USRP +==== + +The USRP (Universal Software Radio Peripheral) product line is a series of software-defined radios (SDRs) +developed by Ettus Research. These devices are widely used in academia, industry, and research for various +wireless communication applications, ranging from simple experimentation to complex signal processing tasks. + +USRP devices offer a flexible platform that can be used with various software frameworks, including GNU Radio +and the USRP Hardware Driver (UHD). The product line includes both entry-level models for hobbyists and +advanced models for professional and research use. + +Supported models +---------------- + +- **USRP B200/B210:** Compact, single-board, full-duplex, with a wide frequency range. +- **USRP N200/N210:** High-performance models with increased bandwidth and connectivity options. +- **USRP X300/X310:** High-end models featuring large bandwidth, multiple MIMO channels, and support for GPSDO. +- **USRP E310/E320:** Embedded devices with onboard processing capabilities. +- **USRP B200mini:** Ultra-compact model for portable and embedded applications. + +Key features +------------ + +- **Frequency Range:** Typically covers from DC to 6 GHz, depending on the model and daughter boards used. +- **Bandwidth:** Varies by model, up to 160 MHz in some high-end versions. +- **Connectivity:** Includes USB 3.0, Ethernet, and PCIe interfaces depending on the model. +- **Software Support:** Compatible with UHD, GNU Radio, and other SDR frameworks. + +Hackability +----------- + +- The UHD library is fully open source and can be modified to meet user untention. +- Certain USRP models have "RFNoC" which streamlines the inclusion of custom FPGA processing in a USRP. + +Limitations +----------- + +- Some models may have limited bandwidth or processing capabilities. +- 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. + +Set up instructions (Linux) +--------------------------- + +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 + + conda install conda-forge::uhd + +**Option B: Build from source (required for pip/venv environments)** + + The Python bindings must target the same Python version used in your virtual environment. + + 1. Install build dependencies: + + .. 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 10–30 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 + + uhd_images_downloader + +2. Verify device access: + + .. code-block:: bash + + uhd_find_devices + + For USB devices (e.g. B-series), install a ``udev`` rule. + + For most users: + + .. 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/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 +------------------- + +- `Official USRP Website `_ +- `USRP Documentation `_ +- `USRP Setup with Radioconda `_ diff --git a/src/ria_toolkit_oss/sdr/hackrf.py b/src/ria_toolkit_oss/sdr/hackrf.py index 79e00a7..d602322 100644 --- a/src/ria_toolkit_oss/sdr/hackrf.py +++ b/src/ria_toolkit_oss/sdr/hackrf.py @@ -58,7 +58,7 @@ class HackRF(SDR): :param channel: The channel the HackRF is set to. (Not actually used) :type channel: int :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 """ print("Initializing RX") diff --git a/src/ria_toolkit_oss/sdr/usrp.py b/src/ria_toolkit_oss/sdr/usrp.py index 70bbc46..e6c4a9f 100644 --- a/src/ria_toolkit_oss/sdr/usrp.py +++ b/src/ria_toolkit_oss/sdr/usrp.py @@ -54,7 +54,7 @@ class USRP(SDR): :param channel: The channel the USRP is set to. :type channel: int :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 :param rx_buffer_size: Internal buffer size for receiving samples. Defaults to 960000. :type rx_buffer_size: int @@ -285,7 +285,7 @@ class USRP(SDR): :param channel: The channel the USRP is set to. :type channel: int :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 """ From 4c2c9c028886712deaa0d37545634402ef8cdf58 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 21 Apr 2026 13:23:49 -0400 Subject: [PATCH 04/28] rx and tx test --- poetry.lock | 2 +- pyproject.toml | 2 +- src/ria_toolkit_oss/orchestration/campaign.py | 4 ++ src/ria_toolkit_oss/orchestration/executor.py | 38 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index f235cb2..9d1e5fe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3749,4 +3749,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "ffde300b2fc93161d2279a6e2b899bc988d3b5eb3833135821830affc9a5fb62" +content-hash = "66c9adf647316db90f963da05e8a83574378bfa4db2c69ce751446b5ee7c408c" diff --git a/pyproject.toml b/pyproject.toml index 00784cc..48a9e1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml (>=6.0.3,<7.0.0)", "click (>=8.1.0,<9.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 diff --git a/src/ria_toolkit_oss/orchestration/campaign.py b/src/ria_toolkit_oss/orchestration/campaign.py index 027c33f..5fe1128 100644 --- a/src/ria_toolkit_oss/orchestration/campaign.py +++ b/src/ria_toolkit_oss/orchestration/campaign.py @@ -233,6 +233,9 @@ class TransmitterConfig: # For sdr_remote control — keys: host, ssh_user, ssh_key_path, device_type, device_id, zmq_port sdr_remote: Optional[dict] = None + # For sdr_agent control — keys: modulation, order, symbol_rate, center_frequency, filter, rolloff + sdr_agent: Optional[dict] = None + @classmethod def from_dict(cls, d: dict) -> "TransmitterConfig": schedule = [CaptureStep.from_dict(s) for s in d.get("schedule", [])] @@ -244,6 +247,7 @@ class TransmitterConfig: script=d.get("script"), device=d.get("device"), sdr_remote=d.get("sdr_remote"), + sdr_agent=d.get("sdr_agent"), ) diff --git a/src/ria_toolkit_oss/orchestration/executor.py b/src/ria_toolkit_oss/orchestration/executor.py index 3467995..445b16b 100644 --- a/src/ria_toolkit_oss/orchestration/executor.py +++ b/src/ria_toolkit_oss/orchestration/executor.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import logging import subprocess +import threading import time from dataclasses import dataclass, field from pathlib import Path @@ -16,6 +17,7 @@ from ria_toolkit_oss.io.recording import to_sigmf from .campaign import CampaignConfig, CaptureStep, TransmitterConfig from .labeler import build_output_filename, label_recording from .qa import QAResult, check_recording +from .tx_executor import TxExecutor logger = logging.getLogger(__name__) @@ -212,6 +214,7 @@ class CampaignExecutor: self.progress_cb = progress_cb self._sdr = None self._remote_tx_controllers: dict = {} + self._tx_executors: dict[str, tuple] = {} # tx_id → (TxExecutor, stop_event, thread) if verbose: logging.basicConfig(level=logging.DEBUG) @@ -266,6 +269,7 @@ class CampaignExecutor: finally: self._close_sdr() self._close_remote_tx_controllers() + self._close_tx_executors() result.end_time = time.time() logger.info( @@ -340,6 +344,12 @@ class CampaignExecutor: logger.warning(f"Error closing remote Tx controller {tx_id}: {exc}") 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: """Capture ``duration_s`` seconds of IQ samples.""" num_samples = int(duration_s * self.config.recorder.sample_rate) @@ -453,6 +463,27 @@ class CampaignExecutor: # Start transmission in background; _record() runs concurrently ctrl.transmit_async(step.duration + 1.0) + elif transmitter.control_method == "sdr_agent": + 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: logger.warning(f"Unknown control method '{transmitter.control_method}' — skipping") @@ -475,6 +506,13 @@ class CampaignExecutor: if ctrl is not None: 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 def _step_params_json(transmitter: TransmitterConfig, step: CaptureStep) -> str: """Serialise step parameters to a JSON string for the control script.""" From 4aea2841bed3b0fc84e1dcb30107cb3f25d27fb5 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 21 Apr 2026 14:09:36 -0400 Subject: [PATCH 05/28] two-machine TX/RX --- src/ria_toolkit_oss/agent/legacy_executor.py | 9 +++++---- src/ria_toolkit_oss/orchestration/executor.py | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ria_toolkit_oss/agent/legacy_executor.py b/src/ria_toolkit_oss/agent/legacy_executor.py index d8a56d6..91221e1 100644 --- a/src/ria_toolkit_oss/agent/legacy_executor.py +++ b/src/ria_toolkit_oss/agent/legacy_executor.py @@ -261,9 +261,10 @@ class NodeAgent: if command == "run_campaign": campaign_id: str = cmd.get("campaign_id") or str(uuid.uuid4()) config_dict: dict = cmd.get("payload") or {} + skip_local_tx: bool = bool(cmd.get("skip_local_tx", False)) threading.Thread( target=self._run_campaign, - args=(campaign_id, config_dict), + args=(campaign_id, config_dict, skip_local_tx), daemon=True, name=f"campaign-{campaign_id[:8]}", ).start() @@ -303,7 +304,7 @@ class NodeAgent: # 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: from ria_toolkit_oss.orchestration.campaign import CampaignConfig from ria_toolkit_oss.orchestration.executor import CampaignExecutor @@ -315,10 +316,10 @@ class NodeAgent: ) 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: config = CampaignConfig.from_dict(config_dict) - executor = CampaignExecutor(config) + executor = CampaignExecutor(config, skip_local_tx=skip_local_tx) result = executor.run() logger.info("Campaign %s completed — uploading recordings", campaign_id[:8]) self._upload_recordings(campaign_id, config, result) diff --git a/src/ria_toolkit_oss/orchestration/executor.py b/src/ria_toolkit_oss/orchestration/executor.py index 445b16b..66c5273 100644 --- a/src/ria_toolkit_oss/orchestration/executor.py +++ b/src/ria_toolkit_oss/orchestration/executor.py @@ -209,9 +209,11 @@ class CampaignExecutor: config: CampaignConfig, progress_cb: Optional[Callable[[int, int, StepResult], None]] = None, verbose: bool = False, + skip_local_tx: bool = False, ): self.config = config self.progress_cb = progress_cb + self.skip_local_tx = skip_local_tx self._sdr = None self._remote_tx_controllers: dict = {} self._tx_executors: dict[str, tuple] = {} # tx_id → (TxExecutor, stop_event, thread) @@ -464,6 +466,9 @@ class CampaignExecutor: 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 From 4d3aaf6ec8e8abaf8fd1d7cf0fa813317f027f23 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 21 Apr 2026 14:34:48 -0400 Subject: [PATCH 06/28] json access issue --- .../orchestration/tx_executor.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/ria_toolkit_oss/orchestration/tx_executor.py b/src/ria_toolkit_oss/orchestration/tx_executor.py index 6ae32b1..84e200b 100644 --- a/src/ria_toolkit_oss/orchestration/tx_executor.py +++ b/src/ria_toolkit_oss/orchestration/tx_executor.py @@ -37,6 +37,26 @@ 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]] = { @@ -83,7 +103,7 @@ class TxExecutor: modulation: str = agent_cfg.get("modulation", "QPSK").upper() symbol_rate: float = float(agent_cfg.get("symbol_rate", 1e6)) - center_freq: float = float(agent_cfg.get("center_frequency", 0.0)) + 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)) @@ -109,7 +129,7 @@ class TxExecutor: filter_type: str, rolloff: float, ) -> None: - duration: float = float(step.get("duration", 10.0)) + 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 From 8a66860d333305c8eff5bc9e7e2f5f5eedd61796 Mon Sep 17 00:00:00 2001 From: madrigal Date: Tue, 21 Apr 2026 14:38:06 -0400 Subject: [PATCH 07/28] Moved all contents of to , refactored accordingly --- README.md | 2 +- docs/source/examples/sdr/rx.rst | 2 +- docs/source/examples/sdr/tx.rst | 2 +- docs/source/intro/getting_started.rst | 2 +- .../datatypes/radio_datasets.rst | 10 +- ...toolkit_oss.datatypes.datasets.license.rst | 2 +- .../datatypes/ria_toolkit_oss.datatypes.rst | 8 +- docs/source/ria_toolkit_oss/index.rst | 2 +- .../annotations/annotation_transforms.py | 2 +- .../annotations/cusum_annotator.py | 4 +- .../annotations/energy_detector.py | 2 +- .../annotations/parallel_signal_separator.py | 2 +- .../annotations/qualify_slice.py | 2 +- .../annotations/signal_isolation.py | 4 +- .../annotations/threshold_qualifier.py | 2 +- src/ria_toolkit_oss/data/annotation.py | 257 +-- .../{datatypes => data}/datasets/__init__.py | 0 .../datasets/dataset_builder.py | 4 +- .../{datatypes => data}/datasets/h5helpers.py | 0 .../datasets/iq_dataset.py | 6 +- .../datasets/license/__init__.py | 0 .../datasets/license/dataset_license.py | 0 .../datasets/radio_dataset.py | 4 +- .../datasets/spect_dataset.py | 4 +- .../{datatypes => data}/datasets/split.py | 15 +- src/ria_toolkit_oss/data/recording.py | 1708 +++++++++-------- src/ria_toolkit_oss/datatypes/__init__.py | 8 - src/ria_toolkit_oss/datatypes/annotation.py | 129 -- src/ria_toolkit_oss/datatypes/recording.py | 855 --------- src/ria_toolkit_oss/io/recording.py | 28 +- src/ria_toolkit_oss/orchestration/executor.py | 2 +- src/ria_toolkit_oss/orchestration/labeler.py | 2 +- src/ria_toolkit_oss/orchestration/qa.py | 2 +- src/ria_toolkit_oss/sdr/blade.py | 2 +- src/ria_toolkit_oss/sdr/hackrf.py | 2 +- src/ria_toolkit_oss/sdr/pluto.py | 2 +- src/ria_toolkit_oss/sdr/rtlsdr.py | 2 +- src/ria_toolkit_oss/sdr/sdr.py | 2 +- src/ria_toolkit_oss/sdr/usrp.py | 2 +- .../signal/basic_signal_generator.py | 2 +- .../generators/pam_generator.py | 2 +- .../generators/psk_generator.py | 2 +- .../generators/qam_generator.py | 2 +- .../block_generator/recordable_block.py | 2 +- .../block_generator/recording_gen_wrapper.py | 2 +- .../source/recording_source.py | 2 +- src/ria_toolkit_oss/signal/recordable.py | 2 +- .../transforms/iq_augmentations.py | 46 +- .../transforms/iq_impairments.py | 26 +- src/ria_toolkit_oss/view/recording.py | 10 +- src/ria_toolkit_oss/view/view_signal.py | 2 +- .../view/view_signal_simple.py | 2 +- src/ria_toolkit_oss/viz/recording.py | 16 +- .../ria_toolkit_oss/annotate.py | 4 +- .../ria_toolkit_oss/combine.py | 2 +- .../ria_toolkit_oss/common.py | 2 +- .../ria_toolkit_oss/generate.py | 2 +- .../ria_toolkit_oss/transform.py | 2 +- .../ria_toolkit_oss/transmit.py | 2 +- tests/datatypes/test_annotation.py | 2 +- tests/datatypes/test_recording.py | 4 +- tests/io/test_recording_io.py | 2 +- tests/orchestration/test_labeler.py | 2 +- tests/orchestration/test_qa.py | 2 +- tests/ria_toolkit_oss_cli/test.combine.py | 2 +- tests/ria_toolkit_oss_cli/test_split.py | 2 +- tests/transforms/test_iq_augmentations.py | 2 +- tests/transforms/test_iq_impairments.py | 2 +- 68 files changed, 1122 insertions(+), 2114 deletions(-) rename src/ria_toolkit_oss/{datatypes => data}/datasets/__init__.py (100%) rename src/ria_toolkit_oss/{datatypes => data}/datasets/dataset_builder.py (93%) rename src/ria_toolkit_oss/{datatypes => data}/datasets/h5helpers.py (100%) rename src/ria_toolkit_oss/{datatypes => data}/datasets/iq_dataset.py (95%) rename src/ria_toolkit_oss/{datatypes => data}/datasets/license/__init__.py (100%) rename src/ria_toolkit_oss/{datatypes => data}/datasets/license/dataset_license.py (100%) rename src/ria_toolkit_oss/{datatypes => data}/datasets/radio_dataset.py (97%) rename src/ria_toolkit_oss/{datatypes => data}/datasets/spect_dataset.py (91%) rename src/ria_toolkit_oss/{datatypes => data}/datasets/split.py (94%) delete mode 100644 src/ria_toolkit_oss/datatypes/__init__.py delete mode 100644 src/ria_toolkit_oss/datatypes/annotation.py delete mode 100644 src/ria_toolkit_oss/datatypes/recording.py diff --git a/README.md b/README.md index cd038e7..d499a75 100644 --- a/README.md +++ b/README.md @@ -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: ```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/). diff --git a/docs/source/examples/sdr/rx.rst b/docs/source/examples/sdr/rx.rst index 8fd2e3e..432ee7e 100644 --- a/docs/source/examples/sdr/rx.rst +++ b/docs/source/examples/sdr/rx.rst @@ -25,7 +25,7 @@ In this example, we initialize the `Blade` SDR, configure it to record a signal 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 my_radio = Blade() diff --git a/docs/source/examples/sdr/tx.rst b/docs/source/examples/sdr/tx.rst index 4975f1d..72d9788 100644 --- a/docs/source/examples/sdr/tx.rst +++ b/docs/source/examples/sdr/tx.rst @@ -21,7 +21,7 @@ Code 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 # Parameters diff --git a/docs/source/intro/getting_started.rst b/docs/source/intro/getting_started.rst index c2386ee..b53af04 100644 --- a/docs/source/intro/getting_started.rst +++ b/docs/source/intro/getting_started.rst @@ -1027,7 +1027,7 @@ For quick non-CLI use: .. 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.transforms import iq_augmentations, iq_impairments diff --git a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst b/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst index 95d47e2..12a1bdb 100644 --- a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst +++ b/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst @@ -11,15 +11,15 @@ The Radio Dataset Framework provides a software interface to access and manipula the need for users to interface with the source files directly. Instead, users initialize and interact with a Python object, while the complexities of efficient data retrieval and source file manipulation are managed behind the scenes. -Ria Toolkit OSS includes an abstract class called :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset`, which defines common properties and -behaviors for all radio datasets. :py:obj:`ria_toolkit_oss.datatypes.datasets.RadioDataset` can be considered a blueprint for all +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.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 -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. Then, in the various project backends, there are concrete dataset classes, which inherit from both Ria Toolkit OSS and the base dataset class from the respective backend. For example, the :py:obj:`TorchIQDataset` class extends both -:py:obj:`ria_toolkit_oss.datatypes.datasets.IQDataset` from 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. Dataset initialization @@ -130,7 +130,7 @@ Dataset processing and manipulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ All radio datasets support methods tailored specifically for radio processing. These methods are backend-independent, -inherited from the blueprints in 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: diff --git a/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.datasets.license.rst b/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.datasets.license.rst index 60a36c6..577dde8 100644 --- a/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.datasets.license.rst +++ b/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.datasets.license.rst @@ -1,7 +1,7 @@ Dataset License SubModule ========================= -.. automodule:: ria_toolkit_oss.datatypes.datasets.license +.. automodule:: ria_toolkit_oss.data.datasets.license :members: :undoc-members: :show-inheritance: diff --git a/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.rst b/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.rst index 77e1b4e..c550144 100644 --- a/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.rst +++ b/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.rst @@ -1,11 +1,11 @@ -Datatypes Package (ria_toolkit_oss.datatypes) +Datatypes Package (ria_toolkit_oss.data) ============================================= .. |br| raw:: html
-.. automodule:: ria_toolkit_oss.datatypes +.. automodule:: ria_toolkit_oss.data :members: :undoc-members: :show-inheritance: @@ -13,7 +13,7 @@ Datatypes Package (ria_toolkit_oss.datatypes) Radio Dataset SubPackage ------------------------ -.. automodule:: ria_toolkit_oss.datatypes.datasets +.. automodule:: ria_toolkit_oss.data.datasets :members: :undoc-members: :show-inheritance: @@ -21,5 +21,5 @@ Radio Dataset SubPackage .. toctree:: :maxdepth: 2 - Dataset License SubModule + Dataset License SubModule Radio Datasets diff --git a/docs/source/ria_toolkit_oss/index.rst b/docs/source/ria_toolkit_oss/index.rst index 42eb31f..e50a3ac 100644 --- a/docs/source/ria_toolkit_oss/index.rst +++ b/docs/source/ria_toolkit_oss/index.rst @@ -11,7 +11,7 @@ class and function signatures, and doctest examples where available. :maxdepth: 2 :caption: Contents: - Datatypes Package + Data Package SDR Package IO Package Transforms Package diff --git a/src/ria_toolkit_oss/annotations/annotation_transforms.py b/src/ria_toolkit_oss/annotations/annotation_transforms.py index 47300c1..822a75a 100644 --- a/src/ria_toolkit_oss/annotations/annotation_transforms.py +++ b/src/ria_toolkit_oss/annotations/annotation_transforms.py @@ -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 diff --git a/src/ria_toolkit_oss/annotations/cusum_annotator.py b/src/ria_toolkit_oss/annotations/cusum_annotator.py index d37186c..e4498e5 100644 --- a/src/ria_toolkit_oss/annotations/cusum_annotator.py +++ b/src/ria_toolkit_oss/annotations/cusum_annotator.py @@ -3,7 +3,7 @@ from typing import Optional import numpy as np -from ria_toolkit_oss.datatypes import Annotation, Recording +from ria_toolkit_oss.data import Annotation, Recording def annotate_with_cusum( @@ -24,7 +24,7 @@ def annotate_with_cusum( changes between a low and high amplitude. :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. :type label: str :param window_size: The length (in samples) of the moving average window. diff --git a/src/ria_toolkit_oss/annotations/energy_detector.py b/src/ria_toolkit_oss/annotations/energy_detector.py index 109fe6e..4f14a9b 100644 --- a/src/ria_toolkit_oss/annotations/energy_detector.py +++ b/src/ria_toolkit_oss/annotations/energy_detector.py @@ -11,7 +11,7 @@ from typing import Tuple import numpy as np 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( diff --git a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py index 957cf58..f2fa999 100644 --- a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py +++ b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py @@ -55,7 +55,7 @@ import numpy as np from scipy import ndimage 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( diff --git a/src/ria_toolkit_oss/annotations/qualify_slice.py b/src/ria_toolkit_oss/annotations/qualify_slice.py index 2336fe5..89eadd7 100644 --- a/src/ria_toolkit_oss/annotations/qualify_slice.py +++ b/src/ria_toolkit_oss/annotations/qualify_slice.py @@ -1,6 +1,6 @@ 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): diff --git a/src/ria_toolkit_oss/annotations/signal_isolation.py b/src/ria_toolkit_oss/annotations/signal_isolation.py index 47852ae..89ae3df 100644 --- a/src/ria_toolkit_oss/annotations/signal_isolation.py +++ b/src/ria_toolkit_oss/annotations/signal_isolation.py @@ -1,8 +1,8 @@ import numpy as np from scipy.signal import butter, lfilter -from ria_toolkit_oss.datatypes.annotation import Annotation -from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.data.annotation import Annotation +from ria_toolkit_oss.data.recording import Recording def isolate_signal(recording: Recording, annotation: Annotation) -> Recording: diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py index 804c5e1..b9bb727 100644 --- a/src/ria_toolkit_oss/annotations/threshold_qualifier.py +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -46,7 +46,7 @@ from typing import Optional 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): diff --git a/src/ria_toolkit_oss/data/annotation.py b/src/ria_toolkit_oss/data/annotation.py index 1182480..d565e17 100644 --- a/src/ria_toolkit_oss/data/annotation.py +++ b/src/ria_toolkit_oss/data/annotation.py @@ -1,128 +1,129 @@ -from __future__ import annotations - -import json -from typing import Any, Optional - -from sigmf import SigMFFile - - -class Annotation: - """Signal annotations are labels or additional information associated with specific data points or segments within - a signal. These annotations could be used for tasks like supervised learning, where the goal is to train a model - to recognize patterns or characteristics in the signal associated with these annotations. - - Annotations can be used to label interesting points in your recording. - - :param sample_start: The index of the starting sample of the annotation. - :type sample_start: int - :param sample_count: The index of the ending sample of the annotation, inclusive. - :type sample_count: int - :param freq_lower_edge: The lower frequency of the annotation. - :type freq_lower_edge: float - :param freq_upper_edge: The upper frequency of the annotation. - :type freq_upper_edge: float - :param label: The label that will be displayed with the bounding box in compatible viewers including IQEngine. - Defaults to an emtpy string. - :type label: str, optional - :param comment: A human-readable comment. Defaults to an empty string. - :type comment: str, optional - :param detail: A dictionary of user defined annotation-specific metadata. Defaults to None. - :type detail: dict, optional - """ - - def __init__( - self, - sample_start: int, - sample_count: int, - freq_lower_edge: float, - freq_upper_edge: float, - label: Optional[str] = "", - comment: Optional[str] = "", - detail: Optional[dict] = None, - ): - """Initialize a new Annotation instance.""" - self.sample_start = int(sample_start) - self.sample_count = int(sample_count) - self.freq_lower_edge = float(freq_lower_edge) - self.freq_upper_edge = float(freq_upper_edge) - self.label = str(label) - self.comment = str(comment) - - if detail is None: - self.detail = {} - elif not _is_jsonable(detail): - raise ValueError(f"Detail object is not json serializable: {detail}") - else: - self.detail = detail - - def is_valid(self) -> bool: - """ - Check that the annotation sample count is > 0 and the freq_lower_edge 0 and self.freq_lower_edge < self.freq_upper_edge - - def overlap(self, other): - """ - Quantify how much the bounding box in this annotation overlaps with another annotation. - - :param other: The other annotation. - :type other: Annotation - - :returns: The area of the overlap in samples*frequency, or 0 if they do not overlap.""" - - sample_overlap_start = max(self.sample_start, other.sample_start) - sample_overlap_end = min(self.sample_start + self.sample_count, other.sample_start + other.sample_count) - - freq_overlap_start = max(self.freq_lower_edge, other.freq_lower_edge) - freq_overlap_end = min(self.freq_upper_edge, other.freq_upper_edge) - - if freq_overlap_start >= freq_overlap_end or sample_overlap_start >= sample_overlap_end: - return 0 - else: - return (sample_overlap_end - sample_overlap_start) * (freq_overlap_end - freq_overlap_start) - - def area(self): - """ - The 'area' of the bounding box, samples*frequency. - Useful to quantify annotation size. - - :returns: sample length multiplied by bandwidth.""" - - return self.sample_count * (self.freq_upper_edge - self.freq_lower_edge) - - def __eq__(self, other: Annotation) -> bool: - return self.__dict__ == other.__dict__ - - def to_sigmf_format(self): - """ - Returns a JSON dictionary representing this annotation formatted to be saved in a .sigmf-meta file. - """ - - annotation_dict = {SigMFFile.START_INDEX_KEY: self.sample_start, SigMFFile.LENGTH_INDEX_KEY: self.sample_count} - - annotation_dict["metadata"] = { - SigMFFile.LABEL_KEY: self.label, - SigMFFile.COMMENT_KEY: self.comment, - SigMFFile.FHI_KEY: self.freq_upper_edge, - SigMFFile.FLO_KEY: self.freq_lower_edge, - "ria:detail": self.detail, - } - - if _is_jsonable(annotation_dict): - return annotation_dict - else: - raise ValueError("Annotation dictionary was not json serializable.") - - -def _is_jsonable(x: Any) -> bool: - """ - :return: True if x is JSON serializable, False otherwise. - """ - try: - json.dumps(x) - return True - except (TypeError, OverflowError): - return False +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 diff --git a/src/ria_toolkit_oss/datatypes/datasets/__init__.py b/src/ria_toolkit_oss/data/datasets/__init__.py similarity index 100% rename from src/ria_toolkit_oss/datatypes/datasets/__init__.py rename to src/ria_toolkit_oss/data/datasets/__init__.py diff --git a/src/ria_toolkit_oss/datatypes/datasets/dataset_builder.py b/src/ria_toolkit_oss/data/datasets/dataset_builder.py similarity index 93% rename from src/ria_toolkit_oss/datatypes/datasets/dataset_builder.py rename to src/ria_toolkit_oss/data/datasets/dataset_builder.py index fa34130..b2c601d 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/dataset_builder.py +++ b/src/ria_toolkit_oss/data/datasets/dataset_builder.py @@ -7,8 +7,8 @@ from typing import Any, Optional from packaging.version import Version -from ria_toolkit_oss.datatypes.datasets.license.dataset_license import DatasetLicense -from ria_toolkit_oss.datatypes.datasets.radio_dataset import RadioDataset +from ria_toolkit_oss.data.datasets.license.dataset_license import DatasetLicense +from ria_toolkit_oss.data.datasets.radio_dataset import RadioDataset from ria_toolkit_oss.utils.abstract_attribute import abstract_attribute diff --git a/src/ria_toolkit_oss/datatypes/datasets/h5helpers.py b/src/ria_toolkit_oss/data/datasets/h5helpers.py similarity index 100% rename from src/ria_toolkit_oss/datatypes/datasets/h5helpers.py rename to src/ria_toolkit_oss/data/datasets/h5helpers.py diff --git a/src/ria_toolkit_oss/datatypes/datasets/iq_dataset.py b/src/ria_toolkit_oss/data/datasets/iq_dataset.py similarity index 95% rename from src/ria_toolkit_oss/datatypes/datasets/iq_dataset.py rename to src/ria_toolkit_oss/data/datasets/iq_dataset.py index bb7164c..bbd9cda 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/iq_dataset.py +++ b/src/ria_toolkit_oss/data/datasets/iq_dataset.py @@ -7,11 +7,11 @@ from typing import Optional import h5py import numpy as np -from ria_toolkit_oss.datatypes.datasets.h5helpers import ( +from ria_toolkit_oss.data.datasets.h5helpers import ( append_entry_inplace, 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): @@ -19,7 +19,7 @@ class IQDataset(RadioDataset, ABC): radiofrequency (RF) signals represented as In-phase (I) and Quadrature (Q) samples. 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 should not be instantiated directly. Instead, it is subclassed to define custom interfaces for specific machine diff --git a/src/ria_toolkit_oss/datatypes/datasets/license/__init__.py b/src/ria_toolkit_oss/data/datasets/license/__init__.py similarity index 100% rename from src/ria_toolkit_oss/datatypes/datasets/license/__init__.py rename to src/ria_toolkit_oss/data/datasets/license/__init__.py diff --git a/src/ria_toolkit_oss/datatypes/datasets/license/dataset_license.py b/src/ria_toolkit_oss/data/datasets/license/dataset_license.py similarity index 100% rename from src/ria_toolkit_oss/datatypes/datasets/license/dataset_license.py rename to src/ria_toolkit_oss/data/datasets/license/dataset_license.py diff --git a/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py b/src/ria_toolkit_oss/data/datasets/radio_dataset.py similarity index 97% rename from src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py rename to src/ria_toolkit_oss/data/datasets/radio_dataset.py index eea94c5..237ea9a 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/radio_dataset.py +++ b/src/ria_toolkit_oss/data/datasets/radio_dataset.py @@ -12,7 +12,7 @@ import numpy as np import pandas as pd from numpy.typing import ArrayLike -from ria_toolkit_oss.datatypes.datasets.h5helpers import ( +from ria_toolkit_oss.data.datasets.h5helpers import ( append_entry_inplace, copy_file, 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 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) samples. diff --git a/src/ria_toolkit_oss/datatypes/datasets/spect_dataset.py b/src/ria_toolkit_oss/data/datasets/spect_dataset.py similarity index 91% rename from src/ria_toolkit_oss/datatypes/datasets/spect_dataset.py rename to src/ria_toolkit_oss/data/datasets/spect_dataset.py index 79ec1bd..057bf2a 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/spect_dataset.py +++ b/src/ria_toolkit_oss/data/datasets/spect_dataset.py @@ -3,7 +3,7 @@ from __future__ import annotations import os 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): @@ -13,7 +13,7 @@ class SpectDataset(RadioDataset, ABC): radio signal spectrograms. 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 should not be instantiated directly. Instead, it is subclassed to define custom interfaces for specific machine diff --git a/src/ria_toolkit_oss/datatypes/datasets/split.py b/src/ria_toolkit_oss/data/datasets/split.py similarity index 94% rename from src/ria_toolkit_oss/datatypes/datasets/split.py rename to src/ria_toolkit_oss/data/datasets/split.py index 4ef7faf..edef47b 100644 --- a/src/ria_toolkit_oss/datatypes/datasets/split.py +++ b/src/ria_toolkit_oss/data/datasets/split.py @@ -6,11 +6,8 @@ from typing import Optional import numpy as np from numpy.random import Generator -from ria_toolkit_oss.datatypes.datasets import RadioDataset -from ria_toolkit_oss.datatypes.datasets.h5helpers import ( - copy_over_example, - make_empty_clone, -) +from ria_toolkit_oss.data.datasets import RadioDataset +from ria_toolkit_oss.data.datasets.h5helpers import copy_over_example, make_empty_clone 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. 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. :type dataset: RadioDataset @@ -50,7 +47,7 @@ def split(dataset: RadioDataset, lengths: list[int | float]) -> list[RadioDatase >>> import string >>> import numpy as np >>> 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: @@ -126,7 +123,7 @@ def random_split( training and test datasets. 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. :param dataset: Dataset to be split. @@ -144,7 +141,7 @@ def random_split( :rtype: list of RadioDataset 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): raise ValueError(f"'dataset' must be RadioDataset or one of its subclasses, got {type(dataset)}.") diff --git a/src/ria_toolkit_oss/data/recording.py b/src/ria_toolkit_oss/data/recording.py index 20939bd..c3cf861 100644 --- a/src/ria_toolkit_oss/data/recording.py +++ b/src/ria_toolkit_oss/data/recording.py @@ -1,853 +1,855 @@ -from __future__ import annotations - -import copy -import hashlib -import json -import os -import re -import time -import warnings -from typing import Any, Iterator, Optional - -import numpy as np -from numpy.typing import ArrayLike - -from ria_toolkit_oss.datatypes.annotation import Annotation - -PROTECTED_KEYS = ["rec_id", "timestamp"] - - -class Recording: - """Tape of complex IQ (in-phase and quadrature) samples with associated metadata and annotations. - - Recording data is a complex array of shape C x N, where C is the number of channels - and N is the number of samples in each channel. - - Metadata is stored in a dictionary of key value pairs, - to include information such as sample_rate and center_frequency. - - Annotations are a list of :ref:`Annotation `, - defining bounding boxes in time and frequency with labels and metadata. - - Here, signal data is represented as a NumPy array. This class is then extended in the RIA Backends to provide - support for different data structures, such as Tensors. - - Recordings are long-form tapes can be obtained either from a software-defined radio (SDR) or generated - synthetically. Then, machine learning datasets are curated from collection of recordings by segmenting these - longer-form tapes into shorter units called slices. - - All recordings are assigned a unique 64-character recording ID, ``rec_id``. If this field is missing from the - provided metadata, a new ID will be generated upon object instantiation. - - :param data: Signal data as a tape IQ samples, either C x N complex, where C is the number of - channels and N is number of samples in the signal. If data is a one-dimensional array of complex samples with - length N, it will be reshaped to a two-dimensional array with dimensions 1 x N. - :type data: array_like - - :param metadata: Additional information associated with the recording. - :type metadata: dict, optional - :param annotations: A collection of ``Annotation`` objects defining bounding boxes. - :type annotations: list of Annotations, optional - - :param dtype: Explicitly specify the data-type of the complex samples. Must be a complex NumPy type, such as - ``np.complex64`` or ``np.complex128``. Default is None, in which case the type is determined implicitly. If - ``data`` is a NumPy array, the Recording will use the dtype of ``data`` directly without any conversion. - :type dtype: numpy dtype object, optional - :param timestamp: The timestamp when the recording data was generated. If provided, it should be a float or integer - representing the time in seconds since epoch (e.g., ``time.time()``). Only used if the `timestamp` field is not - present in the provided metadata. - :type dtype: float or int, optional - - :raises ValueError: If data is not complex 1xN or CxN. - :raises ValueError: If metadata is not a python dict. - :raises ValueError: If metadata is not json serializable. - :raises ValueError: If annotations is not a list of valid annotation objects. - - **Examples:** - - >>> import numpy - >>> from ria_toolkit_oss.datatypes import Recording, Annotation - - >>> # Create an array of complex samples, just 1s in this case. - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - - >>> # Create a dictionary of relevant metadata. - >>> sample_rate = 1e6 - >>> center_frequency = 2.44e9 - >>> metadata = { - ... "sample_rate": sample_rate, - ... "center_frequency": center_frequency, - ... "author": "me", - ... } - - >>> # Create an annotation for the annotations list. - >>> annotations = [ - ... Annotation( - ... sample_start=0, - ... sample_count=1000, - ... freq_lower_edge=center_frequency - (sample_rate / 2), - ... freq_upper_edge=center_frequency + (sample_rate / 2), - ... label="example", - ... ) - ... ] - - >>> # Store samples, metadata, and annotations together in a convenient object. - >>> recording = Recording(data=samples, metadata=metadata, annotations=annotations) - >>> print(recording.metadata) - {'sample_rate': 1000000.0, 'center_frequency': 2440000000.0, 'author': 'me'} - >>> print(recording.annotations[0].label) - 'example' - """ - - def __init__( # noqa C901 - self, - data: ArrayLike | list[list], - metadata: Optional[dict[str, any]] = None, - dtype: Optional[np.dtype] = None, - timestamp: Optional[float | int] = None, - annotations: Optional[list[Annotation]] = None, - ): - - data_arr = np.asarray(data) - - if np.iscomplexobj(data_arr): - # Expect C x N - if data_arr.ndim == 1: - self._data = np.expand_dims(data_arr, axis=0) # N -> 1 x N - elif data_arr.ndim == 2: - self._data = data_arr - else: - raise ValueError("Complex data must be C x N.") - - else: - raise ValueError("Input data must be complex.") - - if dtype is not None: - self._data = self._data.astype(dtype) - - assert np.iscomplexobj(self._data) - - if metadata is None: - self._metadata = {} - elif isinstance(metadata, dict): - self._metadata = metadata - else: - raise ValueError(f"Metadata must be a python dict, but was {type(metadata)}.") - - if not _is_jsonable(metadata): - raise ValueError("Value must be JSON serializable.") - - if "timestamp" not in self.metadata: - if timestamp is not None: - if not isinstance(timestamp, (int, float)): - raise ValueError(f"timestamp must be int or float, not {type(timestamp)}") - self._metadata["timestamp"] = timestamp - else: - self._metadata["timestamp"] = time.time() - else: - if not isinstance(self._metadata["timestamp"], (int, float)): - raise ValueError("timestamp must be int or float, not ", type(self._metadata["timestamp"])) - - if "rec_id" not in self.metadata: - self._metadata["rec_id"] = generate_recording_id(data=self.data, timestamp=self._metadata["timestamp"]) - - if annotations is None: - self._annotations = [] - elif isinstance(annotations, list): - self._annotations = annotations - else: - raise ValueError("Annotations must be a list or None.") - - if not all(isinstance(annotation, Annotation) for annotation in self._annotations): - raise ValueError("All elements in self._annotations must be of type Annotation.") - - self._index = 0 - - @property - def data(self) -> np.ndarray: - """ - :return: Recording data, as a complex array. - :type: np.ndarray - - .. note:: - - For recordings with more than 1,024 samples, this property returns a read-only view of the data. - - .. note:: - - To access specific samples, consider indexing the object directly with ``rec[c, n]``. - """ - if self._data.size > 1024: - # Returning a read-only view prevents mutation at a distance while maintaining performance. - v = self._data.view() - v.setflags(write=False) - return v - else: - return self._data.copy() - - @property - def metadata(self) -> dict: - """ - :return: Dictionary of recording metadata. - :type: dict - """ - return self._metadata.copy() - - @property - def annotations(self) -> list[Annotation]: - """ - :return: List of recording annotations - :type: list of Annotation objects - """ - return self._annotations.copy() - - @property - def shape(self) -> tuple[int]: - """ - :return: The shape of the data array. - :type: tuple of ints - """ - return np.shape(self.data) - - @property - def n_chan(self) -> int: - """ - :return: The number of channels in the recording. - :type: int - """ - return self.shape[0] - - @property - def rec_id(self) -> str: - """ - :return: Recording ID. - :type: str - """ - return self.metadata["rec_id"] - - @property - def dtype(self) -> str: - """ - :return: Data-type of the data array's elements. - :type: numpy dtype object - """ - return self.data.dtype - - @property - def timestamp(self) -> float | int: - """ - :return: Recording timestamp (time in seconds since epoch). - :type: float or int - """ - return self.metadata["timestamp"] - - @property - def sample_rate(self) -> float | None: - """ - :return: Sample rate of the recording, or None if 'sample_rate' is not in metadata. - :type: str - """ - return self.metadata.get("sample_rate") - - @sample_rate.setter - def sample_rate(self, sample_rate: float | int) -> None: - """Set the sample rate of the recording. - - :param sample_rate: The sample rate of the recording. - :type sample_rate: float or int - - :return: None - """ - self.add_to_metadata(key="sample_rate", value=sample_rate) - - def astype(self, dtype: np.dtype) -> Recording: - """Copy of the recording, data cast to a specified type. - - .. todo: This method is not yet implemented. - - :param dtype: Data-type to which the array is cast. Must be a complex scalar type, such as ``np.complex64`` or - ``np.complex128``. - :type dtype: NumPy data type, optional - - .. note: Casting to a data type with less precision can risk losing data by truncating or rounding values, - potentially resulting in a loss of accuracy and significant information. - - :return: A new recording with the same metadata and data, with dtype. - - TODO: Add example usage. - """ - # Rather than check for a valid datatype, let's cast and check the result. This makes it easier to provide - # cross-platform support where the types are aliased across platforms. - with warnings.catch_warnings(): - warnings.simplefilter("ignore") # Casting may generate user warnings. E.g., complex -> real - data = self.data.astype(dtype) - - if np.iscomplexobj(data): - return Recording(data=data, metadata=self.metadata, annotations=self.annotations) - else: - raise ValueError("dtype must be a complex number scalar type.") - - def add_to_metadata(self, key: str, value: Any) -> None: - """Add a new key-value pair to the recording metadata. - - :param key: New metadata key, must be snake_case. - :type key: str - :param value: Corresponding metadata value. - :type value: any - - :raises ValueError: If key is already in metadata or if key is not a valid metadata key. - :raises ValueError: If value is not JSON serializable. - - :return: None. - - **Examples:** - - Create a recording and add metadata: - - >>> import numpy - >>> from ria_toolkit_oss.datatypes import Recording - >>> - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = { - >>> "sample_rate": 1e6, - >>> "center_frequency": 2.44e9, - >>> } - >>> - >>> recording = Recording(data=samples, metadata=metadata) - >>> print(recording.metadata) - {'sample_rate': 1000000.0, - 'center_frequency': 2440000000.0, - 'timestamp': 17369..., - 'rec_id': 'fda0f41...'} - >>> - >>> recording.add_to_metadata(key="author", value="me") - >>> print(recording.metadata) - {'sample_rate': 1000000.0, - 'center_frequency': 2440000000.0, - 'author': 'me', - 'timestamp': 17369..., - 'rec_id': 'fda0f41...'} - """ - if key in self.metadata: - raise ValueError( - f"Key {key} already in metadata. Use Recording.update_metadata() to modify existing fields." - ) - - if not _is_valid_metadata_key(key): - raise ValueError(f"Invalid metadata key: {key}.") - - if not _is_jsonable(value): - raise ValueError("Value must be JSON serializable.") - - self._metadata[key] = value - - def update_metadata(self, key: str, value: Any) -> None: - """Update the value of an existing metadata key, - or add the key value pair if it does not already exist. - - :param key: Existing metadata key. - :type key: str - :param value: New value to enter at key. - :type value: any - - :raises ValueError: If value is not JSON serializable - :raises ValueError: If key is protected. - - :return: None. - - **Examples:** - - Create a recording and update metadata: - - >>> import numpy - >>> from ria_toolkit_oss.datatypes import Recording - - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = { - >>> "sample_rate": 1e6, - >>> "center_frequency": 2.44e9, - >>> "author": "me" - >>> } - - >>> recording = Recording(data=samples, metadata=metadata) - >>> print(recording.metadata) - {'sample_rate': 1000000.0, - 'center_frequency': 2440000000.0, - 'author': "me", - 'timestamp': 17369... - 'rec_id': 'fda0f41...'} - - >>> recording.update_metadata(key="author", value=you") - >>> print(recording.metadata) - {'sample_rate': 1000000.0, - 'center_frequency': 2440000000.0, - 'author': "you", - 'timestamp': 17369... - 'rec_id': 'fda0f41...'} - """ - if key not in self.metadata: - self.add_to_metadata(key=key, value=value) - - if not _is_jsonable(value): - raise ValueError("Value must be JSON serializable.") - - if key in PROTECTED_KEYS: # Check protected keys. - raise ValueError(f"Key {key} is protected and cannot be modified or removed.") - - else: - self._metadata[key] = value - - def remove_from_metadata(self, key: str): - """ - Remove a key from the recording metadata. - Does not remove key if it is protected. - - :param key: The key to remove. - :type key: str - - :raises ValueError: If key is protected. - - :return: None. - - **Examples:** - - Create a recording and add metadata: - - >>> import numpy - >>> from ria_toolkit_oss.datatypes import Recording - - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = { - ... "sample_rate": 1e6, - ... "center_frequency": 2.44e9, - ... } - - >>> recording = Recording(data=samples, metadata=metadata) - >>> print(recording.metadata) - {'sample_rate': 1000000.0, - 'center_frequency': 2440000000.0, - 'timestamp': 17369..., # Example value - 'rec_id': 'fda0f41...'} # Example value - - >>> recording.add_to_metadata(key="author", value="me") - >>> print(recording.metadata) - {'sample_rate': 1000000.0, - 'center_frequency': 2440000000.0, - 'author': 'me', - 'timestamp': 17369..., # Example value - 'rec_id': 'fda0f41...'} # Example value - """ - if key not in PROTECTED_KEYS: - self._metadata.pop(key) - else: - raise ValueError(f"Key {key} is protected and cannot be modified or removed.") - - def view(self, output_path: Optional[str] = "images/signal.png", **kwargs) -> None: - """Create a plot of various signal visualizations as a PNG image. - - :param output_path: The output image path. Defaults to "images/signal.png". - :type output_path: str, optional - :param kwargs: Keyword arguments passed on to utils.view.view_sig. - :type: dict of keyword arguments - - **Examples:** - - Create a recording and view it as a plot in a .png image: - - >>> import numpy - >>> from ria_toolkit_oss.datatypes import Recording - - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = { - >>> "sample_rate": 1e6, - >>> "center_frequency": 2.44e9, - >>> } - - >>> recording = Recording(data=samples, metadata=metadata) - >>> recording.view() - """ - from ria_toolkit_oss.view import view_sig - - view_sig(recording=self, output_path=output_path, **kwargs) - - def simple_view(self, **kwargs) -> None: - """Create a plot of various signal visualizations as a PNG or SVG image. - - :param kwargs: Keyword arguments passed on to ria_toolkit_oss.view.view_signal_simple.create_plots. - :type: dict of keyword arguments - - **Examples:** - - Create a recording and view it as a plot in a .png image: - - >>> import numpy - >>> from ria_toolkit_oss.datatypes import Recording - - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = { - >>> "sample_rate": 1e6, - >>> "center_frequency": 2.44e9, - >>> } - - >>> recording = Recording(data=samples, metadata=metadata) - >>> recording.simple_view() - """ - from ria_toolkit_oss.view.view_signal_simple import view_simple_sig - - view_simple_sig(recording=self, **kwargs) - - def to_sigmf( - self, filename: Optional[str] = None, path: Optional[os.PathLike | str] = None, overwrite: bool = False - ) -> None: - """Write recording to a set of SigMF files. - - The SigMF io format is defined by the `SigMF Specification Project `_ - - :param recording: The recording to be written to file. - :type recording: utils.data.Recording - :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. - :type filename: os.PathLike or str, optional - :param path: The directory path to where the recording is to be saved. Defaults to recordings/. - :type path: os.PathLike or str, optional - - :raises IOError: If there is an issue encountered during the file writing process. - - :return: None - - **Examples:** - - Create a recording and view it as a plot in a `.png` image: - - >>> import numpy - >>> from utils.data import Recording - - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = { - ... "sample_rate": 1e6, - ... "center_frequency": 2.44e9, - ... } - - >>> recording = Recording(data=samples, metadata=metadata) - >>> recording.view() - """ - from ria_toolkit_oss.io.recording import to_sigmf - - to_sigmf(filename=filename, path=path, recording=self, overwrite=overwrite) - - def to_npy( - self, filename: Optional[str] = None, path: Optional[os.PathLike | str] = None, overwrite: bool = False - ) -> str: - """Write recording to ``.npy`` binary file. - - :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. - :type filename: os.PathLike or str, optional - :param path: The directory path to where the recording is to be saved. Defaults to recordings/. - :type path: os.PathLike or str, optional - - :raises IOError: If there is an issue encountered during the file writing process. - - :return: Path where the file was saved. - :rtype: str - - **Examples:** - - Create a recording and save it to a .npy file: - - >>> import numpy - >>> from utils.data import Recording - - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = { - >>> "sample_rate": 1e6, - >>> "center_frequency": 2.44e9, - >>> } - - >>> recording = Recording(data=samples, metadata=metadata) - >>> recording.to_npy() - """ - from ria_toolkit_oss.io.recording import to_npy - - to_npy(recording=self, filename=filename, path=path, overwrite=overwrite) - - def to_wav( - self, - filename: Optional[str] = None, - path: Optional[os.PathLike | str] = None, - target_sample_rate: Optional[int] = 48000, - bits_per_sample: int = 32, - overwrite: bool = False, - ) -> str: - """Write recording to WAV file with embedded YAML metadata. - - WAV format uses stereo audio with I (in-phase) in left channel and Q (quadrature) in right channel. - Metadata is stored in standard LIST INFO chunks with RF-specific metadata encoded as YAML - in the ICMT (comment) field for human readability. - - :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. - :type filename: os.PathLike or str, optional - :param path: The directory path to where the recording is to be saved. Defaults to recordings/. - :type path: os.PathLike or str, optional - :param target_sample_rate: Sample rate stored in the WAV header when no sample_rate metadata - is present. IQ samples are written without decimation or interpolation. Default is 48000 Hz. - :type target_sample_rate: int, optional - :param bits_per_sample: Bits per sample (32 for float32, 16 for int16). Default is 32. - :type bits_per_sample: int, optional - :param overwrite: Whether to overwrite existing files. Default is False. - :type overwrite: bool, optional - - :raises IOError: If there is an issue encountered during the file writing process. - - :return: Path where the file was saved. - :rtype: str - - **Examples:** - - Create a recording and save it to a .wav file: - - >>> import numpy - >>> from utils.data import Recording - >>> samples = numpy.exp(1j * 2 * numpy.pi * 0.1 * numpy.arange(10000)) - >>> metadata = {"sample_rate": 1e6, "center_frequency": 915e6} - >>> recording = Recording(data=samples, metadata=metadata) - >>> recording.to_wav() - """ - from ria_toolkit_oss.io.recording import to_wav - - return to_wav( - recording=self, - filename=filename, - path=path, - target_sample_rate=target_sample_rate, - bits_per_sample=bits_per_sample, - overwrite=overwrite, - ) - - def to_blue( - self, - filename: Optional[str] = None, - path: Optional[os.PathLike | str] = None, - data_format: str = "CI", - overwrite: bool = False, - ) -> str: - """Write recording to MIDAS Blue file format. - - MIDAS Blue is a legacy RF file format with a 512-byte binary header. - Commonly used with X-Midas and other RF/radar signal processing tools. - - :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. - :type filename: os.PathLike or str, optional - :param path: The directory path to where the recording is to be saved. Defaults to recordings/. - :type path: os.PathLike or str, optional - :param data_format: Format code (default 'CI' = complex int16). - Common formats: 'CI' (complex int16), 'CF' (complex float32), 'CD' (complex float64). - Integer formats require the IQ samples to already be scaled within [-1, 1). - :type data_format: str, optional - :param overwrite: Whether to overwrite existing files. Default is False. - :type overwrite: bool, optional - - :raises IOError: If there is an issue encountered during the file writing process. - - :return: Path where the file was saved. - :rtype: str - - **Examples:** - - Create a recording and save it to a .blue file: - - >>> import numpy - >>> from utils.data import Recording - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = {"sample_rate": 1e6, "center_frequency": 2.44e9} - >>> recording = Recording(data=samples, metadata=metadata) - >>> recording.to_blue() - """ - from ria_toolkit_oss.io.recording import to_blue - - return to_blue(recording=self, filename=filename, path=path, data_format=data_format, overwrite=overwrite) - - def trim(self, num_samples: int, start_sample: Optional[int] = 0) -> Recording: - """Trim Recording samples to a desired length, shifting annotations to maintain alignment. - - :param start_sample: The start index of the desired trimmed recording. Defaults to 0. - :type start_sample: int, optional - :param num_samples: The number of samples that the output trimmed recording will have. - :type num_samples: int - :raises IndexError: If start_sample + num_samples is greater than the length of the recording. - :raises IndexError: If sample_start < 0 or num_samples < 0. - - :return: The trimmed Recording. - :rtype: Recording - - **Examples:** - - Create a recording and trim it: - - >>> import numpy - >>> from utils.data import Recording - - >>> samples = numpy.ones(10000, dtype=numpy.complex64) - >>> metadata = { - ... "sample_rate": 1e6, - ... "center_frequency": 2.44e9, - ... } - - >>> recording = Recording(data=samples, metadata=metadata) - >>> print(len(recording)) - 10000 - - >>> trimmed_recording = recording.trim(start_sample=1000, num_samples=1000) - >>> print(len(trimmed_recording)) - 1000 - """ - - if start_sample < 0: - raise IndexError("start_sample cannot be < 0.") - elif start_sample + num_samples > len(self): - raise IndexError( - f"start_sample {start_sample} + num_samples {num_samples} > recording length {len(self)}." - ) - - end_sample = start_sample + num_samples - - data = self.data[:, start_sample:end_sample] - - new_annotations = copy.deepcopy(self.annotations) - for annotation in new_annotations: - # trim annotation if it goes outside the trim boundaries - if annotation.sample_start < start_sample: - annotation.sample_count = annotation.sample_count - (start_sample - annotation.sample_start) - annotation.sample_start = start_sample - - if annotation.sample_start + annotation.sample_count > end_sample: - annotation.sample_count = end_sample - annotation.sample_start - - # shift annotation to align with the new start point - annotation.sample_start = annotation.sample_start - start_sample - - return Recording(data=data, metadata=self.metadata, annotations=new_annotations) - - def normalize(self) -> Recording: - """Scale the recording data, relative to its maximum value, so that the magnitude of the maximum sample is 1. - - :return: Recording where the maximum sample amplitude is 1. - :rtype: Recording - - **Examples:** - - Create a recording with maximum amplitude 0.5 and normalize to a maximum amplitude of 1: - - >>> import numpy - >>> from utils.data import Recording - - >>> samples = numpy.ones(10000, dtype=numpy.complex64) * 0.5 - >>> metadata = { - ... "sample_rate": 1e6, - ... "center_frequency": 2.44e9, - ... } - - >>> recording = Recording(data=samples, metadata=metadata) - >>> print(numpy.max(numpy.abs(recording.data))) - 0.5 - - >>> normalized_recording = recording.normalize() - >>> print(numpy.max(numpy.abs(normalized_recording.data))) - 1 - """ - scaled_data = self.data / np.max(abs(self.data)) - return Recording(data=scaled_data, metadata=self.metadata, annotations=self.annotations) - - def __len__(self) -> int: - """The length of a recording is defined by the number of complex samples in each channel of the recording.""" - return self.shape[1] - - def __eq__(self, other: Recording) -> bool: - """Two Recordings are equal if all data, metadata, and annotations are the same.""" - - # counter used to allow for differently ordered annotation lists - return ( - np.array_equal(self.data, other.data) - and self.metadata == other.metadata - and self.annotations == other.annotations - ) - - def __ne__(self, other: Recording) -> bool: - """Two Recordings are equal if all data, and metadata, and annotations are the same.""" - return not self.__eq__(other=other) - - def __iter__(self) -> Iterator: - self._index = 0 - return self - - def __next__(self) -> np.ndarray: - if self._index < self.n_chan: - to_ret = self.data[self._index] - self._index += 1 - return to_ret - else: - raise StopIteration - - def __getitem__(self, key: int | tuple[int] | slice) -> np.ndarray | np.complexfloating: - """If key is an integer, tuple of integers, or a slice, return the corresponding samples. - - For arrays with 1,024 or fewer samples, return a copy of the recording data. For larger arrays, return a - read-only view. This prevents mutation at a distance while maintaining performance. - """ - if isinstance(key, (int, tuple, slice)): - v = self._data[key] - if isinstance(v, np.complexfloating): - return v - elif v.size > 1024: - v.setflags(write=False) # Make view read-only. - return v - else: - return v.copy() - - else: - raise ValueError(f"Key must be an integer, tuple, or slice but was {type(key)}.") - - def __setitem__(self, *args, **kwargs) -> None: - """Raise an error if an attempt is made to assign to the recording.""" - raise ValueError("Assignment to Recording is not allowed.") - - -def generate_recording_id(data: np.ndarray, timestamp: Optional[float | int] = None) -> str: - """Generate unique 64-character recording ID. The recording ID is generated by hashing the recording data with - the datetime that the recording data was generated. If no datatime is provided, the current datatime is used. - - :param data: Tape of IQ samples, as a NumPy array. - :type data: np.ndarray - :param timestamp: Unix timestamp in seconds. Defaults to None. - :type timestamp: float or int, optional - - :return: 256-character hash, to be used as the recording ID. - :rtype: str - """ - if timestamp is None: - timestamp = time.time() - - byte_sequence = data.tobytes() + str(timestamp).encode("utf-8") - sha256_hash = hashlib.sha256(byte_sequence) - - return sha256_hash.hexdigest() - - -def _is_jsonable(x: Any) -> bool: - """ - :return: True if x is JSON serializable, False otherwise. - """ - try: - json.dumps(x) - return True - except (TypeError, OverflowError): - return False - - -def _is_valid_metadata_key(key: Any) -> bool: - """ - :return: True if key is a valid metadata key, False otherwise. - """ - if isinstance(key, str) and key.islower() and re.match(pattern=r"^[a-z_]+$", string=key) is not None: - return True - - else: - return False +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.data.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.data.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.data.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.data 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 if 'sample_rate' is not in metadata. + :type: str + """ + return self.metadata.get("sample_rate") + + @sample_rate.setter + def sample_rate(self, sample_rate: float | int) -> None: + """Set the sample rate of the recording. + + :param sample_rate: The sample rate of the recording. + :type sample_rate: float or int + + :return: None + """ + self.add_to_metadata(key="sample_rate", value=sample_rate) + + def astype(self, dtype: np.dtype) -> Recording: + """Copy of the recording, data cast to a specified type. + + .. todo: This method is not yet implemented. + + :param dtype: Data-type to which the array is cast. Must be a complex scalar type, such as ``np.complex64`` or + ``np.complex128``. + :type dtype: NumPy data type, optional + + .. note: Casting to a data type with less precision can risk losing data by truncating or rounding values, + potentially resulting in a loss of accuracy and significant information. + + :return: A new recording with the same metadata and data, with dtype. + + + **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.data import Recording + >>> + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + >>> + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(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.data 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.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(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 ria_toolkit_oss.view.view_sig. + :type: dict of keyword arguments + + **Examples:** + + Create a recording and view it as a plot in a .png image: + + >>> import numpy + >>> from ria_toolkit_oss.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 ria_toolkit_oss.view.view_signal_simple.view_simple_sig. + :type: dict of keyword arguments + + **Examples:** + + Create a recording and view it as a plot in a .png image: + + >>> import numpy + >>> from ria_toolkit_oss.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 `_ + + :param recording: The recording to be written to file. + :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. + :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.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + >>> "sample_rate": 1e6, + >>> "center_frequency": 2.44e9, + >>> } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> recording.to_npy() + """ + from ria_toolkit_oss.io.recording import to_npy + + to_npy(recording=self, filename=filename, path=path, overwrite=overwrite) + + def to_wav( + self, + filename: Optional[str] = None, + path: Optional[os.PathLike | str] = None, + target_sample_rate: Optional[int] = 48000, + bits_per_sample: int = 32, + overwrite: bool = False, + ) -> str: + """Write recording to WAV file with embedded YAML metadata. + + WAV format uses stereo audio with I (in-phase) in left channel and Q (quadrature) in right channel. + Metadata is stored in standard LIST INFO chunks with RF-specific metadata encoded as YAML + in the ICMT (comment) field for human readability. + + :param filename: The name of the file where the recording is to be saved. Defaults to auto generated filename. + :type filename: os.PathLike or str, optional + :param path: The directory path to where the recording is to be saved. Defaults to recordings/. + :type path: os.PathLike or str, optional + :param target_sample_rate: Sample rate stored in the WAV header when no sample_rate metadata + is present. IQ samples are written without decimation or interpolation. Default is 48000 Hz. + :type target_sample_rate: int, optional + :param bits_per_sample: Bits per sample (32 for float32, 16 for int16). Default is 32. + :type bits_per_sample: int, optional + :param overwrite: Whether to overwrite existing files. Default is False. + :type overwrite: bool, optional + + :raises IOError: If there is an issue encountered during the file writing process. + + :return: Path where the file was saved. + :rtype: str + + **Examples:** + + Create a recording and save it to a .wav file: + + >>> import numpy + >>> from ria_toolkit_oss.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 ria_toolkit_oss.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.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(len(recording)) + 10000 + + >>> trimmed_recording = recording.trim(start_sample=1000, num_samples=1000) + >>> print(len(trimmed_recording)) + 1000 + """ + + if start_sample < 0: + raise IndexError("start_sample cannot be < 0.") + elif start_sample + num_samples > len(self): + raise IndexError( + f"start_sample {start_sample} + num_samples {num_samples} > recording length {len(self)}." + ) + + end_sample = start_sample + num_samples + + data = self.data[:, start_sample:end_sample] + + new_annotations = copy.deepcopy(self.annotations) + 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.data import Recording + + >>> samples = numpy.ones(10000, dtype=numpy.complex64) * 0.5 + >>> metadata = { + ... "sample_rate": 1e6, + ... "center_frequency": 2.44e9, + ... } + + >>> recording = Recording(data=samples, metadata=metadata) + >>> print(numpy.max(numpy.abs(recording.data))) + 0.5 + + >>> normalized_recording = recording.normalize() + >>> print(numpy.max(numpy.abs(normalized_recording.data))) + 1 + """ + 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 diff --git a/src/ria_toolkit_oss/datatypes/__init__.py b/src/ria_toolkit_oss/datatypes/__init__.py deleted file mode 100644 index 7ae09db..0000000 --- a/src/ria_toolkit_oss/datatypes/__init__.py +++ /dev/null @@ -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 diff --git a/src/ria_toolkit_oss/datatypes/annotation.py b/src/ria_toolkit_oss/datatypes/annotation.py deleted file mode 100644 index d565e17..0000000 --- a/src/ria_toolkit_oss/datatypes/annotation.py +++ /dev/null @@ -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 diff --git a/src/ria_toolkit_oss/datatypes/recording.py b/src/ria_toolkit_oss/datatypes/recording.py deleted file mode 100644 index 11989f9..0000000 --- a/src/ria_toolkit_oss/datatypes/recording.py +++ /dev/null @@ -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 `_ - - :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 diff --git a/src/ria_toolkit_oss/io/recording.py b/src/ria_toolkit_oss/io/recording.py index 1a81a04..ec4b472 100644 --- a/src/ria_toolkit_oss/io/recording.py +++ b/src/ria_toolkit_oss/io/recording.py @@ -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 @@ -19,8 +19,8 @@ from quantiphy import Quantity from sigmf import SigMFFile, sigmffile from sigmf.utils import get_data_type_str -from ria_toolkit_oss.datatypes import Annotation -from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.data import Annotation +from ria_toolkit_oss.data.recording import Recording _BLUE_META_PREFIX = "META_" _BLUE_META_TAG_MAX_LEN = 60 @@ -64,7 +64,7 @@ def to_npy( """Write recording to ``.npy`` binary 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. :type filename: os.PathLike or str, optional :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. :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) @@ -161,7 +161,7 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording: try: raw_ann = np.load(f, allow_pickle=False) 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] except EOFError: @@ -198,7 +198,7 @@ def from_npy_legacy(file: os.PathLike | str) -> Recording: :raises IOError: If there is an issue encountered during the file reading process. :return: The recording, as initialized from the legacy ``.npy`` file. - :rtype: ria_toolkit_oss.datatypes.Recording + :rtype: ria_toolkit_oss.data.Recording **Examples:** @@ -270,7 +270,7 @@ def to_sigmf( The SigMF io format is defined by the `SigMF Specification Project `_ :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. :type filename: os.PathLike or str, optional :param path: The directory path to where the recording is to be saved. Defaults to recordings/. @@ -381,7 +381,7 @@ def from_sigmf(file: os.PathLike | str) -> Recording: :raises IOError: If there is an issue encountered during the file reading process. :return: The recording, as initialized from the SigMF files. - :rtype: ria_toolkit_oss.datatypes.Recording + :rtype: ria_toolkit_oss.data.Recording """ file = str(file) @@ -443,7 +443,7 @@ def to_wav( in the ICMT (comment) field for human readability. :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. :type filename: str, optional @@ -553,7 +553,7 @@ def from_wav(file: os.PathLike | str) -> Recording: :raises ValueError: If file is not stereo or has unsupported format. :return: The recording, as initialized from the WAV file. - :rtype: ria_toolkit_oss.datatypes.Recording + :rtype: ria_toolkit_oss.data.Recording """ import wave @@ -635,7 +635,7 @@ def to_blue( Commonly used with X-Midas and other RF/radar signal processing tools. :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. :type filename: str, optional @@ -792,7 +792,7 @@ def from_blue(file: os.PathLike | str) -> Recording: :raises ValueError: If file format is not valid or unsupported. :return: The recording, as initialized from the Blue file. - :rtype: ria_toolkit_oss.datatypes.Recording + :rtype: ria_toolkit_oss.data.Recording """ filename = str(file) if not filename.endswith(".blue"): @@ -917,7 +917,7 @@ def load_recording(file: os.PathLike) -> Recording: :raises ValueError: If the inferred file extension is not supported. :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 = extension.lstrip(".") diff --git a/src/ria_toolkit_oss/orchestration/executor.py b/src/ria_toolkit_oss/orchestration/executor.py index 1bdd4d8..b04e296 100644 --- a/src/ria_toolkit_oss/orchestration/executor.py +++ b/src/ria_toolkit_oss/orchestration/executor.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from pathlib import Path 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 .campaign import CampaignConfig, CaptureStep, TransmitterConfig diff --git a/src/ria_toolkit_oss/orchestration/labeler.py b/src/ria_toolkit_oss/orchestration/labeler.py index efcb97a..2e4def0 100644 --- a/src/ria_toolkit_oss/orchestration/labeler.py +++ b/src/ria_toolkit_oss/orchestration/labeler.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Optional -from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.data.recording import Recording from .campaign import CaptureStep diff --git a/src/ria_toolkit_oss/orchestration/qa.py b/src/ria_toolkit_oss/orchestration/qa.py index 8836e75..dfebc8a 100644 --- a/src/ria_toolkit_oss/orchestration/qa.py +++ b/src/ria_toolkit_oss/orchestration/qa.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field import numpy as np -from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.data.recording import Recording from .campaign import QAConfig diff --git a/src/ria_toolkit_oss/sdr/blade.py b/src/ria_toolkit_oss/sdr/blade.py index 576a91c..1560a0a 100644 --- a/src/ria_toolkit_oss/sdr/blade.py +++ b/src/ria_toolkit_oss/sdr/blade.py @@ -5,7 +5,7 @@ from typing import Optional import numpy as np 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 diff --git a/src/ria_toolkit_oss/sdr/hackrf.py b/src/ria_toolkit_oss/sdr/hackrf.py index 79e00a7..c9a510c 100644 --- a/src/ria_toolkit_oss/sdr/hackrf.py +++ b/src/ria_toolkit_oss/sdr/hackrf.py @@ -4,7 +4,7 @@ from typing import Optional 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.sdr import SDR, SDRParameterError diff --git a/src/ria_toolkit_oss/sdr/pluto.py b/src/ria_toolkit_oss/sdr/pluto.py index c78d36f..7fe2d46 100644 --- a/src/ria_toolkit_oss/sdr/pluto.py +++ b/src/ria_toolkit_oss/sdr/pluto.py @@ -7,7 +7,7 @@ from typing import Optional import adi 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 ( SDR, SDRError, diff --git a/src/ria_toolkit_oss/sdr/rtlsdr.py b/src/ria_toolkit_oss/sdr/rtlsdr.py index bae677a..50521d0 100644 --- a/src/ria_toolkit_oss/sdr/rtlsdr.py +++ b/src/ria_toolkit_oss/sdr/rtlsdr.py @@ -11,7 +11,7 @@ try: except ImportError as exc: # pragma: no cover - dependency provided by end user 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 diff --git a/src/ria_toolkit_oss/sdr/sdr.py b/src/ria_toolkit_oss/sdr/sdr.py index 443f8fa..33ef384 100644 --- a/src/ria_toolkit_oss/sdr/sdr.py +++ b/src/ria_toolkit_oss/sdr/sdr.py @@ -8,7 +8,7 @@ from typing import Optional import numpy as np import zmq -from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.data.recording import Recording class SDR(ABC): diff --git a/src/ria_toolkit_oss/sdr/usrp.py b/src/ria_toolkit_oss/sdr/usrp.py index 70bbc46..978127d 100644 --- a/src/ria_toolkit_oss/sdr/usrp.py +++ b/src/ria_toolkit_oss/sdr/usrp.py @@ -6,7 +6,7 @@ from typing import Optional import numpy as np 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 diff --git a/src/ria_toolkit_oss/signal/basic_signal_generator.py b/src/ria_toolkit_oss/signal/basic_signal_generator.py index 1f42b9a..5389d9a 100644 --- a/src/ria_toolkit_oss/signal/basic_signal_generator.py +++ b/src/ria_toolkit_oss/signal/basic_signal_generator.py @@ -11,7 +11,7 @@ from scipy.signal import butter from scipy.signal import chirp as sci_chirp from scipy.signal import hilbert, lfilter -from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.data.recording import Recording def sine( diff --git a/src/ria_toolkit_oss/signal/block_generator/generators/pam_generator.py b/src/ria_toolkit_oss/signal/block_generator/generators/pam_generator.py index 9de4d8c..51d94df 100644 --- a/src/ria_toolkit_oss/signal/block_generator/generators/pam_generator.py +++ b/src/ria_toolkit_oss/signal/block_generator/generators/pam_generator.py @@ -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 ( SignalGenerator, ) diff --git a/src/ria_toolkit_oss/signal/block_generator/generators/psk_generator.py b/src/ria_toolkit_oss/signal/block_generator/generators/psk_generator.py index 1525707..da41346 100644 --- a/src/ria_toolkit_oss/signal/block_generator/generators/psk_generator.py +++ b/src/ria_toolkit_oss/signal/block_generator/generators/psk_generator.py @@ -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 ( SignalGenerator, ) diff --git a/src/ria_toolkit_oss/signal/block_generator/generators/qam_generator.py b/src/ria_toolkit_oss/signal/block_generator/generators/qam_generator.py index 828d706..d3d1cd9 100644 --- a/src/ria_toolkit_oss/signal/block_generator/generators/qam_generator.py +++ b/src/ria_toolkit_oss/signal/block_generator/generators/qam_generator.py @@ -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 ( SignalGenerator, ) diff --git a/src/ria_toolkit_oss/signal/block_generator/recordable_block.py b/src/ria_toolkit_oss/signal/block_generator/recordable_block.py index 28f2f2b..4c932a3 100644 --- a/src/ria_toolkit_oss/signal/block_generator/recordable_block.py +++ b/src/ria_toolkit_oss/signal/block_generator/recordable_block.py @@ -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.block_generator.block import Block diff --git a/src/ria_toolkit_oss/signal/block_generator/recording_gen_wrapper.py b/src/ria_toolkit_oss/signal/block_generator/recording_gen_wrapper.py index ef449e4..436780c 100644 --- a/src/ria_toolkit_oss/signal/block_generator/recording_gen_wrapper.py +++ b/src/ria_toolkit_oss/signal/block_generator/recording_gen_wrapper.py @@ -4,7 +4,7 @@ from datetime import datetime import click 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.multirate.upsampling import Upsampling from ria_toolkit_oss.signal.block_generator.pulse_shaping.raised_cosine_filter import ( diff --git a/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py b/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py index 8a1e642..bbd7cb8 100644 --- a/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py +++ b/src/ria_toolkit_oss/signal/block_generator/source/recording_source.py @@ -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.recordable_block import RecordableBlock from ria_toolkit_oss.signal.block_generator.source_block import SourceBlock diff --git a/src/ria_toolkit_oss/signal/recordable.py b/src/ria_toolkit_oss/signal/recordable.py index d9c77f0..3f5d489 100644 --- a/src/ria_toolkit_oss/signal/recordable.py +++ b/src/ria_toolkit_oss/signal/recordable.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod -from ria_toolkit_oss.datatypes import Recording +from ria_toolkit_oss.data import Recording class Recordable(ABC): diff --git a/src/ria_toolkit_oss/transforms/iq_augmentations.py b/src/ria_toolkit_oss/transforms/iq_augmentations.py index 837d28d..7182722 100644 --- a/src/ria_toolkit_oss/transforms/iq_augmentations.py +++ b/src/ria_toolkit_oss/transforms/iq_augmentations.py @@ -11,7 +11,7 @@ from typing import Optional import numpy as np 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 # 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 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. :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 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]]) >>> 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 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. :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 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]]) >>> 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 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. :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 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]]) >>> 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 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. :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 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]]) >>> 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 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. :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 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]]) >>> 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 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. :type max_section_size: int, optional :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 `signal` is an array. If `signal` is a `Recording`, returns a `Recording` object with its `data` 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]]) >>> 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 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. :type bin_number: int, optional :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. If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing 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]]) >>> 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 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. :type max_section_size: int, optional :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` is an array. If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute 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]]) >>> 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 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. Default is None, but if user does not assign any bounds, the bounds become (random index, N-1). :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 starting point if `signal` is an array. If `signal` is a `Recording`, returns a `Recording` 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]]) >>> 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 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. :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). @@ -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 `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. - :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]]) >>> 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 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. :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 an array. If `signal` is a `Recording`, returns a `Recording` object with its `data` attribute containing 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]]) >>> new_rec = patch_shuffle(rec) diff --git a/src/ria_toolkit_oss/transforms/iq_impairments.py b/src/ria_toolkit_oss/transforms/iq_impairments.py index 34a6eb0..32cb7ec 100644 --- a/src/ria_toolkit_oss/transforms/iq_impairments.py +++ b/src/ria_toolkit_oss/transforms/iq_impairments.py @@ -16,7 +16,7 @@ import numpy as np from numpy.typing import ArrayLike 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 @@ -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 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. :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` 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]]) >>> 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 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. :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`, 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]]) >>> 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 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]``. Default is 0.5. :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`, 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]]) >>> 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 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 ``[-π, π]``. Default is π. :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`, 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]]) >>> 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 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. :type amplitude_imbalance: float, optional :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`, 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]]) >>> 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 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. :type up: int, optional :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`, 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]]) >>> new_rec = resample(rec, 2, 1) diff --git a/src/ria_toolkit_oss/view/recording.py b/src/ria_toolkit_oss/view/recording.py index b9c413b..7ff26fd 100644 --- a/src/ria_toolkit_oss/view/recording.py +++ b/src/ria_toolkit_oss/view/recording.py @@ -4,14 +4,14 @@ import scipy.signal as signal from plotly.graph_objs import Figure 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: """Create a spectrogram for the recording. :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. :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. :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. """ @@ -125,7 +125,7 @@ def frequency_spectrum(rec: Recording) -> Figure: """Create a frequency spectrum plot from the recording. :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. """ @@ -160,7 +160,7 @@ def constellation(rec: Recording) -> Figure: """Create a constellation plot from the recording. :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. """ diff --git a/src/ria_toolkit_oss/view/view_signal.py b/src/ria_toolkit_oss/view/view_signal.py index ded3c8c..a059e60 100644 --- a/src/ria_toolkit_oss/view/view_signal.py +++ b/src/ria_toolkit_oss/view/view_signal.py @@ -12,7 +12,7 @@ from scipy.fft import fft, fftshift from scipy.signal import spectrogram 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 ( COLORS, decimate, diff --git a/src/ria_toolkit_oss/view/view_signal_simple.py b/src/ria_toolkit_oss/view/view_signal_simple.py index 1b847ab..c770b5a 100644 --- a/src/ria_toolkit_oss/view/view_signal_simple.py +++ b/src/ria_toolkit_oss/view/view_signal_simple.py @@ -12,7 +12,7 @@ import numpy as np from scipy.fft import fft, fftshift 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 ( COLORS, decimate, diff --git a/src/ria_toolkit_oss/viz/recording.py b/src/ria_toolkit_oss/viz/recording.py index 82ad6a4..d29de6d 100644 --- a/src/ria_toolkit_oss/viz/recording.py +++ b/src/ria_toolkit_oss/viz/recording.py @@ -4,14 +4,14 @@ import scipy.signal as signal from plotly.graph_objs import Figure 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: """Create a spectrogram for the recording. :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. :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. :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. """ @@ -145,7 +145,7 @@ def frequency_spectrum(rec: Recording) -> Figure: """Create a frequency spectrum plot from the recording. :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. """ @@ -187,7 +187,7 @@ def constellation(rec: Recording) -> Figure: """Create a constellation plot from the recording. :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. """ @@ -222,7 +222,7 @@ def power_spectral_density(rec: Recording) -> Figure: """Create a Power Spectral Density (PSD) plot from the recording. :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. """ @@ -268,7 +268,7 @@ def fft_plot(rec: Recording) -> Figure: """Create an FFT magnitude plot from the recording. :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. """ @@ -312,7 +312,7 @@ def spectrogram_3d(rec: Recording) -> Figure: """Create a 3D spectrogram plot from the recording. :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. """ diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py index 4a8d6ac..daaf930 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -11,8 +11,8 @@ from ria_toolkit_oss.annotations import ( split_recording_annotations, threshold_qualifier, ) -from ria_toolkit_oss.datatypes import Annotation -from ria_toolkit_oss.datatypes.recording import Recording +from ria_toolkit_oss.data import Annotation +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_cli.ria_toolkit_oss.common import ( format_frequency, diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/combine.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/combine.py index e8f5e00..49d4e91 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/combine.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/combine.py @@ -7,7 +7,7 @@ from pathlib import Path import click 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_cli.ria_toolkit_oss.common import ( echo_progress, diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/common.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/common.py index 40ec3f6..843f531 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/common.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/common.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional import click 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 diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py index 370d27a..75ecb61 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/generate.py @@ -8,7 +8,7 @@ import numpy as np import yaml 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.continuous_modulation.fsk_modulator import ( FSKModulator, diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py index 4d131c1..805d674 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/transform.py @@ -8,7 +8,7 @@ from pathlib import Path 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.transforms import iq_augmentations, iq_impairments from ria_toolkit_oss_cli.ria_toolkit_oss.common import ( diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/transmit.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/transmit.py index 12c3aea..b2dcdd5 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/transmit.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/transmit.py @@ -6,7 +6,7 @@ import time 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 .common import ( diff --git a/tests/datatypes/test_annotation.py b/tests/datatypes/test_annotation.py index 94b7aaf..5dfdeb5 100644 --- a/tests/datatypes/test_annotation.py +++ b/tests/datatypes/test_annotation.py @@ -1,4 +1,4 @@ -from ria_toolkit_oss.datatypes import Annotation +from ria_toolkit_oss.data import Annotation def test_annotation_creation(): diff --git a/tests/datatypes/test_recording.py b/tests/datatypes/test_recording.py index 452ec6b..7696ebc 100644 --- a/tests/datatypes/test_recording.py +++ b/tests/datatypes/test_recording.py @@ -3,8 +3,8 @@ from typing import Iterable import numpy as np import pytest -from ria_toolkit_oss.datatypes import Annotation, Recording -from ria_toolkit_oss.datatypes.recording import generate_recording_id +from ria_toolkit_oss.data import Annotation, Recording +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]] diff --git a/tests/io/test_recording_io.py b/tests/io/test_recording_io.py index e7e14ae..16383c2 100644 --- a/tests/io/test_recording_io.py +++ b/tests/io/test_recording_io.py @@ -1,6 +1,6 @@ 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_npy, from_sigmf, diff --git a/tests/orchestration/test_labeler.py b/tests/orchestration/test_labeler.py index 37b370d..f305bed 100644 --- a/tests/orchestration/test_labeler.py +++ b/tests/orchestration/test_labeler.py @@ -5,7 +5,7 @@ import time import numpy as np 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.labeler import build_output_filename, label_recording diff --git a/tests/orchestration/test_qa.py b/tests/orchestration/test_qa.py index dfc6095..00fb648 100644 --- a/tests/orchestration/test_qa.py +++ b/tests/orchestration/test_qa.py @@ -3,7 +3,7 @@ import numpy as np 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.qa import QAResult, check_recording, estimate_snr_db diff --git a/tests/ria_toolkit_oss_cli/test.combine.py b/tests/ria_toolkit_oss_cli/test.combine.py index b6f7d8b..5100202 100644 --- a/tests/ria_toolkit_oss_cli/test.combine.py +++ b/tests/ria_toolkit_oss_cli/test.combine.py @@ -7,7 +7,7 @@ import numpy as np import pytest 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_cli.cli import cli diff --git a/tests/ria_toolkit_oss_cli/test_split.py b/tests/ria_toolkit_oss_cli/test_split.py index d2487c4..9471d6f 100644 --- a/tests/ria_toolkit_oss_cli/test_split.py +++ b/tests/ria_toolkit_oss_cli/test_split.py @@ -7,7 +7,7 @@ import numpy as np import pytest 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_sigmf from ria_toolkit_oss_cli.cli import cli diff --git a/tests/transforms/test_iq_augmentations.py b/tests/transforms/test_iq_augmentations.py index 7a12024..a0ecf93 100644 --- a/tests/transforms/test_iq_augmentations.py +++ b/tests/transforms/test_iq_augmentations.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from ria_toolkit_oss.datatypes import Recording +from ria_toolkit_oss.data import Recording from ria_toolkit_oss.transforms import iq_augmentations TEST_DATA1 = [[1 + 1j, 2 + 2j, 3 + 3j, 4 + 4j]] diff --git a/tests/transforms/test_iq_impairments.py b/tests/transforms/test_iq_impairments.py index a5c5a4b..aa49772 100644 --- a/tests/transforms/test_iq_impairments.py +++ b/tests/transforms/test_iq_impairments.py @@ -13,7 +13,7 @@ Bugs/issues identified during review: import numpy as np import pytest -from ria_toolkit_oss.datatypes import Recording +from ria_toolkit_oss.data import Recording from ria_toolkit_oss.transforms import iq_impairments # --------------------------------------------------------------------------- From 39d5d74d6adb5470fd7f4f22349d9fa90661682d Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 21 Apr 2026 15:03:57 -0400 Subject: [PATCH 08/28] large memory fix --- src/ria_toolkit_oss/orchestration/tx_executor.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ria_toolkit_oss/orchestration/tx_executor.py b/src/ria_toolkit_oss/orchestration/tx_executor.py index 84e200b..2d9e1c1 100644 --- a/src/ria_toolkit_oss/orchestration/tx_executor.py +++ b/src/ria_toolkit_oss/orchestration/tx_executor.py @@ -70,6 +70,12 @@ _MOD_TABLE: dict[str, tuple[int, str]] = { _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. @@ -145,7 +151,12 @@ class TxExecutor: ) num_samples = int(duration * sample_rate) - signal = self._synthesise(modulation, sps, num_samples, filter_type, rolloff) + + # 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: From 34b67c0c17c189137b879f9a4930b6b24949e3f7 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 21 Apr 2026 15:56:04 -0400 Subject: [PATCH 09/28] campaign loop support --- src/ria_toolkit_oss/orchestration/campaign.py | 11 ++-- src/ria_toolkit_oss/orchestration/executor.py | 50 +++++++++++-------- .../orchestration/tx_executor.py | 13 ++++- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/ria_toolkit_oss/orchestration/campaign.py b/src/ria_toolkit_oss/orchestration/campaign.py index 5fe1128..6f9be35 100644 --- a/src/ria_toolkit_oss/orchestration/campaign.py +++ b/src/ria_toolkit_oss/orchestration/campaign.py @@ -297,6 +297,7 @@ class CampaignConfig: qa: QAConfig = field(default_factory=QAConfig) output: OutputConfig = field(default_factory=OutputConfig) mode: str = "controlled_testbed" + loops: int = 1 # repeat full schedule this many times; labels get _run{N:02d} suffix # --------------------------------------------------------------------------- # Loaders @@ -324,6 +325,7 @@ class CampaignConfig: return cls( name=safe_name, mode=str(campaign_meta.get("mode", "controlled_testbed")), + loops=max(1, int(campaign_meta.get("loops", 1))), recorder=RecorderConfig.from_dict(raw["recorder"]), transmitters=transmitters, qa=QAConfig.from_dict(raw.get("qa", {})), @@ -388,6 +390,7 @@ class CampaignConfig: return cls( name=safe_name, mode=str(campaign_meta.get("mode", "controlled_testbed")), + loops=max(1, int(campaign_meta.get("loops", 1))), recorder=RecorderConfig.from_dict(raw["recorder"]), transmitters=transmitters, qa=QAConfig.from_dict(raw.get("qa", {})), @@ -490,9 +493,9 @@ class CampaignConfig: ) def total_capture_time_s(self) -> float: - """Sum of all step durations across all transmitters.""" - return sum(step.duration for tx in self.transmitters for step in tx.schedule) + """Sum of all step durations across all transmitters and loops.""" + return sum(step.duration for tx in self.transmitters for step in tx.schedule) * self.loops def total_steps(self) -> int: - """Total number of capture steps across all transmitters.""" - return sum(len(tx.schedule) for tx in self.transmitters) + """Total number of capture steps across all transmitters and loops.""" + return sum(len(tx.schedule) for tx in self.transmitters) * self.loops diff --git a/src/ria_toolkit_oss/orchestration/executor.py b/src/ria_toolkit_oss/orchestration/executor.py index 66c5273..222b348 100644 --- a/src/ria_toolkit_oss/orchestration/executor.py +++ b/src/ria_toolkit_oss/orchestration/executor.py @@ -7,7 +7,7 @@ import logging import subprocess import threading import time -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Callable, Optional @@ -236,10 +236,12 @@ class CampaignExecutor: """ result = CampaignResult(campaign_name=self.config.name) + loops = self.config.loops logger.info( f"Starting campaign '{self.config.name}': " - f"{self.config.total_steps()} steps, " - f"~{self.config.total_capture_time_s():.0f}s capture time" + f"{self.config.total_steps()} steps" + + (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() @@ -248,26 +250,32 @@ class CampaignExecutor: total = self.config.total_steps() step_index = 0 - for transmitter in self.config.transmitters: - logger.info(f"Transmitter: {transmitter.id} ({len(transmitter.schedule)} steps)") - for step in transmitter.schedule: - step_result = self._execute_step(transmitter, step) - result.steps.append(step_result) - step_index += 1 + for loop_idx in range(loops): + if loops > 1: + logger.info(f"Loop {loop_idx + 1}/{loops}") + for transmitter in self.config.transmitters: + logger.info(f"Transmitter: {transmitter.id} ({len(transmitter.schedule)} steps)") + 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: - self.progress_cb(step_index, total, step_result) + if self.progress_cb: + self.progress_cb(step_index, total, step_result) - if step_result.error: - logger.warning(f"Step '{step.label}' error: {step_result.error}") - elif step_result.qa.flagged: - logger.warning(f"Step '{step.label}' flagged for review: " + "; ".join(step_result.qa.issues)) - else: - logger.info( - f"Step '{step.label}' OK " - f"(SNR {step_result.qa.snr_db:.1f} dB, " - f"{step_result.qa.duration_s:.1f}s)" - ) + if step_result.error: + logger.warning(f"Step '{looped_step.label}' error: {step_result.error}") + elif step_result.qa.flagged: + logger.warning( + f"Step '{looped_step.label}' flagged for review: " + "; ".join(step_result.qa.issues) + ) + else: + logger.info( + f"Step '{looped_step.label}' OK " + f"(SNR {step_result.qa.snr_db:.1f} dB, " + f"{step_result.qa.duration_s:.1f}s)" + ) finally: self._close_sdr() self._close_remote_tx_controllers() diff --git a/src/ria_toolkit_oss/orchestration/tx_executor.py b/src/ria_toolkit_oss/orchestration/tx_executor.py index 2d9e1c1..a3c9bdc 100644 --- a/src/ria_toolkit_oss/orchestration/tx_executor.py +++ b/src/ria_toolkit_oss/orchestration/tx_executor.py @@ -112,6 +112,7 @@ class TxExecutor: 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 @@ -119,10 +120,18 @@ class TxExecutor: self._init_sdr(sample_rate, center_freq) try: - for step in schedule: + for loop_idx in range(loops): if self.stop_event.is_set(): break - self._execute_step(step, modulation, sps, symbol_rate, filter_type, rolloff) + 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() From 53e8e5adb630ecfa62cc99f915aa127a22459290 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 21 Apr 2026 16:40:49 -0400 Subject: [PATCH 10/28] chunk timeout error --- src/ria_toolkit_oss/agent/legacy_executor.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ria_toolkit_oss/agent/legacy_executor.py b/src/ria_toolkit_oss/agent/legacy_executor.py index 91221e1..76b55c5 100644 --- a/src/ria_toolkit_oss/agent/legacy_executor.py +++ b/src/ria_toolkit_oss/agent/legacy_executor.py @@ -68,7 +68,7 @@ _HEARTBEAT_INTERVAL = 30 # seconds between heartbeats _POLL_TIMEOUT = 30 # server-side long-poll duration _POLL_CLIENT_TIMEOUT = 40 # client read timeout — slightly longer than server _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 _CAPTURE_SAMPLES = 4096 # IQ samples per inference window _IDLE_LABELS = frozenset({"noise", "idle", "no_signal", "unknown_protocol", "background"}) @@ -659,13 +659,16 @@ class NodeAgent: base_url = f"{self.hub_url}/datasets/upload" steps = (result.get("steps") if isinstance(result, dict) else getattr(result, "steps", None)) or [] + campaign_name: str = getattr(config, "name", None) or "" for step in steps: output_path: str | None = getattr(step, "output_path", None) if not output_path: continue device_id: str = getattr(step, "transmitter_id", "") or "" 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 = { "filename": filename, "repo_owner": repo_owner, @@ -751,7 +754,7 @@ class NodeAgent: headers=headers, files={"file": (filename, chunk, "application/octet-stream")}, data={**metadata, "upload_id": upload_id, "chunk_index": i, "total_chunks": total_chunks}, - timeout=120, + timeout=300, verify=verify, ) if not resp.ok: From c9b19949adba23ef71371e599e4922a535c779f5 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 21 Apr 2026 17:11:16 -0400 Subject: [PATCH 11/28] timeout chunk improvements --- src/ria_toolkit_oss/agent/legacy_executor.py | 6 ++++-- src/ria_toolkit_oss/orchestration/campaign.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ria_toolkit_oss/agent/legacy_executor.py b/src/ria_toolkit_oss/agent/legacy_executor.py index 76b55c5..d4a302a 100644 --- a/src/ria_toolkit_oss/agent/legacy_executor.py +++ b/src/ria_toolkit_oss/agent/legacy_executor.py @@ -659,7 +659,9 @@ class NodeAgent: base_url = f"{self.hub_url}/datasets/upload" steps = (result.get("steps") if isinstance(result, dict) else getattr(result, "steps", None)) or [] - campaign_name: str = getattr(config, "name", 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: output_path: str | None = getattr(step, "output_path", None) if not output_path: @@ -754,7 +756,7 @@ class NodeAgent: headers=headers, files={"file": (filename, chunk, "application/octet-stream")}, data={**metadata, "upload_id": upload_id, "chunk_index": i, "total_chunks": total_chunks}, - timeout=300, + timeout=(30, None), # 30s connect, no read timeout — server may take minutes on final chunk verify=verify, ) if not resp.ok: diff --git a/src/ria_toolkit_oss/orchestration/campaign.py b/src/ria_toolkit_oss/orchestration/campaign.py index 6f9be35..105cc40 100644 --- a/src/ria_toolkit_oss/orchestration/campaign.py +++ b/src/ria_toolkit_oss/orchestration/campaign.py @@ -276,6 +276,7 @@ class OutputConfig: path: str = "recordings" device_id: Optional[str] = None # for device-profile campaigns repo: Optional[str] = None + folder: Optional[str] = None # repo subfolder: None = use campaign name, "" = no subfolder, str = custom @classmethod def from_dict(cls, d: dict) -> "OutputConfig": @@ -284,6 +285,7 @@ class OutputConfig: path=str(d.get("path", "recordings")), device_id=d.get("device_id"), repo=d.get("repo"), + folder=d.get("folder"), ) From 07c72294f5eb137b9a3848b57a09cacdf86da679 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 22 Apr 2026 10:10:25 -0400 Subject: [PATCH 12/28] removing orchestrator references --- src/ria_toolkit_oss/server/app.py | 8 ++-- src/ria_toolkit_oss/server/models.py | 2 +- .../routers/{orchestrator.py => conductor.py} | 2 +- .../ria_toolkit_oss/serve.py | 6 +-- tests/server/test_server.py | 40 +++++++++---------- 5 files changed, 29 insertions(+), 29 deletions(-) rename src/ria_toolkit_oss/server/routers/{orchestrator.py => conductor.py} (98%) diff --git a/src/ria_toolkit_oss/server/app.py b/src/ria_toolkit_oss/server/app.py index 5e4c58b..42799a6 100644 --- a/src/ria_toolkit_oss/server/app.py +++ b/src/ria_toolkit_oss/server/app.py @@ -3,7 +3,7 @@ from fastapi import Depends, FastAPI from .auth import require_api_key -from .routers import inference, orchestrator +from .routers import conductor, inference 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.include_router( - orchestrator.router, - prefix="/orchestrator", - tags=["Orchestrator"], + conductor.router, + prefix="/conductor", + tags=["Conductor"], dependencies=[Depends(require_api_key)], ) app.include_router( diff --git a/src/ria_toolkit_oss/server/models.py b/src/ria_toolkit_oss/server/models.py index e2ba450..9fd88d9 100644 --- a/src/ria_toolkit_oss/server/models.py +++ b/src/ria_toolkit_oss/server/models.py @@ -7,7 +7,7 @@ from pathlib import Path from pydantic import BaseModel, field_validator # --------------------------------------------------------------------------- -# Orchestrator +# Conductor # --------------------------------------------------------------------------- diff --git a/src/ria_toolkit_oss/server/routers/orchestrator.py b/src/ria_toolkit_oss/server/routers/conductor.py similarity index 98% rename from src/ria_toolkit_oss/server/routers/orchestrator.py rename to src/ria_toolkit_oss/server/routers/conductor.py index dfc01af..7ec7d9d 100644 --- a/src/ria_toolkit_oss/server/routers/orchestrator.py +++ b/src/ria_toolkit_oss/server/routers/conductor.py @@ -1,4 +1,4 @@ -"""Orchestrator routes: campaign deployment, status, and cancellation.""" +"""Conductor routes: campaign deployment, status, and cancellation.""" from __future__ import annotations diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/serve.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/serve.py index 21beb6e..5d541b4 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/serve.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/serve.py @@ -23,9 +23,9 @@ def serve(host: str, port: int, api_key: str, log_level: str): \b Endpoints: - POST /orchestrator/deploy - GET /orchestrator/status/{campaign_id} - POST /orchestrator/cancel/{campaign_id} + POST /conductor/deploy + GET /conductor/status/{campaign_id} + POST /conductor/cancel/{campaign_id} POST /inference/load POST /inference/start POST /inference/stop diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 3e9c8db..e3345ae 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1,6 +1,6 @@ """Tests for the RT-OSS HTTP server. -Covers: auth, inference lifecycle (without SDR/ONNX hardware), orchestrator +Covers: auth, inference lifecycle (without SDR/ONNX hardware), conductor lifecycle (with mocked executor), and state helpers. ``start_inference`` and ``_inference_loop`` require real SDR hardware and an @@ -286,17 +286,17 @@ class TestInferenceStop: # --------------------------------------------------------------------------- -# POST /orchestrator/deploy +# POST /conductor/deploy # --------------------------------------------------------------------------- -class TestOrchestratorDeploy: +class TestConductorDeploy: def test_deploy_422_on_invalid_config(self, client): with patch( - "ria_toolkit_oss.server.routers.orchestrator.CampaignConfig.from_dict", + "ria_toolkit_oss.server.routers.conductor.CampaignConfig.from_dict", side_effect=ValueError("missing required field 'name'"), ): - resp = client.post("/orchestrator/deploy", json={"config": {}}) + resp = client.post("/conductor/deploy", json={"config": {}}) assert resp.status_code == 422 def test_deploy_returns_campaign_id(self, client): @@ -307,10 +307,10 @@ class TestOrchestratorDeploy: mock_executor.return_value.run.return_value = MagicMock(to_dict=lambda: {}) with ( - patch("ria_toolkit_oss.server.routers.orchestrator.CampaignConfig.from_dict", return_value=mock_cfg), - patch("ria_toolkit_oss.server.routers.orchestrator.CampaignExecutor", mock_executor), + patch("ria_toolkit_oss.server.routers.conductor.CampaignConfig.from_dict", return_value=mock_cfg), + patch("ria_toolkit_oss.server.routers.conductor.CampaignExecutor", mock_executor), ): - resp = client.post("/orchestrator/deploy", json={"config": {"name": "test_campaign"}}) + resp = client.post("/conductor/deploy", json={"config": {"name": "test_campaign"}}) assert resp.status_code == 200 body = resp.json() @@ -325,23 +325,23 @@ class TestOrchestratorDeploy: mock_executor.return_value.run.return_value = MagicMock(to_dict=lambda: {}) with ( - patch("ria_toolkit_oss.server.routers.orchestrator.CampaignConfig.from_dict", return_value=mock_cfg), - patch("ria_toolkit_oss.server.routers.orchestrator.CampaignExecutor", mock_executor), + patch("ria_toolkit_oss.server.routers.conductor.CampaignConfig.from_dict", return_value=mock_cfg), + patch("ria_toolkit_oss.server.routers.conductor.CampaignExecutor", mock_executor), ): - resp = client.post("/orchestrator/deploy", json={"config": {}}) + resp = client.post("/conductor/deploy", json={"config": {}}) campaign_id = resp.json()["campaign_id"] assert state_module._campaigns.get(campaign_id) is not None # --------------------------------------------------------------------------- -# GET /orchestrator/status/{campaign_id} +# GET /conductor/status/{campaign_id} # --------------------------------------------------------------------------- -class TestOrchestratorStatus: +class TestConductorStatus: def test_status_404_for_unknown_id(self, client): - resp = client.get("/orchestrator/status/nonexistent-id") + resp = client.get("/conductor/status/nonexistent-id") assert resp.status_code == 404 def test_status_returns_campaign_state(self, client): @@ -357,7 +357,7 @@ class TestOrchestratorStatus: ) state_module._campaigns["abc-123"] = state - resp = client.get("/orchestrator/status/abc-123") + resp = client.get("/conductor/status/abc-123") assert resp.status_code == 200 body = resp.json() assert body["campaign_id"] == "abc-123" @@ -367,13 +367,13 @@ class TestOrchestratorStatus: # --------------------------------------------------------------------------- -# POST /orchestrator/cancel/{campaign_id} +# POST /conductor/cancel/{campaign_id} # --------------------------------------------------------------------------- -class TestOrchestratorCancel: +class TestConductorCancel: def test_cancel_404_for_unknown_id(self, client): - resp = client.post("/orchestrator/cancel/no-such-id") + resp = client.post("/conductor/cancel/no-such-id") assert resp.status_code == 404 def test_cancel_sets_cancel_event(self, client): @@ -387,7 +387,7 @@ class TestOrchestratorCancel: ) state_module._campaigns["camp-to-cancel"] = state - resp = client.post("/orchestrator/cancel/camp-to-cancel") + resp = client.post("/conductor/cancel/camp-to-cancel") assert resp.status_code == 200 assert resp.json()["cancelled"] is True assert cancel_event.is_set() @@ -403,7 +403,7 @@ class TestOrchestratorCancel: ) state_module._campaigns["done"] = state - resp = client.post("/orchestrator/cancel/done") + resp = client.post("/conductor/cancel/done") assert resp.status_code == 200 assert resp.json()["cancelled"] is False assert not cancel_event.is_set() From 2c1fba75dada535ed32c181dffe9b42ac45d0161 Mon Sep 17 00:00:00 2001 From: gillian Date: Fri, 24 Apr 2026 14:34:11 -0400 Subject: [PATCH 13/28] docs: improve getting_started and installation readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/source/_static/custom.css | 17 +- docs/source/intro/getting_started.rst | 289 ++++++++++++-------------- docs/source/intro/installation.rst | 36 +++- 3 files changed, 177 insertions(+), 165 deletions(-) diff --git a/docs/source/_static/custom.css b/docs/source/_static/custom.css index 40b2823..da14878 100644 --- a/docs/source/_static/custom.css +++ b/docs/source/_static/custom.css @@ -1,6 +1,7 @@ /* 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 a { color: #ffffff !important; font-size: 22px !important; } @@ -22,8 +23,20 @@ .rst-content .admonition.warning p { color: #ffffff !important; } -.rst-content h4 { color: #404040; } +.rst-content h4 { color: #cccccc; } .highlight * { color: #ffffff !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; +} diff --git a/docs/source/intro/getting_started.rst b/docs/source/intro/getting_started.rst index c2386ee..4e722e2 100644 --- a/docs/source/intro/getting_started.rst +++ b/docs/source/intro/getting_started.rst @@ -6,10 +6,10 @@ This is a practical reference for the ``ria`` CLI from ``ria-toolkit-oss``. **Scope of this guide:** -* Installation and setup -* End-to-end CLI workflows -* Full command reference for CLI features -* Brief scripting section +* **Installation and SDR driver prerequisites** — how to install RIA Toolkit OSS and configure the system drivers your hardware requires +* **End-to-end CLI workflow** — a step-by-step walkthrough from hardware discovery through capture, annotation, and processing +* **Full command reference** — options, flags, and examples for every ``ria`` command +* **Python scripting preview** — using the toolkit API directly without the CLI **Official resources:** @@ -18,76 +18,15 @@ This is a practical reference for the ``ria`` CLI from ``ria-toolkit-oss``. * `PyPI package `_ * `RIA Hub Conda package `_ -.. contents:: Contents - :local: - :depth: 2 - :backlinks: none - 1) Installation and Setup ========================== -1.1 Installation with Conda ----------------------------- - -RIA Toolkit OSS is available as a Conda package on RIA Hub. This is typically the easiest -path when using SDR tooling that depends on native/system libraries. - -.. code-block:: bash - - conda update --force conda - conda config --add channels https://riahub.ai/api/packages/qoherent/conda - conda activate base - conda install ria-toolkit-oss - -Verify: - -.. code-block:: bash - - conda list | grep ria-toolkit-oss +Before using the ``ria`` CLI, follow the :doc:`Installation ` guide to +install RIA Toolkit OSS and any SDR drivers required for your hardware. -1.2 Installation with pip --------------------------- - -Use pip unless you specifically need to edit toolkit source. - -.. code-block:: bash - - python3 -m venv .venv - source .venv/bin/activate - pip install --upgrade pip - pip install ria-toolkit-oss - -Verify CLI entrypoint: - -.. code-block:: bash - - ria --help - -``pyproject.toml`` defines two script entry points: - -* ``ria`` -* ``ria-tools`` - -Both point to the same CLI module (``ria_toolkit_oss_cli.cli:cli``). - - -1.3 Optional install from source ----------------------------------- - -Use this for local development or testing unreleased changes. - -.. code-block:: bash - - git clone https://riahub.ai/qoherent/ria-toolkit-oss.git - cd ria-toolkit-oss - python3 -m venv .venv - source .venv/bin/activate - pip install -e . - - -1.4 SDR driver prerequisites +1.1 SDR driver prerequisites ----------------------------- 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): -* USRP: UHD drivers -* Pluto: libiio / IIO utilities -* BladeRF: libbladeRF -* HackRF: libhackrf -* RTL-SDR: librtlsdr +.. 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 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:** -* ``discover`` -* ``init`` -* ``capture`` -* ``view`` -* ``annotate`` (group) -* ``convert`` -* ``split`` -* ``combine`` -* ``generate`` (group) -* ``transform`` (group) -* ``transmit`` -* ``synth`` (alias of ``generate`` in command bindings) +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Command + - Purpose + * - :ref:`discover ` + - Probe SDR drivers and enumerate attached hardware + * - :ref:`init ` + - Create and manage user metadata defaults + * - :ref:`capture ` + - Record IQ samples from a connected SDR + * - :ref:`view ` + - Generate visualizations from IQ files + * - :ref:`annotate ` + - Label signal regions manually or with auto-detection (group) + * - :ref:`convert ` + - Convert between IQ file formats + * - :ref:`split ` + - Split, trim, or extract recordings + * - :ref:`combine ` + - Merge multiple recordings by concatenation or addition + * - :ref:`generate / synth ` + - Generate synthetic IQ signals (group; ``synth`` is an alias) + * - :ref:`transform ` + - Apply augmentations or impairments to recordings (group) + * - :ref:`transmit ` + - Transmit IQ through a TX-capable SDR 3) Quick End-to-End Workflow @@ -158,10 +124,8 @@ provenance fields. .. code-block:: bash ria init - # or non-interactive - ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" - # show config - ria init --show + ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" # non-interactive + ria init --show # show config 3.3 Capture IQ @@ -227,13 +191,14 @@ Replay recorded or synthesized IQ through a transmit-capable SDR. .. code-block:: bash ria transmit -d hackrf -f 2.44G -s 2e6 --input capture.sigmf-data - # or generated waveform - ria transmit -d hackrf --generate lfm --continuous + ria transmit -d hackrf --generate lfm --continuous # generated waveform 4) Command Reference ===================== +.. _cmd-discover: + 4.1 ``discover`` ----------------- @@ -263,6 +228,8 @@ Replay recorded or synthesized IQ through a transmit-capable SDR. hidden in default output. +.. _cmd-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). +.. _cmd-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 +.. _cmd-view: + 4.4 ``view`` ------------- @@ -444,6 +415,8 @@ Device selection (``--device``) is optional if only one device is detected. Exac ria view old.npy --legacy --type simple +.. _cmd-annotate: + 4.5 ``annotate`` group ----------------------- @@ -459,8 +432,30 @@ Device selection (``--device``) is optional if only one device is detected. Exac ria annotate ... -**Subcommands:** ``list``, ``add``, ``remove``, ``clear``, ``energy``, ``cusum``, -``threshold``, ``separate`` +**Subcommands:** + +.. 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:** @@ -590,6 +585,8 @@ annotations. ria annotate separate capture.sigmf-data --indices 0,1 --verbose +.. _cmd-convert: + 4.6 ``convert`` ---------------- @@ -629,6 +626,8 @@ inferred from the output file extension. ria convert old.npy --format sigmf --legacy --overwrite +.. _cmd-split: + 4.7 ``split`` -------------- @@ -670,6 +669,8 @@ Choose exactly one operation per invocation: ria split annotated.sigmf-data --extract-annotations --annotation-label payload +.. _cmd-combine: + 4.8 ``combine`` ---------------- @@ -717,6 +718,8 @@ Choose exactly one operation per invocation: 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) --------------------------------------------- @@ -728,15 +731,34 @@ Choose exactly one operation per invocation: ``ria synth ...`` is an alias for ``ria generate ...``. -**Shape:** +**Usage:** .. code-block:: bash ria generate [subcommand options] [common options] **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:** @@ -760,22 +782,16 @@ Multipath and IQ imbalance flags apply impairment-style post-processing during g Options: ``--frequency``, ``--amplitude``, ``--phase`` -Clean sinusoidal calibration/reference source. - ``noise`` ~~~~~~~~~~ Options: ``--noise-type {gaussian,uniform}``, ``--power`` -Baseline noise floor data or controlled additive-noise synthesis. - ``chirp`` ~~~~~~~~~~ Options: ``--bandwidth`` (required), ``--period`` (required), ``--type {up,down,up_down}`` -Sweep-based radar/sonar-style signals and bandwidth occupancy tests. - ``square`` ~~~~~~~~~~~ @@ -826,6 +842,8 @@ symbol transition sharpness). ria synth psk -s 2e6 -r 100e3 -M 8 -N 8000 -o psk8.npy +.. _cmd-transform: + 4.10 ``transform`` group ------------------------- @@ -834,7 +852,7 @@ symbol transition sharpness). * Apply algorithmic transforms to existing recordings. * Run reusable augmentations/impairments for dataset diversity and robustness testing. -**Shape:** +**Usage:** .. code-block:: bash @@ -895,6 +913,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 +.. _cmd-transmit: + 4.11 ``transmit`` ------------------ @@ -993,17 +1013,7 @@ experiment-specific fields on the CLI. ria generate noise --config generate.yaml -6) Practical Tips and Safety -============================= - -* Use ``ria discover`` before capture/transmit sessions. -* Keep TX gain conservative first; validate with attenuators/dummy loads when needed. -* Prefer SigMF for interoperable metadata and annotations. -* For long workflows, keep outputs organized by campaign directories and consistent prefixes. -* Use ``--verbose`` when debugging device init or driver issues. - - -7) Version Notes +6) Version Notes ================= These notes are based on the current implementation and should be re-validated against future @@ -1016,11 +1026,12 @@ releases. 3. Multiple non-CLI modules still import ``utils.*``, which can create runtime dependency coupling when using only ``ria-toolkit-oss`` in isolation. -If you observe unexpected import errors after install, check the package version and -changelog, then test ``ria --help`` in a clean virtual environment. +.. tip:: + 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: @@ -1037,47 +1048,3 @@ For quick non-CLI use: to_sigmf(imp, filename="capture_awgn", path=".") You can also call annotation algorithms and block-generator primitives from Python directly. - - -9) Cheat Sheet -=============== - -.. code-block:: bash - - # Install - pip install ria-toolkit-oss - - # Discover - ria discover -v - - # Init defaults - ria init --author "Jane" --project "rf1" --location "Lab-A" - - # Capture - ria capture -d pluto -f 2.44G -s 2e6 -n 1000000 -o cap.sigmf-data - - # View - ria view cap.sigmf-data --type simple - - # Annotate - ria annotate energy cap.sigmf-data --threshold 1.2 - ria annotate list cap.sigmf-data --verbose - - # Convert - ria convert cap.sigmf-data cap.npy - - # Split - ria split cap.sigmf-data --split-every 100000 --output-dir chunks - - # Combine - ria combine chunks/a.npy chunks/b.npy merged.npy - - # Generate - ria generate qam -s 2e6 -r 100e3 -M 16 -N 5000 -o qam16.npy - - # Transform - ria transform augment channel_swap cap.npy - ria transform impair add_awgn_to_signal cap.npy --params snr=10 - - # Transmit - ria transmit -d hackrf --input cap.sigmf-data -f 2.44G -s 2e6 diff --git a/docs/source/intro/installation.rst b/docs/source/intro/installation.rst index 584b13f..309c294 100644 --- a/docs/source/intro/installation.rst +++ b/docs/source/intro/installation.rst @@ -4,7 +4,26 @@ Installation 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 -:ref:`SDR Guides ` section of the documentation for addition setup instructions. +:ref:`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 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 venv\Scripts\activate -2. Install RIA Toolkit OSS from PyPI with pip: +2. Upgrade pip and install RIA Toolkit OSS: .. code-block:: bash + pip install --upgrade pip 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. 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. @@ -119,3 +148,6 @@ Follow the steps below to install RIA Toolkit OSS from source: .. code-block:: bash pip install . + + For local development, use ``pip install -e .`` instead to install in editable mode + so local changes take effect immediately without reinstalling. From 9a304faa00b3aaddb79149845ec7e8a6af55946b Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 28 Apr 2026 11:27:47 -0400 Subject: [PATCH 14/28] docs: enhance getting started guide with example output and image reference --- docs/source/intro/getting_started.rst | 6 ++++++ src/ria_toolkit_oss/view/tools.py | 21 ++++++++++----------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/source/intro/getting_started.rst b/docs/source/intro/getting_started.rst index 43b0724..b07f1a3 100644 --- a/docs/source/intro/getting_started.rst +++ b/docs/source/intro/getting_started.rst @@ -413,6 +413,12 @@ 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 --show --no-save ria view old.npy --legacy --type simple + ria view recordings\qam64_35.npy --type simple + +.. figure:: ../images/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`` .. _cmd-annotate: diff --git a/src/ria_toolkit_oss/view/tools.py b/src/ria_toolkit_oss/view/tools.py index 49ce451..974219a 100644 --- a/src/ria_toolkit_oss/view/tools.py +++ b/src/ria_toolkit_oss/view/tools.py @@ -32,16 +32,15 @@ def extract_metadata_fields(metadata): def set_path(output_path): - split_path = output_path.split("/") - - if len(split_path) == 1: - folder = "images" - file = split_path[0] - elif len(split_path) > 2: - file = split_path[-1] - folder = "/".join(split_path[:-1]) + path = pathlib.Path(output_path) + + # If only filename provided (no directory), use default 'images' folder + if len(path.parts) == 1: + folder = pathlib.Path("images") + file = path.name else: - folder, file = split_path + folder = path.parent + file = path.name split_file = file.split(".") if len(split_file) == 2: @@ -53,5 +52,5 @@ def set_path(output_path): extension = "png" file = file + ".png" - pathlib.Path(folder).mkdir(parents=True, exist_ok=True) - return "/".join([folder, file]), extension + folder.mkdir(parents=True, exist_ok=True) + return str(folder / file), extension From 4c94f6ae949499616a806ccfce7c1ba372fca6dc Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 28 Apr 2026 12:49:43 -0400 Subject: [PATCH 15/28] Changed datasets to data to match utils --- .../ria_toolkit_oss/{datatypes => data}/radio_datasets.rst | 0 .../ria_toolkit_oss.data.datasets.license.rst} | 0 .../ria_toolkit_oss.data.rst} | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename docs/source/ria_toolkit_oss/{datatypes => data}/radio_datasets.rst (100%) rename docs/source/ria_toolkit_oss/{datatypes/ria_toolkit_oss.datatypes.datasets.license.rst => data/ria_toolkit_oss.data.datasets.license.rst} (100%) rename docs/source/ria_toolkit_oss/{datatypes/ria_toolkit_oss.datatypes.rst => data/ria_toolkit_oss.data.rst} (78%) diff --git a/docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst b/docs/source/ria_toolkit_oss/data/radio_datasets.rst similarity index 100% rename from docs/source/ria_toolkit_oss/datatypes/radio_datasets.rst rename to docs/source/ria_toolkit_oss/data/radio_datasets.rst diff --git a/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.datasets.license.rst b/docs/source/ria_toolkit_oss/data/ria_toolkit_oss.data.datasets.license.rst similarity index 100% rename from docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.datasets.license.rst rename to docs/source/ria_toolkit_oss/data/ria_toolkit_oss.data.datasets.license.rst diff --git a/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.rst b/docs/source/ria_toolkit_oss/data/ria_toolkit_oss.data.rst similarity index 78% rename from docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.rst rename to docs/source/ria_toolkit_oss/data/ria_toolkit_oss.data.rst index c550144..bd6adec 100644 --- a/docs/source/ria_toolkit_oss/datatypes/ria_toolkit_oss.datatypes.rst +++ b/docs/source/ria_toolkit_oss/data/ria_toolkit_oss.data.rst @@ -1,5 +1,5 @@ -Datatypes Package (ria_toolkit_oss.data) -============================================= +Data Package (ria_toolkit_oss.data) +======================================= .. |br| raw:: html From e5a3d327e5bf37c1acac9ec72f5d0b0cad1fb927 Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 28 Apr 2026 14:08:44 -0400 Subject: [PATCH 16/28] refactor: unify signal viewer styling and update docs screenshots - 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 --- docs/source/intro/getting_started.rst | 8 +- src/ria_toolkit_oss/view/view_signal.py | 50 +++++++---- .../view/view_signal_simple.py | 83 ++++++++++--------- 3 files changed, 88 insertions(+), 53 deletions(-) diff --git a/docs/source/intro/getting_started.rst b/docs/source/intro/getting_started.rst index b07f1a3..2dd4b6a 100644 --- a/docs/source/intro/getting_started.rst +++ b/docs/source/intro/getting_started.rst @@ -414,12 +414,18 @@ Device selection (``--device``) is optional if only one device is detected. Exac ria view capture.npy --show --no-save 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/qam64_35.png +.. 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: diff --git a/src/ria_toolkit_oss/view/view_signal.py b/src/ria_toolkit_oss/view/view_signal.py index a059e60..8c15fad 100644 --- a/src/ria_toolkit_oss/view/view_signal.py +++ b/src/ria_toolkit_oss/view/view_signal.py @@ -3,11 +3,12 @@ import os import textwrap from typing import Optional +import matplotlib import matplotlib.pyplot as plt import numpy as np -from matplotlib import gridspec +from matplotlib import gridspec, ticker from matplotlib.patches import Patch -from PIL import Image +from PIL import Image, UnidentifiedImageError from scipy.fft import fft, fftshift from scipy.signal import spectrogram from scipy.signal.windows import hann @@ -185,7 +186,7 @@ def view_sig( logo: Optional[bool] = True, dark: Optional[bool] = True, spines: Optional[bool] = False, - title_fontsize: Optional[int] = 35, + title_fontsize: Optional[int] = 25, subtitle_fontsize: Optional[int] = 15, ) -> None: """ @@ -230,11 +231,24 @@ def view_sig( complex_signal = recording.data[0] 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) if dark: 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" else: plt.style.use("default") @@ -252,8 +266,8 @@ def view_sig( plot_x_indx = 0 if plot_spectrogram: - spec_ax = plt.subplot(gs[plot_y_indx : plot_y_indx + 2, :]) - 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 + 3 fft_size = get_fft_size(plot_length=plot_length) _, t_spec, Sxx = spectrogram( @@ -280,7 +294,12 @@ def view_sig( ) 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: 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.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_xlim([min(t), max(t)]) 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) if frequency: @@ -310,10 +330,12 @@ def view_sig( # Convert to dB 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.set_xlabel("Frequency (MHz)") 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) if constellation: @@ -326,7 +348,7 @@ def view_sig( const_ax.set_ylim([-1 * dimension, dimension]) const_ax.set_xlabel("In-phase (I)") 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") if not spines: @@ -375,8 +397,8 @@ def view_sig( image = Image.open(logo_path) # Open the PNG image using PIL logo_ax.imshow(image) - except FileNotFoundError: - print(f"Warning, {logo_path} not found.") + except (FileNotFoundError, UnidentifiedImageError, OSError) as exc: + print(f"Warning, could not load logo image: {logo_path}. Reason: {exc}") fig.subplots_adjust( left=0.1, # Left margin diff --git a/src/ria_toolkit_oss/view/view_signal_simple.py b/src/ria_toolkit_oss/view/view_signal_simple.py index c770b5a..a3bb280 100644 --- a/src/ria_toolkit_oss/view/view_signal_simple.py +++ b/src/ria_toolkit_oss/view/view_signal_simple.py @@ -119,24 +119,19 @@ def setup_style(*, labels_mode: bool = False, compact_mode: bool = False) -> Non label_font = 14 else: base_font = 10 - title_font = 12 + title_font = 15 label_font = 10 matplotlib.rcParams.update( { - "figure.facecolor": "#0f172a", - "axes.facecolor": "#1e293b", - "axes.edgecolor": COLORS["muted"], - "axes.labelcolor": COLORS["light"], - "text.color": COLORS["light"], - "xtick.color": COLORS["muted"], - "ytick.color": COLORS["muted"], - "grid.color": COLORS["muted"], - "grid.alpha": 0.3, + "figure.facecolor": "#161616", + "axes.facecolor": "#161616", + "savefig.facecolor": "#161616", + "savefig.edgecolor": "#161616", "font.size": base_font, "axes.titlesize": title_font, "axes.labelsize": label_font, - "figure.titlesize": title_font + 2, + "figure.titlesize": title_font + 4, "legend.frameon": False, "legend.facecolor": "none", "xtick.labelsize": base_font, @@ -194,7 +189,7 @@ def view_simple_sig( constellation_mode: Optional[bool] = False, labels_mode: Optional[bool] = False, 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. @@ -237,7 +232,7 @@ def view_simple_sig( spec_signal = signal 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_labels = False ax_constellation = ax_psd = None @@ -253,25 +248,24 @@ def view_simple_sig( ax_psd = None else: 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 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 show_title = True show_labels = labels_mode if show_title: - fig.suptitle(title, fontsize=16, color=COLORS["light"], y=0.96) - fig.patch.set_facecolor("#0f172a") + fig.suptitle(title, fontsize=25) + fig.patch.set_facecolor(matplotlib.rcParams["figure.facecolor"]) 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([]) - ax1.plot(t_s, display_signal.real, color=COLORS["purple"], linewidth=0.8, alpha=0.8, label="I") - ax1.plot(t_s, display_signal.imag, color=COLORS["magenta"], linewidth=0.8, alpha=0.8, label="Q") - ax1.set_xlim(0, total_duration_s) - ax1.grid(True, alpha=0.3) + 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.6, alpha=0.8, label="Q") + ax1.grid(True, alpha=0.2, linewidth=0.5) 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_xlim(0, total_duration_s) + ax1.set_xlim(ax2.get_xlim()) if show_labels: if horizontal_mode: @@ -294,20 +288,26 @@ def view_simple_sig( ax2.set_xlabel("Time (s)") ax1.set_ylabel("Amplitude") - ax1.set_title(f"Time Series - {sdr} SDR", loc="left", pad=10) - ax1.legend(loc="upper right") + ax1.set_title(f"IQ Sample Plot - {sdr} SDR", loc="left", pad=10, fontsize=15) + ax1.legend(loc="upper right", fontsize=10) - ax2.set_ylabel("Frequency (Hz)") + ax2.set_ylabel("Frequency (MHz)") 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 + ) + ax2.yaxis.set_major_formatter( + matplotlib.ticker.FuncFormatter(lambda x, _: f"{x / 1e6:.1f}") ) - yticks = ax2.get_yticks() - ax2.set_yticklabels([f"{y / 1e6:.1f}" for y in yticks]) elif not compact_mode: - ax1.set_title("Time Series", loc="left", pad=10) - ax1.legend(loc="upper right", fontsize=8) + ax1.set_title("IQ Sample Plot", loc="left", pad=10, fontsize=15) + 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( annotations=annotations, @@ -339,8 +339,8 @@ def view_simple_sig( ) ax_constellation.set_xlabel("In-phase (I)") ax_constellation.set_ylabel("Quadrature (Q)") - ax_constellation.set_title("Constellation") - ax_constellation.grid(True, alpha=0.3) + ax_constellation.set_title("Constellation", loc="left", fontsize=15) + ax_constellation.grid(True, alpha=0.2, linewidth=0.5) ax_constellation.set_aspect("equal") if ax_psd is not None: @@ -351,11 +351,11 @@ def view_simple_sig( freqs = freqs + center_freq_hz 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_ylabel("Power (dB)") - ax_psd.set_title("Power Spectral Density") - ax_psd.grid(True, alpha=0.3) + ax_psd.set_title("Power Spectral Density", loc="left", fontsize=15) + ax_psd.grid(True, alpha=0.2, linewidth=0.5) if compact_mode: ax1.set_xticks([]) @@ -367,13 +367,20 @@ def view_simple_sig( else: plt.tight_layout() if show_title: - plt.subplots_adjust(top=0.92) + plt.subplots_adjust(top=0.9) if saveplot: output_path, extension = set_path(output_path=output_path) 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}") return output_path From 0a1bef84531b6eb8e7dcf865752b158597f01341 Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 28 Apr 2026 16:31:35 -0400 Subject: [PATCH 17/28] 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 --- .../_sources/intro/getting_started.rst.txt | 309 +++++++++--------- docs/source/intro/getting_started.rst | 6 + .../annotations/cusum_annotator.py | 7 +- .../annotations/energy_detector.py | 26 +- .../annotations/parallel_signal_separator.py | 15 +- .../annotations/qualify_slice.py | 2 +- .../annotations/signal_isolation.py | 13 +- .../annotations/threshold_qualifier.py | 21 +- src/ria_toolkit_oss/io/recording.py | 9 + .../ria_toolkit_oss/annotate.py | 136 +++++--- 10 files changed, 328 insertions(+), 216 deletions(-) diff --git a/docs/_build/html/_sources/intro/getting_started.rst.txt b/docs/_build/html/_sources/intro/getting_started.rst.txt index c2386ee..fa8e87e 100644 --- a/docs/_build/html/_sources/intro/getting_started.rst.txt +++ b/docs/_build/html/_sources/intro/getting_started.rst.txt @@ -6,10 +6,10 @@ This is a practical reference for the ``ria`` CLI from ``ria-toolkit-oss``. **Scope of this guide:** -* Installation and setup -* End-to-end CLI workflows -* Full command reference for CLI features -* Brief scripting section +* **Installation and SDR driver prerequisites** — how to install RIA Toolkit OSS and configure the system drivers your hardware requires +* **End-to-end CLI workflow** — a step-by-step walkthrough from hardware discovery through capture, annotation, and processing +* **Full command reference** — options, flags, and examples for every ``ria`` command +* **Python scripting preview** — using the toolkit API directly without the CLI **Official resources:** @@ -18,76 +18,15 @@ This is a practical reference for the ``ria`` CLI from ``ria-toolkit-oss``. * `PyPI package `_ * `RIA Hub Conda package `_ -.. contents:: Contents - :local: - :depth: 2 - :backlinks: none - 1) Installation and Setup ========================== -1.1 Installation with Conda ----------------------------- - -RIA Toolkit OSS is available as a Conda package on RIA Hub. This is typically the easiest -path when using SDR tooling that depends on native/system libraries. - -.. code-block:: bash - - conda update --force conda - conda config --add channels https://riahub.ai/api/packages/qoherent/conda - conda activate base - conda install ria-toolkit-oss - -Verify: - -.. code-block:: bash - - conda list | grep ria-toolkit-oss +Before using the ``ria`` CLI, follow the :doc:`Installation ` guide to +install RIA Toolkit OSS and any SDR drivers required for your hardware. -1.2 Installation with pip --------------------------- - -Use pip unless you specifically need to edit toolkit source. - -.. code-block:: bash - - python3 -m venv .venv - source .venv/bin/activate - pip install --upgrade pip - pip install ria-toolkit-oss - -Verify CLI entrypoint: - -.. code-block:: bash - - ria --help - -``pyproject.toml`` defines two script entry points: - -* ``ria`` -* ``ria-tools`` - -Both point to the same CLI module (``ria_toolkit_oss_cli.cli:cli``). - - -1.3 Optional install from source ----------------------------------- - -Use this for local development or testing unreleased changes. - -.. code-block:: bash - - git clone https://riahub.ai/qoherent/ria-toolkit-oss.git - cd ria-toolkit-oss - python3 -m venv .venv - source .venv/bin/activate - pip install -e . - - -1.4 SDR driver prerequisites +1.1 SDR driver prerequisites ----------------------------- 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): -* USRP: UHD drivers -* Pluto: libiio / IIO utilities -* BladeRF: libbladeRF -* HackRF: libhackrf -* RTL-SDR: librtlsdr +.. 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 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:** -* ``discover`` -* ``init`` -* ``capture`` -* ``view`` -* ``annotate`` (group) -* ``convert`` -* ``split`` -* ``combine`` -* ``generate`` (group) -* ``transform`` (group) -* ``transmit`` -* ``synth`` (alias of ``generate`` in command bindings) +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Command + - Purpose + * - :ref:`discover ` + - Probe SDR drivers and enumerate attached hardware + * - :ref:`init ` + - Create and manage user metadata defaults + * - :ref:`capture ` + - Record IQ samples from a connected SDR + * - :ref:`view ` + - Generate visualizations from IQ files + * - :ref:`annotate ` + - Label signal regions manually or with auto-detection (group) + * - :ref:`convert ` + - Convert between IQ file formats + * - :ref:`split ` + - Split, trim, or extract recordings + * - :ref:`combine ` + - Merge multiple recordings by concatenation or addition + * - :ref:`generate / synth ` + - Generate synthetic IQ signals (group; ``synth`` is an alias) + * - :ref:`transform ` + - Apply augmentations or impairments to recordings (group) + * - :ref:`transmit ` + - Transmit IQ through a TX-capable SDR 3) Quick End-to-End Workflow @@ -158,10 +124,8 @@ provenance fields. .. code-block:: bash ria init - # or non-interactive - ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" - # show config - ria init --show + ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" # non-interactive + ria init --show # show config 3.3 Capture IQ @@ -227,13 +191,14 @@ Replay recorded or synthesized IQ through a transmit-capable SDR. .. code-block:: bash ria transmit -d hackrf -f 2.44G -s 2e6 --input capture.sigmf-data - # or generated waveform - ria transmit -d hackrf --generate lfm --continuous + ria transmit -d hackrf --generate lfm --continuous # generated waveform 4) Command Reference ===================== +.. _cmd-discover: + 4.1 ``discover`` ----------------- @@ -263,6 +228,8 @@ Replay recorded or synthesized IQ through a transmit-capable SDR. hidden in default output. +.. _cmd-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). +.. _cmd-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 +.. _cmd-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 --show --no-save 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 ----------------------- @@ -459,8 +444,30 @@ Device selection (``--device``) is optional if only one device is detected. Exac ria annotate ... -**Subcommands:** ``list``, ``add``, ``remove``, ``clear``, ``energy``, ``cusum``, -``threshold``, ``separate`` +**Subcommands:** + +.. 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:** @@ -587,8 +594,16 @@ annotations. ria annotate add capture.sigmf-data --start 10000 --count 5000 --label burst ria annotate energy capture.sigmf-data --label signal --threshold 1.3 ria annotate cusum capture.sigmf-data --min-duration 5 + ria annotate threshold recordings/sample_recording3.npy --threshold 0.7 --label 70% 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`` ---------------- @@ -629,6 +644,8 @@ inferred from the output file extension. ria convert old.npy --format sigmf --legacy --overwrite +.. _cmd-split: + 4.7 ``split`` -------------- @@ -670,6 +687,8 @@ Choose exactly one operation per invocation: ria split annotated.sigmf-data --extract-annotations --annotation-label payload +.. _cmd-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 +.. _cmd-generate: + 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 ...``. -**Shape:** +**Usage:** .. code-block:: bash ria generate [subcommand options] [common options] **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:** @@ -760,22 +800,16 @@ Multipath and IQ imbalance flags apply impairment-style post-processing during g Options: ``--frequency``, ``--amplitude``, ``--phase`` -Clean sinusoidal calibration/reference source. - ``noise`` ~~~~~~~~~~ Options: ``--noise-type {gaussian,uniform}``, ``--power`` -Baseline noise floor data or controlled additive-noise synthesis. - ``chirp`` ~~~~~~~~~~ Options: ``--bandwidth`` (required), ``--period`` (required), ``--type {up,down,up_down}`` -Sweep-based radar/sonar-style signals and bandwidth occupancy tests. - ``square`` ~~~~~~~~~~~ @@ -826,6 +860,8 @@ symbol transition sharpness). ria synth psk -s 2e6 -r 100e3 -M 8 -N 8000 -o psk8.npy +.. _cmd-transform: + 4.10 ``transform`` group ------------------------- @@ -834,7 +870,7 @@ symbol transition sharpness). * Apply algorithmic transforms to existing recordings. * Run reusable augmentations/impairments for dataset diversity and robustness testing. -**Shape:** +**Usage:** .. 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 +.. _cmd-transmit: + 4.11 ``transmit`` ------------------ @@ -993,17 +1031,7 @@ experiment-specific fields on the CLI. ria generate noise --config generate.yaml -6) Practical Tips and Safety -============================= - -* Use ``ria discover`` before capture/transmit sessions. -* Keep TX gain conservative first; validate with attenuators/dummy loads when needed. -* Prefer SigMF for interoperable metadata and annotations. -* For long workflows, keep outputs organized by campaign directories and consistent prefixes. -* Use ``--verbose`` when debugging device init or driver issues. - - -7) Version Notes +6) Version Notes ================= 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 coupling when using only ``ria-toolkit-oss`` in isolation. -If you observe unexpected import errors after install, check the package version and -changelog, then test ``ria --help`` in a clean virtual environment. +.. tip:: + 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: .. 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.transforms import iq_augmentations, iq_impairments @@ -1037,47 +1066,3 @@ For quick non-CLI use: to_sigmf(imp, filename="capture_awgn", path=".") You can also call annotation algorithms and block-generator primitives from Python directly. - - -9) Cheat Sheet -=============== - -.. code-block:: bash - - # Install - pip install ria-toolkit-oss - - # Discover - ria discover -v - - # Init defaults - ria init --author "Jane" --project "rf1" --location "Lab-A" - - # Capture - ria capture -d pluto -f 2.44G -s 2e6 -n 1000000 -o cap.sigmf-data - - # View - ria view cap.sigmf-data --type simple - - # Annotate - ria annotate energy cap.sigmf-data --threshold 1.2 - ria annotate list cap.sigmf-data --verbose - - # Convert - ria convert cap.sigmf-data cap.npy - - # Split - ria split cap.sigmf-data --split-every 100000 --output-dir chunks - - # Combine - ria combine chunks/a.npy chunks/b.npy merged.npy - - # Generate - ria generate qam -s 2e6 -r 100e3 -M 16 -N 5000 -o qam16.npy - - # Transform - ria transform augment channel_swap cap.npy - ria transform impair add_awgn_to_signal cap.npy --params snr=10 - - # Transmit - ria transmit -d hackrf --input cap.sigmf-data -f 2.44G -s 2e6 diff --git a/docs/source/intro/getting_started.rst b/docs/source/intro/getting_started.rst index 2dd4b6a..fa8e87e 100644 --- a/docs/source/intro/getting_started.rst +++ b/docs/source/intro/getting_started.rst @@ -594,8 +594,14 @@ annotations. ria annotate add capture.sigmf-data --start 10000 --count 5000 --label burst ria annotate energy capture.sigmf-data --label signal --threshold 1.3 ria annotate cusum capture.sigmf-data --min-duration 5 + ria annotate threshold recordings/sample_recording3.npy --threshold 0.7 --label 70% 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: diff --git a/src/ria_toolkit_oss/annotations/cusum_annotator.py b/src/ria_toolkit_oss/annotations/cusum_annotator.py index e4498e5..9556125 100644 --- a/src/ria_toolkit_oss/annotations/cusum_annotator.py +++ b/src/ria_toolkit_oss/annotations/cusum_annotator.py @@ -38,7 +38,12 @@ def annotate_with_cusum( :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) # Create an object of the time segmenter diff --git a/src/ria_toolkit_oss/annotations/energy_detector.py b/src/ria_toolkit_oss/annotations/energy_detector.py index 4f14a9b..1a482bc 100644 --- a/src/ria_toolkit_oss/annotations/energy_detector.py +++ b/src/ria_toolkit_oss/annotations/energy_detector.py @@ -6,6 +6,7 @@ and occupied bandwidth calculation following ITU-R SM.328 standard. """ import json +import warnings from typing import Tuple import numpy as np @@ -119,6 +120,17 @@ def detect_signals_energy( if active: 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 merged_boundaries = [] if boundaries: @@ -135,7 +147,12 @@ def detect_signals_energy( merged_boundaries.append((start, length)) # 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) # Validate frequency method @@ -351,7 +368,12 @@ def annotate_with_obw( >>> annotated = annotate_with_obw(recording, label="signal_obw") """ 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) # Calculate OBW diff --git a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py index f2fa999..4e08353 100644 --- a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py +++ b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py @@ -49,6 +49,7 @@ allowing splitting of overlapping signals into separate training samples. """ import json +import warnings from typing import List, Optional, Tuple import numpy as np @@ -401,7 +402,12 @@ def split_recording_annotations( return recording 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) # Build new annotation list @@ -425,8 +431,11 @@ def split_recording_annotations( else: # No components found, keep original new_annotations.append(anno) - except Exception: - # Split failed for any reason, keep original + except Exception as e: + warnings.warn( + f"split_recording_annotations: failed to split annotation at index {i} ({e}); keeping original.", + stacklevel=2, + ) new_annotations.append(anno) else: # Not in split list, keep as-is diff --git a/src/ria_toolkit_oss/annotations/qualify_slice.py b/src/ria_toolkit_oss/annotations/qualify_slice.py index 89eadd7..f96668d 100644 --- a/src/ria_toolkit_oss/annotations/qualify_slice.py +++ b/src/ria_toolkit_oss/annotations/qualify_slice.py @@ -24,7 +24,7 @@ def qualify_slice_from_annotations(recording: Recording, slice_length: int): 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 end_index = slice_length * (i + 1) diff --git a/src/ria_toolkit_oss/annotations/signal_isolation.py b/src/ria_toolkit_oss/annotations/signal_isolation.py index 89ae3df..22661e3 100644 --- a/src/ria_toolkit_oss/annotations/signal_isolation.py +++ b/src/ria_toolkit_oss/annotations/signal_isolation.py @@ -35,17 +35,24 @@ def isolate_signal(recording: Recording, annotation: Annotation) -> Recording: 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 shifted_signal_slice = frequency_shift_iq_samples( iq_samples=signal_slice, - sample_rate=recording.metadata["sample_rate"], + sample_rate=sample_rate, shift_frequency=-1 * anno_base_center_freq, ) # filter - if isolation_bw < recording.metadata["sample_rate"] - 1: + if isolation_bw < sample_rate - 1: 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: diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py index b9bb727..24ef833 100644 --- a/src/ria_toolkit_oss/annotations/threshold_qualifier.py +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -42,6 +42,7 @@ classification or demodulation stages. """ import json +import warnings from typing import Optional import numpy as np @@ -216,11 +217,21 @@ def threshold_qualifier( """ # Extract signal and metadata 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) + n_samples = len(sample_data) + if window_size is None: 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 --- # Convert to power (Magnitude squared) @@ -237,6 +248,12 @@ def threshold_qualifier( # Soft early exit: keep a guard for low-contrast noise, but compute it from # the quieter tail of the envelope so burst-heavy captures are not rejected. if dynamic_range_ratio < 1.5: + 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) trigger_val = noise_floor + threshold * (max_power - noise_floor) @@ -296,7 +313,7 @@ def threshold_qualifier( # burst energy does not bleed through the long window into adjacent regions, # which would inflate macro_residual_max and push the trigger above the # faint burst's average power. - macro_window_size = max(window_size * 16, int(sample_rate * 0.02)) + macro_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 # 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- diff --git a/src/ria_toolkit_oss/io/recording.py b/src/ria_toolkit_oss/io/recording.py index ec4b472..a499d73 100644 --- a/src/ria_toolkit_oss/io/recording.py +++ b/src/ria_toolkit_oss/io/recording.py @@ -175,6 +175,15 @@ def from_npy(file: os.PathLike | str, legacy: bool = False) -> Recording: ) data = first # already loaded without pickle (numeric array) 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: annotations = list(np.load(f, allow_pickle=True)) except EOFError: diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py index daaf930..d551d2c 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -51,7 +51,7 @@ def detect_input_format(filepath): 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_is_annotated = input_path.stem.endswith("_annotated") @@ -63,24 +63,20 @@ def determine_output_path(input_path, output_path, fmt, quiet, overwrite): else: target = input_path.with_name(f"{input_path.stem}_annotated{input_path.suffix}") - if fmt == "sigmf": - 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}") + final_path = normalize_sigmf_path(target) if fmt == "sigmf" else target - # Always allow writing to _annotated files; guard against overwriting originals - target_is_annotated = final_path.stem.endswith("_annotated") - 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 + if final_path.exists() and not overwrite: + raise click.ClickException(f"{final_path} already exists. Use --overwrite to replace it.") 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): """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) fmt = detect_input_format(input_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": # Normalize path for SigMF base_path = output_path @@ -312,6 +313,8 @@ def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_ except Exception as e: raise click.ClickException(f"Failed to load recording: {e}") + check_output_available(input, output, overwrite) + # Validate sample range n_samples = len(recording.data[0]) if start < 0: @@ -363,12 +366,9 @@ def add(input, start, count, label, freq_lower, freq_upper, comment, annotation_ if comment: click.echo(f" Comment: {comment}") - try: - save_recording_auto(recording, output, input, quiet, overwrite) - if not quiet: - click.echo(" ✓ Saved") - except Exception as e: - raise click.ClickException(f"Failed to save: {e}") + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") # ============================================================================ @@ -466,8 +466,6 @@ def clear(input, output, overwrite, force, quiet): if not quiet: click.echo(f"\nCleared {count_before} annotation(s)") - recording._annotations = [] - try: save_recording_auto(recording, output_path=input, input_path=input, quiet=quiet, overwrite=True) if not quiet: @@ -503,6 +501,7 @@ def clear(input, output, overwrite, force, quiet): default="standalone", 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("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--quiet", is_flag=True, help="Quiet mode") @@ -517,6 +516,7 @@ def energy( nfft, obw_power, annotation_type, + sample_rate, output, overwrite, quiet, @@ -539,8 +539,11 @@ def energy( 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 full-detected + ria annotate energy signal.npy --sample-rate 1e6 """ + check_output_available(input, output, overwrite) + try: recording = load_recording(input) if not quiet: @@ -548,6 +551,15 @@ def energy( except Exception as 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: click.echo("\nDetecting signals using energy-based method...") click.echo(" Time detection:") @@ -575,13 +587,13 @@ def energy( if not quiet: 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: 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 @@ -601,10 +613,11 @@ def energy( default="standalone", 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("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @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. Detects signal state changes (on/off, amplitude transitions). Best for @@ -616,7 +629,10 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o Examples: 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 --sample-rate 1e6 """ + check_output_available(input, output, overwrite) + try: recording = load_recording(input) if not quiet: @@ -624,6 +640,15 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o except Exception as 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: click.echo("\nDetecting segments using CUSUM...") click.echo(f" Min duration: {min_duration} ms") @@ -644,13 +669,13 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o if not quiet: 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: 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 @@ -675,10 +700,11 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, o help="Annotation type", ) @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("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @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. Detects samples above a percentage of maximum magnitude. Best for simple @@ -688,10 +714,13 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou Examples: 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.4 --sample-rate 1e6 """ if not (0.0 <= threshold <= 1.0): raise click.ClickException(f"--threshold must be between 0.0 and 1.0, got {threshold}") + check_output_available(input, output, overwrite) + try: recording = load_recording(input) if not quiet: @@ -699,11 +728,21 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou except Exception as 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: click.echo("\nDetecting signals using threshold qualifier...") 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" Channel: {channel}") + click.echo(f" Sample rate: {recording.sample_rate:.0f} Hz") try: initial_count = len(recording.annotations) @@ -719,13 +758,13 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, ou if not quiet: 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: 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) @@ -738,11 +777,12 @@ 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("--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("--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("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--quiet", is_flag=True, help="Quiet mode") @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 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. @@ -768,6 +808,8 @@ def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output, ria annotate separate signal.npy --min-component-bw 100000 """ + check_output_available(input, output, overwrite) + try: recording = load_recording(input) if not quiet: @@ -775,6 +817,15 @@ def separate(input, indices, nfft, noise_threshold_db, min_component_bw, output, except Exception as 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 indices_list = get_indices_list(indices=indices, recording=recording) @@ -821,8 +872,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}" ) - save_recording_auto(recording, output, input, quiet, overwrite) - if not quiet: - click.echo(" ✓ Saved") except Exception as e: raise click.ClickException(f"Spectral separation failed: {e}") + + save_recording_auto(recording, output, input, quiet, overwrite) + if not quiet: + click.echo(" ✓ Saved") From 4ce42fa71a76dec7bad54c80e03ba64b83f0b2ff Mon Sep 17 00:00:00 2001 From: madrigal Date: Wed, 29 Apr 2026 09:55:29 -0400 Subject: [PATCH 18/28] Formatting, updated lock file --- poetry.lock | 381 ++++++++---------- src/ria_toolkit_oss/view/tools.py | 2 +- src/ria_toolkit_oss/view/view_signal.py | 36 +- .../view/view_signal_simple.py | 13 +- 4 files changed, 195 insertions(+), 237 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9d1e5fe..e947d9f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 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]] name = "alabaster" @@ -242,14 +242,14 @@ files = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["agent", "docs", "test"] files = [ - {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, - {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, + {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, + {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, ] [[package]] @@ -491,14 +491,14 @@ files = [ [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev", "docs", "server", "test"] files = [ - {file = "click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"}, - {file = "click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5"}, + {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, + {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, ] [package.dependencies] @@ -690,61 +690,61 @@ test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist" [[package]] name = "cryptography" -version = "46.0.7" +version = "47.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.8" groups = ["main"] files = [ - {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, - {file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, - {file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, - {file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, - {file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, - {file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, - {file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, - {file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, - {file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, - {file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, + {file = "cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f"}, + {file = "cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8"}, + {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318"}, + {file = "cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001"}, + {file = "cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203"}, + {file = "cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa"}, + {file = "cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"}, + {file = "cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7"}, + {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52"}, + {file = "cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd"}, + {file = "cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63"}, + {file = "cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b"}, + {file = "cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76"}, + {file = "cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe"}, + {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31"}, + {file = "cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7"}, + {file = "cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310"}, + {file = "cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab"}, + {file = "cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8"}, + {file = "cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb"}, ] [package.dependencies] @@ -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\""} [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)"] -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]] name = "cycler" @@ -850,14 +843,14 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi" -version = "0.136.0" +version = "0.136.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.10" groups = ["server", "test"] files = [ - {file = "fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4"}, - {file = "fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e"}, + {file = "fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f"}, + {file = "fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f"}, ] [package.dependencies] @@ -1161,18 +1154,18 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" -version = "3.11" +version = "3.13" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" groups = ["agent", "docs", "server", "test"] files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"}, + {file = "idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "imagesize" @@ -1271,7 +1264,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.25.0" @@ -1522,67 +1515,67 @@ files = [ [[package]] name = "matplotlib" -version = "3.10.8" +version = "3.10.9" description = "Python plotting package" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7"}, - {file = "matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656"}, - {file = "matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df"}, - {file = "matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17"}, - {file = "matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933"}, - {file = "matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a"}, - {file = "matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160"}, - {file = "matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78"}, - {file = "matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4"}, - {file = "matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2"}, - {file = "matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6"}, - {file = "matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9"}, - {file = "matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2"}, - {file = "matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a"}, - {file = "matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58"}, - {file = "matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04"}, - {file = "matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f"}, - {file = "matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466"}, - {file = "matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf"}, - {file = "matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b"}, - {file = "matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6"}, - {file = "matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1"}, - {file = "matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486"}, - {file = "matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce"}, - {file = "matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6"}, - {file = "matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149"}, - {file = "matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645"}, - {file = "matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077"}, - {file = "matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22"}, - {file = "matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39"}, - {file = "matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565"}, - {file = "matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a"}, - {file = "matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958"}, - {file = "matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5"}, - {file = "matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f"}, - {file = "matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b"}, - {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d"}, - {file = "matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008"}, - {file = "matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c"}, - {file = "matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11"}, - {file = "matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8"}, - {file = "matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50"}, - {file = "matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908"}, - {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a"}, - {file = "matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1"}, - {file = "matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c"}, - {file = "matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b"}, - {file = "matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7"}, - {file = "matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a"}, - {file = "matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2"}, - {file = "matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3"}, + {file = "matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217"}, + {file = "matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b"}, + {file = "matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37"}, + {file = "matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294"}, + {file = "matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65"}, + {file = "matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda"}, + {file = "matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb"}, + {file = "matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb"}, + {file = "matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb"}, + {file = "matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9"}, + {file = "matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb"}, + {file = "matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f"}, + {file = "matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80"}, + {file = "matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1"}, + {file = "matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320"}, + {file = "matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285"}, + {file = "matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2"}, + {file = "matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf"}, + {file = "matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6"}, + {file = "matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42"}, + {file = "matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f"}, + {file = "matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e"}, + {file = "matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f"}, + {file = "matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838"}, + {file = "matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2"}, + {file = "matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921"}, + {file = "matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8"}, + {file = "matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9"}, + {file = "matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4"}, + {file = "matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc"}, + {file = "matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99"}, + {file = "matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d"}, + {file = "matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8"}, + {file = "matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38"}, + {file = "matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d"}, + {file = "matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f"}, + {file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b"}, + {file = "matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2"}, + {file = "matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716"}, + {file = "matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f"}, + {file = "matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456"}, + {file = "matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe"}, + {file = "matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6"}, + {file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c"}, + {file = "matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4"}, + {file = "matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf"}, + {file = "matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39"}, + {file = "matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f"}, + {file = "matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba"}, + {file = "matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4"}, + {file = "matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358"}, ] [package.dependencies] @@ -1597,7 +1590,7 @@ pyparsing = ">=3" python-dateutil = ">=2.7" [package.extras] -dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7)"] +dev = ["meson-python (>=0.13.1,<0.17.0)", "pybind11 (>=2.13.2,!=2.13.3)", "setuptools (>=64)", "setuptools_scm (>=7,<10)"] [[package]] name = "mccabe" @@ -1611,25 +1604,6 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] -[[package]] -name = "mpmath" -version = "1.3.0" -description = "Python library for arbitrary-precision floating-point arithmetic" -optional = false -python-versions = "*" -groups = ["server", "test"] -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]] name = "mypy-extensions" version = "1.1.0" @@ -1717,37 +1691,37 @@ markers = {server = "python_version >= \"3.11\"", test = "python_version >= \"3. [[package]] name = "onnxruntime" -version = "1.24.4" +version = "1.25.1" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = ">=3.11" groups = ["server", "test"] markers = "python_version >= \"3.11\"" files = [ - {file = "onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2"}, - {file = "onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7"}, - {file = "onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330"}, - {file = "onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153"}, - {file = "onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b"}, - {file = "onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78"}, - {file = "onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5"}, - {file = "onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c"}, - {file = "onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb"}, - {file = "onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90"}, - {file = "onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0"}, - {file = "onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13"}, - {file = "onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f"}, - {file = "onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93"}, - {file = "onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19"}, - {file = "onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee"}, - {file = "onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36"}, - {file = "onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4"}, - {file = "onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1"}, - {file = "onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177"}, - {file = "onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858"}, - {file = "onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d"}, - {file = "onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661"}, - {file = "onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731"}, + {file = "onnxruntime-1.25.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:5cf58ec7601120bb4370f0b868f794d3e3626db7b1b1dba366c27874b224e9de"}, + {file = "onnxruntime-1.25.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa7d4daa78a18b8f3b410e31e82dab8580363c85cac644179a853f2748618e89"}, + {file = "onnxruntime-1.25.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79162f873cdfa38cfc8d53d59a8dc7a71a14074df3d565b2f8ce24289545ddc0"}, + {file = "onnxruntime-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:451b9494056f7f96b1be76a32745ccc4582bd61b2a0e1bc52de3708446151d5d"}, + {file = "onnxruntime-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:7e608f8950076da02c0aeceec2dd790d201eeb31dd73acb04ec989b2bf6199dc"}, + {file = "onnxruntime-1.25.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:66e52f7a30d1f780a34aa84d68a0a04d382d9f5b141884ecbf45b7566b9fbde9"}, + {file = "onnxruntime-1.25.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5f41779f044d1ff75593df5c10a4d311bc82563687796d5218e2685b8f9da25"}, + {file = "onnxruntime-1.25.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:905409e9eb2ef87f8226e073f56e71faf731c3e480ebd34952cf953730e4a4ff"}, + {file = "onnxruntime-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4097b75b77486bb45835a8ed25b9a67976040ec6c258aeabae6aadfbdd1201c"}, + {file = "onnxruntime-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:b6c7aa5cae606d5c90a392679fac074b60f80025a2e83e1e90fdf882bd2a97f0"}, + {file = "onnxruntime-1.25.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e9d9b3b1694196bc3c5bc66f760a237a5e27d7688aaa2e2c9c0f66abd0486699"}, + {file = "onnxruntime-1.25.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:311d29b943e46a55ca72ca1ea48d7815c993122bfc359f68215fddeb9583fff4"}, + {file = "onnxruntime-1.25.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98016a038b31160db23208706139fa3b99cd60bc1c5ffdade77aafd6a37a92ad"}, + {file = "onnxruntime-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:08717d6eee2820807ba60b1b17032af207bd7aaca5b6c4abaee71f83feae877b"}, + {file = "onnxruntime-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:84f8963d70e00167bae273ab7e80e9795bfc5eb94f6b23236a99c5c11af00844"}, + {file = "onnxruntime-1.25.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03e800b3a4b48d9f3a2d23aacc4fa95486a3b406b14e51d1a9b8b6981d9adf9c"}, + {file = "onnxruntime-1.25.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd83ef5c10cfc051a1cb465db692d57b996a1bc75a2a97b161398e29cdbc47ff"}, + {file = "onnxruntime-1.25.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:395eb662c437fa2407f44266e4778b75bff261b17c2a6fef042421f9069f871d"}, + {file = "onnxruntime-1.25.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ae85395f41b291ae3e61780ec5092640181d369ef6c268aa8141c478b509e69"}, + {file = "onnxruntime-1.25.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:828e1b12710fbedb6dfab5e7bae6f11563617cddf3c2e7e8d84c64de566a4a3a"}, + {file = "onnxruntime-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:2affc9d2fd9ab013b9c9637464e649a0cca870d57ae18bfef74180eee65c3369"}, + {file = "onnxruntime-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:3387d75d1a815b4b2495b4e47a05ef1b3bcb64a817ddc68587e0bfcb9702bcf6"}, + {file = "onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06280b06604660595037f783c6d24bc70cbe5c6093975f194cd1482e77d450de"}, + {file = "onnxruntime-1.25.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e79fd5ce7db10ebcc24e020e2ed0159476e69e2326b9b7828e5aadcf6184212"}, ] [package.dependencies] @@ -1755,18 +1729,21 @@ flatbuffers = "*" numpy = ">=1.21.6" packaging = "*" protobuf = "*" -sympy = "*" + +[package.extras] +quantization = ["ml_dtypes"] +symbolic = ["sympy"] [[package]] name = "packaging" -version = "26.1" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "dev", "docs", "server", "test"] files = [ - {file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"}, - {file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] markers = {server = "python_version >= \"3.11\""} @@ -1893,21 +1870,20 @@ gssapi = ["gssapi (>=1.4.1) ; platform_system != \"Windows\"", "pyasn1 (>=0.1.7) [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"}, - {file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"}, + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, ] [package.extras] hyperscan = ["hyperscan (>=0.7)"] optional = ["typing-extensions (>=4)"] re2 = ["google-re2 (>=1.1)"] -tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] [[package]] name = "pillow" @@ -2974,14 +2950,14 @@ test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis [[package]] name = "sigmf" -version = "1.8.0" +version = "1.9.0" description = "Easily interact with Signal Metadata Format (SigMF) recordings." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "sigmf-1.8.0-py3-none-any.whl", hash = "sha256:f233ab04344fa3e42170926a646f7e53edd7edc65fcda42eb3d7efaf8a2e8263"}, - {file = "sigmf-1.8.0.tar.gz", hash = "sha256:91e10cb046499639e5f961d66a24c17a33ff76fc98df892eab0953cc9d659a50"}, + {file = "sigmf-1.9.0-py3-none-any.whl", hash = "sha256:902e694894e61f8cdb75b0d69ae8c407f82f35435c3c5e4c1b586b313f77b89b"}, + {file = "sigmf-1.9.0.tar.gz", hash = "sha256:95e4b28156b2182035ecca5f5852108fb3cdef5f20b0cd48919bb0fc5f293d0e"}, ] [package.dependencies] @@ -3229,25 +3205,6 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\"" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] -[[package]] -name = "sympy" -version = "1.14.0" -description = "Computer algebra system (CAS) in Python" -optional = false -python-versions = ">=3.9" -groups = ["server", "test"] -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]] name = "tomli" version = "2.4.1" @@ -3389,14 +3346,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2026.1" +version = "2026.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] files = [ - {file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, - {file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] [[package]] @@ -3419,14 +3376,14 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" -version = "0.44.0" +version = "0.46.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" groups = ["docs", "server", "test"] files = [ - {file = "uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89"}, - {file = "uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e"}, + {file = "uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048"}, + {file = "uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d"}, ] [package.dependencies] @@ -3511,14 +3468,14 @@ test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", [[package]] name = "virtualenv" -version = "21.2.4" +version = "21.3.0" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac"}, - {file = "virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada"}, + {file = "virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7"}, + {file = "virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e"}, ] [package.dependencies] diff --git a/src/ria_toolkit_oss/view/tools.py b/src/ria_toolkit_oss/view/tools.py index 974219a..cad5b67 100644 --- a/src/ria_toolkit_oss/view/tools.py +++ b/src/ria_toolkit_oss/view/tools.py @@ -33,7 +33,7 @@ def extract_metadata_fields(metadata): def set_path(output_path): path = pathlib.Path(output_path) - + # If only filename provided (no directory), use default 'images' folder if len(path.parts) == 1: folder = pathlib.Path("images") diff --git a/src/ria_toolkit_oss/view/view_signal.py b/src/ria_toolkit_oss/view/view_signal.py index 8c15fad..628bb0e 100644 --- a/src/ria_toolkit_oss/view/view_signal.py +++ b/src/ria_toolkit_oss/view/view_signal.py @@ -236,19 +236,21 @@ def view_sig( if dark: 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", - }) + 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" else: plt.style.use("default") @@ -297,9 +299,7 @@ def view_sig( 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}") - ) + spec_ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: f"{x / 1e6:.1f}")) if iq: iq_ax = plt.subplot(gs[plot_y_indx : plot_y_indx + 2, :]) @@ -330,7 +330,9 @@ def view_sig( # Convert to dB 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) / 1e6 + 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.set_xlabel("Frequency (MHz)") freq_ax.set_ylabel("Magnitude (dB)") diff --git a/src/ria_toolkit_oss/view/view_signal_simple.py b/src/ria_toolkit_oss/view/view_signal_simple.py index a3bb280..9402b36 100644 --- a/src/ria_toolkit_oss/view/view_signal_simple.py +++ b/src/ria_toolkit_oss/view/view_signal_simple.py @@ -293,11 +293,12 @@ def view_simple_sig( ax2.set_ylabel("Frequency (MHz)") ax2.set_title( - f"Spectrogram - {center_freq_hz / 1e6:.1f} MHz ± {sample_rate_hz / 2e6:.1f} MHz", loc="left", pad=10, fontsize=15 - ) - ax2.yaxis.set_major_formatter( - matplotlib.ticker.FuncFormatter(lambda x, _: f"{x / 1e6:.1f}") + f"Spectrogram - {center_freq_hz / 1e6:.1f} MHz ± {sample_rate_hz / 2e6:.1f} MHz", + loc="left", + pad=10, + fontsize=15, ) + ax2.yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(lambda x, _: f"{x / 1e6:.1f}")) elif not compact_mode: ax1.set_title("IQ Sample Plot", loc="left", pad=10, fontsize=15) ax1.legend(loc="upper right", fontsize=10) @@ -305,9 +306,7 @@ def view_simple_sig( 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}") - ) + ax2.yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(lambda x, _: f"{x / 1e6:.1f}")) _add_annotations( annotations=annotations, From 18666d95ee6d88b386400ffad5f8b26c4b5b1104 Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 5 May 2026 14:31:42 -0400 Subject: [PATCH 19/28] docs: expand getting_started with real command output, examples, and images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/source/intro/getting_started.rst | 796 ++++++++++++++++++-------- 1 file changed, 569 insertions(+), 227 deletions(-) diff --git a/docs/source/intro/getting_started.rst b/docs/source/intro/getting_started.rst index fa8e87e..59de870 100644 --- a/docs/source/intro/getting_started.rst +++ b/docs/source/intro/getting_started.rst @@ -105,93 +105,99 @@ Top-level CLI follows this model: 3.1 Discover radios -------------------- -Run this first in any new environment to verify drivers and detect hardware before -attempting RX/TX commands. +Run this first to verify drivers and detect connected hardware. .. code-block:: bash - ria discover ria discover -v - ria discover --json-output + +See :ref:`discover ` for JSON output and troubleshooting options. 3.2 Initialize local metadata defaults --------------------------------------- -Set reusable metadata once so generated/captured files automatically include consistent -provenance fields. +Set reusable metadata once so captured files include consistent provenance fields. .. code-block:: bash ria init - ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" # non-interactive - ria init --show # show config + +See :ref:`init ` for non-interactive and config path options. 3.3 Capture IQ --------------- -Capture baseband data from a connected SDR into a reusable file format. +Record baseband IQ from a connected SDR. .. code-block:: bash ria capture -d pluto -f 2.44G -s 2e6 -n 500000 -o capture.sigmf-data +See :ref:`capture ` for all device, format, and metadata options. + 3.4 Visualize and inspect -------------------------- -Render quick diagnostic plots to validate signal presence, quality, and rough structure. +Render a quick diagnostic plot to validate signal presence and quality. .. code-block:: bash ria view capture.sigmf-data --type simple - ria view capture.sigmf-data --type full --show --no-save + +See :ref:`view ` for full multi-panel plots and display options. 3.5 Auto-annotate and inspect annotations ------------------------------------------ -Create initial labels automatically, then inspect annotation objects before downstream use. +Detect signal regions automatically, then verify the results. .. code-block:: bash - ria annotate energy capture.sigmf-data --label signal --threshold 1.2 - ria annotate list capture.sigmf-data --verbose + ria annotate energy capture.sigmf-data --label signal + ria annotate list capture.sigmf-data + +See :ref:`annotate ` for threshold tuning and other detection methods. 3.6 Convert and split ---------------------- -Normalize file format and split large captures into manageable chunks for processing or -training. +Convert to a different format and split into fixed-size chunks. .. code-block:: bash ria convert capture.sigmf-data capture.npy ria split capture.sigmf-data --split-every 100000 --output-dir chunks +See :ref:`convert ` and :ref:`split ` for format and trim options. + 3.7 Apply transforms --------------------- -Augment or impair recordings to produce controlled variants. +Augment or impair a recording to produce controlled variants. .. code-block:: bash - ria transform augment channel_swap capture.npy ria transform impair add_awgn_to_signal capture.npy --params snr=10 +See :ref:`transform ` for available augmentations and custom transforms. + 3.8 Transmit (TX-capable radios only) -------------------------------------- -Replay recorded or synthesized IQ through a transmit-capable SDR. +Replay a recording through a transmit-capable SDR. .. code-block:: bash ria transmit -d hackrf -f 2.44G -s 2e6 --input capture.sigmf-data - ria transmit -d hackrf --generate lfm --continuous # generated waveform + +See :ref:`transmit ` for continuous mode and generated waveform options. 4) Command Reference @@ -227,6 +233,68 @@ Replay recorded or synthesized IQ through a transmit-capable SDR. * Use ``--verbose`` first when troubleshooting; it surfaces driver-level failures that are hidden in default output. +**Example output:** + +Run ``ria discover -v`` to see loaded drivers, failure reasons, and attached devices: + +.. code-block:: text + + $ ria discover -v + + ✅ Loaded drivers (3): + hackrf + pluto + bladerf + + ❌ Failed drivers (3): + usrp: ModuleNotFoundError: uhd + rtlsdr: ImportError: pyrtlsdr is required to use the RTLSDR class + thinkrf: ImportError: pyrf is required to use the ThinkRF integration. + Install with: pip install ria-toolkit-oss[thinkrf] + + ======================================== + Attached Devices + ======================================== + + 📡 USRP/UHD devices (1): + ✅ MyB200 (B200) - Serial: 30C51D5 + + 📱 PlutoSDR devices: None found + 🔧 HackRF devices: None found + + ======================================== + Discovery Summary + ======================================== + Loaded drivers: 3 + Failed drivers: 3 + Detected devices: 1 + +With ``--json-output`` (useful for scripting and automation): + +.. code-block:: json + + { + "loaded_drivers": ["hackrf"], + "failed_drivers": ["pluto", "bladerf", "usrp", "rtlsdr", "thinkrf"], + "devices": [ + { + "type": "BladeRF", + "Description": "Nuand bladeRF 2.0", + "Backend": "libusb", + "Serial": "8518b488d3e3443da979680f472bbb87", + "USB Bus": "4", + "USB Address": "2" + } + ], + "total_devices": 1 + } + +.. note:: + Driver load failures are normal on systems where only a subset of SDR backends are + installed. A failed driver just means that backend's Python library isn't present — it + does not prevent other drivers from working. Install only the packages for the hardware + you use. + .. _cmd-init: @@ -244,36 +312,47 @@ Replay recorded or synthesized IQ through a transmit-capable SDR. ria init [options] -**Key options:** +**Options:** -*Metadata defaults:* -``--author``, ``--organization``, ``--project``, ``--location``, ``--testbed``, -``--license``, ``--hw``, ``--dataset`` - -*Actions:* -``--show``, ``--reset`` - -*Control:* -``--config-path``, ``--interactive`` / ``--no-interactive``, ``-y`` / ``--yes`` - -**What each option category does:** - -* Metadata defaults (``--author``, ``--project``, etc.): stored once and reused for later - recordings so files have consistent provenance. -* SigMF-focused fields (``--license``, ``--hw``, ``--dataset``): populate metadata commonly - expected in shared datasets. +* ``--author``, ``--organization``, ``--project``, ``--location``, ``--testbed``, + ``--license``, ``--hw``, ``--dataset``: stored once and reused for later recordings so + files have consistent provenance. * ``--show``: read-only inspect of the current resolved config. * ``--reset``: remove config and start clean. -* ``--config-path``: use a non-default config location (useful for isolated environments or - CI). +* ``--config-path``: use a non-default config location (useful for isolated environments or CI). * ``--interactive`` / ``--no-interactive``: force prompts on or off regardless of terminal auto-detection. * ``--yes``: suppress confirmation prompts for scripted runs. +**Example output:** + +Set metadata fields non-interactively: + +.. code-block:: text + + $ ria init --author "Jane Doe" --project "rf-campaign-1" --location "Lab-A" --no-interactive + + ✓ Configuration saved to: /home/user/.ria/config.yaml + +Then verify what was saved with ``--show``: + +.. code-block:: text + + $ ria init --show + + Current Configuration (/home/user/.ria/config.yaml): + ============================================================ + + Author: Jane Doe + Project: rf-campaign-1 + Location: Lab-A + + To update: ria init + To reset: ria init --reset + .. note:: - Current command output includes a note that some config integration is still being - finalized. Config values are already consumed by multiple commands (capture, convert, - generate metadata, and YAML config loading paths). + Config integration is still being finalized. Config values are already consumed by + multiple commands (capture, convert, generate metadata, and YAML config loading paths). .. _cmd-capture: @@ -295,53 +374,25 @@ Replay recorded or synthesized IQ through a transmit-capable SDR. Device selection (``--device``) is optional if only one device is detected. Exactly one of ``--num-samples`` or ``--duration`` is required. -**Core options:** - -*Device/connection:* +**Options:** * ``-d, --device {pluto,hackrf,bladerf,usrp,rtlsdr,thinkrf}`` -* ``-i, --ident`` -* ``-c, --config `` - -*RF/capture:* - +* ``-i, --ident``: serial or IP selector when multiple devices of the same type are present. +* ``-c, --config ``: load options from a YAML file; CLI flags override loaded values. * ``-s, --sample-rate`` * ``-f, --center-frequency`` (supports values like ``915e6``, ``2.4G``) -* ``-g, --gain`` -* ``-b, --bandwidth`` -* ``-n, --num-samples`` -* ``-t, --duration`` - -*Output:* - -* ``-o, --output`` -* ``--output-dir`` -* ``--format {npy,sigmf,wav,blue}`` -* ``--save-image`` - -*Metadata/logging:* - -* ``-m, --metadata KEY=VALUE`` (repeatable) +* ``-g, --gain``, ``-b, --bandwidth`` +* ``-n, --num-samples`` or ``-t, --duration``: use sample count for deterministic datasets, + or duration for quick time-based acquisition. +* ``-o, --output``, ``--output-dir``: output path or directory. A timestamped filename is + generated if ``--output`` is omitted; defaults to ``recordings/`` if ``--output-dir`` is + omitted. +* ``--format {npy,sigmf,wav,blue}``: inferred from file extension if not set. ``sigmf`` is + best for annotation workflows. +* ``--save-image``: writes a quick visual summary alongside the capture file. +* ``-m, --metadata KEY=VALUE`` (repeatable): injects run-specific metadata. * ``-v, --verbose``, ``-q, --quiet`` -**How options work in practice:** - -* ``--device`` + ``--ident``: select both device class and target instance; ``--ident`` - takes serial/IP style selectors. -* ``--config``: load a YAML option set, then override specific fields on the CLI as needed. -* ``--num-samples`` vs ``--duration``: use exact sample count for deterministic datasets, - or time-based capture for quick acquisition. -* ``--format``: ``sigmf`` is best for metadata/annotation workflows. -* ``--save-image``: writes a quick visual summary alongside capture output. -* ``--metadata KEY=VALUE``: injects run-specific metadata (campaign ID, antenna, scenario - tag, etc.). - -**Output behavior:** - -* If ``--output`` is omitted, a timestamped filename is generated automatically. -* If ``--output-dir`` is omitted, captures default to ``recordings/``. -* Format is inferred from the ``--output`` extension when no explicit ``--format`` is given. - **Examples:** .. code-block:: bash @@ -350,6 +401,28 @@ Device selection (``--device``) is optional if only one device is detected. Exac ria capture -d pluto -f 915e6 -t 2 --format npy --output-dir recordings ria capture -c capture_config.yaml +**Example output:** + +.. note:: + ``capture`` requires a connected SDR. The following shows representative output for a + HackRF capture. + +.. code-block:: text + + $ ria capture -d hackrf -s 2e6 -f 2.44G -n 1000000 -o rf.sigmf-data + + Initializing HackRF... + Device: HackRF One + Serial: a74ad5e4e2a14b7d + Center frequency: 2.44 GHz + Sample rate: 2.00 MS/s + Gain: 20 dB + + Capturing 1,000,000 samples... + + Saved: rf.sigmf-data + rf.sigmf-meta + .. _cmd-view: @@ -397,13 +470,13 @@ Device selection (``--device``) is optional if only one device is detected. Exac **Mode-specific options:** -*simple:* ``--fast``, ``--compact``, ``--horizontal``, ``--constellation``, ``--labels``, +simple: ``--fast``, ``--compact``, ``--horizontal``, ``--constellation``, ``--labels``, ``--slice start:end[:step]`` -*full:* ``--plot-length``, ``--no-spectrogram``, ``--no-iq``, ``--no-frequency``, +full: ``--plot-length``, ``--no-spectrogram``, ``--no-iq``, ``--no-frequency``, ``--no-constellation``, ``--no-metadata``, ``--no-logo``, ``--spines`` -*annotations/channels:* ``--channel`` +annotations / channels: ``--channel`` **Examples:** @@ -416,15 +489,36 @@ Device selection (``--device``) is optional if only one device is detected. Exac 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 +**Example output:** - Output of ``ria view recordings\qam64_35.npy --type simple`` +.. code-block:: text + + $ ria view qam64_35.npy --type simple + + Loading recording: qam64_35.npy + + Recording Metadata: + ---------------------------------------- + modulation: qam64 + constellation: qam + bits_per_symbol: 6 + sps: 6 + beta: 0.35 + source: signal.block_generator + ---------------------------------------- + + Generating simple visualization... + Saved: qam64_35.png + +.. figure:: ../images/recordings/qam64_35.png + :alt: Example output of ria view qam64_35.npy --type simple + + Output of ``ria view qam64_35.npy --type simple`` .. figure:: ../images/recordings/qam64_35-full.png - :alt: Example output of ria view recordings\qam64_35.npy --type full + :alt: Example output of ria view qam64_35.npy --type full - Output of ``ria view recordings\qam64_35.npy --type full`` + Output of ``ria view qam64_35.npy --type full`` .. _cmd-annotate: @@ -438,7 +532,7 @@ Device selection (``--device``) is optional if only one device is detected. Exac * Build or refine label metadata directly in recordings for downstream training, QA, and filtering. -**Command shape:** +**Usage:** .. code-block:: bash @@ -469,12 +563,11 @@ Device selection (``--device``) is optional if only one device is detected. Exac * - ``separate`` - Decompose annotations into narrower spectral components -**General behavior:** +SigMF is the preferred format for durable annotation metadata. For non-SigMF input, most +operations write a new output artifact unless ``--overwrite`` is set. +``--type {standalone,parallel,intersection}`` controls annotation relation semantics. -* SigMF is the preferred format for durable annotation metadata. -* For non-SigMF input, many operations write a new output artifact unless overwrite behavior - is explicitly requested. -* ``--type {standalone,parallel,intersection}`` controls annotation relation semantics. +**Manual subcommands:** ``ria annotate list`` ~~~~~~~~~~~~~~~~~~~~~ @@ -519,6 +612,8 @@ index. Removes all annotations from the recording. ``--force`` bypasses the confirmation prompt. +**Automatic detection subcommands:** + ``ria annotate energy`` ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -541,6 +636,19 @@ Detects energetic regions above the estimated noise floor and writes them as ann * ``--nfft``, ``--obw-power`` * ``--type``, ``-o`` / ``--output``, ``--overwrite``, ``--quiet`` +``ria annotate threshold`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ria annotate threshold --threshold <0.0..1.0> [options] + +Uses normalized magnitude thresholding to derive annotation spans. Use where a fixed +amplitude threshold is sufficient. + +* ``--label``, ``--window-size``, ``--type``, ``-o`` / ``--output``, ``--overwrite``, + ``--quiet`` + ``ria annotate cusum`` ~~~~~~~~~~~~~~~~~~~~~~~ @@ -557,19 +665,6 @@ contiguous segments. * ``--tolerance``: merges nearby boundaries when set above default. * ``--type``, ``-o`` / ``--output``, ``--overwrite``, ``--quiet`` -``ria annotate threshold`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: bash - - ria annotate threshold --threshold <0.0..1.0> [options] - -Uses normalized magnitude thresholding to derive annotation spans. Use where a fixed -amplitude threshold is sufficient. - -* ``--label``, ``--window-size``, ``--type``, ``-o`` / ``--output``, ``--overwrite``, - ``--quiet`` - ``ria annotate separate`` ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -577,12 +672,17 @@ amplitude threshold is sufficient. ria annotate separate [options] -Decomposes selected annotations into narrower spectral components and emits refined -annotations. +Decomposes existing annotations into narrower sub-band annotations by detecting distinct +frequency components within each annotated time window. It does not detect signal regions +from scratch — run ``energy``, ``threshold``, or ``cusum`` first to produce the input +annotations, then use ``separate`` to refine them spectrally. + +Use this when a single broad annotation covers multiple signals at different frequencies +and you want separate annotations per component. * ``--indices "0,1,2"``: limit operation to specific annotations; omit to process all. * ``--nfft``: larger FFT improves frequency resolution but increases compute time. -* ``--noise-threshold-db``: stabilizes detection across heterogeneous captures. +* ``--noise-threshold-db``: sets the noise floor in dB; auto-estimated if omitted. * ``--min-component-bw``: rejects narrow fragments likely to be noise artifacts. * ``-o`` / ``--output``, ``--overwrite``, ``--quiet``, ``--verbose`` @@ -593,14 +693,208 @@ annotations. ria annotate list capture.sigmf-data --verbose ria annotate add capture.sigmf-data --start 10000 --count 5000 --label burst ria annotate energy capture.sigmf-data --label signal --threshold 1.3 + ria annotate threshold capture.sigmf-data --threshold 0.5 --label signal 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 -.. figure:: ../images/recordings/sample_recording3_annotated.png - :alt: Example output of ria annotate threshold recordings/sample_recording3.npy --threshold 0.7 --label 70% +**Example output:** - Output of ``ria annotate threshold recordings/sample_recording3.npy --threshold 0.7 --label 70%`` +``ria annotate list`` +^^^^^^^^^^^^^^^^^^^^^ + +Inspect all annotations with ``--verbose``: + +.. code-block:: text + + $ ria annotate list sample_recording3_annotated.npy --verbose + + Annotations in sample_recording3_annotated.npy: + [0] Samples 170,599-171,116: signal + Type: standalone + Frequency: 3.41 GHz - 3.41 GHz + Detail: {'generator': 'energy_detector', 'freq_method': 'nbw'} + [1] Samples 182,310-182,841: signal + Type: standalone + Frequency: 3.41 GHz - 3.41 GHz + Detail: {'generator': 'energy_detector', 'freq_method': 'nbw'} + [2] Samples 1,133,165-1,133,706: signal + ... + [7] Samples 2,113,268-2,861,395: signal + Type: standalone + Frequency: 3.41 GHz - 3.41 GHz + Detail: {'generator': 'energy_detector', 'freq_method': 'nbw'} + + Total: 8 annotation(s) + +``ria annotate add`` +^^^^^^^^^^^^^^^^^^^^ + +Add a single annotation by sample index: + +.. code-block:: text + + $ ria annotate add sample_recording3_annotated.npy --start 50000 --count 10000 --label burst -o out.npy + + Loaded: sample_recording3_annotated.npy + + Adding annotation: + Start: 50,000 + Count: 10,000 samples + Frequency: full bandwidth + Label: burst + Type: standalone + Saving to: out.npy + ✓ Saved + +``ria annotate remove`` +^^^^^^^^^^^^^^^^^^^^^^^ + +Remove one annotation by its list index (run ``annotate list`` first to confirm): + +.. code-block:: text + + $ ria annotate remove sample_recording3_annotated.npy 0 -o out.npy + + Loaded: sample_recording3_annotated.npy + + Removing annotation [0]: + Removed: samples 170,599-171,116 (signal) + Saving to: out.npy + ✓ Saved + +``ria annotate clear`` +^^^^^^^^^^^^^^^^^^^^^^ + +Remove all annotations at once: + +.. code-block:: text + + $ ria annotate clear sample_recording3_annotated.npy --force --overwrite + + Loaded: sample_recording3_annotated.npy + + Cleared 8 annotation(s) + Saving to: sample_recording3_annotated.npy + ✓ Saved + +``ria annotate energy`` +^^^^^^^^^^^^^^^^^^^^^^^ + +Auto-detect signal regions above the noise floor: + +.. code-block:: text + + $ ria annotate energy sample_recording3.npy --label signal -o sample_recording3_annotated.npy + + Loaded: sample_recording3.npy + + Detecting signals using energy-based method... + Time detection: + Segments: 10 + Threshold: 1.2x noise floor + Window size: 200 samples + Min distance: 5000 samples + Frequency bounds: nbw + ✓ Added 8 annotation(s) + Saving to: sample_recording3_annotated.npy + ✓ Saved + +.. figure:: ../images/recordings/sample_recording3_annotated.png + :alt: Energy-detected annotations on sample_recording3.npy + + ``ria annotate energy sample_recording3.npy --label signal`` + +``ria annotate threshold`` +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Detect regions above a fixed fraction of peak magnitude: + +.. code-block:: text + + $ ria annotate threshold sample_recording3.npy --threshold 0.7 --label strong -o out.npy + + Loaded: sample_recording3.npy + + Detecting signals using threshold qualifier... + Threshold: 70.0% of max magnitude + Window size: auto (1ms) + Channel: 0 + ✓ Added 2 annotation(s) + Saving to: out.npy + ✓ Saved + +.. figure:: ../images/recordings/sample_recording3_threshold.png + :alt: Threshold annotations on sample_recording3.npy at 0.7 + + ``ria annotate threshold sample_recording3.npy --threshold 0.7 --label strong`` + +``ria annotate cusum`` +^^^^^^^^^^^^^^^^^^^^^^ + +Detect regime changes using change-point detection: + +.. code-block:: text + + $ ria annotate cusum sample_recording3.npy --label regime -o out.npy + + Loaded: sample_recording3.npy + + Detecting segments using CUSUM... + Min duration: 5.0 ms + ✓ Added 37 annotation(s) + Saving to: out.npy + ✓ Saved + +.. figure:: ../images/recordings/sample_recording3_cusum.png + :alt: CUSUM annotations on sample_recording3.npy + + ``ria annotate cusum sample_recording3.npy --label regime`` + +``ria annotate separate`` +^^^^^^^^^^^^^^^^^^^^^^^^^ + +``separate`` takes existing annotations as input and splits each one into narrower +sub-band annotations by finding distinct spectral peaks within the annotated time window. +The typical workflow is to first run ``threshold`` (or ``energy``) to mark signal regions, +then run ``separate`` to resolve the individual frequency components within them. + +Step 1 — create broad annotations with ``threshold``: + +.. code-block:: text + + $ ria annotate threshold sample_recording5.npy --threshold 0.5 --label signal -o annotated.npy + + Loaded: sample_recording5.npy + + Detecting signals using threshold qualifier... + Threshold: 50.0% of max magnitude + Window size: auto (1ms) + Channel: 0 + ✓ Added 3 annotation(s) + Saving to: annotated.npy + ✓ Saved + +Step 2 — run ``separate`` to split by frequency component: + +.. code-block:: text + + $ ria annotate separate annotated.npy -o separated.npy + + Loaded: annotated.npy + + Splitting annotations by frequency components... + Input annotations: 3 + FFT size: 65536 + Noise threshold: auto-estimated + Min component BW: 50.00 kHz + ✓ Output annotations: 6 (+3 change) + Saving to: separated.npy + ✓ Saved + +.. figure:: ../images/recordings/sample_recording5_after_separate.png + :alt: Annotations after separate — split into upper and lower sub-bands + + After ``separate`` — each annotation is resolved into upper and lower frequency components .. _cmd-convert: @@ -643,6 +937,18 @@ inferred from the output file extension. ria convert highrate.npy audio.wav --wav-sample-rate 48000 ria convert old.npy --format sigmf --legacy --overwrite +**Example output:** + +.. code-block:: text + + $ ria convert sample_recording3.npy sample_recording3.sigmf-data + + Converting: sample_recording3.npy → sample_recording3.sigmf-data + Input format: NPY + Output format: SIGMF + Samples: 3,000,000 + Conversion complete: sample_recording3.sigmf-data, sample_recording3.sigmf-meta + .. _cmd-split: @@ -686,6 +992,26 @@ Choose exactly one operation per invocation: ria split recording.npy --trim --start 1000 --length 5000 --output-dir trimmed ria split annotated.sigmf-data --extract-annotations --annotation-label payload +**Example output:** + +.. code-block:: text + + $ ria split sample_recording3.npy --split-every 500000 --output-dir chunks + + Loading: sample_recording3.npy + Total samples: 3,000,000 + + Splitting into chunks of 500,000 samples... + Creating 6 chunks... + Chunk 1/6: samples 0-499,999... + Chunk 2/6: samples 500,000-999,999... + Chunk 3/6: samples 1,000,000-1,499,999... + Chunk 4/6: samples 1,500,000-1,999,999... + Chunk 5/6: samples 2,000,000-2,499,999... + Chunk 6/6: samples 2,500,000-2,999,999... + + Created 6 chunks in chunks/ + .. _cmd-combine: @@ -713,12 +1039,9 @@ Choose exactly one operation per invocation: * ``--overwrite``, ``--metadata KEY=VALUE`` (repeatable) * ``--legacy``, ``--verbose``, ``--quiet`` -**Mode semantics:** - -* ``concat``: append inputs sequentially in time. -* ``add``: sample-wise summation — all inputs must be aligned to the same length. - -**Alignment options for** ``--mode add``: +``--mode concat`` appends inputs sequentially in time. ``--mode add`` performs sample-wise +summation and requires all inputs to be the same length, or an ``--align-mode`` to +reconcile length differences: * ``error``: fail if lengths differ. * ``truncate``: cut all to shortest length. @@ -735,6 +1058,15 @@ Choose exactly one operation per invocation: ria combine long.npy short.npy out.npy --mode add --align-mode pad-center ria combine signal.npy pattern.npy out.npy --mode add --align-mode repeat-spaced --repeat-spacing 10000 +**Example output:** + +.. code-block:: text + + $ ria combine sample_recording3.npy qam64_35.npy combined.npy + + Combining 2 recordings (concat mode)... + Saved to: combined.npy + .. _cmd-generate: @@ -778,76 +1110,45 @@ Choose exactly one operation per invocation: * - ``ook``, ``oqpsk``, ``gmsk`` - On-off keying and continuous-phase modulation schemes -**Common options shared across all generators:** +**Common options** (all subcommands): -* ``-s, --sample-rate`` (required) -* ``-n, --num-samples`` or ``-t, --duration`` -* ``--frequency-shift``, ``-fc`` / ``--center-frequency`` -* ``--add-noise``, ``--noise-power``, ``--path-gain`` -* ``-o, --output`` (required), ``-F`` / ``--format {npy,sigmf,wav,blue}`` -* ``--multipath-paths``, ``--multipath-max-delay`` -* ``--iq-amp-imbalance``, ``--iq-phase-imbalance``, ``--iq-dc-offset`` -* ``--config `` -* ``-w`` / ``--overwrite``, ``-m`` / ``--metadata KEY=VALUE`` (repeatable) -* ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` +* ``-s, --sample-rate`` (required), ``-n, --num-samples`` or ``-t, --duration`` +* ``-o, --output`` (required), ``-F / --format {npy,sigmf,wav,blue}`` +* ``--frequency-shift``, ``--center-frequency``: separate baseband shape from RF metadata. +* ``--add-noise``, ``--noise-power``, ``--path-gain``: apply noise post-generation. +* ``--multipath-paths``, ``--multipath-max-delay``, ``--iq-amp-imbalance``, + ``--iq-phase-imbalance``, ``--iq-dc-offset``: channel and IQ impairments. +* ``--config ``, ``-w / --overwrite``, ``-m / --metadata KEY=VALUE``, + ``-v / --verbose``, ``-q / --quiet`` -``--frequency-shift`` and ``--center-frequency`` let you separate the baseband shape from -RF metadata context. ``--add-noise`` and ``--noise-power`` apply post-generation noise. -Multipath and IQ imbalance flags apply impairment-style post-processing during generation. +**Subcommand options:** -``tone`` -~~~~~~~~~ +.. list-table:: + :widths: 20 80 + :header-rows: 1 -Options: ``--frequency``, ``--amplitude``, ``--phase`` - -``noise`` -~~~~~~~~~~ - -Options: ``--noise-type {gaussian,uniform}``, ``--power`` - -``chirp`` -~~~~~~~~~~ - -Options: ``--bandwidth`` (required), ``--period`` (required), ``--type {up,down,up_down}`` - -``square`` -~~~~~~~~~~~ - -Options: ``--frequency``, ``--amplitude``, ``--duty-cycle``, ``--phase`` - -``sawtooth`` -~~~~~~~~~~~~~ - -Options: ``--frequency``, ``--amplitude``, ``--phase`` - -Digital modulation families: ``qam``, ``apsk``, ``pam``, ``psk`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* ``--symbols``, ``--order`` -* ``--symbol-rate`` -* ``--filter {rrc,rc,gaussian,none}``, ``--filter-span``, ``--filter-beta`` -* ``--message-source {random,file,string}``, ``--message-content`` - -Use ``--message-source random`` for synthetic datasets, ``file`` for deterministic replay, -or ``string`` for small human-readable payload testing. Pulse-shaping filter options -(``--filter``, ``--filter-span``, ``--filter-beta``) control spectral occupancy and ISI. - -``fsk`` -~~~~~~~~ - -Options: ``--symbols``, ``--order``, ``--symbol-rate``, ``--freq-spacing``, -``--modulation-index``, ``--message-source``, ``--message-content`` - -``--freq-spacing`` and ``--modulation-index`` drive tone separation and spectral profile. - -``ook``, ``oqpsk``, ``gmsk`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Options: ``--symbol-rate`` (required), ``--message-source {random,file,string}``, -``--message-content``; ``gmsk`` also accepts ``--bt`` - -``gmsk --bt`` sets the Gaussian filter bandwidth-time product (spectral compactness vs -symbol transition sharpness). + * - Subcommand + - Unique options + * - ``tone`` + - ``--frequency``, ``--amplitude``, ``--phase`` + * - ``noise`` + - ``--noise-type {gaussian,uniform}``, ``--power`` + * - ``chirp`` + - ``--bandwidth`` (required), ``--period`` (required), ``--type {up,down,up_down}`` + * - ``square`` + - ``--frequency``, ``--amplitude``, ``--duty-cycle``, ``--phase`` + * - ``sawtooth`` + - ``--frequency``, ``--amplitude``, ``--phase`` + * - ``qam``, ``apsk``, ``pam``, ``psk`` + - ``--order``, ``--symbol-rate``, ``--filter {rrc,rc,gaussian,none}``, + ``--filter-span``, ``--filter-beta``, + ``--message-source {random,file,string}``, ``--message-content`` + * - ``fsk`` + - ``--order``, ``--symbol-rate``, ``--freq-spacing``, ``--modulation-index``, + ``--message-source``, ``--message-content`` + * - ``ook``, ``oqpsk``, ``gmsk`` + - ``--symbol-rate`` (required), ``--message-source``, ``--message-content``; + ``gmsk`` also accepts ``--bt`` (Gaussian filter bandwidth-time product) **Examples:** @@ -859,6 +1160,20 @@ symbol transition sharpness). ria generate qam -s 2e6 -r 100e3 -M 16 -N 5000 --message-source random -o qam16.npy ria synth psk -s 2e6 -r 100e3 -M 8 -N 8000 -o psk8.npy +**Example output:** + +.. code-block:: text + + $ ria generate tone -s 2e6 -n 100000 --frequency 50e3 -o tone.npy + + Generating tone: 50.00 kHz at 2.00 MS/s + +.. code-block:: text + + $ ria generate qam -s 2e6 -n 50000 --order 16 --symbol-rate 100e3 --message-source random -o qam16.npy + + Generating QAM-16 (2500 symbols)... + .. _cmd-transform: @@ -930,6 +1245,41 @@ inspect parameter hints. ``--view`` writes a PNG preview alongside transform out ria transform custom --transform-dir ./my_transforms --list ria transform custom my_filter in.npy out.npy --transform-dir ./my_transforms --params cutoff=0.2 +**Example output:** + +List available augmentations: + +.. code-block:: text + + $ ria transform augment --list + + Available augmentations: + amplitude_reversal Negates the amplitudes of both the I and Q data samples + channel_swap Switches the I (In-phase) with the Q (Quadrature) data samples + cut_out Cuts out random sections of IQ data and replaces them with zeros + drop_samples Randomly drops IQ data samples + generate_awgn Generates additive white gaussian noise relative to the SNR + magnitude_rescale Selects a random starting point and multiplies IQ data by a scalar + patch_shuffle Selects random patches and shuffles the data samples within them + quantize_parts Quantizes random parts of the IQ data by a few bits + quantize_tape Quantizes the IQ data by a few bits + spectral_inversion Negates the imaginary components (Q) of the data samples + time_reversal Reverses the order of I and Q data samples along the time axis + +Apply an impairment (AWGN at SNR=10 dB): + +.. code-block:: text + + $ ria transform impair add_awgn_to_signal sample_recording3.npy sample_recording3_awgn.npy --params snr=10 + + Impairing: sample_recording3.npy → sample_recording3_awgn.npy + Saved to: sample_recording3_awgn.npy + +.. figure:: ../images/recordings/sample_recording3_awgn.png + :alt: sample_recording3.npy after add_awgn_to_signal at SNR=10 dB + + ``sample_recording3.npy`` after ``add_awgn_to_signal --params snr=10`` + .. _cmd-transmit: @@ -948,30 +1298,21 @@ inspect parameter hints. ``--view`` writes a PNG preview alongside transform out ria transmit [options] -**Input source (choose one):** +If neither ``--input`` nor ``--generate`` is specified, the command defaults to a generated +LFM waveform. +**Options:** + +* ``-d`` / ``--device {pluto,hackrf,bladerf,usrp}``, ``-i`` / ``--ident``, ``-c`` / ``--config`` +* ``-s`` / ``--sample-rate``, ``-f`` / ``--center-frequency``, ``-g`` / ``--gain``, ``-b`` / ``--bandwidth`` * ``--input ``: transmit an existing recording. -* ``--generate {lfm,chirp,sine,pulse}``: synthesize a transmit signal on the fly. -* If neither is specified, the command defaults to a generated LFM waveform. - -**Core options:** - -*Device/radio:* ``-d`` / ``--device {pluto,hackrf,bladerf,usrp}``, ``-i`` / ``--ident``, -``-c`` / ``--config`` - -*RF:* ``-s`` / ``--sample-rate``, ``-f`` / ``--center-frequency``, ``-g`` / ``--gain``, -``-b`` / ``--bandwidth`` - -*Input/gen:* ``--input``, ``--legacy``, ``--generate {lfm,chirp,sine,pulse}`` - -*TX control:* - -* ``-r, --repeat`` +* ``--generate {lfm,chirp,sine,pulse}``: synthesize a signal on the fly. +* ``--legacy``: use older NPY loader for historical recordings. +* ``-r, --repeat``: transmit the input a fixed number of times. * ``--continuous``: transmit until interrupted (``Ctrl+C``). * ``--tx-delay``: pause between repeats when ``--repeat`` is used. * ``-y, --yes``: skip confirmation prompts; use carefully in scripted environments. - -*Logging:* ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` +* ``-v`` / ``--verbose``, ``-q`` / ``--quiet`` .. warning:: ``--continuous`` transmits until manually interrupted. Validate gain settings, antenna @@ -985,6 +1326,25 @@ inspect parameter hints. ``--view`` writes a PNG preview alongside transform out ria transmit -d hackrf --generate lfm -f 2.44G --continuous ria transmit -d usrp --input msg.npy -r 3 --tx-delay 0.5 +**Example output:** + +.. note:: + ``transmit`` requires a TX-capable SDR. The following shows representative output for a + PlutoSDR playback. + +.. code-block:: text + + $ ria transmit -d pluto -f 915e6 -s 2e6 --input capture.sigmf-data + + Initializing PlutoSDR... + URI: ip:192.168.2.1 + Center frequency: 915.00 MHz + Sample rate: 2.00 MS/s + Gain: 0 dB + + Transmitting capture.sigmf-data (500,000 samples)... + Transmit complete. + 5) YAML Config Patterns ======================== @@ -1031,25 +1391,7 @@ experiment-specific fields on the CLI. ria generate noise --config generate.yaml -6) Version Notes -================= - -These notes are based on the current implementation and should be re-validated against future -releases. - -1. Some command docstrings and examples still mention ``utils`` or ``ria_toolkit_oss`` - command prefixes in text blocks. The operational command is ``ria ...``. -2. Command bindings currently import ``viewe`` instead of ``view`` in - ``src/ria_toolkit_oss_cli/ria_toolkit_oss/commands.py``. -3. Multiple non-CLI modules still import ``utils.*``, which can create runtime dependency - coupling when using only ``ria-toolkit-oss`` in isolation. - -.. tip:: - If you observe unexpected import errors after install, check the package version and - changelog, then test ``ria --help`` in a clean virtual environment. - - -7) Brief Scripting (Python) Preview +6) Brief Scripting (Python) Preview ===================================== For quick non-CLI use: From c2dc2e6d43b43a154f64a836bcbe84b302399000 Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 5 May 2026 14:35:41 -0400 Subject: [PATCH 20/28] docs: add generated viewer images for getting_started examples --- .gitignore | 3 ++- docs/source/images/recordings/qam64_35-full.png | 3 +++ docs/source/images/recordings/qam64_35.png | 3 +++ docs/source/images/recordings/sample_recording3-full.png | 3 +++ docs/source/images/recordings/sample_recording3.png | 3 +++ docs/source/images/recordings/sample_recording3_annotated.png | 3 +++ docs/source/images/recordings/sample_recording3_awgn.png | 3 +++ docs/source/images/recordings/sample_recording3_cusum.png | 3 +++ docs/source/images/recordings/sample_recording3_threshold.png | 3 +++ .../images/recordings/sample_recording5_after_separate.png | 3 +++ .../images/recordings/sample_recording5_before_separate.png | 3 +++ 11 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 docs/source/images/recordings/qam64_35-full.png create mode 100644 docs/source/images/recordings/qam64_35.png create mode 100644 docs/source/images/recordings/sample_recording3-full.png create mode 100644 docs/source/images/recordings/sample_recording3.png create mode 100644 docs/source/images/recordings/sample_recording3_annotated.png create mode 100644 docs/source/images/recordings/sample_recording3_awgn.png create mode 100644 docs/source/images/recordings/sample_recording3_cusum.png create mode 100644 docs/source/images/recordings/sample_recording3_threshold.png create mode 100644 docs/source/images/recordings/sample_recording5_after_separate.png create mode 100644 docs/source/images/recordings/sample_recording5_before_separate.png diff --git a/.gitignore b/.gitignore index b2ac7f1..40c654a 100644 --- a/.gitignore +++ b/.gitignore @@ -99,4 +99,5 @@ cython_debug/ *.sigmf-meta *.blue *.wav -images/ +/images/ +!docs/source/images/** diff --git a/docs/source/images/recordings/qam64_35-full.png b/docs/source/images/recordings/qam64_35-full.png new file mode 100644 index 0000000..f515d56 --- /dev/null +++ b/docs/source/images/recordings/qam64_35-full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6da86a26286bd90d8490896314265ab2cfdd4023eddc99af0a7b016537f32a6 +size 1800657 diff --git a/docs/source/images/recordings/qam64_35.png b/docs/source/images/recordings/qam64_35.png new file mode 100644 index 0000000..d603eda --- /dev/null +++ b/docs/source/images/recordings/qam64_35.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7caf225dca79d4f82fa75739773b7706586787573f7dc551c3b86128c99d0d26 +size 1396884 diff --git a/docs/source/images/recordings/sample_recording3-full.png b/docs/source/images/recordings/sample_recording3-full.png new file mode 100644 index 0000000..dea542f --- /dev/null +++ b/docs/source/images/recordings/sample_recording3-full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9cb29f6d572674dde3304e8241ba6874ecae8b3e9567314198af46a0d054a29 +size 6839769 diff --git a/docs/source/images/recordings/sample_recording3.png b/docs/source/images/recordings/sample_recording3.png new file mode 100644 index 0000000..82cc563 --- /dev/null +++ b/docs/source/images/recordings/sample_recording3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:526afa9fbcc00be07972a5193fb21c1fb3f975512c0ab71517062e8fb091a855 +size 2769511 diff --git a/docs/source/images/recordings/sample_recording3_annotated.png b/docs/source/images/recordings/sample_recording3_annotated.png new file mode 100644 index 0000000..c6367c1 --- /dev/null +++ b/docs/source/images/recordings/sample_recording3_annotated.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbe7e40924e8551e9878bcaa1a98379b2cfb071e7314475ed441fa24b95e5f4f +size 5018447 diff --git a/docs/source/images/recordings/sample_recording3_awgn.png b/docs/source/images/recordings/sample_recording3_awgn.png new file mode 100644 index 0000000..5687f7d --- /dev/null +++ b/docs/source/images/recordings/sample_recording3_awgn.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07e3d72ea90838232a5856b16c8eadc1aa11023f19b35390d63951e1f1b85042 +size 2858110 diff --git a/docs/source/images/recordings/sample_recording3_cusum.png b/docs/source/images/recordings/sample_recording3_cusum.png new file mode 100644 index 0000000..be08b3d --- /dev/null +++ b/docs/source/images/recordings/sample_recording3_cusum.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bbf4664f420374d253800f68755aae532cd6472c1353741cbb2ea1ed82c5a03 +size 5020157 diff --git a/docs/source/images/recordings/sample_recording3_threshold.png b/docs/source/images/recordings/sample_recording3_threshold.png new file mode 100644 index 0000000..0511019 --- /dev/null +++ b/docs/source/images/recordings/sample_recording3_threshold.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfb604e93c467074a986d03bdc833a470f7259de5614a0f54d35a91153ab5d1c +size 5029060 diff --git a/docs/source/images/recordings/sample_recording5_after_separate.png b/docs/source/images/recordings/sample_recording5_after_separate.png new file mode 100644 index 0000000..1209e8b --- /dev/null +++ b/docs/source/images/recordings/sample_recording5_after_separate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08f7db98c90f464cb587d37772448fa76977459b4d09c66e0182065e4d74aa15 +size 4465342 diff --git a/docs/source/images/recordings/sample_recording5_before_separate.png b/docs/source/images/recordings/sample_recording5_before_separate.png new file mode 100644 index 0000000..6d574cb --- /dev/null +++ b/docs/source/images/recordings/sample_recording5_before_separate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66d5e9b528c1e7527e3ed3689e05ee94cd4df93a0c63e4884283dd15eb0eced7 +size 4460772 From 3b8b55ae7a6fbd9f2df7357e5c39dfb11170624c Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 12 May 2026 12:19:00 -0400 Subject: [PATCH 21/28] Fix flake8 E501 line too long in annotate.py separate() signature Co-Authored-By: Claude Sonnet 4.6 --- src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py index d551d2c..d8024eb 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -782,7 +782,8 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, sa @click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--quiet", is_flag=True, help="Quiet mode") @click.option("--verbose", is_flag=True, help="Verbose output (show detected components)") -def separate(input, indices, nfft, noise_threshold_db, min_component_bw, sample_rate, output, overwrite, quiet, verbose): +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. From f23bac08a17da61e90a88aacace90e85cdb078d0 Mon Sep 17 00:00:00 2001 From: fordg1 Date: Tue, 12 May 2026 13:10:40 -0400 Subject: [PATCH 22/28] Fix flake8 E501 and C901 violations in annotate.py and annotation classes Co-Authored-By: Claude Sonnet 4.6 --- .../annotations/cusum_annotator.py | 3 +- .../annotations/energy_detector.py | 3 +- .../annotations/parallel_signal_separator.py | 3 +- .../annotations/threshold_qualifier.py | 3 +- .../ria_toolkit_oss/annotate.py | 46 +++++++++++++------ 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/ria_toolkit_oss/annotations/cusum_annotator.py b/src/ria_toolkit_oss/annotations/cusum_annotator.py index 9556125..22ca4c4 100644 --- a/src/ria_toolkit_oss/annotations/cusum_annotator.py +++ b/src/ria_toolkit_oss/annotations/cusum_annotator.py @@ -42,7 +42,8 @@ def annotate_with_cusum( 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." + "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) diff --git a/src/ria_toolkit_oss/annotations/energy_detector.py b/src/ria_toolkit_oss/annotations/energy_detector.py index 1a482bc..7a554c0 100644 --- a/src/ria_toolkit_oss/annotations/energy_detector.py +++ b/src/ria_toolkit_oss/annotations/energy_detector.py @@ -151,7 +151,8 @@ def detect_signals_energy( 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." + "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) diff --git a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py index 4e08353..2838ede 100644 --- a/src/ria_toolkit_oss/annotations/parallel_signal_separator.py +++ b/src/ria_toolkit_oss/annotations/parallel_signal_separator.py @@ -406,7 +406,8 @@ def split_recording_annotations( 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." + "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) diff --git a/src/ria_toolkit_oss/annotations/threshold_qualifier.py b/src/ria_toolkit_oss/annotations/threshold_qualifier.py index 24ef833..d8701ca 100644 --- a/src/ria_toolkit_oss/annotations/threshold_qualifier.py +++ b/src/ria_toolkit_oss/annotations/threshold_qualifier.py @@ -221,7 +221,8 @@ def threshold_qualifier( 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." + "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) diff --git a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py index d8024eb..6477d40 100644 --- a/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py +++ b/src/ria_toolkit_oss_cli/ria_toolkit_oss/annotate.py @@ -501,7 +501,10 @@ def clear(input, output, overwrite, force, quiet): default="standalone", 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( + "--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("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--quiet", is_flag=True, help="Quiet mode") @@ -613,7 +616,10 @@ def energy( default="standalone", 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( + "--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("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--quiet", is_flag=True, help="Quiet mode") @@ -700,7 +706,10 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, s help="Annotation type", ) @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( + "--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("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--quiet", is_flag=True, help="Quiet mode") @@ -777,11 +786,28 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, sa @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("--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( + "--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("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)") @click.option("--quiet", is_flag=True, help="Quiet mode") @click.option("--verbose", is_flag=True, help="Verbose output (show detected components)") +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): """ @@ -835,17 +861,7 @@ def separate( click.echo("No annotations to split") return - 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)}") + _log_separate_start(quiet, recording, indices_list, nfft, noise_threshold_db, min_component_bw) try: initial_count = len(recording.annotations) From 70c790cadd25b4951495f299c63a8cc77fb3c6e9 Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 12 May 2026 13:22:08 -0400 Subject: [PATCH 23/28] Drop Python 3.10 support, minimum is now 3.11 Co-Authored-By: Claude Sonnet 4.6 --- .readthedocs.yaml | 2 +- .riahub/workflows/build-project.yaml | 2 +- .riahub/workflows/tox.yaml | 2 +- README.md | 2 +- pyproject.toml | 4 ++-- tox.ini | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index a6ab77b..9965c06 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.10" + python: "3.11" jobs: post_create_environment: # Install poetry diff --git a/.riahub/workflows/build-project.yaml b/.riahub/workflows/build-project.yaml index 1651656..2cbd258 100644 --- a/.riahub/workflows/build-project.yaml +++ b/.riahub/workflows/build-project.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.10', '3.11', '3.12' ] + python-version: [ '3.11', '3.12' ] name: Build Project steps: diff --git a/.riahub/workflows/tox.yaml b/.riahub/workflows/tox.yaml index 84b0f6b..fc31def 100644 --- a/.riahub/workflows/tox.yaml +++ b/.riahub/workflows/tox.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.11', '3.12'] name: Test with tox steps: diff --git a/README.md b/README.md index d499a75..30d5c32 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - Python Version + Python Version

diff --git a/pyproject.toml b/pyproject.toml index 48a9e1c..1acd4f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.5" description = "An open-source version of the RIA Toolkit, including the fundamental tools to get started developing, testing, and deploying radio intelligence applications" license = { text = "AGPL-3.0-only" } readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [ { name = "Qoherent Inc.", email = "info@qoherent.ai" }, ] @@ -128,7 +128,7 @@ onnxruntime = {version = ">=1.17,<2.0", python = ">=3.11"} [tool.black] line-length = 119 -target-version = ["py310"] +target-version = ["py311"] exclude = ''' /( \.git diff --git a/tox.ini b/tox.ini index 107b46b..f7a3827 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] isolated_build = true -envlist = py310, py311, py312, lint +envlist = py311, py312, lint skipsdist = true [testenv] @@ -30,6 +30,6 @@ per-file-ignores = __init__.py:F401 [gh-actions] python = - 3.10: py310, lint + 3.11: py311, lint 3.11: py311 3.12: py312 From 657dd0d499bb5af6fddfe5ef07c7ee315bf2e105 Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 12 May 2026 13:32:52 -0400 Subject: [PATCH 24/28] Fix duplicate 3.11 key in tox gh-actions config Co-Authored-By: Claude Sonnet 4.6 --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index f7a3827..ffd59ee 100644 --- a/tox.ini +++ b/tox.ini @@ -31,5 +31,4 @@ per-file-ignores = __init__.py:F401 [gh-actions] python = 3.11: py311, lint - 3.11: py311 3.12: py312 From 57d1d6e55ea2cf51142ffe4823617ea71a2c3f27 Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 12 May 2026 13:39:05 -0400 Subject: [PATCH 25/28] Revert "Fix duplicate 3.11 key in tox gh-actions config" This reverts commit 657dd0d499bb5af6fddfe5ef07c7ee315bf2e105. --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index ffd59ee..f7a3827 100644 --- a/tox.ini +++ b/tox.ini @@ -31,4 +31,5 @@ per-file-ignores = __init__.py:F401 [gh-actions] python = 3.11: py311, lint + 3.11: py311 3.12: py312 From 7ef3fe8fb106eacbaf8090ce07f31972833e6d57 Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 12 May 2026 13:39:05 -0400 Subject: [PATCH 26/28] Revert "Drop Python 3.10 support, minimum is now 3.11" This reverts commit 70c790cadd25b4951495f299c63a8cc77fb3c6e9. --- .readthedocs.yaml | 2 +- .riahub/workflows/build-project.yaml | 2 +- .riahub/workflows/tox.yaml | 2 +- README.md | 2 +- pyproject.toml | 4 ++-- tox.ini | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 9965c06..a6ab77b 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.11" + python: "3.10" jobs: post_create_environment: # Install poetry diff --git a/.riahub/workflows/build-project.yaml b/.riahub/workflows/build-project.yaml index 2cbd258..1651656 100644 --- a/.riahub/workflows/build-project.yaml +++ b/.riahub/workflows/build-project.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.11', '3.12' ] + python-version: [ '3.10', '3.11', '3.12' ] name: Build Project steps: diff --git a/.riahub/workflows/tox.yaml b/.riahub/workflows/tox.yaml index fc31def..84b0f6b 100644 --- a/.riahub/workflows/tox.yaml +++ b/.riahub/workflows/tox.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12'] name: Test with tox steps: diff --git a/README.md b/README.md index 30d5c32..d499a75 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - Python Version + Python Version

diff --git a/pyproject.toml b/pyproject.toml index 1acd4f0..48a9e1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.5" description = "An open-source version of the RIA Toolkit, including the fundamental tools to get started developing, testing, and deploying radio intelligence applications" license = { text = "AGPL-3.0-only" } readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.10" authors = [ { name = "Qoherent Inc.", email = "info@qoherent.ai" }, ] @@ -128,7 +128,7 @@ onnxruntime = {version = ">=1.17,<2.0", python = ">=3.11"} [tool.black] line-length = 119 -target-version = ["py311"] +target-version = ["py310"] exclude = ''' /( \.git diff --git a/tox.ini b/tox.ini index f7a3827..107b46b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] isolated_build = true -envlist = py311, py312, lint +envlist = py310, py311, py312, lint skipsdist = true [testenv] @@ -30,6 +30,6 @@ per-file-ignores = __init__.py:F401 [gh-actions] python = - 3.11: py311, lint + 3.10: py310, lint 3.11: py311 3.12: py312 From 98037a0d1638b0810074ac49a7984802a6ca1925 Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 12 May 2026 14:40:17 -0400 Subject: [PATCH 27/28] Bump version to 0.1.6 --- docs/source/conf.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index f1f67e3..07c1ea7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,7 +14,7 @@ sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) project = 'ria-toolkit-oss' copyright = '2026, Qoherent Inc' author = 'Qoherent Inc.' -release = '0.1.5' +release = '0.1.6' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 48a9e1c..e4a91d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] 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" license = { text = "AGPL-3.0-only" } readme = "README.md" From 84400b53c4bcd7a20dcbc8cec20597658a8965ab Mon Sep 17 00:00:00 2001 From: gillian Date: Tue, 12 May 2026 14:44:24 -0400 Subject: [PATCH 28/28] Updated year to 2026 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d499a75..b471315 100644 --- a/README.md +++ b/README.md @@ -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: ``` -[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! ⭐