feat(adr-117/p3+p3.5): vitals + BFLD bindings

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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-24 11:21:58 -04:00
parent 4ac0a4d52b
commit 2d29359809
9 changed files with 2065 additions and 5 deletions

920
python/Cargo.lock generated Normal file
View File

@ -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"

View File

@ -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.

View File

@ -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.

344
python/src/bindings/bfld.rs Normal file
View File

@ -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<Complex64>` 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<Complex64>,
}
#[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<Self> {
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<Complex64> = 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<Complex64>> {
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<Vec<Vec<Complex64>>> {
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<u32>, // sounding indices we hold (don't deep-copy the matrices)
timestamp_first: Option<i64>,
timestamp_last: Option<i64>,
kind: Option<PyBfldKind>,
mean_amplitudes: Vec<f64>, // 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<i64> { self.timestamp_first }
#[getter]
fn timestamp_last(&self) -> Option<i64> { self.timestamp_last }
#[getter]
fn kind(&self) -> Option<PyBfldKind> { 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::<f64>()
/ 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::<f64>()
/ 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::<PyBfldKind>()?;
m.add_class::<PyBfldFrame>()?;
m.add_class::<PyBfldReport>()?;
Ok(())
}

View File

@ -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.10.5 Hz → respiratory rate
//! - `HeartRateExtractor` — bandpass 0.82.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 (630 BPM) from per-subcarrier amplitude
/// residuals via 0.10.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<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
// 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.10.5 Hz bandpass)")
}
}
// ─── HeartRateExtractor ──────────────────────────────────────────────
/// Extracts heart rate (40120 BPM) from per-subcarrier amplitude
/// residuals via 0.82.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<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
est.map(PyVitalEstimate::from_rust)
}
fn __repr__(&self) -> String {
format!("HeartRateExtractor(0.82.0 Hz bandpass)")
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyVitalStatus>()?;
m.add_class::<PyVitalEstimate>()?;
m.add_class::<PyVitalReading>()?;
m.add_class::<PyBreathingExtractor>()?;
m.add_class::<PyHeartRateExtractor>()?;
Ok(())
}

View File

@ -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(())
}

263
python/tests/test_bfld.py Normal file
View File

@ -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__

196
python/tests/test_vitals.py Normal file
View File

@ -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<VitalEstimate>` →
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__

View File

@ -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",
]