From 2d29359809bb6d4575282a043ea01a4a51bd75f3 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 11:21:58 -0400 Subject: [PATCH] feat(adr-117/p3+p3.5): vitals + BFLD bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 — Vital sign extraction bindings (wifi-densepose-vitals): - VitalStatus enum (eq, eq_int, hash, frozen) — Valid/Degraded/Unreliable/Unavailable - VitalEstimate (frozen) — value_bpm + confidence + status - VitalReading (frozen) — HR + BR + signal quality composite - BreathingExtractor — 0.1–0.5 Hz bandpass + zero-crossing - HeartRateExtractor — 0.8–2.0 Hz bandpass + autocorrelation - py.allow_threads on extract() hot loops (Q5 audit confirmed core/vitals/signal are pure-sync — zero tokio deps, safe to release GIL with no embedded runtime needed) - 17 tests covering construction, getters, frozen immutability, esp32_default + explicit ctors, synthetic-signal end-to-end P3.5 — BFLD bindings (forward-compat surface, stub Rust): - BfldKind enum — CompressedHE20/40/80/160 + UncompressedHT20/40 with n_subcarriers, bandwidth_mhz, is_he metadata getters - BfldFrame (frozen) — from_compressed_feedback() accepts numpy Complex64 ndarray [Nr x Nc x Nsc], validates dims against kind, feedback_matrix() returns lossless roundtrip ndarray - BfldReport — aggregates frames, rejects mismatched kinds, computes inverse-CV coherence score - 19 tests covering all 6 PHY variants + numpy roundtrip + dim-mismatch error + aggregation - Real Rust ingestion (wifi-densepose-bfld crate) lands post-v2.0 per ADR-117 §11.11/12 — Python API will not change Total Python test count: 93 (was 57, +36 P3+P3.5). All passing. Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md Refs: #785 Co-Authored-By: claude-flow --- python/Cargo.lock | 920 ++++++++++++++++++++++++++++++ python/Cargo.toml | 8 + python/README.md | 21 +- python/src/bindings/bfld.rs | 344 +++++++++++ python/src/bindings/vitals.rs | 287 ++++++++++ python/src/lib.rs | 9 + python/tests/test_bfld.py | 263 +++++++++ python/tests/test_vitals.py | 196 +++++++ python/wifi_densepose/__init__.py | 22 + 9 files changed, 2065 insertions(+), 5 deletions(-) create mode 100644 python/Cargo.lock create mode 100644 python/src/bindings/bfld.rs create mode 100644 python/src/bindings/vitals.rs create mode 100644 python/tests/test_bfld.py create mode 100644 python/tests/test_vitals.py diff --git a/python/Cargo.lock b/python/Cargo.lock new file mode 100644 index 00000000..2337355a --- /dev/null +++ b/python/Cargo.lock @@ -0,0 +1,920 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", + "serde", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "numpy" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e" +dependencies = [ + "libc", + "ndarray 0.16.1", + "num-complex", + "num-integer", + "num-traits", + "pyo3", + "rustc-hash", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wifi-densepose-core" +version = "0.3.0" +dependencies = [ + "chrono", + "ndarray 0.17.2", + "num-complex", + "num-traits", + "thiserror", + "uuid", +] + +[[package]] +name = "wifi-densepose-py" +version = "2.0.0-alpha.1" +dependencies = [ + "numpy", + "pyo3", + "wifi-densepose-core", + "wifi-densepose-vitals", +] + +[[package]] +name = "wifi-densepose-vitals" +version = "0.3.0" +dependencies = [ + "serde", + "tracing", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/python/Cargo.toml b/python/Cargo.toml index d42a6062..be4542c6 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -35,6 +35,14 @@ pyo3 = { version = "0.22", features = ["extension-module", "abi3-py310"] } # budget by avoiding optional BLAS/openssl chains. wifi-densepose-core = { version = "0.3.0", path = "../v2/crates/wifi-densepose-core" } +# P3 — vitals extraction (HR/BR via the 4-stage pipeline). Pure-sync; +# no tokio (Q5 audited 2026-05-24); safe to wrap in py.allow_threads. +wifi-densepose-vitals = { version = "0.3.0", path = "../v2/crates/wifi-densepose-vitals" } + +# numpy bridge — needed for P3.5 BfldFrame (Complex64 ndarray) and for +# the future P3 CsiFrame numpy round-trip. +numpy = "0.22" + [dev-dependencies] # Doc-test infrastructure for the Python-facing examples in the bound # Rust functions. Lands properly in P2 once #[pyfunction]s exist to test. diff --git a/python/README.md b/python/README.md index e1175f4e..5e34bb6c 100644 --- a/python/README.md +++ b/python/README.md @@ -49,13 +49,24 @@ python/ ## Phase status (per ADR-117 §6) -- ✅ **P1 — Scaffold (this commit)**: module loads, version constant - exposed, 6 smoke tests pass via `maturin develop`. -- ⏳ **P2 — Core type bindings**: `CsiFrame`, `Keypoint`, `PoseEstimate`. -- ⏳ **P3 — Vitals + signal DSP**: 4-stage HR/BR pipeline + `CsiProcessor` - + `PhaseSanitizer`, with `allow_threads` GIL release on hot loops. +- ✅ **P1 — Scaffold**: module loads, version constant exposed, + 6 smoke tests pass via `maturin develop`. +- ✅ **P2 — Core type bindings**: `Keypoint`, `KeypointType`, + `BoundingBox`, `PersonPose`, `PoseEstimate`. 51 additional tests. +- ✅ **P3 — Vitals + signal DSP**: `VitalStatus`, `VitalEstimate`, + `VitalReading`, `BreathingExtractor`, `HeartRateExtractor` with + `py.allow_threads` GIL release on hot loops (Q5 tokio audit on + 2026-05-24 confirmed core/vitals/signal are pure-sync). 17 tests. +- ✅ **P3.5 — BFLD bindings (stub Rust)**: `BfldKind`, `BfldFrame`, + `BfldReport` — forward-compatible Python surface for 802.11ac/ax/be + Beamforming Feedback Loop Data. numpy Complex64 bridge. 19 tests. + Real Rust ingestion lands post-v2.0 in a `wifi-densepose-bfld` + crate (see ADR-117 §11.11/12); the Python API does not change. - ⏳ **P4 — WS/MQTT client**: pure-Python `wifi_densepose.client` extra. - ⏳ **P5 — cibuildwheel + PyPI publish**: Linux/macOS/Windows × abi3-py310. +- ⏳ **P-tomb — v1.99.0 tombstone wheel**: pure-Python ImportError + with migration URL, published to PyPI to soft-fence v1.x users + before v2.0 ships. Each phase ends with a checkbox PR. Tests are additive — every phase's smoke tests must still pass after later phases land. diff --git a/python/src/bindings/bfld.rs b/python/src/bindings/bfld.rs new file mode 100644 index 00000000..bd83ac88 --- /dev/null +++ b/python/src/bindings/bfld.rs @@ -0,0 +1,344 @@ +//! ADR-117 P3.5 — Beamforming Feedback Loop Data (BFLD) bindings. +//! +//! BFLD is the transmitter-side, AP-station-loop view of the WiFi +//! channel — compressed beamforming feedback frames that 802.11ac/ax/be +//! stations send to the AP per sounding cycle. See ADR-117 §5.7a for +//! the design rationale and ADR-117 §11.11/12 for open questions. +//! +//! **Important**: there is NO Rust ingestion crate for BFLD yet. The +//! Python types in this module ship with a **stub Rust impl** that +//! accepts pre-parsed feedback matrices via numpy. When the future +//! `wifi-densepose-bfld` crate lands, it plugs in here without changing +//! the Python API. +//! +//! Today's user path: +//! +//! 1. Capture BFR frames with `tcpdump` / Wireshark + the BFR dissector +//! (or via `mac80211` debugfs on Linux 6.10+) +//! 2. Parse the compressed feedback into a numpy Complex64 ndarray +//! `[Nr × Nc × Nsc]` using your favourite Python BFR parser +//! 3. Construct `BfldFrame.from_compressed_feedback(...)` to hand the +//! matrix to RuView +//! +//! Tomorrow (post-v2.0): `wifi-densepose-bfld` does steps 1+2 for you. + +use pyo3::prelude::*; +use numpy::{Complex64, PyArray3, PyUntypedArrayMethods, PyReadonlyArray3}; + +// ─── BfldKind ──────────────────────────────────────────────────────── + +/// 802.11 PHY variant of the captured BFR frame. Determines the +/// expected matrix dimensions + the quantization step of the +/// compressed angles. +/// +/// Python: +/// ```python +/// from wifi_densepose import BfldKind +/// BfldKind.CompressedHE80 # 802.11ax 80 MHz compressed BFR +/// ``` +#[pyclass(eq, eq_int, hash, frozen, name = "BfldKind")] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum PyBfldKind { + CompressedHE20 = 0, + CompressedHE40 = 1, + CompressedHE80 = 2, + CompressedHE160 = 3, + UncompressedHT20 = 4, + UncompressedHT40 = 5, +} + +#[pymethods] +impl PyBfldKind { + /// Expected number of subcarriers for this BFLD variant. + #[getter] + fn n_subcarriers(&self) -> usize { + match self { + Self::CompressedHE20 => 242, + Self::CompressedHE40 => 484, + Self::CompressedHE80 => 996, + Self::CompressedHE160 => 1992, + Self::UncompressedHT20 => 52, + Self::UncompressedHT40 => 108, + } + } + + /// Bandwidth in MHz for this BFLD variant. + #[getter] + fn bandwidth_mhz(&self) -> u16 { + match self { + Self::CompressedHE20 | Self::UncompressedHT20 => 20, + Self::CompressedHE40 | Self::UncompressedHT40 => 40, + Self::CompressedHE80 => 80, + Self::CompressedHE160 => 160, + } + } + + /// True for 802.11ax (HE) variants, false for legacy HT. + #[getter] + fn is_he(&self) -> bool { + matches!( + self, + Self::CompressedHE20 + | Self::CompressedHE40 + | Self::CompressedHE80 + | Self::CompressedHE160 + ) + } + + fn __repr__(&self) -> String { + let name = match self { + Self::CompressedHE20 => "CompressedHE20", + Self::CompressedHE40 => "CompressedHE40", + Self::CompressedHE80 => "CompressedHE80", + Self::CompressedHE160 => "CompressedHE160", + Self::UncompressedHT20 => "UncompressedHT20", + Self::UncompressedHT40 => "UncompressedHT40", + }; + format!("BfldKind.{}", name) + } +} + +// ─── BfldFrame ─────────────────────────────────────────────────────── + +/// One BFR snapshot: a compressed beamforming feedback matrix tagged +/// with metadata (timestamp, sounding sequence, source MAC, kind). +/// +/// Backing storage: a numpy Complex64 ndarray `[Nr × Nc × Nsc]`. The +/// Python constructor accepts the ndarray directly; under the hood we +/// hold a `Vec` in row-major order. +/// +/// Python: +/// ```python +/// import numpy as np +/// from wifi_densepose import BfldFrame, BfldKind +/// +/// fb = np.zeros((2, 1, 996), dtype=np.complex64) # Nr=2, Nc=1, Nsc=996 +/// frame = BfldFrame.from_compressed_feedback( +/// timestamp_ms=1234, +/// sounding_index=42, +/// sta_mac="aa:bb:cc:dd:ee:ff", +/// kind=BfldKind.CompressedHE80, +/// feedback_matrix=fb, +/// ) +/// print(frame.n_subcarriers, frame.kind, frame.n_rows, frame.n_cols) +/// ``` +#[pyclass(frozen, name = "BfldFrame")] +pub struct PyBfldFrame { + timestamp_ms: i64, + sounding_index: u32, + sta_mac: String, + kind: PyBfldKind, + n_rows: usize, + n_cols: usize, + n_subcarriers: usize, + // Row-major storage of the [Nr × Nc × Nsc] complex matrix. + // Length = n_rows * n_cols * n_subcarriers. + matrix: Vec, +} + +#[pymethods] +impl PyBfldFrame { + /// Construct from a pre-parsed Complex64 ndarray of shape + /// `[n_rows, n_cols, n_subcarriers]`. The last dimension MUST + /// match `kind.n_subcarriers`. + #[staticmethod] + fn from_compressed_feedback<'py>( + timestamp_ms: i64, + sounding_index: u32, + sta_mac: &str, + kind: PyBfldKind, + feedback_matrix: PyReadonlyArray3<'py, Complex64>, + ) -> PyResult { + let shape = feedback_matrix.shape(); + let n_rows = shape[0]; + let n_cols = shape[1]; + let n_subcarriers = shape[2]; + let expected = kind.n_subcarriers(); + if n_subcarriers != expected { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "feedback_matrix subcarrier dim {} does not match {:?}.n_subcarriers={}", + n_subcarriers, kind, expected + ))); + } + // Copy into row-major Vec. This is the safe path; PyArray3 is + // also row-major by default. + let matrix: Vec = feedback_matrix + .as_array() + .iter() + .copied() + .collect(); + Ok(Self { + timestamp_ms, + sounding_index, + sta_mac: sta_mac.to_string(), + kind, + n_rows, + n_cols, + n_subcarriers, + matrix, + }) + } + + #[getter] + fn timestamp_ms(&self) -> i64 { self.timestamp_ms } + + #[getter] + fn sounding_index(&self) -> u32 { self.sounding_index } + + #[getter] + fn sta_mac(&self) -> &str { &self.sta_mac } + + #[getter] + fn kind(&self) -> PyBfldKind { self.kind } + + #[getter] + fn n_rows(&self) -> usize { self.n_rows } + + #[getter] + fn n_cols(&self) -> usize { self.n_cols } + + #[getter] + fn n_subcarriers(&self) -> usize { self.n_subcarriers } + + /// Mean amplitude across the entire matrix (sanity-check metric; + /// production-grade sensing pipelines look at per-subcarrier or + /// per-row stats instead). + #[getter] + fn mean_amplitude(&self) -> f64 { + if self.matrix.is_empty() { + return 0.0; + } + let sum: f64 = self.matrix.iter().map(|c| c.norm()).sum(); + sum / self.matrix.len() as f64 + } + + /// Return the feedback matrix as a numpy Complex64 ndarray of + /// shape `[n_rows, n_cols, n_subcarriers]`. Allocates a fresh + /// Python-owned array; the BfldFrame keeps its own copy. + fn feedback_matrix<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray3> { + PyArray3::from_vec3_bound( + py, + &self.reshape_to_vec3(), + ) + .expect("Vec dimensions match the matrix shape — invariant of from_compressed_feedback") + } + + fn __repr__(&self) -> String { + format!( + "BfldFrame(kind={:?}, nr={}, nc={}, nsc={}, sta={}, idx={}, mean_amp={:.4})", + self.kind, self.n_rows, self.n_cols, self.n_subcarriers, + self.sta_mac, self.sounding_index, self.mean_amplitude(), + ) + } +} + +impl PyBfldFrame { + fn reshape_to_vec3(&self) -> Vec>> { + let mut out = Vec::with_capacity(self.n_rows); + for r in 0..self.n_rows { + let mut row = Vec::with_capacity(self.n_cols); + for c in 0..self.n_cols { + let start = (r * self.n_cols + c) * self.n_subcarriers; + let end = start + self.n_subcarriers; + row.push(self.matrix[start..end].to_vec()); + } + out.push(row); + } + out + } +} + +// ─── BfldReport ────────────────────────────────────────────────────── + +/// Aggregator over a window of `BfldFrame`s — the natural "all BFR +/// data in this 60-second scan" container. Mirrors how `VitalReading` +/// aggregates `VitalEstimate`s in the vitals pipeline. +#[pyclass(name = "BfldReport")] +pub struct PyBfldReport { + frames: Vec, // sounding indices we hold (don't deep-copy the matrices) + timestamp_first: Option, + timestamp_last: Option, + kind: Option, + mean_amplitudes: Vec, // one per frame +} + +#[pymethods] +impl PyBfldReport { + #[new] + fn new() -> Self { + Self { + frames: Vec::new(), + timestamp_first: None, + timestamp_last: None, + kind: None, + mean_amplitudes: Vec::new(), + } + } + + /// Add a frame to the report. All frames must share the same + /// `kind`; the call errors if they don't. + fn add_frame(&mut self, frame: &PyBfldFrame) -> PyResult<()> { + if let Some(k) = self.kind { + if k != frame.kind { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "frame kind {:?} does not match report kind {:?}", + frame.kind, k + ))); + } + } else { + self.kind = Some(frame.kind); + } + self.frames.push(frame.sounding_index); + self.timestamp_first = Some(self.timestamp_first.unwrap_or(frame.timestamp_ms).min(frame.timestamp_ms)); + self.timestamp_last = Some(self.timestamp_last.unwrap_or(frame.timestamp_ms).max(frame.timestamp_ms)); + self.mean_amplitudes.push(frame.mean_amplitude()); + Ok(()) + } + + #[getter] + fn n_frames(&self) -> usize { self.frames.len() } + + #[getter] + fn timestamp_first(&self) -> Option { self.timestamp_first } + + #[getter] + fn timestamp_last(&self) -> Option { self.timestamp_last } + + #[getter] + fn kind(&self) -> Option { self.kind } + + /// Mean of the per-frame mean amplitudes — coarse sanity metric + /// for "the scan captured a stable signal over the window". + #[getter] + fn coherence_score(&self) -> f64 { + if self.mean_amplitudes.is_empty() { + return 0.0; + } + let mean = self.mean_amplitudes.iter().sum::() + / self.mean_amplitudes.len() as f64; + if mean == 0.0 { + return 0.0; + } + // Inverse coefficient of variation, clamped to [0, 1]. + let var = self.mean_amplitudes.iter() + .map(|m| (m - mean).powi(2)) + .sum::() + / self.mean_amplitudes.len() as f64; + let cv = var.sqrt() / mean; + (1.0 - cv.min(1.0)).max(0.0) + } + + fn __repr__(&self) -> String { + format!( + "BfldReport(n_frames={}, kind={:?}, coherence={:.3})", + self.frames.len(), self.kind, self.coherence_score(), + ) + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/src/bindings/vitals.rs b/python/src/bindings/vitals.rs new file mode 100644 index 00000000..ce9613b7 --- /dev/null +++ b/python/src/bindings/vitals.rs @@ -0,0 +1,287 @@ +//! ADR-117 P3 — PyO3 bindings for `wifi_densepose_vitals`. +//! +//! Surfaces: +//! +//! - `VitalStatus` enum — clinical-grade / degraded / unreliable / unavailable +//! - `VitalEstimate` — single BPM estimate + confidence + status +//! - `VitalReading` — combined HR + BR + signal quality snapshot +//! - `BreathingExtractor` — bandpass 0.1–0.5 Hz → respiratory rate +//! - `HeartRateExtractor` — bandpass 0.8–2.0 Hz + autocorrelation → HR +//! +//! ## GIL release strategy (per ADR-117 §7 and the Q5 audit on +//! 2026-05-24) +//! +//! `wifi-densepose-vitals` has zero tokio deps and the extract loops +//! are pure-sync DSP. Wrap the `.extract(...)` calls in +//! `py.allow_threads(|| ...)` so Python users can run inference in a +//! tokio-backed web server without GIL contention starving the +//! event loop. + +use pyo3::prelude::*; + +use wifi_densepose_vitals::{ + BreathingExtractor, HeartRateExtractor, VitalEstimate, VitalReading, VitalStatus, +}; + +// ─── VitalStatus enum ──────────────────────────────────────────────── + +/// Status of a vital sign measurement. +/// +/// Python: +/// ```python +/// from wifi_densepose import VitalStatus +/// VitalStatus.Valid # clinical-grade +/// VitalStatus.Degraded # reduced confidence +/// VitalStatus.Unreliable # single RSSI source / low quality +/// VitalStatus.Unavailable # no measurement possible +/// ``` +#[pyclass(eq, eq_int, hash, frozen, name = "VitalStatus")] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub enum PyVitalStatus { + Valid = 0, + Degraded = 1, + Unreliable = 2, + Unavailable = 3, +} + +#[pymethods] +impl PyVitalStatus { + fn __repr__(&self) -> String { + format!("VitalStatus.{:?}", self.as_rust()) + } +} + +impl PyVitalStatus { + fn as_rust(&self) -> VitalStatus { + match self { + Self::Valid => VitalStatus::Valid, + Self::Degraded => VitalStatus::Degraded, + Self::Unreliable => VitalStatus::Unreliable, + Self::Unavailable => VitalStatus::Unavailable, + } + } + + fn from_rust(s: VitalStatus) -> Self { + match s { + VitalStatus::Valid => Self::Valid, + VitalStatus::Degraded => Self::Degraded, + VitalStatus::Unreliable => Self::Unreliable, + VitalStatus::Unavailable => Self::Unavailable, + } + } +} + +// ─── VitalEstimate ─────────────────────────────────────────────────── + +/// A single vital-sign estimate (BPM + confidence + status). +/// +/// Python: +/// ```python +/// from wifi_densepose import VitalEstimate, VitalStatus +/// est = VitalEstimate(72.4, confidence=0.9, status=VitalStatus.Valid) +/// print(est.value_bpm, est.confidence, est.status) +/// ``` +#[pyclass(frozen, name = "VitalEstimate")] +#[derive(Clone)] +pub struct PyVitalEstimate { + inner: VitalEstimate, +} + +#[pymethods] +impl PyVitalEstimate { + #[new] + fn new(value_bpm: f64, confidence: f64, status: PyVitalStatus) -> Self { + Self { + inner: VitalEstimate { + value_bpm, + confidence, + status: status.as_rust(), + }, + } + } + + #[getter] + fn value_bpm(&self) -> f64 { self.inner.value_bpm } + + #[getter] + fn confidence(&self) -> f64 { self.inner.confidence } + + #[getter] + fn status(&self) -> PyVitalStatus { PyVitalStatus::from_rust(self.inner.status) } + + fn __repr__(&self) -> String { + format!( + "VitalEstimate(value_bpm={:.2}, confidence={:.3}, status={:?})", + self.inner.value_bpm, self.inner.confidence, self.inner.status, + ) + } +} + +impl PyVitalEstimate { + fn from_rust(e: VitalEstimate) -> Self { + Self { inner: e } + } +} + +// ─── VitalReading ──────────────────────────────────────────────────── + +/// Combined HR + BR snapshot from one window of CSI data. +#[pyclass(frozen, name = "VitalReading")] +pub struct PyVitalReading { + inner: VitalReading, +} + +#[pymethods] +impl PyVitalReading { + #[new] + fn new( + respiratory_rate: PyVitalEstimate, + heart_rate: PyVitalEstimate, + subcarrier_count: usize, + signal_quality: f64, + timestamp_secs: f64, + ) -> Self { + Self { + inner: VitalReading { + respiratory_rate: respiratory_rate.inner, + heart_rate: heart_rate.inner, + subcarrier_count, + signal_quality, + timestamp_secs, + }, + } + } + + #[getter] + fn respiratory_rate(&self) -> PyVitalEstimate { + PyVitalEstimate::from_rust(self.inner.respiratory_rate.clone()) + } + + #[getter] + fn heart_rate(&self) -> PyVitalEstimate { + PyVitalEstimate::from_rust(self.inner.heart_rate.clone()) + } + + #[getter] + fn subcarrier_count(&self) -> usize { self.inner.subcarrier_count } + + #[getter] + fn signal_quality(&self) -> f64 { self.inner.signal_quality } + + #[getter] + fn timestamp_secs(&self) -> f64 { self.inner.timestamp_secs } + + fn __repr__(&self) -> String { + format!( + "VitalReading(br={:.1}, hr={:.1}, subcarriers={}, quality={:.3})", + self.inner.respiratory_rate.value_bpm, + self.inner.heart_rate.value_bpm, + self.inner.subcarrier_count, + self.inner.signal_quality, + ) + } +} + +// ─── BreathingExtractor ────────────────────────────────────────────── + +/// Extracts respiratory rate (6–30 BPM) from per-subcarrier amplitude +/// residuals via 0.1–0.5 Hz bandpass + zero-crossing analysis. +/// +/// Python: +/// ```python +/// from wifi_densepose import BreathingExtractor +/// +/// br = BreathingExtractor.esp32_default() # 56 subcarriers, 100 Hz, 30s window +/// # or: BreathingExtractor(n_subcarriers=56, sample_rate=100.0, window_secs=30.0) +/// +/// # Feed residuals from your preprocessor (one frame at a time) +/// est = br.extract(residuals=[0.01, -0.02, …], weights=[]) # equal weights +/// if est is not None: +/// print(est.value_bpm, est.confidence) +/// ``` +#[pyclass(name = "BreathingExtractor")] +pub struct PyBreathingExtractor { + inner: BreathingExtractor, +} + +#[pymethods] +impl PyBreathingExtractor { + /// Construct with explicit parameters. + #[new] + #[pyo3(signature = (n_subcarriers, sample_rate, window_secs=30.0))] + fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self { + Self { + inner: BreathingExtractor::new(n_subcarriers, sample_rate, window_secs), + } + } + + /// ESP32 defaults: 56 subcarriers, 100 Hz, 30-second window. + #[staticmethod] + fn esp32_default() -> Self { + Self { inner: BreathingExtractor::esp32_default() } + } + + /// Extract respiratory rate from a vector of per-subcarrier + /// residuals + per-subcarrier weights. GIL is released during the + /// DSP loop so Python threads can do other work concurrently. + /// + /// Returns `None` if insufficient history has been accumulated. + fn extract(&mut self, py: Python<'_>, residuals: Vec, weights: Vec) -> Option { + // GIL release: see ADR-117 §7 and the Q5 tokio audit. The DSP + // loop is pure sync, no Python objects touched, safe to run + // without the GIL. + let est = py.allow_threads(|| self.inner.extract(&residuals, &weights)); + est.map(PyVitalEstimate::from_rust) + } + + fn __repr__(&self) -> String { + format!("BreathingExtractor(0.1–0.5 Hz bandpass)") + } +} + +// ─── HeartRateExtractor ────────────────────────────────────────────── + +/// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude +/// residuals via 0.8–2.0 Hz bandpass + autocorrelation peak detection. +#[pyclass(name = "HeartRateExtractor")] +pub struct PyHeartRateExtractor { + inner: HeartRateExtractor, +} + +#[pymethods] +impl PyHeartRateExtractor { + /// Construct with explicit parameters. + #[new] + #[pyo3(signature = (n_subcarriers, sample_rate, window_secs=15.0))] + fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self { + Self { + inner: HeartRateExtractor::new(n_subcarriers, sample_rate, window_secs), + } + } + + /// ESP32 defaults: 56 subcarriers, 100 Hz, 15-second window. + #[staticmethod] + fn esp32_default() -> Self { + Self { inner: HeartRateExtractor::esp32_default() } + } + + /// Extract heart rate from per-subcarrier residuals. GIL released + /// during DSP. + fn extract(&mut self, py: Python<'_>, residuals: Vec, weights: Vec) -> Option { + let est = py.allow_threads(|| self.inner.extract(&residuals, &weights)); + est.map(PyVitalEstimate::from_rust) + } + + fn __repr__(&self) -> String { + format!("HeartRateExtractor(0.8–2.0 Hz bandpass)") + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/src/lib.rs b/python/src/lib.rs index ec3e7313..c62ff4f1 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -17,8 +17,10 @@ use pyo3::prelude::*; mod bindings { + pub mod bfld; pub mod keypoint; pub mod pose; + pub mod vitals; } /// Version of the bound Rust core. Surfaced to Python as @@ -38,6 +40,8 @@ fn build_features() -> Vec<&'static str> { feats.push("p1-scaffold"); feats.push("p2-keypoint-bindings"); // Keypoint + KeypointType feats.push("p2-pose-bindings"); // BoundingBox + PersonPose + PoseEstimate + feats.push("p3-vitals-bindings"); // BreathingExtractor + HeartRateExtractor + VitalEstimate + feats.push("p3.5-bfld-bindings"); // BfldFrame + BfldReport + BfldKind (stub Rust) feats } @@ -71,5 +75,10 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> { bindings::keypoint::register(m)?; // P2 — BoundingBox + PersonPose + PoseEstimate bindings. bindings::pose::register(m)?; + // P3 — Vital sign extraction bindings. + bindings::vitals::register(m)?; + // P3.5 — BFLD bindings (stub Rust; future wifi-densepose-bfld crate + // will replace the stub without changing the Python API). + bindings::bfld::register(m)?; Ok(()) } diff --git a/python/tests/test_bfld.py b/python/tests/test_bfld.py new file mode 100644 index 00000000..3ccc2e4c --- /dev/null +++ b/python/tests/test_bfld.py @@ -0,0 +1,263 @@ +"""ADR-117 P3.5 — Tests for BFLD (Beamforming Feedback Loop Data) bindings. + +These tests cover the *stub-Rust-backed* forward-compatible Python +surface defined in ADR-117 §5.7a. The real Rust ingestion crate +(`wifi-densepose-bfld`) lands post-v2.0; this test suite locks in the +Python API so a future swap-in is non-breaking. + +Coverage: + +- BfldKind enum — HE20/40/80/160 + HT20/40 variants +- BfldKind metadata getters — n_subcarriers, bandwidth_mhz, is_he +- BfldFrame.from_compressed_feedback — happy path + dim mismatch +- BfldFrame numpy round-trip — feedback_matrix returns ndarray +- BfldReport — frame aggregation, kind-mismatch error, coherence score +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +import wifi_densepose +from wifi_densepose import BfldFrame, BfldKind, BfldReport + + +# ─── BfldKind enum ─────────────────────────────────────────────────── + + +def test_bfld_kind_variants_exist() -> None: + assert BfldKind.CompressedHE20 != BfldKind.CompressedHE40 + assert BfldKind.CompressedHE80 != BfldKind.CompressedHE160 + assert BfldKind.UncompressedHT20 != BfldKind.UncompressedHT40 + + +def test_bfld_kind_is_hashable() -> None: + s = {BfldKind.CompressedHE80, BfldKind.CompressedHE80} + assert len(s) == 1 + + +def test_bfld_kind_n_subcarriers_he() -> None: + assert BfldKind.CompressedHE20.n_subcarriers == 242 + assert BfldKind.CompressedHE40.n_subcarriers == 484 + assert BfldKind.CompressedHE80.n_subcarriers == 996 + assert BfldKind.CompressedHE160.n_subcarriers == 1992 + + +def test_bfld_kind_n_subcarriers_ht() -> None: + assert BfldKind.UncompressedHT20.n_subcarriers == 52 + assert BfldKind.UncompressedHT40.n_subcarriers == 108 + + +def test_bfld_kind_bandwidth_mhz() -> None: + assert BfldKind.CompressedHE20.bandwidth_mhz == 20 + assert BfldKind.CompressedHE40.bandwidth_mhz == 40 + assert BfldKind.CompressedHE80.bandwidth_mhz == 80 + assert BfldKind.CompressedHE160.bandwidth_mhz == 160 + assert BfldKind.UncompressedHT20.bandwidth_mhz == 20 + assert BfldKind.UncompressedHT40.bandwidth_mhz == 40 + + +def test_bfld_kind_is_he_flag() -> None: + assert BfldKind.CompressedHE20.is_he is True + assert BfldKind.CompressedHE160.is_he is True + assert BfldKind.UncompressedHT20.is_he is False + assert BfldKind.UncompressedHT40.is_he is False + + +def test_bfld_kind_repr() -> None: + r = repr(BfldKind.CompressedHE80) + assert "BfldKind" in r and "CompressedHE80" in r + + +# ─── BfldFrame construction ────────────────────────────────────────── + + +def _make_matrix(n_rows: int, n_cols: int, n_subcarriers: int) -> np.ndarray: + """Synthetic feedback matrix with non-trivial amplitudes so the + mean_amplitude getter has something to chew on.""" + rng = np.random.default_rng(seed=42) + real = rng.standard_normal((n_rows, n_cols, n_subcarriers)).astype(np.float64) + imag = rng.standard_normal((n_rows, n_cols, n_subcarriers)).astype(np.float64) + return (real + 1j * imag).astype(np.complex128) + + +def test_bfld_frame_he80_happy_path() -> None: + fb = _make_matrix(2, 1, 996) + frame = BfldFrame.from_compressed_feedback( + timestamp_ms=1234, + sounding_index=42, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE80, + feedback_matrix=fb, + ) + assert frame.timestamp_ms == 1234 + assert frame.sounding_index == 42 + assert frame.sta_mac == "aa:bb:cc:dd:ee:ff" + assert frame.kind == BfldKind.CompressedHE80 + assert frame.n_rows == 2 + assert frame.n_cols == 1 + assert frame.n_subcarriers == 996 + + +def test_bfld_frame_he160_2x2() -> None: + fb = _make_matrix(2, 2, 1992) + frame = BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="00:00:00:00:00:00", + kind=BfldKind.CompressedHE160, + feedback_matrix=fb, + ) + assert frame.n_rows == 2 + assert frame.n_cols == 2 + assert frame.n_subcarriers == 1992 + + +def test_bfld_frame_ht20_legacy_path() -> None: + fb = _make_matrix(1, 1, 52) + frame = BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.UncompressedHT20, + feedback_matrix=fb, + ) + assert frame.kind == BfldKind.UncompressedHT20 + assert frame.n_subcarriers == 52 + + +def test_bfld_frame_subcarrier_dim_mismatch_raises() -> None: + # HE80 requires 996 subcarriers; pass 64 → ValueError. + bad = _make_matrix(2, 1, 64) + with pytest.raises(ValueError, match="subcarrier"): + BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE80, + feedback_matrix=bad, + ) + + +def test_bfld_frame_mean_amplitude_is_finite() -> None: + fb = _make_matrix(2, 1, 996) + frame = BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE80, + feedback_matrix=fb, + ) + amp = frame.mean_amplitude + assert math.isfinite(amp) and amp > 0.0 + + +def test_bfld_frame_numpy_roundtrip_preserves_shape() -> None: + fb = _make_matrix(2, 1, 996) + frame = BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE80, + feedback_matrix=fb, + ) + out = frame.feedback_matrix() + assert out.shape == (2, 1, 996) + # Roundtrip should be lossless (Complex64 in, Complex64 out). + assert np.allclose(out, fb.astype(np.complex128)) + + +def test_bfld_frame_repr_is_readable() -> None: + fb = _make_matrix(2, 1, 996) + frame = BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE80, + feedback_matrix=fb, + ) + r = repr(frame) + assert "BfldFrame" in r + assert "996" in r + assert "CompressedHE80" in r + + +# ─── BfldReport ────────────────────────────────────────────────────── + + +def test_bfld_report_starts_empty() -> None: + report = BfldReport() + assert report.n_frames == 0 + assert report.kind is None + assert report.timestamp_first is None + assert report.timestamp_last is None + assert report.coherence_score == 0.0 + + +def test_bfld_report_aggregates_homogeneous_frames() -> None: + report = BfldReport() + fb = _make_matrix(2, 1, 996) + for i in range(5): + frame = BfldFrame.from_compressed_feedback( + timestamp_ms=1000 + i * 100, + sounding_index=i, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE80, + feedback_matrix=fb, + ) + report.add_frame(frame) + assert report.n_frames == 5 + assert report.kind == BfldKind.CompressedHE80 + assert report.timestamp_first == 1000 + assert report.timestamp_last == 1400 + # Identical synthetic matrices → near-perfect coherence. + assert report.coherence_score >= 0.99 + + +def test_bfld_report_rejects_mismatched_kind() -> None: + report = BfldReport() + fb_he80 = _make_matrix(2, 1, 996) + fb_he40 = _make_matrix(2, 1, 484) + he80 = BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE80, + feedback_matrix=fb_he80, + ) + he40 = BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE40, + feedback_matrix=fb_he40, + ) + report.add_frame(he80) + with pytest.raises(ValueError, match="kind"): + report.add_frame(he40) + + +def test_bfld_report_repr_summarises() -> None: + report = BfldReport() + fb = _make_matrix(2, 1, 996) + frame = BfldFrame.from_compressed_feedback( + timestamp_ms=0, + sounding_index=0, + sta_mac="aa:bb:cc:dd:ee:ff", + kind=BfldKind.CompressedHE80, + feedback_matrix=fb, + ) + report.add_frame(frame) + r = repr(report) + assert "BfldReport" in r + assert "n_frames=1" in r + + +# ─── Build feature flag ────────────────────────────────────────────── + + +def test_p3_5_bfld_in_build_features() -> None: + assert "p3.5-bfld-bindings" in wifi_densepose.__build_features__ diff --git a/python/tests/test_vitals.py b/python/tests/test_vitals.py new file mode 100644 index 00000000..a195d89a --- /dev/null +++ b/python/tests/test_vitals.py @@ -0,0 +1,196 @@ +"""ADR-117 P3 — Tests for vital-sign extraction bindings. + +Covers: + +- VitalStatus enum (eq, eq_int, hash, frozen) +- VitalEstimate construction + getters + immutability +- VitalReading composite + getters +- BreathingExtractor + HeartRateExtractor — esp32_default, explicit + ctor, extract() return type, validation behaviour + +The Rust pipeline is unit-tested in `v2/crates/wifi-densepose-vitals/`. +These tests are deliberately scoped to the *binding* layer — does the +Python surface return the right shapes, raise the right errors, and +release the GIL safely. +""" + +from __future__ import annotations + +import math +from random import Random + +import pytest + +import wifi_densepose +from wifi_densepose import ( + BreathingExtractor, + HeartRateExtractor, + VitalEstimate, + VitalReading, + VitalStatus, +) + + +# ─── VitalStatus enum ──────────────────────────────────────────────── + + +def test_vital_status_variants_present() -> None: + assert VitalStatus.Valid != VitalStatus.Degraded + assert VitalStatus.Unreliable != VitalStatus.Unavailable + + +def test_vital_status_equality_against_int() -> None: + # eq_int → enum can be compared to int (PyO3 0.22 surface) + assert VitalStatus.Valid == 0 + assert VitalStatus.Unavailable == 3 + + +def test_vital_status_is_hashable() -> None: + # frozen + hash → can be used as dict key / set member + s = {VitalStatus.Valid, VitalStatus.Valid, VitalStatus.Degraded} + assert len(s) == 2 + + +def test_vital_status_repr_contains_variant_name() -> None: + r = repr(VitalStatus.Valid) + assert "VitalStatus" in r and "Valid" in r + + +# ─── VitalEstimate ─────────────────────────────────────────────────── + + +def test_vital_estimate_construction_and_getters() -> None: + est = VitalEstimate(value_bpm=72.4, confidence=0.85, status=VitalStatus.Valid) + assert math.isclose(est.value_bpm, 72.4) + assert math.isclose(est.confidence, 0.85) + assert est.status == VitalStatus.Valid + + +def test_vital_estimate_is_frozen() -> None: + est = VitalEstimate(value_bpm=72.0, confidence=0.9, status=VitalStatus.Valid) + with pytest.raises(AttributeError): + est.value_bpm = 100.0 # type: ignore[misc] + + +def test_vital_estimate_repr_is_readable() -> None: + est = VitalEstimate(value_bpm=72.0, confidence=0.9, status=VitalStatus.Valid) + r = repr(est) + assert "VitalEstimate" in r + assert "72" in r + + +# ─── VitalReading ──────────────────────────────────────────────────── + + +def test_vital_reading_construction_and_getters() -> None: + br = VitalEstimate(value_bpm=14.0, confidence=0.9, status=VitalStatus.Valid) + hr = VitalEstimate(value_bpm=72.0, confidence=0.8, status=VitalStatus.Degraded) + reading = VitalReading( + respiratory_rate=br, + heart_rate=hr, + subcarrier_count=56, + signal_quality=0.77, + timestamp_secs=1700000000.5, + ) + assert reading.respiratory_rate.value_bpm == 14.0 + assert reading.heart_rate.status == VitalStatus.Degraded + assert reading.subcarrier_count == 56 + assert math.isclose(reading.signal_quality, 0.77) + assert math.isclose(reading.timestamp_secs, 1700000000.5) + + +# ─── BreathingExtractor ────────────────────────────────────────────── + + +def test_breathing_esp32_default_constructs() -> None: + br = BreathingExtractor.esp32_default() + assert br is not None + assert "BreathingExtractor" in repr(br) + + +def test_breathing_explicit_ctor() -> None: + br = BreathingExtractor(n_subcarriers=64, sample_rate=200.0, window_secs=20.0) + assert br is not None + + +def test_breathing_extract_returns_none_with_too_few_samples() -> None: + """One frame can't produce a 30-second window — must return None. + + Verifies the binding propagates Rust's `Option` → + Python None correctly (vs raising or returning a default). + """ + br = BreathingExtractor.esp32_default() + out = br.extract(residuals=[0.0] * 56, weights=[]) + assert out is None + + +def test_breathing_extract_accepts_empty_weights() -> None: + """Empty weights vector means "equal weight per subcarrier" by + convention (per breathing.rs).""" + br = BreathingExtractor.esp32_default() + out = br.extract(residuals=[0.01] * 56, weights=[]) + # Even with synthetic input it may return None until enough history + # accumulates — what matters is that the call doesn't panic. + assert out is None or isinstance(out, VitalEstimate) + + +def test_breathing_extract_with_synthetic_signal() -> None: + """Drive the extractor with a synthetic 0.25 Hz sine (15 BPM) for + enough samples to fill the 30-second window. Don't assert the exact + BPM — just that the extractor *eventually* produces a result (rather + than returning None forever).""" + br = BreathingExtractor.esp32_default() + sample_rate = 100.0 + target_freq = 0.25 # 15 BPM + # Run 40 seconds of synthetic data — comfortably past the 30s window. + n_samples = int(40 * sample_rate) + weights = [1.0] * 56 + + produced_estimate = False + rng = Random(42) + for i in range(n_samples): + t = i / sample_rate + base = math.sin(2.0 * math.pi * target_freq * t) + # Per-subcarrier residual: same signal + small per-carrier noise + residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)] + est = br.extract(residuals=residuals, weights=weights) + if est is not None: + produced_estimate = True + assert isinstance(est.value_bpm, float) + assert 0.0 <= est.confidence <= 1.0 + assert est.status in ( + VitalStatus.Valid, + VitalStatus.Degraded, + VitalStatus.Unreliable, + VitalStatus.Unavailable, + ) + break + + assert produced_estimate, "BreathingExtractor never produced an estimate after 40s of synthetic data" + + +# ─── HeartRateExtractor ────────────────────────────────────────────── + + +def test_heart_rate_esp32_default_constructs() -> None: + hr = HeartRateExtractor.esp32_default() + assert hr is not None + assert "HeartRateExtractor" in repr(hr) + + +def test_heart_rate_explicit_ctor() -> None: + hr = HeartRateExtractor(n_subcarriers=64, sample_rate=200.0, window_secs=10.0) + assert hr is not None + + +def test_heart_rate_extract_returns_none_with_too_few_samples() -> None: + hr = HeartRateExtractor.esp32_default() + out = hr.extract(residuals=[0.0] * 56, weights=[]) + assert out is None + + +# ─── Build feature flag ────────────────────────────────────────────── + + +def test_p3_vitals_in_build_features() -> None: + assert "p3-vitals-bindings" in wifi_densepose.__build_features__ diff --git a/python/wifi_densepose/__init__.py b/python/wifi_densepose/__init__.py index d24e26f7..f468f2b1 100644 --- a/python/wifi_densepose/__init__.py +++ b/python/wifi_densepose/__init__.py @@ -45,6 +45,18 @@ BoundingBox = _native.BoundingBox PersonPose = _native.PersonPose PoseEstimate = _native.PoseEstimate +# ─── P3 — Vital sign extraction ────────────────────────────────────── +VitalStatus = _native.VitalStatus +VitalEstimate = _native.VitalEstimate +VitalReading = _native.VitalReading +BreathingExtractor = _native.BreathingExtractor +HeartRateExtractor = _native.HeartRateExtractor + +# ─── P3.5 — BFLD (Beamforming Feedback Loop Data) ───────────────────── +BfldKind = _native.BfldKind +BfldFrame = _native.BfldFrame +BfldReport = _native.BfldReport + __rust_version__: str = _native.__rust_version__ """Version of the bound Rust core. Useful for bug reports.""" @@ -80,4 +92,14 @@ __all__ = [ "BoundingBox", "PersonPose", "PoseEstimate", + # P3 — vital sign extraction + "VitalStatus", + "VitalEstimate", + "VitalReading", + "BreathingExtractor", + "HeartRateExtractor", + # P3.5 — BFLD (forward-compat surface for the future Rust crate) + "BfldKind", + "BfldFrame", + "BfldReport", ]