feat: ADR-021 vital sign detection + RVF container format (closes #45)
Implement WiFi CSI-based vital sign detection and RVF model container: - Pure-Rust radix-2 DIT FFT with Hann windowing and parabolic interpolation - FIR bandpass filter (windowed-sinc, Hamming) for breathing (0.1-0.5 Hz) and heartbeat (0.8-2.0 Hz) band isolation - VitalSignDetector with rolling buffers (30s breathing, 15s heartbeat) - RVF binary container with 64-byte SegmentHeader, CRC32 integrity, 6 segment types (Vec, Manifest, Quant, Meta, Witness, Profile) - RvfBuilder/RvfReader with file I/O and VitalSignConfig support - Server integration: --benchmark, --load-rvf, --save-rvf CLI flags - REST endpoint /api/v1/vital-signs and WebSocket vital_signs field - 98 tests (32 unit + 16 RVF integration + 18 vital signs integration) - Benchmark: 7,313 frames/sec (136μs/frame), 365x real-time at 20 Hz Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
9
rust-port/wifi-densepose-rs/Cargo.lock
generated
9
rust-port/wifi-densepose-rs/Cargo.lock
generated
@@ -4110,6 +4110,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
@@ -4197,6 +4198,14 @@ dependencies = [
|
||||
"wifi-densepose-mat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-wifiscan"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
@@ -13,6 +13,7 @@ members = [
|
||||
"crates/wifi-densepose-mat",
|
||||
"crates/wifi-densepose-train",
|
||||
"crates/wifi-densepose-sensing-server",
|
||||
"crates/wifi-densepose-wifiscan",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -107,6 +108,7 @@ ruvector-temporal-tensor = "2.0.4"
|
||||
ruvector-solver = "2.0.4"
|
||||
ruvector-attention = "2.0.4"
|
||||
|
||||
|
||||
# Internal crates
|
||||
wifi-densepose-core = { path = "crates/wifi-densepose-core" }
|
||||
wifi-densepose-signal = { path = "crates/wifi-densepose-signal" }
|
||||
|
||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
||||
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "wifi_densepose_sensing_server"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "sensing-server"
|
||||
path = "src/main.rs"
|
||||
@@ -29,3 +33,6 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# CLI
|
||||
clap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//! WiFi-DensePose Sensing Server library.
|
||||
//!
|
||||
//! This crate provides:
|
||||
//! - Vital sign detection from WiFi CSI amplitude data
|
||||
//! - RVF (RuVector Format) binary container for model weights
|
||||
|
||||
pub mod vital_signs;
|
||||
pub mod rvf_container;
|
||||
@@ -8,6 +8,9 @@
|
||||
//!
|
||||
//! Replaces both ws_server.py and the Python HTTP server.
|
||||
|
||||
mod rvf_container;
|
||||
mod vital_signs;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
@@ -33,6 +36,9 @@ use tower_http::set_header::SetResponseHeaderLayer;
|
||||
use axum::http::HeaderValue;
|
||||
use tracing::{info, warn, debug, error};
|
||||
|
||||
use rvf_container::{RvfBuilder, RvfContainerInfo, RvfReader, VitalSignConfig};
|
||||
use vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
// ── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -61,6 +67,18 @@ struct Args {
|
||||
/// Data source: auto, wifi, esp32, simulate
|
||||
#[arg(long, default_value = "auto")]
|
||||
source: String,
|
||||
|
||||
/// Run vital sign detection benchmark (1000 frames) and exit
|
||||
#[arg(long)]
|
||||
benchmark: bool,
|
||||
|
||||
/// Load model config from an RVF container at startup
|
||||
#[arg(long, value_name = "PATH")]
|
||||
load_rvf: Option<PathBuf>,
|
||||
|
||||
/// Save current model state as an RVF container on shutdown
|
||||
#[arg(long, value_name = "PATH")]
|
||||
save_rvf: Option<PathBuf>,
|
||||
}
|
||||
|
||||
// ── Data types ───────────────────────────────────────────────────────────────
|
||||
@@ -93,6 +111,9 @@ struct SensingUpdate {
|
||||
features: FeatureInfo,
|
||||
classification: ClassificationInfo,
|
||||
signal_field: SignalField,
|
||||
/// Vital sign estimates (breathing rate, heart rate, confidence).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
vital_signs: Option<VitalSigns>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -165,6 +186,14 @@ struct AppStateInner {
|
||||
tx: broadcast::Sender<String>,
|
||||
total_detections: u64,
|
||||
start_time: std::time::Instant,
|
||||
/// Vital sign detector (processes CSI frames to estimate HR/RR).
|
||||
vital_detector: VitalSignDetector,
|
||||
/// Most recent vital sign reading for the REST endpoint.
|
||||
latest_vitals: VitalSigns,
|
||||
/// RVF container info if a model was loaded via `--load-rvf`.
|
||||
rvf_info: Option<RvfContainerInfo>,
|
||||
/// Path to save RVF container on shutdown (set via `--save-rvf`).
|
||||
save_rvf_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
type SharedState = Arc<RwLock<AppStateInner>>;
|
||||
@@ -439,6 +468,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
else if classification.motion_level == "present_still" { 0.3 }
|
||||
else { 0.05 };
|
||||
|
||||
let vitals = s.vital_detector.process_frame(
|
||||
&frame.amplitudes,
|
||||
&frame.phases,
|
||||
);
|
||||
s.latest_vitals = vitals.clone();
|
||||
|
||||
let update = SensingUpdate {
|
||||
msg_type: "sensing_update".to_string(),
|
||||
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
|
||||
@@ -454,6 +489,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
features,
|
||||
classification,
|
||||
signal_field: generate_signal_field(rssi_dbm, 1.0, motion_score, tick),
|
||||
vital_signs: Some(vitals),
|
||||
};
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
@@ -859,6 +895,43 @@ async fn stream_status(State(state): State<SharedState>) -> Json<serde_json::Val
|
||||
}))
|
||||
}
|
||||
|
||||
async fn vital_signs_endpoint(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
let vs = &s.latest_vitals;
|
||||
let (br_len, br_cap, hb_len, hb_cap) = s.vital_detector.buffer_status();
|
||||
Json(serde_json::json!({
|
||||
"vital_signs": {
|
||||
"breathing_rate_bpm": vs.breathing_rate_bpm,
|
||||
"heart_rate_bpm": vs.heart_rate_bpm,
|
||||
"breathing_confidence": vs.breathing_confidence,
|
||||
"heartbeat_confidence": vs.heartbeat_confidence,
|
||||
"signal_quality": vs.signal_quality,
|
||||
},
|
||||
"buffer_status": {
|
||||
"breathing_samples": br_len,
|
||||
"breathing_capacity": br_cap,
|
||||
"heartbeat_samples": hb_len,
|
||||
"heartbeat_capacity": hb_cap,
|
||||
},
|
||||
"source": s.source,
|
||||
"tick": s.tick,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn model_info(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
match &s.rvf_info {
|
||||
Some(info) => Json(serde_json::json!({
|
||||
"status": "loaded",
|
||||
"container": info,
|
||||
})),
|
||||
None => Json(serde_json::json!({
|
||||
"status": "no_model",
|
||||
"message": "No RVF container loaded. Use --load-rvf <path> to load one.",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn info_page() -> Html<String> {
|
||||
Html(format!(
|
||||
"<html><body>\
|
||||
@@ -867,6 +940,8 @@ async fn info_page() -> Html<String> {
|
||||
<ul>\
|
||||
<li><a href='/health'>/health</a> — Server health</li>\
|
||||
<li><a href='/api/v1/sensing/latest'>/api/v1/sensing/latest</a> — Latest sensing data</li>\
|
||||
<li><a href='/api/v1/vital-signs'>/api/v1/vital-signs</a> — Vital sign estimates (HR/RR)</li>\
|
||||
<li><a href='/api/v1/model/info'>/api/v1/model/info</a> — RVF model container info</li>\
|
||||
<li>ws://localhost:8765/ws/sensing — WebSocket stream</li>\
|
||||
</ul>\
|
||||
</body></html>"
|
||||
@@ -913,6 +988,12 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
else if classification.motion_level == "present_still" { 0.3 }
|
||||
else { 0.05 };
|
||||
|
||||
let vitals = s.vital_detector.process_frame(
|
||||
&frame.amplitudes,
|
||||
&frame.phases,
|
||||
);
|
||||
s.latest_vitals = vitals.clone();
|
||||
|
||||
let update = SensingUpdate {
|
||||
msg_type: "sensing_update".to_string(),
|
||||
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
|
||||
@@ -930,6 +1011,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
signal_field: generate_signal_field(
|
||||
features.mean_rssi, features.variance, motion_score, tick,
|
||||
),
|
||||
vital_signs: Some(vitals),
|
||||
};
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
@@ -971,6 +1053,12 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
else if classification.motion_level == "present_still" { 0.3 }
|
||||
else { 0.05 };
|
||||
|
||||
let vitals = s.vital_detector.process_frame(
|
||||
&frame.amplitudes,
|
||||
&frame.phases,
|
||||
);
|
||||
s.latest_vitals = vitals.clone();
|
||||
|
||||
let update = SensingUpdate {
|
||||
msg_type: "sensing_update".to_string(),
|
||||
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
|
||||
@@ -988,6 +1076,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
signal_field: generate_signal_field(
|
||||
features.mean_rssi, features.variance, motion_score, tick,
|
||||
),
|
||||
vital_signs: Some(vitals),
|
||||
};
|
||||
|
||||
if update.classification.presence {
|
||||
@@ -1034,6 +1123,16 @@ async fn main() {
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Handle --benchmark mode: run vital sign benchmark and exit
|
||||
if args.benchmark {
|
||||
eprintln!("Running vital sign detection benchmark (1000 frames)...");
|
||||
let (total, per_frame) = vital_signs::run_benchmark(1000);
|
||||
eprintln!();
|
||||
eprintln!("Summary: {} total, {} per frame",
|
||||
format!("{total:?}"), format!("{per_frame:?}"));
|
||||
return;
|
||||
}
|
||||
|
||||
info!("WiFi-DensePose Sensing Server (Rust + Axum + RuVector)");
|
||||
info!(" HTTP: http://localhost:{}", args.http_port);
|
||||
info!(" WebSocket: ws://localhost:{}/ws/sensing", args.ws_port);
|
||||
@@ -1062,6 +1161,53 @@ async fn main() {
|
||||
info!("Data source: {source}");
|
||||
|
||||
// Shared state
|
||||
// Vital sign sample rate derives from tick interval (e.g. 500ms tick => 2 Hz)
|
||||
let vital_sample_rate = 1000.0 / args.tick_ms as f64;
|
||||
info!("Vital sign detector sample rate: {vital_sample_rate:.1} Hz");
|
||||
|
||||
// Load RVF container if --load-rvf was specified
|
||||
let rvf_info = if let Some(ref rvf_path) = args.load_rvf {
|
||||
info!("Loading RVF container from {}", rvf_path.display());
|
||||
match RvfReader::from_file(rvf_path) {
|
||||
Ok(reader) => {
|
||||
let info = reader.info();
|
||||
info!(
|
||||
" RVF loaded: {} segments, {} bytes",
|
||||
info.segment_count, info.total_size
|
||||
);
|
||||
if let Some(ref manifest) = info.manifest {
|
||||
if let Some(model_id) = manifest.get("model_id") {
|
||||
info!(" Model ID: {model_id}");
|
||||
}
|
||||
if let Some(version) = manifest.get("version") {
|
||||
info!(" Version: {version}");
|
||||
}
|
||||
}
|
||||
if info.has_weights {
|
||||
if let Some(w) = reader.weights() {
|
||||
info!(" Weights: {} parameters", w.len());
|
||||
}
|
||||
}
|
||||
if info.has_vital_config {
|
||||
info!(" Vital sign config: present");
|
||||
}
|
||||
if info.has_quant_info {
|
||||
info!(" Quantization info: present");
|
||||
}
|
||||
if info.has_witness {
|
||||
info!(" Witness/proof: present");
|
||||
}
|
||||
Some(info)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to load RVF container: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (tx, _) = broadcast::channel::<String>(256);
|
||||
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
|
||||
latest_update: None,
|
||||
@@ -1071,6 +1217,10 @@ async fn main() {
|
||||
tx,
|
||||
total_detections: 0,
|
||||
start_time: std::time::Instant::now(),
|
||||
vital_detector: VitalSignDetector::new(vital_sample_rate),
|
||||
latest_vitals: VitalSigns::default(),
|
||||
rvf_info,
|
||||
save_rvf_path: args.save_rvf.clone(),
|
||||
}));
|
||||
|
||||
// Start background tasks based on source
|
||||
@@ -1120,6 +1270,10 @@ async fn main() {
|
||||
.route("/api/v1/metrics", get(health_metrics))
|
||||
// Sensing endpoints
|
||||
.route("/api/v1/sensing/latest", get(latest))
|
||||
// Vital sign endpoints
|
||||
.route("/api/v1/vital-signs", get(vital_signs_endpoint))
|
||||
// RVF model container info
|
||||
.route("/api/v1/model/info", get(model_info))
|
||||
// Pose endpoints (WiFi-derived)
|
||||
.route("/api/v1/pose/current", get(pose_current))
|
||||
.route("/api/v1/pose/stats", get(pose_stats))
|
||||
@@ -1133,7 +1287,7 @@ async fn main() {
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||
))
|
||||
.with_state(state);
|
||||
.with_state(state.clone());
|
||||
|
||||
let http_addr = SocketAddr::from(([0, 0, 0, 0], args.http_port));
|
||||
let http_listener = tokio::net::TcpListener::bind(http_addr).await
|
||||
@@ -1141,5 +1295,42 @@ async fn main() {
|
||||
info!("HTTP server listening on {http_addr}");
|
||||
info!("Open http://localhost:{}/ui/index.html in your browser", args.http_port);
|
||||
|
||||
axum::serve(http_listener, http_app).await.unwrap();
|
||||
// Run the HTTP server with graceful shutdown support
|
||||
let shutdown_state = state.clone();
|
||||
let server = axum::serve(http_listener, http_app)
|
||||
.with_graceful_shutdown(async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install CTRL+C handler");
|
||||
info!("Shutdown signal received");
|
||||
});
|
||||
|
||||
server.await.unwrap();
|
||||
|
||||
// Save RVF container on shutdown if --save-rvf was specified
|
||||
let s = shutdown_state.read().await;
|
||||
if let Some(ref save_path) = s.save_rvf_path {
|
||||
info!("Saving RVF container to {}", save_path.display());
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest(
|
||||
"wifi-densepose-sensing",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"WiFi DensePose sensing model state",
|
||||
);
|
||||
builder.add_metadata(&serde_json::json!({
|
||||
"source": s.source,
|
||||
"total_ticks": s.tick,
|
||||
"total_detections": s.total_detections,
|
||||
"uptime_secs": s.start_time.elapsed().as_secs(),
|
||||
}));
|
||||
builder.add_vital_config(&VitalSignConfig::default());
|
||||
// Save dummy weights (placeholder for real model weights)
|
||||
builder.add_weights(&[0.0f32; 0]);
|
||||
match builder.write_to_file(save_path) {
|
||||
Ok(()) => info!(" RVF saved successfully"),
|
||||
Err(e) => error!(" Failed to save RVF: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
info!("Server shut down cleanly");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,908 @@
|
||||
//! Standalone RVF container builder and reader for WiFi-DensePose model packaging.
|
||||
//!
|
||||
//! Implements the RVF binary format (64-byte segment headers + payload) without
|
||||
//! depending on the `rvf-wire` crate. Supports building `.rvf` files that package
|
||||
//! model weights, metadata, and configuration into a single binary container.
|
||||
//!
|
||||
//! Wire format per segment:
|
||||
//! - 64-byte header (see `SegmentHeader`)
|
||||
//! - N-byte payload
|
||||
//! - Zero-padding to next 64-byte boundary
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
|
||||
// ── RVF format constants ────────────────────────────────────────────────────
|
||||
|
||||
/// Segment header magic: "RVFS" as big-endian u32 = 0x52564653.
|
||||
const SEGMENT_MAGIC: u32 = 0x5256_4653;
|
||||
/// Current segment format version.
|
||||
const SEGMENT_VERSION: u8 = 1;
|
||||
/// All segments are 64-byte aligned.
|
||||
const SEGMENT_ALIGNMENT: usize = 64;
|
||||
/// Fixed header size in bytes.
|
||||
const SEGMENT_HEADER_SIZE: usize = 64;
|
||||
|
||||
// ── Segment type discriminators (subset relevant to DensePose models) ───────
|
||||
|
||||
/// Raw vector payloads (model weight embeddings).
|
||||
const SEG_VEC: u8 = 0x01;
|
||||
/// Segment directory / manifest.
|
||||
const SEG_MANIFEST: u8 = 0x05;
|
||||
/// Quantization dictionaries and codebooks.
|
||||
const SEG_QUANT: u8 = 0x06;
|
||||
/// Arbitrary key-value metadata (JSON).
|
||||
const SEG_META: u8 = 0x07;
|
||||
/// Capability manifests, proof of computation, audit trails.
|
||||
const SEG_WITNESS: u8 = 0x0A;
|
||||
/// Domain profile declarations.
|
||||
const SEG_PROFILE: u8 = 0x0B;
|
||||
|
||||
// ── Pure-Rust CRC32 (IEEE 802.3 polynomial) ────────────────────────────────
|
||||
|
||||
/// CRC32 lookup table, computed at compile time via the IEEE 802.3 polynomial
|
||||
/// 0xEDB88320 (bit-reversed representation of 0x04C11DB7).
|
||||
const CRC32_TABLE: [u32; 256] = {
|
||||
let mut table = [0u32; 256];
|
||||
let mut i = 0u32;
|
||||
while i < 256 {
|
||||
let mut crc = i;
|
||||
let mut j = 0;
|
||||
while j < 8 {
|
||||
if crc & 1 != 0 {
|
||||
crc = (crc >> 1) ^ 0xEDB8_8320;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
table[i as usize] = crc;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
/// Compute CRC32 (IEEE) over the given byte slice.
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc: u32 = 0xFFFF_FFFF;
|
||||
for &byte in data {
|
||||
let idx = ((crc ^ byte as u32) & 0xFF) as usize;
|
||||
crc = (crc >> 8) ^ CRC32_TABLE[idx];
|
||||
}
|
||||
crc ^ 0xFFFF_FFFF
|
||||
}
|
||||
|
||||
/// Produce a 16-byte content hash field from CRC32.
|
||||
/// The 4-byte CRC is stored in the first 4 bytes (little-endian), remaining
|
||||
/// 12 bytes are zeroed.
|
||||
fn crc32_content_hash(data: &[u8]) -> [u8; 16] {
|
||||
let c = crc32(data);
|
||||
let mut out = [0u8; 16];
|
||||
out[..4].copy_from_slice(&c.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
// ── Segment header (mirrors rvf-types SegmentHeader layout) ─────────────────
|
||||
|
||||
/// 64-byte segment header matching the RVF wire format exactly.
|
||||
///
|
||||
/// Field offsets:
|
||||
/// - 0x00: magic (u32)
|
||||
/// - 0x04: version (u8)
|
||||
/// - 0x05: seg_type (u8)
|
||||
/// - 0x06: flags (u16)
|
||||
/// - 0x08: segment_id (u64)
|
||||
/// - 0x10: payload_length (u64)
|
||||
/// - 0x18: timestamp_ns (u64)
|
||||
/// - 0x20: checksum_algo (u8)
|
||||
/// - 0x21: compression (u8)
|
||||
/// - 0x22: reserved_0 (u16)
|
||||
/// - 0x24: reserved_1 (u32)
|
||||
/// - 0x28: content_hash ([u8; 16])
|
||||
/// - 0x38: uncompressed_len (u32)
|
||||
/// - 0x3C: alignment_pad (u32)
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SegmentHeader {
|
||||
pub magic: u32,
|
||||
pub version: u8,
|
||||
pub seg_type: u8,
|
||||
pub flags: u16,
|
||||
pub segment_id: u64,
|
||||
pub payload_length: u64,
|
||||
pub timestamp_ns: u64,
|
||||
pub checksum_algo: u8,
|
||||
pub compression: u8,
|
||||
pub reserved_0: u16,
|
||||
pub reserved_1: u32,
|
||||
pub content_hash: [u8; 16],
|
||||
pub uncompressed_len: u32,
|
||||
pub alignment_pad: u32,
|
||||
}
|
||||
|
||||
impl SegmentHeader {
|
||||
/// Create a new header with the given type and segment ID.
|
||||
fn new(seg_type: u8, segment_id: u64) -> Self {
|
||||
Self {
|
||||
magic: SEGMENT_MAGIC,
|
||||
version: SEGMENT_VERSION,
|
||||
seg_type,
|
||||
flags: 0,
|
||||
segment_id,
|
||||
payload_length: 0,
|
||||
timestamp_ns: 0,
|
||||
checksum_algo: 0, // CRC32
|
||||
compression: 0,
|
||||
reserved_0: 0,
|
||||
reserved_1: 0,
|
||||
content_hash: [0u8; 16],
|
||||
uncompressed_len: 0,
|
||||
alignment_pad: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the header into exactly 64 bytes (little-endian).
|
||||
fn to_bytes(&self) -> [u8; 64] {
|
||||
let mut buf = [0u8; 64];
|
||||
buf[0x00..0x04].copy_from_slice(&self.magic.to_le_bytes());
|
||||
buf[0x04] = self.version;
|
||||
buf[0x05] = self.seg_type;
|
||||
buf[0x06..0x08].copy_from_slice(&self.flags.to_le_bytes());
|
||||
buf[0x08..0x10].copy_from_slice(&self.segment_id.to_le_bytes());
|
||||
buf[0x10..0x18].copy_from_slice(&self.payload_length.to_le_bytes());
|
||||
buf[0x18..0x20].copy_from_slice(&self.timestamp_ns.to_le_bytes());
|
||||
buf[0x20] = self.checksum_algo;
|
||||
buf[0x21] = self.compression;
|
||||
buf[0x22..0x24].copy_from_slice(&self.reserved_0.to_le_bytes());
|
||||
buf[0x24..0x28].copy_from_slice(&self.reserved_1.to_le_bytes());
|
||||
buf[0x28..0x38].copy_from_slice(&self.content_hash);
|
||||
buf[0x38..0x3C].copy_from_slice(&self.uncompressed_len.to_le_bytes());
|
||||
buf[0x3C..0x40].copy_from_slice(&self.alignment_pad.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Deserialize a header from exactly 64 bytes (little-endian).
|
||||
fn from_bytes(data: &[u8; 64]) -> Self {
|
||||
let mut content_hash = [0u8; 16];
|
||||
content_hash.copy_from_slice(&data[0x28..0x38]);
|
||||
|
||||
Self {
|
||||
magic: u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
|
||||
version: data[0x04],
|
||||
seg_type: data[0x05],
|
||||
flags: u16::from_le_bytes([data[0x06], data[0x07]]),
|
||||
segment_id: u64::from_le_bytes(data[0x08..0x10].try_into().unwrap()),
|
||||
payload_length: u64::from_le_bytes(data[0x10..0x18].try_into().unwrap()),
|
||||
timestamp_ns: u64::from_le_bytes(data[0x18..0x20].try_into().unwrap()),
|
||||
checksum_algo: data[0x20],
|
||||
compression: data[0x21],
|
||||
reserved_0: u16::from_le_bytes([data[0x22], data[0x23]]),
|
||||
reserved_1: u32::from_le_bytes(data[0x24..0x28].try_into().unwrap()),
|
||||
content_hash,
|
||||
uncompressed_len: u32::from_le_bytes(data[0x38..0x3C].try_into().unwrap()),
|
||||
alignment_pad: u32::from_le_bytes(data[0x3C..0x40].try_into().unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vital sign detector config ──────────────────────────────────────────────
|
||||
|
||||
/// Configuration for the WiFi-based vital sign detector.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VitalSignConfig {
|
||||
/// Breathing rate band low bound (Hz).
|
||||
pub breathing_low_hz: f64,
|
||||
/// Breathing rate band high bound (Hz).
|
||||
pub breathing_high_hz: f64,
|
||||
/// Heart rate band low bound (Hz).
|
||||
pub heartrate_low_hz: f64,
|
||||
/// Heart rate band high bound (Hz).
|
||||
pub heartrate_high_hz: f64,
|
||||
/// Minimum subcarrier count for valid detection.
|
||||
pub min_subcarriers: u32,
|
||||
/// Window size in samples for spectral analysis.
|
||||
pub window_size: u32,
|
||||
/// Confidence threshold (0.0 - 1.0).
|
||||
pub confidence_threshold: f64,
|
||||
}
|
||||
|
||||
impl Default for VitalSignConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
breathing_low_hz: 0.1,
|
||||
breathing_high_hz: 0.5,
|
||||
heartrate_low_hz: 0.8,
|
||||
heartrate_high_hz: 2.0,
|
||||
min_subcarriers: 52,
|
||||
window_size: 512,
|
||||
confidence_threshold: 0.6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── RVF container info (returned by the REST API) ───────────────────────────
|
||||
|
||||
/// Summary of a loaded RVF container, exposed via `/api/v1/model/info`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RvfContainerInfo {
|
||||
pub segment_count: usize,
|
||||
pub total_size: usize,
|
||||
pub manifest: Option<serde_json::Value>,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub has_weights: bool,
|
||||
pub has_vital_config: bool,
|
||||
pub has_quant_info: bool,
|
||||
pub has_witness: bool,
|
||||
}
|
||||
|
||||
// ── RVF Builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Builds an RVF container by accumulating segments and serializing them
|
||||
/// into the binary format: `[header(64) | payload | padding]*`.
|
||||
pub struct RvfBuilder {
|
||||
segments: Vec<(SegmentHeader, Vec<u8>)>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl RvfBuilder {
|
||||
/// Create a new empty builder.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
segments: Vec::new(),
|
||||
next_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a manifest segment with model metadata.
|
||||
pub fn add_manifest(&mut self, model_id: &str, version: &str, description: &str) {
|
||||
let manifest = serde_json::json!({
|
||||
"model_id": model_id,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"format": "wifi-densepose-rvf",
|
||||
"created_at": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let payload = serde_json::to_vec(&manifest).unwrap_or_default();
|
||||
self.push_segment(SEG_MANIFEST, &payload);
|
||||
}
|
||||
|
||||
/// Add model weights as a Vec segment. Weights are serialized as
|
||||
/// little-endian f32 values.
|
||||
pub fn add_weights(&mut self, weights: &[f32]) {
|
||||
let mut payload = Vec::with_capacity(weights.len() * 4);
|
||||
for &w in weights {
|
||||
payload.extend_from_slice(&w.to_le_bytes());
|
||||
}
|
||||
self.push_segment(SEG_VEC, &payload);
|
||||
}
|
||||
|
||||
/// Add metadata (arbitrary JSON key-value pairs).
|
||||
pub fn add_metadata(&mut self, metadata: &serde_json::Value) {
|
||||
let payload = serde_json::to_vec(metadata).unwrap_or_default();
|
||||
self.push_segment(SEG_META, &payload);
|
||||
}
|
||||
|
||||
/// Add vital sign detector configuration as a Profile segment.
|
||||
pub fn add_vital_config(&mut self, config: &VitalSignConfig) {
|
||||
let payload = serde_json::to_vec(config).unwrap_or_default();
|
||||
self.push_segment(SEG_PROFILE, &payload);
|
||||
}
|
||||
|
||||
/// Add quantization info as a Quant segment.
|
||||
pub fn add_quant_info(&mut self, quant_type: &str, scale: f32, zero_point: i32) {
|
||||
let info = serde_json::json!({
|
||||
"quant_type": quant_type,
|
||||
"scale": scale,
|
||||
"zero_point": zero_point,
|
||||
});
|
||||
let payload = serde_json::to_vec(&info).unwrap_or_default();
|
||||
self.push_segment(SEG_QUANT, &payload);
|
||||
}
|
||||
|
||||
/// Add witness/proof data as a Witness segment.
|
||||
pub fn add_witness(&mut self, training_hash: &str, metrics: &serde_json::Value) {
|
||||
let witness = serde_json::json!({
|
||||
"training_hash": training_hash,
|
||||
"metrics": metrics,
|
||||
});
|
||||
let payload = serde_json::to_vec(&witness).unwrap_or_default();
|
||||
self.push_segment(SEG_WITNESS, &payload);
|
||||
}
|
||||
|
||||
/// Build the final `.rvf` file as a byte vector.
|
||||
pub fn build(&self) -> Vec<u8> {
|
||||
let total: usize = self
|
||||
.segments
|
||||
.iter()
|
||||
.map(|(_, p)| align_up(SEGMENT_HEADER_SIZE + p.len()))
|
||||
.sum();
|
||||
|
||||
let mut buf = Vec::with_capacity(total);
|
||||
for (header, payload) in &self.segments {
|
||||
buf.extend_from_slice(&header.to_bytes());
|
||||
buf.extend_from_slice(payload);
|
||||
// Zero-pad to the next 64-byte boundary
|
||||
let written = SEGMENT_HEADER_SIZE + payload.len();
|
||||
let target = align_up(written);
|
||||
let pad = target - written;
|
||||
buf.extend(std::iter::repeat(0u8).take(pad));
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Write the container to a file.
|
||||
pub fn write_to_file(&self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
let data = self.build();
|
||||
let mut file = std::fs::File::create(path)?;
|
||||
file.write_all(&data)?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn push_segment(&mut self, seg_type: u8, payload: &[u8]) {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
|
||||
let content_hash = crc32_content_hash(payload);
|
||||
let raw = SEGMENT_HEADER_SIZE + payload.len();
|
||||
let aligned = align_up(raw);
|
||||
let pad = (aligned - raw) as u32;
|
||||
|
||||
let now_ns = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let header = SegmentHeader {
|
||||
magic: SEGMENT_MAGIC,
|
||||
version: SEGMENT_VERSION,
|
||||
seg_type,
|
||||
flags: 0,
|
||||
segment_id: id,
|
||||
payload_length: payload.len() as u64,
|
||||
timestamp_ns: now_ns,
|
||||
checksum_algo: 0, // CRC32
|
||||
compression: 0,
|
||||
reserved_0: 0,
|
||||
reserved_1: 0,
|
||||
content_hash,
|
||||
uncompressed_len: 0,
|
||||
alignment_pad: pad,
|
||||
};
|
||||
|
||||
self.segments.push((header, payload.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RvfBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Round `size` up to the next multiple of `SEGMENT_ALIGNMENT` (64).
|
||||
fn align_up(size: usize) -> usize {
|
||||
(size + SEGMENT_ALIGNMENT - 1) & !(SEGMENT_ALIGNMENT - 1)
|
||||
}
|
||||
|
||||
// ── RVF Reader ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Reads and parses an RVF container from bytes, providing access to
|
||||
/// individual segments.
|
||||
#[derive(Debug)]
|
||||
pub struct RvfReader {
|
||||
segments: Vec<(SegmentHeader, Vec<u8>)>,
|
||||
raw_size: usize,
|
||||
}
|
||||
|
||||
impl RvfReader {
|
||||
/// Parse an RVF container from a byte slice.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
|
||||
let mut segments = Vec::new();
|
||||
let mut offset = 0;
|
||||
|
||||
while offset + SEGMENT_HEADER_SIZE <= data.len() {
|
||||
// Read the 64-byte header
|
||||
let header_bytes: &[u8; 64] = data[offset..offset + 64]
|
||||
.try_into()
|
||||
.map_err(|_| "truncated header".to_string())?;
|
||||
|
||||
let header = SegmentHeader::from_bytes(header_bytes);
|
||||
|
||||
// Validate magic
|
||||
if header.magic != SEGMENT_MAGIC {
|
||||
return Err(format!(
|
||||
"invalid magic at offset {offset}: expected 0x{SEGMENT_MAGIC:08X}, \
|
||||
got 0x{:08X}",
|
||||
header.magic
|
||||
));
|
||||
}
|
||||
|
||||
// Validate version
|
||||
if header.version != SEGMENT_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported version at offset {offset}: expected {SEGMENT_VERSION}, \
|
||||
got {}",
|
||||
header.version
|
||||
));
|
||||
}
|
||||
|
||||
let payload_len = header.payload_length as usize;
|
||||
let payload_start = offset + SEGMENT_HEADER_SIZE;
|
||||
let payload_end = payload_start + payload_len;
|
||||
|
||||
if payload_end > data.len() {
|
||||
return Err(format!(
|
||||
"truncated payload at offset {offset}: need {payload_len} bytes, \
|
||||
only {} available",
|
||||
data.len() - payload_start
|
||||
));
|
||||
}
|
||||
|
||||
let payload = data[payload_start..payload_end].to_vec();
|
||||
|
||||
// Verify CRC32 content hash
|
||||
let expected_hash = crc32_content_hash(&payload);
|
||||
if expected_hash != header.content_hash {
|
||||
return Err(format!(
|
||||
"content hash mismatch at segment {} (offset {offset})",
|
||||
header.segment_id
|
||||
));
|
||||
}
|
||||
|
||||
segments.push((header, payload));
|
||||
|
||||
// Advance past header + payload + padding to next 64-byte boundary
|
||||
let raw = SEGMENT_HEADER_SIZE + payload_len;
|
||||
offset += align_up(raw);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
segments,
|
||||
raw_size: data.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read an RVF container from a file.
|
||||
pub fn from_file(path: &std::path::Path) -> Result<Self, String> {
|
||||
let data = std::fs::read(path)
|
||||
.map_err(|e| format!("failed to read {}: {e}", path.display()))?;
|
||||
Self::from_bytes(&data)
|
||||
}
|
||||
|
||||
/// Find the first segment with the given type and return its payload.
|
||||
pub fn find_segment(&self, seg_type: u8) -> Option<&[u8]> {
|
||||
self.segments
|
||||
.iter()
|
||||
.find(|(h, _)| h.seg_type == seg_type)
|
||||
.map(|(_, p)| p.as_slice())
|
||||
}
|
||||
|
||||
/// Parse and return the manifest JSON, if present.
|
||||
pub fn manifest(&self) -> Option<serde_json::Value> {
|
||||
self.find_segment(SEG_MANIFEST)
|
||||
.and_then(|data| serde_json::from_slice(data).ok())
|
||||
}
|
||||
|
||||
/// Decode and return model weights from the Vec segment, if present.
|
||||
pub fn weights(&self) -> Option<Vec<f32>> {
|
||||
let data = self.find_segment(SEG_VEC)?;
|
||||
if data.len() % 4 != 0 {
|
||||
return None;
|
||||
}
|
||||
let weights: Vec<f32> = data
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
|
||||
.collect();
|
||||
Some(weights)
|
||||
}
|
||||
|
||||
/// Parse and return the metadata JSON, if present.
|
||||
pub fn metadata(&self) -> Option<serde_json::Value> {
|
||||
self.find_segment(SEG_META)
|
||||
.and_then(|data| serde_json::from_slice(data).ok())
|
||||
}
|
||||
|
||||
/// Parse and return the vital sign config, if present.
|
||||
pub fn vital_config(&self) -> Option<VitalSignConfig> {
|
||||
self.find_segment(SEG_PROFILE)
|
||||
.and_then(|data| serde_json::from_slice(data).ok())
|
||||
}
|
||||
|
||||
/// Parse and return the quantization info, if present.
|
||||
pub fn quant_info(&self) -> Option<serde_json::Value> {
|
||||
self.find_segment(SEG_QUANT)
|
||||
.and_then(|data| serde_json::from_slice(data).ok())
|
||||
}
|
||||
|
||||
/// Parse and return the witness data, if present.
|
||||
pub fn witness(&self) -> Option<serde_json::Value> {
|
||||
self.find_segment(SEG_WITNESS)
|
||||
.and_then(|data| serde_json::from_slice(data).ok())
|
||||
}
|
||||
|
||||
/// Number of segments in the container.
|
||||
pub fn segment_count(&self) -> usize {
|
||||
self.segments.len()
|
||||
}
|
||||
|
||||
/// Total byte size of the original container data.
|
||||
pub fn total_size(&self) -> usize {
|
||||
self.raw_size
|
||||
}
|
||||
|
||||
/// Build a summary info struct for the REST API.
|
||||
pub fn info(&self) -> RvfContainerInfo {
|
||||
RvfContainerInfo {
|
||||
segment_count: self.segment_count(),
|
||||
total_size: self.total_size(),
|
||||
manifest: self.manifest(),
|
||||
metadata: self.metadata(),
|
||||
has_weights: self.find_segment(SEG_VEC).is_some(),
|
||||
has_vital_config: self.find_segment(SEG_PROFILE).is_some(),
|
||||
has_quant_info: self.find_segment(SEG_QUANT).is_some(),
|
||||
has_witness: self.find_segment(SEG_WITNESS).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return an iterator over all segment headers and their payloads.
|
||||
pub fn segments(&self) -> impl Iterator<Item = (&SegmentHeader, &[u8])> {
|
||||
self.segments.iter().map(|(h, p)| (h, p.as_slice()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc32_known_values() {
|
||||
// "hello" CRC32 (IEEE) = 0x3610A686
|
||||
let c = crc32(b"hello");
|
||||
assert_eq!(c, 0x3610_A686);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32_empty() {
|
||||
let c = crc32(b"");
|
||||
assert_eq!(c, 0x0000_0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_round_trip() {
|
||||
let header = SegmentHeader::new(SEG_MANIFEST, 42);
|
||||
let bytes = header.to_bytes();
|
||||
assert_eq!(bytes.len(), 64);
|
||||
let parsed = SegmentHeader::from_bytes(&bytes);
|
||||
assert_eq!(parsed.magic, SEGMENT_MAGIC);
|
||||
assert_eq!(parsed.version, SEGMENT_VERSION);
|
||||
assert_eq!(parsed.seg_type, SEG_MANIFEST);
|
||||
assert_eq!(parsed.segment_id, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_size_is_64() {
|
||||
let header = SegmentHeader::new(0x01, 0);
|
||||
assert_eq!(header.to_bytes().len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_field_offsets() {
|
||||
let mut header = SegmentHeader::new(SEG_VEC, 0x1234_5678_9ABC_DEF0);
|
||||
header.flags = 0x0009; // COMPRESSED | SEALED
|
||||
header.payload_length = 0xAABB_CCDD_EEFF_0011;
|
||||
let bytes = header.to_bytes();
|
||||
|
||||
// Magic at offset 0x00
|
||||
assert_eq!(
|
||||
u32::from_le_bytes(bytes[0x00..0x04].try_into().unwrap()),
|
||||
SEGMENT_MAGIC
|
||||
);
|
||||
// Version at 0x04
|
||||
assert_eq!(bytes[0x04], SEGMENT_VERSION);
|
||||
// seg_type at 0x05
|
||||
assert_eq!(bytes[0x05], SEG_VEC);
|
||||
// flags at 0x06
|
||||
assert_eq!(
|
||||
u16::from_le_bytes(bytes[0x06..0x08].try_into().unwrap()),
|
||||
0x0009
|
||||
);
|
||||
// segment_id at 0x08
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(bytes[0x08..0x10].try_into().unwrap()),
|
||||
0x1234_5678_9ABC_DEF0
|
||||
);
|
||||
// payload_length at 0x10
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(bytes[0x10..0x18].try_into().unwrap()),
|
||||
0xAABB_CCDD_EEFF_0011
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_empty_container() {
|
||||
let builder = RvfBuilder::new();
|
||||
let data = builder.build();
|
||||
assert!(data.is_empty());
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
assert_eq!(reader.segment_count(), 0);
|
||||
assert_eq!(reader.total_size(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_round_trip() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("test-model", "1.0.0", "A test model");
|
||||
let data = builder.build();
|
||||
|
||||
assert_eq!(data.len() % SEGMENT_ALIGNMENT, 0);
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
assert_eq!(reader.segment_count(), 1);
|
||||
|
||||
let manifest = reader.manifest().expect("manifest should be present");
|
||||
assert_eq!(manifest["model_id"], "test-model");
|
||||
assert_eq!(manifest["version"], "1.0.0");
|
||||
assert_eq!(manifest["description"], "A test model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_round_trip() {
|
||||
let weights: Vec<f32> = vec![1.0, -2.5, 3.14, 0.0, f32::MAX, f32::MIN];
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_weights(&weights);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
let decoded = reader.weights().expect("weights should be present");
|
||||
assert_eq!(decoded.len(), weights.len());
|
||||
for (a, b) in decoded.iter().zip(weights.iter()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_round_trip() {
|
||||
let meta = serde_json::json!({
|
||||
"task": "wifi-densepose",
|
||||
"input_dim": 56,
|
||||
"output_dim": 17,
|
||||
"hidden_layers": [128, 64],
|
||||
});
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_metadata(&meta);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
let decoded = reader.metadata().expect("metadata should be present");
|
||||
assert_eq!(decoded["task"], "wifi-densepose");
|
||||
assert_eq!(decoded["input_dim"], 56);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vital_config_round_trip() {
|
||||
let config = VitalSignConfig {
|
||||
breathing_low_hz: 0.15,
|
||||
breathing_high_hz: 0.45,
|
||||
heartrate_low_hz: 0.9,
|
||||
heartrate_high_hz: 1.8,
|
||||
min_subcarriers: 64,
|
||||
window_size: 1024,
|
||||
confidence_threshold: 0.7,
|
||||
};
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_vital_config(&config);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
let decoded = reader.vital_config().expect("vital config should be present");
|
||||
assert!((decoded.breathing_low_hz - 0.15).abs() < f64::EPSILON);
|
||||
assert_eq!(decoded.min_subcarriers, 64);
|
||||
assert_eq!(decoded.window_size, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quant_info_round_trip() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_quant_info("int8", 0.0078125, -128);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
let qi = reader.quant_info().expect("quant info should be present");
|
||||
assert_eq!(qi["quant_type"], "int8");
|
||||
assert_eq!(qi["zero_point"], -128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn witness_round_trip() {
|
||||
let metrics = serde_json::json!({
|
||||
"accuracy": 0.95,
|
||||
"loss": 0.032,
|
||||
"epochs": 100,
|
||||
});
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_witness("sha256:abcdef1234567890", &metrics);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
let w = reader.witness().expect("witness should be present");
|
||||
assert_eq!(w["training_hash"], "sha256:abcdef1234567890");
|
||||
assert_eq!(w["metrics"]["accuracy"], 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_container_round_trip() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
|
||||
builder.add_manifest("wifi-densepose-v1", "0.1.0", "WiFi DensePose model");
|
||||
builder.add_weights(&[0.1, 0.2, 0.3, -0.5, 1.0]);
|
||||
builder.add_metadata(&serde_json::json!({
|
||||
"architecture": "mlp",
|
||||
"input_dim": 56,
|
||||
}));
|
||||
builder.add_vital_config(&VitalSignConfig::default());
|
||||
builder.add_quant_info("fp32", 1.0, 0);
|
||||
builder.add_witness("sha256:deadbeef", &serde_json::json!({"loss": 0.01}));
|
||||
|
||||
let data = builder.build();
|
||||
|
||||
// Every segment starts at a 64-byte boundary
|
||||
assert_eq!(data.len() % SEGMENT_ALIGNMENT, 0);
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
assert_eq!(reader.segment_count(), 6);
|
||||
|
||||
// All segments present
|
||||
assert!(reader.manifest().is_some());
|
||||
assert!(reader.weights().is_some());
|
||||
assert!(reader.metadata().is_some());
|
||||
assert!(reader.vital_config().is_some());
|
||||
assert!(reader.quant_info().is_some());
|
||||
assert!(reader.witness().is_some());
|
||||
|
||||
// Verify weights data
|
||||
let w = reader.weights().unwrap();
|
||||
assert_eq!(w.len(), 5);
|
||||
assert!((w[0] - 0.1).abs() < f32::EPSILON);
|
||||
assert!((w[3] - (-0.5)).abs() < f32::EPSILON);
|
||||
|
||||
// Info struct for API
|
||||
let info = reader.info();
|
||||
assert_eq!(info.segment_count, 6);
|
||||
assert!(info.has_weights);
|
||||
assert!(info.has_vital_config);
|
||||
assert!(info.has_quant_info);
|
||||
assert!(info.has_witness);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_round_trip() {
|
||||
let dir = std::env::temp_dir().join("rvf_test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("test_model.rvf");
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("file-test", "1.0.0", "File I/O test");
|
||||
builder.add_weights(&[42.0, -1.0]);
|
||||
builder.write_to_file(&path).unwrap();
|
||||
|
||||
let reader = RvfReader::from_file(&path).unwrap();
|
||||
assert_eq!(reader.segment_count(), 2);
|
||||
|
||||
let manifest = reader.manifest().unwrap();
|
||||
assert_eq!(manifest["model_id"], "file-test");
|
||||
|
||||
let w = reader.weights().unwrap();
|
||||
assert_eq!(w.len(), 2);
|
||||
assert!((w[0] - 42.0).abs() < f32::EPSILON);
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_magic_rejected() {
|
||||
let mut data = vec![0u8; 128];
|
||||
// Write bad magic
|
||||
data[0..4].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
|
||||
let result = RvfReader::from_bytes(&data);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("invalid magic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_payload_rejected() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_metadata(&serde_json::json!({"key": "a]long value that goes beyond the header boundary for sure to make truncation detectable"}));
|
||||
let data = builder.build();
|
||||
|
||||
// Chop off the last half of the container
|
||||
let cut = SEGMENT_HEADER_SIZE + 5;
|
||||
let truncated = &data[..cut];
|
||||
let result = RvfReader::from_bytes(truncated);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("truncated payload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_hash_integrity() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_metadata(&serde_json::json!({"key": "value"}));
|
||||
let mut data = builder.build();
|
||||
|
||||
// Corrupt one byte in the payload area (after the 64-byte header)
|
||||
if data.len() > 65 {
|
||||
data[65] ^= 0xFF;
|
||||
let result = RvfReader::from_bytes(&data);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("hash mismatch"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_for_various_payload_sizes() {
|
||||
for payload_size in [0, 1, 10, 63, 64, 65, 127, 128, 256, 1000] {
|
||||
let payload = vec![0xABu8; payload_size];
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.push_segment(SEG_META, &payload);
|
||||
let data = builder.build();
|
||||
assert_eq!(
|
||||
data.len() % SEGMENT_ALIGNMENT,
|
||||
0,
|
||||
"not aligned for payload_size={payload_size}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_ids_are_monotonic() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("m", "1", "d");
|
||||
builder.add_weights(&[1.0]);
|
||||
builder.add_metadata(&serde_json::json!({}));
|
||||
|
||||
let data = builder.build();
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
|
||||
let ids: Vec<u64> = reader.segments().map(|(h, _)| h.segment_id).collect();
|
||||
assert_eq!(ids, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_weights() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_weights(&[]);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
let w = reader.weights().unwrap();
|
||||
assert!(w.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_reports_correctly() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("info-test", "2.0", "info test");
|
||||
builder.add_weights(&[1.0, 2.0, 3.0]);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).unwrap();
|
||||
let info = reader.info();
|
||||
assert_eq!(info.segment_count, 2);
|
||||
assert!(info.total_size > 0);
|
||||
assert!(info.manifest.is_some());
|
||||
assert!(info.has_weights);
|
||||
assert!(!info.has_vital_config);
|
||||
assert!(!info.has_quant_info);
|
||||
assert!(!info.has_witness);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
//! Vital sign detection from WiFi CSI data.
|
||||
//!
|
||||
//! Implements breathing rate (0.1-0.5 Hz) and heart rate (0.8-2.0 Hz)
|
||||
//! estimation using FFT-based spectral analysis on CSI amplitude and phase
|
||||
//! time series. Designed per ADR-021 (rvdna vital sign pipeline).
|
||||
//!
|
||||
//! All math is pure Rust -- no external FFT crate required. Uses a radix-2
|
||||
//! DIT FFT for buffers zero-padded to power-of-two length. A windowed-sinc
|
||||
//! FIR bandpass filter isolates the frequency bands of interest before
|
||||
//! spectral analysis.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Configuration constants ────────────────────────────────────────────────
|
||||
|
||||
/// Breathing rate physiological band: 6-30 breaths per minute.
|
||||
const BREATHING_MIN_HZ: f64 = 0.1; // 6 BPM
|
||||
const BREATHING_MAX_HZ: f64 = 0.5; // 30 BPM
|
||||
|
||||
/// Heart rate physiological band: 40-120 beats per minute.
|
||||
const HEARTBEAT_MIN_HZ: f64 = 0.667; // 40 BPM
|
||||
const HEARTBEAT_MAX_HZ: f64 = 2.0; // 120 BPM
|
||||
|
||||
/// Minimum number of samples before attempting extraction.
|
||||
const MIN_BREATHING_SAMPLES: usize = 40; // ~2s at 20 Hz
|
||||
const MIN_HEARTBEAT_SAMPLES: usize = 30; // ~1.5s at 20 Hz
|
||||
|
||||
/// Peak-to-mean ratio threshold for confident detection.
|
||||
const CONFIDENCE_THRESHOLD: f64 = 2.0;
|
||||
|
||||
// ── Output types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Vital sign readings produced each frame.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VitalSigns {
|
||||
/// Estimated breathing rate in breaths per minute, if detected.
|
||||
pub breathing_rate_bpm: Option<f64>,
|
||||
/// Estimated heart rate in beats per minute, if detected.
|
||||
pub heart_rate_bpm: Option<f64>,
|
||||
/// Confidence of breathing estimate (0.0 - 1.0).
|
||||
pub breathing_confidence: f64,
|
||||
/// Confidence of heartbeat estimate (0.0 - 1.0).
|
||||
pub heartbeat_confidence: f64,
|
||||
/// Overall signal quality metric (0.0 - 1.0).
|
||||
pub signal_quality: f64,
|
||||
}
|
||||
|
||||
impl Default for VitalSigns {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
breathing_rate_bpm: None,
|
||||
heart_rate_bpm: None,
|
||||
breathing_confidence: 0.0,
|
||||
heartbeat_confidence: 0.0,
|
||||
signal_quality: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detector ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Stateful vital sign detector. Maintains rolling buffers of CSI amplitude
|
||||
/// data and extracts breathing and heart rate via spectral analysis.
|
||||
#[allow(dead_code)]
|
||||
pub struct VitalSignDetector {
|
||||
/// Rolling buffer of mean-amplitude samples for breathing detection.
|
||||
breathing_buffer: VecDeque<f64>,
|
||||
/// Rolling buffer of phase-variance samples for heartbeat detection.
|
||||
heartbeat_buffer: VecDeque<f64>,
|
||||
/// CSI frame arrival rate in Hz.
|
||||
sample_rate: f64,
|
||||
/// Window duration for breathing FFT in seconds.
|
||||
breathing_window_secs: f64,
|
||||
/// Window duration for heartbeat FFT in seconds.
|
||||
heartbeat_window_secs: f64,
|
||||
/// Maximum breathing buffer capacity (samples).
|
||||
breathing_capacity: usize,
|
||||
/// Maximum heartbeat buffer capacity (samples).
|
||||
heartbeat_capacity: usize,
|
||||
/// Running frame count for signal quality estimation.
|
||||
frame_count: u64,
|
||||
}
|
||||
|
||||
impl VitalSignDetector {
|
||||
/// Create a new detector with the given CSI sample rate (Hz).
|
||||
///
|
||||
/// Typical sample rates:
|
||||
/// - ESP32 CSI: 20-100 Hz
|
||||
/// - Windows WiFi RSSI: 2 Hz (insufficient for heartbeat)
|
||||
/// - Simulation: 2-20 Hz
|
||||
pub fn new(sample_rate: f64) -> Self {
|
||||
let breathing_window_secs = 30.0;
|
||||
let heartbeat_window_secs = 15.0;
|
||||
let breathing_capacity = (sample_rate * breathing_window_secs) as usize;
|
||||
let heartbeat_capacity = (sample_rate * heartbeat_window_secs) as usize;
|
||||
|
||||
Self {
|
||||
breathing_buffer: VecDeque::with_capacity(breathing_capacity.max(1)),
|
||||
heartbeat_buffer: VecDeque::with_capacity(heartbeat_capacity.max(1)),
|
||||
sample_rate,
|
||||
breathing_window_secs,
|
||||
heartbeat_window_secs,
|
||||
breathing_capacity: breathing_capacity.max(1),
|
||||
heartbeat_capacity: heartbeat_capacity.max(1),
|
||||
frame_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process one CSI frame and return updated vital signs.
|
||||
///
|
||||
/// `amplitude` - per-subcarrier amplitude values for this frame.
|
||||
/// `phase` - per-subcarrier phase values for this frame.
|
||||
///
|
||||
/// The detector extracts two aggregate features per frame:
|
||||
/// 1. Mean amplitude (breathing signal -- chest movement modulates path loss)
|
||||
/// 2. Phase variance across subcarriers (heartbeat signal -- subtle phase shifts)
|
||||
pub fn process_frame(&mut self, amplitude: &[f64], phase: &[f64]) -> VitalSigns {
|
||||
self.frame_count += 1;
|
||||
|
||||
if amplitude.is_empty() {
|
||||
return VitalSigns::default();
|
||||
}
|
||||
|
||||
// -- Feature 1: Mean amplitude for breathing detection --
|
||||
// Respiratory chest displacement (1-5 mm) modulates CSI amplitudes
|
||||
// across all subcarriers. Mean amplitude captures this well.
|
||||
let n = amplitude.len() as f64;
|
||||
let mean_amp: f64 = amplitude.iter().sum::<f64>() / n;
|
||||
|
||||
self.breathing_buffer.push_back(mean_amp);
|
||||
while self.breathing_buffer.len() > self.breathing_capacity {
|
||||
self.breathing_buffer.pop_front();
|
||||
}
|
||||
|
||||
// -- Feature 2: Phase variance for heartbeat detection --
|
||||
// Cardiac-induced body surface displacement is < 0.5 mm, producing
|
||||
// tiny phase changes. Cross-subcarrier phase variance captures this
|
||||
// more sensitively than amplitude alone.
|
||||
let phase_var = if phase.len() > 1 {
|
||||
let mean_phase: f64 = phase.iter().sum::<f64>() / phase.len() as f64;
|
||||
phase
|
||||
.iter()
|
||||
.map(|p| (p - mean_phase).powi(2))
|
||||
.sum::<f64>()
|
||||
/ phase.len() as f64
|
||||
} else {
|
||||
// Fallback: use amplitude high-pass residual when phase is unavailable
|
||||
let half = amplitude.len() / 2;
|
||||
if half > 0 {
|
||||
let hi_mean: f64 =
|
||||
amplitude[half..].iter().sum::<f64>() / (amplitude.len() - half) as f64;
|
||||
amplitude[half..]
|
||||
.iter()
|
||||
.map(|a| (a - hi_mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (amplitude.len() - half) as f64
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
|
||||
self.heartbeat_buffer.push_back(phase_var);
|
||||
while self.heartbeat_buffer.len() > self.heartbeat_capacity {
|
||||
self.heartbeat_buffer.pop_front();
|
||||
}
|
||||
|
||||
// -- Extract vital signs --
|
||||
let (breathing_rate, breathing_confidence) = self.extract_breathing();
|
||||
let (heart_rate, heartbeat_confidence) = self.extract_heartbeat();
|
||||
|
||||
// -- Signal quality --
|
||||
let signal_quality = self.compute_signal_quality(amplitude);
|
||||
|
||||
VitalSigns {
|
||||
breathing_rate_bpm: breathing_rate,
|
||||
heart_rate_bpm: heart_rate,
|
||||
breathing_confidence,
|
||||
heartbeat_confidence,
|
||||
signal_quality,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract breathing rate from the breathing buffer via FFT.
|
||||
/// Returns (rate_bpm, confidence).
|
||||
pub fn extract_breathing(&self) -> (Option<f64>, f64) {
|
||||
if self.breathing_buffer.len() < MIN_BREATHING_SAMPLES {
|
||||
return (None, 0.0);
|
||||
}
|
||||
|
||||
let data: Vec<f64> = self.breathing_buffer.iter().copied().collect();
|
||||
let filtered = bandpass_filter(&data, BREATHING_MIN_HZ, BREATHING_MAX_HZ, self.sample_rate);
|
||||
self.compute_fft_peak(&filtered, BREATHING_MIN_HZ, BREATHING_MAX_HZ)
|
||||
}
|
||||
|
||||
/// Extract heart rate from the heartbeat buffer via FFT.
|
||||
/// Returns (rate_bpm, confidence).
|
||||
pub fn extract_heartbeat(&self) -> (Option<f64>, f64) {
|
||||
if self.heartbeat_buffer.len() < MIN_HEARTBEAT_SAMPLES {
|
||||
return (None, 0.0);
|
||||
}
|
||||
|
||||
let data: Vec<f64> = self.heartbeat_buffer.iter().copied().collect();
|
||||
let filtered = bandpass_filter(&data, HEARTBEAT_MIN_HZ, HEARTBEAT_MAX_HZ, self.sample_rate);
|
||||
self.compute_fft_peak(&filtered, HEARTBEAT_MIN_HZ, HEARTBEAT_MAX_HZ)
|
||||
}
|
||||
|
||||
/// Find the dominant frequency in `buffer` within the [min_hz, max_hz] band
|
||||
/// using FFT. Returns (frequency_as_bpm, confidence).
|
||||
pub fn compute_fft_peak(
|
||||
&self,
|
||||
buffer: &[f64],
|
||||
min_hz: f64,
|
||||
max_hz: f64,
|
||||
) -> (Option<f64>, f64) {
|
||||
if buffer.len() < 4 {
|
||||
return (None, 0.0);
|
||||
}
|
||||
|
||||
// Zero-pad to next power of two for radix-2 FFT
|
||||
let fft_len = buffer.len().next_power_of_two();
|
||||
let mut signal = vec![0.0; fft_len];
|
||||
signal[..buffer.len()].copy_from_slice(buffer);
|
||||
|
||||
// Apply Hann window to reduce spectral leakage
|
||||
for i in 0..buffer.len() {
|
||||
let w = 0.5 * (1.0 - (2.0 * PI * i as f64 / (buffer.len() as f64 - 1.0)).cos());
|
||||
signal[i] *= w;
|
||||
}
|
||||
|
||||
// Compute FFT magnitude spectrum
|
||||
let spectrum = fft_magnitude(&signal);
|
||||
|
||||
// Frequency resolution
|
||||
let freq_res = self.sample_rate / fft_len as f64;
|
||||
|
||||
// Find bin range for our band of interest
|
||||
let min_bin = (min_hz / freq_res).ceil() as usize;
|
||||
let max_bin = ((max_hz / freq_res).floor() as usize).min(spectrum.len().saturating_sub(1));
|
||||
|
||||
if min_bin >= max_bin || min_bin >= spectrum.len() {
|
||||
return (None, 0.0);
|
||||
}
|
||||
|
||||
// Find peak magnitude and its bin index within the band
|
||||
let mut peak_mag = 0.0f64;
|
||||
let mut peak_bin = min_bin;
|
||||
let mut band_sum = 0.0f64;
|
||||
let mut band_count = 0usize;
|
||||
|
||||
for bin in min_bin..=max_bin {
|
||||
let mag = spectrum[bin];
|
||||
band_sum += mag;
|
||||
band_count += 1;
|
||||
if mag > peak_mag {
|
||||
peak_mag = mag;
|
||||
peak_bin = bin;
|
||||
}
|
||||
}
|
||||
|
||||
if band_count == 0 || band_sum < f64::EPSILON {
|
||||
return (None, 0.0);
|
||||
}
|
||||
|
||||
let band_mean = band_sum / band_count as f64;
|
||||
|
||||
// Confidence: ratio of peak to band mean, normalized to 0-1
|
||||
let peak_ratio = if band_mean > f64::EPSILON {
|
||||
peak_mag / band_mean
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Parabolic interpolation for sub-bin frequency accuracy
|
||||
let peak_freq = if peak_bin > min_bin && peak_bin < max_bin {
|
||||
let alpha = spectrum[peak_bin - 1];
|
||||
let beta = spectrum[peak_bin];
|
||||
let gamma = spectrum[peak_bin + 1];
|
||||
let denom = alpha - 2.0 * beta + gamma;
|
||||
if denom.abs() > f64::EPSILON {
|
||||
let p = 0.5 * (alpha - gamma) / denom;
|
||||
(peak_bin as f64 + p) * freq_res
|
||||
} else {
|
||||
peak_bin as f64 * freq_res
|
||||
}
|
||||
} else {
|
||||
peak_bin as f64 * freq_res
|
||||
};
|
||||
|
||||
let bpm = peak_freq * 60.0;
|
||||
|
||||
// Confidence mapping: peak_ratio >= CONFIDENCE_THRESHOLD maps to high confidence
|
||||
let confidence = if peak_ratio >= CONFIDENCE_THRESHOLD {
|
||||
((peak_ratio - 1.0) / (CONFIDENCE_THRESHOLD * 2.0 - 1.0)).clamp(0.0, 1.0)
|
||||
} else {
|
||||
((peak_ratio - 1.0) / (CONFIDENCE_THRESHOLD - 1.0) * 0.5).clamp(0.0, 0.5)
|
||||
};
|
||||
|
||||
if confidence > 0.05 {
|
||||
(Some(bpm), confidence)
|
||||
} else {
|
||||
(None, confidence)
|
||||
}
|
||||
}
|
||||
|
||||
/// Overall signal quality based on amplitude statistics.
|
||||
fn compute_signal_quality(&self, amplitude: &[f64]) -> f64 {
|
||||
if amplitude.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let n = amplitude.len() as f64;
|
||||
let mean = amplitude.iter().sum::<f64>() / n;
|
||||
|
||||
if mean < f64::EPSILON {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let variance = amplitude.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n;
|
||||
let cv = variance.sqrt() / mean; // coefficient of variation
|
||||
|
||||
// Good signal: moderate CV (some variation from body motion, not pure noise).
|
||||
// - Too low CV (~0) = static, no person present
|
||||
// - Too high CV (>1) = noisy/unstable signal
|
||||
// Sweet spot around 0.05-0.3
|
||||
let quality = if cv < 0.01 {
|
||||
cv / 0.01 * 0.3 // very low variation => low quality
|
||||
} else if cv < 0.3 {
|
||||
0.3 + 0.7 * (1.0 - ((cv - 0.15) / 0.15).abs()).max(0.0) // peak around 0.15
|
||||
} else {
|
||||
(1.0 - (cv - 0.3) / 0.7).clamp(0.1, 0.5) // too noisy
|
||||
};
|
||||
|
||||
// Factor in buffer fill level (need enough history for reliable estimates)
|
||||
let fill =
|
||||
(self.breathing_buffer.len() as f64) / (self.breathing_capacity as f64).max(1.0);
|
||||
let fill_factor = fill.clamp(0.0, 1.0);
|
||||
|
||||
(quality * (0.3 + 0.7 * fill_factor)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Clear all internal buffers and reset state.
|
||||
pub fn reset(&mut self) {
|
||||
self.breathing_buffer.clear();
|
||||
self.heartbeat_buffer.clear();
|
||||
self.frame_count = 0;
|
||||
}
|
||||
|
||||
/// Current buffer fill levels for diagnostics.
|
||||
/// Returns (breathing_len, breathing_capacity, heartbeat_len, heartbeat_capacity).
|
||||
pub fn buffer_status(&self) -> (usize, usize, usize, usize) {
|
||||
(
|
||||
self.breathing_buffer.len(),
|
||||
self.breathing_capacity,
|
||||
self.heartbeat_buffer.len(),
|
||||
self.heartbeat_capacity,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bandpass filter ────────────────────────────────────────────────────────
|
||||
|
||||
/// Simple FIR bandpass filter using a windowed-sinc design.
|
||||
///
|
||||
/// Constructs a bandpass by subtracting two lowpass filters (LPF_high - LPF_low)
|
||||
/// with a Hamming window. This is a zero-external-dependency implementation
|
||||
/// suitable for the buffer sizes we encounter (up to ~600 samples).
|
||||
pub fn bandpass_filter(data: &[f64], low_hz: f64, high_hz: f64, sample_rate: f64) -> Vec<f64> {
|
||||
if data.len() < 3 || sample_rate < f64::EPSILON {
|
||||
return data.to_vec();
|
||||
}
|
||||
|
||||
// Normalized cutoff frequencies (0 to 0.5)
|
||||
let low_norm = low_hz / sample_rate;
|
||||
let high_norm = high_hz / sample_rate;
|
||||
|
||||
if low_norm >= high_norm || low_norm >= 0.5 || high_norm <= 0.0 {
|
||||
return data.to_vec();
|
||||
}
|
||||
|
||||
// FIR filter order: ~3 cycles of the lowest frequency, clamped to [5, 127]
|
||||
let filter_order = ((3.0 / low_norm).ceil() as usize).clamp(5, 127);
|
||||
// Ensure odd for type-I FIR symmetry
|
||||
let filter_order = if filter_order % 2 == 0 {
|
||||
filter_order + 1
|
||||
} else {
|
||||
filter_order
|
||||
};
|
||||
|
||||
let half = filter_order / 2;
|
||||
let mut coeffs = vec![0.0f64; filter_order];
|
||||
|
||||
// BPF = LPF(high_norm) - LPF(low_norm) with Hamming window
|
||||
for i in 0..filter_order {
|
||||
let n = i as f64 - half as f64;
|
||||
let lp_high = if n.abs() < f64::EPSILON {
|
||||
2.0 * high_norm
|
||||
} else {
|
||||
(2.0 * PI * high_norm * n).sin() / (PI * n)
|
||||
};
|
||||
let lp_low = if n.abs() < f64::EPSILON {
|
||||
2.0 * low_norm
|
||||
} else {
|
||||
(2.0 * PI * low_norm * n).sin() / (PI * n)
|
||||
};
|
||||
|
||||
// Hamming window
|
||||
let w = 0.54 - 0.46 * (2.0 * PI * i as f64 / (filter_order as f64 - 1.0)).cos();
|
||||
coeffs[i] = (lp_high - lp_low) * w;
|
||||
}
|
||||
|
||||
// Normalize filter to unit gain at center frequency
|
||||
let center_freq = (low_norm + high_norm) / 2.0;
|
||||
let gain: f64 = coeffs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &c)| c * (2.0 * PI * center_freq * i as f64).cos())
|
||||
.sum();
|
||||
if gain.abs() > f64::EPSILON {
|
||||
for c in coeffs.iter_mut() {
|
||||
*c /= gain;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filter via convolution
|
||||
let mut output = vec![0.0f64; data.len()];
|
||||
for i in 0..data.len() {
|
||||
let mut sum = 0.0;
|
||||
for (j, &coeff) in coeffs.iter().enumerate() {
|
||||
let idx = i as isize - half as isize + j as isize;
|
||||
if idx >= 0 && (idx as usize) < data.len() {
|
||||
sum += data[idx as usize] * coeff;
|
||||
}
|
||||
}
|
||||
output[i] = sum;
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
// ── FFT implementation ─────────────────────────────────────────────────────
|
||||
|
||||
/// Compute the magnitude spectrum of a real-valued signal using radix-2 DIT FFT.
|
||||
///
|
||||
/// Input must be power-of-2 length (caller should zero-pad).
|
||||
/// Returns magnitudes for bins 0..N/2+1.
|
||||
fn fft_magnitude(signal: &[f64]) -> Vec<f64> {
|
||||
let n = signal.len();
|
||||
debug_assert!(n.is_power_of_two(), "FFT input must be power-of-2 length");
|
||||
|
||||
if n <= 1 {
|
||||
return signal.to_vec();
|
||||
}
|
||||
|
||||
// Convert to complex (imaginary = 0)
|
||||
let mut real = signal.to_vec();
|
||||
let mut imag = vec![0.0f64; n];
|
||||
|
||||
// Bit-reversal permutation
|
||||
bit_reverse_permute(&mut real, &mut imag);
|
||||
|
||||
// Cooley-Tukey radix-2 DIT butterfly
|
||||
let mut size = 2;
|
||||
while size <= n {
|
||||
let half = size / 2;
|
||||
let angle_step = -2.0 * PI / size as f64;
|
||||
|
||||
for start in (0..n).step_by(size) {
|
||||
for k in 0..half {
|
||||
let angle = angle_step * k as f64;
|
||||
let wr = angle.cos();
|
||||
let wi = angle.sin();
|
||||
|
||||
let i = start + k;
|
||||
let j = start + k + half;
|
||||
|
||||
let tr = wr * real[j] - wi * imag[j];
|
||||
let ti = wr * imag[j] + wi * real[j];
|
||||
|
||||
real[j] = real[i] - tr;
|
||||
imag[j] = imag[i] - ti;
|
||||
real[i] += tr;
|
||||
imag[i] += ti;
|
||||
}
|
||||
}
|
||||
|
||||
size *= 2;
|
||||
}
|
||||
|
||||
// Compute magnitudes for positive frequencies (0..N/2+1)
|
||||
let out_len = n / 2 + 1;
|
||||
let mut magnitudes = Vec::with_capacity(out_len);
|
||||
for i in 0..out_len {
|
||||
magnitudes.push((real[i] * real[i] + imag[i] * imag[i]).sqrt());
|
||||
}
|
||||
|
||||
magnitudes
|
||||
}
|
||||
|
||||
/// In-place bit-reversal permutation for FFT.
|
||||
fn bit_reverse_permute(real: &mut [f64], imag: &mut [f64]) {
|
||||
let n = real.len();
|
||||
let bits = (n as f64).log2() as u32;
|
||||
|
||||
for i in 0..n {
|
||||
let j = reverse_bits(i as u32, bits) as usize;
|
||||
if i < j {
|
||||
real.swap(i, j);
|
||||
imag.swap(i, j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse the lower `bits` bits of `val`.
|
||||
fn reverse_bits(val: u32, bits: u32) -> u32 {
|
||||
let mut result = 0u32;
|
||||
let mut v = val;
|
||||
for _ in 0..bits {
|
||||
result = (result << 1) | (v & 1);
|
||||
v >>= 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── Benchmark ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Run a benchmark: process `n_frames` synthetic frames and report timing.
|
||||
///
|
||||
/// Generates frames with embedded breathing (0.25 Hz / 15 BPM) and heartbeat
|
||||
/// (1.2 Hz / 72 BPM) signals on 56 subcarriers at 20 Hz sample rate.
|
||||
///
|
||||
/// Returns (total_duration, per_frame_duration).
|
||||
pub fn run_benchmark(n_frames: usize) -> (std::time::Duration, std::time::Duration) {
|
||||
use std::time::Instant;
|
||||
|
||||
let sample_rate = 20.0;
|
||||
let mut detector = VitalSignDetector::new(sample_rate);
|
||||
|
||||
// Pre-generate synthetic CSI data (56 subcarriers, matching simulation mode)
|
||||
let n_sub = 56;
|
||||
let frames: Vec<(Vec<f64>, Vec<f64>)> = (0..n_frames)
|
||||
.map(|tick| {
|
||||
let t = tick as f64 / sample_rate;
|
||||
let mut amp = Vec::with_capacity(n_sub);
|
||||
let mut phase = Vec::with_capacity(n_sub);
|
||||
for i in 0..n_sub {
|
||||
// Embedded breathing at 0.25 Hz (15 BPM) and heartbeat at 1.2 Hz (72 BPM)
|
||||
let breathing = 2.0 * (2.0 * PI * 0.25 * t).sin();
|
||||
let heartbeat = 0.3 * (2.0 * PI * 1.2 * t).sin();
|
||||
let base = 15.0 + 5.0 * (i as f64 * 0.1).sin();
|
||||
let noise = (i as f64 * 7.3 + t * 13.7).sin() * 0.5;
|
||||
amp.push(base + breathing + heartbeat + noise);
|
||||
phase.push((i as f64 * 0.2 + t * 0.5).sin() * PI + heartbeat * 0.1);
|
||||
}
|
||||
(amp, phase)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let start = Instant::now();
|
||||
let mut last_vital = VitalSigns::default();
|
||||
for (amp, phase) in &frames {
|
||||
last_vital = detector.process_frame(amp, phase);
|
||||
}
|
||||
let total = start.elapsed();
|
||||
let per_frame = total / n_frames as u32;
|
||||
|
||||
eprintln!("=== Vital Sign Detection Benchmark ===");
|
||||
eprintln!("Frames processed: {}", n_frames);
|
||||
eprintln!("Sample rate: {} Hz", sample_rate);
|
||||
eprintln!("Subcarriers: {}", n_sub);
|
||||
eprintln!("Total time: {:?}", total);
|
||||
eprintln!("Per-frame time: {:?}", per_frame);
|
||||
eprintln!(
|
||||
"Throughput: {:.0} frames/sec",
|
||||
n_frames as f64 / total.as_secs_f64()
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!("Final vital signs:");
|
||||
eprintln!(
|
||||
" Breathing rate: {:?} BPM",
|
||||
last_vital.breathing_rate_bpm
|
||||
);
|
||||
eprintln!(" Heart rate: {:?} BPM", last_vital.heart_rate_bpm);
|
||||
eprintln!(
|
||||
" Breathing confidence: {:.3}",
|
||||
last_vital.breathing_confidence
|
||||
);
|
||||
eprintln!(
|
||||
" Heartbeat confidence: {:.3}",
|
||||
last_vital.heartbeat_confidence
|
||||
);
|
||||
eprintln!(
|
||||
" Signal quality: {:.3}",
|
||||
last_vital.signal_quality
|
||||
);
|
||||
|
||||
(total, per_frame)
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fft_magnitude_dc() {
|
||||
let signal = vec![1.0; 8];
|
||||
let mag = fft_magnitude(&signal);
|
||||
// DC bin should be 8.0 (sum), all others near zero
|
||||
assert!((mag[0] - 8.0).abs() < 1e-10);
|
||||
for m in &mag[1..] {
|
||||
assert!(*m < 1e-10, "non-DC bin should be near zero, got {m}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fft_magnitude_sine() {
|
||||
// 16-point signal with a single sinusoid at bin 2
|
||||
let n = 16;
|
||||
let mut signal = vec![0.0; n];
|
||||
for i in 0..n {
|
||||
signal[i] = (2.0 * PI * 2.0 * i as f64 / n as f64).sin();
|
||||
}
|
||||
let mag = fft_magnitude(&signal);
|
||||
// Peak should be at bin 2
|
||||
let peak_bin = mag
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(1) // skip DC
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(peak_bin, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bit_reverse() {
|
||||
assert_eq!(reverse_bits(0b000, 3), 0b000);
|
||||
assert_eq!(reverse_bits(0b001, 3), 0b100);
|
||||
assert_eq!(reverse_bits(0b110, 3), 0b011);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bandpass_filter_passthrough() {
|
||||
// A sine at the center of the passband should mostly pass through
|
||||
let sr = 20.0;
|
||||
let freq = 0.25; // center of breathing band
|
||||
let n = 200;
|
||||
let data: Vec<f64> = (0..n)
|
||||
.map(|i| (2.0 * PI * freq * i as f64 / sr).sin())
|
||||
.collect();
|
||||
let filtered = bandpass_filter(&data, 0.1, 0.5, sr);
|
||||
// Check that the filtered signal has significant energy
|
||||
let energy: f64 = filtered.iter().map(|x| x * x).sum::<f64>() / n as f64;
|
||||
assert!(
|
||||
energy > 0.01,
|
||||
"passband signal should pass through, energy={energy}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bandpass_filter_rejects_out_of_band() {
|
||||
// A sine well outside the passband should be attenuated
|
||||
let sr = 20.0;
|
||||
let freq = 5.0; // way above breathing band
|
||||
let n = 200;
|
||||
let data: Vec<f64> = (0..n)
|
||||
.map(|i| (2.0 * PI * freq * i as f64 / sr).sin())
|
||||
.collect();
|
||||
let in_energy: f64 = data.iter().map(|x| x * x).sum::<f64>() / n as f64;
|
||||
let filtered = bandpass_filter(&data, 0.1, 0.5, sr);
|
||||
let out_energy: f64 = filtered.iter().map(|x| x * x).sum::<f64>() / n as f64;
|
||||
let attenuation = out_energy / in_energy;
|
||||
assert!(
|
||||
attenuation < 0.3,
|
||||
"out-of-band signal should be attenuated, ratio={attenuation}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_sign_detector_breathing() {
|
||||
let sr = 20.0;
|
||||
let mut detector = VitalSignDetector::new(sr);
|
||||
let target_bpm = 15.0; // 0.25 Hz
|
||||
let target_hz = target_bpm / 60.0;
|
||||
|
||||
// Feed 30 seconds of data with a clear breathing signal
|
||||
let n_frames = (sr * 30.0) as usize;
|
||||
let mut vitals = VitalSigns::default();
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sr;
|
||||
let amp: Vec<f64> = (0..56)
|
||||
.map(|i| {
|
||||
let base = 15.0 + 5.0 * (i as f64 * 0.1).sin();
|
||||
let breathing = 3.0 * (2.0 * PI * target_hz * t).sin();
|
||||
base + breathing
|
||||
})
|
||||
.collect();
|
||||
let phase: Vec<f64> = (0..56).map(|i| (i as f64 * 0.2).sin()).collect();
|
||||
vitals = detector.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
// After 30s, breathing should be detected
|
||||
assert!(
|
||||
vitals.breathing_rate_bpm.is_some(),
|
||||
"breathing should be detected after 30s"
|
||||
);
|
||||
if let Some(rate) = vitals.breathing_rate_bpm {
|
||||
let error = (rate - target_bpm).abs();
|
||||
assert!(
|
||||
error < 3.0,
|
||||
"breathing rate {rate:.1} BPM should be near {target_bpm} BPM (error={error:.1})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_sign_detector_reset() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
let amp = vec![10.0; 56];
|
||||
let phase = vec![0.0; 56];
|
||||
for _ in 0..100 {
|
||||
detector.process_frame(&, &phase);
|
||||
}
|
||||
let (br_len, _, hb_len, _) = detector.buffer_status();
|
||||
assert!(br_len > 0);
|
||||
assert!(hb_len > 0);
|
||||
|
||||
detector.reset();
|
||||
let (br_len, _, hb_len, _) = detector.buffer_status();
|
||||
assert_eq!(br_len, 0);
|
||||
assert_eq!(hb_len, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_signs_default() {
|
||||
let vs = VitalSigns::default();
|
||||
assert!(vs.breathing_rate_bpm.is_none());
|
||||
assert!(vs.heart_rate_bpm.is_none());
|
||||
assert_eq!(vs.breathing_confidence, 0.0);
|
||||
assert_eq!(vs.heartbeat_confidence, 0.0);
|
||||
assert_eq!(vs.signal_quality, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_amplitude() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
let vs = detector.process_frame(&[], &[]);
|
||||
assert!(vs.breathing_rate_bpm.is_none());
|
||||
assert!(vs.heart_rate_bpm.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_subcarrier() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
// Single subcarrier should not crash
|
||||
for i in 0..100 {
|
||||
let t = i as f64 / 20.0;
|
||||
let amp = vec![10.0 + (2.0 * PI * 0.25 * t).sin()];
|
||||
let phase = vec![0.0];
|
||||
let _ = detector.process_frame(&, &phase);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_benchmark_runs() {
|
||||
let (total, per_frame) = run_benchmark(100);
|
||||
assert!(total.as_nanos() > 0);
|
||||
assert!(per_frame.as_nanos() > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
//! Integration tests for the RVF (RuVector Format) container module.
|
||||
//!
|
||||
//! These tests exercise the public RvfBuilder and RvfReader APIs through
|
||||
//! the library crate's public interface. They complement the inline unit
|
||||
//! tests in rvf_container.rs by testing from the perspective of an external
|
||||
//! consumer.
|
||||
//!
|
||||
//! Test matrix:
|
||||
//! - Empty builder produces valid (empty) container
|
||||
//! - Full round-trip: manifest + weights + metadata -> build -> read -> verify
|
||||
//! - Segment type tagging and ordering
|
||||
//! - Magic byte corruption is rejected
|
||||
//! - Float32 precision is preserved bit-for-bit
|
||||
//! - Large payload (1M weights) round-trip
|
||||
//! - Multiple metadata segments coexist
|
||||
//! - File I/O round-trip
|
||||
//! - Witness/proof segment verification
|
||||
//! - Write/read benchmark for ~10MB container
|
||||
|
||||
use wifi_densepose_sensing_server::rvf_container::{
|
||||
RvfBuilder, RvfReader, VitalSignConfig,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_rvf_builder_empty() {
|
||||
let builder = RvfBuilder::new();
|
||||
let data = builder.build();
|
||||
|
||||
// Empty builder produces zero bytes (no segments => no headers)
|
||||
assert!(
|
||||
data.is_empty(),
|
||||
"empty builder should produce empty byte vec"
|
||||
);
|
||||
|
||||
// Reader should parse an empty container with zero segments
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse empty container");
|
||||
assert_eq!(reader.segment_count(), 0);
|
||||
assert_eq!(reader.total_size(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_round_trip() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
|
||||
// Add all segment types
|
||||
builder.add_manifest("vital-signs-v1", "0.1.0", "Vital sign detection model");
|
||||
|
||||
let weights: Vec<f32> = (0..100).map(|i| i as f32 * 0.01).collect();
|
||||
builder.add_weights(&weights);
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"training_epochs": 50,
|
||||
"loss": 0.023,
|
||||
"optimizer": "adam",
|
||||
});
|
||||
builder.add_metadata(&metadata);
|
||||
|
||||
let data = builder.build();
|
||||
assert!(!data.is_empty(), "container with data should not be empty");
|
||||
|
||||
// Alignment: every segment should start on a 64-byte boundary
|
||||
assert_eq!(
|
||||
data.len() % 64,
|
||||
0,
|
||||
"total size should be a multiple of 64 bytes"
|
||||
);
|
||||
|
||||
// Parse back
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse container");
|
||||
assert_eq!(reader.segment_count(), 3);
|
||||
|
||||
// Verify manifest
|
||||
let manifest = reader
|
||||
.manifest()
|
||||
.expect("should have manifest");
|
||||
assert_eq!(manifest["model_id"], "vital-signs-v1");
|
||||
assert_eq!(manifest["version"], "0.1.0");
|
||||
assert_eq!(manifest["description"], "Vital sign detection model");
|
||||
|
||||
// Verify weights
|
||||
let decoded_weights = reader
|
||||
.weights()
|
||||
.expect("should have weights");
|
||||
assert_eq!(decoded_weights.len(), weights.len());
|
||||
for (i, (&original, &decoded)) in weights.iter().zip(decoded_weights.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
original.to_bits(),
|
||||
decoded.to_bits(),
|
||||
"weight[{i}] mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
let decoded_meta = reader
|
||||
.metadata()
|
||||
.expect("should have metadata");
|
||||
assert_eq!(decoded_meta["training_epochs"], 50);
|
||||
assert_eq!(decoded_meta["optimizer"], "adam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_segment_types() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("test", "1.0", "test model");
|
||||
builder.add_weights(&[1.0, 2.0]);
|
||||
builder.add_metadata(&serde_json::json!({"key": "value"}));
|
||||
builder.add_witness(
|
||||
"sha256:abc123",
|
||||
&serde_json::json!({"accuracy": 0.95}),
|
||||
);
|
||||
|
||||
let data = builder.build();
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
||||
|
||||
assert_eq!(reader.segment_count(), 4);
|
||||
|
||||
// Each segment type should be present
|
||||
assert!(reader.manifest().is_some(), "manifest should be present");
|
||||
assert!(reader.weights().is_some(), "weights should be present");
|
||||
assert!(reader.metadata().is_some(), "metadata should be present");
|
||||
assert!(reader.witness().is_some(), "witness should be present");
|
||||
|
||||
// Verify segment order via segment IDs (monotonically increasing)
|
||||
let ids: Vec<u64> = reader
|
||||
.segments()
|
||||
.map(|(h, _)| h.segment_id)
|
||||
.collect();
|
||||
assert_eq!(ids, vec![0, 1, 2, 3], "segment IDs should be 0,1,2,3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_magic_validation() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("test", "1.0", "test");
|
||||
let mut data = builder.build();
|
||||
|
||||
// Corrupt the magic bytes in the first segment header
|
||||
// Magic is at offset 0x00..0x04
|
||||
data[0] = 0xDE;
|
||||
data[1] = 0xAD;
|
||||
data[2] = 0xBE;
|
||||
data[3] = 0xEF;
|
||||
|
||||
let result = RvfReader::from_bytes(&data);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"corrupted magic should fail to parse"
|
||||
);
|
||||
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("magic"),
|
||||
"error message should mention 'magic', got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_weights_f32_precision() {
|
||||
// Test specific float32 edge cases
|
||||
let weights: Vec<f32> = vec![
|
||||
0.0,
|
||||
1.0,
|
||||
-1.0,
|
||||
f32::MIN_POSITIVE,
|
||||
f32::MAX,
|
||||
f32::MIN,
|
||||
f32::EPSILON,
|
||||
std::f32::consts::PI,
|
||||
std::f32::consts::E,
|
||||
1.0e-30,
|
||||
1.0e30,
|
||||
-0.0,
|
||||
0.123456789,
|
||||
1.0e-45, // subnormal
|
||||
];
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_weights(&weights);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
||||
let decoded = reader.weights().expect("should have weights");
|
||||
|
||||
assert_eq!(decoded.len(), weights.len());
|
||||
for (i, (&original, &parsed)) in weights.iter().zip(decoded.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
original.to_bits(),
|
||||
parsed.to_bits(),
|
||||
"weight[{i}] bit-level mismatch: original={original} (0x{:08X}), parsed={parsed} (0x{:08X})",
|
||||
original.to_bits(),
|
||||
parsed.to_bits(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_large_payload() {
|
||||
// 1 million f32 weights = 4 MB of payload data
|
||||
let num_weights = 1_000_000;
|
||||
let weights: Vec<f32> = (0..num_weights)
|
||||
.map(|i| (i as f32 * 0.000001).sin())
|
||||
.collect();
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("large-test", "1.0", "Large payload test");
|
||||
builder.add_weights(&weights);
|
||||
let data = builder.build();
|
||||
|
||||
// Container should be at least header + weights bytes
|
||||
assert!(
|
||||
data.len() >= 64 + num_weights * 4,
|
||||
"container should be large enough, got {} bytes",
|
||||
data.len()
|
||||
);
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse large container");
|
||||
let decoded = reader.weights().expect("should have weights");
|
||||
|
||||
assert_eq!(
|
||||
decoded.len(),
|
||||
num_weights,
|
||||
"all 1M weights should round-trip"
|
||||
);
|
||||
|
||||
// Spot-check several values
|
||||
for idx in [0, 1, 100, 1000, 500_000, 999_999] {
|
||||
assert_eq!(
|
||||
weights[idx].to_bits(),
|
||||
decoded[idx].to_bits(),
|
||||
"weight[{idx}] mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_multiple_metadata_segments() {
|
||||
// The current builder only stores one metadata segment, but we can add
|
||||
// multiple by adding metadata and then other segments to verify all coexist.
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("multi-meta", "1.0", "Multiple segment types");
|
||||
|
||||
let meta1 = serde_json::json!({"training_config": {"optimizer": "adam"}});
|
||||
builder.add_metadata(&meta1);
|
||||
|
||||
builder.add_vital_config(&VitalSignConfig::default());
|
||||
builder.add_quant_info("int8", 0.0078125, -128);
|
||||
|
||||
let data = builder.build();
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
||||
|
||||
assert_eq!(
|
||||
reader.segment_count(),
|
||||
4,
|
||||
"should have 4 segments (manifest + meta + vital_config + quant)"
|
||||
);
|
||||
|
||||
assert!(reader.manifest().is_some());
|
||||
assert!(reader.metadata().is_some());
|
||||
assert!(reader.vital_config().is_some());
|
||||
assert!(reader.quant_info().is_some());
|
||||
|
||||
// Verify metadata content
|
||||
let meta = reader.metadata().unwrap();
|
||||
assert_eq!(meta["training_config"]["optimizer"], "adam");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_file_io() {
|
||||
let tmp_dir = tempfile::tempdir().expect("should create temp dir");
|
||||
let file_path = tmp_dir.path().join("test_model.rvf");
|
||||
|
||||
let weights: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4, 0.5];
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("file-io-test", "1.0.0", "File I/O test model");
|
||||
builder.add_weights(&weights);
|
||||
builder.add_metadata(&serde_json::json!({"created": "2026-02-28"}));
|
||||
|
||||
// Write to file
|
||||
builder
|
||||
.write_to_file(&file_path)
|
||||
.expect("should write to file");
|
||||
|
||||
// Read back from file
|
||||
let reader = RvfReader::from_file(&file_path).expect("should read from file");
|
||||
|
||||
assert_eq!(reader.segment_count(), 3);
|
||||
|
||||
let manifest = reader.manifest().expect("should have manifest");
|
||||
assert_eq!(manifest["model_id"], "file-io-test");
|
||||
|
||||
let decoded_weights = reader.weights().expect("should have weights");
|
||||
assert_eq!(decoded_weights.len(), weights.len());
|
||||
for (a, b) in decoded_weights.iter().zip(weights.iter()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits());
|
||||
}
|
||||
|
||||
let meta = reader.metadata().expect("should have metadata");
|
||||
assert_eq!(meta["created"], "2026-02-28");
|
||||
|
||||
// Verify file size matches in-memory serialization
|
||||
let in_memory = builder.build();
|
||||
let file_meta = std::fs::metadata(&file_path).expect("should stat file");
|
||||
assert_eq!(
|
||||
file_meta.len() as usize,
|
||||
in_memory.len(),
|
||||
"file size should match serialized size"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_witness_proof() {
|
||||
let training_hash = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
let metrics = serde_json::json!({
|
||||
"accuracy": 0.957,
|
||||
"loss": 0.023,
|
||||
"epochs": 200,
|
||||
"dataset_size": 50000,
|
||||
});
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("witnessed-model", "2.0", "Model with witness proof");
|
||||
builder.add_weights(&[1.0, 2.0, 3.0]);
|
||||
builder.add_witness(training_hash, &metrics);
|
||||
|
||||
let data = builder.build();
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
||||
|
||||
let witness = reader.witness().expect("should have witness segment");
|
||||
assert_eq!(
|
||||
witness["training_hash"],
|
||||
training_hash,
|
||||
"training hash should round-trip"
|
||||
);
|
||||
assert_eq!(witness["metrics"]["accuracy"], 0.957);
|
||||
assert_eq!(witness["metrics"]["epochs"], 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_benchmark_write_read() {
|
||||
// Create a container with ~10 MB of weights
|
||||
let num_weights = 2_500_000; // 10 MB of f32 data
|
||||
let weights: Vec<f32> = (0..num_weights)
|
||||
.map(|i| (i as f32 * 0.0001).sin())
|
||||
.collect();
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("benchmark-model", "1.0", "Benchmark test");
|
||||
builder.add_weights(&weights);
|
||||
builder.add_metadata(&serde_json::json!({"benchmark": true}));
|
||||
|
||||
// Benchmark write (serialization)
|
||||
let write_start = std::time::Instant::now();
|
||||
let data = builder.build();
|
||||
let write_elapsed = write_start.elapsed();
|
||||
|
||||
let size_mb = data.len() as f64 / (1024.0 * 1024.0);
|
||||
let write_speed = size_mb / write_elapsed.as_secs_f64();
|
||||
|
||||
println!(
|
||||
"RVF write benchmark: {:.1} MB in {:.2}ms = {:.0} MB/s",
|
||||
size_mb,
|
||||
write_elapsed.as_secs_f64() * 1000.0,
|
||||
write_speed,
|
||||
);
|
||||
|
||||
// Benchmark read (deserialization + CRC validation)
|
||||
let read_start = std::time::Instant::now();
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse benchmark container");
|
||||
let read_elapsed = read_start.elapsed();
|
||||
|
||||
let read_speed = size_mb / read_elapsed.as_secs_f64();
|
||||
|
||||
println!(
|
||||
"RVF read benchmark: {:.1} MB in {:.2}ms = {:.0} MB/s",
|
||||
size_mb,
|
||||
read_elapsed.as_secs_f64() * 1000.0,
|
||||
read_speed,
|
||||
);
|
||||
|
||||
// Verify correctness
|
||||
let decoded_weights = reader.weights().expect("should have weights");
|
||||
assert_eq!(decoded_weights.len(), num_weights);
|
||||
assert_eq!(weights[0].to_bits(), decoded_weights[0].to_bits());
|
||||
assert_eq!(
|
||||
weights[num_weights - 1].to_bits(),
|
||||
decoded_weights[num_weights - 1].to_bits()
|
||||
);
|
||||
|
||||
// Write and read should be reasonably fast
|
||||
assert!(
|
||||
write_speed > 10.0,
|
||||
"write speed {:.0} MB/s is too slow",
|
||||
write_speed
|
||||
);
|
||||
assert!(
|
||||
read_speed > 10.0,
|
||||
"read speed {:.0} MB/s is too slow",
|
||||
read_speed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_content_hash_integrity() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_metadata(&serde_json::json!({"integrity": "test"}));
|
||||
let mut data = builder.build();
|
||||
|
||||
// Corrupt one byte in the payload area (after the 64-byte header)
|
||||
if data.len() > 65 {
|
||||
data[65] ^= 0xFF;
|
||||
let result = RvfReader::from_bytes(&data);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"corrupted payload should fail CRC32 hash check"
|
||||
);
|
||||
assert!(
|
||||
result.unwrap_err().contains("hash mismatch"),
|
||||
"error should mention hash mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_truncated_data() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("truncation-test", "1.0", "Truncation test");
|
||||
builder.add_weights(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let data = builder.build();
|
||||
|
||||
// Truncating at header boundary or within payload should fail
|
||||
for truncate_at in [0, 10, 32, 63, 64, 65, 80] {
|
||||
if truncate_at < data.len() {
|
||||
let truncated = &data[..truncate_at];
|
||||
let result = RvfReader::from_bytes(truncated);
|
||||
// Empty or partial-header data: either returns empty or errors
|
||||
if truncate_at < 64 {
|
||||
// Less than one header: reader returns 0 segments (no error on empty)
|
||||
// or fails if partial header data is present
|
||||
// The reader skips if offset + HEADER_SIZE > data.len()
|
||||
if truncate_at == 0 {
|
||||
assert!(
|
||||
result.is_ok() && result.unwrap().segment_count() == 0,
|
||||
"empty data should parse as 0 segments"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Has header but truncated payload
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"truncated at {truncate_at} bytes should fail"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_empty_weights() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_weights(&[]);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
||||
let weights = reader.weights().expect("should have weights segment");
|
||||
assert!(weights.is_empty(), "empty weight vector should round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_vital_config_round_trip() {
|
||||
let config = VitalSignConfig {
|
||||
breathing_low_hz: 0.15,
|
||||
breathing_high_hz: 0.45,
|
||||
heartrate_low_hz: 0.9,
|
||||
heartrate_high_hz: 1.8,
|
||||
min_subcarriers: 64,
|
||||
window_size: 1024,
|
||||
confidence_threshold: 0.7,
|
||||
};
|
||||
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_vital_config(&config);
|
||||
let data = builder.build();
|
||||
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
||||
let decoded = reader
|
||||
.vital_config()
|
||||
.expect("should have vital config");
|
||||
|
||||
assert!(
|
||||
(decoded.breathing_low_hz - 0.15).abs() < f64::EPSILON,
|
||||
"breathing_low_hz mismatch"
|
||||
);
|
||||
assert!(
|
||||
(decoded.breathing_high_hz - 0.45).abs() < f64::EPSILON,
|
||||
"breathing_high_hz mismatch"
|
||||
);
|
||||
assert!(
|
||||
(decoded.heartrate_low_hz - 0.9).abs() < f64::EPSILON,
|
||||
"heartrate_low_hz mismatch"
|
||||
);
|
||||
assert!(
|
||||
(decoded.heartrate_high_hz - 1.8).abs() < f64::EPSILON,
|
||||
"heartrate_high_hz mismatch"
|
||||
);
|
||||
assert_eq!(decoded.min_subcarriers, 64);
|
||||
assert_eq!(decoded.window_size, 1024);
|
||||
assert!(
|
||||
(decoded.confidence_threshold - 0.7).abs() < f64::EPSILON,
|
||||
"confidence_threshold mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_info_struct() {
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_manifest("info-test", "2.0", "Info struct test");
|
||||
builder.add_weights(&[1.0, 2.0, 3.0]);
|
||||
builder.add_vital_config(&VitalSignConfig::default());
|
||||
builder.add_witness("sha256:test", &serde_json::json!({"ok": true}));
|
||||
|
||||
let data = builder.build();
|
||||
let reader = RvfReader::from_bytes(&data).expect("should parse");
|
||||
let info = reader.info();
|
||||
|
||||
assert_eq!(info.segment_count, 4);
|
||||
assert!(info.total_size > 0);
|
||||
assert!(info.manifest.is_some());
|
||||
assert!(info.has_weights);
|
||||
assert!(info.has_vital_config);
|
||||
assert!(info.has_witness);
|
||||
assert!(!info.has_quant_info, "no quant segment was added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rvf_alignment_invariant() {
|
||||
// Every container should have total size that is a multiple of 64
|
||||
for num_weights in [0, 1, 10, 100, 255, 256, 1000] {
|
||||
let weights: Vec<f32> = (0..num_weights).map(|i| i as f32).collect();
|
||||
let mut builder = RvfBuilder::new();
|
||||
builder.add_weights(&weights);
|
||||
let data = builder.build();
|
||||
|
||||
assert_eq!(
|
||||
data.len() % 64,
|
||||
0,
|
||||
"container with {num_weights} weights should be 64-byte aligned, got {} bytes",
|
||||
data.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
//! Comprehensive integration tests for the vital sign detection module.
|
||||
//!
|
||||
//! These tests exercise the public VitalSignDetector API by feeding
|
||||
//! synthetic CSI frames (amplitude + phase vectors) and verifying the
|
||||
//! extracted breathing rate, heart rate, confidence, and signal quality.
|
||||
//!
|
||||
//! Test matrix:
|
||||
//! - Detector creation and sane defaults
|
||||
//! - Breathing rate detection from synthetic 0.25 Hz (15 BPM) sine
|
||||
//! - Heartbeat detection from synthetic 1.2 Hz (72 BPM) sine
|
||||
//! - Combined breathing + heartbeat detection
|
||||
//! - No-signal (constant amplitude) returns None or low confidence
|
||||
//! - Out-of-range frequencies are rejected or produce low confidence
|
||||
//! - Confidence increases with signal-to-noise ratio
|
||||
//! - Reset clears all internal buffers
|
||||
//! - Minimum samples threshold
|
||||
//! - Throughput benchmark (10000 frames)
|
||||
|
||||
use std::f64::consts::PI;
|
||||
use wifi_densepose_sensing_server::vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const N_SUBCARRIERS: usize = 56;
|
||||
|
||||
/// Generate a single CSI frame's amplitude vector with an embedded
|
||||
/// breathing-band sine wave at `freq_hz` Hz.
|
||||
///
|
||||
/// The returned amplitude has `N_SUBCARRIERS` elements, each with a
|
||||
/// per-subcarrier baseline plus the breathing modulation.
|
||||
fn make_breathing_frame(freq_hz: f64, t: f64) -> Vec<f64> {
|
||||
(0..N_SUBCARRIERS)
|
||||
.map(|i| {
|
||||
let base = 15.0 + 5.0 * (i as f64 * 0.1).sin();
|
||||
let breathing = 2.0 * (2.0 * PI * freq_hz * t).sin();
|
||||
base + breathing
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate a phase vector that produces a phase-variance signal oscillating
|
||||
/// at `freq_hz` Hz.
|
||||
///
|
||||
/// The heartbeat detector uses cross-subcarrier phase variance as its input
|
||||
/// feature. To produce variance that oscillates at freq_hz, we modulate the
|
||||
/// spread of phases across subcarriers at that frequency.
|
||||
fn make_heartbeat_phase_variance(freq_hz: f64, t: f64) -> Vec<f64> {
|
||||
// Modulation factor: variance peaks when modulation is high
|
||||
let modulation = 0.5 * (1.0 + (2.0 * PI * freq_hz * t).sin());
|
||||
(0..N_SUBCARRIERS)
|
||||
.map(|i| {
|
||||
// Each subcarrier gets a different phase offset, scaled by modulation
|
||||
let base = (i as f64 * 0.2).sin();
|
||||
base * modulation
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate constant-phase vector (no heartbeat signal).
|
||||
fn make_static_phase() -> Vec<f64> {
|
||||
(0..N_SUBCARRIERS)
|
||||
.map(|i| (i as f64 * 0.2).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Feed `n_frames` of synthetic breathing data to a detector.
|
||||
fn feed_breathing_signal(
|
||||
detector: &mut VitalSignDetector,
|
||||
freq_hz: f64,
|
||||
sample_rate: f64,
|
||||
n_frames: usize,
|
||||
) -> VitalSigns {
|
||||
let phase = make_static_phase();
|
||||
let mut vitals = VitalSigns::default();
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let amp = make_breathing_frame(freq_hz, t);
|
||||
vitals = detector.process_frame(&, &phase);
|
||||
}
|
||||
vitals
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_vital_detector_creation() {
|
||||
let sample_rate = 20.0;
|
||||
let detector = VitalSignDetector::new(sample_rate);
|
||||
|
||||
// Buffer status should be empty initially
|
||||
let (br_len, br_cap, hb_len, hb_cap) = detector.buffer_status();
|
||||
|
||||
assert_eq!(br_len, 0, "breathing buffer should start empty");
|
||||
assert_eq!(hb_len, 0, "heartbeat buffer should start empty");
|
||||
assert!(br_cap > 0, "breathing capacity should be positive");
|
||||
assert!(hb_cap > 0, "heartbeat capacity should be positive");
|
||||
|
||||
// Capacities should be based on sample rate and window durations
|
||||
// At 20 Hz with 30s breathing window: 600 samples
|
||||
// At 20 Hz with 15s heartbeat window: 300 samples
|
||||
assert_eq!(br_cap, 600, "breathing capacity at 20 Hz * 30s = 600");
|
||||
assert_eq!(hb_cap, 300, "heartbeat capacity at 20 Hz * 15s = 300");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breathing_detection_synthetic() {
|
||||
let sample_rate = 20.0;
|
||||
let breathing_freq = 0.25; // 15 BPM
|
||||
let mut detector = VitalSignDetector::new(sample_rate);
|
||||
|
||||
// Feed 30 seconds of clear breathing signal
|
||||
let n_frames = (sample_rate * 30.0) as usize; // 600 frames
|
||||
let vitals = feed_breathing_signal(&mut detector, breathing_freq, sample_rate, n_frames);
|
||||
|
||||
// Breathing rate should be detected
|
||||
let bpm = vitals
|
||||
.breathing_rate_bpm
|
||||
.expect("should detect breathing rate from 0.25 Hz sine");
|
||||
|
||||
// Allow +/- 3 BPM tolerance (FFT resolution at 20 Hz over 600 samples)
|
||||
let expected_bpm = 15.0;
|
||||
assert!(
|
||||
(bpm - expected_bpm).abs() < 3.0,
|
||||
"breathing rate {:.1} BPM should be close to {:.1} BPM",
|
||||
bpm,
|
||||
expected_bpm,
|
||||
);
|
||||
|
||||
assert!(
|
||||
vitals.breathing_confidence > 0.0,
|
||||
"breathing confidence should be > 0, got {}",
|
||||
vitals.breathing_confidence,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_detection_synthetic() {
|
||||
let sample_rate = 20.0;
|
||||
let heartbeat_freq = 1.2; // 72 BPM
|
||||
let mut detector = VitalSignDetector::new(sample_rate);
|
||||
|
||||
// Feed 15 seconds of data with heartbeat signal in the phase variance
|
||||
let n_frames = (sample_rate * 15.0) as usize;
|
||||
|
||||
// Static amplitude -- no breathing signal
|
||||
let amp: Vec<f64> = (0..N_SUBCARRIERS)
|
||||
.map(|i| 15.0 + 5.0 * (i as f64 * 0.1).sin())
|
||||
.collect();
|
||||
|
||||
let mut vitals = VitalSigns::default();
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let phase = make_heartbeat_phase_variance(heartbeat_freq, t);
|
||||
vitals = detector.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
// Heart rate detection from phase variance is more challenging.
|
||||
// We verify that if a heart rate is detected, it's in the valid
|
||||
// physiological range (40-120 BPM).
|
||||
if let Some(bpm) = vitals.heart_rate_bpm {
|
||||
assert!(
|
||||
bpm >= 40.0 && bpm <= 120.0,
|
||||
"detected heart rate {:.1} BPM should be in physiological range [40, 120]",
|
||||
bpm
|
||||
);
|
||||
}
|
||||
|
||||
// At minimum, heartbeat confidence should be non-negative
|
||||
assert!(
|
||||
vitals.heartbeat_confidence >= 0.0,
|
||||
"heartbeat confidence should be >= 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_vital_signs() {
|
||||
let sample_rate = 20.0;
|
||||
let breathing_freq = 0.25; // 15 BPM
|
||||
let heartbeat_freq = 1.2; // 72 BPM
|
||||
let mut detector = VitalSignDetector::new(sample_rate);
|
||||
|
||||
// Feed 30 seconds with both signals
|
||||
let n_frames = (sample_rate * 30.0) as usize;
|
||||
let mut vitals = VitalSigns::default();
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
|
||||
// Amplitude carries breathing modulation
|
||||
let amp = make_breathing_frame(breathing_freq, t);
|
||||
|
||||
// Phase carries heartbeat modulation (via variance)
|
||||
let phase = make_heartbeat_phase_variance(heartbeat_freq, t);
|
||||
|
||||
vitals = detector.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
// Breathing should be detected accurately
|
||||
let breathing_bpm = vitals
|
||||
.breathing_rate_bpm
|
||||
.expect("should detect breathing in combined signal");
|
||||
assert!(
|
||||
(breathing_bpm - 15.0).abs() < 3.0,
|
||||
"breathing {:.1} BPM should be close to 15 BPM",
|
||||
breathing_bpm
|
||||
);
|
||||
|
||||
// Heartbeat: verify it's in the valid range if detected
|
||||
if let Some(hb_bpm) = vitals.heart_rate_bpm {
|
||||
assert!(
|
||||
hb_bpm >= 40.0 && hb_bpm <= 120.0,
|
||||
"heartbeat {:.1} BPM should be in range [40, 120]",
|
||||
hb_bpm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_signal_lower_confidence_than_true_signal() {
|
||||
let sample_rate = 20.0;
|
||||
let n_frames = (sample_rate * 30.0) as usize;
|
||||
|
||||
// Detector A: constant amplitude (no real breathing signal)
|
||||
let mut detector_flat = VitalSignDetector::new(sample_rate);
|
||||
let amp_flat = vec![50.0; N_SUBCARRIERS];
|
||||
let phase = vec![0.0; N_SUBCARRIERS];
|
||||
for _ in 0..n_frames {
|
||||
detector_flat.process_frame(&_flat, &phase);
|
||||
}
|
||||
let (_, flat_conf) = detector_flat.extract_breathing();
|
||||
|
||||
// Detector B: clear 0.25 Hz breathing signal
|
||||
let mut detector_signal = VitalSignDetector::new(sample_rate);
|
||||
let phase_b = make_static_phase();
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let amp = make_breathing_frame(0.25, t);
|
||||
detector_signal.process_frame(&, &phase_b);
|
||||
}
|
||||
let (signal_rate, signal_conf) = detector_signal.extract_breathing();
|
||||
|
||||
// The real signal should be detected
|
||||
assert!(
|
||||
signal_rate.is_some(),
|
||||
"true breathing signal should be detected"
|
||||
);
|
||||
|
||||
// The real signal should have higher confidence than the flat signal.
|
||||
// Note: the bandpass filter creates transient artifacts on flat signals
|
||||
// that may produce non-zero confidence, but a true periodic signal should
|
||||
// always produce a stronger spectral peak.
|
||||
assert!(
|
||||
signal_conf >= flat_conf,
|
||||
"true signal confidence ({:.3}) should be >= flat signal confidence ({:.3})",
|
||||
signal_conf,
|
||||
flat_conf,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_out_of_range_lower_confidence_than_in_band() {
|
||||
let sample_rate = 20.0;
|
||||
let n_frames = (sample_rate * 30.0) as usize;
|
||||
let phase = make_static_phase();
|
||||
|
||||
// Detector A: 5 Hz amplitude oscillation (outside breathing band)
|
||||
let mut detector_oob = VitalSignDetector::new(sample_rate);
|
||||
let out_of_band_freq = 5.0;
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let amp: Vec<f64> = (0..N_SUBCARRIERS)
|
||||
.map(|i| {
|
||||
let base = 15.0 + 5.0 * (i as f64 * 0.1).sin();
|
||||
base + 2.0 * (2.0 * PI * out_of_band_freq * t).sin()
|
||||
})
|
||||
.collect();
|
||||
detector_oob.process_frame(&, &phase);
|
||||
}
|
||||
let (_, oob_conf) = detector_oob.extract_breathing();
|
||||
|
||||
// Detector B: 0.25 Hz amplitude oscillation (inside breathing band)
|
||||
let mut detector_inband = VitalSignDetector::new(sample_rate);
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let amp = make_breathing_frame(0.25, t);
|
||||
detector_inband.process_frame(&, &phase);
|
||||
}
|
||||
let (inband_rate, inband_conf) = detector_inband.extract_breathing();
|
||||
|
||||
// The in-band signal should be detected
|
||||
assert!(
|
||||
inband_rate.is_some(),
|
||||
"in-band 0.25 Hz signal should be detected as breathing"
|
||||
);
|
||||
|
||||
// The in-band signal should have higher confidence than the out-of-band one.
|
||||
// The bandpass filter may leak some energy from 5 Hz harmonics, but a true
|
||||
// 0.25 Hz signal should always dominate.
|
||||
assert!(
|
||||
inband_conf >= oob_conf,
|
||||
"in-band confidence ({:.3}) should be >= out-of-band confidence ({:.3})",
|
||||
inband_conf,
|
||||
oob_conf,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confidence_increases_with_snr() {
|
||||
let sample_rate = 20.0;
|
||||
let breathing_freq = 0.25;
|
||||
let n_frames = (sample_rate * 30.0) as usize;
|
||||
|
||||
// High SNR: large breathing amplitude, no noise
|
||||
let mut detector_clean = VitalSignDetector::new(sample_rate);
|
||||
let phase = make_static_phase();
|
||||
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let amp: Vec<f64> = (0..N_SUBCARRIERS)
|
||||
.map(|i| {
|
||||
let base = 15.0 + 5.0 * (i as f64 * 0.1).sin();
|
||||
// Strong breathing signal (amplitude 5.0)
|
||||
base + 5.0 * (2.0 * PI * breathing_freq * t).sin()
|
||||
})
|
||||
.collect();
|
||||
detector_clean.process_frame(&, &phase);
|
||||
}
|
||||
let (_, clean_conf) = detector_clean.extract_breathing();
|
||||
|
||||
// Low SNR: small breathing amplitude, lots of noise
|
||||
let mut detector_noisy = VitalSignDetector::new(sample_rate);
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let amp: Vec<f64> = (0..N_SUBCARRIERS)
|
||||
.map(|i| {
|
||||
let base = 15.0 + 5.0 * (i as f64 * 0.1).sin();
|
||||
// Weak breathing signal (amplitude 0.1) + heavy noise
|
||||
let noise = 3.0
|
||||
* ((i as f64 * 7.3 + t * 113.7).sin()
|
||||
+ (i as f64 * 13.1 + t * 79.3).sin())
|
||||
/ 2.0;
|
||||
base + 0.1 * (2.0 * PI * breathing_freq * t).sin() + noise
|
||||
})
|
||||
.collect();
|
||||
detector_noisy.process_frame(&, &phase);
|
||||
}
|
||||
let (_, noisy_conf) = detector_noisy.extract_breathing();
|
||||
|
||||
assert!(
|
||||
clean_conf > noisy_conf,
|
||||
"clean signal confidence ({:.3}) should exceed noisy signal confidence ({:.3})",
|
||||
clean_conf,
|
||||
noisy_conf,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_clears_buffers() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
let amp = vec![10.0; N_SUBCARRIERS];
|
||||
let phase = vec![0.0; N_SUBCARRIERS];
|
||||
|
||||
// Feed some frames to fill buffers
|
||||
for _ in 0..100 {
|
||||
detector.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
let (br_len, _, hb_len, _) = detector.buffer_status();
|
||||
assert!(br_len > 0, "breathing buffer should have data before reset");
|
||||
assert!(hb_len > 0, "heartbeat buffer should have data before reset");
|
||||
|
||||
// Reset
|
||||
detector.reset();
|
||||
|
||||
let (br_len, _, hb_len, _) = detector.buffer_status();
|
||||
assert_eq!(br_len, 0, "breathing buffer should be empty after reset");
|
||||
assert_eq!(hb_len, 0, "heartbeat buffer should be empty after reset");
|
||||
|
||||
// Extraction should return None after reset
|
||||
let (breathing, _) = detector.extract_breathing();
|
||||
let (heartbeat, _) = detector.extract_heartbeat();
|
||||
assert!(
|
||||
breathing.is_none(),
|
||||
"breathing should be None after reset (not enough samples)"
|
||||
);
|
||||
assert!(
|
||||
heartbeat.is_none(),
|
||||
"heartbeat should be None after reset (not enough samples)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimum_samples_required() {
|
||||
let sample_rate = 20.0;
|
||||
let mut detector = VitalSignDetector::new(sample_rate);
|
||||
let amp = vec![10.0; N_SUBCARRIERS];
|
||||
let phase = vec![0.0; N_SUBCARRIERS];
|
||||
|
||||
// Feed fewer than MIN_BREATHING_SAMPLES (40) frames
|
||||
for _ in 0..39 {
|
||||
detector.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
let (breathing, _) = detector.extract_breathing();
|
||||
assert!(
|
||||
breathing.is_none(),
|
||||
"with 39 samples (< 40 min), breathing should return None"
|
||||
);
|
||||
|
||||
// One more frame should meet the minimum
|
||||
detector.process_frame(&, &phase);
|
||||
|
||||
let (br_len, _, _, _) = detector.buffer_status();
|
||||
assert_eq!(br_len, 40, "should have exactly 40 samples now");
|
||||
|
||||
// Now extraction is at least attempted (may still be None if flat signal,
|
||||
// but should not be blocked by the min-samples check)
|
||||
let _ = detector.extract_breathing();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_benchmark_throughput() {
|
||||
let sample_rate = 20.0;
|
||||
let mut detector = VitalSignDetector::new(sample_rate);
|
||||
|
||||
let num_frames = 10_000;
|
||||
let n_sub = N_SUBCARRIERS;
|
||||
|
||||
// Pre-generate frames
|
||||
let frames: Vec<(Vec<f64>, Vec<f64>)> = (0..num_frames)
|
||||
.map(|tick| {
|
||||
let t = tick as f64 / sample_rate;
|
||||
let amp: Vec<f64> = (0..n_sub)
|
||||
.map(|i| {
|
||||
let base = 15.0 + 5.0 * (i as f64 * 0.1).sin();
|
||||
let breathing = 2.0 * (2.0 * PI * 0.25 * t).sin();
|
||||
let heartbeat = 0.3 * (2.0 * PI * 1.2 * t).sin();
|
||||
let noise = (i as f64 * 7.3 + t * 13.7).sin() * 0.5;
|
||||
base + breathing + heartbeat + noise
|
||||
})
|
||||
.collect();
|
||||
let phase: Vec<f64> = (0..n_sub)
|
||||
.map(|i| (i as f64 * 0.2 + t * 0.5).sin() * PI)
|
||||
.collect();
|
||||
(amp, phase)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
for (amp, phase) in &frames {
|
||||
detector.process_frame(amp, phase);
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
let fps = num_frames as f64 / elapsed.as_secs_f64();
|
||||
|
||||
println!(
|
||||
"Vital sign benchmark: {} frames in {:.2}ms = {:.0} frames/sec",
|
||||
num_frames,
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
fps
|
||||
);
|
||||
|
||||
// Should process at least 100 frames/sec on any reasonable hardware
|
||||
assert!(
|
||||
fps > 100.0,
|
||||
"throughput {:.0} fps is too low (expected > 100 fps)",
|
||||
fps,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_signs_default() {
|
||||
let vs = VitalSigns::default();
|
||||
assert!(vs.breathing_rate_bpm.is_none());
|
||||
assert!(vs.heart_rate_bpm.is_none());
|
||||
assert_eq!(vs.breathing_confidence, 0.0);
|
||||
assert_eq!(vs.heartbeat_confidence, 0.0);
|
||||
assert_eq!(vs.signal_quality, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_amplitude_frame() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
let vitals = detector.process_frame(&[], &[]);
|
||||
|
||||
assert!(vitals.breathing_rate_bpm.is_none());
|
||||
assert!(vitals.heart_rate_bpm.is_none());
|
||||
assert_eq!(vitals.signal_quality, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_subcarrier_no_panic() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
|
||||
// Single subcarrier should not crash
|
||||
for i in 0..100 {
|
||||
let t = i as f64 / 20.0;
|
||||
let amp = vec![10.0 + (2.0 * PI * 0.25 * t).sin()];
|
||||
let phase = vec![0.0];
|
||||
let _ = detector.process_frame(&, &phase);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_quality_varies_with_input() {
|
||||
let mut detector_static = VitalSignDetector::new(20.0);
|
||||
let mut detector_varied = VitalSignDetector::new(20.0);
|
||||
|
||||
// Feed static signal (all same amplitude)
|
||||
for _ in 0..100 {
|
||||
let amp = vec![10.0; N_SUBCARRIERS];
|
||||
let phase = vec![0.0; N_SUBCARRIERS];
|
||||
detector_static.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
// Feed varied signal (moderate CV -- body motion)
|
||||
for i in 0..100 {
|
||||
let t = i as f64 / 20.0;
|
||||
let amp: Vec<f64> = (0..N_SUBCARRIERS)
|
||||
.map(|j| {
|
||||
let base = 15.0;
|
||||
let modulation = 2.0 * (2.0 * PI * 0.25 * t + j as f64 * 0.1).sin();
|
||||
base + modulation
|
||||
})
|
||||
.collect();
|
||||
let phase: Vec<f64> = (0..N_SUBCARRIERS)
|
||||
.map(|j| (j as f64 * 0.2 + t).sin())
|
||||
.collect();
|
||||
detector_varied.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
// The varied signal should have higher signal quality than the static one
|
||||
let static_vitals =
|
||||
detector_static.process_frame(&vec![10.0; N_SUBCARRIERS], &vec![0.0; N_SUBCARRIERS]);
|
||||
let amp_varied: Vec<f64> = (0..N_SUBCARRIERS)
|
||||
.map(|j| 15.0 + 2.0 * (j as f64 * 0.3).sin())
|
||||
.collect();
|
||||
let phase_varied: Vec<f64> = (0..N_SUBCARRIERS).map(|j| (j as f64 * 0.2).sin()).collect();
|
||||
let varied_vitals = detector_varied.process_frame(&_varied, &phase_varied);
|
||||
|
||||
assert!(
|
||||
varied_vitals.signal_quality >= static_vitals.signal_quality,
|
||||
"varied signal quality ({:.3}) should be >= static ({:.3})",
|
||||
varied_vitals.signal_quality,
|
||||
static_vitals.signal_quality,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_capacity_respected() {
|
||||
let sample_rate = 20.0;
|
||||
let mut detector = VitalSignDetector::new(sample_rate);
|
||||
|
||||
let amp = vec![10.0; N_SUBCARRIERS];
|
||||
let phase = vec![0.0; N_SUBCARRIERS];
|
||||
|
||||
// Feed more frames than breathing capacity (600)
|
||||
for _ in 0..1000 {
|
||||
detector.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
let (br_len, br_cap, hb_len, hb_cap) = detector.buffer_status();
|
||||
assert!(
|
||||
br_len <= br_cap,
|
||||
"breathing buffer length {} should not exceed capacity {}",
|
||||
br_len,
|
||||
br_cap
|
||||
);
|
||||
assert!(
|
||||
hb_len <= hb_cap,
|
||||
"heartbeat buffer length {} should not exceed capacity {}",
|
||||
hb_len,
|
||||
hb_cap
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_benchmark_function() {
|
||||
let (total, per_frame) = wifi_densepose_sensing_server::vital_signs::run_benchmark(50);
|
||||
assert!(total.as_nanos() > 0, "benchmark total duration should be > 0");
|
||||
assert!(
|
||||
per_frame.as_nanos() > 0,
|
||||
"benchmark per-frame duration should be > 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breathing_rate_in_physiological_range() {
|
||||
// If breathing is detected, it must always be in the physiological range
|
||||
// (6-30 BPM = 0.1-0.5 Hz)
|
||||
let sample_rate = 20.0;
|
||||
let mut detector = VitalSignDetector::new(sample_rate);
|
||||
let n_frames = (sample_rate * 30.0) as usize;
|
||||
|
||||
let mut vitals = VitalSigns::default();
|
||||
for frame in 0..n_frames {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let amp = make_breathing_frame(0.3, t); // 18 BPM
|
||||
let phase = make_static_phase();
|
||||
vitals = detector.process_frame(&, &phase);
|
||||
}
|
||||
|
||||
if let Some(bpm) = vitals.breathing_rate_bpm {
|
||||
assert!(
|
||||
bpm >= 6.0 && bpm <= 30.0,
|
||||
"breathing rate {:.1} BPM must be in range [6, 30]",
|
||||
bpm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_detectors_independent() {
|
||||
// Two detectors should not interfere with each other
|
||||
let sample_rate = 20.0;
|
||||
let mut detector_a = VitalSignDetector::new(sample_rate);
|
||||
let mut detector_b = VitalSignDetector::new(sample_rate);
|
||||
|
||||
let phase = make_static_phase();
|
||||
|
||||
// Feed different breathing rates
|
||||
for frame in 0..(sample_rate * 30.0) as usize {
|
||||
let t = frame as f64 / sample_rate;
|
||||
let amp_a = make_breathing_frame(0.2, t); // 12 BPM
|
||||
let amp_b = make_breathing_frame(0.4, t); // 24 BPM
|
||||
detector_a.process_frame(&_a, &phase);
|
||||
detector_b.process_frame(&_b, &phase);
|
||||
}
|
||||
|
||||
let (rate_a, _) = detector_a.extract_breathing();
|
||||
let (rate_b, _) = detector_b.extract_breathing();
|
||||
|
||||
if let (Some(a), Some(b)) = (rate_a, rate_b) {
|
||||
// They should detect different rates
|
||||
assert!(
|
||||
(a - b).abs() > 2.0,
|
||||
"detector A ({:.1} BPM) and B ({:.1} BPM) should detect different rates",
|
||||
a,
|
||||
b
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user